Merge pull request #1816 from z2250389/codex-z2250389/fix-windows-unicode-git-root

fix(windows): support Unicode repository paths
This commit is contained in:
Martin Vogel
2026-09-27 16:02:10 +02:00
committed by GitHub
5 changed files with 138 additions and 16 deletions
+2 -3
View File
@@ -2,6 +2,7 @@
#include "foundation/compat_fs.h"
#include "foundation/constants.h"
#include "foundation/platform.h"
#include "foundation/str_util.h"
#include <ctype.h>
@@ -9,7 +10,6 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
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;
}
+7 -8
View File
@@ -715,7 +715,6 @@ static void handle_processes(cbm_http_conn_t *c) {
/* ── Directory browser ────────────────────────────────────────── */
#include <dirent.h>
static void append_roots_json(char *buf, size_t bufsz, int *pos) {
http_appendf(buf, bufsz, pos, ",\"roots\":[");
@@ -770,7 +769,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;
@@ -781,16 +780,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;
@@ -799,7 +798,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)) {
@@ -810,7 +809,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];
+48
View File
@@ -1859,6 +1859,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;
@@ -5652,6 +5699,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_ui_index_accepts_non_ascii_directory);
RUN_TEST(daemon_application_index_args_compare_repo_path_separator_equivalently);
RUN_TEST(daemon_application_programmatic_index_injects_resource_policy);
RUN_TEST(daemon_application_auto_index_honors_tracked_file_limit);
+27 -5
View File
@@ -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 <limits.h>
#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);
+54
View File
@@ -2150,6 +2150,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
@@ -2367,6 +2420,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);