diff --git a/src/daemon/application.c b/src/daemon/application.c index 69ff52ec7..d005f9217 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -1588,6 +1588,33 @@ static bool application_index_args_normalize_defaults(yyjson_mut_val *root) { return true; } +/* One directory is one root. The auto-index job spells repo_path the way the + * session policy holds it - the platform's native form, backslashes on + * Windows - while an explicit index_repository request arrives in the + * handler's forward-slash spelling. Compared byte-exact the two never matched + * on Windows, and the request was refused as an options conflict instead of + * joining the job already running for its root. The policy keeps its + * spelling: the sensitive-root and allowed-root containment checks match it + * byte-exact against HOME and the granted roots, and respelling it there + * admitted $HOME. So the fold happens here, on this comparison's private copy, + * and nothing the daemon stores changes. */ +static bool application_index_args_fold_repo_path(yyjson_mut_doc *document) { + yyjson_mut_val *root = yyjson_mut_doc_get_root(document); + yyjson_mut_val *repo_path = yyjson_mut_obj_get(root, "repo_path"); + if (!repo_path || !yyjson_mut_is_str(repo_path)) { + return true; + } + char *folded = strdup(yyjson_mut_get_str(repo_path)); + if (!folded) { + return false; + } + cbm_normalize_path_sep(folded); + yyjson_mut_val *key = yyjson_mut_str(document, "repo_path"); + yyjson_mut_val *value = yyjson_mut_strcpy(document, folded); + free(folded); + return key && value && yyjson_mut_obj_replace(root, key, value); +} + static bool application_index_args_equal(const char *left, const char *right) { if (!left || !right) { return false; @@ -1600,6 +1627,8 @@ static bool application_index_args_equal(const char *left, const char *right) { yyjson_mut_val *right_root = right_copy ? yyjson_mut_doc_get_root(right_copy) : NULL; bool equal = application_index_args_normalize_defaults(left_root) && application_index_args_normalize_defaults(right_root) && + application_index_args_fold_repo_path(left_copy) && + application_index_args_fold_repo_path(right_copy) && yyjson_mut_equals(left_root, right_root); yyjson_mut_doc_free(left_copy); yyjson_mut_doc_free(right_copy); @@ -1608,6 +1637,10 @@ static bool application_index_args_equal(const char *left, const char *right) { return equal; } +bool cbm_daemon_application_index_args_equal_for_test(const char *left, const char *right) { + return application_index_args_equal(left, right); +} + /* Caller holds application->mutex. Keeping watcher ownership validation and * this admission in the same critical section closes the unwatch race. */ static cbm_daemon_application_job_t *application_job_subscribe_locked( diff --git a/src/daemon/application_internal.h b/src/daemon/application_internal.h index 04f24421a..bd06e4efe 100644 --- a/src/daemon/application_internal.h +++ b/src/daemon/application_internal.h @@ -45,4 +45,10 @@ int cbm_daemon_application_busy_queue_waits_for_test(void); char *cbm_daemon_application_framable_response_for_test(char *response, const cbm_jsonrpc_request_t *request); +/* The job registry's index-argument equality: the running job's args against + * a request's. Exposed because the two repo_path spellings it folds - the + * session policy's native root and a handler's forward-slash one - only differ + * on Windows, while the fold itself runs on every platform. */ +bool cbm_daemon_application_index_args_equal_for_test(const char *left, const char *right); + #endif /* CBM_DAEMON_APPLICATION_INTERNAL_H */ diff --git a/tests/test_daemon.c b/tests/test_daemon.c index fb0d0095a..04aece649 100644 --- a/tests/test_daemon.c +++ b/tests/test_daemon.c @@ -467,6 +467,26 @@ TEST(daemon_sessions_keep_distinct_roots_and_allowed_root_policy) { 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); @@ -480,4 +500,5 @@ SUITE(daemon) { 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); } diff --git a/tests/test_daemon_application.c b/tests/test_daemon_application.c index fceeb7130..cea698fda 100644 --- a/tests/test_daemon_application.c +++ b/tests/test_daemon_application.c @@ -5382,6 +5382,30 @@ TEST(daemon_application_oversized_reply_is_a_jsonrpc_error_not_a_death) { PASS(); } +/* One directory is one root for the job registry. The auto-index job spells + * repo_path the way the session policy holds it - the platform's native form, + * backslashes on Windows - while an explicit index_repository request arrives + * in the handler's forward-slash spelling. Compared byte-exact the two never + * matched on Windows, and the request was refused as an options conflict + * instead of joining the job already running for its root. The fold runs on + * every platform, so this binds wherever the suite runs; every other option + * stays exact. */ +TEST(daemon_application_index_args_compare_repo_path_separator_equivalently) { + ASSERT_TRUE(cbm_daemon_application_index_args_equal_for_test( + "{\"repo_path\":\"C:\\\\repos\\\\cbm\"}", "{\"repo_path\":\"C:/repos/cbm\"}")); + ASSERT_TRUE(cbm_daemon_application_index_args_equal_for_test( + "{\"repo_path\":\"C:\\\\repos\\\\cbm\",\"mode\":\"full\"}", + "{\"mode\":\"full\",\"repo_path\":\"C:/repos/cbm\"}")); + ASSERT_FALSE(cbm_daemon_application_index_args_equal_for_test( + "{\"repo_path\":\"C:\\\\repos\\\\cbm\"}", "{\"repo_path\":\"C:/repos/cbm2\"}")); + ASSERT_FALSE(cbm_daemon_application_index_args_equal_for_test( + "{\"repo_path\":\"C:\\\\repos\\\\cbm\"}", "{\"repo_path\":\"C:/repos/cbm/sub\"}")); + ASSERT_FALSE(cbm_daemon_application_index_args_equal_for_test( + "{\"repo_path\":\"C:\\\\repos\\\\cbm\",\"mode\":\"incremental\"}", + "{\"repo_path\":\"C:/repos/cbm\"}")); + PASS(); +} + SUITE(daemon_application) { RUN_TEST(daemon_application_oversized_reply_is_a_jsonrpc_error_not_a_death); RUN_TEST(daemon_application_new_session_does_not_retain_initial_store); @@ -5400,6 +5424,7 @@ SUITE(daemon_application) { RUN_TEST(daemon_application_initialize_coalesces_auto_index_for_full_sessions); RUN_TEST(daemon_application_sensitive_root_blocks_auto_index_but_preserves_controls); RUN_TEST(daemon_application_sensitive_root_blocks_watch_but_preserves_controls); + RUN_TEST(daemon_application_index_args_compare_repo_path_separator_equivalently); RUN_TEST(daemon_application_auto_index_honors_tracked_file_limit); RUN_TEST(daemon_application_auto_index_file_count_handles_literal_metacharacter_path); RUN_TEST(daemon_application_auto_index_file_count_supports_non_git_roots);