Files
CST-agent/src/attach.c
T
WHC 60cbce3c5c v0.9.2: files the agent delivers come back as cards, and the saved key is used
A turn could write a report and say "here it is" -- and there was nothing to
open. The engine had no way to hand a finished file to the person, and the
front end had no way to show one. This release adds the whole path: a tool to
deliver, a store that keeps the copy, a card in the transcript, and open /
share through the platform. It also fixes two bugs the phone exposed while it
was being tried there.

deliver_file (engine)
- cst_attachment_t.for_user marks a copy that belongs to the person, not to
  the model; cst_attach_import_artifact() stores any bytes up to
  CST_ARTIFACT_MAX_BYTES (64 MB) with the mime taken from the name's
  extension. Attachments are unchanged (content decides the type, 16 MB).
- It is a read-policy engine tool: path resolves inside the workspace, a
  directory is refused, title (optional) is the display name.
- The wire is what keeps it out of the conversation: parts_add_attachments()
  skips for_user entries without even a "not sent" note, and a turn that only
  delivered a file no longer invents an empty user message. tests/test_artifacts.c
  pins that down end to end, including a session-file round trip.

The card (Qt front end)
- filesText() replaced imagesText(): one line per file -- kind, stored name,
  name, origin, mime, bytes, pixels -- decoded by ImageStrip.qml. A for_user
  entry is a file card (extension badge, name, type and size, Open, Share);
  a picture stays a thumbnail and opens on tap. Both shapes are read, so
  sessions written by an older version still draw.
- AgentCore::openArtifact() / shareArtifact() accept a name only when one of
  the session's own messages carries it, and hand the display name to the
  platform layer for the status line.
- Android opens through the app's FileProvider (content:// + ACTION_VIEW,
  resolveActivity() checked, read permission granted) and shares through the
  share sheet; a desktop uses QDesktopServices / the clipboard.
  CST_APP_NO_OPEN=1 makes --uitest run without opening anything.

Two bugs the device found
- The session writer had its own copy of the attachment JSON and forgot
  `for_user`: the file survived the file, but came back as an attachment --
  no card, and put back in front of the model. It now uses the one writer
  (cst_attachments_to_json()), with the round trip covered by a test.
- engineToolImages() looked the card up with findTool(), which only finds a
  card that is still running -- and the engine reports the result *before*
  the files, so the card was already complete and the files were dropped.
  A live turn therefore showed no card while a replay of the same session
  did. It now matches on the call id.
- A tap that could not work said nothing: ImageStrip.qml's errorText was
  never drawn. The card now carries the reason ("no app on the phone opens
  report.md").

Also
- The saved provider key reaches the engine on every start: AgentCore::open()
  was handed an empty key and the engine never resolves credentials itself
  (only the CLI did), so every start asked for it again. It resolves the same
  order main.c uses (saved key, provider environment, CST_API_KEY) and wipes
  the copy it made; --selftest checks it against a scratch credentials file.
- Android: notification_send reads the Java exception text into its result,
  and keeps failing on the NX789J -- the build() returns null and Qt clears
  the exception before C++ can read it, so the call has to be wrapped in Java
  next (docs/android.md says so).
- --uitest grew the delivered-file case (card, Open, Share, a forged name
  refused), the live order of result-then-files, and the startup row count.

Version 0.9.2.
2026-10-11 16:34:47 +08:00

891 lines
28 KiB
C

#ifndef _DEFAULT_SOURCE
#define _DEFAULT_SOURCE
#endif
#include "cJSON.h"
#include "cst_attach.h"
#include "cst_pdf.h"
#include "cst_util.h"
#include "os_compat.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* --- base64 -------------------------------------------------------------- */
static const char B64[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
char *cst_base64_encode(const void *data, size_t n) {
const unsigned char *p = data;
size_t olen = ((n + 2) / 3) * 4;
char *out = malloc(olen + 1);
if (!out)
return NULL;
size_t o = 0;
for (size_t i = 0; i < n; i += 3) {
unsigned v = (unsigned)p[i] << 16;
if (i + 1 < n)
v |= (unsigned)p[i + 1] << 8;
if (i + 2 < n)
v |= (unsigned)p[i + 2];
out[o++] = B64[(v >> 18) & 63];
out[o++] = B64[(v >> 12) & 63];
out[o++] = i + 1 < n ? B64[(v >> 6) & 63] : '=';
out[o++] = i + 2 < n ? B64[v & 63] : '=';
}
out[o] = 0;
return out;
}
static int b64_val(unsigned char c) {
if (c >= 'A' && c <= 'Z')
return c - 'A';
if (c >= 'a' && c <= 'z')
return c - 'a' + 26;
if (c >= '0' && c <= '9')
return c - '0' + 52;
if (c == '+' || c == '-')
return 62;
if (c == '/' || c == '_')
return 63;
return -1;
}
char *cst_base64_decode(const char *s, size_t n, size_t *out_len) {
if (out_len)
*out_len = 0;
if (!s)
return NULL;
char *out = malloc(n / 4 * 3 + 4);
if (!out)
return NULL;
size_t o = 0;
unsigned acc = 0;
int bits = 0;
for (size_t i = 0; i < n; i++) {
unsigned char c = (unsigned char)s[i];
if (c == '=')
break;
if (c == ' ' || c == '\t' || c == '\r' || c == '\n')
continue;
int v = b64_val(c);
if (v < 0) {
free(out);
return NULL;
}
acc = (acc << 6) | (unsigned)v;
bits += 6;
if (bits >= 8) {
bits -= 8;
out[o++] = (char)((acc >> bits) & 0xFF);
}
}
if (out_len)
*out_len = o;
return out;
}
/* --- format sniffing ----------------------------------------------------- */
static int be32(const unsigned char *p) {
return (p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3];
}
static int le32(const unsigned char *p) {
return (int)((unsigned)p[0] | ((unsigned)p[1] << 8) |
((unsigned)p[2] << 16) | ((unsigned)p[3] << 24));
}
static int le16(const unsigned char *p) {
return (int)((unsigned)p[0] | ((unsigned)p[1] << 8));
}
static const char *mime_of_ext(const char *mime) {
if (strcmp(mime, "application/pdf") == 0 ||
strcmp(mime, "text/plain") == 0)
return "txt"; /* a document is stored as the text it was reduced to */
if (strcmp(mime, "image/png") == 0)
return "png";
if (strcmp(mime, "image/jpeg") == 0)
return "jpg";
if (strcmp(mime, "image/gif") == 0)
return "gif";
if (strcmp(mime, "image/webp") == 0)
return "webp";
if (strcmp(mime, "image/bmp") == 0)
return "bmp";
return "bin";
}
/* A JPEG SOF marker carries the frame size; skip it when scanning. */
static int jpeg_sof(int marker) {
switch (marker) {
case 0xC0:
case 0xC1:
case 0xC2:
case 0xC3:
case 0xC5:
case 0xC6:
case 0xC7:
case 0xC9:
case 0xCA:
case 0xCB:
case 0xCD:
case 0xCE:
case 0xCF:
return 1;
default:
return 0;
}
}
int cst_attach_sniff(const void *data, size_t n, char *mime, size_t mime_sz) {
const unsigned char *p = data;
const char *m = NULL;
if (n >= 8 && memcmp(p, "\x89PNG\r\n\x1a\n", 8) == 0)
m = "image/png";
else if (n >= 3 && p[0] == 0xFF && p[1] == 0xD8 && p[2] == 0xFF)
m = "image/jpeg";
else if (n >= 6 && memcmp(p, "GIF87a", 6) == 0)
m = "image/gif";
else if (n >= 6 && memcmp(p, "GIF89a", 6) == 0)
m = "image/gif";
else if (n >= 12 && memcmp(p, "RIFF", 4) == 0 && memcmp(p + 8, "WEBP", 4) == 0)
m = "image/webp";
else if (n >= 2 && p[0] == 'B' && p[1] == 'M')
m = "image/bmp";
if (!m)
return -1;
snprintf(mime, mime_sz, "%s", m);
return 0;
}
/* PNG / GIF / BMP are direct; JPEG needs a marker walk; WebP has three
* container flavours. */
int cst_attach_dimensions(const void *data, size_t n, int *w, int *h) {
const unsigned char *p = data;
*w = *h = 0;
char mime[32];
if (cst_attach_sniff(data, n, mime, sizeof(mime)) != 0)
return -1;
if (strcmp(mime, "image/png") == 0) {
/* 8 signature + 4 length + "IHDR" + w + h */
if (n < 24 || memcmp(p + 12, "IHDR", 4) != 0)
return -1;
*w = be32(p + 16);
*h = be32(p + 20);
return 0;
}
if (strcmp(mime, "image/gif") == 0) {
if (n < 10)
return -1;
*w = le16(p + 6);
*h = le16(p + 8);
return 0;
}
if (strcmp(mime, "image/bmp") == 0) {
if (n < 26)
return -1;
int wi = le32(p + 18);
int hi = le32(p + 22);
if (wi < 0)
wi = -wi; /* bottom-up rows are stored with a negative height */
if (hi < 0)
hi = -hi;
*w = wi;
*h = hi;
return 0;
}
if (strcmp(mime, "image/webp") == 0) {
if (n < 30)
return -1;
const unsigned char *c = p + 12;
if (memcmp(c, "VP8 ", 4) == 0) {
/* 4 tag + 3 start code, then 16-bit width / height (14 bits each) */
if (n < 30 || !(c[7] == 0x9D && c[8] == 0x01 && c[9] == 0x2A))
return -1;
*w = le16(c + 10) & 0x3FFF;
*h = le16(c + 12) & 0x3FFF;
return 0;
}
if (memcmp(c, "VP8L", 4) == 0) {
if (n < 25 || c[8] != 0x2F)
return -1;
unsigned b = (unsigned)c[9] | ((unsigned)c[10] << 8) |
((unsigned)c[11] << 16) | ((unsigned)c[12] << 24);
*w = (int)(b & 0x3FFF) + 1;
*h = (int)((b >> 14) & 0x3FFF) + 1;
return 0;
}
if (memcmp(c, "VP8X", 4) == 0) {
if (n < 30)
return -1;
*w = (int)((unsigned)c[12] | ((unsigned)c[13] << 8) |
((unsigned)c[14] << 16)) + 1;
*h = (int)((unsigned)c[15] | ((unsigned)c[16] << 8) |
((unsigned)c[17] << 16)) + 1;
return 0;
}
return -1;
}
if (strcmp(mime, "image/jpeg") == 0) {
size_t i = 2;
while (i + 3 < n) {
if (p[i] != 0xFF)
return -1; /* lost sync */
unsigned marker = p[i + 1];
i += 2;
if (marker == 0xD8 || (marker >= 0xD0 && marker <= 0xD7))
continue; /* standalone markers: no payload */
if (marker == 0xD9 || marker == 0x01)
continue;
if (i + 1 >= n)
return -1;
size_t seg = (size_t)((p[i] << 8) | p[i + 1]);
if (seg < 2 || i + seg > n)
return -1;
if (jpeg_sof((int)marker) && seg >= 7) {
*h = (p[i + 3] << 8) | p[i + 4];
*w = (p[i + 5] << 8) | p[i + 6];
return 0;
}
i += seg;
}
}
return -1;
}
/* --- storage ------------------------------------------------------------- */
/* <session dir>/attachments/<session stem> — beside the JSONL so moving the
* workspace keeps the two together. */
int cst_attach_dir(const char *session_path, char *out, size_t out_sz) {
if (!session_path || !session_path[0])
return -1;
char dir[OS_PATH_MAX];
snprintf(dir, sizeof(dir), "%s", session_path);
char *slash = strrchr(dir, '/');
#ifdef _WIN32
char *bslash = strrchr(dir, '\\');
if (bslash && (!slash || bslash > slash))
slash = bslash;
#endif
char leaf[256];
const char *base = slash ? slash + 1 : dir;
size_t bl = strlen(base);
if (bl >= sizeof(leaf))
bl = sizeof(leaf) - 1;
memcpy(leaf, base, bl);
leaf[bl] = 0;
if (slash)
*(slash + 1) = 0;
else {
dir[0] = '.';
dir[1] = 0;
}
char *dot = strstr(leaf, ".jsonl");
if (dot)
*dot = 0;
char rel[320];
snprintf(rel, sizeof(rel), "attachments/%s", leaf[0] ? leaf : "session");
char full[OS_PATH_MAX];
if (os_path_join(full, sizeof(full), dir, rel) != 0)
return -1;
if (os_mkdir_p(full, 0700) != 0)
return -1;
snprintf(out, out_sz, "%s", full);
return 0;
}
/* Stored names are "<id>.<ext>" made by store(); anything else in a JSONL
* (hand-edited or crafted) could point outside the attachment directory. */
int cst_attach_rel_ok(const char *rel) {
if (!rel || !rel[0] || rel[0] == '.')
return 0;
for (const char *p = rel; *p; p++) {
char c = *p;
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.'))
return 0;
}
return strstr(rel, "..") == NULL;
}
int cst_attach_full_path(const char *session_path, const cst_attachment_t *a,
char *out, size_t out_sz) {
if (!a || !cst_attach_rel_ok(a->rel))
return -1;
char dir[OS_PATH_MAX];
if (cst_attach_dir(session_path, dir, sizeof(dir)) != 0)
return -1;
return os_path_join(out, out_sz, dir, a->rel);
}
static char *read_whole(const char *path, size_t *out_len, size_t max) {
FILE *f = fopen(path, "rb");
if (!f)
return NULL;
cst_buf_t b = {0};
char chunk[16384];
size_t n;
while ((n = fread(chunk, 1, sizeof(chunk), f)) > 0) {
if (b.len + n > max || cst_buf_append(&b, chunk, n) != 0) {
cst_buf_free(&b);
fclose(f);
return NULL;
}
}
fclose(f);
*out_len = b.len;
return cst_buf_steal(&b);
}
/* FNV over the bytes, the name and the clock: short, stable, unique enough
* inside one session. The prefix says what the name is for: "img" for what a
* model is shown, "art" for what a person is handed. */
static void make_id(const char *prefix, const char *name, const char *data,
size_t n, char *out, size_t out_sz) {
uint64_t h = cst_fnv1a64(CST_FNV_INIT, data, n > 4096 ? 4096 : n);
h = cst_fnv1a64(h, name, strlen(name));
h = cst_fnv1a64(h, &n, sizeof(n));
double now = os_now_ms();
h = cst_fnv1a64(h, &now, sizeof(now));
h = cst_fnv1a64(h, &(int){os_getpid()}, sizeof(int));
snprintf(out, out_sz, "%s-%08x", prefix && prefix[0] ? prefix : "img",
(unsigned)(h & 0xFFFFFFFFu));
}
/* n bytes, NUL-terminated: a text payload is handed around as a C string. */
static char *memdup_z(const char *data, size_t n) {
char *copy = malloc(n + 1);
if (!copy)
return NULL;
memcpy(copy, data, n);
copy[n] = 0;
return copy;
}
/* What a picked file is, plus the payload to store for it: an image stays as
* it is, a PDF is reduced to its text layer (the mime still says PDF, the
* stored file holds text), anything else must be UTF-8 text. The caller frees
* the result; *out_len is its length. */
static char *prepare(const char *data, size_t n, char *mime, size_t mime_sz,
int *pages, size_t *out_len, char *err, size_t err_sz) {
*pages = 0;
*out_len = n;
if (cst_attach_sniff(data, n, mime, mime_sz) == 0) {
if (n > CST_ATTACH_MAX_BYTES) {
snprintf(err, err_sz, "image is %.1f MB; the limit is %d MB",
(double)n / (1024 * 1024), (int)(CST_ATTACH_MAX_BYTES >> 20));
return NULL;
}
char *copy = memdup_z(data, n);
if (!copy)
snprintf(err, err_sz, "out of memory");
return copy;
}
if (n >= 4 && memcmp(data, "%PDF", 4) == 0) {
int pg = 0;
char perr[256];
char *text = cst_pdf_text(data, n, &pg, perr, sizeof(perr));
if (!text) {
snprintf(err, err_sz, "cannot read the PDF: %.200s", perr);
return NULL;
}
snprintf(mime, mime_sz, "application/pdf");
*pages = pg;
*out_len = strlen(text);
return text;
}
if (n > CST_ATTACH_TEXT_MAX) {
snprintf(err, err_sz, "text is %.1f MB; the limit is %d MB",
(double)n / (1024 * 1024), (int)(CST_ATTACH_TEXT_MAX >> 20));
return NULL;
}
if (memchr(data, 0, n) || !cst_utf8_valid(data, n)) {
snprintf(err, err_sz,
"not an image (PNG, JPEG, GIF, WebP or BMP), a PDF or UTF-8 "
"text; the content decides, not the extension");
return NULL;
}
snprintf(mime, mime_sz, "text/plain");
char *copy = memdup_z(data, n);
if (!copy)
snprintf(err, err_sz, "out of memory");
return copy;
}
/* Write one payload whose kind is already known. id_prefix names it ("img" /
* "art"), for_user marks it as the person's rather than the model's, and
* ext_override is the stored file's extension when the caller knows better
* than the mime does. */
static cst_attachment_t *store(const char *session_path, const char *name,
const char *data, size_t n, const char *mime,
int pages, const char *origin, int for_user,
const char *id_prefix, const char *ext_override,
char *err, size_t err_sz) {
if (!name || !name[0])
name = "image";
char dir[OS_PATH_MAX];
if (cst_attach_dir(session_path, dir, sizeof(dir)) != 0) {
snprintf(err, err_sz, "cannot create the attachment directory");
return NULL;
}
cst_attachment_t *a = calloc(1, sizeof(*a));
if (!a) {
snprintf(err, err_sz, "out of memory");
return NULL;
}
make_id(id_prefix, name, data, n, a->id, sizeof(a->id));
snprintf(a->rel, sizeof(a->rel), "%s.%s", a->id,
ext_override && ext_override[0] ? ext_override : mime_of_ext(mime));
snprintf(a->name, sizeof(a->name), "%s", name && name[0] ? name : "image");
snprintf(a->mime, sizeof(a->mime), "%s", mime);
snprintf(a->origin, sizeof(a->origin), "%s", origin ? origin : "user");
snprintf(a->detail, sizeof(a->detail), "auto");
a->for_user = for_user ? 1 : 0;
a->bytes = (long long)n;
a->pages = pages;
if (cst_attach_is_image(a))
cst_attach_dimensions(data, n, &a->width, &a->height);
char full[OS_PATH_MAX];
if (cst_attach_full_path(session_path, a, full, sizeof(full)) != 0) {
snprintf(err, err_sz, "attachment path is too long");
free(a);
return NULL;
}
FILE *f = fopen(full, "wb");
if (!f) {
snprintf(err, err_sz, "cannot write %.200s", full);
free(a);
return NULL;
}
size_t w = fwrite(data, 1, n, f);
if (fclose(f) != 0 || w != n) {
snprintf(err, err_sz, "cannot write %.200s", full);
free(a);
return NULL;
}
return a;
}
cst_attachment_t *cst_attach_import_bytes(const char *session_path,
const char *name, const char *data,
size_t n, char *err, size_t err_sz) {
char dummy[8];
if (!err || !err_sz) {
err = dummy;
err_sz = sizeof(dummy);
}
err[0] = 0;
if (!session_path || !session_path[0]) {
snprintf(err, err_sz, "no session to store the file in");
return NULL;
}
if (!data || !n) {
snprintf(err, err_sz, "the file is empty");
return NULL;
}
char mime[32];
if (cst_attach_sniff(data, n, mime, sizeof(mime)) != 0) {
snprintf(err, err_sz,
"not a supported image (PNG, JPEG, GIF, WebP or BMP; "
"checked by content, not by extension)");
return NULL;
}
if (n > CST_ATTACH_MAX_BYTES) {
snprintf(err, err_sz, "image is %.1f MB; the limit is %d MB",
(double)n / (1024 * 1024), (int)(CST_ATTACH_MAX_BYTES >> 20));
return NULL;
}
return store(session_path, name, data, n, mime, 0, "user", 0, "img", NULL,
err, err_sz);
}
cst_attachment_t *cst_attach_import_file(const char *session_path,
const char *src_path, char *err,
size_t err_sz) {
char dummy[8];
if (!err || !err_sz) {
err = dummy;
err_sz = sizeof(dummy);
}
err[0] = 0;
size_t n = 0;
char *data = read_whole(src_path, &n, CST_ATTACH_MAX_BYTES);
if (!data) {
snprintf(err, err_sz, "cannot read %.300s (or it is larger than %d MB)",
src_path, (int)(CST_ATTACH_MAX_BYTES >> 20));
return NULL;
}
const char *leaf = src_path;
for (const char *p = src_path; *p; p++)
if (*p == '/' || *p == '\\')
leaf = p + 1;
char mime[32];
int pages = 0;
size_t pn = 0;
char *payload =
prepare(data, n, mime, sizeof(mime), &pages, &pn, err, err_sz);
free(data);
if (!payload)
return NULL;
cst_attachment_t *a =
store(session_path, leaf, payload, pn, mime, pages, "user", 0, "img",
NULL, err, err_sz);
free(payload);
return a;
}
/* The mime of a delivered file: its extension decides (the content is not
* sniffed -- a .zip is not a picture), and anything unknown is a plain blob
* the OS still opens by name. */
static const char *mime_from_name(const char *name) {
static const struct {
const char *ext, *mime;
} T[] = {
{"md", "text/markdown"}, {"markdown", "text/markdown"},
{"txt", "text/plain"}, {"log", "text/plain"},
{"json", "application/json"}, {"csv", "text/csv"},
{"html", "text/html"}, {"htm", "text/html"},
{"xml", "application/xml"}, {"yaml", "application/yaml"},
{"yml", "application/yaml"}, {"toml", "application/toml"},
{"pdf", "application/pdf"}, {"zip", "application/zip"},
{"gz", "application/gzip"}, {"tgz", "application/gzip"},
{"tar", "application/x-tar"}, {"sh", "text/x-shellscript"},
{"py", "text/x-python"}, {"c", "text/x-c"},
{"h", "text/x-c"}, {"cpp", "text/x-c++"},
{"java", "text/x-java"}, {"kt", "text/x-kotlin"},
{"js", "text/javascript"}, {"ts", "text/typescript"},
{"go", "text/x-go"}, {"rs", "text/x-rust"},
{"rb", "text/x-ruby"}, {"qml", "text/x-qml"},
{"css", "text/css"}, {"sql", "text/x-sql"},
{"diff", "text/x-diff"}, {"patch", "text/x-diff"},
{"png", "image/png"}, {"jpg", "image/jpeg"},
{"jpeg", "image/jpeg"}, {"gif", "image/gif"},
{"webp", "image/webp"}, {"bmp", "image/bmp"},
{"svg", "image/svg+xml"}, {"ico", "image/x-icon"},
{"mp3", "audio/mpeg"}, {"m4a", "audio/mp4"},
{"ogg", "audio/ogg"}, {"wav", "audio/wav"},
{"mp4", "video/mp4"}, {"webm", "video/webm"},
{"apk", "application/vnd.android.package-archive"},
{"docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
{"xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"},
{"pptx",
"application/vnd.openxmlformats-officedocument.presentationml."
"presentation"},
};
const char *dot = name ? strrchr(name, '.') : NULL;
if (!dot || !dot[1])
return "application/octet-stream";
const char *e = dot + 1;
size_t el = strlen(e);
for (size_t i = 0; i < sizeof(T) / sizeof(T[0]); i++) {
if (strlen(T[i].ext) != el)
continue;
size_t k = 0;
for (; k < el; k++) {
char c = e[k];
if (c >= 'A' && c <= 'Z')
c = (char)(c - 'A' + 'a');
if (c != T[i].ext[k])
break;
}
if (k == el)
return T[i].mime;
}
return "application/octet-stream";
}
/* The stored file's extension: the name's own when it is plain ([a-z0-9], at
* most 8 characters -- a stored name has to stay portable), else whatever the
* mime maps to. */
static void ext_for_name(const char *name, const char *mime, char *out,
size_t out_sz) {
size_t k = 0;
const char *dot = name ? strrchr(name, '.') : NULL;
if (dot && dot[1] && out_sz > 1) {
for (const char *p = dot + 1; *p && k < 8 && k + 1 < out_sz; p++) {
char c = *p;
if (c >= 'A' && c <= 'Z')
c = (char)(c - 'A' + 'a');
if (!((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')))
break;
out[k++] = c;
}
}
out[k] = 0;
if (!k)
snprintf(out, out_sz, "%s", mime_of_ext(mime));
}
cst_attachment_t *cst_attach_import_artifact(const char *session_path,
const char *src_path,
const char *title, char *err,
size_t err_sz) {
char dummy[8];
if (!err || !err_sz) {
err = dummy;
err_sz = sizeof(dummy);
}
err[0] = 0;
if (!session_path || !session_path[0]) {
snprintf(err, err_sz, "no session to store the file in");
return NULL;
}
if (!src_path || !src_path[0]) {
snprintf(err, err_sz, "no file to deliver");
return NULL;
}
size_t n = 0;
char *data = read_whole(src_path, &n, CST_ARTIFACT_MAX_BYTES);
if (!data) {
snprintf(err, err_sz, "cannot read %.300s (or it is larger than %d MB)",
src_path, (int)(CST_ARTIFACT_MAX_BYTES >> 20));
return NULL;
}
const char *leaf = src_path;
for (const char *p = src_path; *p; p++)
if (*p == '/' || *p == '\\')
leaf = p + 1;
const char *name = (title && title[0]) ? title : leaf;
char mime[64], ext[16];
snprintf(mime, sizeof(mime), "%s", mime_from_name(name));
ext_for_name(name, mime, ext, sizeof(ext));
cst_attachment_t *a = store(session_path, name, data, n, mime, 0, "tool", 1,
"art", ext, err, err_sz);
free(data);
return a;
}
/* Images are opaque and travel as image parts; everything else is text the
* store holds and the request inlines. */
int cst_attach_is_image(const cst_attachment_t *a) {
return a && strncmp(a->mime, "image/", 6) == 0;
}
int cst_attach_is_text(const cst_attachment_t *a) {
/* A delivered file is the person's: it is never inlined into a request. */
return a && !a->for_user && !cst_attach_is_image(a);
}
char *cst_attach_read(const char *session_path, const cst_attachment_t *a,
size_t *out_len, char *err, size_t err_sz) {
char dummy[8];
if (!err || !err_sz) {
err = dummy;
err_sz = sizeof(dummy);
}
err[0] = 0;
char full[OS_PATH_MAX];
if (cst_attach_full_path(session_path, a, full, sizeof(full)) != 0) {
snprintf(err, err_sz, "attachment has no stored file");
return NULL;
}
char *data = read_whole(full, out_len, CST_ATTACH_MAX_BYTES);
if (!data) {
snprintf(err, err_sz,
"%.200s is missing; it was deleted from the session store",
a->name);
return NULL;
}
if (!cst_attach_is_image(a)) /* a document is text, nothing to verify */
return data;
char mime[32];
if (cst_attach_sniff(data, *out_len, mime, sizeof(mime)) != 0) {
snprintf(err, err_sz, "%.200s in the session store is no longer an image",
a->name);
free(data);
return NULL;
}
return data;
}
char *cst_attach_data_url(const char *session_path, const cst_attachment_t *a) {
if (!cst_attach_is_image(a)) /* a document travels as text, not as a URL */
return NULL;
char err[256];
size_t n = 0;
char *data = cst_attach_read(session_path, a, &n, err, sizeof(err));
if (!data)
return NULL;
char mime[32];
cst_attach_sniff(data, n, mime, sizeof(mime)); /* the bytes decide */
char *b64 = cst_base64_encode(data, n);
free(data);
if (!b64)
return NULL;
cst_buf_t b = {0};
cst_buf_printf(&b, "data:%s;base64,", mime);
cst_buf_puts(&b, b64);
free(b64);
return cst_buf_steal(&b);
}
void cst_attach_describe(const cst_attachment_t *a, char *out, size_t out_sz) {
if (!a) {
snprintf(out, out_sz, "(none)");
return;
}
const char *kind = a->mime;
char dim[32] = "";
if (cst_attach_is_image(a)) {
if (strncmp(a->mime, "image/", 6) == 0)
kind = a->mime + 6;
if (a->width > 0 && a->height > 0)
snprintf(dim, sizeof(dim), "%dx%d ", a->width, a->height);
} else if (strcmp(a->mime, "application/pdf") == 0) {
kind = "pdf";
if (a->pages > 0)
snprintf(dim, sizeof(dim), "%d page%s ", a->pages,
a->pages == 1 ? "" : "s");
} else if (strcmp(a->mime, "text/plain") == 0 || !a->mime[0]) {
kind = "text";
}
out[0] = 0;
if (a->bytes >= 1024 * 1024)
snprintf(out, out_sz, "%s%s %.1f MB", dim, kind,
(double)a->bytes / (1024 * 1024));
else if (a->bytes >= 1024)
snprintf(out, out_sz, "%s%s %.1f KB", dim, kind, (double)a->bytes / 1024);
else
snprintf(out, out_sz, "%s%s %lld B", dim, kind, a->bytes);
}
/* --- lifetime ------------------------------------------------------------ */
void cst_attach_free(cst_attachment_t *a) { free(a); }
/* Entries always live in one array (a 1-element array for a single import),
* so freeing the block releases them all. */
void cst_attachments_free(cst_attachment_t *list, int n) {
(void)n;
free(list);
}
cst_attachment_t *cst_attachments_dup(const cst_attachment_t *list, int n) {
if (!list || n <= 0)
return NULL;
cst_attachment_t *out = calloc((size_t)n, sizeof(*out));
if (!out)
return NULL;
for (int i = 0; i < n; i++)
out[i] = list[i];
return out;
}
int cst_attachments_push(cst_attachment_t **list, int *n, int *cap,
const cst_attachment_t *src) {
if (!list || !n || !cap || !src)
return -1;
if (*n == *cap) {
int nc = *cap ? *cap * 2 : 4;
cst_attachment_t *nv = realloc(*list, (size_t)nc * sizeof(**list));
if (!nv)
return -1;
*list = nv;
*cap = nc;
}
(*list)[(*n)++] = *src;
return 0;
}
/* --- JSONL --------------------------------------------------------------- */
char *cst_attachments_to_json(const cst_attachment_t *list, int n) {
if (!list || n <= 0)
return NULL;
cJSON *arr = cJSON_CreateArray();
if (!arr)
return NULL;
for (int i = 0; i < n; i++) {
const cst_attachment_t *a = &list[i];
cJSON *o = cJSON_CreateObject();
cJSON_AddStringToObject(o, "id", a->id);
cJSON_AddStringToObject(o, "name", a->name);
cJSON_AddStringToObject(o, "mime", a->mime);
cJSON_AddStringToObject(o, "rel", a->rel);
cJSON_AddNumberToObject(o, "bytes", (double)a->bytes);
if (a->width > 0 && a->height > 0) {
cJSON_AddNumberToObject(o, "width", a->width);
cJSON_AddNumberToObject(o, "height", a->height);
}
if (a->pages > 0)
cJSON_AddNumberToObject(o, "pages", a->pages);
if (a->detail[0] && strcmp(a->detail, "auto") != 0)
cJSON_AddStringToObject(o, "detail", a->detail);
if (a->origin[0])
cJSON_AddStringToObject(o, "origin", a->origin);
if (a->for_user)
cJSON_AddNumberToObject(o, "for_user", 1);
cJSON_AddItemToArray(arr, o);
}
char *s = cJSON_PrintUnformatted(arr);
cJSON_Delete(arr);
return s;
}
cst_attachment_t *cst_attachments_from_json(const void *array_json, int *n_out) {
if (n_out)
*n_out = 0;
const cJSON *arr = array_json;
if (!cJSON_IsArray(arr))
return NULL;
int n = cJSON_GetArraySize(arr);
if (n <= 0)
return NULL;
cst_attachment_t *list = calloc((size_t)n, sizeof(*list));
if (!list)
return NULL;
int k = 0;
const cJSON *it = NULL;
cJSON_ArrayForEach(it, arr) {
if (!cJSON_IsObject(it))
continue;
const cJSON *jid = cJSON_GetObjectItemCaseSensitive(it, "id");
const cJSON *jrel = cJSON_GetObjectItemCaseSensitive(it, "rel");
if (!cJSON_IsString(jid) || !cJSON_IsString(jrel) ||
!cst_attach_rel_ok(jrel->valuestring))
continue;
cst_attachment_t *a = &list[k];
snprintf(a->id, sizeof(a->id), "%s", jid->valuestring);
snprintf(a->rel, sizeof(a->rel), "%s", jrel->valuestring);
const cJSON *v = cJSON_GetObjectItemCaseSensitive(it, "name");
snprintf(a->name, sizeof(a->name), "%s",
cJSON_IsString(v) ? v->valuestring : a->id);
v = cJSON_GetObjectItemCaseSensitive(it, "mime");
snprintf(a->mime, sizeof(a->mime), "%s",
cJSON_IsString(v) ? v->valuestring : "image/*");
v = cJSON_GetObjectItemCaseSensitive(it, "bytes");
a->bytes = cJSON_IsNumber(v) ? (long long)v->valuedouble : 0;
v = cJSON_GetObjectItemCaseSensitive(it, "width");
a->width = cJSON_IsNumber(v) ? v->valueint : 0;
v = cJSON_GetObjectItemCaseSensitive(it, "height");
a->height = cJSON_IsNumber(v) ? v->valueint : 0;
v = cJSON_GetObjectItemCaseSensitive(it, "pages");
a->pages = cJSON_IsNumber(v) ? v->valueint : 0;
v = cJSON_GetObjectItemCaseSensitive(it, "detail");
snprintf(a->detail, sizeof(a->detail), "%s",
cJSON_IsString(v) ? v->valuestring : "auto");
v = cJSON_GetObjectItemCaseSensitive(it, "origin");
snprintf(a->origin, sizeof(a->origin), "%s",
cJSON_IsString(v) ? v->valuestring : "");
v = cJSON_GetObjectItemCaseSensitive(it, "for_user");
a->for_user = cJSON_IsNumber(v) ? v->valueint : 0;
k++;
}
if (k == 0) {
free(list);
return NULL;
}
if (n_out)
*n_out = k;
return list;
}