Skip to content

Commit 4dd0996

Browse files
committed
fix(windows): pin the search_code PowerShell pipe to UTF-8
PowerShell 5.1 encodes stdout for a native-process pipe in the console OEM codepage, so raw search content containing characters the inherited CP cannot carry (Cyrillic under CP437/850, ...) reached collect_grep_matches as '?' — and whether it degraded depended entirely on which console the server happened to inherit. That surfaced as the intermittent test_mcp raw-Русский mojibake on the windows CI leg and means real Windows users in a default console get '?' for all non-ASCII search_code raw content. Every generated command now pins [Console]::OutputEncoding to UTF-8, so the pipe is codepage-independent by construction. The read side needs no pin: Select-String decodes BOM-less UTF-8 via .NET StreamReader defaults. A Windows-side builder test asserts all five command variants carry the prelude. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
1 parent 773bb03 commit 4dd0996

2 files changed

Lines changed: 84 additions & 42 deletions

File tree

‎src/mcp/mcp.c‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8848,6 +8848,16 @@ bool cbm_search_code_file_pattern_can_prefilter(const char *file_pattern) {
88488848
/* Build the grep/search command string based on scoped vs recursive mode.
88498849
* On Windows, uses PowerShell Select-String with tab-delimited output.
88508850
* On POSIX, uses grep with colon-delimited output. */
8851+
/* Windows PowerShell 5.1 encodes stdout for a native-process pipe in the
8852+
* console OEM codepage, so any character the inherited CP cannot carry
8853+
* (Cyrillic under CP437/850, etc.) degrades to '?' before it ever reaches
8854+
* collect_grep_matches — and WHETHER it degrades depends on the console the
8855+
* server happened to inherit. Pin the pipe to UTF-8 inside every generated
8856+
* command so raw search content is codepage-independent. (The read side is
8857+
* already safe: Select-String decodes BOM-less UTF-8 via .NET StreamReader
8858+
* defaults.) */
8859+
#define CBM_PS_UTF8_PRELUDE "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; "
8860+
88518861
void cbm_search_code_build_grep_cmd(char *cmd, size_t cmd_sz, bool use_regex, bool scoped,
88528862
const char *file_pattern, const char *tmpfile,
88538863
const char *filelist, const char *root_path) {
@@ -8858,7 +8868,8 @@ void cbm_search_code_build_grep_cmd(char *cmd, size_t cmd_sz, bool use_regex, bo
88588868
if (cbm_search_code_file_pattern_can_prefilter(file_pattern)) {
88598869
snprintf(
88608870
cmd, cmd_sz,
8861-
"powershell -Command \"$pat = Get-Content -Encoding UTF8 -LiteralPath '%s'; "
8871+
"powershell -Command \"" CBM_PS_UTF8_PRELUDE
8872+
"$pat = Get-Content -Encoding UTF8 -LiteralPath '%s'; "
88628873
"Get-Content -Encoding UTF8 -LiteralPath '%s'"
88638874
" | Where-Object { $_ -like '%s' }"
88648875
" | ForEach-Object { Select-String -LiteralPath $_ -Pattern $pat%s "
@@ -8869,7 +8880,8 @@ void cbm_search_code_build_grep_cmd(char *cmd, size_t cmd_sz, bool use_regex, bo
88698880
} else {
88708881
snprintf(
88718882
cmd, cmd_sz,
8872-
"powershell -Command \"$pat = Get-Content -Encoding UTF8 -LiteralPath '%s'; "
8883+
"powershell -Command \"" CBM_PS_UTF8_PRELUDE
8884+
"$pat = Get-Content -Encoding UTF8 -LiteralPath '%s'; "
88738885
"Get-Content -Encoding UTF8 -LiteralPath '%s' | ForEach-Object { Select-String "
88748886
"-LiteralPath $_ -Pattern $pat%s "
88758887
"-ErrorAction SilentlyContinue }"
@@ -8880,7 +8892,8 @@ void cbm_search_code_build_grep_cmd(char *cmd, size_t cmd_sz, bool use_regex, bo
88808892
} else {
88818893
snprintf(
88828894
cmd, cmd_sz,
8883-
"powershell -Command \"$pat = Get-Content -Encoding UTF8 -LiteralPath '%s'; "
8895+
"powershell -Command \"" CBM_PS_UTF8_PRELUDE
8896+
"$pat = Get-Content -Encoding UTF8 -LiteralPath '%s'; "
88848897
"Get-Content -Encoding UTF8 -LiteralPath '%s' | ForEach-Object { Select-String "
88858898
"-LiteralPath $_ -Pattern $pat%s "
88868899
"-ErrorAction SilentlyContinue }"
@@ -8891,7 +8904,8 @@ void cbm_search_code_build_grep_cmd(char *cmd, size_t cmd_sz, bool use_regex, bo
88918904
if (file_pattern) {
88928905
snprintf(
88938906
cmd, cmd_sz,
8894-
"powershell -Command \"Get-ChildItem -Recurse -Path '%s\\*' -Include '%s' -File "
8907+
"powershell -Command \"" CBM_PS_UTF8_PRELUDE
8908+
"Get-ChildItem -Recurse -Path '%s\\*' -Include '%s' -File "
88958909
"-ErrorAction SilentlyContinue"
88968910
" | Select-String -Pattern (Get-Content -Encoding UTF8 -LiteralPath '%s')%s "
88978911
"-ErrorAction SilentlyContinue"
@@ -8900,7 +8914,8 @@ void cbm_search_code_build_grep_cmd(char *cmd, size_t cmd_sz, bool use_regex, bo
89008914
} else {
89018915
snprintf(
89028916
cmd, cmd_sz,
8903-
"powershell -Command \"Get-ChildItem -Recurse -Path '%s\\*' -File -ErrorAction "
8917+
"powershell -Command \"" CBM_PS_UTF8_PRELUDE
8918+
"Get-ChildItem -Recurse -Path '%s\\*' -File -ErrorAction "
89048919
"SilentlyContinue"
89058920
" | Select-String -Pattern (Get-Content -Encoding UTF8 -LiteralPath '%s')%s "
89068921
"-ErrorAction SilentlyContinue"

‎tests/test_mcp.c‎

Lines changed: 64 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2120,7 +2120,7 @@ TEST(tool_search_graph_includes_node_properties) {
21202120
ASSERT_NOT_NULL(strstr(inner, "func HandleRequest")); /* its value */
21212121
ASSERT_NOT_NULL(strstr(inner, "\"base_classes\""));
21222122
ASSERT_NOT_NULL(strstr(inner, "[\"HandlerBase\",\"Audited\"]"));
2123-
ASSERT_NULL(strstr(inner, "is_exported")); /* blob never spills */
2123+
ASSERT_NULL(strstr(inner, "is_exported")); /* blob never spills */
21242124
free(inner);
21252125
free(resp);
21262126

@@ -2680,15 +2680,13 @@ TEST(tool_check_index_coverage_accepts_truncated_ignored_catalog_for_fresh_path_
26802680
((int64_t)source_stat.st_mtimespec.tv_sec * (int64_t)CBM_NSEC_PER_SEC) +
26812681
(int64_t)source_stat.st_mtimespec.tv_nsec;
26822682
#elif defined(_WIN32)
2683-
int64_t source_mtime_ns =
2684-
(int64_t)source_stat.st_mtime * (int64_t)CBM_NSEC_PER_SEC;
2683+
int64_t source_mtime_ns = (int64_t)source_stat.st_mtime * (int64_t)CBM_NSEC_PER_SEC;
26852684
#else
2686-
int64_t source_mtime_ns =
2687-
((int64_t)source_stat.st_mtim.tv_sec * (int64_t)CBM_NSEC_PER_SEC) +
2688-
(int64_t)source_stat.st_mtim.tv_nsec;
2685+
int64_t source_mtime_ns = ((int64_t)source_stat.st_mtim.tv_sec * (int64_t)CBM_NSEC_PER_SEC) +
2686+
(int64_t)source_stat.st_mtim.tv_nsec;
26892687
#endif
2690-
ASSERT_EQ(cbm_store_upsert_file_hash(store, "test-project", "main.go", "",
2691-
source_mtime_ns, source_stat.st_size),
2688+
ASSERT_EQ(cbm_store_upsert_file_hash(store, "test-project", "main.go", "", source_mtime_ns,
2689+
source_stat.st_size),
26922690
CBM_STORE_OK);
26932691
cbm_project_t project = {0};
26942692
ASSERT_EQ(cbm_store_get_project(store, "test-project", &project), CBM_STORE_OK);
@@ -2702,8 +2700,7 @@ TEST(tool_check_index_coverage_accepts_truncated_ignored_catalog_for_fresh_path_
27022700
.coverage_version = 1,
27032701
.hash_records_complete = true,
27042702
};
2705-
ASSERT_EQ(cbm_store_coverage_replace_ex(store, "test-project", NULL, 0, &meta),
2706-
CBM_STORE_OK);
2703+
ASSERT_EQ(cbm_store_coverage_replace_ex(store, "test-project", NULL, 0, &meta), CBM_STORE_OK);
27072704
cbm_project_free_fields(&project);
27082705

27092706
char *response = cbm_mcp_handle_tool(
@@ -4351,12 +4348,12 @@ TEST(search_code_full_preserves_utf8_source) {
43514348
.end_line = 2};
43524349
ASSERT_GT(cbm_store_upsert_node(st, &section), 0);
43534350

4354-
char *resp = cbm_mcp_server_handle(
4355-
srv, "{\"jsonrpc\":\"2.0\",\"id\":97,\"method\":\"tools/call\","
4356-
"\"params\":{\"name\":\"search_code\",\"arguments\":{"
4357-
"\"project\":\"utf8-search\",\"pattern\":\"accounting-design\","
4358-
"\"file_pattern\":\"*.md\",\"path_filter\":\"^design/\","
4359-
"\"mode\":\"full\",\"format\":\"json\",\"limit\":5}}}");
4351+
char *resp =
4352+
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":97,\"method\":\"tools/call\","
4353+
"\"params\":{\"name\":\"search_code\",\"arguments\":{"
4354+
"\"project\":\"utf8-search\",\"pattern\":\"accounting-design\","
4355+
"\"file_pattern\":\"*.md\",\"path_filter\":\"^design/\","
4356+
"\"mode\":\"full\",\"format\":\"json\",\"limit\":5}}}");
43604357
ASSERT_NOT_NULL(resp);
43614358
char *inner = extract_text_content(resp);
43624359
ASSERT_NOT_NULL(inner);
@@ -4408,11 +4405,11 @@ TEST(search_code_raw_match_preserves_utf8_content) {
44084405
.end_line = 1};
44094406
ASSERT_GT(cbm_store_upsert_node(st, &node), 0);
44104407

4411-
char *resp = cbm_mcp_server_handle(
4412-
srv, "{\"jsonrpc\":\"2.0\",\"id\":98,\"method\":\"tools/call\","
4413-
"\"params\":{\"name\":\"search_code\",\"arguments\":{"
4414-
"\"project\":\"test-project\",\"pattern\":\"raw-\","
4415-
"\"file_pattern\":\"*.md\",\"format\":\"json\",\"limit\":5}}}");
4408+
char *resp =
4409+
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":98,\"method\":\"tools/call\","
4410+
"\"params\":{\"name\":\"search_code\",\"arguments\":{"
4411+
"\"project\":\"test-project\",\"pattern\":\"raw-\","
4412+
"\"file_pattern\":\"*.md\",\"format\":\"json\",\"limit\":5}}}");
44164413
ASSERT_NOT_NULL(resp);
44174414
char *inner = extract_text_content(resp);
44184415
ASSERT_NOT_NULL(inner);
@@ -4459,11 +4456,11 @@ TEST(search_code_context_preserves_utf8_context) {
44594456
.end_line = 3};
44604457
ASSERT_GT(cbm_store_upsert_node(st, &node), 0);
44614458

4462-
char *resp = cbm_mcp_server_handle(
4463-
srv, "{\"jsonrpc\":\"2.0\",\"id\":99,\"method\":\"tools/call\","
4464-
"\"params\":{\"name\":\"search_code\",\"arguments\":{"
4465-
"\"project\":\"test-project\",\"pattern\":\"context-needle\","
4466-
"\"format\":\"json\",\"context\":1,\"limit\":5}}}");
4459+
char *resp =
4460+
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":99,\"method\":\"tools/call\","
4461+
"\"params\":{\"name\":\"search_code\",\"arguments\":{"
4462+
"\"project\":\"test-project\",\"pattern\":\"context-needle\","
4463+
"\"format\":\"json\",\"context\":1,\"limit\":5}}}");
44674464
ASSERT_NOT_NULL(resp);
44684465
char *inner = extract_text_content(resp);
44694466
ASSERT_NOT_NULL(inner);
@@ -4507,8 +4504,8 @@ TEST(search_code_invalid_utf8_still_returns_valid_json) {
45074504
char invalid_path[512];
45084505
snprintf(invalid_path, sizeof(invalid_path), "%s/project/invalid.md", tmp);
45094506
static const unsigned char invalid_source[] = {
4510-
'i', 'n', 'v', 'a', 'l', 'i', 'd', '-', 'n', 'e', 'e', 'd', 'l', 'e', '\n',
4511-
'c', 'o', 'n', 't', 'e', 'x', 't', ' ', 0xFF, '\n',
4507+
'i', 'n', 'v', 'a', 'l', 'i', 'd', '-', 'n', 'e', 'e', 'd', 'l',
4508+
'e', '\n', 'c', 'o', 'n', 't', 'e', 'x', 't', ' ', 0xFF, '\n',
45124509
};
45134510
FILE *fp = cbm_fopen(invalid_path, "wb");
45144511
ASSERT_NOT_NULL(fp);
@@ -4526,11 +4523,11 @@ TEST(search_code_invalid_utf8_still_returns_valid_json) {
45264523
.end_line = 2};
45274524
ASSERT_GT(cbm_store_upsert_node(st, &node), 0);
45284525

4529-
char *resp = cbm_mcp_server_handle(
4530-
srv, "{\"jsonrpc\":\"2.0\",\"id\":100,\"method\":\"tools/call\","
4531-
"\"params\":{\"name\":\"search_code\",\"arguments\":{"
4532-
"\"project\":\"test-project\",\"pattern\":\"invalid-needle\","
4533-
"\"mode\":\"full\",\"format\":\"json\",\"limit\":5}}}");
4526+
char *resp =
4527+
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":100,\"method\":\"tools/call\","
4528+
"\"params\":{\"name\":\"search_code\",\"arguments\":{"
4529+
"\"project\":\"test-project\",\"pattern\":\"invalid-needle\","
4530+
"\"mode\":\"full\",\"format\":\"json\",\"limit\":5}}}");
45344531
ASSERT_NOT_NULL(resp);
45354532
char *inner = extract_text_content(resp);
45364533
ASSERT_NOT_NULL(inner);
@@ -4921,6 +4918,35 @@ TEST(search_code_windows_prefilter_precedes_content_scan) {
49214918
#endif
49224919
}
49234920

4921+
/* Windows raw scans must pin the PowerShell pipe to UTF-8: PS 5.1 otherwise
4922+
* emits stdout in the console OEM codepage and non-ASCII content degrades to
4923+
* '?' depending on the inherited console CP (seen as test_mcp raw-Русский
4924+
* mojibake on CI). Every builder variant must carry the prelude. */
4925+
TEST(search_code_windows_scan_pins_utf8_output) {
4926+
#ifdef _WIN32
4927+
static const char prelude[] =
4928+
"powershell -Command \"[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; ";
4929+
char command[CBM_SZ_4K];
4930+
struct {
4931+
bool scoped;
4932+
const char *file_pattern;
4933+
} cases[] = {{true, "*.go"}, /* scoped + prefilterable pattern */
4934+
{true, "*x*z*.go"}, /* scoped + non-prefilterable pattern */
4935+
{true, NULL}, /* scoped, no pattern */
4936+
{false, "*.go"}, /* unscoped + pattern */
4937+
{false, NULL}}; /* unscoped, no pattern */
4938+
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
4939+
cbm_search_code_build_grep_cmd(command, sizeof(command), false, cases[i].scoped,
4940+
cases[i].file_pattern, "C:/tmp/pattern", "C:/tmp/filelist",
4941+
"C:/tmp/root");
4942+
ASSERT_TRUE(strncmp(command, prelude, sizeof(prelude) - 1) == 0);
4943+
}
4944+
PASS();
4945+
#else
4946+
SKIP_PLATFORM("PowerShell scan encoding applies on Windows");
4947+
#endif
4948+
}
4949+
49244950
/* issue #283: search_code with regex=true and a syntactically invalid pattern
49254951
* must return an explicit error, not an empty result indistinguishable from a
49264952
* legitimate no-match. */
@@ -8769,10 +8795,10 @@ TEST(tool_resolve_store_by_internal_name_issue704) {
87698795
ASSERT_NULL(strstr(list, "ghost704")); /* 0-byte ghost filtered (RED before) */
87708796
free(list);
87718797

8772-
char *page = cbm_mcp_server_handle(
8773-
srv, "{\"jsonrpc\":\"2.0\",\"id\":11,\"method\":\"tools/call\","
8774-
"\"params\":{\"name\":\"list_projects\","
8775-
"\"arguments\":{\"offset\":0,\"limit\":1}}}");
8798+
char *page =
8799+
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":11,\"method\":\"tools/call\","
8800+
"\"params\":{\"name\":\"list_projects\","
8801+
"\"arguments\":{\"offset\":0,\"limit\":1}}}");
87768802
ASSERT_NOT_NULL(page);
87778803
ASSERT_NOT_NULL(strstr(page, "\\\"total\\\":3"));
87788804
ASSERT_NOT_NULL(strstr(page, "\\\"limit\\\":1"));
@@ -11223,6 +11249,7 @@ SUITE(mcp) {
1122311249
RUN_TEST(search_code_path_filter_matches_nothing);
1122411250
RUN_TEST(search_code_file_pattern_prefilter_boundaries);
1122511251
RUN_TEST(search_code_windows_prefilter_precedes_content_scan);
11252+
RUN_TEST(search_code_windows_scan_pins_utf8_output);
1122611253
RUN_TEST(search_code_invalid_regex_errors_issue283);
1122711254
RUN_TEST(search_code_literal_pipe_warns_issue282);
1122811255
RUN_TEST(search_code_reports_phase_timings_only_in_debug_mode);

0 commit comments

Comments
 (0)