@@ -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+
171296SUITE (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