Skip to content

Commit 3714266

Browse files
authored
Merge pull request #881 from Flipper1994/fix/win-index-worker-cmdline-quoting
fix(win): escape spawned command-line args so the index worker gets valid argv
2 parents 94ba1e2 + 982916b commit 3714266

5 files changed

Lines changed: 271 additions & 19 deletions

File tree

src/foundation/subprocess.c

Lines changed: 93 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -132,25 +132,107 @@ static bool cbm_tail_log(const char *log_file, long *tail_pos, cbm_proc_log_cb c
132132
return progressed;
133133
}
134134

135+
/* ── Windows command-line quoting (pure; unit-tested on every platform) ─────── */
136+
137+
/* Append char `c` to buf[cap], reserving the final byte for a NUL terminator.
138+
* Sets *ovf on overflow and stops writing; pos keeps advancing so the caller
139+
* still detects the overflow after the loop. */
140+
static size_t cbm_cmdline_put(char *buf, size_t cap, size_t pos, char c, bool *ovf) {
141+
if (pos + 1 >= cap) {
142+
*ovf = true;
143+
return pos;
144+
}
145+
buf[pos] = c;
146+
return pos + 1;
147+
}
148+
149+
/* Append one argv element to the command line using the Microsoft C runtime
150+
* quoting rules (see MS "Parsing C Command-Line Arguments"). CreateProcess takes
151+
* a SINGLE string that the child re-parses back into argv, so any element with a
152+
* space, tab or double-quote must be wrapped in quotes and its embedded quotes /
153+
* preceding backslashes escaped. Without this a JSON argument like
154+
* {"repo_path":"C:/r"} loses its inner quotes and the child receives the invalid
155+
* {repo_path:C:/r} — the Windows-only index-worker cmdline-quoting bug (the worker exited
156+
* non-zero at JSON-arg parse, misattributed to the last-marked file). POSIX is
157+
* unaffected: cbm_run_posix passes the argv array straight to execv. */
158+
static size_t cbm_cmdline_append_arg(char *buf, size_t cap, size_t pos, const char *arg, bool first,
159+
bool *ovf) {
160+
if (!first) {
161+
pos = cbm_cmdline_put(buf, cap, pos, ' ', ovf);
162+
}
163+
pos = cbm_cmdline_put(buf, cap, pos, '"', ovf);
164+
for (const char *p = arg; *p;) {
165+
size_t nbs = 0;
166+
while (*p == '\\') {
167+
nbs++;
168+
p++;
169+
}
170+
if (*p == '\0') {
171+
/* Trailing backslashes precede the closing quote: double them so the
172+
* quote stays a delimiter, not an escaped literal. */
173+
for (size_t k = 0; k < nbs * 2; k++) {
174+
pos = cbm_cmdline_put(buf, cap, pos, '\\', ovf);
175+
}
176+
break;
177+
}
178+
if (*p == '"') {
179+
/* N backslashes then a quote -> 2N+1 backslashes then an escaped quote. */
180+
for (size_t k = 0; k < nbs * 2 + 1; k++) {
181+
pos = cbm_cmdline_put(buf, cap, pos, '\\', ovf);
182+
}
183+
pos = cbm_cmdline_put(buf, cap, pos, '"', ovf);
184+
p++;
185+
} else {
186+
for (size_t k = 0; k < nbs; k++) {
187+
pos = cbm_cmdline_put(buf, cap, pos, '\\', ovf);
188+
}
189+
pos = cbm_cmdline_put(buf, cap, pos, *p, ovf);
190+
p++;
191+
}
192+
}
193+
pos = cbm_cmdline_put(buf, cap, pos, '"', ovf);
194+
return pos;
195+
}
196+
197+
/* Build a full Windows CreateProcess command line from a NULL-terminated argv,
198+
* applying the MS C runtime quoting rules so the child re-parses byte-identical
199+
* argv. Returns true on success, false if the result would overflow `buf`.
200+
*
201+
* Defined unconditionally (pure string logic, no Windows headers) so the quoting
202+
* contract is unit-tested on Linux/macOS CI too — even though the real spawn path
203+
* only runs on Windows. Shared by cbm_run_win AND the UI http_server index spawn
204+
* so both escape identically; a naive `"%s"` wrap silently corrupts any argument
205+
* containing a quote (e.g. the index JSON {"repo_path":"…"}), corrupting the
206+
* spawned child's argv. */
207+
bool cbm_build_win_cmdline(char *buf, size_t cap, const char *const *argv) {
208+
if (!buf || cap == 0 || !argv) {
209+
return false;
210+
}
211+
size_t pos = 0;
212+
bool ovf = false;
213+
for (int i = 0; argv[i]; i++) {
214+
pos = cbm_cmdline_append_arg(buf, cap, pos, argv[i], i == 0, &ovf);
215+
if (ovf) {
216+
return false;
217+
}
218+
}
219+
buf[pos] = '\0';
220+
return true;
221+
}
222+
135223
#ifdef _WIN32
136224

137225
static int cbm_run_win(const cbm_proc_opts_t *opts, cbm_proc_result_t *out) {
138226
const char *bin = opts->bin;
139227
const char *const default_argv[] = {bin, NULL};
140228
const char *const *argv = opts->argv ? opts->argv : default_argv;
141229

142-
/* Build a quoted command line from argv. */
143230
char cmdline[8192];
144-
size_t pos = 0;
145-
for (int i = 0; argv[i]; i++) {
146-
int n = snprintf(cmdline + pos, sizeof(cmdline) - pos, "%s\"%s\"", (i ? " " : ""), argv[i]);
147-
if (n < 0 || (size_t)n >= sizeof(cmdline) - pos) {
148-
out->outcome = CBM_PROC_SPAWN_FAILED;
149-
out->exit_code = -1;
150-
out->term_signal = 0;
151-
return -1;
152-
}
153-
pos += (size_t)n;
231+
if (!cbm_build_win_cmdline(cmdline, sizeof(cmdline), argv)) {
232+
out->outcome = CBM_PROC_SPAWN_FAILED;
233+
out->exit_code = -1;
234+
out->term_signal = 0;
235+
return -1;
154236
}
155237

156238
HANDLE hlog = INVALID_HANDLE_VALUE;

src/foundation/subprocess.h

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
#define CBM_SUBPROCESS_H
2222

2323
#include <stdbool.h>
24+
#include <stddef.h> /* size_t (cbm_build_win_cmdline) */
2425

2526
/* How a supervised child ended. */
2627
typedef enum {
@@ -73,4 +74,17 @@ cbm_proc_outcome_t cbm_proc_classify(bool exited_normally, int exit_code, int te
7374
/* Stable lowercase name for an outcome (for structured logs / skip reasons). */
7475
const char *cbm_proc_outcome_str(cbm_proc_outcome_t o);
7576

77+
/* Build a Windows CreateProcess command line from a NULL-terminated argv, applying
78+
* the Microsoft C runtime quoting rules (quote-wrap + escape embedded quotes and
79+
* their preceding backslashes) so the spawned child re-parses byte-identical argv.
80+
* Returns true on success, false on overflow (buf then holds a truncated string).
81+
*
82+
* CreateProcess re-parses a SINGLE command string into argv, so a naive `"%s"` wrap
83+
* silently corrupts any element containing a double-quote — e.g. the index worker's
84+
* JSON arg {"repo_path":"…"} arrives as {repo_path:…}, the Windows index-worker bug.
85+
* Exposed (and compiled on every platform — it is pure string logic) so the quoting
86+
* is unit-tested on Linux/macOS CI, and so both spawn sites (cbm_subprocess_run and
87+
* the UI http_server index spawn) escape through one shared, tested implementation. */
88+
bool cbm_build_win_cmdline(char *buf, size_t cap, const char *const *argv);
89+
7690
#endif /* CBM_SUBPROCESS_H */

src/mcp/index_supervisor.c

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,10 @@ int cbm_index_spawn_worker(const char *args_json, bool single_thread, const char
192192
opts.argv = argv;
193193
opts.log_file = log_path;
194194
opts.quiet_timeout_ms = worker_quiet_timeout_ms();
195-
opts.delete_log_on_exit = true;
195+
/* We manage log deletion ourselves after reaping (below): keep it on failure
196+
* for post-mortem, delete it only on a clean run. See the observability
197+
* note at the reap site. */
198+
opts.delete_log_on_exit = false;
196199

197200
cbm_proc_result_t r;
198201
int run_rc = cbm_subprocess_run(&opts, &r);
@@ -209,6 +212,7 @@ int cbm_index_spawn_worker(const char *args_json, bool single_thread, const char
209212

210213
if (run_rc != 0) {
211214
(void)remove(resp_path);
215+
(void)remove(log_path); /* empty/partial log from a failed spawn — nothing to keep */
212216
cbm_log_warn("index.supervisor.spawn_failed", "action", "degrade_in_process");
213217
return -1;
214218
}
@@ -222,9 +226,25 @@ int cbm_index_spawn_worker(const char *args_json, bool single_thread, const char
222226
(void)remove(resp_path);
223227

224228
char sig[16];
229+
char exit_buf[16];
225230
snprintf(sig, sizeof(sig), "%d", r.term_signal);
226-
cbm_log_info("index.supervisor.reap", "outcome", cbm_proc_outcome_str(r.outcome), "signal",
227-
sig);
231+
snprintf(exit_buf, sizeof(exit_buf), "%d", r.exit_code);
232+
cbm_log_info("index.supervisor.reap", "outcome", cbm_proc_outcome_str(r.outcome), "exit_code",
233+
exit_buf, "signal", sig);
234+
235+
/* Observability: on a CLEAN run the worker log is noise → delete it. On
236+
* ANY failure keep it and surface its path + raw exit code, so the worker's own
237+
* stdout/stderr (pipeline logs, any assert/abort text, the exact exit code) is
238+
* available post-mortem instead of vanishing. Previously the log was ALWAYS
239+
* deleted and only outcome+signal were logged, so a worker that exited non-zero
240+
* left nothing to diagnose — the CI blind spot that hid this bug (a mangled JSON
241+
* arg → "repo_path is required" exit) behind a generic "crashed on a file". */
242+
if (r.outcome == CBM_PROC_CLEAN) {
243+
(void)remove(log_path);
244+
} else {
245+
cbm_log_warn("index.supervisor.worker_failed", "outcome", cbm_proc_outcome_str(r.outcome),
246+
"exit_code", exit_buf, "log", log_path);
247+
}
228248
return 0;
229249
}
230250

src/ui/http_server.c

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
#include "foundation/compat_fs.h"
3434
#include "foundation/str_util.h"
3535
#include "foundation/compat_thread.h"
36+
#include "foundation/subprocess.h" /* cbm_build_win_cmdline — shared MS-CRT arg quoting */
3637

3738
#include <sqlite3/sqlite3.h>
3839
#include <yyjson/yyjson.h>
@@ -957,13 +958,20 @@ static void *index_thread_fn(void *arg) {
957958
snprintf(log_file, sizeof(log_file), "%s\\cbm_index_%d.log",
958959
getenv("TEMP") ? getenv("TEMP") : ".", (int)_getpid());
959960

960-
/* Build command line for CreateProcess */
961-
char cmdline[2048];
962-
/* --index-worker: this http_server spawn is already the crash-isolation layer,
961+
/* Build command line for CreateProcess through the shared MS-CRT quoter so the
962+
* JSON arg's embedded quotes survive the child's argv re-parse — a naive
963+
* `"%s"` wrap dropped them, corrupting {"repo_path":"…"} into {repo_path:…}.
964+
* --index-worker: this http_server spawn is already the crash-isolation layer,
963965
* so the child runs indexing in-process rather than spawning its own supervisor
964966
* (avoids redundant process nesting). */
965-
snprintf(cmdline, sizeof(cmdline), "\"%s\" cli --index-worker index_repository \"%s\"", bin,
966-
json_arg);
967+
char cmdline[2048];
968+
const char *const idx_argv[] = {bin, "cli", "--index-worker", "index_repository",
969+
json_arg, NULL};
970+
if (!cbm_build_win_cmdline(cmdline, sizeof(cmdline), idx_argv)) {
971+
snprintf(job->error_msg, sizeof(job->error_msg), "index command line too long");
972+
atomic_store(&job->status, 3);
973+
return NULL;
974+
}
967975

968976
cbm_log_info("ui.index.spawn", "bin", bin, "log", log_file);
969977

tests/test_subprocess.c

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,131 @@ TEST(subprocess_run_null_bin_rejected) {
168168
PASS();
169169
}
170170

171+
/* ── Layer 3: Windows command-line quoting (pure; every platform) ─────────────
172+
*
173+
* The Windows index-worker "crash" was a quoting bug: the Windows spawn wrapped each
174+
* argv element in bare quotes without escaping, so a JSON argument like
175+
* {"repo_path":"C:/r"} lost its inner quotes when the child re-parsed the command
176+
* line — the worker then failed at JSON-arg parse and exited non-zero, which the
177+
* supervisor misreported as a per-file crash. We guard cbm_build_win_cmdline by
178+
* ROUND-TRIP: a reference implementation of the Windows CommandLineToArgvW rules
179+
* (the inverse of the builder) must re-parse the emitted line back into the exact
180+
* original argv. Testing the invariant — not a hand-computed escaped string — keeps
181+
* the guard honest and readable, and runs on Linux/macOS CI (the builder is pure). */
182+
183+
/* Reference re-parser: the subset of CommandLineToArgvW our builder emits (every
184+
* arg quote-wrapped; \" for embedded quotes; backslashes doubled before a quote). */
185+
static int parse_win_cmdline(const char *cmd, char out[][256], int max_args) {
186+
int argc = 0;
187+
const char *p = cmd;
188+
while (*p) {
189+
while (*p == ' ' || *p == '\t') {
190+
p++;
191+
}
192+
if (!*p || argc >= max_args) {
193+
break;
194+
}
195+
char *o = out[argc];
196+
size_t oi = 0;
197+
bool in_quotes = false;
198+
for (;;) {
199+
size_t nbs = 0;
200+
while (*p == '\\') {
201+
nbs++;
202+
p++;
203+
}
204+
if (*p == '"') {
205+
for (size_t k = 0; k < nbs / 2; k++) {
206+
o[oi++] = '\\';
207+
}
208+
if (nbs % 2) {
209+
o[oi++] = '"'; /* odd run → the quote is an escaped literal */
210+
} else {
211+
in_quotes = !in_quotes; /* even run → the quote is a delimiter */
212+
}
213+
p++;
214+
} else {
215+
for (size_t k = 0; k < nbs; k++) {
216+
o[oi++] = '\\';
217+
}
218+
if (*p == '\0' || (!in_quotes && (*p == ' ' || *p == '\t'))) {
219+
break;
220+
}
221+
o[oi++] = *p++;
222+
}
223+
}
224+
o[oi] = '\0';
225+
argc++;
226+
}
227+
return argc;
228+
}
229+
230+
static bool cmdline_roundtrips(const char *const *argv) {
231+
char cmd[4096];
232+
if (!cbm_build_win_cmdline(cmd, sizeof(cmd), argv)) {
233+
return false;
234+
}
235+
char parsed[16][256];
236+
int pc = parse_win_cmdline(cmd, parsed, 16);
237+
int oc = 0;
238+
while (argv[oc]) {
239+
oc++;
240+
}
241+
if (pc != oc) {
242+
return false;
243+
}
244+
for (int i = 0; i < oc; i++) {
245+
if (strcmp(argv[i], parsed[i]) != 0) {
246+
return false;
247+
}
248+
}
249+
return true;
250+
}
251+
252+
/* The exact index-worker argv: the command line with a JSON arg full of quotes.
253+
* Round-trips, AND the emitted line must contain an ESCAPED quote (\") — the bare
254+
* `"%s"` wrap that caused the bug never would. */
255+
TEST(win_cmdline_index_worker_json) {
256+
const char *const argv[] = {"C:/bin/cbm.exe", "cli",
257+
"--index-worker", "index_repository",
258+
"{\"repo_path\":\"C:/r\"}", "--response-out",
259+
"C:/c/w.response", NULL};
260+
ASSERT(cmdline_roundtrips(argv));
261+
char cmd[4096];
262+
ASSERT(cbm_build_win_cmdline(cmd, sizeof(cmd), argv));
263+
ASSERT(strstr(cmd, "\\\"repo_path\\\"") != NULL); /* inner quotes are escaped */
264+
PASS();
265+
}
266+
267+
/* A battery of adversarial argv (spaces, tabs, embedded quotes, backslash runs,
268+
* trailing backslashes, backslash-before-quote, real Windows paths) must all
269+
* round-trip byte-for-byte through the builder + reference parser. */
270+
TEST(win_cmdline_roundtrip_battery) {
271+
const char *const a1[] = {"a", "b", NULL};
272+
const char *const a2[] = {"has space", "tab\there", NULL};
273+
const char *const a3[] = {"trailing\\", "a\\b\\c", NULL};
274+
const char *const a4[] = {"a\\\"b", "\"", "\\\\\"", NULL};
275+
const char *const a5[] = {"C:\\Users\\me\\my repo", "{\"name\":\"a b\",\"x\":\"y\\\\z\"}",
276+
NULL};
277+
const char *const a6[] = {"", "plain", NULL};
278+
ASSERT(cmdline_roundtrips(a1));
279+
ASSERT(cmdline_roundtrips(a2));
280+
ASSERT(cmdline_roundtrips(a3));
281+
ASSERT(cmdline_roundtrips(a4));
282+
ASSERT(cmdline_roundtrips(a5));
283+
ASSERT(cmdline_roundtrips(a6));
284+
PASS();
285+
}
286+
287+
/* Overflow is reported (false), never a silent truncation that would spawn a
288+
* corrupted command line. */
289+
TEST(win_cmdline_overflow_rejected) {
290+
const char *const argv[] = {"aaaaaaaaaa", "bbbbbbbbbb", NULL};
291+
char tiny[8];
292+
ASSERT_FALSE(cbm_build_win_cmdline(tiny, sizeof(tiny), argv));
293+
PASS();
294+
}
295+
171296
SUITE(subprocess) {
172297
RUN_TEST(subprocess_classify_clean);
173298
RUN_TEST(subprocess_classify_exit_nonzero);
@@ -182,4 +307,7 @@ SUITE(subprocess) {
182307
RUN_TEST(subprocess_run_hang_is_hang);
183308
RUN_TEST(subprocess_run_spawn_failure);
184309
RUN_TEST(subprocess_run_null_bin_rejected);
310+
RUN_TEST(win_cmdline_index_worker_json);
311+
RUN_TEST(win_cmdline_roundtrip_battery);
312+
RUN_TEST(win_cmdline_overflow_rejected);
185313
}

0 commit comments

Comments
 (0)