// SPDX-License-Identifier: ISC#include "clm/session.h"#include "clm/cleanup.h" #include <errno.h>#include <fcntl.h>#include <limits.h>#include <stdio.h>#include <stdlib.h>#include <string.h>#include <sys/stat.h>#include <time.h>#include <unistd.h> #include "useful.h"#include "session_internal.h"#include "banned.h" #define SESSION_FMT_VERSION 1#define SESSION_ID_MAX 64#define SESSION_SNIPPET_MAX 80 struct clm_session { int fd; char *id; char *path; bool has_msgs; /* a user/assistant message exists, ever */}; /* * Session ids embed straight into filenames, so reject anything outside * [0-9A-Za-z-] before any path is built: "../evil" must never reach the * filesystem. */static boolid_valid(const char *id){ size_t i; if (id == NULL || id[0] == '\0') return false; for (i = 0; id[i] != '\0'; i++) { char c = id[i]; if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '-')) return false; if (i >= SESSION_ID_MAX) return false; } return true;} /* mkdir -p, final component and parents created 0700. */static intmkdir_p(const char *path){ autofree char *buf = NULL; size_t i, len; len = strlen(path); if (len == 0) return -EINVAL; buf = strdup(path); if (buf == NULL) return -ENOMEM; for (i = 1; i <= len; i++) { if (buf[i] != '/' && buf[i] != '\0') continue; buf[i] = '\0'; if (mkdir(buf, 0700) < 0 && errno != EEXIST) return -errno; if (i < len) buf[i] = '/'; } return 0;} intclm_session_state_dir(char *buf, size_t bufsz){ const char *base; int n; ASSERT_RETURN(buf != NULL && bufsz > 0, -EINVAL); base = getenv("XDG_STATE_HOME"); if (base != NULL && base[0] != '\0') { n = snprintf(buf, bufsz, "%s/clm", base); } else { const char *home = getenv("HOME"); if (home == NULL || home[0] == '\0') return -ENOENT; n = snprintf(buf, bufsz, "%s/.local/state/clm", home); } if (n < 0 || (size_t)n >= bufsz) return -ENAMETOOLONG; return mkdir_p(buf);} /* Resolve dir (NULL = default state dir) into a malloc'd string. */static intresolve_dir(const char *dir, char **out){ if (dir == NULL) { char *buf = malloc(PATH_MAX); int r; if (buf == NULL) return -ENOMEM; r = clm_session_state_dir(buf, PATH_MAX); if (r < 0) { free(buf); return r; } *out = buf; return 0; } *out = strdup(dir); return *out != NULL ? 0 : -ENOMEM;} /* Build <dir>/<id>.jsonl into a malloc'd string. */static intsession_path(const char *dir, const char *id, char **out){ autofree char *d = NULL; int r; r = resolve_dir(dir, &d); if (r < 0) return r; if (asprintf(out, "%s/%s.jsonl", d, id) < 0) { *out = NULL; return -ENOMEM; } return 0;} /* YYYYMMDD-HHMMSS-xxxxxxxx: local time plus 8 hex chars of randomness. */static intgenerate_id(char *buf, size_t bufsz){ struct tm tm; time_t now = time(NULL); unsigned char rnd[4]; autoclose int fd = -1; if (localtime_r(&now, &tm) == NULL) return -EINVAL; fd = open("/dev/urandom", O_RDONLY | O_CLOEXEC); if (fd < 0 || read(fd, rnd, sizeof(rnd)) != (ssize_t)sizeof(rnd)) { /* No entropy source: degrade to pid/time salt -- ids only * need uniqueness within one user's session dir. */ uint32_t v = (uint32_t)now ^ ((uint32_t)getpid() << 16); memcpy(rnd, &v, sizeof(rnd)); } int n = snprintf(buf, bufsz, "%04d%02d%02d-%02d%02d%02d-%02x%02x%02x%02x", tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, tm.tm_hour, tm.tm_min, tm.tm_sec, rnd[0], rnd[1], rnd[2], rnd[3]); if (n < 0 || (size_t)n >= bufsz) return -ENAMETOOLONG; return 0;} /* Serialize obj onto one line and append it with a single write. */static intwrite_line(int fd, const cJSON *obj){ autofree char *line = cJSON_PrintUnformatted(obj); size_t len; char *nl; if (line == NULL) return -ENOMEM; len = strlen(line); nl = realloc(line, len + 2); if (nl == NULL) return -ENOMEM; line = nl; line[len] = '\n'; line[len + 1] = '\0'; ssize_t w = write(fd, line, len + 1); if (w < 0) return -errno; if ((size_t)w != len + 1) return -EIO; return 0;} static struct clm_session *session_alloc(int fd, const char *id, const char *path){ struct clm_session *s = calloc(1, sizeof(*s)); if (s == NULL) return NULL; s->fd = fd; s->id = strdup(id); s->path = strdup(path); if (s->id == NULL || s->path == NULL) { free(s->id); free(s->path); free(s); return NULL; } return s;} intclm_session_create(const char *dir, const char *model, const char *provider_name, const char *agent_name, struct clm_session **out){ char id[SESSION_ID_MAX]; autofree char *path = NULL; json_cleanup cJSON *meta = NULL; autoclose int fd = -1; struct clm_session *s; int r; ASSERT_RETURN(out != NULL, -EINVAL); *out = NULL; r = generate_id(id, sizeof(id)); if (r < 0) return r; r = session_path(dir, id, &path); if (r < 0) return r; fd = open(path, O_WRONLY | O_APPEND | O_CREAT | O_EXCL | O_CLOEXEC, 0600); if (fd < 0) return -errno; meta = cJSON_CreateObject(); if (meta == NULL || cJSON_AddStringToObject(meta, "type", "meta") == NULL || cJSON_AddNumberToObject(meta, "v", SESSION_FMT_VERSION) == NULL || cJSON_AddStringToObject(meta, "id", id) == NULL || cJSON_AddNumberToObject(meta, "created", (double)time(NULL)) == NULL) goto fail_nomem; if (model != NULL && cJSON_AddStringToObject(meta, "model", model) == NULL) goto fail_nomem; if (provider_name != NULL && cJSON_AddStringToObject(meta, "provider", provider_name) == NULL) goto fail_nomem; if (agent_name != NULL && cJSON_AddStringToObject(meta, "agent", agent_name) == NULL) goto fail_nomem; r = write_line(fd, meta); if (r < 0) goto fail; s = session_alloc(fd, id, path); if (s == NULL) goto fail_nomem; fd = -1; /* owned by s now */ *out = s; return 0; fail_nomem: r = -ENOMEM;fail: (void)unlink(path); return r;} intclm_session_open(const char *dir, const char *id, struct clm_session **out){ autofree char *path = NULL; autoclose int fd = -1; struct clm_session *s; int r; ASSERT_RETURN(out != NULL, -EINVAL); *out = NULL; if (!id_valid(id)) return -EINVAL; r = session_path(dir, id, &path); if (r < 0) return r; fd = open(path, O_WRONLY | O_APPEND | O_CLOEXEC); if (fd < 0) return -errno; s = session_alloc(fd, id, path); if (s == NULL) return -ENOMEM; fd = -1; /* A session worth resuming has messages; treat it as non-empty so * an exit right after resume never deletes the file. */ s->has_msgs = true; *out = s; return 0;} intclm_session_append(struct clm_session *s, const struct clm_message *m, const struct clm_compressor *cz){ json_cleanup cJSON *obj = NULL; int r; ASSERT_RETURN(s != NULL && m != NULL, -EINVAL); obj = clm_message_to_json_full(m, cz); if (obj == NULL) return -ENOMEM; if (cJSON_AddStringToObject(obj, "type", "msg") == NULL) return -ENOMEM; r = write_line(s->fd, obj); if (r < 0) return r; if (m->role == CLM_ROLE_USER || m->role == CLM_ROLE_ASSISTANT) s->has_msgs = true; return 0;} const char *clm_session_id(const struct clm_session *s){ return s != NULL ? s->id : NULL;} boolclm_session_is_empty(const struct clm_session *s){ return s == NULL || !s->has_msgs;} intclm_session_discard(struct clm_session *s){ int r = 0; ASSERT_RETURN(s != NULL, -EINVAL); if (unlink(s->path) < 0) r = -errno; clm_session_free(s); return r;} voidclm_session_free(struct clm_session *s){ if (s == NULL) return; if (s->fd >= 0) close(s->fd); free(s->id); free(s->path); free(s);} /* * Parse one JSONL line into hist (also the fuzz entry point, see * session_internal.h). Unknown types and unparsable lines are skipped * (returns 0): the loader must survive a truncated final line and lines * written by a newer clm. A meta line is validated for version and, when * out_meta is non-NULL and still empty, handed to the caller. */intsession_parse_line(struct clm_history *hist, const char *line, size_t len, cJSON **out_meta){ json_cleanup cJSON *obj = NULL; const char *type; obj = cJSON_ParseWithLength(line, len); if (obj == NULL || !cJSON_IsObject(obj)) return 0; /* tolerated: truncated/garbage line */ type = cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(obj, "type")); if (type == NULL) return 0; if (strcmp(type, "meta") == 0) { cJSON *v = cJSON_GetObjectItemCaseSensitive(obj, "v"); if (cJSON_IsNumber(v) && v->valuedouble > SESSION_FMT_VERSION) return -EPROTONOSUPPORT; if (out_meta != NULL && *out_meta == NULL) { *out_meta = obj; obj = NULL; /* stolen */ } return 0; } if (strcmp(type, "msg") == 0) { int r = clm_message_from_json(hist, obj, NULL); /* A malformed message line is skipped like any other bad * line; only allocation failure is fatal. */ return r == -ENOMEM ? -ENOMEM : 0; } return 0; /* unknown type: forward compatibility */} intclm_session_load(const char *dir, const char *id, struct clm_history *hist, cJSON **out_meta){ autofree char *path = NULL; autoclosefile FILE *f = NULL; autofree char *line = NULL; size_t cap = 0; ssize_t n; int r; ASSERT_RETURN(hist != NULL, -EINVAL); if (out_meta != NULL) *out_meta = NULL; if (!id_valid(id)) return -EINVAL; r = session_path(dir, id, &path); if (r < 0) return r; f = fopen(path, "re"); if (f == NULL) return -errno; while ((n = getline(&line, &cap, f)) >= 0) { r = session_parse_line(hist, line, (size_t)n, out_meta); if (r < 0) return r; } if (ferror(f)) return -EIO; return 0;} /* Newest first: descending by created, then by id for a stable order. */static intinfo_cmp(const void *a, const void *b){ const struct clm_session_info *ia = a, *ib = b; if (ia->created != ib->created) return ia->created < ib->created ? 1 : -1; return strcmp(ib->id, ia->id);} static voidinfo_clear(struct clm_session_info *info){ free(info->id); free(info->model); free(info->agent); free(info->first_user);} static char *strdup_or_null(const char *s){ return s != NULL ? strdup(s) : NULL;} /* First SESSION_SNIPPET_MAX bytes of s, newlines flattened to spaces. */static char *snippet(const char *s){ char *out; size_t i, len = strlen(s); if (len > SESSION_SNIPPET_MAX) len = SESSION_SNIPPET_MAX; out = malloc(len + 1); if (out == NULL) return NULL; for (i = 0; i < len; i++) out[i] = (s[i] == '\n' || s[i] == '\r') ? ' ' : s[i]; out[len] = '\0'; return out;} /* Fill one listing row by reading a session file's meta and history. */static intinfo_fill(const char *dir, const char *id, struct clm_session_info *info){ struct clm_history hist; json_cleanup cJSON *meta = NULL;Lines 1–500 of 624. Load more
// SPDX-License-Identifier: ISC #include "clm/session.h" #include "clm/cleanup.h" #include <errno.h> #include <fcntl.h> #include <limits.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/stat.h> #include <time.h> #include <unistd.h> #include "useful.h" #include "session_internal.h" #include "banned.h" #define SESSION_FMT_VERSION 1 #define SESSION_ID_MAX 64 #define SESSION_SNIPPET_MAX 80 struct clm_session { int fd; char *id; char *path; bool has_msgs; /* a user/assistant message exists, ever */ }; /* * Session ids embed straight into filenames, so reject anything outside * [0-9A-Za-z-] before any path is built: "../evil" must never reach the * filesystem. */ static bool id_valid(const char *id) { size_t i; if (id == NULL || id[0] == '\0') return false; for (i = 0; id[i] != '\0'; i++) { char c = id[i]; if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '-')) return false; if (i >= SESSION_ID_MAX) return false; } return true; } /* mkdir -p, final component and parents created 0700. */ static int mkdir_p(const char *path) { autofree char *buf = NULL; size_t i, len; len = strlen(path); if (len == 0) return -EINVAL; buf = strdup(path); if (buf == NULL) return -ENOMEM; for (i = 1; i <= len; i++) { if (buf[i] != '/' && buf[i] != '\0') continue; buf[i] = '\0'; if (mkdir(buf, 0700) < 0 && errno != EEXIST) return -errno; if (i < len) buf[i] = '/'; } return 0; } int clm_session_state_dir(char *buf, size_t bufsz) { const char *base; int n; ASSERT_RETURN(buf != NULL && bufsz > 0, -EINVAL); base = getenv("XDG_STATE_HOME"); if (base != NULL && base[0] != '\0') { n = snprintf(buf, bufsz, "%s/clm", base); } else { const char *home = getenv("HOME"); if (home == NULL || home[0] == '\0') return -ENOENT; n = snprintf(buf, bufsz, "%s/.local/state/clm", home); } if (n < 0 || (size_t)n >= bufsz) return -ENAMETOOLONG; return mkdir_p(buf); } /* Resolve dir (NULL = default state dir) into a malloc'd string. */ static int resolve_dir(const char *dir, char **out) { if (dir == NULL) { char *buf = malloc(PATH_MAX); int r; if (buf == NULL) return -ENOMEM; r = clm_session_state_dir(buf, PATH_MAX); if (r < 0) { free(buf); return r; } *out = buf; return 0; } *out = strdup(dir); return *out != NULL ? 0 : -ENOMEM; } /* Build <dir>/<id>.jsonl into a malloc'd string. */ static int session_path(const char *dir, const char *id, char **out) { autofree char *d = NULL; int r; r = resolve_dir(dir, &d); if (r < 0) return r; if (asprintf(out, "%s/%s.jsonl", d, id) < 0) { *out = NULL; return -ENOMEM; } return 0; } /* YYYYMMDD-HHMMSS-xxxxxxxx: local time plus 8 hex chars of randomness. */ static int generate_id(char *buf, size_t bufsz) { struct tm tm; time_t now = time(NULL); unsigned char rnd[4]; autoclose int fd = -1; if (localtime_r(&now, &tm) == NULL) return -EINVAL; fd = open("/dev/urandom", O_RDONLY | O_CLOEXEC); if (fd < 0 || read(fd, rnd, sizeof(rnd)) != (ssize_t)sizeof(rnd)) { /* No entropy source: degrade to pid/time salt -- ids only * need uniqueness within one user's session dir. */ uint32_t v = (uint32_t)now ^ ((uint32_t)getpid() << 16); memcpy(rnd, &v, sizeof(rnd)); } int n = snprintf(buf, bufsz, "%04d%02d%02d-%02d%02d%02d-%02x%02x%02x%02x", tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, tm.tm_hour, tm.tm_min, tm.tm_sec, rnd[0], rnd[1], rnd[2], rnd[3]); if (n < 0 || (size_t)n >= bufsz) return -ENAMETOOLONG; return 0; } /* Serialize obj onto one line and append it with a single write. */ static int write_line(int fd, const cJSON *obj) { autofree char *line = cJSON_PrintUnformatted(obj); size_t len; char *nl; if (line == NULL) return -ENOMEM; len = strlen(line); nl = realloc(line, len + 2); if (nl == NULL) return -ENOMEM; line = nl; line[len] = '\n'; line[len + 1] = '\0'; ssize_t w = write(fd, line, len + 1); if (w < 0) return -errno; if ((size_t)w != len + 1) return -EIO; return 0; } static struct clm_session * session_alloc(int fd, const char *id, const char *path) { struct clm_session *s = calloc(1, sizeof(*s)); if (s == NULL) return NULL; s->fd = fd; s->id = strdup(id); s->path = strdup(path); if (s->id == NULL || s->path == NULL) { free(s->id); free(s->path); free(s); return NULL; } return s; } int clm_session_create(const char *dir, const char *model, const char *provider_name, const char *agent_name, struct clm_session **out) { char id[SESSION_ID_MAX]; autofree char *path = NULL; json_cleanup cJSON *meta = NULL; autoclose int fd = -1; struct clm_session *s; int r; ASSERT_RETURN(out != NULL, -EINVAL); *out = NULL; r = generate_id(id, sizeof(id)); if (r < 0) return r; r = session_path(dir, id, &path); if (r < 0) return r; fd = open(path, O_WRONLY | O_APPEND | O_CREAT | O_EXCL | O_CLOEXEC, 0600); if (fd < 0) return -errno; meta = cJSON_CreateObject(); if (meta == NULL || cJSON_AddStringToObject(meta, "type", "meta") == NULL || cJSON_AddNumberToObject(meta, "v", SESSION_FMT_VERSION) == NULL || cJSON_AddStringToObject(meta, "id", id) == NULL || cJSON_AddNumberToObject(meta, "created", (double)time(NULL)) == NULL) goto fail_nomem; if (model != NULL && cJSON_AddStringToObject(meta, "model", model) == NULL) goto fail_nomem; if (provider_name != NULL && cJSON_AddStringToObject(meta, "provider", provider_name) == NULL) goto fail_nomem; if (agent_name != NULL && cJSON_AddStringToObject(meta, "agent", agent_name) == NULL) goto fail_nomem; r = write_line(fd, meta); if (r < 0) goto fail; s = session_alloc(fd, id, path); if (s == NULL) goto fail_nomem; fd = -1; /* owned by s now */ *out = s; return 0; fail_nomem: r = -ENOMEM; fail: (void)unlink(path); return r; } int clm_session_open(const char *dir, const char *id, struct clm_session **out) { autofree char *path = NULL; autoclose int fd = -1; struct clm_session *s; int r; ASSERT_RETURN(out != NULL, -EINVAL); *out = NULL; if (!id_valid(id)) return -EINVAL; r = session_path(dir, id, &path); if (r < 0) return r; fd = open(path, O_WRONLY | O_APPEND | O_CLOEXEC); if (fd < 0) return -errno; s = session_alloc(fd, id, path); if (s == NULL) return -ENOMEM; fd = -1; /* A session worth resuming has messages; treat it as non-empty so * an exit right after resume never deletes the file. */ s->has_msgs = true; *out = s; return 0; } int clm_session_append(struct clm_session *s, const struct clm_message *m, const struct clm_compressor *cz) { json_cleanup cJSON *obj = NULL; int r; ASSERT_RETURN(s != NULL && m != NULL, -EINVAL); obj = clm_message_to_json_full(m, cz); if (obj == NULL) return -ENOMEM; if (cJSON_AddStringToObject(obj, "type", "msg") == NULL) return -ENOMEM; r = write_line(s->fd, obj); if (r < 0) return r; if (m->role == CLM_ROLE_USER || m->role == CLM_ROLE_ASSISTANT) s->has_msgs = true; return 0; } const char * clm_session_id(const struct clm_session *s) { return s != NULL ? s->id : NULL; } bool clm_session_is_empty(const struct clm_session *s) { return s == NULL || !s->has_msgs; } int clm_session_discard(struct clm_session *s) { int r = 0; ASSERT_RETURN(s != NULL, -EINVAL); if (unlink(s->path) < 0) r = -errno; clm_session_free(s); return r; } void clm_session_free(struct clm_session *s) { if (s == NULL) return; if (s->fd >= 0) close(s->fd); free(s->id); free(s->path); free(s); } /* * Parse one JSONL line into hist (also the fuzz entry point, see * session_internal.h). Unknown types and unparsable lines are skipped * (returns 0): the loader must survive a truncated final line and lines * written by a newer clm. A meta line is validated for version and, when * out_meta is non-NULL and still empty, handed to the caller. */ int session_parse_line(struct clm_history *hist, const char *line, size_t len, cJSON **out_meta) { json_cleanup cJSON *obj = NULL; const char *type; obj = cJSON_ParseWithLength(line, len); if (obj == NULL || !cJSON_IsObject(obj)) return 0; /* tolerated: truncated/garbage line */ type = cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(obj, "type")); if (type == NULL) return 0; if (strcmp(type, "meta") == 0) { cJSON *v = cJSON_GetObjectItemCaseSensitive(obj, "v"); if (cJSON_IsNumber(v) && v->valuedouble > SESSION_FMT_VERSION) return -EPROTONOSUPPORT; if (out_meta != NULL && *out_meta == NULL) { *out_meta = obj; obj = NULL; /* stolen */ } return 0; } if (strcmp(type, "msg") == 0) { int r = clm_message_from_json(hist, obj, NULL); /* A malformed message line is skipped like any other bad * line; only allocation failure is fatal. */ return r == -ENOMEM ? -ENOMEM : 0; } return 0; /* unknown type: forward compatibility */ } int clm_session_load(const char *dir, const char *id, struct clm_history *hist, cJSON **out_meta) { autofree char *path = NULL; autoclosefile FILE *f = NULL; autofree char *line = NULL; size_t cap = 0; ssize_t n; int r; ASSERT_RETURN(hist != NULL, -EINVAL); if (out_meta != NULL) *out_meta = NULL; if (!id_valid(id)) return -EINVAL; r = session_path(dir, id, &path); if (r < 0) return r; f = fopen(path, "re"); if (f == NULL) return -errno; while ((n = getline(&line, &cap, f)) >= 0) { r = session_parse_line(hist, line, (size_t)n, out_meta); if (r < 0) return r; } if (ferror(f)) return -EIO; return 0; } /* Newest first: descending by created, then by id for a stable order. */ static int info_cmp(const void *a, const void *b) { const struct clm_session_info *ia = a, *ib = b; if (ia->created != ib->created) return ia->created < ib->created ? 1 : -1; return strcmp(ib->id, ia->id); } static void info_clear(struct clm_session_info *info) { free(info->id); free(info->model); free(info->agent); free(info->first_user); } static char * strdup_or_null(const char *s) { return s != NULL ? strdup(s) : NULL; } /* First SESSION_SNIPPET_MAX bytes of s, newlines flattened to spaces. */ static char * snippet(const char *s) { char *out; size_t i, len = strlen(s); if (len > SESSION_SNIPPET_MAX) len = SESSION_SNIPPET_MAX; out = malloc(len + 1); if (out == NULL) return NULL; for (i = 0; i < len; i++) out[i] = (s[i] == '\n' || s[i] == '\r') ? ' ' : s[i]; out[len] = '\0'; return out; } /* Fill one listing row by reading a session file's meta and history. */ static int info_fill(const char *dir, const char *id, struct clm_session_info *info) { struct clm_history hist; json_cleanup cJSON *meta = NULL;