Fix cmd.exe /S quote desync and gh-stub EINVAL on Windows - #20
Merged
Merged
Conversation
buildWindowsCmdInvocation joined per-argument cmd-escaped segments
("file" "arg1" "arg2"...) without an outer wrapping quote pair. cmd's
/S rule strips the first character (if a quote) and the *last quote
character anywhere in the remainder*, not the last character of the
string -- with no wrapper, that second strip landed on the closing
quote of the last argument instead of a deliberate boundary, desyncing
every argument's quoting and reproducing the reported
"& was unexpected at this time". Wrap the whole escaped command in one
more outer quote pair, matching the pattern already used correctly by
startOnWindows and check-runner.ts's buildCheckInvocation in this same
codebase.
gh-stub.test.ts drove gh.cmd directly via execFileSync, bypassing
exec()'s cmd.exe indirection entirely. Node refuses to spawn .cmd/.bat
files without shell: true (the CVE-2024-27980 mitigation) and throws
EINVAL synchronously, matching the reported
"spawnSync ... gh.cmd EINVAL". Add shell: true on Windows for those
three direct launcher invocations.
test/exec-windows-cmd.test.ts now simulates the /S stripping rule and
asserts the round-trip back to the correctly escaped inner command;
the previous test asserted the unwrapped shape, which was the bug.
worktrees.test.ts, repo-health.test.ts and signing.test.ts's gpg case
remain unexplained per the issue thread and are not touched here --
verifying a fix needs a real Windows host, and two prior attempts at
reasoning about them from source were wrong.
Fixes #3
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #3 (partially — see scope note below).
Root causes fixed
buildWindowsCmdInvocation/cmdEscapeArgument(src/main/exec.ts) — the actual reason the.cmdspawn bug survived #2. The remainder passed tocmd.exe /d /s /cwas a sequence of separately quoted-and-caret-escaped segments ("file" "arg1" "arg2"...) with no outer wrapper. Percmd /?,/Sstrips the first character (if a quote) and the last quote character found anywhere in the remainder — not the last character of the string. With no wrapper, that second strip landed on the closing quote of the last argument instead of a deliberate boundary, desyncing every argument's quote pairing — exactly reproducing& was unexpected at this time. Fix: wrap the whole escaped command in one more outer quote pair, matching the pattern already used correctly elsewhere in this file (startOnWindows) and incheck-runner.ts'sbuildCheckInvocation.gh-stub.test.tsspawnSync ... EINVAL— that test drivesgh.cmddirectly viaexecFileSync, bypassingexec()'s cmd.exe indirection entirely (as the issue notes: "This isspawnSync, notexec(), so it never went through the fix in #2 at all"). Node refuses to spawn.cmd/.batwithoutshell: true(the CVE-2024-27980 mitigation) and throwsEINVALsynchronously — matching the report exactly. Addedshell: trueon Windows for the three direct launcher invocations.test/exec-windows-cmd.test.tsnow simulates the/Sstripping rule itself and asserts the round-trip back to the correctly escaped inner command. The previous test actually asserted the unwrapped shape was correct — that assertion was the bug.Not touched
worktrees.test.ts(4),repo-health.test.ts(1), andsigning.test.ts's real-GPG case remain unexplained per the issue thread ("likely path or line-ending handling... needs to be worked out against a real Windows shell rather than reasoned about — two attempts have now been wrong"). I don't have a Windows host to verify a fix against, so I left these alone rather than guess a third time. Also left theif: runner.os == 'Linux'CI guards in place — removing them before those are fixed would just turn CI red.Verification
npx vitest run test/exec-windows-cmd.test.ts test/fixture/gh-stub.test.ts test/check-runner.test.ts— 24/24 pass.npx tsc --noEmit— clean./S-stripping simulation added toexec-windows-cmd.test.tsis the strongest proof available without an actual Windows runner; recommend re-measuring the fixture suite on Windows CI against this branch.