diff --git a/src/daemon/application.c b/src/daemon/application.c index 7e4a35be7..5b8ea28e2 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -2324,9 +2324,7 @@ static cbm_daemon_runtime_application_status_t application_set_context( if (canonical && allowed_present) { canonical = cbm_canonical_path(allowed, canonical_allowed, sizeof(canonical_allowed)); } - struct stat root_status; - canonical = - canonical && stat(canonical_root, &root_status) == 0 && S_ISDIR(root_status.st_mode); + canonical = canonical && cbm_is_dir(canonical_root); bool set = canonical && cbm_mcp_server_set_session_context(session->mcp, canonical_root, allowed_present ? canonical_allowed : NULL); @@ -3339,9 +3337,8 @@ static int application_background_index(cbm_daemon_application_t *application, return -1; } char canonical_root[APPLICATION_PATH_CAP]; - struct stat root_status; if (!cbm_canonical_path(root_path, canonical_root, sizeof(canonical_root)) || - stat(canonical_root, &root_status) != 0 || !S_ISDIR(root_status.st_mode)) { + !cbm_is_dir(canonical_root)) { return -1; } yyjson_mut_doc *document = yyjson_mut_doc_new(NULL); diff --git a/src/git/git_context.c b/src/git/git_context.c index f739c46e6..47956562f 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -2,6 +2,7 @@ #include "foundation/compat_fs.h" #include "foundation/constants.h" +#include "foundation/platform.h" #include "foundation/str_util.h" #include @@ -9,7 +10,6 @@ #include #include #include -#include enum { GIT_CMD_MAX = 1024, @@ -254,8 +254,7 @@ int cbm_git_context_resolve(const char *path, cbm_git_context_t *out) { return CBM_NOT_FOUND; } - struct stat st; - out->root_exists = (stat(path, &st) == 0); + out->root_exists = cbm_is_dir(path); if (!out->root_exists) { return 0; } diff --git a/src/ui/http_server.c b/src/ui/http_server.c index 18589d8d8..a7cf615d8 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -705,7 +705,6 @@ static void handle_processes(cbm_http_conn_t *c) { /* ── Directory browser ────────────────────────────────────────── */ -#include static void append_roots_json(char *buf, size_t bufsz, int *pos) { http_appendf(buf, bufsz, pos, ",\"roots\":["); @@ -760,7 +759,7 @@ static void handle_browse(cbm_http_conn_t *c, const cbm_http_req_t *req) { return; } - DIR *dir = opendir(path); + cbm_dir_t *dir = cbm_opendir(path); if (!dir) { cbm_http_replyf(c, 403, g_cors_json, "{\"error\":\"cannot open directory\"}"); return; @@ -771,16 +770,16 @@ static void handle_browse(cbm_http_conn_t *c, const cbm_http_req_t *req) { int pos = 0; http_appendf(buf, sizeof(buf), &pos, "{\"path\":\"%s\",\"dirs\":[", path); - struct dirent *ent; + cbm_dirent_t *ent; int count = 0; - while ((ent = readdir(dir)) != NULL) { + while ((ent = cbm_readdir(dir)) != NULL) { /* Skip hidden dirs and . / .. */ - if (ent->d_name[0] == '.') + if (ent->name[0] == '.') continue; /* Check if it's actually a directory */ char full[2048]; - snprintf(full, sizeof(full), "%s/%s", path, ent->d_name); + snprintf(full, sizeof(full), "%s/%s", path, ent->name); if (!cbm_is_dir(full)) continue; @@ -789,7 +788,7 @@ static void handle_browse(cbm_http_conn_t *c, const cbm_http_req_t *req) { /* Escape directory name to prevent XSS (e.g., names with quotes/angle brackets) */ { char esc[512]; - cbm_json_escape(esc, (int)sizeof(esc), ent->d_name); + cbm_json_escape(esc, (int)sizeof(esc), ent->name); http_appendf(buf, sizeof(buf), &pos, "\"%s\"", esc); } if (pos >= (int)sizeof(buf)) { @@ -800,7 +799,7 @@ static void handle_browse(cbm_http_conn_t *c, const cbm_http_req_t *req) { if (count >= 200) break; /* safety limit */ } - closedir(dir); + cbm_closedir(dir); /* Parent path — escape to prevent injection */ char parent[1024]; diff --git a/tests/test_daemon_application.c b/tests/test_daemon_application.c index c639ace2f..1868b86ec 100644 --- a/tests/test_daemon_application.c +++ b/tests/test_daemon_application.c @@ -713,6 +713,49 @@ TEST(daemon_application_requires_immutable_explicit_context) { PASS(); } +/* Windows' narrow stat() interprets UTF-8 through the active ANSI code page. + * A valid repository root containing CJK text was therefore rejected before + * an MCP session could establish its immutable context. */ +TEST(daemon_application_accepts_non_ascii_session_context) { + char root[APP_TEST_PATH_CAP]; + (void)snprintf(root, sizeof(root), + "%s/cbm-app-context-\xE6\x97\xA5\xE6\x9C\xAC\xE8\xAA\x9E-XXXXXX", cbm_tmpdir()); + bool root_created = cbm_mkdtemp(root) != NULL; + cbm_daemon_application_t *application = cbm_daemon_application_new(NULL); + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *session = + application ? app_test_open(&callbacks, 109) : NULL; + uint8_t *context = NULL; + uint32_t context_length = 0; + uint8_t *response = NULL; + uint32_t response_length = 0; + bool encoded = + root_created && session && app_test_context_request(root, root, &context, &context_length); + cbm_daemon_runtime_application_status_t status = + encoded ? app_test_request(&callbacks, session, context, context_length, &response, + &response_length) + : CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + + if (session) { + callbacks.session_close(callbacks.context, session); + } + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + free(context); + free(response); + bool cleaned = th_rmtree(root) == 0; + + ASSERT_NOT_NULL(application); + ASSERT_NOT_NULL(session); + ASSERT_TRUE(root_created); + ASSERT_TRUE(encoded); + ASSERT_EQ(status, CBM_DAEMON_RUNTIME_APPLICATION_OK); + ASSERT_TRUE(stopped); + ASSERT_TRUE(cleaned); + PASS(); +} + TEST(daemon_application_mcp_notification_has_no_response) { cbm_daemon_application_t *application = cbm_daemon_application_new(NULL); cbm_daemon_runtime_application_callbacks_t callbacks = @@ -1783,6 +1826,53 @@ static bool app_env_backup_restore(app_env_backup_t *backup) { return status == 0; } +/* The Graph UI enters through the daemon's background coordinator rather than + * the MCP tool path. Keep that path UTF-8-safe as well. */ +TEST(daemon_application_ui_index_accepts_non_ascii_directory) { + app_env_backup_t cache_environment; + bool cache_saved = app_env_backup_capture(&cache_environment, "CBM_CACHE_DIR"); + char root[APP_TEST_PATH_CAP]; + char cache[APP_TEST_PATH_CAP]; + (void)snprintf(root, sizeof(root), + "%s/cbm-app-ui-index-\xE6\x97\xA5\xE6\x9C\xAC\xE8\xAA\x9E-XXXXXX", cbm_tmpdir()); + (void)snprintf(cache, sizeof(cache), "%s/cbm-app-ui-index-cache-XXXXXX", cbm_tmpdir()); + bool dirs_created = cbm_mkdtemp(root) != NULL && cbm_mkdtemp(cache) != NULL; + bool cache_set = cache_saved && dirs_created && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0; + + app_fake_worker_context_t fake; + app_fake_worker_context_init(&fake); + atomic_store(&fake.allow_completion, true); + cbm_daemon_application_worker_ops_t worker_ops = { + .context = &fake, + .start = app_fake_worker_start, + .poll = app_fake_worker_poll, + .cancel = app_fake_worker_cancel, + .log_path = app_fake_worker_log_path, + .destroy = app_fake_worker_destroy, + }; + cbm_daemon_application_config_t config = {.worker_ops = &worker_ops}; + cbm_daemon_application_t *application = cache_set ? cbm_daemon_application_new(&config) : NULL; + int result = application ? cbm_daemon_application_index(application, "", root) : -1; + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + bool root_cleaned = th_rmtree(root) == 0; + bool cache_cleaned = th_rmtree(cache) == 0; + bool cache_restored = app_env_backup_restore(&cache_environment); + + ASSERT_TRUE(cache_saved); + ASSERT_TRUE(dirs_created); + ASSERT_TRUE(cache_set); + ASSERT_NOT_NULL(application); + ASSERT_EQ(result, 0); + ASSERT_EQ(atomic_load(&fake.starts), 1); + ASSERT_EQ(atomic_load(&fake.destroys), 1); + ASSERT_TRUE(stopped); + ASSERT_TRUE(root_cleaned); + ASSERT_TRUE(cache_cleaned); + ASSERT_TRUE(cache_restored); + PASS(); +} + typedef struct { atomic_int starts; atomic_int start_failures_remaining; @@ -5087,6 +5177,7 @@ SUITE(daemon_application) { RUN_TEST(daemon_application_new_session_does_not_retain_initial_store); RUN_TEST(daemon_application_request_cancel_is_scoped_to_exact_token); RUN_TEST(daemon_application_requires_immutable_explicit_context); + RUN_TEST(daemon_application_accepts_non_ascii_session_context); RUN_TEST(daemon_application_ui_config_updates_are_masked_and_serialized); RUN_TEST(daemon_application_ui_config_rejects_noncanonical_frames); RUN_TEST(daemon_application_ui_readiness_proof_is_generation_bound_before_context); @@ -5097,6 +5188,7 @@ SUITE(daemon_application) { RUN_TEST(daemon_application_free_releases_live_watch_once); RUN_TEST(daemon_application_prune_clears_logical_watch_for_reregistration); RUN_TEST(daemon_application_initialize_coalesces_auto_index_for_full_sessions); + RUN_TEST(daemon_application_ui_index_accepts_non_ascii_directory); 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); diff --git a/tests/test_git_context.c b/tests/test_git_context.c index a384651a5..cc94f1d06 100644 --- a/tests/test_git_context.c +++ b/tests/test_git_context.c @@ -8,8 +8,9 @@ * (input_path), not to worktree_root. Joining it with worktree_root and then * string-stripping "/.git" left unresolved ".." components in the result. * - * These tests shell out to `git`, so they SKIP_PLATFORM on Windows (the CI - * shell there cannot init a repo via system()). + * The canonical_root tests shell out to `git`, so they SKIP_PLATFORM on + * Windows (the CI shell there cannot init a repo via system()). The root-path + * existence test runs on every platform. * * Reproduce-first guard: canonical_root_subdir is the genuine RED-without-the-fix * guard — a repo indexed from a subdirectory yields a relative --git-common-dir @@ -34,9 +35,29 @@ #include #endif -/* These helpers shell out to git and are only used by the non-Windows test - * bodies below; on Windows every test SKIP_PLATFORMs, so guard them here too or - * they'd be unused-static functions and fail the -Werror build. */ +/* The Windows narrow stat() API interprets UTF-8 paths through the active ANSI + * code page. That made an existing Unicode repository root appear absent and + * stopped Git discovery before git was invoked (#1240). */ +TEST(root_exists_non_ascii_directory) { + /* UTF-8 for "Japanese language", written as bytes to keep the source ASCII. */ + char *tmp = th_mktempdir("cbm_gitctx_\xE6\x97\xA5\xE6\x9C\xAC\xE8\xAA\x9E"); + if (!tmp) + FAIL("th_mktempdir returned NULL for non-ASCII path"); + + cbm_git_context_t ctx = {0}; + int rc = cbm_git_context_resolve(tmp, &ctx); + bool root_exists = ctx.root_exists; + cbm_git_context_free(&ctx); + th_rmtree(tmp); + + ASSERT_EQ(rc, 0); + ASSERT_TRUE(root_exists); + PASS(); +} + +/* These helpers shell out to git and are only used by the non-Windows + * canonical_root test bodies below; guard them here too or they'd be + * unused-static functions and fail the -Werror build on Windows. */ #ifndef _WIN32 /* Run a git command inside dir, return 0 on success. */ static int git_run(const char *dir, const char *args) { @@ -235,6 +256,7 @@ TEST(canonical_root_linked_worktree) { /* ── Suite ──────────────────────────────────────────────────────── */ SUITE(git_context) { + RUN_TEST(root_exists_non_ascii_directory); RUN_TEST(canonical_root_repo_root); RUN_TEST(canonical_root_subdir); RUN_TEST(canonical_root_linked_worktree); diff --git a/tests/test_httpd.c b/tests/test_httpd.c index 0d2f82402..fb81962a9 100644 --- a/tests/test_httpd.c +++ b/tests/test_httpd.c @@ -2040,6 +2040,59 @@ TEST(ui_server_rejects_non_loopback_host) { 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 @@ -2257,6 +2310,7 @@ TEST(ui_server_index_status_long_paths_no_overflow) { /* ── Suite ────────────────────────────────────────────────────── */ SUITE(httpd) { + 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);