From 51921d9063d7bcecf96feef05ce6d877732e2fa7 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 11 Jul 2026 15:54:58 +0200 Subject: [PATCH 1/2] fix(pipeline): valid JSON for sequential-path service edges and Route nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two emission bugs in the sequential pipeline (pass_calls.c) — the parallel path was correct, so the two pipelines produced different bytes for the same input: - Brokered ASYNC_CALLS edge props closed the method value's quote but not the broker's, emitting "broker":"bullmq} on EVERY such edge; the fixup below only repaired truncation, which never fires in the normal case. Rebuilt as the parallel path's incremental snprintf. - create_svc_route_node stored the RAW method/broker string as the synthesized Route node's properties (literally 'bullmq' instead of {"broker":"bullmq"}). Now emits the parallel path's exact JSON. Consequences of the malformed rows: json_extract errors across the query surfaces, the edges generated columns (local_name_gen, url_path index) fail, and PRAGMA quick_check aborts with 'malformed JSON' — which since the artifact deep-integrity check (#895) also means such caches are refused at import. Reproduce-first: sequential_service_edge_props_are_valid_json_issue898 indexes a small celery fixture (sequential path), asserts >=1 ASYNC_CALLS edge exists (non-vacuous), every edge/node properties blob is json_valid, and the broker is json_extract-able exactly like the parallel path emits it. RED on the old emitters (1 malformed edge + 1 malformed Route node), GREEN now. Note: the reporter's original bullmq JS repro no longer classifies on current main because CommonJS require()/ESM import resolution has a separate gap — that is exactly open issue #871; probed and confirmed while building this fix. Closes #898 Signed-off-by: Martin Vogel --- src/foundation/compat_fs.c | 45 ------------ src/foundation/compat_fs.h | 4 - src/mcp/mcp.c | 29 +++++--- src/pipeline/fqn.c | 12 ++- src/pipeline/pass_calls.c | 40 ++++++---- tests/test_mcp.c | 98 ++++++++++++++++++++++++- tests/windows/test_cli_non_ascii_arg.py | 22 ------ 7 files changed, 148 insertions(+), 102 deletions(-) diff --git a/src/foundation/compat_fs.c b/src/foundation/compat_fs.c index 428f83b89..838c1cf96 100644 --- a/src/foundation/compat_fs.c +++ b/src/foundation/compat_fs.c @@ -714,51 +714,6 @@ int cbm_exec_no_shell(const char *const *argv) { #endif /* _WIN32 */ -/* Canonicalize an EXISTING path (collapse `..`, resolve per-OS): realpath on - * POSIX; on Windows a wide-path GetFileAttributesW existence check + - * GetFullPathNameW. The previous callers used the ANSI CRT (_access/ - * _fullpath) on UTF-8 input — locale-dependent by construction: on a CJK - * system codepage (e.g. Big5) the UTF-8 bytes of a CJK path re-decode into - * different characters and canonicalization corrupts the path (#973). - * Returns 0 when the path does not exist or cannot be resolved. */ -int cbm_canonical_path(const char *path, char *out, size_t out_sz) { - if (!path || !out || out_sz == 0) { - return 0; - } -#ifdef _WIN32 - wchar_t *wpath = cbm_utf8_to_wide(path); - if (!wpath) { - return 0; - } - if (GetFileAttributesW(wpath) == INVALID_FILE_ATTRIBUTES) { - free(wpath); - return 0; - } - enum { CANON_WIDE_MAX = 4096 }; - wchar_t wfull[CANON_WIDE_MAX]; - DWORD n = GetFullPathNameW(wpath, CANON_WIDE_MAX, wfull, NULL); - free(wpath); - if (n == 0 || n >= CANON_WIDE_MAX) { - return 0; - } - char *utf8 = cbm_wide_to_utf8(wfull); - if (!utf8) { - return 0; - } - size_t len = strlen(utf8); - if (len >= out_sz) { - free(utf8); - return 0; - } - memcpy(out, utf8, len + 1); - free(utf8); - return 1; -#else - /* Callers pass >= 4K buffers (>= PATH_MAX on our platforms). */ - return realpath(path, out) != NULL; -#endif -} - /* rename() with overwrite semantics on every platform: POSIX rename already * replaces atomically; Windows rename fails with EEXIST when the target * exists, so use MoveFileExW(MOVEFILE_REPLACE_EXISTING) there (wide paths — diff --git a/src/foundation/compat_fs.h b/src/foundation/compat_fs.h index 9f2929c37..05cae3dda 100644 --- a/src/foundation/compat_fs.h +++ b/src/foundation/compat_fs.h @@ -53,10 +53,6 @@ void cbm_remove_db_sidecars(const char *db_path); /* rename() that replaces an existing destination on every platform * (Windows rename fails with EEXIST; this uses MoveFileExW there). */ int cbm_rename_replace(const char *src, const char *dst); -/* Canonicalize an EXISTING path (realpath / wide GetFullPathNameW). Locale- - * independent on Windows — never routes UTF-8 through the ANSI CRT (#973). - * out must be >= 4096 bytes. Returns 1 on success, 0 otherwise. */ -int cbm_canonical_path(const char *path, char *out, size_t out_sz); /* Delete an empty directory. Returns 0 on success. */ int cbm_rmdir(const char *path); diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index d9e732066..cf92c4fae 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -811,10 +811,8 @@ static char *canonicalize_repo_path_if_exists(char *repo_path) { } char real[CBM_SZ_4K]; - /* Wide-path canonicalization: the old _access/_fullpath pair decoded the - * UTF-8 repo_path through the ANSI codepage and corrupted CJK paths on - * CJK-locale systems (#973). */ - if (cbm_canonical_path(repo_path, real, sizeof(real))) { +#ifdef _WIN32 + if (_access(repo_path, 0) == 0 && _fullpath(real, repo_path, sizeof(real))) { cbm_normalize_path_sep(real); char *canonical = heap_strdup(real); if (canonical) { @@ -822,6 +820,16 @@ static char *canonicalize_repo_path_if_exists(char *repo_path) { return canonical; } } +#else + if (realpath(repo_path, real)) { + cbm_normalize_path_sep(real); + char *canonical = heap_strdup(real); + if (canonical) { + free(repo_path); + return canonical; + } + } +#endif return repo_path; } @@ -5048,17 +5056,16 @@ static yyjson_doc *enrich_node_properties(yyjson_mut_doc *doc, yyjson_mut_val *o * across preprocessor branches, which defeats source-level tooling that parses * without the preprocessor (and left this function unindexed in the graph). */ static bool resolve_canonical_path(const char *path, char *out, size_t out_sz) { - /* cbm_canonical_path: realpath on POSIX; wide existence check + - * GetFullPathNameW on Windows (the old bare _fullpath was ANSI — - * CJK-locale corruption, #973 — and, unlike POSIX realpath, resolved - * nonexistent paths too; requiring existence aligns the platforms). */ - if (!cbm_canonical_path(path, out, out_sz)) { +#ifdef _WIN32 + if (!_fullpath(out, path, out_sz)) { return false; } -#ifdef _WIN32 cbm_normalize_path_sep(out); -#endif return true; +#else + (void)out_sz; + return realpath(path, out) != NULL; +#endif } bool cbm_path_within_root(const char *root_path, const char *abs_path) { diff --git a/src/pipeline/fqn.c b/src/pipeline/fqn.c index 869eeb35f..d0594ff55 100644 --- a/src/pipeline/fqn.c +++ b/src/pipeline/fqn.c @@ -5,7 +5,6 @@ * Handles Python __init__.py, JS/TS index.{js,ts}, path separators. */ #include "pipeline/pipeline.h" -#include "foundation/compat_fs.h" #include "foundation/constants.h" #include "foundation/platform.h" @@ -411,12 +410,17 @@ char *cbm_project_name_from_path(const char *abs_path) { char real[CBM_SZ_4K]; const char *name_path = abs_path; - /* Wide-path canonicalization — the ANSI _access/_fullpath pair corrupted - * CJK paths on CJK-locale Windows (#973). */ - if (cbm_canonical_path(abs_path, real, sizeof(real))) { +#ifdef _WIN32 + if (_access(abs_path, 0) == 0 && _fullpath(real, abs_path, sizeof(real))) { cbm_normalize_path_sep(real); name_path = real; } +#else + if (realpath(abs_path, real)) { + cbm_normalize_path_sep(real); + name_path = real; + } +#endif /* Work on mutable copy */ char *path = strdup(name_path); diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 7ed132d98..2cff8fb56 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -248,13 +248,19 @@ static int64_t create_svc_route_node(cbm_pipeline_ctx_t *ctx, const char *url, c prefix = broker ? broker : "async"; } snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", prefix, qpath); - const char *rp; - if (svc == CBM_SVC_HTTP) { - rp = method ? method : "{}"; + /* Valid-JSON route properties, byte-identical to the parallel path's + * build_service_route. The old code stored the RAW method/broker string + * (literally `bullmq`), which broke json_extract, the edges generated + * columns, and PRAGMA quick_check on every such cache (#898). */ + char route_props[CBM_SZ_256]; + if (method) { + snprintf(route_props, sizeof(route_props), "{\"method\":\"%s\"}", method); + } else if (broker) { + snprintf(route_props, sizeof(route_props), "{\"broker\":\"%s\"}", broker); } else { - rp = broker ? broker : "{}"; + snprintf(route_props, sizeof(route_props), "{}"); } - return cbm_gbuf_upsert_node(ctx->gbuf, "Route", url, route_qn, "", 0, 0, rp); + return cbm_gbuf_upsert_node(ctx->gbuf, "Route", url, route_qn, "", 0, 0, route_props); } /* Insert an edge, splicing the call-site line (,"line":N) in before the closing @@ -362,15 +368,23 @@ static void emit_http_async_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call, char esc_url[CBM_SZ_256]; cbm_json_escape(esc_callee, sizeof(esc_callee), call->callee_name); cbm_json_escape(esc_url, sizeof(esc_url), url_or_topic); + /* Incremental build mirroring the parallel path's + * emit_http_async_service_edge: the old single format string closed the + * method value's quote but not the broker's, emitting + * "broker":"bullmq} on EVERY brokered ASYNC_CALLS edge — and its fixup + * only handled truncation, which never fires in the normal case (#898). */ char props[CBM_SZ_512]; - snprintf(props, sizeof(props), "{\"callee\":\"%s\",\"url_path\":\"%s\"%s%s%s%s%s}", esc_callee, - esc_url, method ? ",\"method\":\"" : "", method ? method : "", method ? "\"" : "", - broker ? ",\"broker\":\"" : "", broker ? broker : ""); - if (broker) { - size_t plen = strlen(props); - if (plen > 0 && props[plen - SKIP_ONE] != '}') { - snprintf(props + plen - 1, sizeof(props) - plen + SKIP_ONE, "\"}"); - } + int n = snprintf(props, sizeof(props), "{\"callee\":\"%s\",\"url_path\":\"%s\"", esc_callee, + esc_url); + if (method && n > 0 && (size_t)n < sizeof(props)) { + n += snprintf(props + n, sizeof(props) - (size_t)n, ",\"method\":\"%s\"", method); + } + if (broker && n > 0 && (size_t)n < sizeof(props)) { + n += snprintf(props + n, sizeof(props) - (size_t)n, ",\"broker\":\"%s\"", broker); + } + if (n > 0 && (size_t)n < sizeof(props) - 1) { + props[n] = '}'; + props[n + 1] = '\0'; } calls_emit_edge(ctx->gbuf, source->id, route_id, edge_type, props, sizeof(props), call); } diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 0bb62b16f..80581105c 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -4,6 +4,7 @@ * Covers: JSON-RPC parsing, MCP protocol, tool dispatch, tool handlers. */ #include "../src/foundation/compat.h" +#include #include "../src/foundation/compat_fs.h" /* cbm_unlink / cbm_rmdir */ #include "../src/foundation/constants.h" #include "../src/foundation/log.h" @@ -963,9 +964,8 @@ TEST(mcp_discovery_methods_return_empty_lists) { }; for (int i = 0; i < 3; i++) { char reqbuf[256]; - snprintf(reqbuf, sizeof(reqbuf), - "{\"jsonrpc\":\"2.0\",\"id\":%d,\"method\":\"%s\"}", 100 + i, - cases[i].method); + snprintf(reqbuf, sizeof(reqbuf), "{\"jsonrpc\":\"2.0\",\"id\":%d,\"method\":\"%s\"}", + 100 + i, cases[i].method); char *resp = cbm_mcp_server_handle(srv, reqbuf); ASSERT_NOT_NULL(resp); ASSERT_NULL(strstr(resp, "Method not found")); @@ -4964,6 +4964,97 @@ static int idx773_double_index_check(const char *dir_a, const char *dir_b) { } #endif /* !_WIN32 */ +/* #898: the SEQUENTIAL pipeline emitted malformed JSON for brokered + * ASYNC_CALLS edges ("broker":"bullmq} — missing closing quote) and stored + * the RAW broker/method string as the synthesized Route node's properties + * (literally `bullmq` instead of {"broker":"bullmq"}). json_extract over + * those rows errors, generated-column indexes fail, and PRAGMA quick_check + * aborts with "malformed JSON" — which since the artifact deep-integrity + * check also means such caches are refused at import. The parallel path + * was correct; both pipelines must emit identical, valid JSON. */ +TEST(sequential_service_edge_props_are_valid_json_issue898) { + char tmp[CBM_SZ_256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_seq898_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("mkdtemp failed"); + } + char src_path[CBM_SZ_512]; + snprintf(src_path, sizeof(src_path), "%s/queue.py", tmp); + FILE *f = fopen(src_path, "w"); + ASSERT_NOT_NULL(f); + /* celery.Celery("tasks") resolves through the import map to a QN the + * service-pattern table classifies as ASYNC with broker "celery". */ + fputs("import celery\n" + "\n" + "def enqueue():\n" + " celery.Celery(\"tasks\")\n", + f); + fclose(f); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + char args[CBM_SZ_512]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", tmp); + char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "indexed")); + free(resp); + + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + struct sqlite3 *db = cbm_store_get_db(store); + ASSERT_NOT_NULL(db); + + /* Non-vacuous: the fixture must actually produce a brokered edge. */ + sqlite3_stmt *stmt = NULL; + ASSERT_EQ(sqlite3_prepare_v2(db, "SELECT count(*) FROM edges WHERE type='ASYNC_CALLS';", -1, + &stmt, NULL), + SQLITE_OK); + ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW); + int async_edges = sqlite3_column_int(stmt, 0); + sqlite3_finalize(stmt); + ASSERT_TRUE(async_edges >= 1); + + /* THE BUG: malformed properties on edges (broker quote) and Route nodes + * (raw string). Every properties blob must be valid JSON. */ + ASSERT_EQ(sqlite3_prepare_v2(db, + "SELECT count(*) FROM edges WHERE properties IS NOT NULL " + "AND properties != '' AND json_valid(properties)=0;", + -1, &stmt, NULL), + SQLITE_OK); + ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW); + int bad_edges = sqlite3_column_int(stmt, 0); + sqlite3_finalize(stmt); + ASSERT_EQ(bad_edges, 0); + + ASSERT_EQ(sqlite3_prepare_v2(db, + "SELECT count(*) FROM nodes WHERE properties IS NOT NULL " + "AND properties != '' AND json_valid(properties)=0;", + -1, &stmt, NULL), + SQLITE_OK); + ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW); + int bad_nodes = sqlite3_column_int(stmt, 0); + sqlite3_finalize(stmt); + ASSERT_EQ(bad_nodes, 0); + + /* Pipeline parity: the broker must be extractable exactly like the + * parallel path emits it. */ + ASSERT_EQ(sqlite3_prepare_v2(db, + "SELECT count(*) FROM edges WHERE type='ASYNC_CALLS' AND " + "json_extract(properties,'$.broker')='celery';", + -1, &stmt, NULL), + SQLITE_OK); + ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW); + int brokered = sqlite3_column_int(stmt, 0); + sqlite3_finalize(stmt); + ASSERT_TRUE(brokered >= 1); + + cbm_mcp_server_free(srv); + unlink(src_path); + cbm_rmdir(tmp); + PASS(); +} + TEST(index_second_inprocess_run_survives_issue773) { #ifdef _WIN32 SKIP_PLATFORM("fork-isolated crash guard (POSIX-only)"); @@ -5615,6 +5706,7 @@ SUITE(mcp) { RUN_TEST(index_repository_cli_name_override_issue823); RUN_TEST(index_supervisor_gate_requires_marked_host_issue845); RUN_TEST(index_bg_paths_route_through_supervisor_issue832); + RUN_TEST(sequential_service_edge_props_are_valid_json_issue898); RUN_TEST(index_second_inprocess_run_survives_issue773); RUN_TEST(index_recovery_parallel_quarantines_crasher); RUN_TEST(tool_manage_adr_not_found_rich_error); diff --git a/tests/windows/test_cli_non_ascii_arg.py b/tests/windows/test_cli_non_ascii_arg.py index 8c9f68aa8..959ab747e 100644 --- a/tests/windows/test_cli_non_ascii_arg.py +++ b/tests/windows/test_cli_non_ascii_arg.py @@ -98,28 +98,6 @@ def main(): print("non-ASCII argv (supervised): rc=%d" % p.returncode) print(" stdout: %s" % out[:200].replace("\n", " ")) print(" stderr: %s" % err[-200:].replace("\n", " ")) - # #973 variant: the reporter's exact shape — Traditional-Chinese dir, - # FLAG-form --repo-path + --mode fast, supervised. This adds coverage - # of the flag->JSON converter and the repo-path canonicalization, - # which used ANSI _access/_fullpath (locale-dependent — corrupted CJK - # paths on CJK-codepage systems) before the cbm_canonical_path fix. - cjk_repo = os.path.join(work, "\u96f7\u9054\u6e2c\u8a66") - make_fixture(cjk_repo) - env3 = dict(os.environ) - env3["CBM_CACHE_DIR"] = os.path.join(work, "cache_cjk") - env3.pop("CBM_INDEX_SUPERVISOR", None) - p2 = subprocess.run([binary, "cli", "index_repository", - "--repo-path", cjk_repo, "--mode", "fast"], - capture_output=True, timeout=120, env=env3) - out2 = (p2.stdout or b"").decode("utf-8", "replace") - cjk_ok = '"nodes"' in out2 and '"nodes":0' not in out2.replace(" ", "") - print("CJK flag-form (--repo-path, supervised, fast): rc=%d" % p2.returncode) - print(" stdout: %s" % out2[:200].replace("\n", " ")) - if not cjk_ok: - print("\nRED: CJK flag-form repo_path was not indexed (#973 — " - "canonicalization or spawn boundary mangled the path).") - return 1 - if honored: print("\nGREEN: CLI honored the non-ASCII repo_path (argv + worker spawn).") return 0 From 93740cedd61aa01b99c05f4789933ea47d7a1608 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 11 Jul 2026 16:01:04 +0200 Subject: [PATCH 2/2] fix(branch): restore files clobbered by a stale-base soft reset The previous commit accidentally staged a stale-main vs current-main delta (a git reset --soft against an outdated origin/main ref), reverting the #1028 wide-canonicalization work and #1027's regression test. Restore every touched file to current main; the net branch diff is now exactly the intended two-file #898 fix (pass_calls.c + tests/test_mcp.c). Signed-off-by: Martin Vogel --- src/foundation/compat_fs.c | 45 +++++++++++++++++++++++++ src/foundation/compat_fs.h | 4 +++ src/mcp/mcp.c | 29 ++++++---------- src/pipeline/fqn.c | 12 +++---- tests/test_mcp.c | 5 +-- tests/windows/test_cli_non_ascii_arg.py | 22 ++++++++++++ 6 files changed, 89 insertions(+), 28 deletions(-) diff --git a/src/foundation/compat_fs.c b/src/foundation/compat_fs.c index 838c1cf96..428f83b89 100644 --- a/src/foundation/compat_fs.c +++ b/src/foundation/compat_fs.c @@ -714,6 +714,51 @@ int cbm_exec_no_shell(const char *const *argv) { #endif /* _WIN32 */ +/* Canonicalize an EXISTING path (collapse `..`, resolve per-OS): realpath on + * POSIX; on Windows a wide-path GetFileAttributesW existence check + + * GetFullPathNameW. The previous callers used the ANSI CRT (_access/ + * _fullpath) on UTF-8 input — locale-dependent by construction: on a CJK + * system codepage (e.g. Big5) the UTF-8 bytes of a CJK path re-decode into + * different characters and canonicalization corrupts the path (#973). + * Returns 0 when the path does not exist or cannot be resolved. */ +int cbm_canonical_path(const char *path, char *out, size_t out_sz) { + if (!path || !out || out_sz == 0) { + return 0; + } +#ifdef _WIN32 + wchar_t *wpath = cbm_utf8_to_wide(path); + if (!wpath) { + return 0; + } + if (GetFileAttributesW(wpath) == INVALID_FILE_ATTRIBUTES) { + free(wpath); + return 0; + } + enum { CANON_WIDE_MAX = 4096 }; + wchar_t wfull[CANON_WIDE_MAX]; + DWORD n = GetFullPathNameW(wpath, CANON_WIDE_MAX, wfull, NULL); + free(wpath); + if (n == 0 || n >= CANON_WIDE_MAX) { + return 0; + } + char *utf8 = cbm_wide_to_utf8(wfull); + if (!utf8) { + return 0; + } + size_t len = strlen(utf8); + if (len >= out_sz) { + free(utf8); + return 0; + } + memcpy(out, utf8, len + 1); + free(utf8); + return 1; +#else + /* Callers pass >= 4K buffers (>= PATH_MAX on our platforms). */ + return realpath(path, out) != NULL; +#endif +} + /* rename() with overwrite semantics on every platform: POSIX rename already * replaces atomically; Windows rename fails with EEXIST when the target * exists, so use MoveFileExW(MOVEFILE_REPLACE_EXISTING) there (wide paths — diff --git a/src/foundation/compat_fs.h b/src/foundation/compat_fs.h index 05cae3dda..9f2929c37 100644 --- a/src/foundation/compat_fs.h +++ b/src/foundation/compat_fs.h @@ -53,6 +53,10 @@ void cbm_remove_db_sidecars(const char *db_path); /* rename() that replaces an existing destination on every platform * (Windows rename fails with EEXIST; this uses MoveFileExW there). */ int cbm_rename_replace(const char *src, const char *dst); +/* Canonicalize an EXISTING path (realpath / wide GetFullPathNameW). Locale- + * independent on Windows — never routes UTF-8 through the ANSI CRT (#973). + * out must be >= 4096 bytes. Returns 1 on success, 0 otherwise. */ +int cbm_canonical_path(const char *path, char *out, size_t out_sz); /* Delete an empty directory. Returns 0 on success. */ int cbm_rmdir(const char *path); diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index cf92c4fae..d9e732066 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -811,8 +811,10 @@ static char *canonicalize_repo_path_if_exists(char *repo_path) { } char real[CBM_SZ_4K]; -#ifdef _WIN32 - if (_access(repo_path, 0) == 0 && _fullpath(real, repo_path, sizeof(real))) { + /* Wide-path canonicalization: the old _access/_fullpath pair decoded the + * UTF-8 repo_path through the ANSI codepage and corrupted CJK paths on + * CJK-locale systems (#973). */ + if (cbm_canonical_path(repo_path, real, sizeof(real))) { cbm_normalize_path_sep(real); char *canonical = heap_strdup(real); if (canonical) { @@ -820,16 +822,6 @@ static char *canonicalize_repo_path_if_exists(char *repo_path) { return canonical; } } -#else - if (realpath(repo_path, real)) { - cbm_normalize_path_sep(real); - char *canonical = heap_strdup(real); - if (canonical) { - free(repo_path); - return canonical; - } - } -#endif return repo_path; } @@ -5056,16 +5048,17 @@ static yyjson_doc *enrich_node_properties(yyjson_mut_doc *doc, yyjson_mut_val *o * across preprocessor branches, which defeats source-level tooling that parses * without the preprocessor (and left this function unindexed in the graph). */ static bool resolve_canonical_path(const char *path, char *out, size_t out_sz) { -#ifdef _WIN32 - if (!_fullpath(out, path, out_sz)) { + /* cbm_canonical_path: realpath on POSIX; wide existence check + + * GetFullPathNameW on Windows (the old bare _fullpath was ANSI — + * CJK-locale corruption, #973 — and, unlike POSIX realpath, resolved + * nonexistent paths too; requiring existence aligns the platforms). */ + if (!cbm_canonical_path(path, out, out_sz)) { return false; } +#ifdef _WIN32 cbm_normalize_path_sep(out); - return true; -#else - (void)out_sz; - return realpath(path, out) != NULL; #endif + return true; } bool cbm_path_within_root(const char *root_path, const char *abs_path) { diff --git a/src/pipeline/fqn.c b/src/pipeline/fqn.c index d0594ff55..869eeb35f 100644 --- a/src/pipeline/fqn.c +++ b/src/pipeline/fqn.c @@ -5,6 +5,7 @@ * Handles Python __init__.py, JS/TS index.{js,ts}, path separators. */ #include "pipeline/pipeline.h" +#include "foundation/compat_fs.h" #include "foundation/constants.h" #include "foundation/platform.h" @@ -410,17 +411,12 @@ char *cbm_project_name_from_path(const char *abs_path) { char real[CBM_SZ_4K]; const char *name_path = abs_path; -#ifdef _WIN32 - if (_access(abs_path, 0) == 0 && _fullpath(real, abs_path, sizeof(real))) { + /* Wide-path canonicalization — the ANSI _access/_fullpath pair corrupted + * CJK paths on CJK-locale Windows (#973). */ + if (cbm_canonical_path(abs_path, real, sizeof(real))) { cbm_normalize_path_sep(real); name_path = real; } -#else - if (realpath(abs_path, real)) { - cbm_normalize_path_sep(real); - name_path = real; - } -#endif /* Work on mutable copy */ char *path = strdup(name_path); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 80581105c..e24ec0d73 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -964,8 +964,9 @@ TEST(mcp_discovery_methods_return_empty_lists) { }; for (int i = 0; i < 3; i++) { char reqbuf[256]; - snprintf(reqbuf, sizeof(reqbuf), "{\"jsonrpc\":\"2.0\",\"id\":%d,\"method\":\"%s\"}", - 100 + i, cases[i].method); + snprintf(reqbuf, sizeof(reqbuf), + "{\"jsonrpc\":\"2.0\",\"id\":%d,\"method\":\"%s\"}", 100 + i, + cases[i].method); char *resp = cbm_mcp_server_handle(srv, reqbuf); ASSERT_NOT_NULL(resp); ASSERT_NULL(strstr(resp, "Method not found")); diff --git a/tests/windows/test_cli_non_ascii_arg.py b/tests/windows/test_cli_non_ascii_arg.py index 959ab747e..8c9f68aa8 100644 --- a/tests/windows/test_cli_non_ascii_arg.py +++ b/tests/windows/test_cli_non_ascii_arg.py @@ -98,6 +98,28 @@ def main(): print("non-ASCII argv (supervised): rc=%d" % p.returncode) print(" stdout: %s" % out[:200].replace("\n", " ")) print(" stderr: %s" % err[-200:].replace("\n", " ")) + # #973 variant: the reporter's exact shape — Traditional-Chinese dir, + # FLAG-form --repo-path + --mode fast, supervised. This adds coverage + # of the flag->JSON converter and the repo-path canonicalization, + # which used ANSI _access/_fullpath (locale-dependent — corrupted CJK + # paths on CJK-codepage systems) before the cbm_canonical_path fix. + cjk_repo = os.path.join(work, "\u96f7\u9054\u6e2c\u8a66") + make_fixture(cjk_repo) + env3 = dict(os.environ) + env3["CBM_CACHE_DIR"] = os.path.join(work, "cache_cjk") + env3.pop("CBM_INDEX_SUPERVISOR", None) + p2 = subprocess.run([binary, "cli", "index_repository", + "--repo-path", cjk_repo, "--mode", "fast"], + capture_output=True, timeout=120, env=env3) + out2 = (p2.stdout or b"").decode("utf-8", "replace") + cjk_ok = '"nodes"' in out2 and '"nodes":0' not in out2.replace(" ", "") + print("CJK flag-form (--repo-path, supervised, fast): rc=%d" % p2.returncode) + print(" stdout: %s" % out2[:200].replace("\n", " ")) + if not cjk_ok: + print("\nRED: CJK flag-form repo_path was not indexed (#973 — " + "canonicalization or spawn boundary mangled the path).") + return 1 + if honored: print("\nGREEN: CLI honored the non-ASCII repo_path (argv + worker spawn).") return 0