Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions src/daemon/application.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 2 additions & 3 deletions src/git/git_context.c
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@

#include "foundation/compat_fs.h"
#include "foundation/constants.h"
#include "foundation/platform.h"
#include "foundation/str_util.h"

#include <ctype.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>

enum {
GIT_CMD_MAX = 1024,
Expand Down Expand Up @@ -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;
}
Expand Down
15 changes: 7 additions & 8 deletions src/ui/http_server.c
Original file line number Diff line number Diff line change
Expand Up @@ -705,7 +705,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\":[");
Expand Down Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -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)) {
Expand All @@ -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];
Expand Down
92 changes: 92 additions & 0 deletions tests/test_daemon_application.c
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
32 changes: 27 additions & 5 deletions tests/test_git_context.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
54 changes: 54 additions & 0 deletions tests/test_httpd.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
Loading