Skip to content
Merged
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
44 changes: 35 additions & 9 deletions src/mcp/mcp.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions src/pipeline/fqn.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
7 changes: 7 additions & 0 deletions src/pipeline/pipeline.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
86 changes: 86 additions & 0 deletions tests/test_mcp.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand Down
Loading