Files
codebase-memory-mcp/tests/test_daemon.c
Martin Vogel 9846c3f171 fix(daemon): compare session roots separator-equivalently without respelling the policy root
The first cut on this branch (c1b9c451) respelled the session root and
the allowed root with forward slashes on the way into the session policy
(cbm_mcp_server_set_session_context) and at the three application.c sites
that canonicalize a root. CI's Windows shard then failed two tests:

  tests/test_daemon_application.c:2256: ASSERT(sensitive_blocked)
  tests/test_daemon_application.c:2380: ASSERT(sensitive_blocked)

Both set HOME to the cbm_canonical_path spelling of a directory - the
platform's native form, backslashes on Windows - and expect a session
rooted there to be refused for auto-index and for watch. The sensitive-root
and allowed-root containment walk (ws_is_ancestor_or_equal in
src/foundation/workspace.c) compares prefixes byte-exact, so a root that
now read C:/Users/... no longer matched a HOME of C:\Users\...: the
sensitive refusal was lost and $HOME was admitted. Respelling the policy
root was the wrong layer.

This commit keeps the policy roots in their canonical native spelling
(mcp.c and the three application.c sites are back to what main runs) and
folds the separator spelling at the one comparison the original defect
lives in: application_index_args_equal, where the running job's args are
compared with an explicit index_repository request's. The auto-index job
spells repo_path the way the session policy holds it; the handler spells
it with forward slashes. On Windows the two never matched, and the request
was refused as OPTIONS_CONFLICT instead of joining the job already running
for its root. The fold acts on the parsed copies only (yyjson_mut_obj_replace
with a cbm_normalize_path_sep'd string), so nothing the daemon stores
changes spelling and every other option stays exact.

Why this is correct on Windows without a local Windows run: the policy
spelling is untouched, so the containment path is byte-for-byte what main
runs there today; cbm_normalize_path_sep folds backslashes on every
platform, so the new fold is exercised by the new test wherever the suite
runs.

Tests: daemon_session_context_keeps_one_spelling_of_a_root asserted that
the policy respells a root with forward slashes. That was the wrong
contract: the policy's containment checks compare its spelling byte-exact
against HOME and the granted roots, so a respelled root is exactly what
stops matching them. It is replaced by
daemon_session_context_keeps_the_policy_spelling_of_a_root, which binds
the opposite: the policy stores the root as given. The comparison is exposed through
application_internal.h as cbm_daemon_application_index_args_equal_for_test
and bound by daemon_application_index_args_compare_repo_path_separator_equivalently:
a backslash and a forward-slash repo_path compare equal; a different path,
a sub-path, and a different mode still do not.

Refs #1726

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-09-06 16:23:45 +02:00

505 lines
22 KiB
C

/*
* test_daemon.c — Guards for shared MCP-daemon coordination and framing.
*/
#include "test_framework.h"
#include "daemon/daemon.h"
#include "mcp/mcp.h"
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
size_t job_cancel_count;
size_t shared_job_cancel_count;
size_t lease_job_cancel_count;
size_t shutdown_job_cancel_count;
size_t watch_release_count;
size_t explicit_watch_release_count;
size_t lease_watch_release_count;
size_t shutdown_watch_release_count;
size_t unexpected_key_count;
} daemon_release_tracker_t;
static void record_job_cancel(const char *project_key, void *context) {
daemon_release_tracker_t *tracker = context;
tracker->job_cancel_count++;
if (strcmp(project_key, "project-shared") == 0) {
tracker->shared_job_cancel_count++;
} else if (strcmp(project_key, "project-lease-job") == 0) {
tracker->lease_job_cancel_count++;
} else if (strcmp(project_key, "project-shutdown-job") == 0) {
tracker->shutdown_job_cancel_count++;
} else {
tracker->unexpected_key_count++;
}
}
static void record_watch_release(const char *project_key, void *context) {
daemon_release_tracker_t *tracker = context;
tracker->watch_release_count++;
if (strcmp(project_key, "project-explicit-watch") == 0) {
tracker->explicit_watch_release_count++;
} else if (strcmp(project_key, "project-lease-watch") == 0) {
tracker->lease_watch_release_count++;
} else if (strcmp(project_key, "project-shutdown-watch") == 0) {
tracker->shutdown_watch_release_count++;
} else {
tracker->unexpected_key_count++;
}
}
static bool install_release_tracker(cbm_daemon_coordinator_t *coordinator,
daemon_release_tracker_t *tracker) {
const cbm_daemon_coordinator_hooks_t hooks = {
.cancel_job = record_job_cancel,
.release_watch = record_watch_release,
.context = tracker,
};
return cbm_daemon_coordinator_set_hooks(coordinator, &hooks);
}
TEST(daemon_client_ids_are_connection_bound) {
cbm_daemon_coordinator_t *c = cbm_daemon_coordinator_new(250);
ASSERT_NOT_NULL(c);
cbm_daemon_client_id_t a = cbm_daemon_client_connected(c, 1000);
cbm_daemon_client_id_t b = cbm_daemon_client_connected(c, 1001);
ASSERT_NEQ(a, CBM_DAEMON_CLIENT_ID_INVALID);
ASSERT_NEQ(b, CBM_DAEMON_CLIENT_ID_INVALID);
ASSERT_NEQ(a, b);
ASSERT_EQ(cbm_daemon_active_clients(c), 2);
cbm_daemon_subscription_id_t rejected = UINT64_MAX;
ASSERT_EQ(cbm_daemon_job_subscribe(c, UINT64_MAX, "project-a", &rejected),
CBM_DAEMON_SUBSCRIPTION_REJECTED);
ASSERT_EQ(rejected, CBM_DAEMON_SUBSCRIPTION_ID_INVALID);
ASSERT_TRUE(cbm_daemon_client_disconnected(c, a, 1010));
ASSERT_FALSE(cbm_daemon_client_disconnected(c, a, 1011));
rejected = UINT64_MAX;
ASSERT_EQ(cbm_daemon_job_subscribe(c, a, "project-a", &rejected),
CBM_DAEMON_SUBSCRIPTION_REJECTED);
ASSERT_EQ(rejected, CBM_DAEMON_SUBSCRIPTION_ID_INVALID);
cbm_daemon_client_id_t c_id = cbm_daemon_client_connected(c, 1012);
ASSERT_NEQ(c_id, CBM_DAEMON_CLIENT_ID_INVALID);
ASSERT_NEQ(c_id, a); /* closed connection IDs are never recycled */
ASSERT_NEQ(c_id, b);
ASSERT_EQ(cbm_daemon_active_clients(c), 2);
cbm_daemon_coordinator_free(c);
PASS();
}
TEST(daemon_shared_job_survives_until_final_subscriber_disconnects) {
cbm_daemon_coordinator_t *c = cbm_daemon_coordinator_new(250);
daemon_release_tracker_t tracker = {0};
ASSERT_NOT_NULL(c);
ASSERT_TRUE(install_release_tracker(c, &tracker));
cbm_daemon_client_id_t a = cbm_daemon_client_connected(c, 2000);
cbm_daemon_client_id_t b = cbm_daemon_client_connected(c, 2000);
cbm_daemon_client_id_t observer = cbm_daemon_client_connected(c, 2000);
ASSERT_NEQ(a, CBM_DAEMON_CLIENT_ID_INVALID);
ASSERT_NEQ(b, CBM_DAEMON_CLIENT_ID_INVALID);
ASSERT_NEQ(observer, CBM_DAEMON_CLIENT_ID_INVALID);
cbm_daemon_subscription_id_t a_first = CBM_DAEMON_SUBSCRIPTION_ID_INVALID;
cbm_daemon_subscription_id_t a_second = CBM_DAEMON_SUBSCRIPTION_ID_INVALID;
cbm_daemon_subscription_id_t b_subscription = CBM_DAEMON_SUBSCRIPTION_ID_INVALID;
ASSERT_EQ(cbm_daemon_job_subscribe(c, a, "project-shared", &a_first),
CBM_DAEMON_SUBSCRIPTION_STARTED);
ASSERT_EQ(cbm_daemon_job_subscribe(c, a, "project-shared", &a_second),
CBM_DAEMON_SUBSCRIPTION_JOINED);
ASSERT_EQ(cbm_daemon_job_subscribe(c, b, "project-shared", &b_subscription),
CBM_DAEMON_SUBSCRIPTION_JOINED);
ASSERT_NEQ(a_first, CBM_DAEMON_SUBSCRIPTION_ID_INVALID);
ASSERT_NEQ(a_second, CBM_DAEMON_SUBSCRIPTION_ID_INVALID);
ASSERT_NEQ(b_subscription, CBM_DAEMON_SUBSCRIPTION_ID_INVALID);
ASSERT_NEQ(a_first, a_second);
ASSERT_NEQ(a_first, b_subscription);
ASSERT_NEQ(a_second, b_subscription);
ASSERT_EQ(cbm_daemon_active_jobs(c), 1);
ASSERT_EQ(cbm_daemon_job_state(c, "project-shared"), CBM_DAEMON_JOB_RUNNING);
ASSERT_EQ(cbm_daemon_job_subscribers(c, "project-shared"), 3);
ASSERT_FALSE(cbm_daemon_job_unsubscribe(c, b, a_first));
ASSERT_EQ(cbm_daemon_job_subscribers(c, "project-shared"), 3);
ASSERT_TRUE(cbm_daemon_job_unsubscribe(c, a, a_first));
ASSERT_FALSE(cbm_daemon_job_unsubscribe(c, a, a_first));
ASSERT_EQ(cbm_daemon_job_subscribers(c, "project-shared"), 2);
ASSERT_EQ(tracker.job_cancel_count, 0);
ASSERT_TRUE(cbm_daemon_client_disconnected(c, a, 2001));
ASSERT_EQ(cbm_daemon_active_jobs(c), 1);
ASSERT_EQ(cbm_daemon_job_subscribers(c, "project-shared"), 1);
ASSERT_EQ(tracker.job_cancel_count, 0);
/* The final subscription requests cancellation even while the daemon
* remains RUNNING because unrelated clients are still connected. */
ASSERT_TRUE(cbm_daemon_job_unsubscribe(c, b, b_subscription));
ASSERT_FALSE(cbm_daemon_job_unsubscribe(c, b, b_subscription));
ASSERT_EQ(cbm_daemon_coordinator_state(c), CBM_DAEMON_COORDINATOR_RUNNING);
ASSERT_EQ(cbm_daemon_active_jobs(c), 1);
ASSERT_EQ(cbm_daemon_job_subscribers(c, "project-shared"), 0);
ASSERT_EQ(cbm_daemon_job_state(c, "project-shared"), CBM_DAEMON_JOB_CANCEL_REQUESTED);
ASSERT_EQ(tracker.job_cancel_count, 1);
ASSERT_EQ(tracker.shared_job_cancel_count, 1);
ASSERT_EQ(tracker.unexpected_key_count, 0);
/* Cancellation is asynchronous: the job remains active until its worker
* has actually been reaped, and duplicate owner cleanup cannot re-fire it. */
ASSERT_TRUE(cbm_daemon_client_disconnected(c, b, 2002));
ASSERT_EQ(tracker.job_cancel_count, 1);
ASSERT_EQ(cbm_daemon_active_clients(c), 1);
ASSERT_EQ(cbm_daemon_active_jobs(c), 1);
ASSERT_TRUE(cbm_daemon_job_reaped(c, "project-shared", 2003));
ASSERT_FALSE(cbm_daemon_job_reaped(c, "project-shared", 2004));
ASSERT_EQ(cbm_daemon_active_jobs(c), 0);
ASSERT_EQ(cbm_daemon_job_state(c, "project-shared"), CBM_DAEMON_JOB_NONE);
cbm_daemon_coordinator_free(c);
PASS();
}
TEST(daemon_watch_subscription_ids_are_connection_bound) {
cbm_daemon_coordinator_t *c = cbm_daemon_coordinator_new(250);
daemon_release_tracker_t tracker = {0};
ASSERT_NOT_NULL(c);
ASSERT_TRUE(install_release_tracker(c, &tracker));
cbm_daemon_client_id_t a = cbm_daemon_client_connected(c, 3000);
cbm_daemon_client_id_t b = cbm_daemon_client_connected(c, 3000);
cbm_daemon_subscription_id_t a_watch = CBM_DAEMON_SUBSCRIPTION_ID_INVALID;
cbm_daemon_subscription_id_t b_watch = CBM_DAEMON_SUBSCRIPTION_ID_INVALID;
ASSERT_EQ(cbm_daemon_watch_subscribe(c, a, "project-explicit-watch", &a_watch),
CBM_DAEMON_SUBSCRIPTION_STARTED);
ASSERT_EQ(cbm_daemon_watch_subscribe(c, b, "project-explicit-watch", &b_watch),
CBM_DAEMON_SUBSCRIPTION_JOINED);
ASSERT_NEQ(a_watch, CBM_DAEMON_SUBSCRIPTION_ID_INVALID);
ASSERT_NEQ(b_watch, CBM_DAEMON_SUBSCRIPTION_ID_INVALID);
ASSERT_NEQ(a_watch, b_watch);
ASSERT_EQ(cbm_daemon_watch_subscribers(c, "project-explicit-watch"), 2);
ASSERT_FALSE(cbm_daemon_watch_unsubscribe(c, b, a_watch));
ASSERT_TRUE(cbm_daemon_watch_unsubscribe(c, a, a_watch));
ASSERT_FALSE(cbm_daemon_watch_unsubscribe(c, a, a_watch));
ASSERT_EQ(cbm_daemon_active_watches(c), 1);
ASSERT_EQ(cbm_daemon_watch_subscribers(c, "project-explicit-watch"), 1);
ASSERT_EQ(tracker.watch_release_count, 0);
ASSERT_TRUE(cbm_daemon_watch_unsubscribe(c, b, b_watch));
ASSERT_FALSE(cbm_daemon_watch_unsubscribe(c, b, b_watch));
ASSERT_EQ(cbm_daemon_active_watches(c), 0);
ASSERT_EQ(cbm_daemon_watch_subscribers(c, "project-explicit-watch"), 0);
ASSERT_EQ(tracker.watch_release_count, 1);
ASSERT_EQ(tracker.explicit_watch_release_count, 1);
ASSERT_EQ(tracker.unexpected_key_count, 0);
cbm_daemon_coordinator_free(c);
PASS();
}
TEST(daemon_heartbeat_extends_lease_and_expiry_releases_connection) {
cbm_daemon_coordinator_t *c = cbm_daemon_coordinator_new(250);
daemon_release_tracker_t tracker = {0};
ASSERT_NOT_NULL(c);
ASSERT_TRUE(install_release_tracker(c, &tracker));
cbm_daemon_client_id_t owner = cbm_daemon_client_connected(c, 1000);
cbm_daemon_client_id_t observer = cbm_daemon_client_connected(c, 1200);
cbm_daemon_subscription_id_t job_subscription = CBM_DAEMON_SUBSCRIPTION_ID_INVALID;
cbm_daemon_subscription_id_t watch_subscription = CBM_DAEMON_SUBSCRIPTION_ID_INVALID;
ASSERT_EQ(cbm_daemon_job_subscribe(c, owner, "project-lease-job", &job_subscription),
CBM_DAEMON_SUBSCRIPTION_STARTED);
ASSERT_EQ(cbm_daemon_watch_subscribe(c, owner, "project-lease-watch", &watch_subscription),
CBM_DAEMON_SUBSCRIPTION_STARTED);
ASSERT_TRUE(cbm_daemon_client_heartbeat(c, owner, 1200));
ASSERT_EQ(cbm_daemon_expire_leases(c, 1250), 0);
ASSERT_EQ(cbm_daemon_active_clients(c), 2);
ASSERT_TRUE(cbm_daemon_client_heartbeat(c, observer, 1449));
ASSERT_EQ(cbm_daemon_expire_leases(c, 1449), 0);
ASSERT_EQ(cbm_daemon_expire_leases(c, 1450), 1);
ASSERT_EQ(cbm_daemon_active_clients(c), 1);
ASSERT_EQ(cbm_daemon_coordinator_state(c), CBM_DAEMON_COORDINATOR_RUNNING);
ASSERT_FALSE(cbm_daemon_client_heartbeat(c, owner, 1451));
ASSERT_FALSE(cbm_daemon_client_disconnected(c, owner, 1451));
ASSERT_EQ(cbm_daemon_job_subscribers(c, "project-lease-job"), 0);
ASSERT_EQ(cbm_daemon_job_state(c, "project-lease-job"), CBM_DAEMON_JOB_CANCEL_REQUESTED);
ASSERT_EQ(cbm_daemon_active_jobs(c), 1);
ASSERT_EQ(cbm_daemon_active_watches(c), 0);
ASSERT_EQ(tracker.job_cancel_count, 1);
ASSERT_EQ(tracker.lease_job_cancel_count, 1);
ASSERT_EQ(tracker.watch_release_count, 1);
ASSERT_EQ(tracker.lease_watch_release_count, 1);
ASSERT_EQ(tracker.unexpected_key_count, 0);
ASSERT_TRUE(cbm_daemon_job_reaped(c, "project-lease-job", 1452));
ASSERT_EQ(cbm_daemon_active_jobs(c), 0);
cbm_daemon_coordinator_free(c);
PASS();
}
TEST(daemon_last_client_stops_immediately_and_releases_owned_work) {
cbm_daemon_coordinator_t *c = cbm_daemon_coordinator_new(250);
daemon_release_tracker_t tracker = {0};
ASSERT_NOT_NULL(c);
ASSERT_TRUE(install_release_tracker(c, &tracker));
cbm_daemon_client_id_t client = cbm_daemon_client_connected(c, 5000);
cbm_daemon_subscription_id_t job_subscription = CBM_DAEMON_SUBSCRIPTION_ID_INVALID;
cbm_daemon_subscription_id_t watch_subscription = CBM_DAEMON_SUBSCRIPTION_ID_INVALID;
ASSERT_NEQ(client, CBM_DAEMON_CLIENT_ID_INVALID);
ASSERT_EQ(cbm_daemon_coordinator_state(c), CBM_DAEMON_COORDINATOR_RUNNING);
ASSERT_EQ(cbm_daemon_job_subscribe(c, client, "project-shutdown-job", &job_subscription),
CBM_DAEMON_SUBSCRIPTION_STARTED);
ASSERT_EQ(cbm_daemon_watch_subscribe(c, client, "project-shutdown-watch", &watch_subscription),
CBM_DAEMON_SUBSCRIPTION_STARTED);
ASSERT_NEQ(job_subscription, CBM_DAEMON_SUBSCRIPTION_ID_INVALID);
ASSERT_NEQ(watch_subscription, CBM_DAEMON_SUBSCRIPTION_ID_INVALID);
ASSERT_EQ(cbm_daemon_active_jobs(c), 1);
ASSERT_EQ(cbm_daemon_active_watches(c), 1);
ASSERT_EQ(cbm_daemon_watch_subscribers(c, "project-shutdown-watch"), 1);
ASSERT_TRUE(cbm_daemon_client_disconnected(c, client, 5200));
ASSERT_EQ(cbm_daemon_active_clients(c), 0);
ASSERT_EQ(cbm_daemon_coordinator_state(c), CBM_DAEMON_COORDINATOR_STOPPING);
ASSERT_EQ(cbm_daemon_active_jobs(c), 1);
ASSERT_EQ(cbm_daemon_active_watches(c), 0);
ASSERT_EQ(cbm_daemon_job_subscribers(c, "project-shutdown-job"), 0);
ASSERT_EQ(cbm_daemon_job_state(c, "project-shutdown-job"), CBM_DAEMON_JOB_CANCEL_REQUESTED);
ASSERT_EQ(cbm_daemon_watch_subscribers(c, "project-shutdown-watch"), 0);
ASSERT_EQ(tracker.job_cancel_count, 1);
ASSERT_EQ(tracker.shutdown_job_cancel_count, 1);
ASSERT_EQ(tracker.watch_release_count, 1);
ASSERT_EQ(tracker.shutdown_watch_release_count, 1);
ASSERT_EQ(tracker.unexpected_key_count, 0);
/* There is no post-client grace before STOPPING, but process exit waits
* until the cancelled worker is confirmed reaped. */
ASSERT_FALSE(cbm_daemon_should_exit(c, 5200));
ASSERT_FALSE(cbm_daemon_should_exit(c, 10000));
/* STOPPING is terminal for this daemon instance. A late connection or a
* stale connection ID cannot resurrect it or enqueue fresh work. */
ASSERT_EQ(cbm_daemon_client_connected(c, 5201), CBM_DAEMON_CLIENT_ID_INVALID);
cbm_daemon_subscription_id_t rejected = UINT64_MAX;
ASSERT_EQ(cbm_daemon_job_subscribe(c, client, "project-too-late", &rejected),
CBM_DAEMON_SUBSCRIPTION_REJECTED);
ASSERT_EQ(rejected, CBM_DAEMON_SUBSCRIPTION_ID_INVALID);
rejected = UINT64_MAX;
ASSERT_EQ(cbm_daemon_watch_subscribe(c, client, "project-too-late", &rejected),
CBM_DAEMON_SUBSCRIPTION_REJECTED);
ASSERT_EQ(rejected, CBM_DAEMON_SUBSCRIPTION_ID_INVALID);
ASSERT_FALSE(cbm_daemon_client_disconnected(c, client, 5201));
ASSERT_EQ(tracker.job_cancel_count, 1);
ASSERT_EQ(tracker.watch_release_count, 1);
ASSERT_TRUE(cbm_daemon_job_reaped(c, "project-shutdown-job", 5202));
ASSERT_EQ(cbm_daemon_active_jobs(c), 0);
ASSERT_EQ(cbm_daemon_job_state(c, "project-shutdown-job"), CBM_DAEMON_JOB_NONE);
ASSERT_TRUE(cbm_daemon_should_exit(c, 5202));
cbm_daemon_coordinator_free(c);
PASS();
}
TEST(daemon_completed_job_is_not_cancelled_on_later_disconnect) {
cbm_daemon_coordinator_t *c = cbm_daemon_coordinator_new(250);
daemon_release_tracker_t tracker = {0};
ASSERT_NOT_NULL(c);
ASSERT_TRUE(install_release_tracker(c, &tracker));
cbm_daemon_client_id_t client = cbm_daemon_client_connected(c, 6000);
cbm_daemon_subscription_id_t subscription = CBM_DAEMON_SUBSCRIPTION_ID_INVALID;
ASSERT_EQ(cbm_daemon_job_subscribe(c, client, "project-normal", &subscription),
CBM_DAEMON_SUBSCRIPTION_STARTED);
ASSERT_NEQ(subscription, CBM_DAEMON_SUBSCRIPTION_ID_INVALID);
ASSERT_EQ(cbm_daemon_job_state(c, "project-normal"), CBM_DAEMON_JOB_RUNNING);
ASSERT_TRUE(cbm_daemon_job_completed(c, "project-normal", 6100));
ASSERT_FALSE(cbm_daemon_job_completed(c, "project-normal", 6101));
ASSERT_FALSE(cbm_daemon_job_reaped(c, "project-normal", 6101));
ASSERT_EQ(cbm_daemon_active_jobs(c), 0);
ASSERT_EQ(cbm_daemon_job_subscribers(c, "project-normal"), 0);
ASSERT_EQ(cbm_daemon_job_state(c, "project-normal"), CBM_DAEMON_JOB_NONE);
ASSERT_TRUE(cbm_daemon_client_disconnected(c, client, 6200));
ASSERT_EQ(cbm_daemon_coordinator_state(c), CBM_DAEMON_COORDINATOR_STOPPING);
ASSERT_EQ(tracker.job_cancel_count, 0);
ASSERT_EQ(tracker.unexpected_key_count, 0);
ASSERT_TRUE(cbm_daemon_should_exit(c, 6200));
cbm_daemon_coordinator_free(c);
PASS();
}
TEST(daemon_frame_header_rejects_wrong_protocol_and_oversize) {
uint8_t header[CBM_DAEMON_FRAME_HEADER_SIZE];
cbm_daemon_frame_t frame;
ASSERT_TRUE(cbm_daemon_frame_header_encode(header, CBM_DAEMON_FRAME_REQUEST, 0, 1234));
ASSERT_TRUE(cbm_daemon_frame_header_decode(header, &frame));
ASSERT_EQ(frame.type, CBM_DAEMON_FRAME_REQUEST);
ASSERT_EQ(frame.flags, 0);
ASSERT_EQ(frame.length, 1234);
header[4] = (uint8_t)(CBM_DAEMON_RENDEZVOUS_FRAME_VERSION + 1);
ASSERT_FALSE(cbm_daemon_frame_header_decode(header, &frame));
ASSERT_FALSE(cbm_daemon_frame_header_encode(header, CBM_DAEMON_FRAME_REQUEST, 0,
CBM_DAEMON_MAX_FRAME_SIZE + 1));
PASS();
}
static FILE *message_stream_bytes(const void *bytes, size_t length) {
FILE *f = tmpfile();
if (!f) {
return NULL;
}
(void)fwrite(bytes, 1, length, f);
rewind(f);
return f;
}
static FILE *message_stream(const char *bytes) {
return message_stream_bytes(bytes, strlen(bytes));
}
TEST(daemon_bridge_reads_newline_and_content_length_messages) {
char *message = NULL;
bool framed = false;
FILE *line = message_stream("{\"jsonrpc\":\"2.0\",\"method\":\"ping\"}\n");
ASSERT_NOT_NULL(line);
ASSERT_EQ(cbm_mcp_read_message(line, &message, &framed), 1);
ASSERT_FALSE(framed);
ASSERT_STR_EQ(message, "{\"jsonrpc\":\"2.0\",\"method\":\"ping\"}");
free(message);
message = NULL;
fclose(line);
const char *body = "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/list\"}";
char wire[512];
snprintf(wire, sizeof(wire), "Content-Length: %zu\r\nX-Test: yes\r\n\r\n%s", strlen(body),
body);
FILE *content_length = message_stream(wire);
ASSERT_NOT_NULL(content_length);
ASSERT_EQ(cbm_mcp_read_message(content_length, &message, &framed), 1);
ASSERT_TRUE(framed);
ASSERT_STR_EQ(message, body);
free(message);
fclose(content_length);
PASS();
}
TEST(daemon_bridge_rejects_malformed_content_length) {
char *message = NULL;
bool framed = false;
FILE *wire = message_stream("Content-Length: 4junk\r\n\r\nping");
ASSERT_NOT_NULL(wire);
ASSERT_EQ(cbm_mcp_read_message(wire, &message, &framed), -1);
ASSERT_NULL(message);
fclose(wire);
PASS();
}
TEST(daemon_bridge_rejects_embedded_nul_body) {
static const unsigned char wire[] = {
'C', 'o', 'n', 't', 'e', 'n', 't', '-', 'L', 'e', 'n', 'g', 't',
'h', ':', ' ', '4', '\r', '\n', '\r', '\n', 'a', '\0', 'b', 'c',
};
char *message = NULL;
bool framed = false;
FILE *stream = message_stream_bytes(wire, sizeof(wire));
ASSERT_NOT_NULL(stream);
ASSERT_EQ(cbm_mcp_read_message(stream, &message, &framed), -1);
ASSERT_NULL(message);
fclose(stream);
PASS();
}
TEST(daemon_bridge_rejects_oversized_headers) {
const size_t fill_length = 9000;
const char prefix[] = "Content-Length: 2\r\nX-Fill: ";
const char suffix[] = "\r\n\r\n{}";
size_t wire_length = sizeof(prefix) - 1 + fill_length + sizeof(suffix) - 1;
char *wire_bytes = malloc(wire_length);
ASSERT_NOT_NULL(wire_bytes);
memcpy(wire_bytes, prefix, sizeof(prefix) - 1);
memset(wire_bytes + sizeof(prefix) - 1, 'x', fill_length);
memcpy(wire_bytes + sizeof(prefix) - 1 + fill_length, suffix, sizeof(suffix) - 1);
char *message = NULL;
bool framed = false;
FILE *stream = message_stream_bytes(wire_bytes, wire_length);
free(wire_bytes);
ASSERT_NOT_NULL(stream);
ASSERT_EQ(cbm_mcp_read_message(stream, &message, &framed), -1);
ASSERT_NULL(message);
fclose(stream);
PASS();
}
TEST(daemon_sessions_keep_distinct_roots_and_allowed_root_policy) {
cbm_mcp_server_t *a = cbm_mcp_server_new(NULL);
cbm_mcp_server_t *b = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(a);
ASSERT_NOT_NULL(b);
ASSERT_TRUE(cbm_mcp_server_set_session_context(a, "/tmp/cbm-session-a", "/tmp"));
ASSERT_TRUE(cbm_mcp_server_set_session_context(b, "/tmp/cbm-session-b", NULL));
cbm_mcp_server_set_background_tasks(a, false);
cbm_mcp_server_set_background_tasks(b, false);
ASSERT_STR_EQ(cbm_mcp_server_session_root(a), "/tmp/cbm-session-a");
ASSERT_STR_EQ(cbm_mcp_server_allowed_root(a), "/tmp");
ASSERT_STR_EQ(cbm_mcp_server_session_root(b), "/tmp/cbm-session-b");
ASSERT_NULL(cbm_mcp_server_allowed_root(b));
ASSERT_STR_NEQ(cbm_mcp_server_session_project(a), cbm_mcp_server_session_project(b));
cbm_mcp_server_free(a);
cbm_mcp_server_free(b);
PASS();
}
/* The session policy keeps a root in the spelling the daemon canonicalized it
* to - the platform's native form, backslashes on Windows. The sensitive-root
* and allowed-root containment checks compare that spelling byte-exact against
* HOME and the granted roots, so a root respelled with forward slashes on the
* way in stopped matching them, and $HOME was admitted for auto-index and
* watch on Windows. One directory being one root for the job registry is
* folded at that comparison, never by respelling the policy. */
TEST(daemon_session_context_keeps_the_policy_spelling_of_a_root) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_mcp_server_set_background_tasks(srv, false);
ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, "C:\\repos\\cbm", "C:\\repos"));
ASSERT_STR_EQ(cbm_mcp_server_session_root(srv), "C:\\repos\\cbm");
ASSERT_STR_EQ(cbm_mcp_server_allowed_root(srv), "C:\\repos");
cbm_mcp_server_free(srv);
PASS();
}
SUITE(daemon) {
RUN_TEST(daemon_client_ids_are_connection_bound);
RUN_TEST(daemon_shared_job_survives_until_final_subscriber_disconnects);
RUN_TEST(daemon_watch_subscription_ids_are_connection_bound);
RUN_TEST(daemon_heartbeat_extends_lease_and_expiry_releases_connection);
RUN_TEST(daemon_last_client_stops_immediately_and_releases_owned_work);
RUN_TEST(daemon_completed_job_is_not_cancelled_on_later_disconnect);
RUN_TEST(daemon_frame_header_rejects_wrong_protocol_and_oversize);
RUN_TEST(daemon_bridge_reads_newline_and_content_length_messages);
RUN_TEST(daemon_bridge_rejects_malformed_content_length);
RUN_TEST(daemon_bridge_rejects_embedded_nul_body);
RUN_TEST(daemon_bridge_rejects_oversized_headers);
RUN_TEST(daemon_sessions_keep_distinct_roots_and_allowed_root_policy);
RUN_TEST(daemon_session_context_keeps_the_policy_spelling_of_a_root);
}