/* * test_httpd.c — Tests for the first-party graph-UI HTTP server. * * Two layers: * 1. Parser/helper unit tests against httpd.h's pure functions * (no sockets): request-line parsing, strict CRLF, Content-Length * edge cases, chunked rejection, NUL/percent-decode rules, * query-param decoding, route pattern matching. * 2. Live-socket integration tests against the full UI server * (http_server.c) on an ephemeral port: routing, CORS policy, * RPC dispatch, transport limits, receive deadline, clean shutdown. */ #include "../src/foundation/compat.h" #if defined(__APPLE__) #include /* _NSGetExecutablePath — deleted-self driver */ #endif #include "../src/foundation/compat_fs.h" #include "../src/foundation/compat_thread.h" #include "../src/foundation/log.h" #include "../src/foundation/mem_core.h" #include "../src/foundation/platform.h" #include "../src/foundation/str_util.h" /* cbm_json_escape: expected index-status fragment */ #include "../src/cli/cli.h" #include "../src/daemon/host_internal.h" #include "../src/git/git_context.h" /* #798 follow-up: live-socket git-resolve repro */ #include "../src/ui/http_server.h" #include "test_framework.h" #include "test_helpers.h" #include "ui/httpd.h" #include "ui/activity.h" #include "ui/atlas.h" #include "ui/layout3d.h" #include #include "ui/http_server.h" #include #include #include /* the index replies are asserted as parsed JSON */ #include /* PATH_MAX: which platforms resolve a root past 1 KB */ #include #include #include #include #ifndef CBM_VERSION #define CBM_VERSION "dev" #endif #ifndef _WIN32 #include #endif #ifdef _WIN32 #include #include #include /* #798 follow-up: CreateThread/WaitForSingleObject watchdog */ typedef SOCKET th_sock_t; #define th_sock_close closesocket #define th_sock_shutdown(s) shutdown((s), SD_BOTH) #define TH_SOCK_BAD INVALID_SOCKET #else #include #include #include #include /* struct timeval for the SO_RCVTIMEO watchdog (#798 follow-up) */ #include /* fork/waitpid crash-isolation for the browse overflow guard */ #include typedef int th_sock_t; #define th_sock_close close #define th_sock_shutdown(s) shutdown((s), SHUT_RDWR) #define TH_SOCK_BAD (-1) #endif static char httpd_log_buf[8192]; static void httpd_capture_log(const char *line) { size_t used = strlen(httpd_log_buf); size_t avail = sizeof(httpd_log_buf) - used; if (avail <= 1) return; int n = snprintf(httpd_log_buf + used, avail, "%s\n", line ? line : ""); if (n < 0 || (size_t)n >= avail) httpd_log_buf[sizeof(httpd_log_buf) - 1] = '\0'; } /* ── Raw-socket test client ───────────────────────────────────── */ static th_sock_t th_connect_with_recv_buffer(int port, int recv_buffer) { #ifdef _WIN32 WSADATA wsa; WSAStartup(MAKEWORD(2, 2), &wsa); /* refcounted; cleanup not needed in tests */ #endif th_sock_t s = socket(AF_INET, SOCK_STREAM, 0); if (s == TH_SOCK_BAD) return TH_SOCK_BAD; if (recv_buffer > 0 && setsockopt(s, SOL_SOCKET, SO_RCVBUF, (const char *)&recv_buffer, sizeof(recv_buffer)) != 0) { th_sock_close(s); return TH_SOCK_BAD; } struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons((unsigned short)port); addr.sin_addr.s_addr = htonl(0x7F000001); /* 127.0.0.1 */ if (connect(s, (struct sockaddr *)&addr, sizeof(addr)) != 0) { th_sock_close(s); return TH_SOCK_BAD; } return s; } static th_sock_t th_connect(int port) { return th_connect_with_recv_buffer(port, 0); } static int th_send_all(th_sock_t s, const char *data, size_t len) { size_t off = 0; while (off < len) { #ifdef _WIN32 int n = send(s, data + off, (int)(len - off), 0); #else ssize_t n = send(s, data + off, len - off, 0); #endif if (n <= 0) return -1; off += (size_t)n; } return 0; } /* Read until the server closes the connection (Connection: close model). */ static int th_recv_until_close(th_sock_t s, char *buf, size_t bufsz) { size_t off = 0; for (;;) { #ifdef _WIN32 int n = recv(s, buf + off, (int)(bufsz - 1 - off), 0); #else ssize_t n = recv(s, buf + off, bufsz - 1 - off, 0); #endif if (n <= 0) break; off += (size_t)n; if (off >= bufsz - 1) break; } buf[off] = '\0'; return (int)off; } /* One-shot raw HTTP exchange. Returns response length, 0 on connect failure. */ static int th_http_raw(int port, const char *request, char *resp, size_t respsz) { th_sock_t s = th_connect(port); if (s == TH_SOCK_BAD) return 0; if (th_send_all(s, request, strlen(request)) != 0) { th_sock_close(s); return 0; } int n = th_recv_until_close(s, resp, respsz); th_sock_close(s); return n; } /* Existing route tests focus on endpoint behavior. Add the loopback Host and * JSON mutation header the browser supplies. Security tests use th_http_raw() * to exercise missing/hostile headers without this convenience layer. */ static char *th_request_with_ui_headers(int port, const char *request) { const char *head_end = strstr(request, "\r\n\r\n"); if (!head_end) return strdup(request); const char *target = strchr(request, ' '); target = target ? target + 1 : NULL; bool protected_route = target && (strncmp(target, "/api/", 5) == 0 || strncmp(target, "/rpc ", 5) == 0 || strncmp(target, "/rpc?", 5) == 0); bool mutation = strncmp(request, "POST ", 5) == 0; bool have_host = strstr(request, "\r\nHost:") != NULL; bool have_content_type = strstr(request, "\r\nContent-Type:") != NULL; size_t request_len = strlen(request); size_t capacity = request_len + 256; char *result = malloc(capacity); if (!result) return NULL; size_t head_len = (size_t)(head_end - request); memcpy(result, request, head_len); size_t pos = head_len; if (!have_host) pos += (size_t)snprintf(result + pos, capacity - pos, "\r\nHost: 127.0.0.1:%d", port); if (protected_route && mutation && !have_content_type) { pos += (size_t)snprintf(result + pos, capacity - pos, "\r\nContent-Type: application/json"); } (void)snprintf(result + pos, capacity - pos, "\r\n\r\n%s", head_end + 4); return result; } static int th_http(int port, const char *request, char *resp, size_t respsz) { char *prepared = th_request_with_ui_headers(port, request); if (!prepared) return 0; int result = th_http_raw(port, prepared, resp, respsz); free(prepared); return result; } /* HTTP status code from a raw response ("HTTP/1.1 404 ..."), or -1. */ static int th_status(const char *resp) { if (strncmp(resp, "HTTP/1.1 ", 9) != 0) return -1; return atoi(resp + 9); } /* ── Parser unit tests ────────────────────────────────────────── */ TEST(httpd_parse_simple_get) { const char *raw = "GET /api/logs?lines=5 HTTP/1.1\r\n" "Host: 127.0.0.1\r\n" "Origin: http://localhost:5173\r\n" "\r\n"; cbm_http_req_t req; size_t body_off = 0, clen = 99; int rc = cbm_http_parse_head(raw, strlen(raw), &req, &body_off, &clen); ASSERT_EQ(rc, 0); ASSERT_STR_EQ(req.method, "GET"); ASSERT_STR_EQ(req.path, "/api/logs"); ASSERT_STR_EQ(req.query, "lines=5"); ASSERT_EQ(req.http_minor, 1); ASSERT_STR_EQ(req.origin, "http://localhost:5173"); ASSERT_EQ((int)clen, 0); ASSERT_EQ((int)body_off, (int)strlen(raw)); PASS(); } TEST(httpd_parse_security_headers_and_rejects_duplicates) { const char *raw = "POST /rpc HTTP/1.1\r\n" "Host: 127.0.0.1:9749\r\n" "Content-Type: application/json\r\n" "Origin: http://127.0.0.1:9749\r\n" "Content-Length: 0\r\n\r\n"; cbm_http_req_t req; size_t body_off = 0, content_length = 0; ASSERT_EQ(cbm_http_parse_head(raw, strlen(raw), &req, &body_off, &content_length), 0); ASSERT_STR_EQ(req.host, "127.0.0.1:9749"); ASSERT_STR_EQ(req.content_type, "application/json"); static const char *duplicates[] = { "GET / HTTP/1.1\r\nHost: localhost\r\nHost: localhost\r\n\r\n", "GET / HTTP/1.1\r\nOrigin: http://localhost\r\nOrigin: http://localhost\r\n\r\n", ("POST / HTTP/1.1\r\nContent-Type: application/json\r\nContent-Type: " "application/json\r\n\r\n"), }; for (size_t i = 0; i < sizeof(duplicates) / sizeof(duplicates[0]); i++) { ASSERT_EQ(cbm_http_parse_head(duplicates[i], strlen(duplicates[i]), &req, &body_off, &content_length), 400); } PASS(); } TEST(httpd_parse_post_with_body_offset) { const char *raw = "POST /rpc HTTP/1.1\r\n" "Content-Length: 7\r\n" "Content-Type: application/json\r\n" "\r\n" "{\"a\":1}"; cbm_http_req_t req; size_t body_off = 0, clen = 0; int rc = cbm_http_parse_head(raw, strlen(raw), &req, &body_off, &clen); ASSERT_EQ(rc, 0); ASSERT_STR_EQ(req.method, "POST"); ASSERT_STR_EQ(req.path, "/rpc"); ASSERT_STR_EQ(req.query, ""); ASSERT_EQ((int)clen, 7); ASSERT_STR_EQ(raw + body_off, "{\"a\":1}"); PASS(); } TEST(httpd_parse_origin_case_insensitive) { const char *raw = "GET / HTTP/1.1\r\n" "origin: http://127.0.0.1:9749\r\n" "\r\n"; cbm_http_req_t req; size_t body_off = 0, clen = 0; ASSERT_EQ(cbm_http_parse_head(raw, strlen(raw), &req, &body_off, &clen), 0); ASSERT_STR_EQ(req.origin, "http://127.0.0.1:9749"); PASS(); } TEST(httpd_parse_rejects_bare_lf) { const char *raw = "GET / HTTP/1.1\nHost: x\n\n"; cbm_http_req_t req; size_t body_off = 0, clen = 0; ASSERT_EQ(cbm_http_parse_head(raw, strlen(raw), &req, &body_off, &clen), 400); PASS(); } TEST(httpd_parse_rejects_chunked) { const char *raw = "POST /rpc HTTP/1.1\r\n" "Transfer-Encoding: chunked\r\n" "\r\n"; cbm_http_req_t req; size_t body_off = 0, clen = 0; ASSERT_EQ(cbm_http_parse_head(raw, strlen(raw), &req, &body_off, &clen), 411); PASS(); } TEST(httpd_parse_rejects_oversized_content_length) { char raw[256]; snprintf(raw, sizeof(raw), "POST /rpc HTTP/1.1\r\nContent-Length: %d\r\n\r\n", CBM_HTTP_MAX_BODY + 1); cbm_http_req_t req; size_t body_off = 0, clen = 0; ASSERT_EQ(cbm_http_parse_head(raw, strlen(raw), &req, &body_off, &clen), 413); PASS(); } TEST(httpd_parse_rejects_garbage_content_length) { const char *raw = "POST /rpc HTTP/1.1\r\nContent-Length: abc\r\n\r\n"; cbm_http_req_t req; size_t body_off = 0, clen = 0; ASSERT_EQ(cbm_http_parse_head(raw, strlen(raw), &req, &body_off, &clen), 400); const char *neg = "POST /rpc HTTP/1.1\r\nContent-Length: -5\r\n\r\n"; ASSERT_EQ(cbm_http_parse_head(neg, strlen(neg), &req, &body_off, &clen), 400); PASS(); } TEST(httpd_parse_rejects_percent00_in_target) { const char *raw = "GET /a%00b HTTP/1.1\r\n\r\n"; cbm_http_req_t req; size_t body_off = 0, clen = 0; ASSERT_EQ(cbm_http_parse_head(raw, strlen(raw), &req, &body_off, &clen), 400); /* %00 hidden in the query string is rejected too */ const char *q = "GET /ok?x=%00 HTTP/1.1\r\n\r\n"; ASSERT_EQ(cbm_http_parse_head(q, strlen(q), &req, &body_off, &clen), 400); PASS(); } TEST(httpd_parse_rejects_raw_nul_in_head) { char raw[64] = "GET /a"; size_t len = 6; raw[len++] = '\0'; memcpy(raw + len, " HTTP/1.1\r\n\r\n", 13); len += 13; cbm_http_req_t req; size_t body_off = 0, clen = 0; ASSERT_EQ(cbm_http_parse_head(raw, len, &req, &body_off, &clen), 400); PASS(); } TEST(httpd_parse_incomplete_head_needs_more) { const char *raw = "GET /api/logs HTTP/1.1\r\nHost: x\r\n"; /* no CRLFCRLF yet */ cbm_http_req_t req; size_t body_off = 0, clen = 0; ASSERT_EQ(cbm_http_parse_head(raw, strlen(raw), &req, &body_off, &clen), CBM_HTTP_NEED_MORE); PASS(); } TEST(httpd_parse_rejects_missing_version) { const char *raw = "GET /\r\n\r\n"; cbm_http_req_t req; size_t body_off = 0, clen = 0; ASSERT_EQ(cbm_http_parse_head(raw, strlen(raw), &req, &body_off, &clen), 400); const char *v2 = "GET / HTTP/2\r\n\r\n"; ASSERT_EQ(cbm_http_parse_head(v2, strlen(v2), &req, &body_off, &clen), 400); PASS(); } TEST(httpd_parse_rejects_oversized_head) { /* A head that exceeds CBM_HTTP_MAX_HEAD without terminating → 431 */ size_t big = CBM_HTTP_MAX_HEAD + 1024; char *raw = malloc(big); ASSERT_NOT_NULL(raw); memcpy(raw, "GET / HTTP/1.1\r\nX-Junk: ", 24); memset(raw + 24, 'A', big - 24); cbm_http_req_t req; size_t body_off = 0, clen = 0; int rc = cbm_http_parse_head(raw, big, &req, &body_off, &clen); free(raw); ASSERT_EQ(rc, 431); PASS(); } TEST(httpd_query_param_decode) { char buf[64]; ASSERT_TRUE(cbm_http_query_param("a=hello+world&b=%2Ffoo%2F", "a", buf, (int)sizeof(buf))); ASSERT_STR_EQ(buf, "hello world"); ASSERT_TRUE(cbm_http_query_param("a=hello+world&b=%2Ffoo%2F", "b", buf, (int)sizeof(buf))); ASSERT_STR_EQ(buf, "/foo/"); /* uppercase + lowercase hex */ ASSERT_TRUE(cbm_http_query_param("p=%2fTmp%2F", "p", buf, (int)sizeof(buf))); ASSERT_STR_EQ(buf, "/Tmp/"); PASS(); } TEST(httpd_query_param_edge_cases) { char buf[8]; /* missing param */ ASSERT_FALSE(cbm_http_query_param("a=1", "b", buf, (int)sizeof(buf))); /* empty value (current server treats it as absent) */ ASSERT_FALSE(cbm_http_query_param("a=&b=2", "a", buf, (int)sizeof(buf))); /* value too large for buf */ ASSERT_FALSE(cbm_http_query_param("a=123456789", "a", buf, (int)sizeof(buf))); /* decoded NUL rejected */ char big[32]; ASSERT_FALSE(cbm_http_query_param("a=x%00y", "a", big, (int)sizeof(big))); /* name is a prefix of another name — must not match */ ASSERT_FALSE(cbm_http_query_param("abc=1", "ab", buf, (int)sizeof(buf))); /* truncated percent escape */ ASSERT_FALSE(cbm_http_query_param("a=%2", "a", buf, (int)sizeof(buf))); PASS(); } TEST(httpd_path_match_matrix) { /* exact */ ASSERT_TRUE(cbm_http_path_match("/", "/")); ASSERT_FALSE(cbm_http_path_match("/x", "/")); ASSERT_TRUE(cbm_http_path_match("/rpc", "/rpc")); ASSERT_FALSE(cbm_http_path_match("/rpc2", "/rpc")); /* trailing-* prefix */ ASSERT_TRUE(cbm_http_path_match("/api/layout", "/api/layout*")); ASSERT_TRUE(cbm_http_path_match("/assets/index-abc.js", "/assets/*")); ASSERT_FALSE(cbm_http_path_match("/api/browse", "/api/layout*")); /* raw path is matched — percent-encoded slash must NOT route */ ASSERT_FALSE(cbm_http_path_match("/api%2Fbrowse", "/api/browse*")); ASSERT_FALSE(cbm_http_path_match("/api%2fbrowse", "/api/browse*")); /* CORS origin patterns */ ASSERT_TRUE(cbm_http_path_match("http://localhost:5173", "http://localhost:*")); ASSERT_TRUE(cbm_http_path_match("http://127.0.0.1:9749", "http://127.0.0.1:*")); ASSERT_FALSE(cbm_http_path_match("http://evil.com", "http://localhost:*")); ASSERT_FALSE(cbm_http_path_match("https://localhost:5173", "http://localhost:*")); ASSERT_FALSE(cbm_http_path_match("http://localhost.evil.com:80", "http://localhost:*")); PASS(); } TEST(httpd_resolves_bare_binary_path_from_path) { #ifdef _WIN32 PASS(); #else char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_httpd_bin_XXXXXX"); char *td = cbm_mkdtemp(tmpdir); ASSERT_NOT_NULL(td); char exe[512]; snprintf(exe, sizeof(exe), "%s/codebase-memory-mcp", td); FILE *f = fopen(exe, "w"); ASSERT_NOT_NULL(f); fputs("#!/bin/sh\nexit 0\n", f); fclose(f); ASSERT_EQ(chmod(exe, 0755), 0); char *old_path = getenv("PATH") ? strdup(getenv("PATH")) : NULL; cbm_setenv("PATH", td, 1); char resolved[1024]; ASSERT_TRUE( cbm_http_server_resolve_binary_path("codebase-memory-mcp", resolved, sizeof(resolved))); ASSERT_STR_EQ(resolved, exe); if (old_path) { cbm_setenv("PATH", old_path, 1); free(old_path); } else { cbm_unsetenv("PATH"); } PASS(); #endif } TEST(httpd_deleted_self_spawn_path_follows_platform_ruling) { #if defined(__linux__) || defined(__APPLE__) char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_httpd_deleted_self_XXXXXX"); ASSERT_NOT_NULL(cbm_mkdtemp(tmpdir)); char current[1024]; #if defined(__linux__) ssize_t current_len = readlink("/proc/self/exe", current, sizeof(current) - 1); ASSERT_GT(current_len, 0); ASSERT_LT(current_len, (ssize_t)(sizeof(current) - 1)); current[current_len] = '\0'; #else uint32_t current_sz = sizeof(current); ASSERT_EQ(_NSGetExecutablePath(current, ¤t_sz), 0); #endif char launch_path[512]; char replacement_path[512]; snprintf(launch_path, sizeof(launch_path), "%s/test-runner", tmpdir); snprintf(replacement_path, sizeof(replacement_path), "%s/test-runner.new", tmpdir); ASSERT_EQ(cbm_copy_file(current, launch_path), 0); ASSERT_EQ(chmod(launch_path, 0755), 0); int ready_pipe[2]; int continue_pipe[2]; ASSERT_EQ(pipe(ready_pipe), 0); ASSERT_EQ(pipe(continue_pipe), 0); pid_t child = fork(); if (child == 0) { close(ready_pipe[0]); close(continue_pipe[1]); char ready_fd[32]; char continue_fd[32]; snprintf(ready_fd, sizeof(ready_fd), "%d", ready_pipe[1]); snprintf(continue_fd, sizeof(continue_fd), "%d", continue_pipe[0]); execl(launch_path, launch_path, "__cbm_deleted_self_probe", ready_fd, continue_fd, launch_path, (char *)NULL); _exit(127); } ASSERT_GT(child, 0); close(ready_pipe[1]); close(continue_pipe[0]); char ready = '\0'; ASSERT_EQ(read(ready_pipe[0], &ready, 1), 1); ASSERT_EQ(ready, 'R'); #if defined(__linux__) /* Atomic rename-over: the installer's real move. /proc/self/exe now reads * "(deleted)" in the child, which is the state the magic-link contract * covers. */ ASSERT_EQ(cbm_copy_file(current, replacement_path), 0); ASSERT_EQ(chmod(replacement_path, 0755), 0); ASSERT_EQ(rename(replacement_path, launch_path), 0); #else /* macOS: a rename-over leaves a VALID executable at the launch path, which * resolve is right to return (the worker's build-fingerprint gate is the * layer that refuses a mismatched build — covered by its own tests). The * fail-closed branch guards the image-GONE case, so remove the image. */ (void)replacement_path; ASSERT_EQ(unlink(launch_path), 0); #endif ASSERT_EQ(write(continue_pipe[1], "G", 1), 1); close(ready_pipe[0]); close(continue_pipe[1]); int status = 0; ASSERT_EQ(waitpid(child, &status, 0), child); ASSERT_TRUE(WIFEXITED(status)); ASSERT_EQ(WEXITSTATUS(status), 0); unlink(launch_path); rmdir(tmpdir); PASS(); #else PASS(); #endif } /* ── Transport integration (listener only) ────────────────────── */ TEST(httpd_listen_ephemeral_port) { cbm_httpd_t *d = cbm_httpd_listen(0); ASSERT_NOT_NULL(d); int port = cbm_httpd_port(d); ASSERT_GT(port, 0); /* accept with a short timeout and no client → NULL, promptly */ cbm_http_conn_t *c = cbm_httpd_accept(d, 50); ASSERT_NULL(c); ASSERT_TRUE(cbm_httpd_close(d)); PASS(); } TEST(httpd_listen_port_collision_returns_null) { cbm_httpd_t *d1 = cbm_httpd_listen(0); ASSERT_NOT_NULL(d1); cbm_httpd_t *d2 = cbm_httpd_listen(cbm_httpd_port(d1)); ASSERT_NULL(d2); ASSERT_TRUE(cbm_httpd_close(d1)); PASS(); } TEST(httpd_close_refuses_while_connection_owns_listener) { cbm_httpd_t *listener = cbm_httpd_listen(0); ASSERT_NOT_NULL(listener); th_sock_t client = th_connect(cbm_httpd_port(listener)); ASSERT_TRUE(client != TH_SOCK_BAD); cbm_http_conn_t *connection = cbm_httpd_accept(listener, 1000); ASSERT_NOT_NULL(connection); ASSERT_FALSE(cbm_httpd_close(listener)); cbm_httpd_conn_close(connection); th_sock_close(client); ASSERT_TRUE(cbm_httpd_close(listener)); PASS(); } /* ── Full UI server integration ───────────────────────────────── */ typedef struct { cbm_http_server_t *srv; cbm_thread_t tid; } th_server_t; static void *th_server_thread(void *arg) { cbm_http_server_run((cbm_http_server_t *)arg); return NULL; } static int th_server_thread_start(cbm_thread_t *thread, cbm_http_server_t *server) { if (!cbm_http_server_schedule_run(server)) return -1; int rc = cbm_thread_create(thread, 0, th_server_thread, server); if (rc != 0 && !cbm_http_server_cancel_scheduled_run(server)) return -1; return rc; } static int th_server_start(th_server_t *ts) { ts->srv = cbm_http_server_new(0); if (!ts->srv) return -1; if (th_server_thread_start(&ts->tid, ts->srv) != 0) { (void)cbm_http_server_free(ts->srv); return -1; } return 0; } static int th_server_start_with_watcher(th_server_t *ts, cbm_watcher_t *watcher) { ts->srv = cbm_http_server_new(0); if (!ts->srv) return -1; cbm_http_server_set_watcher(ts->srv, watcher); if (th_server_thread_start(&ts->tid, ts->srv) != 0) { (void)cbm_http_server_free(ts->srv); return -1; } return 0; } typedef struct { atomic_int calls; char root_path[4096]; /* the route resolves into a 4 KB buffer; record it whole */ char project_name[256]; } th_ui_index_executor_t; typedef struct { atomic_int calls; atomic_int release; } th_ui_blocking_index_executor_t; static int th_ui_index_executor(void *opaque, const char *root_path, const char *project_name) { th_ui_index_executor_t *executor = opaque; snprintf(executor->root_path, sizeof(executor->root_path), "%s", root_path); snprintf(executor->project_name, sizeof(executor->project_name), "%s", project_name ? project_name : ""); atomic_fetch_add(&executor->calls, 1); return 0; } static int th_ui_blocking_index_executor(void *opaque, const char *root_path, const char *project_name) { (void)root_path; (void)project_name; th_ui_blocking_index_executor_t *executor = opaque; atomic_fetch_add(&executor->calls, 1); while (!atomic_load(&executor->release)) cbm_usleep(1000); return 0; } static bool th_wait_atomic_int(atomic_int *value, int expected, uint32_t timeout_ms) { uint64_t deadline = cbm_now_ms() + timeout_ms; while (cbm_now_ms() < deadline) { if (atomic_load(value) == expected) { return true; } cbm_usleep(1000); } return atomic_load(value) == expected; } static bool th_wait_http_server_activity(cbm_http_server_t *server, cbm_httpd_activity_t expected, uint32_t timeout_ms) { uint64_t deadline = cbm_now_ms() + timeout_ms; while (cbm_now_ms() < deadline) { if (cbm_http_server_activity_for_test(server) == expected) return true; cbm_usleep(1000); } return cbm_http_server_activity_for_test(server) == expected; } static bool th_wait_httpd_activity(cbm_httpd_t *listener, cbm_httpd_activity_t expected, uint32_t timeout_ms) { uint64_t deadline = cbm_now_ms() + timeout_ms; while (cbm_now_ms() < deadline) { if (cbm_httpd_activity_for_test(listener) == expected) return true; cbm_usleep(1000); } return cbm_httpd_activity_for_test(listener) == expected; } typedef struct { atomic_int begin_calls; atomic_int end_calls; bool allow; char begin_project[256]; char end_project[256]; } th_ui_mutation_guard_t; static void th_ui_mutation_guard_init(th_ui_mutation_guard_t *guard, bool allow) { memset(guard, 0, sizeof(*guard)); atomic_init(&guard->begin_calls, 0); atomic_init(&guard->end_calls, 0); guard->allow = allow; } static bool th_ui_mutation_begin(void *opaque, const char *project) { th_ui_mutation_guard_t *guard = opaque; snprintf(guard->begin_project, sizeof(guard->begin_project), "%s", project ? project : ""); atomic_fetch_add(&guard->begin_calls, 1); return guard->allow; } static void th_ui_mutation_end(void *opaque, const char *project) { th_ui_mutation_guard_t *guard = opaque; snprintf(guard->end_project, sizeof(guard->end_project), "%s", project ? project : ""); atomic_fetch_add(&guard->end_calls, 1); } static int th_server_start_with_mutation_guard(th_server_t *ts, cbm_watcher_t *watcher, th_ui_mutation_guard_t *guard) { ts->srv = cbm_http_server_new(0); if (!ts->srv) return -1; if (watcher) cbm_http_server_set_watcher(ts->srv, watcher); cbm_http_server_set_project_mutation_guard(ts->srv, th_ui_mutation_begin, th_ui_mutation_end, guard); if (th_server_thread_start(&ts->tid, ts->srv) != 0) { (void)cbm_http_server_free(ts->srv); return -1; } return 0; } static void th_server_stop(th_server_t *ts) { cbm_http_server_stop(ts->srv); (void)cbm_thread_join(&ts->tid); (void)cbm_http_server_free(ts->srv); } typedef struct { char tmpdir[256]; char cache_dir[512]; char root_dir[512]; char *saved_cache_dir; cbm_store_t *store; cbm_watcher_t *watcher; } ui_delete_fixture_t; static int ui_delete_fixture_init(ui_delete_fixture_t *fx) { memset(fx, 0, sizeof(*fx)); char *tmp = th_mktempdir("cbm_httpd_delete"); if (!tmp) return -1; snprintf(fx->tmpdir, sizeof(fx->tmpdir), "%s", tmp); snprintf(fx->cache_dir, sizeof(fx->cache_dir), "%s/cache", fx->tmpdir); snprintf(fx->root_dir, sizeof(fx->root_dir), "%s/root", fx->tmpdir); const char *saved = getenv("CBM_CACHE_DIR"); fx->saved_cache_dir = saved ? strdup(saved) : NULL; if (th_mkdir_p(fx->cache_dir) != 0 || th_mkdir_p(fx->root_dir) != 0) { return -1; } cbm_setenv("CBM_CACHE_DIR", fx->cache_dir, 1); fx->store = cbm_store_open_memory(); fx->watcher = cbm_watcher_new(fx->store, NULL, NULL); return fx->store && fx->watcher ? 0 : -1; } static void ui_delete_fixture_cleanup(ui_delete_fixture_t *fx) { if (fx->watcher) cbm_watcher_free(fx->watcher); if (fx->store) cbm_store_close(fx->store); if (fx->saved_cache_dir) { cbm_setenv("CBM_CACHE_DIR", fx->saved_cache_dir, 1); free(fx->saved_cache_dir); } else { cbm_unsetenv("CBM_CACHE_DIR"); } th_cleanup(fx->tmpdir); } static void ui_delete_db_path(const ui_delete_fixture_t *fx, const char *project, char *out, size_t outsz) { snprintf(out, outsz, "%s/%s.db", fx->cache_dir, project); } static int ui_delete_make_db_file(const ui_delete_fixture_t *fx, const char *project) { char path[1024]; ui_delete_db_path(fx, project, path, sizeof(path)); return th_write_file(path, "test db"); } static int ui_delete_make_sidecars(const ui_delete_fixture_t *fx, const char *project) { char path[1024]; ui_delete_db_path(fx, project, path, sizeof(path)); char wal[1040], shm[1040]; snprintf(wal, sizeof(wal), "%s-wal", path); snprintf(shm, sizeof(shm), "%s-shm", path); return th_write_file(wal, "wal") == 0 && th_write_file(shm, "shm") == 0 ? 0 : -1; } static int ui_delete_request(th_server_t *ts, const char *target, char *resp, size_t respsz) { char req[512]; snprintf(req, sizeof(req), "DELETE %s HTTP/1.1\r\n\r\n", target); return th_http(cbm_http_server_port(ts->srv), req, resp, respsz); } static int ui_adr_post_request(th_server_t *ts, const char *project, const char *content, char *resp, size_t respsz) { char body[2048]; int body_len = snprintf(body, sizeof(body), "{\"project\":\"%s\",\"content\":\"%s\"}", project, content); if (body_len < 0 || (size_t)body_len >= sizeof(body)) return 0; char req[2304]; int req_len = snprintf(req, sizeof(req), "POST /api/adr HTTP/1.1\r\n" "Content-Type: application/json\r\n" "Content-Length: %d\r\n\r\n%s", body_len, body); if (req_len < 0 || (size_t)req_len >= sizeof(req)) return 0; return th_http(cbm_http_server_port(ts->srv), req, resp, respsz); } static int ui_adr_get_request(th_server_t *ts, const char *project, char *resp, size_t respsz) { char req[512]; int req_len = snprintf(req, sizeof(req), "GET /api/adr?project=%s HTTP/1.1\r\n\r\n", project); if (req_len < 0 || (size_t)req_len >= sizeof(req)) return 0; return th_http(cbm_http_server_port(ts->srv), req, resp, respsz); } static int ui_adr_seed(const ui_delete_fixture_t *fx, const char *project, const char *content) { char db_path[1024]; ui_delete_db_path(fx, project, db_path, sizeof(db_path)); cbm_store_t *store = cbm_store_open_path(db_path); if (!store) return CBM_STORE_ERR; int rc = cbm_store_adr_store(store, project, content); cbm_store_close(store); return rc; } static bool ui_adr_equals(const ui_delete_fixture_t *fx, const char *project, const char *expected) { char db_path[1024]; ui_delete_db_path(fx, project, db_path, sizeof(db_path)); cbm_store_t *store = cbm_store_open_path_query(db_path); if (!store) return false; cbm_adr_t adr = {0}; int rc = cbm_store_adr_get(store, project, &adr); bool equal = rc == CBM_STORE_OK && adr.content && strcmp(adr.content, expected) == 0; if (rc == CBM_STORE_OK) cbm_store_adr_free(&adr); cbm_store_close(store); return equal; } TEST(ui_server_readiness_proof_is_exact_and_generation_bound) { static const char challenge[] = "202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f"; static const char expected[] = "62215de7bddcea7e2c4047ff6bb94f8d18262fc8b3f3648134bb7d44158ff84d"; uint8_t secret[CBM_SHA256_DIGEST_LEN]; for (size_t index = 0; index < sizeof(secret); index++) { secret[index] = (uint8_t)index; } th_server_t without_secret; ASSERT_EQ(th_server_start(&without_secret), 0); char request[512]; ASSERT_GT(snprintf(request, sizeof(request), "GET /__cbm/ui-readiness?challenge=%s HTTP/1.1\r\n\r\n", challenge), 0); char response[4096]; int response_length = th_http(cbm_http_server_port(without_secret.srv), request, response, sizeof(response)); int missing_secret_status = response_length > 0 ? th_status(response) : -1; th_server_stop(&without_secret); th_server_t server = {0}; server.srv = cbm_http_server_new(0); ASSERT_NOT_NULL(server.srv); cbm_http_server_set_readiness_secret(server.srv, secret); ASSERT_EQ(th_server_thread_start(&server.tid, server.srv), 0); int port = cbm_http_server_port(server.srv); response_length = th_http(port, request, response, sizeof(response)); char *body = response_length > 0 ? strstr(response, "\r\n\r\n") : NULL; body = body ? body + 4 : NULL; bool exact_proof = response_length > 0 && th_status(response) == 200 && body && strcmp(body, expected) == 0 && strstr(response, "Content-Type: text/plain; charset=utf-8") != NULL && strstr(response, "Cache-Control: no-store") != NULL; ASSERT_GT(snprintf(request, sizeof(request), "GET /__cbm/ui-readiness?challenge=" "202122232425262728292A2b2c2d2e2f303132333435363738393a3b3c3d3e3f " "HTTP/1.1\r\n\r\n"), 0); response_length = th_http(port, request, response, sizeof(response)); int uppercase_status = response_length > 0 ? th_status(response) : -1; ASSERT_GT(snprintf(request, sizeof(request), "GET /__cbm/ui-readiness?challenge=%s&extra=1 HTTP/1.1\r\n\r\n", challenge), 0); response_length = th_http(port, request, response, sizeof(response)); int extra_parameter_status = response_length > 0 ? th_status(response) : -1; response_length = th_http(port, "GET /__cbm/ui-readiness HTTP/1.1\r\n\r\n", response, sizeof(response)); int missing_challenge_status = response_length > 0 ? th_status(response) : -1; th_server_stop(&server); ASSERT_EQ(missing_secret_status, 503); ASSERT_TRUE(exact_proof); ASSERT_EQ(uppercase_status, 400); ASSERT_EQ(extra_parameter_status, 400); ASSERT_EQ(missing_challenge_status, 400); PASS(); } TEST(ui_server_unknown_path_404) { th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); int port = cbm_http_server_port(ts.srv); char resp[4096]; int n = th_http(port, "GET /definitely/not/here HTTP/1.1\r\n\r\n", resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 404); /* every response is explicit-length + close */ ASSERT_NOT_NULL(strstr(resp, "Connection: close")); ASSERT_NOT_NULL(strstr(resp, "Content-Length:")); th_server_stop(&ts); PASS(); } /* Security regression: process IDs are not cancellation capabilities. The UI * must never expose an endpoint that accepts an arbitrary PID and reaches an * OS process-termination API (the former Windows path accepted every PID but * self). Daemon-owned jobs are cancelled by opaque, owner-bound subscription * handles instead, so this legacy route must be completely absent. */ TEST(ui_server_process_kill_route_is_unavailable) { th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); int port = cbm_http_server_port(ts.srv); static const char body[] = "{\"pid\":2147483646}"; char request[512]; snprintf(request, sizeof(request), "POST /api/process-kill HTTP/1.1\r\n" "Content-Type: application/json\r\n" "Content-Length: %zu\r\n\r\n%s", strlen(body), body); char resp[4096]; int n = th_http(port, request, resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 404); ASSERT_NULL(strstr(resp, "\"killed\"")); th_server_stop(&ts); PASS(); } TEST(ui_server_process_measurements_declare_platform_semantics) { th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); char resp[16384]; int n = th_http(cbm_http_server_port(ts.srv), "GET /api/processes HTTP/1.1\r\n\r\n", resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 200); #ifdef _WIN32 ASSERT_NOT_NULL(strstr(resp, "\"cpu_unit\":\"seconds\"")); ASSERT_NOT_NULL(strstr(resp, "\"memory_kind\":\"working_set\"")); ASSERT_NOT_NULL(strstr(resp, "\"self_memory_kind\":\"working_set\"")); #else ASSERT_NOT_NULL(strstr(resp, "\"cpu_unit\":\"percent\"")); ASSERT_NOT_NULL(strstr(resp, "\"memory_kind\":\"resident\"")); ASSERT_NOT_NULL(strstr(resp, "\"self_memory_kind\":\"peak_resident\"")); #endif ASSERT_NOT_NULL(strstr(resp, "\"self_memory_available\":")); ASSERT_NOT_NULL(strstr(resp, "\"self_pid\":")); th_server_stop(&ts); PASS(); } TEST(ui_server_routes_indexing_through_joinable_daemon_executor) { char *root = th_mktempdir("cbm_httpd_daemon_index"); ASSERT_NOT_NULL(root); /* The executor receives the root in resolved form (the string the route * checked), which on macOS differs from the /tmp spelling. */ char resolved_root[4096]; ASSERT_TRUE(cbm_canonical_path(root, resolved_root, sizeof(resolved_root))); th_ui_index_executor_t executor = {0}; atomic_init(&executor.calls, 0); th_server_t ts; ts.srv = cbm_http_server_new(0); ASSERT_NOT_NULL(ts.srv); cbm_http_server_set_index_executor(ts.srv, th_ui_index_executor, &executor); ASSERT_EQ(th_server_thread_start(&ts.tid, ts.srv), 0); char body[1024]; snprintf(body, sizeof(body), "{\"root_path\":\"%s\",\"project_name\":\"ui-project\"}", root); char request[1400]; snprintf(request, sizeof(request), "POST /api/index HTTP/1.1\r\nContent-Type: application/json\r\n" "Content-Length: %zu\r\n\r\n%s", strlen(body), body); char response[4096]; int response_length = th_http(cbm_http_server_port(ts.srv), request, response, sizeof(response)); bool called = th_wait_atomic_int(&executor.calls, 1, 2000); th_server_stop(&ts); ASSERT_GT(response_length, 0); ASSERT_EQ(th_status(response), 202); ASSERT_TRUE(called); ASSERT_STR_EQ(executor.root_path, resolved_root); ASSERT_STR_EQ(executor.project_name, "ui-project"); th_cleanup(root); PASS(); } TEST(ui_server_free_never_joins_active_index_worker) { char *root = th_mktempdir("cbm_httpd_active_index"); ASSERT_NOT_NULL(root); th_ui_blocking_index_executor_t executor = {0}; atomic_init(&executor.calls, 0); atomic_init(&executor.release, 0); th_server_t ts; ts.srv = cbm_http_server_new(0); ASSERT_NOT_NULL(ts.srv); cbm_http_server_set_index_executor(ts.srv, th_ui_blocking_index_executor, &executor); ASSERT_EQ(th_server_thread_start(&ts.tid, ts.srv), 0); char body[1024]; snprintf(body, sizeof(body), "{\"root_path\":\"%s\",\"project_name\":\"blocked\"}", root); char request[1400]; snprintf(request, sizeof(request), "POST /api/index HTTP/1.1\r\nContent-Type: application/json\r\n" "Content-Length: %zu\r\n\r\n%s", strlen(body), body); char response[4096]; ASSERT_GT(th_http(cbm_http_server_port(ts.srv), request, response, sizeof(response)), 0); ASSERT_EQ(th_status(response), 202); ASSERT_TRUE(th_wait_atomic_int(&executor.calls, 1, 2000)); cbm_http_server_stop(ts.srv); ASSERT_EQ(cbm_thread_join(&ts.tid), 0); uint64_t started = cbm_now_ms(); ASSERT_FALSE(cbm_http_server_free(ts.srv)); ASSERT_LT(cbm_now_ms() - started, 1000); atomic_store(&executor.release, 1); bool freed = false; uint64_t deadline = cbm_now_ms() + 2000; while (!freed && cbm_now_ms() < deadline) { freed = cbm_http_server_free(ts.srv); if (!freed) cbm_usleep(1000); } ASSERT_TRUE(freed); th_cleanup(root); PASS(); } /* ── Frontend log file (POST/GET /api/ui-log) ─────────────────── */ static int ui_log_read_file(const char *path, char *out, size_t outsz) { FILE *f = cbm_fopen(path, "rb"); if (!f) return -1; size_t n = fread(out, 1, outsz - 1, f); fclose(f); out[n] = '\0'; return (int)n; } static int ui_log_count_char(const char *s, char ch) { int n = 0; for (; *s; s++) { if (*s == ch) n++; } return n; } static int ui_log_post(int port, const char *body, char *resp, size_t respsz) { size_t cap = strlen(body) + 512; char *req = malloc(cap); if (!req) return 0; snprintf(req, cap, "POST /api/ui-log HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n" "Content-Type: application/json\r\nContent-Length: %zu\r\n\r\n%s", port, strlen(body), body); int n = th_http(port, req, resp, respsz); free(req); return n; } static int activity_interrupt_rows(unsigned kind, void *database, void *statement, void *unused) { (void)kind; (void)unused; const char *sql = sqlite3_sql(statement); if (sql && (strstr(sql, "SELECT id,ts,level,source,message") || strstr(sql, "SELECT id,payload FROM agent_events"))) sqlite3_interrupt(database); return 0; } TEST(activity_reply_memory_core_ownership) { sqlite3 *db = cbm_activity_open(":memory:"); ASSERT_NOT_NULL(db); size_t before_blocks = cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER); size_t before_bytes = cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER); char *reply = cbm_activity_logs(db, -1, 10, 0); ASSERT_NOT_NULL(reply); ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER), before_blocks + 1); cbm_free(CBM_MEM_CLASS_OTHER, reply); reply = NULL; ASSERT_EQ(cbm_activity_ingest(db, "{", &reply), 400); ASSERT_NULL(reply); ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER), before_blocks); reply = NULL; ASSERT_EQ(cbm_activity_ingest(db, "{\"project\":\"memory-test\",\"events\":[]}", &reply), 200); ASSERT_NOT_NULL(reply); ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER), before_blocks + 1); cbm_free(CBM_MEM_CLASS_OTHER, reply); reply = cbm_activity_agents(db, "memory-test", 0, 10); ASSERT_NOT_NULL(reply); ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER), before_blocks + 1); cbm_free(CBM_MEM_CLASS_OTHER, reply); ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER), before_blocks); ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER), before_bytes); sqlite3_close(db); PASS(); } TEST(activity_preserves_severity_and_reports_interrupted_reads) { sqlite3 *db = cbm_activity_open(":memory:"); ASSERT_NOT_NULL(db); ASSERT_TRUE(cbm_activity_log( db, "{\"level\":\"info\",\"msg\":\"read.file\",\"path\":\"notes/level=error.md\"}")); ASSERT_TRUE(cbm_activity_log(db, "level=info msg=read.file path=notes/level=error.md")); char *json = cbm_activity_logs(db, -1, 10, 2); ASSERT_NOT_NULL(json); ASSERT_NOT_NULL(strstr(json, "\"total\":0")); cbm_free(CBM_MEM_CLASS_OTHER, json); ASSERT_EQ(sqlite3_trace_v2(db, SQLITE_TRACE_STMT, activity_interrupt_rows, db), SQLITE_OK); ASSERT_NULL(cbm_activity_logs(db, -1, 10, 0)); ASSERT_NULL(cbm_activity_agents(db, "test", 0, 10)); sqlite3_trace_v2(db, 0, NULL, NULL); sqlite3_close(db); PASS(); } TEST(activity_pruned_start_events_do_not_resurrect_on_retry) { sqlite3 *db = cbm_activity_open(":memory:"); ASSERT_NOT_NULL(db); const char *batch = "{\"project\":\"fixture-a\",\"events\":[{\"run\":\"r1\",\"agent\":\"fixture\",\"tool\":" "\"Read\",\"seq\":1,\"ts\":1000,\"phase\":\"start\"}]}"; char *reply = NULL; ASSERT_EQ(cbm_activity_ingest(db, batch, &reply), 200); cbm_free(CBM_MEM_CLASS_OTHER, reply); ASSERT_EQ( sqlite3_exec( db, "WITH RECURSIVE n(v) AS (SELECT 1 UNION ALL SELECT v+1 FROM n WHERE v<10001) INSERT " "INTO agent_events(project,run,seq,payload) SELECT 'fixture-b','filler',v,'{}' FROM n", NULL, NULL, NULL), SQLITE_OK); ASSERT_EQ(cbm_activity_ingest(db, "{\"project\":\"fixture-b\",\"events\":[]}", &reply), 200); cbm_free(CBM_MEM_CLASS_OTHER, reply); ASSERT_EQ(cbm_activity_ingest(db, batch, &reply), 200); ASSERT_NOT_NULL(strstr(reply, "\"accepted\":0")); ASSERT_NOT_NULL(strstr(reply, "\"duplicates\":1")); cbm_free(CBM_MEM_CLASS_OTHER, reply); reply = cbm_activity_agents(db, "fixture-a", 0, 10); ASSERT_NOT_NULL(strstr(reply, "\"events\":[]")); ASSERT_NOT_NULL(strstr(reply, "\"truncated\":true")); cbm_free(CBM_MEM_CLASS_OTHER, reply); sqlite3_close(db); PASS(); } TEST(activity_migration_preserves_v1_logs_and_rejects_future_schema) { char *dir = th_mktempdir("cbm_activity_migration"); ASSERT_NOT_NULL(dir); char path[1024]; snprintf(path, sizeof(path), "%s/activity.db", dir); sqlite3 *db = NULL; ASSERT_EQ(sqlite3_open(path, &db), SQLITE_OK); ASSERT_EQ( sqlite3_exec(db, "CREATE TABLE daemon_logs(id INTEGER PRIMARY KEY AUTOINCREMENT,ts TEXT NOT " "NULL,level INTEGER NOT NULL,source TEXT NOT NULL,message TEXT NOT NULL);" "INSERT INTO daemon_logs(ts,level,source,message) " "VALUES('2026-09-09T00:00:00Z',3,'migration fixture','preserved " "failure');PRAGMA user_version=1", NULL, NULL, NULL), SQLITE_OK); sqlite3_close(db); db = cbm_activity_open(path); ASSERT_NOT_NULL(db); char *json = cbm_activity_logs(db, -1, 10, 2); ASSERT_NOT_NULL(json); ASSERT_NOT_NULL(strstr(json, "preserved failure")); ASSERT_NOT_NULL(strstr(json, "\"total\":1")); cbm_free(CBM_MEM_CLASS_OTHER, json); const char *ui = "{\"session\":\"migration-fixture\",\"seq\":1,\"level\":\"error\",\"source\":" "\"frontend\",\"message\":\"retry-safe\"}"; ASSERT_TRUE(cbm_activity_log(db, ui)); ASSERT_TRUE(cbm_activity_log(db, ui)); json = cbm_activity_logs(db, -1, 10, 2); ASSERT_NOT_NULL(strstr(json, "\"total\":2")); cbm_free(CBM_MEM_CLASS_OTHER, json); ASSERT_EQ(sqlite3_exec(db, "PRAGMA user_version=99", NULL, NULL, NULL), SQLITE_OK); sqlite3_close(db); ASSERT_NULL(cbm_activity_open(path)); th_cleanup(dir); PASS(); } TEST(activity_log_project_scope_preserves_unknown_v2_history_and_filters_before_limit) { char *dir = th_mktempdir("cbm_activity_project_scope"); ASSERT_NOT_NULL(dir); char path[1024]; snprintf(path, sizeof(path), "%s/activity.db", dir); sqlite3 *db = NULL; ASSERT_EQ(sqlite3_open(path, &db), SQLITE_OK); ASSERT_EQ( sqlite3_exec( db, "CREATE TABLE daemon_logs(id INTEGER PRIMARY KEY AUTOINCREMENT,ts TEXT NOT NULL," "level INTEGER NOT NULL,source TEXT NOT NULL,message TEXT NOT NULL,event_key TEXT);" "INSERT INTO daemon_logs VALUES(1,'2026-09-09',3,'old-worker'," "'legacy project=fixture-a path=repo-a/broken.c',NULL);PRAGMA user_version=2", NULL, NULL, NULL), SQLITE_OK); sqlite3_close(db); db = cbm_activity_open(path); ASSERT_NOT_NULL(db); ASSERT_TRUE(cbm_activity_log(db, "{\"level\":\"error\",\"source\":\"fixture\"," "\"project\":\"fixture-a\",\"message\":\"first-a\"}")); ASSERT_TRUE( cbm_activity_log(db, "level=error msg=index.worker project=fixture-a record=last-a")); ASSERT_TRUE(cbm_activity_log( db, "{\"level\":\"error\",\"source\":\"unknown\"," "\"message\":\" project=fixture-a \",\"page\":\"/?project=fixture-a\"}")); ASSERT_TRUE(cbm_activity_log(db, "level=error msg=unknown record=project=fixture-a")); ASSERT_EQ( sqlite3_exec(db, "WITH RECURSIVE n(v) AS (SELECT 1 UNION ALL SELECT v+1 FROM n WHERE v<600) " "INSERT INTO daemon_logs(ts,level,source,message,project) " "SELECT '2026-09-09',3,'fixture-b','unrelated flood','fixture-b' FROM n", NULL, NULL, NULL), SQLITE_OK); char *page = cbm_activity_logs_scoped(db, -1, 1, 2, "fixture-a", false); ASSERT_NOT_NULL(page); ASSERT_NOT_NULL(strstr(page, "last-a")); ASSERT_NOT_NULL(strstr(page, "\"total\":2")); ASSERT_NOT_NULL(strstr(page, "\"scope\":\"project\"")); ASSERT_NULL(strstr(page, "unrelated flood")); ASSERT_NULL(strstr(page, "legacy")); cbm_free(CBM_MEM_CLASS_OTHER, page); page = cbm_activity_logs_scoped(db, 0, 1, 2, "fixture-a", false); ASSERT_NOT_NULL(strstr(page, "first-a")); ASSERT_NOT_NULL(strstr(page, "\"cursor\":2")); ASSERT_NOT_NULL(strstr(page, "\"has_more\":true")); cbm_free(CBM_MEM_CLASS_OTHER, page); sqlite3_close(db); db = cbm_activity_open(path); ASSERT_NOT_NULL(db); page = cbm_activity_logs_scoped(db, 2, 1, 2, "fixture-a", false); ASSERT_NOT_NULL(strstr(page, "last-a")); ASSERT_NOT_NULL(strstr(page, "\"cursor\":3")); ASSERT_NOT_NULL(strstr(page, "\"has_more\":false")); cbm_free(CBM_MEM_CLASS_OTHER, page); page = cbm_activity_logs_scoped(db, -1, 10, 2, NULL, true); ASSERT_NOT_NULL(strstr(page, "legacy project=fixture-a")); ASSERT_NOT_NULL(strstr(page, "\"total\":3")); ASSERT_NOT_NULL(strstr(page, "\"project\":null")); ASSERT_NOT_NULL(strstr(page, "\"scope\":\"unattributed\"")); cbm_free(CBM_MEM_CLASS_OTHER, page); page = cbm_activity_logs(db, -1, 1, 2); ASSERT_NOT_NULL(strstr(page, "\"total\":605")); ASSERT_NOT_NULL(strstr(page, "\"scope\":\"daemon\"")); cbm_free(CBM_MEM_CLASS_OTHER, page); sqlite3_close(db); th_cleanup(dir); PASS(); } TEST(ui_log_scopes_use_explicit_entry_ownership_and_survive_server_restart) { ui_delete_fixture_t fx; ASSERT_EQ(ui_delete_fixture_init(&fx), 0); th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); int port = cbm_http_server_port(ts.srv); const char *body = "{\"project\":\"fixture-a\",\"page\":\"/?project=wrong\"," "\"session\":\"project-scope-fixture\",\"entries\":[" "{\"seq\":1,\"level\":\"error\",\"message\":\"owned-a\"}," "{\"seq\":2,\"level\":\"error\",\"project\":\"fixture-b\",\"message\":\"owned-b\"}," "{\"seq\":3,\"level\":\"error\",\"project\":null,\"message\":\"unknown-owner\"}]}"; char response[8192]; ASSERT_GT(ui_log_post(port, body, response, sizeof(response)), 0); ASSERT_EQ(th_status(response), 200); th_server_stop(&ts); ASSERT_EQ(th_server_start(&ts), 0); port = cbm_http_server_port(ts.srv); ASSERT_GT(th_http(port, "GET /api/logs?project=fixture-a&lines=1&min_level=error HTTP/1.1\r\n\r\n", response, sizeof(response)), 0); ASSERT_EQ(th_status(response), 200); ASSERT_NOT_NULL(strstr(response, "owned-a")); ASSERT_NULL(strstr(response, "owned-b")); ASSERT_NULL(strstr(response, "unknown-owner")); ASSERT_NOT_NULL(strstr(response, "\"total\":1")); ASSERT_GT(th_http(port, "GET /api/logs?project=fixture-b&lines=1 HTTP/1.1\r\n\r\n", response, sizeof(response)), 0); ASSERT_NOT_NULL(strstr(response, "owned-b")); ASSERT_GT(th_http(port, "GET /api/logs?scope=unattributed&lines=10 HTTP/1.1\r\n\r\n", response, sizeof(response)), 0); ASSERT_NOT_NULL(strstr(response, "unknown-owner")); ASSERT_NULL(strstr(response, "owned-a")); ASSERT_NULL(strstr(response, "owned-b")); ASSERT_GT(th_http(port, "GET /api/logs?project= HTTP/1.1\r\n\r\n", response, sizeof(response)), 0); ASSERT_EQ(th_status(response), 400); ASSERT_GT(th_http(port, "GET /api/logs?project=fixture-a&scope=unattributed HTTP/1.1\r\n\r\n", response, sizeof(response)), 0); ASSERT_EQ(th_status(response), 400); th_server_stop(&ts); ui_delete_fixture_cleanup(&fx); PASS(); } TEST(activity_log_search_and_severity_apply_before_limit_and_survive_reopen) { char *dir = th_mktempdir("cbm_log_filter_fixture"); ASSERT_NOT_NULL(dir); char path[1024]; snprintf(path, sizeof(path), "%s/activity.db", dir); sqlite3 *db = cbm_activity_open(path); ASSERT_NOT_NULL(db); ASSERT_EQ( sqlite3_exec(db, "WITH RECURSIVE n(v) AS (SELECT 1 UNION ALL SELECT v+1 FROM n WHERE v<49) " "INSERT INTO daemon_logs(ts,level,source,message,project) " "SELECT '2026-09-09',3,'parser','src/10%_broken.c','fixture-a' FROM n;" "WITH RECURSIVE n(v) AS (SELECT 1 UNION ALL SELECT v+1 FROM n WHERE v<600) " "INSERT INTO daemon_logs(ts,level,source,message,project) " "SELECT '2026-09-09',1,'http','routine','fixture-a' FROM n", NULL, NULL, NULL), SQLITE_OK); char *json = cbm_activity_logs(db, -1, 200, 0); ASSERT_NOT_NULL(json); ASSERT_NULL(strstr(json, "10%_broken")); cbm_free(CBM_MEM_CLASS_OTHER, json); json = cbm_activity_logs_filtered(db, -1, 200, 3, "fixture-a", false, "10%_"); ASSERT_NOT_NULL(json); ASSERT_NOT_NULL(strstr(json, "\"total\":49")); ASSERT_NOT_NULL(strstr(json, "\"query\":\"10%_\"")); ASSERT_NOT_NULL(strstr(json, "10%_broken.c")); ASSERT_NULL(strstr(json, "routine")); cbm_free(CBM_MEM_CLASS_OTHER, json); sqlite3_close(db); db = cbm_activity_open(path); ASSERT_NOT_NULL(db); json = cbm_activity_logs_filtered(db, 0, 1, 3, "fixture-a", false, "BROKEN"); ASSERT_NOT_NULL(json); ASSERT_NOT_NULL(strstr(json, "\"cursor\":1")); ASSERT_NOT_NULL(strstr(json, "\"has_more\":true")); ASSERT_NOT_NULL(strstr(json, "\"total\":49")); cbm_free(CBM_MEM_CLASS_OTHER, json); json = cbm_activity_logs_filtered(db, 1, 1, 3, "fixture-a", false, "BROKEN"); ASSERT_NOT_NULL(strstr(json, "\"cursor\":2")); cbm_free(CBM_MEM_CLASS_OTHER, json); json = cbm_activity_logs_filtered(db, -1, 200, 3, "fixture-b", false, "10%_"); ASSERT_NOT_NULL(strstr(json, "\"total\":0")); cbm_free(CBM_MEM_CLASS_OTHER, json); sqlite3_close(db); th_cleanup(dir); PASS(); } TEST(ui_log_retention_prevents_expired_retry_and_preserves_full_json) { char *dir = th_mktempdir("cbm_ui_retention_fixture"); ASSERT_NOT_NULL(dir); char path[1024]; snprintf(path, sizeof(path), "%s/activity.db", dir); sqlite3 *db = cbm_activity_open(path); ASSERT_NOT_NULL(db); char message[12000]; memset(message, 'x', sizeof(message)); const char *prefix = "{\"received\":\"2026-01-01T00:00:00Z\",\"page\":\"/\"," "\"session\":\"retention-fixture\",\"seq\":1,\"level\":\"info\",\"message\":\""; memcpy(message, prefix, strlen(prefix)); memcpy(message + sizeof(message) - 3, "\"}", 3); ASSERT_EQ(cbm_activity_ui_log(db, message, false), 1); char *json = cbm_activity_ui_logs(db, 200, path, "", NULL); ASSERT_NOT_NULL(json); yyjson_doc *doc = yyjson_read(json, strlen(json), 0); ASSERT_NOT_NULL(doc); const char *line = yyjson_get_str(yyjson_arr_get(yyjson_obj_get(yyjson_doc_get_root(doc), "lines"), 0)); ASSERT_NOT_NULL(line); ASSERT_EQ(strlen(line), strlen(message)); yyjson_doc *full = yyjson_read(line, strlen(line), 0); ASSERT_NOT_NULL(full); yyjson_doc_free(full); yyjson_doc_free(doc); cbm_free(CBM_MEM_CLASS_OTHER, json); ASSERT_EQ( sqlite3_exec(db, "WITH RECURSIVE n(v) AS (SELECT 1 UNION ALL SELECT v+1 FROM n WHERE v<3000) " "INSERT INTO daemon_logs(ts,level,source,message) SELECT " "'2026-09-09',1,'fixture','flood' FROM n", NULL, NULL, NULL), SQLITE_OK); ASSERT_TRUE(cbm_activity_log(db, "level=info msg=retention.trigger")); ASSERT_EQ(cbm_activity_ui_log(db, message, false), 0); sqlite3_close(db); db = cbm_activity_open(path); ASSERT_NOT_NULL(db); ASSERT_EQ(cbm_activity_ui_log(db, message, false), 0); json = cbm_activity_ui_logs(db, 200, path, "", NULL); ASSERT_NOT_NULL(strstr(json, "\"total\":0")); cbm_free(CBM_MEM_CLASS_OTHER, json); sqlite3_close(db); th_cleanup(dir); PASS(); } TEST(ui_log_journal_is_canonical_for_info_retry_and_restart) { ui_delete_fixture_t fx; ASSERT_EQ(ui_delete_fixture_init(&fx), 0); th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); int port = cbm_http_server_port(ts.srv); char response[16384]; const char *body = "{\"page\":\"/\",\"session\":\"canonical-fixture\",\"entries\":[" "{\"seq\":1,\"level\":\"info\",\"source\":\"console\",\"project\":\"fixture-a\"," "\"message\":\"ordinary retained 10%_detail\"}]}"; ASSERT_GT(ui_log_post(port, body, response, sizeof(response)), 0); ASSERT_EQ(th_status(response), 200); ASSERT_NOT_NULL(strstr(response, "\"accepted\":1")); ASSERT_GT(ui_log_post(port, body, response, sizeof(response)), 0); ASSERT_NOT_NULL(strstr(response, "\"accepted\":0")); ASSERT_NOT_NULL(strstr(response, "\"duplicates\":1")); char path[1024]; snprintf(path, sizeof(path), "%s/logs/ui.log", fx.cache_dir); char content[4096]; ASSERT_GT(ui_log_read_file(path, content, sizeof(content)), 0); ASSERT_EQ(ui_log_count_char(content, '\n'), 1); FILE *file = cbm_fopen(path, "wb"); ASSERT_NOT_NULL(file); fputs("unrelated export mutation\n", file); fclose(file); th_server_stop(&ts); ASSERT_EQ(th_server_start(&ts), 0); port = cbm_http_server_port(ts.srv); ASSERT_GT(th_http(port, "GET /api/ui-log HTTP/1.1\r\n\r\n", response, sizeof(response)), 0); ASSERT_EQ(th_status(response), 200); ASSERT_NOT_NULL(strstr(response, "ordinary retained 10%_detail")); ASSERT_NOT_NULL(strstr(response, "\"source\":\"sqlite\"")); ASSERT_NOT_NULL(strstr(response, "activity.db")); ASSERT_NOT_NULL(strstr(response, "\"total\":1")); ASSERT_NULL(strstr(response, "unrelated export mutation")); ASSERT_GT(th_http(port, "GET /api/logs?project=fixture-a&min_level=info&q=10%25_ HTTP/1.1\r\n\r\n", response, sizeof(response)), 0); ASSERT_EQ(th_status(response), 200); ASSERT_NOT_NULL(strstr(response, "ordinary retained 10%_detail")); ASSERT_NOT_NULL(strstr(response, "\"total\":1")); ASSERT_GT(th_http(port, "GET /api/logs?q= HTTP/1.1\r\n\r\n", response, sizeof(response)), 0); ASSERT_EQ(th_status(response), 400); ASSERT_GT(ui_log_post(port, body, response, sizeof(response)), 0); ASSERT_NOT_NULL(strstr(response, "\"duplicates\":1")); th_server_stop(&ts); ui_delete_fixture_cleanup(&fx); PASS(); } TEST(ui_log_legacy_import_is_once_and_preserves_unknown_provenance_and_time) { ui_delete_fixture_t fx; ASSERT_EQ(ui_delete_fixture_init(&fx), 0); char path[1024]; snprintf(path, sizeof(path), "%s/logs", fx.cache_dir); ASSERT_TRUE(cbm_mkdir_p(path, 0700)); snprintf(path, sizeof(path), "%s/logs/ui.log", fx.cache_dir); FILE *file = cbm_fopen(path, "wb"); ASSERT_NOT_NULL(file); fputs("{\"received\":\"2026-01-01T00:00:00Z\",\"page\":\"/?project=wrong\"," "\"session\":\"\",\"level\":\"info\",\"source\":\"old-ui\"," "\"project\":\"not-current\",\"message\":\"legacy info fixture\"}\n", file); fclose(file); th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); int port = cbm_http_server_port(ts.srv); char response[16384]; ASSERT_GT(th_http(port, "GET /api/ui-log HTTP/1.1\r\n\r\n", response, sizeof(response)), 0); ASSERT_EQ(th_status(response), 200); ASSERT_NOT_NULL(strstr(response, "legacy info fixture")); ASSERT_NOT_NULL(strstr(response, "\"total\":1")); th_server_stop(&ts); ASSERT_EQ(th_server_start(&ts), 0); port = cbm_http_server_port(ts.srv); ASSERT_GT(th_http(port, "GET /api/ui-log HTTP/1.1\r\n\r\n", response, sizeof(response)), 0); ASSERT_NOT_NULL(strstr(response, "\"total\":1")); ASSERT_GT(th_http(port, "GET /api/logs?scope=unattributed&q=legacy HTTP/1.1\r\n\r\n", response, sizeof(response)), 0); ASSERT_NOT_NULL(strstr(response, "\"project\":null")); ASSERT_NOT_NULL(strstr(response, "\"ts\":\"2026-01-01T00:00:00Z\"")); ASSERT_GT(th_http(port, "GET /api/logs?project=not-current&q=legacy HTTP/1.1\r\n\r\n", response, sizeof(response)), 0); ASSERT_NOT_NULL(strstr(response, "\"total\":0")); th_server_stop(&ts); ui_delete_fixture_cleanup(&fx); PASS(); } TEST(activity_journal_reopen_cursor_dedup_and_project_isolation) { char *dir = th_mktempdir("cbm_activity"); ASSERT_NOT_NULL(dir); char path[1024]; snprintf(path, sizeof(path), "%s/activity.db", dir); sqlite3 *db = cbm_activity_open(path); ASSERT_NOT_NULL(db); const char *batch = "{\"project\":\"demo\",\"events\":[" "{\"ts\":1000,\"agent\":\"test\",\"run\":\"run\",\"seq\":2,\"phase\":" "\"end\",\"tool\":\"Read\",\"response\":\"never-store-this\"}," "{\"ts\":999,\"agent\":\"test\",\"run\":\"run\",\"seq\":1,\"phase\":" "\"end\",\"tool\":\"Read\"}]}"; char *reply = NULL; ASSERT_EQ(cbm_activity_ingest(db, batch, &reply), 200); ASSERT_NOT_NULL(strstr(reply, "\"accepted\":2")); cbm_free(CBM_MEM_CLASS_OTHER, reply); ASSERT_EQ(cbm_activity_ingest(db, batch, &reply), 200); ASSERT_NOT_NULL(strstr(reply, "\"duplicates\":2")); cbm_free(CBM_MEM_CLASS_OTHER, reply); char *page = cbm_activity_agents(db, "demo", 0, 1); ASSERT_NOT_NULL(page); ASSERT_NULL(strstr(page, "never-store-this")); ASSERT_NOT_NULL(strstr(page, "\"has_more\":true")); yyjson_doc *parsed = yyjson_read(page, strlen(page), 0); int64_t cursor = yyjson_get_sint(yyjson_obj_get(yyjson_doc_get_root(parsed), "cursor")); yyjson_doc_free(parsed); cbm_free(CBM_MEM_CLASS_OTHER, page); sqlite3_close(db); db = cbm_activity_open(path); ASSERT_NOT_NULL(db); page = cbm_activity_agents(db, "demo", cursor, 10); ASSERT_NOT_NULL(strstr(page, "\"seq\":1")); ASSERT_NULL(strstr(page, "\"seq\":2")); cbm_free(CBM_MEM_CLASS_OTHER, page); page = cbm_activity_agents(db, "other", 0, 10); ASSERT_NOT_NULL(strstr(page, "\"events\":[]")); cbm_free(CBM_MEM_CLASS_OTHER, page); ASSERT_EQ(cbm_activity_ingest(db, "{\"events\":[{\"run\":\"bad\"}]}", &reply), 400); sqlite3_close(db); th_cleanup(dir); PASS(); } TEST(activity_errors_survive_routine_log_retention_and_reopen) { char *dir = th_mktempdir("cbm_activity_logs"); ASSERT_NOT_NULL(dir); char path[1024]; snprintf(path, sizeof(path), "%s/activity.db", dir); sqlite3 *db = cbm_activity_open(path); ASSERT_NOT_NULL(db); ASSERT_TRUE(cbm_activity_log(db, "level=error msg=index.parse.failed path=src/broken.c")); for (int i = 0; i < 3010; i++) ASSERT_TRUE(cbm_activity_log(db, "level=info msg=http.request")); sqlite3 *reader = cbm_activity_open(path); ASSERT_NOT_NULL(reader); char *page = cbm_activity_logs(reader, -1, 10, 2); ASSERT_NOT_NULL(strstr(page, "index.parse.failed")); ASSERT_NOT_NULL(strstr(page, "\"level\":\"error\"")); ASSERT_NOT_NULL(strstr(page, "\"total\":1")); ASSERT_NULL(strstr(page, "http.request")); cbm_free(CBM_MEM_CLASS_OTHER, page); sqlite3_close(reader); sqlite3_close(db); db = cbm_activity_open(path); ASSERT_NOT_NULL(db); page = cbm_activity_logs(db, -1, 10, 0); ASSERT_NOT_NULL(strstr(page, "\"total\":3001")); cbm_free(CBM_MEM_CLASS_OTHER, page); sqlite3_close(db); th_cleanup(dir); PASS(); } TEST(ui_agent_events_and_errors_use_daemon_port_and_survive_restart) { ui_delete_fixture_t fx; ASSERT_EQ(ui_delete_fixture_init(&fx), 0); th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); const char *body = "{\"project\":\"demo\",\"events\":[{\"ts\":1000,\"agent\":\"test " "fixture\",\"run\":\"restart-fixture\",\"seq\":1,\"phase\":\"end\",\"tool\":\"Read\"}]}"; char request[2048], resp[16384]; snprintf(request, sizeof(request), "POST /api/agent-events HTTP/1.1\r\nContent-Type: application/json\r\nContent-Length: " "%zu\r\n\r\n%s", strlen(body), body); ASSERT_GT(th_http(cbm_http_server_port(ts.srv), request, resp, sizeof(resp)), 0); ASSERT_EQ(th_status(resp), 200); ASSERT_NOT_NULL(strstr(resp, "\"accepted\":1")); cbm_ui_log_append("level=error msg=index.failure.fixture path=test-fixture/broken.c"); th_server_stop(&ts); ASSERT_EQ(th_server_start(&ts), 0); int port = cbm_http_server_port(ts.srv); ASSERT_GT( th_http(port, "GET /api/agent-events?project=demo HTTP/1.1\r\n\r\n", resp, sizeof(resp)), 0); ASSERT_EQ(th_status(resp), 200); ASSERT_NOT_NULL(strstr(resp, "restart-fixture")); ASSERT_GT(th_http(port, request, resp, sizeof(resp)), 0); ASSERT_NOT_NULL(strstr(resp, "\"duplicates\":1")); ASSERT_GT(th_http(port, "GET /api/logs?min_level=warn HTTP/1.1\r\n\r\n", resp, sizeof(resp)), 0); ASSERT_EQ(th_status(resp), 200); ASSERT_NOT_NULL(strstr(resp, "index.failure.fixture")); ASSERT_NOT_NULL(strstr(resp, "\"persistent\":true")); th_server_stop(&ts); ui_delete_fixture_cleanup(&fx); PASS(); } /* One post, two entries: both are durable in SQLite and mirrored as JSONL * for compatibility. Both readers use the journal, including ordinary logs. */ TEST(ui_log_post_writes_jsonl_and_tail_reads_it) { ui_delete_fixture_t fx; ASSERT_EQ(ui_delete_fixture_init(&fx), 0); th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); int port = cbm_http_server_port(ts.srv); const char *body = "{\"page\":\"/?project=demo\",\"session\":\"s1\",\"entries\":[" "{\"ts\":\"2026-09-08T10:00:00.000Z\",\"seq\":1,\"level\":\"error\",\"source\":\"rpc\"," "\"message\":\"get_code_snippet returned no source\",\"detail\":\"HTTP 200\"," "\"stack\":\"Error: x\\n at y\"}," "{\"ts\":\"2026-09-08T10:00:01.000Z\",\"seq\":2,\"level\":\"log\",\"source\":\"console\"," "\"message\":\"galaxy \\\"ready\\\"\"}]}"; char resp[16384]; ASSERT_TRUE(ui_log_post(port, body, resp, sizeof(resp)) > 0); ASSERT_EQ(th_status(resp), 200); ASSERT_NOT_NULL(strstr(resp, "\"accepted\":2")); ASSERT_NOT_NULL(strstr(resp, "\"dropped\":0")); ASSERT_NOT_NULL(strstr(resp, "\"file_error\":false")); ASSERT_NOT_NULL(strstr(resp, "/logs/ui.log\"")); char path[1024]; snprintf(path, sizeof(path), "%s/logs/ui.log", fx.cache_dir); char content[8192]; ASSERT_TRUE(ui_log_read_file(path, content, sizeof(content)) > 0); ASSERT_EQ(ui_log_count_char(content, '\n'), 2); ASSERT_TRUE(strncmp(content, "{\"received\":\"", 13) == 0); ASSERT_NOT_NULL(strstr(content, "\"page\":\"/?project=demo\",\"session\":\"s1\",\"seq\":1")); ASSERT_NOT_NULL(strstr(content, "\"level\":\"error\",\"source\":\"rpc\"," "\"message\":\"get_code_snippet returned no source\"," "\"detail\":\"HTTP 200\",\"stack\":\"Error: x\\n at y\"}")); ASSERT_NOT_NULL(strstr(content, "\"seq\":2,\"ts\":\"2026-09-08T10:00:01.000Z\",\"level\":\"log\"," "\"source\":\"console\",\"message\":\"galaxy \\\"ready\\\"\"}")); /* The tail, asked for one line: the newest, and it says it is partial. */ char req[256]; snprintf(req, sizeof(req), "GET /api/ui-log?lines=1 HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n", port); ASSERT_TRUE(th_http(port, req, resp, sizeof(resp)) > 0); ASSERT_EQ(th_status(resp), 200); ASSERT_NOT_NULL(strstr(resp, "\"partial\":true")); ASSERT_NOT_NULL(strstr(resp, "\"total\":2")); ASSERT_NOT_NULL(strstr(resp, "galaxy")); ASSERT_TRUE(strstr(resp, "get_code_snippet") == NULL); ASSERT_NOT_NULL(strstr(resp, "\"size_bytes\":")); ASSERT_TRUE(strstr(resp, "previous_path") == NULL); /* Asked for more than there is: both, complete. */ snprintf(req, sizeof(req), "GET /api/ui-log?lines=10 HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n", port); ASSERT_TRUE(th_http(port, req, resp, sizeof(resp)) > 0); ASSERT_EQ(th_status(resp), 200); ASSERT_NOT_NULL(strstr(resp, "\"partial\":false")); ASSERT_NOT_NULL(strstr(resp, "get_code_snippet")); ASSERT_NOT_NULL(strstr(resp, "galaxy")); /* The same journal carries both the error and the ordinary info line. */ snprintf(req, sizeof(req), "GET /api/logs?lines=50 HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n", port); ASSERT_TRUE(th_http(port, req, resp, sizeof(resp)) > 0); ASSERT_EQ(th_status(resp), 200); ASSERT_NOT_NULL(strstr(resp, "ui.error rpc: get_code_snippet returned no source")); ASSERT_NOT_NULL(strstr(resp, "ui.info console: galaxy")); th_server_stop(&ts); ui_delete_fixture_cleanup(&fx); PASS(); } /* Refused bodies never create the file: empty, not JSON, no entries[], and a * post above the 64 KiB cap. Entries that are not objects are counted as * dropped rather than refused, so one bad entry does not cost the rest. */ TEST(ui_log_post_refuses_bad_bodies) { ui_delete_fixture_t fx; ASSERT_EQ(ui_delete_fixture_init(&fx), 0); th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); int port = cbm_http_server_port(ts.srv); char resp[4096]; char req[512]; snprintf(req, sizeof(req), "POST /api/ui-log HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n" "Content-Type: application/json\r\nContent-Length: 0\r\n\r\n", port); ASSERT_TRUE(th_http(port, req, resp, sizeof(resp)) > 0); ASSERT_EQ(th_status(resp), 400); ASSERT_TRUE(ui_log_post(port, "not json", resp, sizeof(resp)) > 0); ASSERT_EQ(th_status(resp), 400); ASSERT_TRUE(ui_log_post(port, "{\"page\":\"/\"}", resp, sizeof(resp)) > 0); ASSERT_EQ(th_status(resp), 400); size_t big_len = 70000; char *big = malloc(big_len + 64); ASSERT_NOT_NULL(big); strcpy(big, "{\"entries\":[{\"message\":\""); size_t at = strlen(big); memset(big + at, 'a', big_len); strcpy(big + at + big_len, "\"}]}"); ASSERT_TRUE(ui_log_post(port, big, resp, sizeof(resp)) > 0); ASSERT_EQ(th_status(resp), 413); free(big); char path[1024]; snprintf(path, sizeof(path), "%s/logs/ui.log", fx.cache_dir); ASSERT_TRUE(!cbm_file_exists(path)); ASSERT_TRUE(ui_log_post(port, "{\"entries\":[42,{\"message\":\"kept\"}]}", resp, sizeof(resp)) > 0); ASSERT_EQ(th_status(resp), 200); ASSERT_NOT_NULL(strstr(resp, "\"accepted\":1,\"dropped\":1")); ASSERT_TRUE(cbm_file_exists(path)); th_server_stop(&ts); ui_delete_fixture_cleanup(&fx); PASS(); } /* The file rotates once to ui.log.1 when it has reached the configured size * at the time of a post; the tail names the previous file. */ TEST(ui_log_rotates_at_configured_size) { ui_delete_fixture_t fx; ASSERT_EQ(ui_delete_fixture_init(&fx), 0); cbm_setenv("CBM_UI_LOG_ROTATE_BYTES", "300", 1); th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); int port = cbm_http_server_port(ts.srv); char resp[8192]; const char *names[] = {"first", "second", "third"}; for (int i = 0; i < 3; i++) { char body[1024]; char pad[320]; memset(pad, 'p', sizeof(pad) - 1); pad[sizeof(pad) - 1] = '\0'; snprintf(body, sizeof(body), "{\"entries\":[{\"level\":\"info\",\"message\":\"%s %s\"}]}", names[i], pad); ASSERT_TRUE(ui_log_post(port, body, resp, sizeof(resp)) > 0); ASSERT_EQ(th_status(resp), 200); } char path[1024], previous[1040]; snprintf(path, sizeof(path), "%s/logs/ui.log", fx.cache_dir); snprintf(previous, sizeof(previous), "%s.1", path); char content[4096]; ASSERT_TRUE(ui_log_read_file(path, content, sizeof(content)) > 0); ASSERT_NOT_NULL(strstr(content, "\"message\":\"third ")); ASSERT_TRUE(strstr(content, "second") == NULL); ASSERT_TRUE(ui_log_read_file(previous, content, sizeof(content)) > 0); ASSERT_NOT_NULL(strstr(content, "\"message\":\"second ")); ASSERT_TRUE(strstr(content, "first") == NULL); char req[256]; snprintf(req, sizeof(req), "GET /api/ui-log HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n", port); ASSERT_TRUE(th_http(port, req, resp, sizeof(resp)) > 0); ASSERT_EQ(th_status(resp), 200); ASSERT_NOT_NULL(strstr(resp, "\"previous_path\":\"")); ASSERT_NOT_NULL(strstr(resp, "ui.log.1\"")); ASSERT_NOT_NULL(strstr(resp, "third ")); th_server_stop(&ts); cbm_unsetenv("CBM_UI_LOG_ROTATE_BYTES"); ui_delete_fixture_cleanup(&fx); PASS(); } /* The UI connections have a fixed allowlist: one optional local inference service and the * model host plus its verified download CDN. Browser consent controls model * downloads; script sources remain local, with no unrestricted CDN or eval * allowance. */ TEST(ui_csp_connect_src_has_explicit_download_hosts) { const char *csp = CBM_UI_CSP_VALUE; ASSERT_NOT_NULL(strstr(csp, "connect-src 'self' http://127.0.0.1:4141 " "https://huggingface.co https://us.aws.cdn.hf.co;")); ASSERT_NOT_NULL(strstr(csp, "script-src 'self' 'wasm-unsafe-eval';")); ASSERT_NOT_NULL(strstr(csp, "worker-src 'self' blob:;")); ASSERT_TRUE(strstr(csp, "*") == NULL); ASSERT_TRUE(strstr(csp, "'unsafe-eval'") == NULL); ASSERT_TRUE(strstr(csp, "ws://") == NULL); ASSERT_TRUE(strstr(csp, "wss://") == NULL); int hosts = 0; const char *p = csp; while ((p = strstr(p, "http://")) != NULL) { ASSERT_TRUE(strncmp(p, "http://127.0.0.1:4141", 21) == 0); hosts++; p += 7; } ASSERT_EQ(hosts, 1); int secure_hosts = 0; p = csp; while ((p = strstr(p, "https://")) != NULL) { secure_hosts++; p += 8; } ASSERT_EQ(secure_hosts, 2); ASSERT_NOT_NULL(strstr(csp, "frame-ancestors 'none'")); ASSERT_NOT_NULL(strstr(csp, "object-src 'none'")); PASS(); } /* ── POST /api/index carries the resolved root ────────────────── */ /* The route resolves the request's root_path, runs the workspace check on the * resolved form, and the job must then carry exactly that string: what was * checked is what the daemon receives. These cases drive the real endpoint * with the recording executor and read every place the stored root surfaces * (the executor argument, the 202 reply, /api/index-status). */ typedef struct { bool was_set; char value[4096]; } th_allowed_root_saved_t; /* Set (or, with NULL, clear) CBM_ALLOWED_ROOT and remember the previous value * so the test can put it back before asserting — a failed assertion returns * early, and a boundary left behind would leak into every later test. */ static void th_allowed_root_set(th_allowed_root_saved_t *saved, const char *value) { const char *current = getenv("CBM_ALLOWED_ROOT"); saved->was_set = current != NULL; snprintf(saved->value, sizeof(saved->value), "%s", current ? current : ""); if (value) { cbm_setenv("CBM_ALLOWED_ROOT", value, 1); } else { cbm_unsetenv("CBM_ALLOWED_ROOT"); } } static void th_allowed_root_restore(const th_allowed_root_saved_t *saved) { if (saved->was_set) { cbm_setenv("CBM_ALLOWED_ROOT", saved->value, 1); } else { cbm_unsetenv("CBM_ALLOWED_ROOT"); } } typedef struct { th_ui_index_executor_t executor; int status; /* HTTP status of the POST, -1 when no reply came */ bool executor_called; /* only waited for after a 202 */ char response[8192]; /* the raw POST reply */ char index_status[16384]; /* GET /api/index-status, read after the POST */ } th_ui_index_probe_t; /* Response body: everything after the header block, or "" when absent. */ static const char *th_response_body(const char *response) { const char *body = strstr(response, "\r\n\r\n"); return body ? body + 4 : ""; } /* Boot a server with the recording executor, POST one /api/index request, * wait for the worker only when the route accepted the job, read * /api/index-status and stop the server. Everything observed lands in the * probe so the caller asserts after teardown. */ static void th_ui_index_probe(th_ui_index_probe_t *probe, const char *root_path, const char *project_name) { memset(probe, 0, sizeof(*probe)); atomic_init(&probe->executor.calls, 0); probe->status = -1; th_server_t ts; ts.srv = cbm_http_server_new(0); if (!ts.srv) { return; } cbm_http_server_set_index_executor(ts.srv, th_ui_index_executor, &probe->executor); if (th_server_thread_start(&ts.tid, ts.srv) != 0) { (void)cbm_http_server_free(ts.srv); return; } int port = cbm_http_server_port(ts.srv); char body[8192]; snprintf(body, sizeof(body), "{\"root_path\":\"%s\",\"project_name\":\"%s\"}", root_path, project_name); char request[8192 + 256]; snprintf(request, sizeof(request), "POST /api/index HTTP/1.1\r\nContent-Type: application/json\r\n" "Content-Length: %zu\r\n\r\n%s", strlen(body), body); if (th_http(port, request, probe->response, sizeof(probe->response)) > 0) { probe->status = th_status(probe->response); } if (probe->status == 202) { probe->executor_called = th_wait_atomic_int(&probe->executor.calls, 1, 2000); } char status_request[128]; snprintf(status_request, sizeof(status_request), "GET /api/index-status HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n", port); (void)th_http(port, status_request, probe->index_status, sizeof(probe->index_status)); th_server_stop(&ts); } TEST(ui_server_index_start_refuses_root_outside_allowed) { char *tmp = th_mktempdir("cbm_httpd_index_bound"); ASSERT_NOT_NULL(tmp); char base[256]; snprintf(base, sizeof(base), "%s", tmp); char allowed[512]; char outside[512]; snprintf(allowed, sizeof(allowed), "%s/allowed", base); snprintf(outside, sizeof(outside), "%s/outside", base); ASSERT_EQ(th_mkdir_p(allowed), 0); ASSERT_EQ(th_mkdir_p(outside), 0); /* The boundary is declared in resolved form, as allow-root records it. */ char canonical_base[4096]; ASSERT_TRUE(cbm_canonical_path(base, canonical_base, sizeof(canonical_base))); char allowed_root[4096 + 16]; snprintf(allowed_root, sizeof(allowed_root), "%s/allowed", canonical_base); /* Spelled under the allowed root, resolving beside it: the verdict has to * come from the resolved form. */ char request_path[600]; snprintf(request_path, sizeof(request_path), "%s/allowed/../outside", base); th_allowed_root_saved_t saved; th_allowed_root_set(&saved, allowed_root); th_ui_index_probe_t probe; th_ui_index_probe(&probe, request_path, "bound"); th_allowed_root_restore(&saved); th_cleanup(base); ASSERT_EQ(probe.status, 403); ASSERT_NOT_NULL(strstr(probe.response, "outside the allowed root")); ASSERT_EQ(atomic_load(&probe.executor.calls), 0); ASSERT_STR_EQ(th_response_body(probe.index_status), "[]"); PASS(); } TEST(ui_server_index_start_stores_resolved_root) { char *tmp = th_mktempdir("cbm_httpd_index_root"); ASSERT_NOT_NULL(tmp); char base[256]; snprintf(base, sizeof(base), "%s", tmp); char inner[512]; snprintf(inner, sizeof(inner), "%s/allowed/inner", base); ASSERT_EQ(th_mkdir_p(inner), 0); /* The resolved form of the allowed directory itself, as the server * computes it. A "/allowed" suffix on a resolved parent is not that form * on Windows, where the resolved path uses backslashes throughout. */ char allowed_dir[512]; snprintf(allowed_dir, sizeof(allowed_dir), "%s/allowed", base); char allowed_root[4096]; ASSERT_TRUE(cbm_canonical_path(allowed_dir, allowed_root, sizeof(allowed_root))); /* The request names the allowed root through a ".." segment, so its * spelling is not the resolved form (on macOS /tmp is itself a link to * /private/tmp, so the prefix differs as well). */ char request_path[600]; snprintf(request_path, sizeof(request_path), "%s/allowed/inner/..", base); ASSERT_TRUE(strcmp(request_path, allowed_root) != 0); th_allowed_root_saved_t saved; th_allowed_root_set(&saved, allowed_root); th_ui_index_probe_t probe; th_ui_index_probe(&probe, request_path, "resolved"); th_allowed_root_restore(&saved); th_cleanup(base); ASSERT_EQ(probe.status, 202); ASSERT_TRUE(probe.executor_called); /* The daemon receives the string the check ran on, not the request * spelling. */ ASSERT_STR_EQ(probe.executor.root_path, allowed_root); ASSERT_STR_EQ(probe.executor.project_name, "resolved"); /* The 202 reply and the status listing report that same stored root, * JSON-escaped (a Windows path carries backslashes). */ char escaped[8192]; cbm_json_escape(escaped, (int)sizeof(escaped), allowed_root); char expected[8192 + 64]; snprintf(expected, sizeof(expected), "\"path\":\"%s\"}", escaped); ASSERT_NOT_NULL(strstr(probe.response, expected)); snprintf(expected, sizeof(expected), "\"path\":\"%s\",\"error\"", escaped); ASSERT_NOT_NULL(strstr(probe.index_status, expected)); PASS(); } TEST(ui_server_index_start_long_root_not_truncated) { char *tmp = th_mktempdir("cbm_httpd_index_long"); ASSERT_NOT_NULL(tmp); char base[256]; snprintf(base, sizeof(base), "%s", tmp); /* A root well past 1 KB, the capacity the job used to hold, and well * inside the 4 KB the route resolves; 200-byte components keep every * component under NAME_MAX. */ char component[201]; memset(component, 'd', sizeof(component) - 1); component[sizeof(component) - 1] = '\0'; char deep[2048]; int n = snprintf(deep, sizeof(deep), "%s", base); while (n < 1400) { n += snprintf(deep + n, sizeof(deep) - (size_t)n, "/%s", component); } bool created = th_mkdir_p(deep) == 0; char canonical[4096]; bool resolved = created && cbm_canonical_path(deep, canonical, sizeof(canonical)) != 0; /* No boundary here: the question is the length the job keeps. */ th_allowed_root_saved_t saved; th_allowed_root_set(&saved, NULL); th_ui_index_probe_t probe; th_ui_index_probe(&probe, deep, "long"); th_allowed_root_restore(&saved); th_cleanup(base); if (probe.status == 202) { /* The platform resolved the path, so the worker has to receive it * whole — the same string the route checked. */ ASSERT_TRUE(resolved); ASSERT_TRUE(probe.executor_called); ASSERT_TRUE(strlen(probe.executor.root_path) > 1023); ASSERT_STR_EQ(probe.executor.root_path, canonical); } else { /* The platform cannot resolve a path this long (macOS: PATH_MAX is * 1024, so mkdir and stat refuse it before the route sees it); the * route answers 400 and keeps nothing. */ ASSERT_EQ(probe.status, 400); ASSERT_EQ(atomic_load(&probe.executor.calls), 0); ASSERT_STR_EQ(th_response_body(probe.index_status), "[]"); } #if defined(PATH_MAX) && PATH_MAX >= 2048 /* Linux resolves it (PATH_MAX 4096), and that is the one platform where a * cut-off could reach the worker; the accepted branch is the only honest * outcome there, so the refusal branch may not stand in for it. */ ASSERT_TRUE(created); ASSERT_EQ(probe.status, 202); #endif PASS(); } /* The 202 reply names the stored root; a quote or backslash in it has to * travel escaped or the reply is not JSON. */ TEST(ui_server_index_start_reply_escapes_path) { char *tmp = th_mktempdir("cbm_httpd_index_quote"); ASSERT_NOT_NULL(tmp); char base[256]; snprintf(base, sizeof(base), "%s", tmp); char root[512]; #ifdef _WIN32 /* A quote is not a legal file-name byte on Windows; the separators of the * resolved path already need escaping there. */ snprintf(root, sizeof(root), "%s\\plain", base); #else snprintf(root, sizeof(root), "%s/q\"uote", base); #endif ASSERT_EQ(th_mkdir_p(root), 0); char canonical[4096]; ASSERT_TRUE(cbm_canonical_path(root, canonical, sizeof(canonical))); /* The request body is JSON too, so the root goes in escaped. */ char request_path[1024]; cbm_json_escape(request_path, (int)sizeof(request_path), root); th_ui_index_probe_t probe; th_ui_index_probe(&probe, request_path, "quoted"); th_cleanup(base); bool parsed = false; int slot = -1; char reply_path[4096] = ""; const char *body = th_response_body(probe.response); yyjson_doc *doc = yyjson_read(body, strlen(body), 0); if (doc) { yyjson_val *obj = yyjson_doc_get_root(doc); yyjson_val *path = yyjson_obj_get(obj, "path"); yyjson_val *slot_val = yyjson_obj_get(obj, "slot"); parsed = yyjson_is_obj(obj); if (yyjson_is_str(path)) { snprintf(reply_path, sizeof(reply_path), "%s", yyjson_get_str(path)); } if (yyjson_is_int(slot_val)) { slot = yyjson_get_int(slot_val); } yyjson_doc_free(doc); } ASSERT_EQ(probe.status, 202); ASSERT_TRUE(probe.executor_called); ASSERT_STR_EQ(probe.executor.root_path, canonical); ASSERT_TRUE(parsed); ASSERT_STR_EQ(reply_path, canonical); ASSERT_TRUE(slot >= 0); PASS(); } TEST(ui_server_root_without_embedded_assets_is_not_found) { /* The frontend is linked into the image, so its availability is decided at * build time, not warmed at runtime: a binary built without --with-ui has * no index.html and never will. That is a permanent 404, NOT a retryable * 503 -- promising a retry for a condition that cannot change would make * every client poll forever. */ th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); char resp[4096]; int n = th_http(cbm_http_server_port(ts.srv), "GET / HTTP/1.1\r\n\r\n", resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 404); ASSERT_NOT_NULL(strstr(resp, "no frontend embedded")); ASSERT_NULL(strstr(resp, "Retry-After")); th_server_stop(&ts); PASS(); } TEST(ui_server_same_origin_request_is_allowed) { th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); int port = cbm_http_server_port(ts.srv); char req[512]; snprintf(req, sizeof(req), "OPTIONS /rpc HTTP/1.1\r\n" "Host: 127.0.0.1:%d\r\n" "Origin: http://127.0.0.1:%d\r\n\r\n", port, port); char resp[4096]; int n = th_http_raw(port, req, resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 204); char expected_origin[128]; snprintf(expected_origin, sizeof(expected_origin), "Access-Control-Allow-Origin: http://127.0.0.1:%d", port); ASSERT_NOT_NULL(strstr(resp, expected_origin)); th_server_stop(&ts); PASS(); } TEST(ui_server_rejects_foreign_and_null_origins) { th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); int port = cbm_http_server_port(ts.srv); char resp[4096]; char req[768]; snprintf(req, sizeof(req), "OPTIONS /rpc HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n" "Origin: http://evil.example.com\r\n\r\n", port); int n = th_http_raw(port, req, resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 403); ASSERT_NULL(strstr(resp, "Access-Control-Allow-Origin")); snprintf(req, sizeof(req), "GET / HTTP/1.1\r\nHost: localhost:%d\r\nOrigin: null\r\n\r\n", port); n = th_http_raw(port, req, resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 403); snprintf(req, sizeof(req), "GET / HTTP/1.1\r\nHost: localhost:%d\r\n" "Origin: http://127.0.0.1:%d\r\n\r\n", port, port); n = th_http_raw(port, req, resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 403); static const char body[] = "{\"project\":\"victim\",\"content\":\"poison\"}"; snprintf(req, sizeof(req), "POST /api/adr HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n" "Origin: http://evil.example.com\r\nContent-Type: text/plain\r\n" "Content-Length: %zu\r\n\r\n%s", port, strlen(body), body); n = th_http_raw(port, req, resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 403); th_server_stop(&ts); PASS(); } TEST(ui_server_mutations_require_json_content_type) { th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); int port = cbm_http_server_port(ts.srv); const char *body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\"," "\"params\":{\"name\":\"list_projects\",\"arguments\":{}}}"; char req[1024]; char resp[8192]; snprintf(req, sizeof(req), "POST /rpc HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n" "Content-Type: text/plain\r\n" "Content-Length: %zu\r\n\r\n%s", port, strlen(body), body); int n = th_http_raw(port, req, resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 415); snprintf(req, sizeof(req), "POST /rpc HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n" "Content-Length: %zu\r\n\r\n%s", port, strlen(body), body); n = th_http_raw(port, req, resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 415); snprintf(req, sizeof(req), "POST /rpc HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n" "Content-Type: application/json; charset=utf-8\r\n" "Content-Length: %zu\r\n\r\n%s", port, strlen(body), body); n = th_http_raw(port, req, resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 200); th_server_stop(&ts); PASS(); } TEST(ui_server_rpc_rejects_duplicate_adr_arguments) { th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); static const struct { const char *params; int expected_status; } cases[] = { {"{\"name\":\"manage_adr\"}", 200}, {"{\"name\":\"manage_adr\",\"arguments\":{\"mode\":\"update\"}," "\"arguments\":{\"mode\":\"get\"}}", 403}, {"{\"name\":\"manage_adr\",\"arguments\":{\"mode\":\"get\"}," "\"arguments\":{\"mode\":\"update\"}}", 403}, }; int statuses[sizeof(cases) / sizeof(cases[0])] = {0}; for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { char body[512], request[1024], response[8192]; snprintf(body, sizeof(body), "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":%s}", cases[i].params); snprintf(request, sizeof(request), "POST /rpc HTTP/1.1\r\nContent-Type: application/json\r\n" "Content-Length: %zu\r\n\r\n%s", strlen(body), body); int received = th_http(cbm_http_server_port(ts.srv), request, response, sizeof(response)); statuses[i] = received > 0 ? th_status(response) : 0; } th_server_stop(&ts); for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { ASSERT_EQ(statuses[i], cases[i].expected_status); } PASS(); } TEST(ui_server_rpc_allows_only_ui_read_tools) { th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); /* CBM Atlas reads the graph through /rpc: every read-only query tool must * pass the allowlist (a 403 on get_graph_schema is #1663). HTTP 200 means * "allowed and dispatched" — tool-level argument errors still ride inside * a 200 JSON-RPC envelope. */ static const char *allowed_tools[] = { "list_projects", "get_code_snippet", "get_graph_schema", "search_graph", "search_code", "trace_path", "trace_call_path", "get_architecture", "query_graph", "detect_changes", "check_index_coverage", "index_status", }; char req[1024]; char resp[8192]; int n = 0; for (size_t i = 0; i < sizeof(allowed_tools) / sizeof(allowed_tools[0]); i++) { char body[512]; snprintf(body, sizeof(body), "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\"," "\"params\":{\"name\":\"%s\",\"arguments\":{}}}", allowed_tools[i]); snprintf(req, sizeof(req), "POST /rpc HTTP/1.1\r\n" "Content-Type: application/json\r\n" "Content-Length: %zu\r\n\r\n%s", strlen(body), body); n = th_http(cbm_http_server_port(ts.srv), req, resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 200); ASSERT_NOT_NULL(strstr(resp, "\"jsonrpc\"")); } static const char *blocked_tools[] = {"delete_project", "ingest_traces", "index_repository"}; for (size_t i = 0; i < sizeof(blocked_tools) / sizeof(blocked_tools[0]); i++) { char blocked_body[512]; snprintf(blocked_body, sizeof(blocked_body), "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\"," "\"params\":{\"name\":\"%s\",\"arguments\":{}}}", blocked_tools[i]); snprintf(req, sizeof(req), "POST /rpc HTTP/1.1\r\nContent-Type: application/json\r\n" "Content-Length: %zu\r\n\r\n%s", strlen(blocked_body), blocked_body); n = th_http(cbm_http_server_port(ts.srv), req, resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 403); } /* manage_adr: the read modes pass, everything that writes is refused. */ static const struct { const char *arguments; int expected_status; } adr_cases[] = { {"{}", 200}, /* no mode → default "get" */ {"{\"mode\":\"get\"}", 200}, /* explicit read */ {"{\"mode\":\"sections\"}", 200}, /* header listing */ {"{\"mode\":\"update\"}", 403}, /* write */ {"{\"mode\":\"store\"}", 403}, /* legacy write alias */ {"{\"mode\":1}", 403}, /* non-string mode */ {"{\"mode\":\"get\",\"mode\":\"update\"}", 403}, /* smuggled duplicate */ }; for (size_t i = 0; i < sizeof(adr_cases) / sizeof(adr_cases[0]); i++) { char adr_body[512]; snprintf(adr_body, sizeof(adr_body), "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\"," "\"params\":{\"name\":\"manage_adr\",\"arguments\":%s}}", adr_cases[i].arguments); snprintf(req, sizeof(req), "POST /rpc HTTP/1.1\r\nContent-Type: application/json\r\n" "Content-Length: %zu\r\n\r\n%s", strlen(adr_body), adr_body); n = th_http(cbm_http_server_port(ts.srv), req, resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), adr_cases[i].expected_status); } const char *initialize = "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"initialize\"," "\"params\":{}}"; snprintf(req, sizeof(req), "POST /rpc HTTP/1.1\r\nContent-Type: application/json\r\n" "Content-Length: %zu\r\n\r\n%s", strlen(initialize), initialize); n = th_http(cbm_http_server_port(ts.srv), req, resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 403); const char *ambiguous = "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\"," "\"params\":{\"name\":\"list_projects\",\"name\":\"delete_project\"," "\"arguments\":{}}}"; snprintf(req, sizeof(req), "POST /rpc HTTP/1.1\r\nContent-Type: application/json\r\n" "Content-Length: %zu\r\n\r\n%s", strlen(ambiguous), ambiguous); n = th_http(cbm_http_server_port(ts.srv), req, resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 403); th_server_stop(&ts); PASS(); } TEST(ui_server_atlas_routes_are_wired) { th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); /* A wired route with a missing ?project answers 400; an unwired path * falls through to the static handler's 404. This pins the /api/flow * (singular) route, which once used a "?*" pattern that never matched * because req->path carries no query string. */ static const char *const routes[] = { "/api/tree", "/api/symbol", "/api/flows", "/api/flow", "/api/metrics", "/api/trace", "/api/scent", "/api/bridges", "/api/blast", "/api/handout", "/api/why"}; char req[256]; char resp[4096]; for (size_t i = 0; i < sizeof(routes) / sizeof(routes[0]); i++) { snprintf(req, sizeof(req), "GET %s HTTP/1.1\r\n\r\n", routes[i]); int n = th_http(cbm_http_server_port(ts.srv), req, resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 400); } th_server_stop(&ts); PASS(); } TEST(ui_server_retained_cache_memory_is_flushed_before_thread_exit) { cbm_atlas_metrics_cache_clear(); cbm_atlas_flows_cache_clear(); cbm_layout_regions_cache_clear(); ui_delete_fixture_t fixture; ASSERT_EQ(ui_delete_fixture_init(&fixture), 0); char path[1024]; ui_delete_db_path(&fixture, "http-memory", path, sizeof(path)); cbm_store_t *store = cbm_store_open_path(path); ASSERT_NOT_NULL(store); ASSERT_EQ(cbm_store_upsert_project(store, "http-memory", ""), CBM_STORE_OK); /* Settle the independent history cache on the caller before measuring the * HTTP worker. An empty source root does not retain history-scan buffers. */ char *warm = cbm_atlas_metrics_json(store, "http-memory"); ASSERT_NOT_NULL(warm); cbm_free(CBM_MEM_CLASS_OTHER, warm); cbm_atlas_metrics_cache_clear(); cbm_layout_regions_cache_clear(); cbm_store_close(store); void *sentinel = cbm_alloc(CBM_MEM_CLASS_OTHER, 64); ASSERT_NOT_NULL(sentinel); th_server_t server; server.srv = cbm_http_server_new(0); ASSERT_NOT_NULL(server.srv); size_t before_blocks = cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER); size_t before_bytes = cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER); ASSERT_EQ(th_server_thread_start(&server.tid, server.srv), 0); char response[16384]; int length = th_http(cbm_http_server_port(server.srv), "GET /api/metrics?project=http-memory HTTP/1.1\r\n\r\n", response, sizeof(response)); bool metrics_ok = length > 0 && th_status(response) == 200; /* Receiving a second reply proves the first request reached its flush * boundary. It avoids asserting the listener's transient idle state. */ length = th_http(cbm_http_server_port(server.srv), "GET /missing-memory-probe HTTP/1.1\r\n\r\n", response, sizeof(response)); bool fence_ok = length > 0 && th_status(response) == 404; size_t retained_blocks = cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER); cbm_http_server_stop(server.srv); int joined = cbm_thread_join(&server.tid); cbm_atlas_metrics_cache_clear(); cbm_atlas_flows_cache_clear(); cbm_layout_regions_cache_clear(); size_t after_blocks = cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER); size_t after_bytes = cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER); bool freed = cbm_http_server_free(server.srv); cbm_free(CBM_MEM_CLASS_OTHER, sentinel); ui_delete_fixture_cleanup(&fixture); ASSERT_TRUE(metrics_ok); ASSERT_TRUE(fence_ok); ASSERT_EQ(joined, 0); ASSERT_TRUE(freed); ASSERT_GT(retained_blocks, before_blocks); ASSERT_EQ(after_blocks, before_blocks); ASSERT_EQ(after_bytes, before_bytes); PASS(); } TEST(ui_server_oversized_body_rejected) { th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); char req[256]; snprintf(req, sizeof(req), "POST /rpc HTTP/1.1\r\nContent-Length: %d\r\n\r\n", CBM_HTTP_MAX_BODY + 1); char resp[4096]; int n = th_http(cbm_http_server_port(ts.srv), req, resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 413); th_server_stop(&ts); PASS(); } TEST(ui_server_encoded_slash_not_routed) { th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); char resp[4096]; int n = th_http(cbm_http_server_port(ts.srv), "GET /api%2Fbrowse?path=/tmp HTTP/1.1\r\n\r\n", resp, sizeof(resp)); ASSERT_GT(n, 0); /* must fall through to 404 — NOT the browse handler */ ASSERT_EQ(th_status(resp), 404); ASSERT_NULL(strstr(resp, "\"dirs\"")); th_server_stop(&ts); PASS(); } TEST(ui_server_nul_in_target_rejected) { th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); char resp[4096]; int n = th_http(cbm_http_server_port(ts.srv), "GET /a%00b HTTP/1.1\r\n\r\n", resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 400); th_server_stop(&ts); PASS(); } TEST(ui_server_browse_traversal_probe) { /* Percent-encoded traversal in the QUERY VALUE is decoded (that is the * documented contract) and then hits the same directory checks as any * other path. The server must answer with a well-formed JSON error or * listing — never crash, never echo raw unescaped input. */ th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); char resp[65536]; int n = th_http(cbm_http_server_port(ts.srv), "GET /api/browse?path=%2Ftmp%2F..%2F..%2Fprivate HTTP/1.1\r\n\r\n", resp, sizeof(resp)); ASSERT_GT(n, 0); int st = th_status(resp); ASSERT_TRUE(st == 200 || st == 400 || st == 403); const char *json = strstr(resp, "\r\n\r\n"); ASSERT_NOT_NULL(json); ASSERT_EQ(json[4], '{'); th_server_stop(&ts); PASS(); } TEST(ui_server_adr_mutation_guard_busy_preserves_existing_adr) { static const char *project = "ui-guard-adr-busy"; static const char *original = "original architecture"; ui_delete_fixture_t fx; ASSERT_EQ(ui_delete_fixture_init(&fx), 0); ASSERT_EQ(ui_adr_seed(&fx, project, original), CBM_STORE_OK); th_ui_mutation_guard_t guard; th_ui_mutation_guard_init(&guard, false); th_server_t ts; ASSERT_EQ(th_server_start_with_mutation_guard(&ts, NULL, &guard), 0); char resp[4096]; int n = ui_adr_post_request(&ts, project, "replacement architecture", resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 423); ASSERT_NOT_NULL(strstr(resp, "project is busy; retry after indexing")); ASSERT_EQ(atomic_load(&guard.begin_calls), 1); ASSERT_EQ(atomic_load(&guard.end_calls), 0); ASSERT_STR_EQ(guard.begin_project, project); /* A rejected mutation must leave the published DB queryable and unchanged. * GET is a query operation and therefore must not enter the mutation guard. */ n = ui_adr_get_request(&ts, project, resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 200); ASSERT_NOT_NULL(strstr(resp, "\"has_adr\":true")); ASSERT_NOT_NULL(strstr(resp, original)); ASSERT_EQ(atomic_load(&guard.begin_calls), 1); ASSERT_EQ(atomic_load(&guard.end_calls), 0); ASSERT_TRUE(ui_adr_equals(&fx, project, original)); th_server_stop(&ts); ui_delete_fixture_cleanup(&fx); PASS(); } TEST(ui_server_adr_mutation_guard_balances_success) { static const char *project = "ui-guard-adr-success"; static const char *content = "coordinated architecture"; ui_delete_fixture_t fx; ASSERT_EQ(ui_delete_fixture_init(&fx), 0); th_ui_mutation_guard_t guard; th_ui_mutation_guard_init(&guard, true); th_server_t ts; ASSERT_EQ(th_server_start_with_mutation_guard(&ts, NULL, &guard), 0); char resp[4096]; int n = ui_adr_post_request(&ts, project, content, resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 200); ASSERT_NOT_NULL(strstr(resp, "{\"saved\":true}")); ASSERT_EQ(atomic_load(&guard.begin_calls), 1); ASSERT_EQ(atomic_load(&guard.end_calls), 1); ASSERT_STR_EQ(guard.begin_project, project); ASSERT_STR_EQ(guard.end_project, project); ASSERT_TRUE(ui_adr_equals(&fx, project, content)); th_server_stop(&ts); ui_delete_fixture_cleanup(&fx); PASS(); } TEST(ui_server_delete_mutation_guard_busy_preserves_project) { static const char *project = "ui-guard-delete-busy"; ui_delete_fixture_t fx; ASSERT_EQ(ui_delete_fixture_init(&fx), 0); ASSERT_EQ(ui_delete_make_db_file(&fx, project), 0); ASSERT_EQ(ui_delete_make_sidecars(&fx, project), 0); cbm_watcher_watch(fx.watcher, project, fx.root_dir); ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 1); th_ui_mutation_guard_t guard; th_ui_mutation_guard_init(&guard, false); th_server_t ts; ASSERT_EQ(th_server_start_with_mutation_guard(&ts, fx.watcher, &guard), 0); char resp[4096]; int n = ui_delete_request(&ts, "/api/project?name=ui-guard-delete-busy", resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 423); ASSERT_NOT_NULL(strstr(resp, "project is busy; retry after indexing")); ASSERT_EQ(atomic_load(&guard.begin_calls), 1); ASSERT_EQ(atomic_load(&guard.end_calls), 0); ASSERT_STR_EQ(guard.begin_project, project); char db_path[1024], wal_path[1040], shm_path[1040]; ui_delete_db_path(&fx, project, db_path, sizeof(db_path)); snprintf(wal_path, sizeof(wal_path), "%s-wal", db_path); snprintf(shm_path, sizeof(shm_path), "%s-shm", db_path); ASSERT_TRUE(cbm_file_exists(db_path)); ASSERT_TRUE(cbm_file_exists(wal_path)); ASSERT_TRUE(cbm_file_exists(shm_path)); ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 1); th_server_stop(&ts); ui_delete_fixture_cleanup(&fx); PASS(); } TEST(ui_server_delete_mutation_guard_balances_success) { static const char *project = "ui-guard-delete-success"; ui_delete_fixture_t fx; ASSERT_EQ(ui_delete_fixture_init(&fx), 0); ASSERT_EQ(ui_delete_make_db_file(&fx, project), 0); cbm_watcher_watch(fx.watcher, project, fx.root_dir); ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 1); th_ui_mutation_guard_t guard; th_ui_mutation_guard_init(&guard, true); th_server_t ts; ASSERT_EQ(th_server_start_with_mutation_guard(&ts, fx.watcher, &guard), 0); char resp[4096]; int n = ui_delete_request(&ts, "/api/project?name=ui-guard-delete-success", resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 200); ASSERT_NOT_NULL(strstr(resp, "{\"deleted\":true}")); ASSERT_EQ(atomic_load(&guard.begin_calls), 1); ASSERT_EQ(atomic_load(&guard.end_calls), 1); ASSERT_STR_EQ(guard.begin_project, project); ASSERT_STR_EQ(guard.end_project, project); char db_path[1024]; ui_delete_db_path(&fx, project, db_path, sizeof(db_path)); ASSERT_FALSE(cbm_file_exists(db_path)); ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 0); th_server_stop(&ts); ui_delete_fixture_cleanup(&fx); PASS(); } TEST(ui_server_delete_project_unwatches_after_delete) { ui_delete_fixture_t fx; ASSERT_EQ(ui_delete_fixture_init(&fx), 0); ASSERT_EQ(ui_delete_make_db_file(&fx, "ui-delete-watch"), 0); ASSERT_EQ(ui_delete_make_sidecars(&fx, "ui-delete-watch"), 0); cbm_watcher_watch(fx.watcher, "ui-delete-watch", fx.root_dir); ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 1); th_server_t ts; ASSERT_EQ(th_server_start_with_watcher(&ts, fx.watcher), 0); char resp[4096]; int n = ui_delete_request(&ts, "/api/project?name=ui-delete-watch", resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 200); ASSERT_NOT_NULL(strstr(resp, "{\"deleted\":true}")); char db[1024], wal[1040], shm[1040]; ui_delete_db_path(&fx, "ui-delete-watch", db, sizeof(db)); snprintf(wal, sizeof(wal), "%s-wal", db); snprintf(shm, sizeof(shm), "%s-shm", db); ASSERT_FALSE(cbm_file_exists(db)); ASSERT_FALSE(cbm_file_exists(wal)); ASSERT_FALSE(cbm_file_exists(shm)); ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 0); th_server_stop(&ts); ui_delete_fixture_cleanup(&fx); PASS(); } TEST(ui_server_delete_project_unwatches_missing_db) { ui_delete_fixture_t fx; ASSERT_EQ(ui_delete_fixture_init(&fx), 0); cbm_watcher_watch(fx.watcher, "ui-delete-missing", fx.root_dir); ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 1); th_server_t ts; ASSERT_EQ(th_server_start_with_watcher(&ts, fx.watcher), 0); char resp[4096]; int n = ui_delete_request(&ts, "/api/project?name=ui-delete-missing", resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 404); ASSERT_NOT_NULL(strstr(resp, "{\"error\":\"project not found\"}")); ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 0); th_server_stop(&ts); ui_delete_fixture_cleanup(&fx); PASS(); } TEST(ui_server_delete_project_no_watcher_still_deletes) { ui_delete_fixture_t fx; ASSERT_EQ(ui_delete_fixture_init(&fx), 0); ASSERT_EQ(ui_delete_make_db_file(&fx, "ui-delete-no-watcher"), 0); th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); char resp[4096]; int n = ui_delete_request(&ts, "/api/project?name=ui-delete-no-watcher", resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 200); char db[1024]; ui_delete_db_path(&fx, "ui-delete-no-watcher", db, sizeof(db)); ASSERT_FALSE(cbm_file_exists(db)); th_server_stop(&ts); ui_delete_fixture_cleanup(&fx); PASS(); } TEST(ui_server_delete_project_missing_name_keeps_watch) { ui_delete_fixture_t fx; ASSERT_EQ(ui_delete_fixture_init(&fx), 0); cbm_watcher_watch(fx.watcher, "ui-delete-still-watched", fx.root_dir); ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 1); th_server_t ts; ASSERT_EQ(th_server_start_with_watcher(&ts, fx.watcher), 0); char resp[4096]; int n = ui_delete_request(&ts, "/api/project", resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 400); ASSERT_NOT_NULL(strstr(resp, "{\"error\":\"missing name\"}")); ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 1); th_server_stop(&ts); ui_delete_fixture_cleanup(&fx); PASS(); } TEST(ui_server_delete_project_invalid_name_keeps_watch) { ui_delete_fixture_t fx; ASSERT_EQ(ui_delete_fixture_init(&fx), 0); cbm_watcher_watch(fx.watcher, "bad/name", fx.root_dir); ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 1); th_server_t ts; ASSERT_EQ(th_server_start_with_watcher(&ts, fx.watcher), 0); char resp[4096]; int n = ui_delete_request(&ts, "/api/project?name=bad%2Fname", resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 404); ASSERT_NOT_NULL(strstr(resp, "{\"error\":\"project not found\"}")); ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 1); th_server_stop(&ts); ui_delete_fixture_cleanup(&fx); PASS(); } TEST(ui_server_delete_project_unlink_failure_keeps_watch) { ui_delete_fixture_t fx; ASSERT_EQ(ui_delete_fixture_init(&fx), 0); char db[1024]; ui_delete_db_path(&fx, "ui-delete-unlink-fails", db, sizeof(db)); ASSERT_EQ(th_mkdir_p(db), 0); cbm_watcher_watch(fx.watcher, "ui-delete-unlink-fails", fx.root_dir); ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 1); th_server_t ts; ASSERT_EQ(th_server_start_with_watcher(&ts, fx.watcher), 0); char resp[4096]; int n = ui_delete_request(&ts, "/api/project?name=ui-delete-unlink-fails", resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 500); ASSERT_NOT_NULL(strstr(resp, "{\"error\":\"failed to delete\"}")); ASSERT_TRUE(cbm_file_exists(db)); ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 1); th_server_stop(&ts); ui_delete_fixture_cleanup(&fx); PASS(); } static bool ui_health_matches(th_server_t *server, const char *project, const char *status, const char *indexed_at, bool registered, bool running) { char request[512], response[4096]; snprintf(request, sizeof(request), "GET /api/project-health?name=%s HTTP/1.1\r\n\r\n", project); int length = th_http(cbm_http_server_port(server->srv), request, response, sizeof(response)); if (length <= 0 || th_status(response) != 200) return false; const char *body = strstr(response, "\r\n\r\n"); if (!body) return false; body += 4; yyjson_doc *doc = yyjson_read(body, strlen(body), 0); if (!doc) return false; yyjson_val *root = yyjson_doc_get_root(doc); yyjson_val *actual_status = yyjson_obj_get(root, "status"); yyjson_val *timestamp = yyjson_obj_get(root, "indexed_at"); yyjson_val *watch = yyjson_obj_get(root, "watch_registered"); yyjson_val *loop = yyjson_obj_get(root, "watcher_running"); bool matches = yyjson_is_str(actual_status) && strcmp(yyjson_get_str(actual_status), status) == 0 && yyjson_is_bool(watch) && yyjson_get_bool(watch) == registered && yyjson_is_bool(loop) && yyjson_get_bool(loop) == running; matches = matches && (indexed_at ? yyjson_is_str(timestamp) && strcmp(yyjson_get_str(timestamp), indexed_at) == 0 : yyjson_is_null(timestamp)); if (strcmp(status, "healthy") == 0) { matches = matches && yyjson_is_int(yyjson_obj_get(root, "nodes")) && yyjson_get_int(yyjson_obj_get(root, "nodes")) == 0 && yyjson_is_int(yyjson_obj_get(root, "edges")) && yyjson_get_int(yyjson_obj_get(root, "edges")) == 0 && yyjson_get_int(yyjson_obj_get(root, "size_bytes")) > 0; } yyjson_doc_free(doc); return matches; } TEST(ui_server_project_health_timestamp_and_no_watcher) { ui_delete_fixture_t fx; ASSERT_EQ(ui_delete_fixture_init(&fx), 0); char db_path[1024]; ui_delete_db_path(&fx, "health-indexed", db_path, sizeof(db_path)); cbm_store_t *store = cbm_store_open_path(db_path); ASSERT_NOT_NULL(store); ASSERT_EQ(cbm_store_upsert_project(store, "health-indexed", fx.root_dir), CBM_STORE_OK); cbm_project_t project = {0}; ASSERT_EQ(cbm_store_get_project(store, "health-indexed", &project), CBM_STORE_OK); ASSERT_NOT_NULL(project.indexed_at); ASSERT_TRUE(project.indexed_at[0] != '\0'); cbm_store_close(store); /* This endpoint's corrupt branch means "cannot open". A text file is not * sufficient: the store's immutable fallback opens lazily and can return * a handle before discovering invalid contents. An existing directory * makes both open attempts fail, independently of file permissions. */ ui_delete_db_path(&fx, "health-unreadable", db_path, sizeof(db_path)); ASSERT_EQ(th_mkdir_p(db_path), 0); ASSERT_TRUE(cbm_file_exists(db_path)); ASSERT_NULL(cbm_store_open_path_query(db_path)); th_server_t server; ASSERT_EQ(th_server_start(&server), 0); bool healthy = ui_health_matches(&server, "health-indexed", "healthy", project.indexed_at, false, false); bool missing = ui_health_matches(&server, "health-missing", "missing", NULL, false, false); bool corrupt = ui_health_matches(&server, "health-unreadable", "corrupt", NULL, false, false); th_server_stop(&server); cbm_project_free_fields(&project); ui_delete_fixture_cleanup(&fx); ASSERT_TRUE(healthy); ASSERT_TRUE(missing); ASSERT_TRUE(corrupt); PASS(); } static void *ui_health_watcher_run(void *opaque) { (void)cbm_watcher_run(opaque, 10); return NULL; } TEST(ui_server_project_health_live_watcher_state) { ui_delete_fixture_t fx; ASSERT_EQ(ui_delete_fixture_init(&fx), 0); /* Missing root is owned by this fixture and prevents any Git subprocess. */ char missing_root[1024]; snprintf(missing_root, sizeof(missing_root), "%s/absent", fx.root_dir); ASSERT_TRUE(cbm_watcher_watch(fx.watcher, "health-watched", missing_root)); th_server_t server; ASSERT_EQ(th_server_start_with_watcher(&server, fx.watcher), 0); bool registered_idle = ui_health_matches(&server, "health-watched", "missing", NULL, true, false); bool unregistered_idle = ui_health_matches(&server, "health-other", "missing", NULL, false, false); cbm_thread_t thread; ASSERT_EQ(cbm_thread_create(&thread, 0, ui_health_watcher_run, fx.watcher), 0); uint64_t deadline = cbm_now_ms() + 5000; while (!cbm_watcher_project_status(fx.watcher, NULL).running && cbm_now_ms() < deadline) cbm_usleep(1000); bool registered_running = ui_health_matches(&server, "health-watched", "missing", NULL, true, true); bool unregistered_running = ui_health_matches(&server, "health-other", "missing", NULL, false, true); cbm_watcher_stop(fx.watcher); bool stopped = ui_health_matches(&server, "health-watched", "missing", NULL, true, false); int joined = cbm_thread_join(&thread); cbm_watcher_unwatch(fx.watcher, "health-watched"); bool removed = ui_health_matches(&server, "health-watched", "missing", NULL, false, false); th_server_stop(&server); ui_delete_fixture_cleanup(&fx); ASSERT_TRUE(registered_idle); ASSERT_TRUE(unregistered_idle); ASSERT_TRUE(registered_running); ASSERT_TRUE(unregistered_running); ASSERT_TRUE(stopped); ASSERT_EQ(joined, 0); ASSERT_TRUE(removed); PASS(); } TEST(ui_server_ui_config_detects_zh_accept_language) { th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); char resp[4096]; int n = th_http(cbm_http_server_port(ts.srv), "GET /api/ui-config HTTP/1.1\r\n" "Accept-Language: zh-CN,zh;q=0.9,en;q=0.8\r\n" "\r\n", resp, sizeof(resp)); ASSERT_TRUE(n > 0); ASSERT_EQ(th_status(resp), 200); ASSERT_NOT_NULL(strstr(resp, "\"lang\":\"zh\"")); th_server_stop(&ts); PASS(); } TEST(ui_server_ui_config_includes_serving_version_issue1820) { th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); char resp[4096]; int n = th_http(cbm_http_server_port(ts.srv), "GET /api/ui-config HTTP/1.1\r\n\r\n", resp, sizeof(resp)); ASSERT_TRUE(n > 0); ASSERT_EQ(th_status(resp), 200); char expected_version[128]; snprintf(expected_version, sizeof(expected_version), "\"version\":\"%s\"", CBM_VERSION); ASSERT_NOT_NULL(strstr(resp, expected_version)); th_server_stop(&ts); PASS(); } TEST(ui_server_ui_config_prefers_config_lang) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_httpd_cfg_XXXXXX"); char *td = cbm_mkdtemp(tmpdir); ASSERT_NOT_NULL(td); char *old_home = getenv("HOME") ? strdup(getenv("HOME")) : NULL; cbm_setenv("HOME", td, 1); char cache_dir[1024]; snprintf(cache_dir, sizeof(cache_dir), "%s", cbm_resolve_cache_dir()); cbm_config_t *cfg = cbm_config_open(cache_dir); ASSERT_NOT_NULL(cfg); ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_UI_LANG, "zh"), 0); cbm_config_close(cfg); th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); char resp[4096]; int n = th_http(cbm_http_server_port(ts.srv), "GET /api/ui-config HTTP/1.1\r\n" "Accept-Language: en-US,en;q=0.9\r\n" "\r\n", resp, sizeof(resp)); ASSERT_TRUE(n > 0); ASSERT_EQ(th_status(resp), 200); ASSERT_NOT_NULL(strstr(resp, "\"lang\":\"zh\"")); th_server_stop(&ts); if (old_home) { cbm_setenv("HOME", old_home, 1); free(old_home); } PASS(); } TEST(ui_server_slow_request_hits_deadline) { th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); /* Shorten the deadline so the test is fast */ cbm_http_server_set_recv_deadline_ms(ts.srv, 300); int port = cbm_http_server_port(ts.srv); th_sock_t s = th_connect(port); ASSERT_TRUE(s != TH_SOCK_BAD); ASSERT_EQ(th_send_all(s, "GET /api", 8), 0); /* partial request, then stall */ char resp[1024]; int n = th_recv_until_close(s, resp, sizeof(resp)); /* server must give up */ th_sock_close(s); /* Either a 408 or a bare close is acceptable — the loop must move on */ if (n > 0) { ASSERT_EQ(th_status(resp), 408); } /* …and the server must still answer the next request */ char resp2[4096]; int n2 = th_http(port, "GET /definitely/not/here HTTP/1.1\r\n\r\n", resp2, sizeof(resp2)); ASSERT_GT(n2, 0); ASSERT_EQ(th_status(resp2), 404); th_server_stop(&ts); PASS(); } TEST(ui_server_access_log_redacts_query) { httpd_log_buf[0] = '\0'; CBMLogLevel prev_level = cbm_log_get_level(); cbm_log_set_level(CBM_LOG_DEBUG); cbm_log_set_format(CBM_LOG_FORMAT_TEXT); cbm_log_set_sink_ex(httpd_capture_log, CBM_LOG_SINK_REPLACE); th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); char resp[4096]; int n = th_http(cbm_http_server_port(ts.srv), "GET /definitely/not/here?token=secret HTTP/1.1\r\n\r\n", resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 404); th_server_stop(&ts); cbm_log_set_sink(NULL); cbm_log_set_level(prev_level); ASSERT_NOT_NULL(strstr(httpd_log_buf, "msg=http.request")); ASSERT_NOT_NULL(strstr(httpd_log_buf, "component=graph_ui")); ASSERT_NOT_NULL(strstr(httpd_log_buf, "method=GET")); ASSERT_NOT_NULL(strstr(httpd_log_buf, "path=/definitely/not/here")); ASSERT_NOT_NULL(strstr(httpd_log_buf, "status=404")); ASSERT_NULL(strstr(httpd_log_buf, "token")); ASSERT_NULL(strstr(httpd_log_buf, "secret")); PASS(); } TEST(ui_server_stop_joins_cleanly) { th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); /* no requests at all — stop must unblock the accept wait promptly */ th_server_stop(&ts); PASS(); } TEST(ui_server_free_refuses_active_loop) { cbm_http_server_t *server = cbm_http_server_new(0); ASSERT_NOT_NULL(server); cbm_thread_t thread; ASSERT_EQ(th_server_thread_start(&thread, server), 0); char response[512]; ASSERT_GT( th_http(cbm_http_server_port(server), "GET / HTTP/1.1\r\n\r\n", response, sizeof(response)), 0); ASSERT_FALSE(cbm_http_server_free(server)); cbm_http_server_stop(server); ASSERT_EQ(cbm_thread_join(&thread), 0); ASSERT_TRUE(cbm_http_server_free(server)); PASS(); } TEST(ui_server_free_refuses_scheduled_run_before_child_starts) { cbm_http_server_t *server = cbm_http_server_new(0); ASSERT_NOT_NULL(server); ASSERT_TRUE(cbm_http_server_schedule_run(server)); ASSERT_FALSE(cbm_http_server_free(server)); ASSERT_TRUE(cbm_http_server_cancel_scheduled_run(server)); ASSERT_TRUE(cbm_http_server_free(server)); PASS(); } TEST(daemon_host_http_thread_create_failure_cancels_scheduled_run) { ASSERT_TRUE(cbm_daemon_host_http_thread_create_failure_lifecycle_for_test()); PASS(); } typedef struct { th_sock_t socket; atomic_int *operation_finished; atomic_int stop_finished; atomic_int watchdog_fired; } th_http_stop_watchdog_t; static void *th_http_stop_watchdog(void *opaque) { th_http_stop_watchdog_t *watchdog = opaque; /* Hang detector, not a latency assertion: sized for the slowest * sanitizer/loaded-runner tail (see AGENTS.md, CI determinism). */ for (int elapsed_ms = 0; elapsed_ms < 10000; elapsed_ms += 10) { if (atomic_load(&watchdog->stop_finished) || (watchdog->operation_finished && atomic_load(watchdog->operation_finished))) return NULL; cbm_usleep(10 * 1000); } atomic_store(&watchdog->watchdog_fired, 1); (void)th_sock_shutdown(watchdog->socket); return NULL; } typedef struct { cbm_httpd_t *listener; atomic_int accepted; atomic_int finished; int send_deadline_ms; } th_httpd_large_reply_t; static void *th_httpd_large_reply(void *opaque) { th_httpd_large_reply_t *reply = opaque; cbm_http_conn_t *connection = cbm_httpd_accept(reply->listener, 3000); if (!connection) { atomic_store(&reply->finished, 1); return NULL; } atomic_store(&reply->accepted, 1); if (reply->send_deadline_ms) cbm_http_conn_set_send_deadline_ms(connection, reply->send_deadline_ms); size_t response_size = 8U * 1024U * 1024U; char *response = malloc(response_size); if (response) { memset(response, 'R', response_size); cbm_http_reply_buf(connection, 200, "Content-Type: application/octet-stream\r\n", response, response_size); free(response); } cbm_httpd_conn_close(connection); atomic_store(&reply->finished, 1); return NULL; } TEST(httpd_interrupt_unblocks_nonreading_large_response_within_one_second) { cbm_httpd_t *listener = cbm_httpd_listen(0); ASSERT_NOT_NULL(listener); cbm_httpd_set_send_buffer_for_test(listener, 64 * 1024); /* Deadline pinned far out of reach: the only remaining way the blocked * 8 MB send can end is the interrupt, so the join itself proves * interrupt causality — no wall-clock discrimination needed (the old * elapsed<500ms check was a lottery under sanitizer slowdown). */ cbm_httpd_set_send_deadline_for_test(listener, 60 * 1000); th_httpd_large_reply_t reply = {.listener = listener}; atomic_init(&reply.accepted, 0); atomic_init(&reply.finished, 0); th_sock_t socket = th_connect_with_recv_buffer(cbm_httpd_port(listener), 1024); ASSERT_TRUE(socket != TH_SOCK_BAD); cbm_thread_t reply_thread; ASSERT_EQ(cbm_thread_create(&reply_thread, 0, th_httpd_large_reply, &reply), 0); ASSERT_TRUE(th_wait_httpd_activity(listener, CBM_HTTPD_ACTIVITY_RESPONDING, 1000)); ASSERT_EQ(atomic_load(&reply.accepted), 1); ASSERT_EQ(atomic_load(&reply.finished), 0); th_http_stop_watchdog_t watchdog = { .socket = socket, .operation_finished = &reply.finished, }; atomic_init(&watchdog.stop_finished, 0); atomic_init(&watchdog.watchdog_fired, 0); cbm_thread_t watchdog_thread; ASSERT_EQ(cbm_thread_create(&watchdog_thread, 0, th_http_stop_watchdog, &watchdog), 0); cbm_httpd_interrupt(listener); ASSERT_EQ(cbm_thread_join(&reply_thread), 0); atomic_store(&watchdog.stop_finished, 1); ASSERT_EQ(cbm_thread_join(&watchdog_thread), 0); (void)th_sock_shutdown(socket); th_sock_close(socket); ASSERT_TRUE(cbm_httpd_close(listener)); /* The 60 s deadline cannot have fired, so the join above is the proof * of interrupt delivery; the watchdog is purely a hang detector. */ ASSERT_EQ(atomic_load(&watchdog.watchdog_fired), 0); PASS(); } TEST(httpd_large_response_opt_in_survives_a_busy_reader_without_changing_default) { cbm_httpd_t *listener = cbm_httpd_listen(0); ASSERT_NOT_NULL(listener); cbm_httpd_set_send_buffer_for_test(listener, 64 * 1024); cbm_httpd_set_send_deadline_for_test(listener, 100); th_httpd_large_reply_t reply = {.listener = listener, .send_deadline_ms = 5000}; atomic_init(&reply.accepted, 0); atomic_init(&reply.finished, 0); th_sock_t socket = th_connect_with_recv_buffer(cbm_httpd_port(listener), 64 * 1024); ASSERT_TRUE(socket != TH_SOCK_BAD); cbm_thread_t reply_thread; ASSERT_EQ(cbm_thread_create(&reply_thread, 0, th_httpd_large_reply, &reply), 0); ASSERT_TRUE(th_wait_httpd_activity(listener, CBM_HTTPD_ACTIVITY_RESPONDING, 1000)); /* Deterministic backpressure spans the ordinary test deadline. The new * per-response opt-in must preserve the complete promised Content-Length. */ cbm_usleep(300 * 1000); size_t size = 8U * 1024U * 1024U; char *response = malloc(size + 1024); ASSERT_NOT_NULL(response); int got = th_recv_until_close(socket, response, size + 1024); ASSERT_EQ(cbm_thread_join(&reply_thread), 0); th_sock_close(socket); ASSERT_TRUE(cbm_httpd_close(listener)); ASSERT_GT(got, (int)size); char *body = strstr(response, "\r\n\r\n"); ASSERT_NOT_NULL(body); ASSERT_EQ((size_t)got - (size_t)(body + 4 - response), size); ASSERT_EQ(response[got - 1], 'R'); free(response); PASS(); } TEST(httpd_nonreading_large_response_hits_send_deadline_without_interrupt) { cbm_httpd_t *listener = cbm_httpd_listen(0); ASSERT_NOT_NULL(listener); cbm_httpd_set_send_buffer_for_test(listener, 64 * 1024); th_httpd_large_reply_t reply = {.listener = listener}; atomic_init(&reply.accepted, 0); atomic_init(&reply.finished, 0); th_sock_t socket = th_connect_with_recv_buffer(cbm_httpd_port(listener), 1024); ASSERT_TRUE(socket != TH_SOCK_BAD); cbm_thread_t reply_thread; ASSERT_EQ(cbm_thread_create(&reply_thread, 0, th_httpd_large_reply, &reply), 0); ASSERT_TRUE(th_wait_httpd_activity(listener, CBM_HTTPD_ACTIVITY_RESPONDING, 1000)); ASSERT_EQ(atomic_load(&reply.accepted), 1); ASSERT_EQ(atomic_load(&reply.finished), 0); uint64_t started = cbm_now_ms(); th_http_stop_watchdog_t watchdog = { .socket = socket, .operation_finished = &reply.finished, }; atomic_init(&watchdog.stop_finished, 0); atomic_init(&watchdog.watchdog_fired, 0); cbm_thread_t watchdog_thread; ASSERT_EQ(cbm_thread_create(&watchdog_thread, 0, th_http_stop_watchdog, &watchdog), 0); ASSERT_EQ(cbm_thread_join(&reply_thread), 0); uint64_t elapsed = cbm_now_ms() - started; atomic_store(&watchdog.stop_finished, 1); ASSERT_EQ(cbm_thread_join(&watchdog_thread), 0); th_sock_close(socket); ASSERT_TRUE(cbm_httpd_close(listener)); ASSERT_GTE(elapsed, 500); ASSERT_EQ(atomic_load(&watchdog.watchdog_fired), 0); PASS(); } TEST(ui_server_stop_interrupts_partial_request_within_one_second) { th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); cbm_http_server_set_recv_deadline_ms(ts.srv, 3000); int port = cbm_http_server_port(ts.srv); th_sock_t socket = th_connect(port); ASSERT_TRUE(socket != TH_SOCK_BAD); char partial[256]; snprintf(partial, sizeof(partial), "GET /api/logs HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n", port); ASSERT_EQ(th_send_all(socket, partial, strlen(partial)), 0); ASSERT_TRUE(th_wait_http_server_activity(ts.srv, CBM_HTTPD_ACTIVITY_READING_REQUEST, 1000)); th_http_stop_watchdog_t watchdog = {.socket = socket}; atomic_init(&watchdog.stop_finished, 0); atomic_init(&watchdog.watchdog_fired, 0); cbm_thread_t watchdog_thread; ASSERT_EQ(cbm_thread_create(&watchdog_thread, 0, th_http_stop_watchdog, &watchdog), 0); uint64_t started = cbm_now_ms(); cbm_http_server_stop(ts.srv); ASSERT_EQ(cbm_thread_join(&ts.tid), 0); uint64_t elapsed = cbm_now_ms() - started; atomic_store(&watchdog.stop_finished, 1); ASSERT_EQ(cbm_thread_join(&watchdog_thread), 0); (void)th_sock_shutdown(socket); th_sock_close(socket); ASSERT_TRUE(cbm_http_server_free(ts.srv)); ASSERT_LT(elapsed, 1000); ASSERT_EQ(atomic_load(&watchdog.watchdog_fired), 0); PASS(); } /* ── /api/repo-info git-remote URL helpers (distilled from PR #789) ── */ /* The web base must always be https (deep-links can't be downgraded) and must * never carry embedded credentials, across scp / ssh / https remote shapes. */ TEST(repo_info_web_base_normalizes_to_https) { struct { const char *in; const char *want; } cases[] = { {"git@github.com:org/repo.git", "https://github.com/org/repo"}, {"git@github.com:org/repo", "https://github.com/org/repo"}, {"https://github.com/org/repo.git", "https://github.com/org/repo"}, {"ssh://git@github.com/org/repo.git", "https://github.com/org/repo"}, {"https://user:token@github.com/org/repo.git", "https://github.com/org/repo"}, }; for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) { char *got = cbm_ui_git_web_base(cases[i].in); ASSERT_NOT_NULL(got); ASSERT_STR_EQ(got, cases[i].want); /* Never leak credentials into the web base. */ ASSERT_NULL(strstr(got, "token")); ASSERT_NULL(strstr(got, "@")); cbm_free(CBM_MEM_CLASS_OTHER, got); } /* Unrecognized shapes yield NULL, not a bogus link. */ ASSERT_NULL(cbm_ui_git_web_base("")); ASSERT_NULL(cbm_ui_git_web_base("not-a-url")); PASS(); } /* The remote_url field echoed to the client must have any user:pass@ stripped. */ TEST(repo_info_strips_credentials_from_remote) { char *safe = cbm_ui_git_strip_credentials("https://alice:s3cr3t@github.com/org/repo.git"); ASSERT_NOT_NULL(safe); ASSERT_STR_EQ(safe, "https://github.com/org/repo.git"); ASSERT_NULL(strstr(safe, "s3cr3t")); ASSERT_NULL(strstr(safe, "alice")); free(safe); /* Credential-free URLs pass through unchanged. */ char *plain = cbm_ui_git_strip_credentials("https://github.com/org/repo.git"); ASSERT_NOT_NULL(plain); ASSERT_STR_EQ(plain, "https://github.com/org/repo.git"); free(plain); /* An '@' in the path (not the authority) must not be treated as creds. */ char *pathat = cbm_ui_git_strip_credentials("https://github.com/org/repo/@scope"); ASSERT_NOT_NULL(pathat); ASSERT_STR_EQ(pathat, "https://github.com/org/repo/@scope"); free(pathat); /* scp-style carries no secret and is left intact. */ char *scp = cbm_ui_git_strip_credentials("git@github.com:org/repo.git"); ASSERT_NOT_NULL(scp); ASSERT_STR_EQ(scp, "git@github.com:org/repo.git"); free(scp); ASSERT_NULL(cbm_ui_git_strip_credentials(NULL)); PASS(); } /* ── #798 follow-up: full UI-mode hang repro (live sockets) ───── */ /* Like th_http but arms a client-side receive-timeout watchdog. If the * single-threaded server wedges, recv() returns instead of blocking forever, so * the test FAILs deterministically rather than hanging CI. 0 on connect/timeout. */ static int th_http_deadline(int port, const char *request, char *resp, size_t respsz, int timeout_ms) { char *prepared = th_request_with_ui_headers(port, request); if (!prepared) return 0; th_sock_t s = th_connect(port); if (s == TH_SOCK_BAD) { free(prepared); return 0; } #ifdef _WIN32 DWORD tv = (DWORD)timeout_ms; setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (const char *)&tv, sizeof(tv)); #else struct timeval tv; tv.tv_sec = timeout_ms / 1000; tv.tv_usec = (timeout_ms % 1000) * 1000; setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); #endif if (th_send_all(s, prepared, strlen(prepared)) != 0) { free(prepared); th_sock_close(s); return 0; } free(prepared); int n = th_recv_until_close(s, resp, respsz); th_sock_close(s); return n; } /* #798 was a single-threaded-server wedge: list_projects never returned and the * whole UI stopped answering. Assert the running server answers list_projects * within a hard deadline while it holds live listening sockets. The client * receive-timeout is the watchdog: a wedge → no 200 → FAIL, never a CI hang. */ TEST(ui_server_list_projects_responds_under_watchdog) { th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); const char *body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\"," "\"params\":{\"name\":\"list_projects\",\"arguments\":{}}}"; char req[512]; snprintf(req, sizeof(req), "POST /rpc HTTP/1.1\r\n" "Content-Type: application/json\r\n" "Content-Length: %d\r\n\r\n%s", (int)strlen(body), body); char resp[8192]; int n = th_http_deadline(cbm_http_server_port(ts.srv), req, resp, sizeof(resp), 15000); th_server_stop(&ts); ASSERT_GT(n, 0); /* a response arrived before the watchdog fired */ ASSERT_EQ(th_status(resp), 200); ASSERT_NOT_NULL(strstr(resp, "\"jsonrpc\"")); PASS(); } #ifdef _WIN32 typedef struct { char path[512]; int resolved_ok; } th_gitctx_probe_t; static DWORD WINAPI th_gitctx_probe_thread(LPVOID arg) { th_gitctx_probe_t *p = (th_gitctx_probe_t *)arg; cbm_git_context_t ctx; memset(&ctx, 0, sizeof(ctx)); int rc = cbm_git_context_resolve(p->path, &ctx); p->resolved_ok = (rc == 0 && ctx.is_git) ? 1 : 0; cbm_git_context_free(&ctx); return 0; } #endif /* The load-bearing end-to-end repro of #798: while the single-threaded UI server * holds LIVE listening/AFD socket handles in this process, cbm_git_context_resolve * — the exact path list_projects runs (add_git_context_json → resolve → * cbm_popen(git)) — must not hang. Under a raw-_popen regression git inherits * those sockets and its MSYS2 runtime deadlocks in NtQueryObject; the watchdog * turns that into a hard FAIL instead of an infinite hang. */ TEST(git_context_resolve_no_hang_under_live_ui_sockets) { #ifndef _WIN32 SKIP_PLATFORM("Windows-only: #798 UI listening-socket handle inheritance"); #else char *tmp = th_mktempdir("cbm_798repro"); if (!tmp) FAIL("th_mktempdir returned NULL"); char cmd[1024]; snprintf(cmd, sizeof(cmd), "git -C \"%s\" init -q && git -C \"%s\" -c user.email=t@t -c user.name=t " "commit -q --allow-empty -m init", tmp, tmp); if (system(cmd) != 0) { th_rmtree(tmp); SKIP_PLATFORM("git not available to init a repo"); } th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); th_gitctx_probe_t *probe = (th_gitctx_probe_t *)calloc(1, sizeof(*probe)); ASSERT_NOT_NULL(probe); snprintf(probe->path, sizeof(probe->path), "%s", tmp); HANDLE h = CreateThread(NULL, 0, th_gitctx_probe_thread, probe, 0, NULL); ASSERT_NOT_NULL(h); DWORD w = WaitForSingleObject(h, 30000); if (w != WAIT_OBJECT_0) { /* Wedged on the inherited-socket NtQueryObject walk. Deliberately leak * the heap probe + thread (a late wake must not touch freed memory); * process exit reaps them. Fail loudly rather than hang CI. */ th_server_stop(&ts); FAIL("cbm_git_context_resolve hung under live UI sockets (#798 regression)"); } CloseHandle(h); th_server_stop(&ts); int ok = probe->resolved_ok; free(probe); th_rmtree(tmp); ASSERT_EQ(ok, 1); PASS(); #endif } /* Host is part of the authority boundary: HTTP/1.1 requires exactly one, and * the optional port must be the actual bound port. This blocks DNS rebinding * and prevents a foreign localhost service from manufacturing same-origin * requests for this daemon. */ TEST(ui_server_rejects_non_loopback_host) { th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); int port = cbm_http_server_port(ts.srv); char resp[4096]; int n = th_http(port, "GET / HTTP/1.1\r\nHost: evil.example.com\r\n\r\n", resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 403); n = th_http_raw(port, "GET / HTTP/1.1\r\n\r\n", resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 400); static const char *bare_loopback_authorities[] = { "127.0.0.1", "localhost", "[::1]", }; char req[256]; for (size_t i = 0; i < sizeof(bare_loopback_authorities) / sizeof(bare_loopback_authorities[0]); i++) { snprintf(req, sizeof(req), "GET / HTTP/1.1\r\nHost: %s\r\n\r\n", bare_loopback_authorities[i]); n = th_http_raw(port, req, resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 403); } snprintf(req, sizeof(req), "GET / HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n", port + 1); n = th_http_raw(port, req, resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 403); snprintf(req, sizeof(req), "GET / HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n", port); n = th_http_raw(port, req, resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_NEQ(th_status(resp), 400); ASSERT_NEQ(th_status(resp), 403); static const char *bare_origins[] = { "http://127.0.0.1", "http://localhost", "http://[::1]", }; for (size_t i = 0; i < sizeof(bare_origins) / sizeof(bare_origins[0]); i++) { snprintf(req, sizeof(req), "GET / HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nOrigin: %s\r\n\r\n", port, bare_origins[i]); n = th_http_raw(port, req, resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 403); } th_server_stop(&ts); PASS(); } /* The browser sends UTF-8 repository paths in a percent-encoded query. On * Windows, the handler's narrow opendir/readdir calls accepted the ASCII parent * but could neither list nor open a non-ASCII child, even though cbm_is_dir's * wide-path check had already accepted it. Exercise both user-visible steps: * discover the Unicode directory from its parent, then browse into it. */ TEST(ui_server_browse_non_ascii_directory) { static const char utf8_name[] = "\xE9\x81\x93\xE5\x85\xB7\xE7\xAE\xB1"; /* toolbox */ char *created = th_mktempdir("cbm_browse_utf8"); if (!created) FAIL("mktempdir"); char base[512]; snprintf(base, sizeof(base), "%s", created); cbm_normalize_path_sep(base); char unicode_dir[768]; char child[1024]; int unicode_len = snprintf(unicode_dir, sizeof(unicode_dir), "%s/%s", base, utf8_name); int child_len = snprintf(child, sizeof(child), "%s/Percy", unicode_dir); if (unicode_len <= 0 || (size_t)unicode_len >= sizeof(unicode_dir) || child_len <= 0 || (size_t)child_len >= sizeof(child) || th_mkdir_p(child) != 0) { th_cleanup(base); FAIL("failed to create non-ASCII browse fixture"); } th_server_t ts; if (th_server_start(&ts) != 0) { th_cleanup(base); FAIL("server start"); } int port = cbm_http_server_port(ts.srv); char request[1536]; char response[8192]; snprintf(request, sizeof(request), "GET /api/browse?path=%s/%%E9%%81%%93%%E5%%85%%B7%%E7%%AE%%B1 HTTP/1.1\r\n\r\n", base); int n = th_http(port, request, response, sizeof(response)); bool child_browse_ok = n > 0 && th_status(response) == 200 && strstr(response, "\"Percy\"") != NULL; snprintf(request, sizeof(request), "GET /api/browse?path=%s HTTP/1.1\r\n\r\n", base); n = th_http(port, request, response, sizeof(response)); bool parent_lists_unicode = n > 0 && th_status(response) == 200 && strstr(response, utf8_name) != NULL; th_server_stop(&ts); th_cleanup(base); ASSERT_TRUE(parent_lists_unicode); ASSERT_TRUE(child_browse_ok); PASS(); } /* The directory browser formats readdir() entries into a fixed 32 KB response * buffer. The per-entry loop is clamped, but the trailing "parent"/"roots" * appends were not — once the entries filled the buffer, pos ran past the end * and the next size argument wrapped, writing out of bounds. Fill the buffer * with many long-named subdirectories and browse it in a forked child so an * overflow surfaces as a killing signal (ASan abort) rather than a clean run. */ TEST(ui_server_browse_wide_dir_no_overflow) { #ifdef _WIN32 SKIP_PLATFORM("fork crash-isolation is POSIX-only; the clamp is platform-agnostic"); #else char *dir = th_mktempdir("cbm_browse"); if (!dir) { FAIL("mktempdir"); } char longname[240]; memset(longname, 'a', sizeof(longname) - 1); longname[sizeof(longname) - 1] = '\0'; for (int i = 0; i < 250; i++) { /* 250 * ~220 chars overflows the 32 KB buffer */ char sub[600]; snprintf(sub, sizeof(sub), "%s/%s%03d", dir, longname, i); th_mkdir_p(sub); } fflush(NULL); pid_t pid = fork(); if (pid == 0) { th_server_t ts; if (th_server_start(&ts) != 0) { _exit(2); } char req[512]; int port = cbm_http_server_port(ts.srv); snprintf(req, sizeof(req), "GET /api/browse?path=%s HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n", dir, port); char *resp = malloc(262144); int n = resp ? th_http(port, req, resp, 262144) : 0; int ok = (n > 0 && strstr(resp, "HTTP/1.1 200") != NULL); free(resp); th_server_stop(&ts); _exit(ok ? 0 : 3); } ASSERT_TRUE(pid > 0); int status = 0; (void)waitpid(pid, &status, 0); char rm[600]; snprintf(rm, sizeof(rm), "rm -rf '%s'", dir); (void)system(rm); if (WIFSIGNALED(status)) { char m[96]; snprintf(m, sizeof(m), "browse killed by signal %d — response buffer overflow", WTERMSIG(status)); FAIL(m); } ASSERT_TRUE(WIFEXITED(status)); ASSERT_EQ(WEXITSTATUS(status), 0); PASS(); #endif } /* The log endpoint serialises the ring into one heap buffer budgeted at * LOG_LINE_MAX + 10 bytes per line. JSON escaping doubles every '"' and '\\', * so an escape-dense line needs ~2x LOG_LINE_MAX. The inner escape loop stops * at buf_size - 10, but the per-line framing writes (separator comma, opening * quote, closing quote) were raw indexes, so every line past saturation wrote * three bytes beyond the allocation. Fill the ring with escape-dense lines and * read it back in a forked child, so the overflow surfaces as a killing signal * (ASan abort) instead of silent heap corruption. */ TEST(ui_server_logs_escape_dense_no_overflow) { #ifdef _WIN32 SKIP_PLATFORM("fork crash-isolation is POSIX-only; the clamp is platform-agnostic"); #else fflush(NULL); pid_t pid = fork(); if (pid == 0) { /* Worst case the ring can hold: every byte escapes to two bytes. */ char dense[512]; for (size_t i = 0; i < sizeof(dense) - 1; i++) { dense[i] = (i % 2 == 0) ? '"' : '\\'; } dense[sizeof(dense) - 1] = '\0'; for (int i = 0; i < 500; i++) { /* fills the whole 500-entry ring */ cbm_ui_log_append(dense); } th_server_t ts; if (th_server_start(&ts) != 0) { _exit(2); } char req[256]; int port = cbm_http_server_port(ts.srv); snprintf(req, sizeof(req), "GET /api/logs?lines=500 HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n", port); size_t cap = 4u * 1024u * 1024u; char *resp = malloc(cap); int n = resp ? th_http(port, req, resp, (int)cap) : 0; int ok = (n > 0 && strstr(resp, "HTTP/1.1 200") != NULL); free(resp); th_server_stop(&ts); _exit(ok ? 0 : 3); } ASSERT_TRUE(pid > 0); int status = 0; (void)waitpid(pid, &status, 0); if (WIFSIGNALED(status)) { char m[96]; snprintf(m, sizeof(m), "logs killed by signal %d — response buffer overflow", WTERMSIG(status)); FAIL(m); } ASSERT_TRUE(WIFEXITED(status)); ASSERT_EQ(WEXITSTATUS(status), 0); PASS(); #endif } /* The index-status endpoint renders every active job into a fixed 2 KB stack * buffer. http_appendf clamps its own writes, but the separator and the closing * bracket were raw indexes, so two jobs holding ~1 KB root paths (the field * was 1024 bytes and now holds the 4 KB resolved root) pushed pos to * the clamp and the close then wrote past the buffer. Drive it through the real * endpoint with the index executor stubbed out, in a forked child so the * overflow surfaces as a killing signal. */ #define MAX_TEST_INDEX_JOBS 4 TEST(ui_server_index_status_long_paths_no_overflow) { #ifdef _WIN32 SKIP_PLATFORM("fork crash-isolation is POSIX-only; the clamp is platform-agnostic"); #else char *base = th_mktempdir("cbm_status"); if (!base) { FAIL("mktempdir"); } /* Two real directories whose paths are long enough that two rendered job * entries exceed the 2 KB response buffer. */ /* Four slots x ~520 chars overflows the 2 KB buffer while every path stays * well inside PATH_MAX — a single pair of ~1 KB paths would reach the * buffer too, but mkdir refuses them once the temp-dir prefix is added. */ char comp[200]; memset(comp, 'd', sizeof(comp) - 1); comp[sizeof(comp) - 1] = '\0'; char deep[MAX_TEST_INDEX_JOBS][800]; for (int j = 0; j < MAX_TEST_INDEX_JOBS; j++) { int n = snprintf(deep[j], sizeof(deep[j]), "%s/%d", base, j); while (n < 520) { n += snprintf(deep[j] + n, sizeof(deep[j]) - (size_t)n, "/%s", comp); } th_mkdir_p(deep[j]); } fflush(NULL); pid_t pid = fork(); if (pid == 0) { /* The blocking executor holds every job open so all four slots are * occupied at once. With the non-blocking stub each job finishes * immediately and handle_index_start recycles the same slot, leaving a * single entry to render — far short of the buffer. */ th_ui_blocking_index_executor_t executor = {0}; atomic_init(&executor.calls, 0); atomic_init(&executor.release, 0); th_server_t ts; ts.srv = cbm_http_server_new(0); if (!ts.srv) { _exit(2); } cbm_http_server_set_index_executor(ts.srv, th_ui_blocking_index_executor, &executor); if (th_server_thread_start(&ts.tid, ts.srv) != 0) { _exit(2); } int port = cbm_http_server_port(ts.srv); for (int j = 0; j < MAX_TEST_INDEX_JOBS; j++) { char body[1200]; snprintf(body, sizeof(body), "{\"root_path\":\"%s\",\"project_name\":\"p%d\"}", deep[j], j); char request[1500]; snprintf(request, sizeof(request), "POST /api/index HTTP/1.1\r\nContent-Type: application/json\r\n" "Content-Length: %zu\r\n\r\n%s", strlen(body), body); char response[4096]; int rn = th_http(port, request, response, sizeof(response)); /* A rejected POST would leave the slot empty and the endpoint would * render nothing — a vacuous pass. Fail loudly instead. */ if (rn <= 0 || th_status(response) != 202) { _exit(4); } } /* Wait for the state the assertion depends on — all four jobs actually * running — rather than for a duration. */ if (!th_wait_atomic_int(&executor.calls, MAX_TEST_INDEX_JOBS, 5000)) { atomic_store(&executor.release, 1); _exit(5); } char req[256]; snprintf(req, sizeof(req), "GET /api/index-status HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n", port); char resp[8192]; int n = th_http(port, req, resp, sizeof(resp)); int ok = (n > 0 && strstr(resp, "HTTP/1.1 200") != NULL); atomic_store(&executor.release, 1); th_server_stop(&ts); _exit(ok ? 0 : 3); } ASSERT_TRUE(pid > 0); int status = 0; (void)waitpid(pid, &status, 0); th_cleanup(base); if (WIFSIGNALED(status)) { char m[96]; snprintf(m, sizeof(m), "index-status killed by signal %d — response buffer overflow", WTERMSIG(status)); FAIL(m); } ASSERT_TRUE(WIFEXITED(status)); ASSERT_EQ(WEXITSTATUS(status), 0); PASS(); #endif } TEST(ui_server_configuration_is_atomic_and_origin_protected) { char directory[256]; snprintf(directory, sizeof(directory), "%s/cbm-http-settings-XXXXXX", cbm_tmpdir()); ASSERT_NOT_NULL(cbm_mkdtemp(directory)); char *previous = getenv("CBM_CACHE_DIR") ? strdup(getenv("CBM_CACHE_DIR")) : NULL; ASSERT_EQ(cbm_setenv("CBM_CACHE_DIR", directory, 1), 0); th_server_t server; ASSERT_EQ(th_server_start(&server), 0); int port = cbm_http_server_port(server.srv); char *response = malloc(131072); ASSERT_NOT_NULL(response); ASSERT_GT(th_http(port, "GET /api/config HTTP/1.1\r\n\r\n", response, 131072), 0); ASSERT_EQ(th_status(response), 200); ASSERT_NOT_NULL(strstr(response, "Cache-Control: no-store")); char *body = strstr(response, "\r\n\r\n"); ASSERT_NOT_NULL(body); body += 4; yyjson_doc *document = yyjson_read(body, strlen(body), 0); ASSERT_NOT_NULL(document); const char *revision = yyjson_get_str(yyjson_obj_get(yyjson_doc_get_root(document), "revision")); ASSERT_NOT_NULL(revision); char payload[256]; snprintf(payload, sizeof(payload), "{\"revision\":\"%s\",\"changes\":{\"auto_index\":\"true\"}}", revision); yyjson_doc_free(document); char request[1024]; snprintf( request, sizeof(request), "POST /api/config HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nOrigin: " "http://foreign.example\r\nContent-Type: application/json\r\nContent-Length: %zu\r\n\r\n%s", port, strlen(payload), payload); ASSERT_GT(th_http_raw(port, request, response, 131072), 0); ASSERT_EQ(th_status(response), 403); snprintf(request, sizeof(request), "POST /api/config HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nContent-Type: " "text/plain\r\nContent-Length: %zu\r\n\r\n%s", port, strlen(payload), payload); ASSERT_GT(th_http_raw(port, request, response, 131072), 0); ASSERT_EQ(th_status(response), 415); snprintf(request, sizeof(request), "POST /api/config HTTP/1.1\r\nContent-Length: %zu\r\n\r\n%s", strlen(payload), payload); ASSERT_GT(th_http(port, request, response, 131072), 0); ASSERT_EQ(th_status(response), 200); ASSERT_GT(th_http(port, request, response, 131072), 0); ASSERT_EQ(th_status(response), 409); ASSERT_GT(th_http(port, "GET /api/config-extra HTTP/1.1\r\n\r\n", response, 131072), 0); ASSERT_EQ(th_status(response), 404); th_server_stop(&server); free(response); if (previous) { cbm_setenv("CBM_CACHE_DIR", previous, 1); free(previous); } else cbm_unsetenv("CBM_CACHE_DIR"); char path[512]; snprintf(path, sizeof(path), "%s/_config.db", directory); remove(path); cbm_rmdir(directory); PASS(); } /* Grow path in place to exactly target bytes with 'd' components, none * longer than 200 so every component stays under NAME_MAX. */ static void th_pad_path_to(char *path, size_t cap, int target) { char fill[201]; memset(fill, 'd', sizeof(fill) - 1); fill[sizeof(fill) - 1] = '\0'; int n = (int)strlen(path); while (n < target && (size_t)n + 2 < cap) { int room = target - n - 1; /* after the '/' */ int len = room > 200 ? 200 : room; if (len <= 0) { break; } n += snprintf(path + n, cap - (size_t)n, "/%.*s", len, fill); } } /* Parse an index-status body and compare each entry's "path" with the root * expected for its slot. Results go to out-params so the caller can tear * down before asserting. */ static void th_index_status_check(const char *body, int expected_count, char expected[][4096], bool *parsed, int *count, bool *paths_whole) { *parsed = false; *count = 0; *paths_whole = false; yyjson_doc *doc = yyjson_read(body, strlen(body), 0); if (!doc) { return; } yyjson_val *arr = yyjson_doc_get_root(doc); if (yyjson_is_arr(arr)) { *parsed = true; *count = (int)yyjson_arr_size(arr); bool whole = *count == expected_count; size_t idx; size_t max_entries; yyjson_val *entry; yyjson_arr_foreach(arr, idx, max_entries, entry) { yyjson_val *slot = yyjson_obj_get(entry, "slot"); yyjson_val *path = yyjson_obj_get(entry, "path"); int s = yyjson_is_int(slot) ? yyjson_get_int(slot) : -1; whole = whole && s >= 0 && s < expected_count && yyjson_is_str(path) && strcmp(yyjson_get_str(path), expected[s]) == 0; } *paths_whole = whole; } yyjson_doc_free(doc); } /* The listing is sized for what the job table holds. Every slot takes a root * as long as the platform resolves; the body has to parse as JSON and carry * each root whole, first with one job and then with the table full. Roots * enter the table the way the clamp test's do: real directories through the * route, held open by the blocking executor so every slot stays occupied. */ TEST(ui_server_index_status_long_roots_render_whole) { char *tmp = th_mktempdir("cbm_status_whole"); ASSERT_NOT_NULL(tmp); char base[256]; snprintf(base, sizeof(base), "%s", tmp); /* Linux: just under the 4 KB field; the 4 KB request body still wraps it. */ int target = 4096 - 160; #if defined(_WIN32) /* MAX_PATH: long roots are not characterised there; the listing is still * checked for shape and content with every slot occupied. */ target = 200; #elif defined(PATH_MAX) && PATH_MAX < 4096 /* macOS: the longest root the route can resolve; four still exceed 2 KB. */ target = PATH_MAX - 96; #endif char deep[MAX_TEST_INDEX_JOBS][4096]; char expected[MAX_TEST_INDEX_JOBS][4096]; for (int j = 0; j < MAX_TEST_INDEX_JOBS; j++) { snprintf(deep[j], sizeof(deep[j]), "%s/%d", base, j); th_pad_path_to(deep[j], sizeof(deep[j]), target); ASSERT_EQ((int)strlen(deep[j]), target); ASSERT_EQ(th_mkdir_p(deep[j]), 0); ASSERT_TRUE(cbm_canonical_path(deep[j], expected[j], sizeof(expected[j]))); } th_ui_blocking_index_executor_t executor = {0}; atomic_init(&executor.calls, 0); atomic_init(&executor.release, 0); th_server_t ts; ts.srv = cbm_http_server_new(0); ASSERT_NOT_NULL(ts.srv); cbm_http_server_set_index_executor(ts.srv, th_ui_blocking_index_executor, &executor); ASSERT_EQ(th_server_thread_start(&ts.tid, ts.srv), 0); int port = cbm_http_server_port(ts.srv); char status_request[128]; snprintf(status_request, sizeof(status_request), "GET /api/index-status HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n", port); int accepted = 0; bool started = true; bool one_parsed = false; bool one_whole = false; bool all_parsed = false; bool all_whole = false; int one_count = 0; int all_count = 0; size_t one_body_len = 0; size_t all_body_len = 0; char listing[65536]; for (int j = 0; j < MAX_TEST_INDEX_JOBS; j++) { char body[4600]; snprintf(body, sizeof(body), "{\"root_path\":\"%s\",\"project_name\":\"p%d\"}", deep[j], j); char request[4900]; snprintf(request, sizeof(request), "POST /api/index HTTP/1.1\r\nContent-Type: application/json\r\n" "Content-Length: %zu\r\n\r\n%s", strlen(body), body); char response[8192]; int rn = th_http(port, request, response, sizeof(response)); if (rn > 0 && th_status(response) == 202) { accepted++; } /* Wait for the state the listing depends on: this job running. */ started = started && th_wait_atomic_int(&executor.calls, j + 1, 5000); if (j == 0 || j == MAX_TEST_INDEX_JOBS - 1) { int n = th_http(port, status_request, listing, sizeof(listing)); const char *status_body = n > 0 ? th_response_body(listing) : ""; if (j == 0) { one_body_len = strlen(status_body); th_index_status_check(status_body, 1, expected, &one_parsed, &one_count, &one_whole); } else { all_body_len = strlen(status_body); th_index_status_check(status_body, MAX_TEST_INDEX_JOBS, expected, &all_parsed, &all_count, &all_whole); } } } /* Release the workers and tear down; free refuses until each worker has * flagged completion, the same way the active-worker test waits. */ atomic_store(&executor.release, 1); cbm_http_server_stop(ts.srv); ASSERT_EQ(cbm_thread_join(&ts.tid), 0); bool freed = false; uint64_t deadline = cbm_now_ms() + 2000; while (!freed && cbm_now_ms() < deadline) { freed = cbm_http_server_free(ts.srv); if (!freed) cbm_usleep(1000); } th_cleanup(base); ASSERT_TRUE(freed); ASSERT_EQ(accepted, MAX_TEST_INDEX_JOBS); ASSERT_TRUE(started); if (!one_parsed) { char m[128]; snprintf(m, sizeof(m), "one-job index-status body of %zu bytes is not JSON", one_body_len); FAIL(m); } ASSERT_EQ(one_count, 1); ASSERT_TRUE(one_whole); if (!all_parsed) { char m[128]; snprintf(m, sizeof(m), "full-table index-status body of %zu bytes is not JSON", all_body_len); FAIL(m); } ASSERT_EQ(all_count, MAX_TEST_INDEX_JOBS); ASSERT_TRUE(all_whole); PASS(); } /* ── Suite ────────────────────────────────────────────────────── */ SUITE(httpd) { RUN_TEST(ui_server_configuration_is_atomic_and_origin_protected); RUN_TEST(ui_server_browse_non_ascii_directory); RUN_TEST(ui_server_browse_wide_dir_no_overflow); RUN_TEST(ui_server_logs_escape_dense_no_overflow); RUN_TEST(ui_server_index_status_long_paths_no_overflow); RUN_TEST(ui_server_index_status_long_roots_render_whole); /* Parser / helpers */ RUN_TEST(httpd_parse_simple_get); RUN_TEST(httpd_parse_security_headers_and_rejects_duplicates); RUN_TEST(httpd_parse_post_with_body_offset); RUN_TEST(httpd_parse_origin_case_insensitive); RUN_TEST(httpd_parse_rejects_bare_lf); RUN_TEST(httpd_parse_rejects_chunked); RUN_TEST(httpd_parse_rejects_oversized_content_length); RUN_TEST(httpd_parse_rejects_garbage_content_length); RUN_TEST(httpd_parse_rejects_percent00_in_target); RUN_TEST(httpd_parse_rejects_raw_nul_in_head); RUN_TEST(httpd_parse_incomplete_head_needs_more); RUN_TEST(httpd_parse_rejects_missing_version); RUN_TEST(httpd_parse_rejects_oversized_head); RUN_TEST(httpd_query_param_decode); RUN_TEST(httpd_query_param_edge_cases); RUN_TEST(httpd_path_match_matrix); RUN_TEST(httpd_resolves_bare_binary_path_from_path); RUN_TEST(httpd_deleted_self_spawn_path_follows_platform_ruling); RUN_TEST(repo_info_web_base_normalizes_to_https); RUN_TEST(repo_info_strips_credentials_from_remote); /* Transport */ RUN_TEST(httpd_listen_ephemeral_port); RUN_TEST(httpd_listen_port_collision_returns_null); RUN_TEST(httpd_close_refuses_while_connection_owns_listener); /* Full UI server */ RUN_TEST(ui_server_readiness_proof_is_exact_and_generation_bound); RUN_TEST(ui_server_rejects_non_loopback_host); RUN_TEST(ui_server_unknown_path_404); RUN_TEST(ui_server_process_kill_route_is_unavailable); RUN_TEST(ui_server_process_measurements_declare_platform_semantics); RUN_TEST(ui_server_routes_indexing_through_joinable_daemon_executor); RUN_TEST(ui_server_free_never_joins_active_index_worker); RUN_TEST(ui_csp_connect_src_has_explicit_download_hosts); RUN_TEST(activity_preserves_severity_and_reports_interrupted_reads); RUN_TEST(activity_reply_memory_core_ownership); RUN_TEST(activity_pruned_start_events_do_not_resurrect_on_retry); RUN_TEST(activity_migration_preserves_v1_logs_and_rejects_future_schema); RUN_TEST(activity_log_project_scope_preserves_unknown_v2_history_and_filters_before_limit); RUN_TEST(ui_log_scopes_use_explicit_entry_ownership_and_survive_server_restart); RUN_TEST(activity_log_search_and_severity_apply_before_limit_and_survive_reopen); RUN_TEST(ui_log_retention_prevents_expired_retry_and_preserves_full_json); RUN_TEST(ui_log_journal_is_canonical_for_info_retry_and_restart); RUN_TEST(ui_log_legacy_import_is_once_and_preserves_unknown_provenance_and_time); RUN_TEST(activity_journal_reopen_cursor_dedup_and_project_isolation); RUN_TEST(activity_errors_survive_routine_log_retention_and_reopen); RUN_TEST(ui_agent_events_and_errors_use_daemon_port_and_survive_restart); RUN_TEST(ui_log_post_writes_jsonl_and_tail_reads_it); RUN_TEST(ui_log_post_refuses_bad_bodies); RUN_TEST(ui_log_rotates_at_configured_size); RUN_TEST(ui_server_index_start_refuses_root_outside_allowed); RUN_TEST(ui_server_index_start_stores_resolved_root); RUN_TEST(ui_server_index_start_long_root_not_truncated); RUN_TEST(ui_server_index_start_reply_escapes_path); RUN_TEST(ui_server_root_without_embedded_assets_is_not_found); RUN_TEST(ui_server_same_origin_request_is_allowed); RUN_TEST(ui_server_rejects_foreign_and_null_origins); RUN_TEST(ui_server_mutations_require_json_content_type); RUN_TEST(ui_server_rpc_rejects_duplicate_adr_arguments); RUN_TEST(ui_server_rpc_allows_only_ui_read_tools); RUN_TEST(ui_server_atlas_routes_are_wired); RUN_TEST(ui_server_retained_cache_memory_is_flushed_before_thread_exit); RUN_TEST(ui_server_oversized_body_rejected); RUN_TEST(ui_server_encoded_slash_not_routed); RUN_TEST(ui_server_nul_in_target_rejected); RUN_TEST(ui_server_browse_traversal_probe); RUN_TEST(ui_server_adr_mutation_guard_busy_preserves_existing_adr); RUN_TEST(ui_server_adr_mutation_guard_balances_success); RUN_TEST(ui_server_delete_mutation_guard_busy_preserves_project); RUN_TEST(ui_server_delete_mutation_guard_balances_success); RUN_TEST(ui_server_delete_project_unwatches_after_delete); RUN_TEST(ui_server_delete_project_unwatches_missing_db); RUN_TEST(ui_server_delete_project_no_watcher_still_deletes); RUN_TEST(ui_server_delete_project_missing_name_keeps_watch); RUN_TEST(ui_server_delete_project_invalid_name_keeps_watch); RUN_TEST(ui_server_delete_project_unlink_failure_keeps_watch); RUN_TEST(ui_server_project_health_timestamp_and_no_watcher); RUN_TEST(ui_server_project_health_live_watcher_state); RUN_TEST(ui_server_ui_config_detects_zh_accept_language); RUN_TEST(ui_server_ui_config_includes_serving_version_issue1820); RUN_TEST(ui_server_ui_config_prefers_config_lang); RUN_TEST(ui_server_slow_request_hits_deadline); RUN_TEST(ui_server_access_log_redacts_query); RUN_TEST(ui_server_stop_joins_cleanly); RUN_TEST(ui_server_free_refuses_active_loop); RUN_TEST(ui_server_free_refuses_scheduled_run_before_child_starts); RUN_TEST(daemon_host_http_thread_create_failure_cancels_scheduled_run); RUN_TEST(httpd_interrupt_unblocks_nonreading_large_response_within_one_second); RUN_TEST(httpd_large_response_opt_in_survives_a_busy_reader_without_changing_default); RUN_TEST(httpd_nonreading_large_response_hits_send_deadline_without_interrupt); RUN_TEST(ui_server_stop_interrupts_partial_request_within_one_second); /* #798 follow-up: full UI-mode hang repro under live sockets */ RUN_TEST(ui_server_list_projects_responds_under_watchdog); RUN_TEST(git_context_resolve_no_hang_under_live_ui_sockets); }