Files
Hermit/tests/test_attach.c
T
WHC c2320f946b v0.9.0: the Qt front end as an Android APK, images in the transcript
Two strands land together: the window draws pictures now, and the whole front
end builds, installs and runs on the phone with the engine linked in-process.

Images (the "make a picture actually show up" part)
- Engine, additively: cst_agent_hooks_t.on_tool_images(call, list, n, ud) fires
  when a call left attachments behind, and cst_agent_pending_rel() /
  cst_agent_pending_is_image() read that list without the app touching the
  store. tests/test_attach.c::test_agent_pending pins the three cases (an
  image, a stored name that passes cst_attach_rel_ok, out-of-range NULL).
- App: image://cstattach/<stored name> (app/src/ui/SessionImages.cpp) serves
  only the current session's attachment directory, checked with
  cst_attach_rel_ok, decodes down to the requested sourceSize and never
  upscales, and holds a mutex because requestImage() runs on the render
  thread while the directory can change underneath it. AgentCore gained
  attachmentDir(), pendingImages(), the toolImages()/promptImages() signals
  and hookToolImages(); history() gives replayed rows their images too.
- QML: ImageStrip.qml (a thumb strip, <=150 px tall, shaped by the picture,
  tooltip with name/size/origin, the file's name when it went missing),
  wired into MessageCard and ToolCard. Qt 6's ListModel cannot hold a
  list-valued role (append() reads back undefined, setProperty drops it), so
  a row carries one image per line as <stored>|<name>|<origin>|<w>x<h> and
  ImageStrip decodes that.

Android (the engine is already in the APK; this is the front end)
- app/src/main.cpp: on Android the workspace, config, tmp and HOME go under
  the app's own data directory before the engine opens anything, because
  QDir::currentPath() is "/" there and /tmp is not writable.
- app/android/: the manifest (QtApplication/QtActivity, INTERNET and
  POST_NOTIFICATIONS, adjustResize, no storage permission -- the picker will
  be the system one), strings, a vector icon; minSdk 24 and targetSdk 35 come
  from app/CMakeLists.txt through QT_ANDROID_MIN/TARGET_SDK_VERSION.
- app/src/platform/android/AndroidTools.cpp: the Android file behind
  PlatformInit(), owner "app:android" (notification, clipboard, share sheet,
  file picker are stubs for now, so the tool list does not lie about them).
- app/tools/selftest.c decides its scratch workspace with os_temp_dir(), so
  the C smoke test runs on the device too.
- scripts/build_apk_android.sh: sources to installed APK in one command
  (environment checks, qt-cmake configure, --target apk, adb install), and
  the host-side plumbing the NixOS + qt.io combination needs -- the NDK
  sysroot include dirs for Qt's FindGLESv2/FindEGL (they only look under
  <sysroot>/include), -L$PREFIX/lib on the shared/module link lines as well
  as the executables', the Android debug keystore created on first use with
  QT_ANDROID_SIGN_APK + QT_ANDROID_KEYSTORE_* (androiddeployqt copies the
  unsigned gradle output and signs it itself), and the #!/bin/bash shebang of
  the build-tools wrappers, which NixOS does not have.
- scripts/gradle-android-init.gradle (installed into ~/.gradle/init.d/): the
  release lint task is switched off -- lintVitalAnalyzeRelease resolves its
  own tool jars straight from dl.google.com, bypassing every repository the
  build configures, and fails the build after all the real work is done.
- Icons: app/icons/*.svg (Breeze's 24@3x files, the 19 names the window asks
  for) are compiled in as :/cst-icons and ThemeIcons.cpp falls back to them
  when the platform has no icon theme. On the phone the Qt theme lookup found
  nothing and the QML glyph fallback was an empty box in the phone's fonts.
- docs/android.md: "The APK (Qt front end)" -- the SDK assembled without
  sdkmanager, the host Qt's missing libraries on NixOS, the Gradle mirrors,
  lint, signing -- and a Status that says what runs where. app/README.md
  gains the images and APK lines, and stops claiming Android platform tools
  that are still stubs.

Verified: engine `make check` green; desktop `cst-app-selftest: OK` and
--uitest all checks pass; phone (NX789J, Android 16, arm64-v8a) -- the APK
installs, starts on the workspace under /data/user/0/xyz.whcself.cstagent/
files/, a turn reaches a configured provider over HTTPS from inside the ART
process and renders the reply, rotation keeps the session and docks the
sidebar, and the toolbar/card icons draw.

Version 0.9.0.

mode = coding
2026-10-11 15:22:10 +08:00

653 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_pending_is_image(a, 0) == 1,
"a pending image says that it is one");
EXPECT(cst_agent_pending_rel(a, 0) != NULL &&
cst_attach_rel_ok(cst_agent_pending_rel(a, 0)),
"its stored name is one a view may serve");
EXPECT(cst_agent_pending_rel(a, -1) == NULL &&
cst_agent_pending_rel(a, 99) == NULL &&
cst_agent_pending_is_image(a, 99) == 0,
"out of range has no name and is no image");
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;
}