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>
This commit is contained in:
Martin Vogel committed 2026-08-18 09:31:59 +02:00
1 parent 773bb037dc
commit 4dd099679d
2 files changed
+84 -42

No files matched your search

+20 -5
View File
@@ -8848,6 +8848,16 @@ bool cbm_search_code_file_pattern_can_prefilter(const char *file_pattern) {
/* Build the grep/search command string based on scoped vs recursive mode.
* On Windows, uses PowerShell Select-String with tab-delimited output.
* On POSIX, uses grep with colon-delimited output. */
/* Windows PowerShell 5.1 encodes stdout for a native-process pipe in the
* console OEM codepage, so any character the inherited CP cannot carry
* (Cyrillic under CP437/850, etc.) degrades to '?' before it ever reaches
* collect_grep_matches — and WHETHER it degrades depends on the console the
* server happened to inherit. Pin the pipe to UTF-8 inside every generated
* command so raw search content is codepage-independent. (The read side is
* already safe: Select-String decodes BOM-less UTF-8 via .NET StreamReader
* defaults.) */
#define CBM_PS_UTF8_PRELUDE "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; "
void cbm_search_code_build_grep_cmd(char *cmd, size_t cmd_sz, bool use_regex, bool scoped,
const char *file_pattern, const char *tmpfile,
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
if (cbm_search_code_file_pattern_can_prefilter(file_pattern)) {
snprintf(
cmd, cmd_sz,
"powershell -Command \"$pat = Get-Content -Encoding UTF8 -LiteralPath '%s'; "
"powershell -Command \"" CBM_PS_UTF8_PRELUDE
"$pat = Get-Content -Encoding UTF8 -LiteralPath '%s'; "
"Get-Content -Encoding UTF8 -LiteralPath '%s'"
" | Where-Object { $_ -like '%s' }"
" | 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
} else {
snprintf(
cmd, cmd_sz,
"powershell -Command \"$pat = Get-Content -Encoding UTF8 -LiteralPath '%s'; "
"powershell -Command \"" CBM_PS_UTF8_PRELUDE
"$pat = Get-Content -Encoding UTF8 -LiteralPath '%s'; "
"Get-Content -Encoding UTF8 -LiteralPath '%s' | ForEach-Object { Select-String "
"-LiteralPath $_ -Pattern $pat%s "
"-ErrorAction SilentlyContinue }"
@@ -8880,7 +8892,8 @@ void cbm_search_code_build_grep_cmd(char *cmd, size_t cmd_sz, bool use_regex, bo
} else {
snprintf(
cmd, cmd_sz,
"powershell -Command \"$pat = Get-Content -Encoding UTF8 -LiteralPath '%s'; "
"powershell -Command \"" CBM_PS_UTF8_PRELUDE
"$pat = Get-Content -Encoding UTF8 -LiteralPath '%s'; "
"Get-Content -Encoding UTF8 -LiteralPath '%s' | ForEach-Object { Select-String "
"-LiteralPath $_ -Pattern $pat%s "
"-ErrorAction SilentlyContinue }"
@@ -8891,7 +8904,8 @@ void cbm_search_code_build_grep_cmd(char *cmd, size_t cmd_sz, bool use_regex, bo
if (file_pattern) {
snprintf(
cmd, cmd_sz,
"powershell -Command \"Get-ChildItem -Recurse -Path '%s\\*' -Include '%s' -File "
"powershell -Command \"" CBM_PS_UTF8_PRELUDE
"Get-ChildItem -Recurse -Path '%s\\*' -Include '%s' -File "
"-ErrorAction SilentlyContinue"
" | Select-String -Pattern (Get-Content -Encoding UTF8 -LiteralPath '%s')%s "
"-ErrorAction SilentlyContinue"
@@ -8900,7 +8914,8 @@ void cbm_search_code_build_grep_cmd(char *cmd, size_t cmd_sz, bool use_regex, bo
} else {
snprintf(
cmd, cmd_sz,
"powershell -Command \"Get-ChildItem -Recurse -Path '%s\\*' -File -ErrorAction "
"powershell -Command \"" CBM_PS_UTF8_PRELUDE
"Get-ChildItem -Recurse -Path '%s\\*' -File -ErrorAction "
"SilentlyContinue"
" | Select-String -Pattern (Get-Content -Encoding UTF8 -LiteralPath '%s')%s "
"-ErrorAction SilentlyContinue"
+64 -37
View File
@@ -2120,7 +2120,7 @@ TEST(tool_search_graph_includes_node_properties) {
ASSERT_NOT_NULL(strstr(inner, "func HandleRequest")); /* its value */
ASSERT_NOT_NULL(strstr(inner, "\"base_classes\""));
ASSERT_NOT_NULL(strstr(inner, "[\"HandlerBase\",\"Audited\"]"));
ASSERT_NULL(strstr(inner, "is_exported")); /* blob never spills */
ASSERT_NULL(strstr(inner, "is_exported")); /* blob never spills */
free(inner);
free(resp);
@@ -2680,15 +2680,13 @@ TEST(tool_check_index_coverage_accepts_truncated_ignored_catalog_for_fresh_path_
((int64_t)source_stat.st_mtimespec.tv_sec * (int64_t)CBM_NSEC_PER_SEC) +
(int64_t)source_stat.st_mtimespec.tv_nsec;
#elif defined(_WIN32)
int64_t source_mtime_ns =
(int64_t)source_stat.st_mtime * (int64_t)CBM_NSEC_PER_SEC;
int64_t source_mtime_ns = (int64_t)source_stat.st_mtime * (int64_t)CBM_NSEC_PER_SEC;
#else
int64_t source_mtime_ns =
((int64_t)source_stat.st_mtim.tv_sec * (int64_t)CBM_NSEC_PER_SEC) +
(int64_t)source_stat.st_mtim.tv_nsec;
int64_t source_mtime_ns = ((int64_t)source_stat.st_mtim.tv_sec * (int64_t)CBM_NSEC_PER_SEC) +
(int64_t)source_stat.st_mtim.tv_nsec;
#endif
ASSERT_EQ(cbm_store_upsert_file_hash(store, "test-project", "main.go", "",
source_mtime_ns, source_stat.st_size),
ASSERT_EQ(cbm_store_upsert_file_hash(store, "test-project", "main.go", "", source_mtime_ns,
source_stat.st_size),
CBM_STORE_OK);
cbm_project_t project = {0};
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_
.coverage_version = 1,
.hash_records_complete = true,
};
ASSERT_EQ(cbm_store_coverage_replace_ex(store, "test-project", NULL, 0, &meta),
CBM_STORE_OK);
ASSERT_EQ(cbm_store_coverage_replace_ex(store, "test-project", NULL, 0, &meta), CBM_STORE_OK);
cbm_project_free_fields(&project);
char *response = cbm_mcp_handle_tool(
@@ -4351,12 +4348,12 @@ TEST(search_code_full_preserves_utf8_source) {
.end_line = 2};
ASSERT_GT(cbm_store_upsert_node(st, &section), 0);
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":97,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_code\",\"arguments\":{"
"\"project\":\"utf8-search\",\"pattern\":\"accounting-design\","
"\"file_pattern\":\"*.md\",\"path_filter\":\"^design/\","
"\"mode\":\"full\",\"format\":\"json\",\"limit\":5}}}");
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":97,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_code\",\"arguments\":{"
"\"project\":\"utf8-search\",\"pattern\":\"accounting-design\","
"\"file_pattern\":\"*.md\",\"path_filter\":\"^design/\","
"\"mode\":\"full\",\"format\":\"json\",\"limit\":5}}}");
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
@@ -4408,11 +4405,11 @@ TEST(search_code_raw_match_preserves_utf8_content) {
.end_line = 1};
ASSERT_GT(cbm_store_upsert_node(st, &node), 0);
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":98,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_code\",\"arguments\":{"
"\"project\":\"test-project\",\"pattern\":\"raw-\","
"\"file_pattern\":\"*.md\",\"format\":\"json\",\"limit\":5}}}");
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":98,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_code\",\"arguments\":{"
"\"project\":\"test-project\",\"pattern\":\"raw-\","
"\"file_pattern\":\"*.md\",\"format\":\"json\",\"limit\":5}}}");
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
@@ -4459,11 +4456,11 @@ TEST(search_code_context_preserves_utf8_context) {
.end_line = 3};
ASSERT_GT(cbm_store_upsert_node(st, &node), 0);
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":99,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_code\",\"arguments\":{"
"\"project\":\"test-project\",\"pattern\":\"context-needle\","
"\"format\":\"json\",\"context\":1,\"limit\":5}}}");
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":99,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_code\",\"arguments\":{"
"\"project\":\"test-project\",\"pattern\":\"context-needle\","
"\"format\":\"json\",\"context\":1,\"limit\":5}}}");
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
@@ -4507,8 +4504,8 @@ TEST(search_code_invalid_utf8_still_returns_valid_json) {
char invalid_path[512];
snprintf(invalid_path, sizeof(invalid_path), "%s/project/invalid.md", tmp);
static const unsigned char invalid_source[] = {
'i', 'n', 'v', 'a', 'l', 'i', 'd', '-', 'n', 'e', 'e', 'd', 'l', 'e', '\n',
'c', 'o', 'n', 't', 'e', 'x', 't', ' ', 0xFF, '\n',
'i', 'n', 'v', 'a', 'l', 'i', 'd', '-', 'n', 'e', 'e', 'd', 'l',
'e', '\n', 'c', 'o', 'n', 't', 'e', 'x', 't', ' ', 0xFF, '\n',
};
FILE *fp = cbm_fopen(invalid_path, "wb");
ASSERT_NOT_NULL(fp);
@@ -4526,11 +4523,11 @@ TEST(search_code_invalid_utf8_still_returns_valid_json) {
.end_line = 2};
ASSERT_GT(cbm_store_upsert_node(st, &node), 0);
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":100,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_code\",\"arguments\":{"
"\"project\":\"test-project\",\"pattern\":\"invalid-needle\","
"\"mode\":\"full\",\"format\":\"json\",\"limit\":5}}}");
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":100,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_code\",\"arguments\":{"
"\"project\":\"test-project\",\"pattern\":\"invalid-needle\","
"\"mode\":\"full\",\"format\":\"json\",\"limit\":5}}}");
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
@@ -4921,6 +4918,35 @@ TEST(search_code_windows_prefilter_precedes_content_scan) {
#endif
}
/* Windows raw scans must pin the PowerShell pipe to UTF-8: PS 5.1 otherwise
* emits stdout in the console OEM codepage and non-ASCII content degrades to
* '?' depending on the inherited console CP (seen as test_mcp raw-Русский
* mojibake on CI). Every builder variant must carry the prelude. */
TEST(search_code_windows_scan_pins_utf8_output) {
#ifdef _WIN32
static const char prelude[] =
"powershell -Command \"[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; ";
char command[CBM_SZ_4K];
struct {
bool scoped;
const char *file_pattern;
} cases[] = {{true, "*.go"}, /* scoped + prefilterable pattern */
{true, "*x*z*.go"}, /* scoped + non-prefilterable pattern */
{true, NULL}, /* scoped, no pattern */
{false, "*.go"}, /* unscoped + pattern */
{false, NULL}}; /* unscoped, no pattern */
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
cbm_search_code_build_grep_cmd(command, sizeof(command), false, cases[i].scoped,
cases[i].file_pattern, "C:/tmp/pattern", "C:/tmp/filelist",
"C:/tmp/root");
ASSERT_TRUE(strncmp(command, prelude, sizeof(prelude) - 1) == 0);
}
PASS();
#else
SKIP_PLATFORM("PowerShell scan encoding applies on Windows");
#endif
}
/* issue #283: search_code with regex=true and a syntactically invalid pattern
* must return an explicit error, not an empty result indistinguishable from a
* legitimate no-match. */
@@ -8769,10 +8795,10 @@ TEST(tool_resolve_store_by_internal_name_issue704) {
ASSERT_NULL(strstr(list, "ghost704")); /* 0-byte ghost filtered (RED before) */
free(list);
char *page = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":11,\"method\":\"tools/call\","
"\"params\":{\"name\":\"list_projects\","
"\"arguments\":{\"offset\":0,\"limit\":1}}}");
char *page =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":11,\"method\":\"tools/call\","
"\"params\":{\"name\":\"list_projects\","
"\"arguments\":{\"offset\":0,\"limit\":1}}}");
ASSERT_NOT_NULL(page);
ASSERT_NOT_NULL(strstr(page, "\\\"total\\\":3"));
ASSERT_NOT_NULL(strstr(page, "\\\"limit\\\":1"));
@@ -11223,6 +11249,7 @@ SUITE(mcp) {
RUN_TEST(search_code_path_filter_matches_nothing);
RUN_TEST(search_code_file_pattern_prefilter_boundaries);
RUN_TEST(search_code_windows_prefilter_precedes_content_scan);
RUN_TEST(search_code_windows_scan_pins_utf8_output);
RUN_TEST(search_code_invalid_regex_errors_issue283);
RUN_TEST(search_code_literal_pipe_warns_issue282);
RUN_TEST(search_code_reports_phase_timings_only_in_debug_mode);