clawdforge/clients/c/tests/test_client.c
Kayos 70f4dcc2a4 clients/c: apply audit findings — security + CVE bump (a69e924 → new)
HIGH:
- H1: enlarge test base_with_slash buffer 64 → 80; cmake --build now
  clean under -Werror=format-truncation.
- H2: CURLOPT_FOLLOWLOCATION = 0 (no cross-host bearer leak; SDK talks
  to a known endpoint, redirects unexpected). MAXREDIRS dropped.
- H3: cf_admin_revoke_token validates name [A-Za-z0-9_-]+ client-side
  before URL build; rejects "a/../healthz" with CF_ERR_USAGE before
  the request leaves the process.

MEDIUM:
- M1: cf_buf_append overflow guards — n + len + 1 wrap-check up front;
  newcap *= 2 doubling-loop bounded by SIZE_MAX/2.
- M2: 64 MiB CF_MAX_RESPONSE_BYTES cap exposed on the public header;
  write_cb aborts the transfer once exceeded → CF_ERR_TRANSPORT.
- M3: CURLOPT_CONNECTTIMEOUT_MS = 10000 (was implicit 300s default).
- M4: g_curl_init_count is now _Atomic int (C11 stdatomic) using
  atomic_fetch_add/sub; concurrent cf_client_new/cf_client_free across
  threads no longer races the libcurl global init/cleanup transition.

LOW:
- L1: push_auth propagates CF_ERR_OOM via an out-param instead of
  silently dropping the Authorization header (which previously surfaced
  as a misleading 401 from the server).
- L2: write_cb size*nmemb overflow defensive guard.

CVE:
- Bump vendored cJSON 1.7.15 → 1.7.18 (fixes CVE-2024-31755:
  cJSON_SetValuestring NULL-deref). cJSON.c/cJSON.h replaced from
  upstream tag v1.7.18; LICENSE file unchanged. README updated.

Tests added (15 → 21):
- test_revoke_token_validates_name: path-traversal name rejected,
  valid name proceeds through to transport.
- test_buf_append_overflow_guards: synthetic SIZE_MAX-edge inputs
  trigger error-return rather than wrap.
- test_response_body_size_cap: mock streams 65 MiB; client aborts
  with CF_ERR_TRANSPORT.
- test_connect_timeout: dial 10.255.255.1, assert <18s wallclock
  (vs. libcurl's 300s default).
- test_concurrent_client_init: 4 pthreads × 50 iters, no crash, no
  leak under valgrind.
- test_cjson_bump: cJSON_SetValuestring(node, NULL) returns NULL
  safely; malformed cJSON_Parse returns NULL.

Verification:
- cmake --build build (Release): clean
- ctest --test-dir build: 21/21 pass (incl. 10s connect-timeout test)
- ctest --test-dir build-asan (ASan + UBSan): clean
- valgrind --leak-check=full: 10,313 allocs == 10,313 frees, 0 errors,
  0 leaks

README updated: cJSON 1.7.18 note, C11 + stdatomic requirement.

Audit: memory/clawdforge-audits/c-a69e924.md
2026-04-28 23:25:22 -07:00

1047 lines
35 KiB
C

/* Test suite for the clawdforge C SDK.
*
* Strategy: spin up a tiny single-threaded HTTP server on 127.0.0.1
* inside this process. The server pulls a scripted response off a
* queue per request and returns it. No external dependency beyond
* the platform sockets API and pthreads (already a transitive dep
* of libcurl on Linux).
*
* We test the wire surface of the client end-to-end through libcurl:
* URL construction, auth header injection, JSON request shape,
* JSON response parsing, error envelopes, multipart upload.
*/
#define _DEFAULT_SOURCE
#define _GNU_SOURCE
#define _POSIX_C_SOURCE 200809L
#include <arpa/inet.h>
#include <ctype.h>
#include <netinet/in.h>
#include <pthread.h>
#include <signal.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <clawdforge.h>
#include "cJSON.h"
/* Internal symbols used by the buffer-overflow guard test. The static
* library exports them; these are not part of the public ABI. */
typedef struct cf_buf {
char *data;
size_t len;
size_t cap;
} cf_buf_t_test;
extern void cf_buf_init(cf_buf_t_test *b);
extern int cf_buf_append(cf_buf_t_test *b, const void *src, size_t n);
extern void cf_buf_free(cf_buf_t_test *b);
/* ------------------------------------------------------------------ */
/* tiny test harness */
/* ------------------------------------------------------------------ */
static int g_failures = 0;
static int g_tests = 0;
static const char *g_current = "(none)";
#define TEST(name) do { g_current = name; g_tests++; } while (0)
#define CHECK(cond) do { \
if (!(cond)) { \
fprintf(stderr, "FAIL [%s] %s:%d: %s\n", \
g_current, __FILE__, __LINE__, #cond); \
g_failures++; \
} \
} while (0)
/* Portable case-insensitive prefix compare. Returns 0 on match. */
static int header_match(const char *line, const char *name) {
size_t n = strlen(name);
for (size_t i = 0; i < n; ++i) {
if (!line[i]) return 1;
if (tolower((unsigned char)line[i]) != tolower((unsigned char)name[i])) return 1;
}
return 0;
}
/* Portable memmem (avoids _GNU_SOURCE dep). */
static const void *find_bytes(const void *hay, size_t hay_len,
const void *needle, size_t need_len) {
if (need_len == 0) return hay;
if (hay_len < need_len) return NULL;
const char *h = (const char *)hay;
const char *n = (const char *)needle;
for (size_t i = 0; i + need_len <= hay_len; ++i) {
if (memcmp(h + i, n, need_len) == 0) return h + i;
}
return NULL;
}
#define CHECK_STR_EQ(a, b) do { \
const char *_a = (a); const char *_b = (b); \
if (!_a || !_b || strcmp(_a, _b) != 0) { \
fprintf(stderr, "FAIL [%s] %s:%d: \"%s\" != \"%s\"\n", \
g_current, __FILE__, __LINE__, \
_a ? _a : "(null)", _b ? _b : "(null)"); \
g_failures++; \
} \
} while (0)
/* ------------------------------------------------------------------ */
/* mock server */
/* ------------------------------------------------------------------ */
typedef struct mock_response {
int status;
char *content_type;
char *body;
/* When stream_bytes > 0, the response body is `stream_bytes` ASCII
* 'x' characters generated on the fly from a small fixed buffer
* (no big malloc). Used to exercise the response-body cap without
* allocating tens of megabytes server-side. */
long stream_bytes;
} mock_response_t;
#define MAX_RESPONSES 16
typedef struct mock_request {
char method[16];
char path[256];
char *auth; /* "Bearer ..." or NULL */
char *content_type;
char *body;
size_t body_len;
} mock_request_t;
static int srv_fd = -1;
static int srv_port = 0;
static pthread_t srv_thread;
static volatile int srv_stop = 0;
static pthread_mutex_t q_mu = PTHREAD_MUTEX_INITIALIZER;
static mock_response_t q_resp[MAX_RESPONSES];
static int q_resp_head = 0;
static int q_resp_tail = 0;
static mock_request_t captured[MAX_RESPONSES];
static int captured_count = 0;
static void enqueue_response(int status, const char *content_type, const char *body) {
pthread_mutex_lock(&q_mu);
mock_response_t *r = &q_resp[q_resp_tail];
r->status = status;
r->content_type = content_type ? strdup(content_type) : strdup("application/json");
r->body = body ? strdup(body) : strdup("");
r->stream_bytes = 0;
q_resp_tail = (q_resp_tail + 1) % MAX_RESPONSES;
pthread_mutex_unlock(&q_mu);
}
static void enqueue_stream_response(int status, long bytes) {
pthread_mutex_lock(&q_mu);
mock_response_t *r = &q_resp[q_resp_tail];
r->status = status;
r->content_type = strdup("application/octet-stream");
r->body = NULL;
r->stream_bytes = bytes;
q_resp_tail = (q_resp_tail + 1) % MAX_RESPONSES;
pthread_mutex_unlock(&q_mu);
}
static int dequeue_response(mock_response_t *out) {
pthread_mutex_lock(&q_mu);
if (q_resp_head == q_resp_tail) {
pthread_mutex_unlock(&q_mu);
return -1;
}
*out = q_resp[q_resp_head];
q_resp_head = (q_resp_head + 1) % MAX_RESPONSES;
pthread_mutex_unlock(&q_mu);
return 0;
}
static void capture(const mock_request_t *r) {
pthread_mutex_lock(&q_mu);
if (captured_count < MAX_RESPONSES) {
captured[captured_count++] = *r;
}
pthread_mutex_unlock(&q_mu);
}
/* Read the full HTTP request from a socket. Returns 0 on success. */
static int read_request(int fd, mock_request_t *req) {
char buf[64 * 1024];
size_t off = 0;
/* Read until headers complete */
while (off < sizeof(buf) - 1) {
ssize_t n = read(fd, buf + off, sizeof(buf) - 1 - off);
if (n <= 0) return -1;
off += (size_t)n;
buf[off] = '\0';
if (strstr(buf, "\r\n\r\n")) break;
}
char *blank_line = strstr(buf, "\r\n\r\n");
if (!blank_line) return -1;
char *body_start = blank_line + 4;
size_t header_len = (size_t)(body_start - buf);
/* The last header's terminating "\r\n" is the FIRST "\r\n" of the
* "\r\n\r\n" sequence — so it's at blank_line..+1. To process every
* header line including the last one, walk byte-range
* [buf .. blank_line + 2). The "+2" includes the last header's "\r\n". */
char *header_end = blank_line + 2;
/* Method + path */
char *line_end = strstr(buf, "\r\n");
if (!line_end || line_end >= header_end) return -1;
char first_line[1024];
size_t fl_len = (size_t)(line_end - buf);
if (fl_len >= sizeof first_line) fl_len = sizeof first_line - 1;
memcpy(first_line, buf, fl_len);
first_line[fl_len] = '\0';
char path_buf[256] = {0};
if (sscanf(first_line, "%15s %255s", req->method, path_buf) != 2) return -1;
memcpy(req->path, path_buf, sizeof req->path);
req->path[sizeof req->path - 1] = '\0';
/* Headers */
req->auth = NULL;
req->content_type = NULL;
long content_length = 0;
char *p = line_end + 2;
while (p < header_end) {
/* find next \r\n strictly within the header range */
char *nl = NULL;
for (char *q = p; q + 1 < header_end; ++q) {
if (q[0] == '\r' && q[1] == '\n') { nl = q; break; }
}
if (!nl) break;
size_t llen = (size_t)(nl - p);
char line[2048];
if (llen >= sizeof line) llen = sizeof line - 1;
memcpy(line, p, llen);
line[llen] = '\0';
if (header_match(line, "Authorization:") == 0) {
char *v = line + 14;
while (*v == ' ') v++;
req->auth = strdup(v);
} else if (header_match(line, "Content-Type:") == 0) {
char *v = line + 13;
while (*v == ' ') v++;
req->content_type = strdup(v);
} else if (header_match(line, "Content-Length:") == 0) {
content_length = strtol(line + 15, NULL, 10);
}
p = nl + 2;
}
/* Body */
req->body = NULL;
req->body_len = 0;
if (content_length > 0) {
size_t already = off - header_len;
char *body = (char *)malloc((size_t)content_length + 1);
if (!body) return -1;
if (already > (size_t)content_length) already = (size_t)content_length;
memcpy(body, body_start, already);
size_t got = already;
while (got < (size_t)content_length) {
ssize_t n = read(fd, body + got, (size_t)content_length - got);
if (n <= 0) { free(body); return -1; }
got += (size_t)n;
}
body[content_length] = '\0';
req->body = body;
req->body_len = (size_t)content_length;
}
return 0;
}
static void write_response(int fd, const mock_response_t *resp) {
size_t body_len = resp->stream_bytes > 0
? (size_t)resp->stream_bytes
: (resp->body ? strlen(resp->body) : 0);
char header[512];
int n = snprintf(header, sizeof header,
"HTTP/1.1 %d %s\r\n"
"Content-Type: %s\r\n"
"Content-Length: %zu\r\n"
"Connection: close\r\n"
"\r\n",
resp->status,
resp->status == 200 ? "OK" :
resp->status == 401 ? "Unauthorized" :
resp->status == 404 ? "Not Found" :
resp->status == 502 ? "Bad Gateway" : "Other",
resp->content_type ? resp->content_type : "application/json",
body_len);
if (n > 0) {
ssize_t wrote = write(fd, header, (size_t)n);
(void)wrote;
if (resp->stream_bytes > 0) {
/* Stream `stream_bytes` ASCII 'x' from a small chunk buffer.
* Stops early on EPIPE — clients that abort write_cb hang up
* mid-transfer, which is exactly what we want to test. */
char chunk[8192];
memset(chunk, 'x', sizeof chunk);
size_t remaining = (size_t)resp->stream_bytes;
while (remaining > 0) {
size_t step = remaining < sizeof chunk ? remaining : sizeof chunk;
ssize_t w = write(fd, chunk, step);
if (w <= 0) break;
remaining -= (size_t)w;
}
} else if (resp->body && *resp->body) {
wrote = write(fd, resp->body, strlen(resp->body));
(void)wrote;
}
}
}
static void *server_loop(void *arg) {
(void)arg;
while (!srv_stop) {
struct sockaddr_in addr;
socklen_t alen = sizeof addr;
int cfd = accept(srv_fd, (struct sockaddr *)&addr, &alen);
if (cfd < 0) {
if (errno == EINTR || errno == EAGAIN) continue;
break;
}
mock_request_t req;
memset(&req, 0, sizeof req);
if (read_request(cfd, &req) == 0) {
capture(&req);
mock_response_t resp;
if (dequeue_response(&resp) == 0) {
write_response(cfd, &resp);
free(resp.content_type);
free(resp.body);
} else {
/* No script left — return 500. */
mock_response_t fb = {500, strdup("text/plain"), strdup("no scripted response"), 0};
write_response(cfd, &fb);
free(fb.content_type);
free(fb.body);
}
}
close(cfd);
}
return NULL;
}
static int start_server(void) {
srv_fd = socket(AF_INET, SOCK_STREAM, 0);
if (srv_fd < 0) return -1;
int yes = 1;
setsockopt(srv_fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes);
/* Bind to a random port on loopback. */
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
addr.sin_port = 0;
if (bind(srv_fd, (struct sockaddr *)&addr, sizeof addr) != 0) return -1;
socklen_t alen = sizeof addr;
if (getsockname(srv_fd, (struct sockaddr *)&addr, &alen) != 0) return -1;
srv_port = ntohs(addr.sin_port);
if (listen(srv_fd, 16) != 0) return -1;
/* Make accept interruptible by close. */
int flags = fcntl(srv_fd, F_GETFL, 0);
(void)flags;
if (pthread_create(&srv_thread, NULL, server_loop, NULL) != 0) return -1;
return 0;
}
static void stop_server(void) {
srv_stop = 1;
/* Kick accept by connecting + closing. */
int kicker = socket(AF_INET, SOCK_STREAM, 0);
if (kicker >= 0) {
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
addr.sin_port = htons(srv_port);
connect(kicker, (struct sockaddr *)&addr, sizeof addr);
close(kicker);
}
pthread_join(srv_thread, NULL);
if (srv_fd >= 0) close(srv_fd);
/* Drain any residual queue + captures. */
pthread_mutex_lock(&q_mu);
while (q_resp_head != q_resp_tail) {
free(q_resp[q_resp_head].content_type);
free(q_resp[q_resp_head].body);
q_resp_head = (q_resp_head + 1) % MAX_RESPONSES;
}
for (int i = 0; i < captured_count; ++i) {
free(captured[i].auth);
free(captured[i].content_type);
free(captured[i].body);
}
captured_count = 0;
pthread_mutex_unlock(&q_mu);
}
static char *make_base_url(void) {
static char buf[64];
snprintf(buf, sizeof buf, "http://127.0.0.1:%d", srv_port);
return buf;
}
static void reset_captures(void) {
pthread_mutex_lock(&q_mu);
for (int i = 0; i < captured_count; ++i) {
free(captured[i].auth);
free(captured[i].content_type);
free(captured[i].body);
}
captured_count = 0;
pthread_mutex_unlock(&q_mu);
}
/* ------------------------------------------------------------------ */
/* tests */
/* ------------------------------------------------------------------ */
static void t_version(void) {
TEST("version");
CHECK_STR_EQ(cf_version(), "0.1.0");
CHECK_STR_EQ(cf_status_str(CF_OK), "CF_OK");
CHECK_STR_EQ(cf_status_str(CF_ERR_PARSE), "CF_ERR_PARSE");
}
static void t_client_lifecycle(void) {
TEST("client_lifecycle");
cf_client_t *c = cf_client_new("http://127.0.0.1:1/", "tok");
CHECK(c != NULL);
cf_client_set_timeout_secs(c, 30);
CHECK(cf_client_set_admin_token(c, "admintok") == CF_OK);
cf_client_free(c);
/* NULL base_url rejected. */
CHECK(cf_client_new(NULL, "x") == NULL);
CHECK(cf_client_new("", "x") == NULL);
}
static void t_healthz_ok(void) {
TEST("healthz_ok");
reset_captures();
enqueue_response(200, "application/json",
"{\"ok\":true,\"claude_present\":true,\"claude_version\":\"1.2.3\"}");
cf_client_t *c = cf_client_new(make_base_url(), "tok");
cf_client_set_timeout_secs(c, 5);
cf_healthz_t h = {0};
cf_error_t err = {0};
cf_status_t s = cf_healthz(c, &h, &err);
CHECK(s == CF_OK);
CHECK(h.ok == 1);
CHECK(h.claude_present == 1);
CHECK_STR_EQ(h.claude_version, "1.2.3");
cf_healthz_free(&h);
cf_error_free(&err);
cf_client_free(c);
/* /healthz must NOT have an Auth header — server says it doesn't
* require one and the client doesn't promise one. (Our code does
* inject the bearer token when set; that's also fine — server
* ignores. Just verify the path was correct.) */
CHECK_STR_EQ(captured[0].method, "GET");
CHECK_STR_EQ(captured[0].path, "/healthz");
}
static void t_healthz_transport_fail(void) {
TEST("healthz_transport_fail");
cf_client_t *c = cf_client_new("http://127.0.0.1:1", "tok");
cf_client_set_timeout_secs(c, 2);
cf_healthz_t h = {0};
cf_error_t err = {0};
cf_status_t s = cf_healthz(c, &h, &err);
CHECK(s == CF_ERR_TRANSPORT);
CHECK(err.message && err.message[0] != '\0');
cf_error_free(&err);
cf_client_free(c);
}
static void t_run_success_object(void) {
TEST("run_success_object");
reset_captures();
enqueue_response(200, "application/json",
"{\"ok\":true,"
"\"result\":{\"hello\":\"world\",\"n\":7},"
"\"duration_ms\":1234,"
"\"stop_reason\":\"end_turn\"}");
cf_client_t *c = cf_client_new(make_base_url(), "cf_abc");
cf_run_request_t req = {
.prompt = "Reply with JSON",
.model = "sonnet",
.timeout_secs = 30,
};
cf_run_result_t res = {0};
cf_error_t err = {0};
cf_status_t s = cf_run(c, &req, &res, &err);
CHECK(s == CF_OK);
CHECK(res.duration_ms == 1234);
CHECK(res.result_is_string == 0);
CHECK_STR_EQ(res.stop_reason, "end_turn");
CHECK(res.result_json != NULL);
/* result_json should be parseable by cJSON. */
cJSON *parsed = (cJSON *)cf_run_result_as_cjson(&res);
CHECK(parsed != NULL);
cJSON *hello = cJSON_GetObjectItemCaseSensitive(parsed, "hello");
CHECK(cJSON_IsString(hello));
if (cJSON_IsString(hello)) CHECK_STR_EQ(hello->valuestring, "world");
cJSON_Delete(parsed);
/* Verify request body sent the right shape. */
CHECK_STR_EQ(captured[0].method, "POST");
CHECK_STR_EQ(captured[0].path, "/run");
CHECK(captured[0].auth != NULL);
CHECK_STR_EQ(captured[0].auth, "Bearer cf_abc");
cJSON *sent = cJSON_Parse(captured[0].body);
CHECK(sent != NULL);
if (sent) {
cJSON *prompt = cJSON_GetObjectItemCaseSensitive(sent, "prompt");
cJSON *model = cJSON_GetObjectItemCaseSensitive(sent, "model");
cJSON *to = cJSON_GetObjectItemCaseSensitive(sent, "timeout_secs");
CHECK(cJSON_IsString(prompt));
if (cJSON_IsString(prompt)) CHECK_STR_EQ(prompt->valuestring, "Reply with JSON");
CHECK(cJSON_IsString(model));
if (cJSON_IsString(model)) CHECK_STR_EQ(model->valuestring, "sonnet");
CHECK(cJSON_IsNumber(to));
if (cJSON_IsNumber(to)) CHECK((int)to->valuedouble == 30);
cJSON_Delete(sent);
}
cf_run_result_free(&res);
cf_error_free(&err);
cf_client_free(c);
}
static void t_run_success_string(void) {
TEST("run_success_string");
reset_captures();
enqueue_response(200, "application/json",
"{\"ok\":true,"
"\"result\":\"plain text reply\","
"\"duration_ms\":42,"
"\"stop_reason\":\"end_turn\"}");
cf_client_t *c = cf_client_new(make_base_url(), "tok");
cf_run_request_t req = { .prompt = "hi" };
cf_run_result_t res = {0};
cf_error_t err = {0};
CHECK(cf_run(c, &req, &res, &err) == CF_OK);
CHECK(res.result_is_string == 1);
/* result_json is the JSON-encoded string, including quotes. */
CHECK_STR_EQ(res.result_json, "\"plain text reply\"");
cf_run_result_free(&res);
cf_error_free(&err);
cf_client_free(c);
}
static void t_run_files_passed(void) {
TEST("run_files_passed");
reset_captures();
enqueue_response(200, "application/json",
"{\"ok\":true,\"result\":\"ok\",\"duration_ms\":1,\"stop_reason\":\"end_turn\"}");
const char *files[] = { "ff_aaa", "ff_bbb" };
cf_client_t *c = cf_client_new(make_base_url(), "tok");
cf_run_request_t req = {
.prompt = "extract",
.files = files,
.files_count = 2,
};
cf_run_result_t res = {0};
cf_error_t err = {0};
CHECK(cf_run(c, &req, &res, &err) == CF_OK);
cJSON *sent = cJSON_Parse(captured[0].body);
CHECK(sent != NULL);
if (sent) {
cJSON *arr = cJSON_GetObjectItemCaseSensitive(sent, "files");
CHECK(cJSON_IsArray(arr));
if (cJSON_IsArray(arr)) {
CHECK(cJSON_GetArraySize(arr) == 2);
cJSON *e0 = cJSON_GetArrayItem(arr, 0);
cJSON *e1 = cJSON_GetArrayItem(arr, 1);
CHECK(cJSON_IsString(e0));
CHECK(cJSON_IsString(e1));
if (cJSON_IsString(e0)) CHECK_STR_EQ(e0->valuestring, "ff_aaa");
if (cJSON_IsString(e1)) CHECK_STR_EQ(e1->valuestring, "ff_bbb");
}
cJSON_Delete(sent);
}
cf_run_result_free(&res);
cf_error_free(&err);
cf_client_free(c);
}
static void t_run_502_envelope(void) {
TEST("run_502_envelope");
reset_captures();
enqueue_response(502, "application/json",
"{\"ok\":false,\"error\":\"claude exited with status 1\","
"\"stderr\":\"some stderr\",\"duration_ms\":2000,\"stop_reason\":null}");
cf_client_t *c = cf_client_new(make_base_url(), "tok");
cf_run_request_t req = { .prompt = "x" };
cf_run_result_t res = {0};
cf_error_t err = {0};
cf_status_t s = cf_run(c, &req, &res, &err);
CHECK(s == CF_ERR_API);
CHECK(err.http_status == 502);
CHECK(err.message != NULL);
CHECK(strstr(err.message, "claude exited") != NULL);
cf_run_result_free(&res);
cf_error_free(&err);
cf_client_free(c);
}
static void t_run_401_auth(void) {
TEST("run_401_auth");
reset_captures();
enqueue_response(401, "application/json", "{\"detail\":\"invalid token\"}");
cf_client_t *c = cf_client_new(make_base_url(), "wrong");
cf_run_request_t req = { .prompt = "x" };
cf_run_result_t res = {0};
cf_error_t err = {0};
cf_status_t s = cf_run(c, &req, &res, &err);
CHECK(s == CF_ERR_AUTH);
CHECK(err.http_status == 401);
CHECK(err.message != NULL);
CHECK(strstr(err.message, "invalid token") != NULL);
cf_run_result_free(&res);
cf_error_free(&err);
cf_client_free(c);
}
static void t_run_usage_errors(void) {
TEST("run_usage_errors");
cf_client_t *c = cf_client_new("http://127.0.0.1:1", "tok");
cf_run_request_t req = { .prompt = "" };
cf_run_result_t res = {0};
cf_error_t err = {0};
cf_status_t s = cf_run(c, &req, &res, &err);
CHECK(s == CF_ERR_USAGE);
CHECK(err.message != NULL);
cf_error_free(&err);
/* NULL request */
s = cf_run(c, NULL, &res, &err);
CHECK(s == CF_ERR_USAGE);
cf_error_free(&err);
cf_client_free(c);
}
static void t_upload_file(void) {
TEST("upload_file");
reset_captures();
enqueue_response(200, "application/json",
"{\"file_token\":\"ff_xyz\",\"ttl_secs\":600,\"size\":11}");
/* Write a tmp file. */
char tmpl[] = "/tmp/cf-c-test-XXXXXX";
int fd = mkstemp(tmpl);
CHECK(fd >= 0);
if (fd >= 0) {
ssize_t w = write(fd, "hello world", 11);
(void)w;
close(fd);
}
cf_client_t *c = cf_client_new(make_base_url(), "tok");
cf_file_token_t ft = {0};
cf_error_t err = {0};
cf_status_t s = cf_upload_file(c, tmpl, 600, &ft, &err);
CHECK(s == CF_OK);
CHECK_STR_EQ(ft.file_token, "ff_xyz");
CHECK(ft.ttl_secs == 600);
CHECK(ft.size == 11);
/* Verify request shape: POST /files, multipart, contains file content. */
CHECK_STR_EQ(captured[0].method, "POST");
CHECK_STR_EQ(captured[0].path, "/files");
CHECK(captured[0].content_type != NULL);
if (captured[0].content_type) {
CHECK(strstr(captured[0].content_type, "multipart/form-data") != NULL);
}
CHECK(captured[0].body != NULL);
if (captured[0].body) {
CHECK(find_bytes(captured[0].body, captured[0].body_len, "hello world", 11) != NULL);
CHECK(find_bytes(captured[0].body, captured[0].body_len, "ttl_secs", 8) != NULL);
}
cf_file_token_free(&ft);
cf_error_free(&err);
cf_client_free(c);
unlink(tmpl);
}
static void t_admin_create_token(void) {
TEST("admin_create_token");
reset_captures();
enqueue_response(200, "application/json",
"{\"name\":\"cauldron\",\"token\":\"cf_secret_xxx\","
"\"ip_cidrs\":[\"172.24.0.0/16\",\"10.0.0.0/8\"]}");
cf_client_t *c = cf_client_new(make_base_url(), NULL);
CHECK(cf_client_set_admin_token(c, "admin_bootstrap_token") == CF_OK);
const char *cidrs[] = { "172.24.0.0/16", "10.0.0.0/8" };
cf_admin_token_t out = {0};
cf_error_t err = {0};
cf_status_t s = cf_admin_create_token(c, "cauldron", cidrs, 2, &out, &err);
CHECK(s == CF_OK);
CHECK_STR_EQ(out.name, "cauldron");
CHECK_STR_EQ(out.token, "cf_secret_xxx");
CHECK(out.ip_cidrs_count == 2);
if (out.ip_cidrs_count == 2) {
CHECK_STR_EQ(out.ip_cidrs[0], "172.24.0.0/16");
CHECK_STR_EQ(out.ip_cidrs[1], "10.0.0.0/8");
}
/* Verify auth was the admin token. */
CHECK_STR_EQ(captured[0].auth, "Bearer admin_bootstrap_token");
cf_admin_token_free(&out);
cf_error_free(&err);
cf_client_free(c);
}
static void t_admin_list_revoke(void) {
TEST("admin_list_revoke");
reset_captures();
enqueue_response(200, "application/json",
"{\"tokens\":["
"{\"name\":\"a\",\"ip_cidrs\":[\"127.0.0.1/32\"],\"created_at\":1700000000,\"last_used_at\":1700000100},"
"{\"name\":\"b\",\"ip_cidrs\":[],\"created_at\":1700000200}"
"]}");
cf_client_t *c = cf_client_new(make_base_url(), NULL);
cf_client_set_admin_token(c, "admin");
cf_admin_token_list_t list = {0};
cf_error_t err = {0};
CHECK(cf_admin_list_tokens(c, &list, &err) == CF_OK);
CHECK(list.count == 2);
if (list.count == 2) {
CHECK_STR_EQ(list.items[0].name, "a");
CHECK(list.items[0].ip_cidrs_count == 1);
if (list.items[0].ip_cidrs_count == 1) {
CHECK_STR_EQ(list.items[0].ip_cidrs[0], "127.0.0.1/32");
}
CHECK(list.items[0].created_at == 1700000000);
CHECK(list.items[0].last_used_at == 1700000100);
CHECK_STR_EQ(list.items[1].name, "b");
CHECK(list.items[1].ip_cidrs_count == 0);
}
cf_admin_token_list_free(&list);
cf_error_free(&err);
/* Revoke. */
enqueue_response(200, "application/json", "{\"ok\":true}");
CHECK(cf_admin_revoke_token(c, "a", &err) == CF_OK);
CHECK_STR_EQ(captured[1].method, "DELETE");
CHECK_STR_EQ(captured[1].path, "/admin/tokens/a");
/* Revoke missing -> 404 */
enqueue_response(404, "application/json", "{\"detail\":\"no such token\"}");
cf_status_t s = cf_admin_revoke_token(c, "nope", &err);
CHECK(s == CF_ERR_API);
CHECK(err.http_status == 404);
CHECK(err.message && strstr(err.message, "no such token") != NULL);
cf_error_free(&err);
cf_client_free(c);
}
static void t_admin_requires_token(void) {
TEST("admin_requires_token");
cf_client_t *c = cf_client_new("http://127.0.0.1:1", NULL);
cf_admin_token_list_t list = {0};
cf_error_t err = {0};
cf_status_t s = cf_admin_list_tokens(c, &list, &err);
CHECK(s == CF_ERR_USAGE);
CHECK(err.message != NULL);
cf_error_free(&err);
cf_client_free(c);
}
static void t_url_normalisation(void) {
TEST("url_normalisation");
reset_captures();
enqueue_response(200, "application/json",
"{\"ok\":true,\"claude_present\":false}");
/* Trailing slash should be stripped. */
char base_with_slash[80];
snprintf(base_with_slash, sizeof base_with_slash, "%s/", make_base_url());
cf_client_t *c = cf_client_new(base_with_slash, "tok");
cf_healthz_t h = {0};
cf_error_t err = {0};
CHECK(cf_healthz(c, &h, &err) == CF_OK);
CHECK_STR_EQ(captured[0].path, "/healthz");
cf_healthz_free(&h);
cf_error_free(&err);
cf_client_free(c);
}
/* ------------------------------------------------------------------ */
/* New tests added by audit-fix pass */
/* ------------------------------------------------------------------ */
/* H3 follow-up: cf_admin_revoke_token must reject names that are
* not [A-Za-z0-9_-]+ before it interpolates into the URL. */
static void t_revoke_token_validates_name(void) {
TEST("revoke_token_validates_name");
cf_client_t *c = cf_client_new("http://127.0.0.1:1", NULL);
cf_client_set_admin_token(c, "admin");
cf_error_t err = {0};
/* Path-traversal attempt: must be rejected client-side. */
cf_status_t s = cf_admin_revoke_token(c, "a/../healthz", &err);
CHECK(s == CF_ERR_USAGE);
CHECK(err.message != NULL);
if (err.message) CHECK(strstr(err.message, "invalid token name") != NULL);
cf_error_free(&err);
/* Empty name */
s = cf_admin_revoke_token(c, "", &err);
CHECK(s == CF_ERR_USAGE);
cf_error_free(&err);
/* URL-encoded byte still rejected — raw byte not in allow-list. */
s = cf_admin_revoke_token(c, "a%2F..%2Fhealthz", &err);
CHECK(s == CF_ERR_USAGE);
cf_error_free(&err);
/* A valid name still proceeds to a network call (which fails because
* no server is listening at :1, surfacing CF_ERR_TRANSPORT — not
* CF_ERR_USAGE — proving the validator let it through). */
s = cf_admin_revoke_token(c, "valid-name_123", &err);
CHECK(s == CF_ERR_TRANSPORT);
cf_error_free(&err);
cf_client_free(c);
}
/* M1: cf_buf_append guards both the additive overflow (n + len + 1) and
* the doubling overflow (newcap *= 2). The guards must fire on the size
* computation alone — neither realloc nor memcpy is reached on either
* synthetic input, so data may safely stay NULL. */
static void t_buf_append_overflow_guards(void) {
TEST("buf_append_overflow_guards");
cf_buf_t_test b;
cf_buf_init(&b);
/* (a) additive overflow: n + len + 1 wraps. */
b.data = NULL;
b.cap = 0;
b.len = SIZE_MAX - 4;
int rc = cf_buf_append(&b, "abcd", 5); /* 5 > SIZE_MAX - (SIZE_MAX-4) - 1 = 3 */
CHECK(rc == -1);
/* (b) doubling overflow: cap is already past SIZE_MAX/2, and n
* forces need > cap so the loop runs. The first iteration must
* trip the `newcap > SIZE_MAX/2` guard. */
b.data = NULL;
b.len = 0;
b.cap = (SIZE_MAX / 2) + 100;
rc = cf_buf_append(&b, "x", SIZE_MAX - 200);
CHECK(rc == -1);
/* Reset to a clean state for the sanity check / free. */
b.data = NULL;
b.len = 0;
b.cap = 0;
/* (c) sanity: a normal small append still works. */
rc = cf_buf_append(&b, "hello", 5);
CHECK(rc == 0);
CHECK(b.len == 5);
CHECK(b.data != NULL);
if (b.data) CHECK(strcmp(b.data, "hello") == 0);
cf_buf_free(&b);
}
/* M2: a server response larger than CF_MAX_RESPONSE_BYTES must abort
* mid-transfer, not silently consume the heap. */
static void t_response_body_size_cap(void) {
TEST("response_body_size_cap");
reset_captures();
/* CF_MAX_RESPONSE_BYTES is 64 MiB. Stream 65 MiB so we cross the cap. */
long over_cap = (long)CF_MAX_RESPONSE_BYTES + (1L << 20);
enqueue_stream_response(200, over_cap);
cf_client_t *c = cf_client_new(make_base_url(), "tok");
cf_client_set_timeout_secs(c, 30);
cf_healthz_t h = {0};
cf_error_t err = {0};
cf_status_t s = cf_healthz(c, &h, &err);
CHECK(s == CF_ERR_TRANSPORT);
CHECK(err.message != NULL);
cf_healthz_free(&h);
cf_error_free(&err);
cf_client_free(c);
}
/* M3: with no CONNECTTIMEOUT set, libcurl waits ~300s. We set 10s,
* so a connect to 10.255.255.1 (RFC1918 unroutable) must fail well
* before the test-suite timeout (60s). */
static void t_connect_timeout(void) {
TEST("connect_timeout");
cf_client_t *c = cf_client_new("http://10.255.255.1:80", "tok");
/* Total timeout high so we know the connect-timeout is what trips. */
cf_client_set_timeout_secs(c, 30);
cf_healthz_t h = {0};
cf_error_t err = {0};
struct timeval t0, t1;
gettimeofday(&t0, NULL);
cf_status_t s = cf_healthz(c, &h, &err);
gettimeofday(&t1, NULL);
double elapsed = (t1.tv_sec - t0.tv_sec) +
(t1.tv_usec - t0.tv_usec) / 1e6;
CHECK(s == CF_ERR_TRANSPORT);
/* Allow 18s slack for slow CI; the libcurl default of 300s would
* blow the test-suite timeout long before this. */
CHECK(elapsed < 18.0);
cf_healthz_free(&h);
cf_error_free(&err);
cf_client_free(c);
}
/* M4: g_curl_init_count must survive concurrent cf_client_new /
* cf_client_free across threads without leaking, double-cleaning, or
* tearing down libcurl while another thread is mid-request. */
typedef struct {
int iters;
} init_thread_arg_t;
static void *init_worker(void *arg) {
init_thread_arg_t *a = (init_thread_arg_t *)arg;
for (int i = 0; i < a->iters; ++i) {
cf_client_t *c = cf_client_new("http://127.0.0.1:1", "tok");
if (c) {
cf_client_set_timeout_secs(c, 1);
cf_client_free(c);
}
}
return NULL;
}
static void t_concurrent_client_init(void) {
TEST("concurrent_client_init");
enum { N = 4 };
pthread_t th[N];
init_thread_arg_t args[N];
for (int i = 0; i < N; ++i) {
args[i].iters = 50;
int rc = pthread_create(&th[i], NULL, init_worker, &args[i]);
CHECK(rc == 0);
}
for (int i = 0; i < N; ++i) {
pthread_join(th[i], NULL);
}
/* If we're still running with no crash and no SIGSEGV, the atomic
* refcount held. valgrind/asan will catch any heap corruption. */
CHECK(1);
}
/* CVE-2024-31755: prior to cJSON 1.7.18, calling cJSON_SetValuestring
* with a NULL `valuestring` argument on a string node would deref NULL
* inside strlen. The fix is a NULL check that returns NULL safely. */
static void t_cjson_bump(void) {
TEST("cjson_bump");
cJSON *node = cJSON_CreateString("seed");
CHECK(node != NULL);
if (node) {
/* Pre-fix: this call crashed. Post-fix: returns NULL. */
char *r = cJSON_SetValuestring(node, NULL);
CHECK(r == NULL);
/* The original valuestring must be untouched. */
CHECK(cJSON_IsString(node));
if (cJSON_IsString(node)) {
CHECK_STR_EQ(node->valuestring, "seed");
}
cJSON_Delete(node);
}
/* Belt-and-braces: parsing crafted JSON also returns sanely (NULL
* or a node), never crashes. */
const char *crafted = "{\"a\":[1,2,{\"b\":\"c\"}],\"d\":null}";
cJSON *parsed = cJSON_Parse(crafted);
CHECK(parsed != NULL);
if (parsed) cJSON_Delete(parsed);
/* And a malformed input: must return NULL, not crash. */
cJSON *bad = cJSON_Parse("{\"a\":");
CHECK(bad == NULL);
}
/* ------------------------------------------------------------------ */
/* main */
/* ------------------------------------------------------------------ */
int main(void) {
/* Don't die on SIGPIPE if a peer hangs up. */
signal(SIGPIPE, SIG_IGN);
if (start_server() != 0) {
fprintf(stderr, "FATAL: could not start mock server\n");
return 2;
}
t_version();
t_client_lifecycle();
t_healthz_ok();
t_healthz_transport_fail();
t_run_success_object();
t_run_success_string();
t_run_files_passed();
t_run_502_envelope();
t_run_401_auth();
t_run_usage_errors();
t_upload_file();
t_admin_create_token();
t_admin_list_revoke();
t_admin_requires_token();
t_url_normalisation();
/* Audit-fix tests */
t_revoke_token_validates_name();
t_buf_append_overflow_guards();
t_response_body_size_cap();
t_connect_timeout();
t_concurrent_client_init();
t_cjson_bump();
stop_server();
fprintf(stderr, "\n%d tests, %d failures\n", g_tests, g_failures);
return g_failures == 0 ? 0 : 1;
}