From fd55745f663d25622ed1478ae199f8e145bc15d1 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 13 Oct 2026 00:48:44 -0400 Subject: [PATCH] fix(scripts): double backslashes before a quote in win-shell-args An argument ending in a backslash was quoted as "...\\", which the CRT parser reads as an escaped quote, swallowing the rest of the command line. Backslash runs before an embedded quote or the closing quote are now doubled, with tests including a round trip through a reference CRT parser. Closes #5111 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- scripts/lib/win-shell-args.mjs | 12 +++++- scripts/lib/win-shell-args.test.mjs | 62 +++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/scripts/lib/win-shell-args.mjs b/scripts/lib/win-shell-args.mjs index 5467b37688..151ee87cab 100644 --- a/scripts/lib/win-shell-args.mjs +++ b/scripts/lib/win-shell-args.mjs @@ -43,8 +43,16 @@ export function quoteWinArg(arg) { ); } if (!NEEDS_QUOTING.test(arg)) return arg; - // A literal `"` inside a quoted string is doubled, per the CRT parser. - return `"${arg.replace(/"/g, '""')}"`; + // The CRT parser reads backslashes literally except before a `"`, where 2n + // backslashes yield n and leave the quote special. So any run of backslashes + // that ends up in front of a quote is doubled: one before an embedded `"`, + // and a trailing run, which the closing quote follows (#5111). Undoubled, + // `C:\dir\` becomes `"C:\dir\"`, whose `\"` is an escaped quote and swallows + // the rest of the command line. The embedded `"` itself is then doubled + // rather than written `\"`: `""` keeps `cmd.exe`'s own quote tracking in + // step, while `\"` would flip it and expose any metacharacter after it. + const escaped = arg.replace(/(\\*)"/g, '$1$1""').replace(/(\\+)$/, "$1$1"); + return `"${escaped}"`; } /** diff --git a/scripts/lib/win-shell-args.test.mjs b/scripts/lib/win-shell-args.test.mjs index dac6481f4a..5794446cdf 100644 --- a/scripts/lib/win-shell-args.test.mjs +++ b/scripts/lib/win-shell-args.test.mjs @@ -39,6 +39,68 @@ test("doubles an embedded quote", () => { assert.equal(quoteWinArg('a"b c'), '"a""b c"'); }); +test("doubles a trailing backslash run, which the closing quote follows (#5111)", () => { + // Undoubled, `"C:\dir with space\"` ends in `\"`, an escaped quote, and the + // CRT parser runs the argument on into the next one. + assert.equal(quoteWinArg("C:\\a b\\"), '"C:\\a b\\\\"'); + assert.equal(quoteWinArg("C:\\a b\\\\"), '"C:\\a b\\\\\\\\"'); +}); + +test("doubles backslashes before an embedded quote, then doubles the quote", () => { + assert.equal(quoteWinArg('a\\"b c'), '"a\\\\""b c"'); + assert.equal(quoteWinArg('a\\\\"b'), '"a\\\\\\\\""b"'); +}); + +test("leaves backslashes not followed by a quote alone", () => { + assert.equal(quoteWinArg("C:\\a b\\c"), '"C:\\a b\\c"'); + // An inert trailing-backslash path is not quoted, so nothing follows it. + assert.equal(quoteWinArg("C:\\dir\\"), "C:\\dir\\"); +}); + +test("quoted output parses back to the original under the CRT rules", () => { + // A reference parser for one quoted argument (the MSVCRT 2008+ rules), so + // the expectations above are checked against what the child actually sees. + const parse = (s) => { + let out = ""; + let inQuotes = false; + for (let i = 0; i < s.length; ) { + let n = 0; + while (s[i] === "\\") { + n++; + i++; + } + if (s[i] === '"') { + out += "\\".repeat(n >> 1); + if (n & 1) { + out += '"'; + } else if (inQuotes && s[i + 1] === '"') { + out += '"'; + i++; + } else { + inQuotes = !inQuotes; + } + i++; + } else { + out += "\\".repeat(n); + if (i < s.length) out += s[i++]; + } + } + return out; + }; + for (const arg of [ + "C:\\a b\\", + "C:\\a b\\\\", + 'a\\"b c', + 'a\\\\"b', + 'a"b c', + '"', + "\\ \\", + "C:\\Temp(1)\\", + ]) { + assert.equal(parse(quoteWinArg(arg)), arg, `round trip: ${arg}`); + } +}); + test("throws on `%` rather than silently mis-expanding it", () => { // cmd.exe expands %NAME% before it processes quotes, so quoting cannot save // this one — the child would receive a different path than we passed.