diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 75ff8e396c..4df0a4ffeb 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -1481,24 +1481,50 @@ static bool repo_path_is_absolute(const char *path) { #endif } +bool cbm_validate_project_name(const char *project); + +static bool has_non_ascii_byte(const char *s) { + for (; *s; s++) { + if ((unsigned char)*s >= 0x80) { + return true; + } + } + return false; +} + +/* #1827: a bare non-ASCII selector, typically the real folder name of a repo + * under a CJK path, is encoded exactly like the path segment it came from + * (non-ASCII bytes -> hex, #571), so the exact/tail lookup matches the stored + * identity. Stored names never change; ASCII selectors keep their existing + * validation; a selector that collapses to the "root" fallback is left alone + * so it never selects the root project. */ +static bool bare_project_encoding_usable(const char *encoded) { + return strcmp(encoded, "root") != 0 && cbm_validate_project_name(encoded); +} + static char *normalize_project_arg(char *project) { - if (!project || (!strchr(project, '/') && !strchr(project, '\\'))) { + if (!project) { return project; } - - project = canonicalize_repo_path_if_exists(project); - char *normalized = cbm_project_name_from_path(project); - if (normalized) { - free(project); - return normalized; + char *normalized = NULL; + bool bare = !strchr(project, '/') && !strchr(project, '\\'); + if (bare) { + if (has_non_ascii_byte(project)) { + normalized = cbm_project_name_sanitize(project); + } + } else { + project = canonicalize_repo_path_if_exists(project); + normalized = cbm_project_name_from_path(project); } - return project; + /* Keep exactly one of the two strings and release the other. */ + bool use_normalized = normalized && (!bare || bare_project_encoding_usable(normalized)); + free(use_normalized ? project : normalized); + return use_normalized ? normalized : project; } /* Forward decls — defined below alongside store resolution. */ static const char *cache_dir(char *buf, size_t bufsz); static bool is_project_db_file(const char *name, size_t len); -bool cbm_validate_project_name(const char *project); /* #1025: agents naturally pass the repo FOLDER name ("codebase-memory-mcp"), * but indexed project names derive from the full path diff --git a/src/pipeline/fqn.c b/src/pipeline/fqn.c index 9d0861155e..96e959a0be 100644 --- a/src/pipeline/fqn.c +++ b/src/pipeline/fqn.c @@ -428,9 +428,13 @@ char *cbm_project_name_from_path(const char *abs_path) { cbm_normalize_path_sep(real); name_path = real; } + return cbm_project_name_sanitize(name_path); +} - /* Work on mutable copy */ - char *path = strdup(name_path); +char *cbm_project_name_sanitize(const char *name_path) { + /* Work on mutable copy. NULL and "" map to "" and fall through to the + * empty-after-trim "root" return below. */ + char *path = strdup(name_path ? name_path : ""); if (!path) { return NULL; } diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index e5a672cb0b..8cff690643 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -206,6 +206,13 @@ char *cbm_pipeline_resolve_relative_import(const char *source_rel, const char *m * Caller must free() the returned string. */ char *cbm_project_name_from_path(const char *abs_path); +/* The name-mapping half of cbm_project_name_from_path, WITHOUT path + * canonicalization: maps any string to the stored project-name form (unsafe + * ASCII -> '-', non-ASCII bytes -> two hex digits, dash/dot collapse, trim, + * #624 length cap). Lets a selector such as a bare non-ASCII folder name be + * encoded exactly like the segment it came from (#1827). Caller frees. */ +char *cbm_project_name_sanitize(const char *name_path); + /* ── Function Registry ──────────────────────────────────────────── */ typedef struct cbm_registry cbm_registry_t; diff --git a/tests/test_mcp.c b/tests/test_mcp.c index ffa9ea3a3b..5ad8a6b928 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -8382,6 +8382,91 @@ TEST(tool_project_arg_resolves_unique_tail_issue1025) { PASS(); } +/* #1827: a repo under a non-ASCII folder ("中文测试仓库") gets a project name + * whose non-ASCII bytes are hex-transliterated (#571), e.g. + * "...-e4b8ade69687e6b58be8af95e4bb93e5ba93". That stored identity stays + * stable (it is the cache DB file name), but the project must stay reachable + * by what the user actually knows: the real path (already normalized) AND the + * real folder name, which the #1025 tail match skipped because the raw + * non-ASCII name fails the project-name validator before any lookup. */ +TEST(tool_project_arg_resolves_non_ascii_folder_issue1827) { + char parent[CBM_SZ_256]; + char cache[CBM_SZ_256]; + snprintf(parent, sizeof(parent), "/tmp/cbm-i1827a-XXXXXX"); + snprintf(cache, sizeof(cache), "/tmp/cbm-i1827c-XXXXXX"); + if (!cbm_mkdtemp(parent) || !cbm_mkdtemp(cache)) { + FAIL("mkdtemp failed"); + } + const char *folder = "\xe4\xb8\xad\xe6\x96\x87\xe6\xb5\x8b\xe8\xaf\x95\xe4\xbb\x93\xe5\xba\x93"; + char repo[CBM_SZ_512]; + snprintf(repo, sizeof(repo), "%s/%s", parent, folder); + ASSERT_TRUE(cbm_mkdir_p(repo, 0755)); + char file[CBM_SZ_1K]; + snprintf(file, sizeof(file), "%s/hello.js", repo); + FILE *f = cbm_fopen(file, "w"); + ASSERT_NOT_NULL(f); + fprintf(f, "function greet_1827(){return 'hi';}\n"); + fclose(f); + + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? cbm_strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + cbm_setenv("CBM_INDEX_SUPERVISOR", "0", 1); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char args[CBM_SZ_2K]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", repo); + char *r = cbm_mcp_handle_tool(srv, "index_repository", args); + ASSERT_NOT_NULL(r); + free(r); + + /* The stored identity keeps its existing hex form (no DB rename). */ + char *stored = cbm_project_name_from_path(repo); + ASSERT_NOT_NULL(stored); + ASSERT_NOT_NULL(strstr(stored, "-e4b8ade69687e6b58be8af95e4bb93e5ba93")); + free(stored); + + /* 1. The real folder name resolves (RED before: "project not found"). */ + snprintf(args, sizeof(args), "{\"project\":\"%s\",\"name_pattern\":\".*greet_1827.*\"}", + folder); + r = cbm_mcp_handle_tool(srv, "search_graph", args); + ASSERT_NOT_NULL(r); + if (strstr(r, "project not found")) { + fprintf(stderr, " [1827] FAIL folder name did not resolve: %.200s\n", r); + } + ASSERT_NULL(strstr(r, "project not found")); + ASSERT_NOT_NULL(strstr(r, "greet_1827")); + free(r); + + /* 2. The real path keeps resolving. */ + snprintf(args, sizeof(args), "{\"project\":\"%s\",\"name_pattern\":\".*greet_1827.*\"}", repo); + r = cbm_mcp_handle_tool(srv, "search_graph", args); + ASSERT_NOT_NULL(r); + ASSERT_NULL(strstr(r, "project not found")); + ASSERT_NOT_NULL(strstr(r, "greet_1827")); + free(r); + + /* 3. A non-ASCII name matching nothing stays a not-found error. */ + r = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"\xe4\xb8\x8d\xe5\xad\x98\xe5\x9c\xa8\"," + "\"name_pattern\":\".*\"}"); + ASSERT_NOT_NULL(r); + ASSERT_NOT_NULL(strstr(r, "project not found")); + free(r); + + cbm_mcp_server_free(srv); + if (saved_cache_copy) { + cbm_setenv("CBM_CACHE_DIR", saved_cache_copy, 1); + free(saved_cache_copy); + } else { + cbm_unsetenv("CBM_CACHE_DIR"); + } + th_rmtree(parent); + th_rmtree(cache); + PASS(); +} + /* Regression for #604: path scopes architecture totals and content. */ TEST(tool_get_architecture_path_scoping) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); @@ -20597,6 +20682,7 @@ SUITE(mcp) { RUN_TEST(tool_get_architecture_accepts_project_name_alias_issue640); RUN_TEST(tool_search_graph_accepts_project_name_alias_issue640); RUN_TEST(tool_project_arg_resolves_unique_tail_issue1025); + RUN_TEST(tool_project_arg_resolves_non_ascii_folder_issue1827); RUN_TEST(tool_get_architecture_path_scoping); RUN_TEST(tool_query_graph_missing_query);