diff --git a/src/foundation/compat_fs.c b/src/foundation/compat_fs.c index 0fa643393..82ded6a55 100644 --- a/src/foundation/compat_fs.c +++ b/src/foundation/compat_fs.c @@ -13,6 +13,33 @@ #include #include +static bool cbm_dirent_name_len(const char *name, size_t *out_len) { + if (!name) { + return false; + } + const char *end = memchr(name, '\0', CBM_DIRENT_NAME_MAX); + if (!end) { + return false; + } + if (out_len) { + *out_len = (size_t)(end - name); + } + return true; +} + +bool cbm_dirent_name_fits(const char *name) { + return cbm_dirent_name_len(name, NULL); +} + +static bool cbm_dirent_set_name(cbm_dirent_t *entry, const char *name) { + size_t nlen = 0; + if (!entry || !cbm_dirent_name_len(name, &nlen)) { + return false; + } + memcpy(entry->name, name, nlen + SKIP_ONE); + return true; +} + #ifdef _WIN32 /* ── Windows implementation ────────────────────────────────── */ @@ -81,38 +108,37 @@ cbm_dirent_t *cbm_readdir(cbm_dir_t *d) { if (!d || d->done) { return NULL; } - if (!d->first) { - if (!FindNextFileW(d->find_handle, &d->find_data)) { + + for (;;) { + if (!d->first) { + if (!FindNextFileW(d->find_handle, &d->find_data)) { + d->done = true; + return NULL; + } + } + d->first = false; + + if (d->find_data.cFileName[0] == L'.' && + (d->find_data.cFileName[1] == L'\0' || + (d->find_data.cFileName[1] == L'.' && d->find_data.cFileName[2] == L'\0'))) { + continue; + } + + char *u8 = cbm_wide_to_utf8(d->find_data.cFileName); + if (!u8) { d->done = true; return NULL; } - } - d->first = false; - - while (d->find_data.cFileName[0] == L'.' && - (d->find_data.cFileName[1] == L'\0' || - (d->find_data.cFileName[1] == L'.' && d->find_data.cFileName[2] == L'\0'))) { - if (!FindNextFileW(d->find_handle, &d->find_data)) { - d->done = true; - return NULL; + bool copied = cbm_dirent_set_name(&d->entry, u8); + free(u8); + if (!copied) { + continue; } - } - char *u8 = cbm_wide_to_utf8(d->find_data.cFileName); - if (!u8) { - d->done = true; - return NULL; + d->entry.is_dir = (d->find_data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0; + d->entry.d_type = 0; + return &d->entry; } - size_t nlen = strlen(u8); - if (nlen >= CBM_DIRENT_NAME_MAX) { - nlen = CBM_DIRENT_NAME_MAX - SKIP_ONE; - } - memcpy(d->entry.name, u8, nlen); - d->entry.name[nlen] = '\0'; - free(u8); - d->entry.is_dir = (d->find_data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0; - d->entry.d_type = 0; - return &d->entry; } void cbm_closedir(cbm_dir_t *d) { @@ -286,12 +312,9 @@ cbm_dirent_t *cbm_readdir(cbm_dir_t *d) { (de->d_name[SKIP_ONE] == '.' && de->d_name[PAIR_LEN] == '\0'))) { continue; } - size_t nlen = strlen(de->d_name); - if (nlen >= CBM_DIRENT_NAME_MAX) { - nlen = CBM_DIRENT_NAME_MAX - SKIP_ONE; + if (!cbm_dirent_set_name(&d->entry, de->d_name)) { + continue; } - memcpy(d->entry.name, de->d_name, nlen); - d->entry.name[nlen] = '\0'; d->entry.is_dir = (de->d_type == DT_DIR); d->entry.d_type = de->d_type; return &d->entry; diff --git a/src/foundation/compat_fs.h b/src/foundation/compat_fs.h index 8b430c3e3..bb1379a52 100644 --- a/src/foundation/compat_fs.h +++ b/src/foundation/compat_fs.h @@ -11,10 +11,12 @@ #include #include +#include "foundation/constants.h" + /* ── Directory iteration ──────────────────────────────────────── */ /* Max filename length (MAX_PATH on Windows, NAME_MAX on POSIX). */ -#define CBM_DIRENT_NAME_MAX 260 +#define CBM_DIRENT_NAME_MAX (CBM_SZ_256 + CBM_SZ_4) typedef struct cbm_dir cbm_dir_t; @@ -31,6 +33,9 @@ cbm_dir_t *cbm_opendir(const char *path); * valid until the next cbm_readdir call on the same handle. */ cbm_dirent_t *cbm_readdir(cbm_dir_t *d); +/* True when name can be represented exactly in cbm_dirent_t.name. */ +bool cbm_dirent_name_fits(const char *name); + /* Close directory handle. */ void cbm_closedir(cbm_dir_t *d); diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index df2ffecfb..1b88b62e0 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -1551,11 +1551,19 @@ static bool is_project_db_file(const char *name, size_t len); /* Forward decl — definition lives below in trace_path helpers. */ static void free_node_contents(cbm_node_t *n); -/* Scan cache dir for .db files, writing comma-separated quoted names into out. - * Returns the number of projects found. */ -static int collect_db_project_names(const char *dir_path, char *out, size_t out_sz) { +/* Scan cache dir for .db files, writing complete quoted JSON names into out. + * Returns the total projects found; out may list fewer when truncated is set. */ +static int collect_db_project_names(const char *dir_path, char *out, size_t out_sz, + bool *truncated) { int count = 0; - int offset = 0; + int listed = 0; + size_t offset = 0; + if (truncated) { + *truncated = false; + } + if (out && out_sz > 0) { + out[0] = '\0'; + } cbm_dir_t *d = cbm_opendir(dir_path); if (!d) { return 0; @@ -1567,20 +1575,48 @@ static int collect_db_project_names(const char *dir_path, char *out, size_t out_ if (!is_project_db_file(n, len)) { continue; } - if ((size_t)offset >= out_sz) - break; /* bounds check before write */ - if (count > 0 && offset < (int)out_sz - MCP_SEPARATOR) { + count++; + + char project_name[CBM_DIRENT_NAME_MAX]; + size_t project_len = len - MCP_DB_EXT; + if (project_len >= sizeof(project_name)) { + if (truncated) { + *truncated = true; + } + continue; + } + memcpy(project_name, n, project_len); + project_name[project_len] = '\0'; + + char escaped[CBM_SZ_1K]; + int escaped_len = cbm_json_escape(escaped, (int)sizeof(escaped), project_name); + if (escaped_len <= 0 && project_name[0] != '\0') { + if (truncated) { + *truncated = true; + } + continue; + } + + size_t item_len = (size_t)escaped_len + CBM_QUOTE_PAIR; + if (listed > 0) { + item_len += SKIP_ONE; + } + if (!out || out_sz == 0 || offset + item_len >= out_sz) { + if (truncated) { + *truncated = true; + } + continue; + } + + if (listed > 0) { out[offset++] = ','; } - int wrote = snprintf(out + offset, out_sz - (size_t)offset, "\"%.*s\"", - (int)(len - MCP_DB_EXT), n); - if (wrote > 0) { - offset += wrote; - if ((size_t)offset >= out_sz) { - offset = (int)out_sz - 1; /* clamp on truncation */ - } - } - count++; + out[offset++] = '"'; + memcpy(out + offset, escaped, (size_t)escaped_len); + offset += (size_t)escaped_len; + out[offset++] = '"'; + out[offset] = '\0'; + listed++; } cbm_closedir(d); return count; @@ -1623,7 +1659,8 @@ static char *build_project_list_error_srv(cbm_mcp_server_t *srv, const char *rea cache_dir(dir_path, sizeof(dir_path)); char projects[CBM_SZ_4K] = ""; - int count = collect_db_project_names(dir_path, projects, sizeof(projects)); + bool projects_truncated = false; + int count = collect_db_project_names(dir_path, projects, sizeof(projects), &projects_truncated); /* Optional: session_project and _context fields for richer error context */ char session_frag[256] = ""; @@ -1646,8 +1683,10 @@ static char *build_project_list_error_srv(cbm_mcp_server_t *srv, const char *rea if (count > 0) { snprintf(buf, sizeof(buf), "{\"error\":\"%s\",\"hint\":\"Use list_projects to see all indexed projects, " - "then pass the project name.\",\"available_projects\":[%s],\"count\":%d%s%s}", - reason, projects, count, session_frag, context_frag); + "then pass the project name.\",\"available_projects\":[%s],\"count\":%d%s%s%s}", + reason, projects, count, + projects_truncated ? ",\"available_projects_truncated\":true" : "", + session_frag, context_frag); } else { snprintf(buf, sizeof(buf), "{\"error\":\"%s\",\"hint\":\"No projects indexed yet. " diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 3a1292975..5ed159368 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -2928,6 +2928,19 @@ TEST(tool_bad_project_name_no_overflow_issue235) { "\"project\":\"definitely-not-a-real-project-xyz\"}}}"); ASSERT_NOT_NULL(resp); ASSERT_NOT_NULL(strstr(resp, "not found")); + char *inner = extract_text_content(resp); + ASSERT_NOT_NULL(inner); + yyjson_doc *doc = yyjson_read(inner, strlen(inner), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + ASSERT_NOT_NULL(root); + ASSERT_EQ((int)yyjson_get_int(yyjson_obj_get(root, "count")), ISSUE235_N); + ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(root, "available_projects_truncated"))); + yyjson_val *projects = yyjson_obj_get(root, "available_projects"); + ASSERT_TRUE(yyjson_is_arr(projects)); + ASSERT_TRUE((int)yyjson_arr_size(projects) < ISSUE235_N); + yyjson_doc_free(doc); + free(inner); free(resp); cbm_mcp_server_free(srv); diff --git a/tests/test_platform.c b/tests/test_platform.c index dc2540e0c..7509c7cfe 100644 --- a/tests/test_platform.c +++ b/tests/test_platform.c @@ -3,6 +3,7 @@ */ #include "test_framework.h" #include "../src/foundation/compat.h" /* cbm_setenv / cbm_unsetenv (Windows-portable) */ +#include "../src/foundation/compat_fs.h" #include "../src/foundation/platform.h" #include "../src/foundation/system_info_internal.h" #include @@ -163,6 +164,21 @@ TEST(platform_getenv_fits) { PASS(); } +TEST(platform_dirent_name_fits_boundary) { + char fits[CBM_DIRENT_NAME_MAX]; + char too_long[CBM_DIRENT_NAME_MAX + SKIP_ONE]; + + memset(fits, 'a', sizeof(fits) - SKIP_ONE); + fits[sizeof(fits) - SKIP_ONE] = '\0'; + ASSERT_TRUE(cbm_dirent_name_fits(fits)); + + memset(too_long, 'b', sizeof(too_long) - SKIP_ONE); + too_long[sizeof(too_long) - SKIP_ONE] = '\0'; + ASSERT_FALSE(cbm_dirent_name_fits(too_long)); + ASSERT_FALSE(cbm_dirent_name_fits(NULL)); + PASS(); +} + /* ── cgroup-aware detection (Linux only) ─────────────────────────── */ #ifdef __linux__ @@ -348,6 +364,7 @@ SUITE(platform) { RUN_TEST(platform_default_workers_env_invalid); RUN_TEST(platform_default_workers_env_unset); RUN_TEST(platform_getenv_fits); + RUN_TEST(platform_dirent_name_fits_boundary); #ifdef __linux__ RUN_TEST(cgroup_v2_cpu_quota); RUN_TEST(cgroup_v2_cpu_quota_rounds_up);