Working mode: --mode coding|general (CST_MODE, settings.json, /mode, WebUI run settings). general swaps the default framing for a general assistant one -- workflow, safety notes, tool wording (run_shell, the subagents) -- and /status shows it; minimal (the paper profile) stays byte-frozen. The prompt state carries the mode so a switch leaves a system note in history. Clipboard: clipboard_read / clipboard_write tools (16 MiB cap read, default 64 KiB window; writing is a WRITE permission with a preview) on pbcopy / wl-copy / xclip / pbpaste / wl-paste / xclip and the Win32 clipboard. In the TUI, /attach paste falls back to clipboard text when there is no image on it. PDF: a dependency-free text extractor (src/pdf_text.c): inflate, Flate/ASCIIHex/ASCII85, PNG/TIFF predictors, layout-independent object scan, object streams, page tree walk with inherited resources, content lexer (Tj/TJ/'/" and the Td/TD/T*/Tm spacing heuristics), per-font ToUnicode CMaps and inline images skipped. Encrypted files and pages with no text layer are refused with the reason. Attachments: /attach (a path or files dropped on the terminal) now take images, PDFs and text. A PDF is stored as its text layer with the mime still saying application/pdf and its page count; anything else has to be UTF-8 text. Documents travel inline as text parts, so CST_NO_IMAGES and text-only models no longer drop them, while images keep their old path. Tests: test_pdf_text (plain, bare and zlib-framed Flate, inline image, CID + ToUnicode, object stream, error paths), documents in test_attach, inlined documents in test_llm_body, mode in test_config.
644 lines
23 KiB
C
644 lines
23 KiB
C
#define _POSIX_C_SOURCE 200809L
|
|
|
|
#include "cJSON.h"
|
|
#include "cst_attach.h"
|
|
#include "cst_agent.h"
|
|
#include "cst_clipboard.h"
|
|
#include "cst_test_util.h"
|
|
#include "os_compat.h"
|
|
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
static int nfail;
|
|
|
|
#define EXPECT(cond, msg) \
|
|
do { \
|
|
if (!(cond)) { \
|
|
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, msg); \
|
|
nfail++; \
|
|
} \
|
|
} while (0)
|
|
|
|
static void put_be32(unsigned char *p, unsigned v) {
|
|
p[0] = (unsigned char)(v >> 24);
|
|
p[1] = (unsigned char)(v >> 16);
|
|
p[2] = (unsigned char)(v >> 8);
|
|
p[3] = (unsigned char)v;
|
|
}
|
|
|
|
static void put_le16(unsigned char *p, unsigned v) {
|
|
p[0] = (unsigned char)(v & 0xFF);
|
|
p[1] = (unsigned char)((v >> 8) & 0xFF);
|
|
}
|
|
|
|
/* 8 signature + 4 length + "IHDR" + w + h (what cst_attach_dimensions reads). */
|
|
static size_t png_hdr(unsigned char *b, unsigned w, unsigned h) {
|
|
static const unsigned char sig[8] = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'};
|
|
memcpy(b, sig, 8);
|
|
b[8] = b[9] = b[10] = 0;
|
|
b[11] = 13;
|
|
memcpy(b + 12, "IHDR", 4);
|
|
put_be32(b + 16, w);
|
|
put_be32(b + 20, h);
|
|
return 32;
|
|
}
|
|
|
|
static size_t gif_hdr(unsigned char *b, unsigned w, unsigned h) {
|
|
memcpy(b, "GIF89a", 6);
|
|
put_le16(b + 6, w);
|
|
put_le16(b + 8, h);
|
|
return 16;
|
|
}
|
|
|
|
static void put_le32(unsigned char *p, unsigned v) {
|
|
p[0] = (unsigned char)(v & 0xFF);
|
|
p[1] = (unsigned char)((v >> 8) & 0xFF);
|
|
p[2] = (unsigned char)((v >> 16) & 0xFF);
|
|
p[3] = (unsigned char)((v >> 24) & 0xFF);
|
|
}
|
|
|
|
static size_t bmp_hdr(unsigned char *b, int w, int h) {
|
|
memset(b, 0, 64);
|
|
b[0] = 'B';
|
|
b[1] = 'M';
|
|
put_le32(b + 18, (unsigned)w);
|
|
put_le32(b + 22, (unsigned)h);
|
|
return 64;
|
|
}
|
|
|
|
/* FFD8 + a filler APP0 segment + SOF0 carrying the frame size. */
|
|
static size_t jpeg_hdr(unsigned char *b, unsigned w, unsigned h) {
|
|
size_t i = 0;
|
|
b[i++] = 0xFF;
|
|
b[i++] = 0xD8;
|
|
b[i++] = 0xFF;
|
|
b[i++] = 0xE0;
|
|
b[i++] = 0x00;
|
|
b[i++] = 0x04;
|
|
b[i++] = 0x00;
|
|
b[i++] = 0x00;
|
|
b[i++] = 0xFF;
|
|
b[i++] = 0xC0;
|
|
b[i++] = 0x00;
|
|
b[i++] = 0x0B;
|
|
b[i++] = 0x08;
|
|
b[i++] = (unsigned char)(h >> 8);
|
|
b[i++] = (unsigned char)h;
|
|
b[i++] = (unsigned char)(w >> 8);
|
|
b[i++] = (unsigned char)w;
|
|
b[i++] = 0x01; /* one component */
|
|
b[i++] = 0x01; /* component id */
|
|
b[i++] = 0x11; /* sampling */
|
|
b[i++] = 0x00; /* quant table */
|
|
return i;
|
|
}
|
|
|
|
static void test_sniff(void) {
|
|
unsigned char b[64];
|
|
char mime[32];
|
|
size_t n;
|
|
|
|
n = png_hdr(b, 32, 24);
|
|
EXPECT(cst_attach_sniff(b, n, mime, sizeof(mime)) == 0 &&
|
|
strcmp(mime, "image/png") == 0, "PNG sniffed");
|
|
n = jpeg_hdr(b, 20, 10);
|
|
EXPECT(cst_attach_sniff(b, n, mime, sizeof(mime)) == 0 &&
|
|
strcmp(mime, "image/jpeg") == 0, "JPEG sniffed");
|
|
n = gif_hdr(b, 3, 5);
|
|
EXPECT(cst_attach_sniff(b, n, mime, sizeof(mime)) == 0 &&
|
|
strcmp(mime, "image/gif") == 0, "GIF sniffed");
|
|
memcpy(b, "RIFF\0\0\0\0WEBPVP8 ", 16);
|
|
EXPECT(cst_attach_sniff(b, 30, mime, sizeof(mime)) == 0 &&
|
|
strcmp(mime, "image/webp") == 0, "WebP sniffed");
|
|
n = bmp_hdr(b, 7, 9);
|
|
EXPECT(cst_attach_sniff(b, n, mime, sizeof(mime)) == 0 &&
|
|
strcmp(mime, "image/bmp") == 0, "BMP sniffed");
|
|
|
|
/* an extension never decides: text and a PDF are not images */
|
|
EXPECT(cst_attach_sniff("hello, world", 12, mime, sizeof(mime)) != 0,
|
|
"text rejected");
|
|
EXPECT(cst_attach_sniff("%PDF-1.7\n", 9, mime, sizeof(mime)) != 0,
|
|
"PDF rejected");
|
|
EXPECT(cst_attach_sniff("\x89PNG", 4, mime, sizeof(mime)) != 0,
|
|
"truncated PNG rejected");
|
|
EXPECT(cst_attach_sniff("", 0, mime, sizeof(mime)) != 0, "empty rejected");
|
|
}
|
|
|
|
static void test_dimensions(void) {
|
|
unsigned char b[64];
|
|
int w = 0, h = 0;
|
|
size_t n;
|
|
|
|
n = png_hdr(b, 1280, 720);
|
|
EXPECT(cst_attach_dimensions(b, n, &w, &h) == 0 && w == 1280 && h == 720,
|
|
"PNG dimensions");
|
|
n = gif_hdr(b, 16, 40);
|
|
EXPECT(cst_attach_dimensions(b, n, &w, &h) == 0 && w == 16 && h == 40,
|
|
"GIF dimensions");
|
|
n = bmp_hdr(b, 7, 9);
|
|
EXPECT(cst_attach_dimensions(b, n, &w, &h) == 0 && w == 7 && h == 9,
|
|
"BMP dimensions");
|
|
n = jpeg_hdr(b, 640, 480);
|
|
EXPECT(cst_attach_dimensions(b, n, &w, &h) == 0 && w == 640 && h == 480,
|
|
"JPEG dimensions");
|
|
|
|
w = h = -1;
|
|
EXPECT(cst_attach_dimensions("nope", 4, &w, &h) != 0 && w == 0 && h == 0,
|
|
"unknown format gives 0x0");
|
|
}
|
|
|
|
static void test_base64(void) {
|
|
char *s = cst_base64_encode("Man", 3);
|
|
EXPECT(s && strcmp(s, "TWFu") == 0, "base64 <Man>");
|
|
free(s);
|
|
s = cst_base64_encode("Ma", 2);
|
|
EXPECT(s && strcmp(s, "TWE=") == 0, "base64 padding 1");
|
|
free(s);
|
|
s = cst_base64_encode("M", 1);
|
|
EXPECT(s && strcmp(s, "TQ==") == 0, "base64 padding 2");
|
|
free(s);
|
|
s = cst_base64_encode("\x00\xff\xfe", 3);
|
|
EXPECT(s && strcmp(s, "AP/+") == 0, "base64 binary");
|
|
free(s);
|
|
|
|
size_t n = 0;
|
|
char *d = cst_base64_decode("TWFu", 4, &n);
|
|
EXPECT(d && n == 3 && memcmp(d, "Man", 3) == 0, "decode <Man>");
|
|
free(d);
|
|
d = cst_base64_decode("TQ==", 4, &n);
|
|
EXPECT(d && n == 1 && d[0] == 'M', "decode with padding");
|
|
free(d);
|
|
d = cst_base64_decode("AP/+\n", 5, &n);
|
|
EXPECT(d && n == 3 && memcmp(d, "\x00\xff\xfe", 3) == 0,
|
|
"decode binary, newline ignored");
|
|
free(d);
|
|
d = cst_base64_decode("AP_-", 4, &n);
|
|
EXPECT(d && n == 3 && memcmp(d, "\x00\xff\xfe", 3) == 0, "URL-safe alphabet");
|
|
free(d);
|
|
EXPECT(cst_base64_decode("A*B=", 4, &n) == NULL, "bad character refused");
|
|
|
|
unsigned char raw[200];
|
|
for (int i = 0; i < 200; i++)
|
|
raw[i] = (unsigned char)(i * 37);
|
|
for (size_t len = 0; len < 7; len++) {
|
|
char *e = cst_base64_encode(raw, 190 + len);
|
|
d = e ? cst_base64_decode(e, strlen(e), &n) : NULL;
|
|
EXPECT(d && n == 190 + len && memcmp(d, raw, n) == 0, "round trip");
|
|
free(e);
|
|
free(d);
|
|
}
|
|
}
|
|
|
|
static void test_rel_ok(const char *root) {
|
|
EXPECT(cst_attach_rel_ok("img-0a1b2c3d.png"), "normal name accepted");
|
|
EXPECT(!cst_attach_rel_ok("../x.png"), "parent dir refused");
|
|
EXPECT(!cst_attach_rel_ok("a/../../b.png"), "slash refused");
|
|
EXPECT(!cst_attach_rel_ok("/etc/passwd"), "absolute refused");
|
|
EXPECT(!cst_attach_rel_ok("a\\b.png"), "backslash refused");
|
|
EXPECT(!cst_attach_rel_ok(".hidden"), "dot file refused");
|
|
EXPECT(!cst_attach_rel_ok(""), "empty refused");
|
|
|
|
cJSON *arr = cJSON_Parse("[{\"id\":\"x\",\"rel\":\"../../secret.png\"},"
|
|
"{\"id\":\"y\",\"rel\":\"img-1.png\"}]");
|
|
int n = 0;
|
|
cst_attachment_t *back = cst_attachments_from_json(arr, &n);
|
|
EXPECT(n == 1 && back && strcmp(back[0].id, "y") == 0,
|
|
"escaping rel dropped on load");
|
|
cst_attachments_free(back, n);
|
|
cJSON_Delete(arr);
|
|
|
|
/* a stored file swapped for non-image bytes is not sent */
|
|
char session[1024], full[1024], err[256];
|
|
snprintf(session, sizeof(session), "%.900s/sessions/r.jsonl", root);
|
|
unsigned char b[64];
|
|
size_t bn = png_hdr(b, 4, 4);
|
|
cst_attachment_t *a =
|
|
cst_attach_import_bytes(session, "x.png", (const char *)b, bn, err,
|
|
sizeof(err));
|
|
EXPECT(a != NULL, "stored");
|
|
if (!a)
|
|
return;
|
|
EXPECT(cst_attach_full_path(session, a, full, sizeof(full)) == 0, "path");
|
|
FILE *f = fopen(full, "wb");
|
|
if (f) {
|
|
fputs("plain text now", f);
|
|
fclose(f);
|
|
}
|
|
size_t rn = 0;
|
|
EXPECT(cst_attach_read(session, a, &rn, err, sizeof(err)) == NULL &&
|
|
strstr(err, "no longer an image"),
|
|
"read re-checks the bytes");
|
|
EXPECT(cst_attach_data_url(session, a) == NULL, "no data url for it");
|
|
snprintf(a->rel, sizeof(a->rel), "../r.jsonl");
|
|
EXPECT(cst_attach_full_path(session, a, full, sizeof(full)) != 0,
|
|
"full path refuses an escaping rel");
|
|
cst_attach_free(a);
|
|
}
|
|
|
|
static void test_store(const char *root) {
|
|
char session[1024], dir[1024];
|
|
snprintf(session, sizeof(session), "%.900s/sessions/a.jsonl", root);
|
|
unsigned char b[64];
|
|
size_t n = png_hdr(b, 32, 24);
|
|
char err[256];
|
|
|
|
cst_attachment_t *a = cst_attach_import_bytes(session, "shot.png",
|
|
(const char *)b, n, err,
|
|
sizeof(err));
|
|
EXPECT(a != NULL, "import stored");
|
|
if (!a) {
|
|
fprintf(stderr, " err: %s\n", err);
|
|
return;
|
|
}
|
|
EXPECT(strcmp(a->mime, "image/png") == 0, "stored mime");
|
|
EXPECT(a->width == 32 && a->height == 24, "stored dimensions");
|
|
EXPECT(a->bytes == (long long)n, "stored size");
|
|
EXPECT(strcmp(a->name, "shot.png") == 0, "stored name");
|
|
EXPECT(strcmp(a->origin, "user") == 0, "origin=user");
|
|
EXPECT(strcmp(a->rel, "img-") != 0 && strstr(a->rel, ".png") != NULL,
|
|
"rel has a .png extension");
|
|
|
|
EXPECT(cst_attach_dir(session, dir, sizeof(dir)) == 0, "attach dir");
|
|
char full[1024];
|
|
EXPECT(cst_attach_full_path(session, a, full, sizeof(full)) == 0 &&
|
|
strstr(full, "attachments/") != NULL,
|
|
"full path inside attachments/");
|
|
FILE *f = fopen(full, "rb");
|
|
EXPECT(f != NULL, "stored file exists");
|
|
if (f)
|
|
fclose(f);
|
|
|
|
char desc[128];
|
|
cst_attach_describe(a, desc, sizeof(desc));
|
|
EXPECT(strstr(desc, "32x24") && strstr(desc, "png"), "describe has size");
|
|
|
|
size_t rn = 0;
|
|
char *data = cst_attach_read(session, a, &rn, err, sizeof(err));
|
|
EXPECT(data && rn == n && memcmp(data, b, n) == 0, "read back exact bytes");
|
|
free(data);
|
|
|
|
char *url = cst_attach_data_url(session, a);
|
|
EXPECT(url && strncmp(url, "data:image/png;base64,", 22) == 0,
|
|
"data url prefix");
|
|
if (url)
|
|
EXPECT(strlen(url) == 22 + ((n + 2) / 3) * 4, "data url length");
|
|
free(url);
|
|
|
|
cst_attach_free(a);
|
|
|
|
/* a text file named .png is still refused */
|
|
EXPECT(cst_attach_import_bytes(session, "fake.png", "not an image", 12, err,
|
|
sizeof(err)) == NULL,
|
|
"content decides, not the name");
|
|
EXPECT(strstr(err, "not a supported image") != NULL, "err explains why");
|
|
EXPECT(cst_attach_import_bytes(session, "empty.png", "", 0, err,
|
|
sizeof(err)) == NULL,
|
|
"empty data refused");
|
|
}
|
|
|
|
static void test_import_file(const char *root) {
|
|
char session[1024], src[1024];
|
|
snprintf(session, sizeof(session), "%.900s/sessions/b.jsonl", root);
|
|
snprintf(src, sizeof(src), "%.900s/logo.gif", root);
|
|
unsigned char b[64];
|
|
size_t n = gif_hdr(b, 8, 6);
|
|
FILE *f = fopen(src, "wb");
|
|
EXPECT(f != NULL, "write source image");
|
|
if (f) {
|
|
fwrite(b, 1, n, f);
|
|
fclose(f);
|
|
}
|
|
char err[256];
|
|
cst_attachment_t *a =
|
|
cst_attach_import_file(session, src, err, sizeof(err));
|
|
EXPECT(a != NULL, "file imported");
|
|
if (a) {
|
|
EXPECT(strcmp(a->mime, "image/gif") == 0, "imported mime");
|
|
EXPECT(strcmp(a->name, "logo.gif") == 0, "imported keeps the leaf name");
|
|
EXPECT(a->width == 8 && a->height == 6, "imported dimensions");
|
|
cst_attach_free(a);
|
|
}
|
|
EXPECT(cst_attach_import_file(session, "/nonexistent/x.png", err,
|
|
sizeof(err)) == NULL,
|
|
"missing file refused");
|
|
}
|
|
|
|
/* Documents: text is stored as text, a PDF as its text layer while the mime
|
|
* still says PDF, and neither is an image as far as the wire is concerned. */
|
|
static void test_documents(const char *root) {
|
|
char session[1024], src[1024];
|
|
snprintf(session, sizeof(session), "%.900s/sessions/doc.jsonl", root);
|
|
char err[256] = "";
|
|
|
|
const char *text = "# Notes\n\nFive painters, one canvas.\n";
|
|
snprintf(src, sizeof(src), "%.900s/notes.md", root);
|
|
FILE *f = fopen(src, "wb");
|
|
EXPECT(f != NULL, "write the text file");
|
|
if (f) {
|
|
fwrite(text, 1, strlen(text), f);
|
|
fclose(f);
|
|
}
|
|
cst_attachment_t *a = cst_attach_import_file(session, src, err, sizeof(err));
|
|
EXPECT(a != NULL, "text file imported");
|
|
if (a) {
|
|
EXPECT(strcmp(a->mime, "text/plain") == 0, "text mime");
|
|
EXPECT(!cst_attach_is_image(a) && cst_attach_is_text(a), "not an image");
|
|
EXPECT(a->pages == 0, "no pages for text");
|
|
EXPECT(strstr(a->rel, ".txt") != NULL, "text stored as .txt");
|
|
EXPECT(a->bytes == (long long)strlen(text), "text size");
|
|
size_t n = 0;
|
|
char *back = cst_attach_read(session, a, &n, err, sizeof(err));
|
|
EXPECT(back && n == strlen(text) && memcmp(back, text, n) == 0,
|
|
"text reads back exactly");
|
|
free(back);
|
|
EXPECT(cst_attach_data_url(session, a) == NULL,
|
|
"a document has no data url");
|
|
char desc[128];
|
|
cst_attach_describe(a, desc, sizeof(desc));
|
|
EXPECT(strstr(desc, "text") != NULL, "describe says text");
|
|
cst_attach_free(a);
|
|
}
|
|
|
|
/* a one-page PDF with a text layer */
|
|
static const char PDF[] =
|
|
"%PDF-1.4\n"
|
|
"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"
|
|
"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"
|
|
"3 0 obj\n<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 4 0 R "
|
|
">> >> /Contents 5 0 R >>\nendobj\n"
|
|
"4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\n"
|
|
"endobj\n"
|
|
"5 0 obj\n<< >>\nstream\n"
|
|
"BT /F1 12 Tf 72 720 Td (Attached paper abstract) Tj ET\n"
|
|
"endstream\nendobj\n"
|
|
"trailer\n<< /Root 1 0 R >>\n%%EOF\n";
|
|
snprintf(src, sizeof(src), "%.900s/paper.pdf", root);
|
|
f = fopen(src, "wb");
|
|
EXPECT(f != NULL, "write the PDF");
|
|
if (f) {
|
|
fwrite(PDF, 1, strlen(PDF), f);
|
|
fclose(f);
|
|
}
|
|
err[0] = 0;
|
|
a = cst_attach_import_file(session, src, err, sizeof(err));
|
|
EXPECT(a != NULL, "PDF imported");
|
|
if (a) {
|
|
EXPECT(strcmp(a->mime, "application/pdf") == 0, "the PDF keeps its mime");
|
|
EXPECT(a->pages == 1, "page count comes from the extractor");
|
|
EXPECT(strstr(a->rel, ".txt") != NULL, "the text layer is what is stored");
|
|
EXPECT(cst_attach_is_text(a), "documents are text on the wire");
|
|
char desc[128];
|
|
cst_attach_describe(a, desc, sizeof(desc));
|
|
EXPECT(strstr(desc, "pdf") != NULL && strstr(desc, "1 page") != NULL,
|
|
"describe names the PDF and its pages");
|
|
size_t n = 0;
|
|
char *back = cst_attach_read(session, a, &n, err, sizeof(err));
|
|
EXPECT(back && strstr(back, "Attached paper abstract") != NULL,
|
|
"the extracted text reads back");
|
|
free(back);
|
|
cst_attach_free(a);
|
|
}
|
|
err[0] = 0;
|
|
EXPECT(cst_attach_import_file(session, "/nonexistent/x.pdf", err,
|
|
sizeof(err)) == NULL,
|
|
"a missing PDF is refused");
|
|
|
|
/* neither image, PDF nor text: a binary blob */
|
|
static const char binary[] = "\x01\x02\x03\x00\x04\x05";
|
|
snprintf(src, sizeof(src), "%.900s/blob.bin", root);
|
|
f = fopen(src, "wb");
|
|
if (f) {
|
|
fwrite(binary, 1, sizeof(binary), f);
|
|
fclose(f);
|
|
}
|
|
err[0] = 0;
|
|
EXPECT(cst_attach_import_file(session, src, err, sizeof(err)) == NULL,
|
|
"a binary file is refused");
|
|
EXPECT(strstr(err, "not an image") != NULL, "err says what was expected");
|
|
}
|
|
|
|
static void test_json_roundtrip(void) {
|
|
cst_attachment_t list[3];
|
|
memset(list, 0, sizeof(list));
|
|
snprintf(list[0].id, sizeof(list[0].id), "img-1");
|
|
snprintf(list[0].name, sizeof(list[0].name), "a.png");
|
|
snprintf(list[0].mime, sizeof(list[0].mime), "image/png");
|
|
snprintf(list[0].rel, sizeof(list[0].rel), "img-1.png");
|
|
list[0].bytes = 12345;
|
|
list[0].width = 640;
|
|
list[0].height = 480;
|
|
snprintf(list[0].origin, sizeof(list[0].origin), "user");
|
|
|
|
snprintf(list[1].id, sizeof(list[1].id), "img-2");
|
|
snprintf(list[1].name, sizeof(list[1].name), "b.jpg");
|
|
snprintf(list[1].mime, sizeof(list[1].mime), "image/jpeg");
|
|
snprintf(list[1].rel, sizeof(list[1].rel), "img-2.jpg");
|
|
list[1].bytes = 4096;
|
|
snprintf(list[1].detail, sizeof(list[1].detail), "high");
|
|
snprintf(list[1].origin, sizeof(list[1].origin), "view_image");
|
|
snprintf(list[1].mime, sizeof(list[1].mime), "application/pdf");
|
|
list[1].pages = 12;
|
|
|
|
snprintf(list[2].id, sizeof(list[2].id), "img-3");
|
|
snprintf(list[2].name, sizeof(list[2].name), "c.webp");
|
|
snprintf(list[2].mime, sizeof(list[2].mime), "image/webp");
|
|
snprintf(list[2].rel, sizeof(list[2].rel), "img-3.webp");
|
|
list[2].bytes = 7;
|
|
|
|
char *json = cst_attachments_to_json(list, 3);
|
|
EXPECT(json != NULL, "to_json");
|
|
int n = 0;
|
|
cJSON *parsed = cJSON_Parse(json);
|
|
cst_attachment_t *back = cst_attachments_from_json(parsed, &n);
|
|
cJSON_Delete(parsed);
|
|
EXPECT(back != NULL && n == 3, "from_json count");
|
|
if (back && n == 3) {
|
|
EXPECT(strcmp(back[0].id, "img-1") == 0 && back[0].bytes == 12345 &&
|
|
back[0].width == 640 && back[0].height == 480,
|
|
"first entry survives");
|
|
EXPECT(strcmp(back[1].detail, "high") == 0 &&
|
|
strcmp(back[1].origin, "view_image") == 0,
|
|
"detail and origin survive");
|
|
EXPECT(strcmp(back[1].mime, "application/pdf") == 0 && back[1].pages == 12,
|
|
"a document keeps its mime and page count");
|
|
EXPECT(back[2].pages == 0, "pages default to 0");
|
|
EXPECT(strcmp(back[2].mime, "image/webp") == 0 && back[2].width == 0,
|
|
"entry without dimensions");
|
|
EXPECT(strcmp(back[0].detail, "auto") == 0, "missing detail defaults");
|
|
cst_attachments_free(back, n);
|
|
}
|
|
free(json);
|
|
|
|
/* entries without rel/id are dropped; a non-array is NULL */
|
|
cJSON *arr = cJSON_Parse("[{\"id\":\"x\"},{\"id\":\"y\",\"rel\":\"y.png\"}]");
|
|
back = cst_attachments_from_json(arr, &n);
|
|
EXPECT(back != NULL && n == 1 && strcmp(back[0].id, "y") == 0,
|
|
"incomplete entries skipped");
|
|
cst_attachments_free(back, n);
|
|
cJSON_Delete(arr);
|
|
cJSON *obj = cJSON_Parse("{}");
|
|
n = 7;
|
|
EXPECT(cst_attachments_from_json(obj, &n) == NULL && n == 0,
|
|
"object is not an attachment array");
|
|
cJSON_Delete(obj);
|
|
EXPECT(cst_attachments_to_json(NULL, 0) == NULL, "empty list is NULL");
|
|
}
|
|
|
|
static void test_lists(void) {
|
|
cst_attachment_t src;
|
|
memset(&src, 0, sizeof(src));
|
|
snprintf(src.id, sizeof(src.id), "img-x");
|
|
snprintf(src.mime, sizeof(src.mime), "image/png");
|
|
|
|
cst_attachment_t *list = NULL;
|
|
int n = 0, cap = 0;
|
|
for (int i = 0; i < 10; i++)
|
|
EXPECT(cst_attachments_push(&list, &n, &cap, &src) == 0, "push");
|
|
EXPECT(n == 10 && cap >= 10, "list grew");
|
|
EXPECT(strcmp(list[9].id, "img-x") == 0, "last entry copied");
|
|
|
|
cst_attachment_t *dup = cst_attachments_dup(list, n);
|
|
EXPECT(dup && strcmp(dup[3].mime, "image/png") == 0, "dup copies fields");
|
|
dup[3].id[0] = 'Z';
|
|
EXPECT(list[3].id[0] == 'i', "dup is a deep copy");
|
|
cst_attachments_free(dup, n);
|
|
EXPECT(cst_attachments_dup(NULL, 0) == NULL, "dup of nothing is NULL");
|
|
cst_attachments_free(list, n);
|
|
}
|
|
|
|
static void test_agent_pending(const char *root) {
|
|
cst_agent_config_t cfg;
|
|
memset(&cfg, 0, sizeof(cfg));
|
|
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", root);
|
|
snprintf(cfg.session_path, sizeof(cfg.session_path), "%s/sessions/c.jsonl",
|
|
root);
|
|
cst_agent_t *a = cst_agent_create(&cfg);
|
|
EXPECT(a != NULL, "agent created");
|
|
if (!a)
|
|
return;
|
|
|
|
char err[256], line[128];
|
|
EXPECT(cst_agent_pending_count(a) == 0, "no pending at start");
|
|
EXPECT(cst_agent_pending_describe(a, 0, line, sizeof(line)) == -1,
|
|
"describe out of range");
|
|
|
|
unsigned char b[32];
|
|
size_t n = png_hdr(b, 4, 2);
|
|
char src[1024];
|
|
snprintf(src, sizeof(src), "%.900s/pic.png", root);
|
|
FILE *f = fopen(src, "wb");
|
|
EXPECT(f != NULL, "write the source png");
|
|
if (f) {
|
|
fwrite(b, 1, n, f);
|
|
fclose(f);
|
|
}
|
|
EXPECT(cst_agent_attach_file(a, src, err, sizeof(err)) == 0,
|
|
"attach through the agent");
|
|
EXPECT(cst_agent_pending_count(a) == 1, "one pending");
|
|
EXPECT(cst_agent_pending_describe(a, 0, line, sizeof(line)) == 0 &&
|
|
strstr(line, "4x2") != NULL,
|
|
"pending describe shows the size");
|
|
|
|
EXPECT(cst_agent_attach_file(a, "/nonexistent/x.png", err, sizeof(err)) == -1,
|
|
"missing file refused");
|
|
EXPECT(cst_agent_pending_count(a) == 1, "a failure keeps the queue");
|
|
|
|
for (int i = 1; i < CST_ATTACH_MAX_COUNT; i++)
|
|
EXPECT(cst_agent_attach_file(a, src, err, sizeof(err)) == 0, "attach more");
|
|
EXPECT(cst_agent_pending_count(a) == CST_ATTACH_MAX_COUNT, "queue full");
|
|
EXPECT(cst_agent_attach_file(a, src, err, sizeof(err)) == -1 &&
|
|
strstr(err, "at most") != NULL,
|
|
"one over the limit is refused");
|
|
|
|
cst_agent_pending_clear(a);
|
|
EXPECT(cst_agent_pending_count(a) == 0, "cleared");
|
|
cst_agent_destroy(a);
|
|
}
|
|
|
|
static void test_paste_paths(void) {
|
|
static char p[CST_PASTE_MAX_PATHS][CST_PASTE_PATH_MAX];
|
|
const char *s = "/tmp/a.png";
|
|
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == 1 &&
|
|
strcmp(p[0], "/tmp/a.png") == 0,
|
|
"single path");
|
|
s = " '/home/u/my shot.png' \"/x/y z.jpg\"\n/plain.gif\r\n";
|
|
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == 3, "quoted paths count");
|
|
EXPECT(strcmp(p[0], "/home/u/my shot.png") == 0, "single quotes");
|
|
EXPECT(strcmp(p[1], "/x/y z.jpg") == 0, "double quotes");
|
|
EXPECT(strcmp(p[2], "/plain.gif") == 0, "newline separated");
|
|
s = "file:///home/u/a%20b.png file://localhost/c.png";
|
|
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == 2, "file uris");
|
|
EXPECT(strcmp(p[0], "/home/u/a b.png") == 0, "percent decoded");
|
|
EXPECT(strcmp(p[1], "/c.png") == 0, "localhost host");
|
|
s = "file://evil.example/c.png";
|
|
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == -1, "remote host refused");
|
|
#ifndef _WIN32
|
|
s = "/home/u/my\\ shot.png";
|
|
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == 1 &&
|
|
strcmp(p[0], "/home/u/my shot.png") == 0,
|
|
"backslash escape");
|
|
#endif
|
|
s = "'/unterminated.png";
|
|
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == -1, "unbalanced quote");
|
|
s = " \n ";
|
|
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == -1, "blank");
|
|
s = "a b c";
|
|
EXPECT(cst_paste_paths(s, strlen(s), p, 2) == -1, "too many");
|
|
}
|
|
|
|
static void test_dib(void) {
|
|
/* 3x2, 24-bit, bottom-up: stride 12 (9 bytes + pad). */
|
|
unsigned char dib[40 + 24];
|
|
memset(dib, 0, sizeof(dib));
|
|
put_le32(dib, 40);
|
|
put_le32(dib + 4, 3);
|
|
put_le32(dib + 8, 2);
|
|
put_le16(dib + 12, 1);
|
|
put_le16(dib + 14, 24);
|
|
dib[40] = 0xff; /* bottom-left pixel blue (BGR) */
|
|
size_t n = 0;
|
|
char *img = cst_dib_to_image(dib, sizeof(dib), &n);
|
|
char mime[32];
|
|
int w = 0, h = 0;
|
|
EXPECT(img && cst_attach_sniff(img, n, mime, sizeof(mime)) == 0 &&
|
|
strcmp(mime, "image/png") == 0,
|
|
"24-bit DIB becomes a PNG");
|
|
EXPECT(img && cst_attach_dimensions(img, n, &w, &h) == 0 && w == 3 && h == 2,
|
|
"PNG keeps the size");
|
|
free(img);
|
|
|
|
put_le16(dib + 14, 8); /* palette needs 256 entries: past the end */
|
|
EXPECT(cst_dib_to_image(dib, sizeof(dib), &n) == NULL, "short DIB refused");
|
|
put_le32(dib, 4);
|
|
EXPECT(cst_dib_to_image(dib, sizeof(dib), &n) == NULL, "bad header size");
|
|
}
|
|
|
|
int main(void) {
|
|
char root[1024];
|
|
if (test_tmpdir(root, sizeof(root), "cst-attach") != 0) {
|
|
fprintf(stderr, "cannot create a scratch dir\n");
|
|
return 1;
|
|
}
|
|
test_sniff();
|
|
test_dimensions();
|
|
test_base64();
|
|
test_rel_ok(root);
|
|
test_paste_paths();
|
|
test_dib();
|
|
test_store(root);
|
|
test_import_file(root);
|
|
test_documents(root);
|
|
test_json_roundtrip();
|
|
test_lists();
|
|
test_agent_pending(root);
|
|
test_rmrf(root);
|
|
if (nfail) {
|
|
fprintf(stderr, "test_attach: %d failed\n", nfail);
|
|
return 1;
|
|
}
|
|
printf("test_attach: OK\n");
|
|
return 0;
|
|
}
|