inspector/scripts/lib/win-shell-args.mjs
cliffhall c93552139c fix(scripts): keep the resolver local, complete the cmd.exe quoting
Copilot review round 3, all three real:

- `require.resolve.paths()` also appends Node's GLOBAL FOLDERS
  ($HOME/.node_modules, $HOME/.node_libraries, $PREFIX/lib/node) and any
  NODE_PATH entries, so round 2's lookup could satisfy a missing repo
  install from a GLOBALLY installed typescript/vite — measuring the
  programs with the wrong compiler instead of producing the actionable
  "run npm install" this helper promises. Filter to the node_modules
  directories on fromDir's own ancestor chain.
- The quoting predicate missed characters cmd.exe still parses, notably
  parentheses (`C:\Temp(1)`), and quoting cannot suppress `%VAR%` at all
  — expansion happens before quotes are processed. Extracted to
  lib/win-shell-args.mjs with a broad predicate and a hard error on `%`
  rather than a silently different path. `platform` is a parameter so the
  Windows behavior is exercised on the POSIX machines that run the suite.
- AGENTS.md claimed pack-and-verify spawns via `process.execPath`; it does
  not, and cannot — its children are `npm` and the installed `.bin` shim.
  State the exception and why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01387r3MotGk52hrkfjzHFsN
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 00:18:40 -04:00

58 lines
2.8 KiB
JavaScript

// Quoting for the few spawns that still need a Windows shell (#1939).
//
// Most call sites avoid the shell entirely — `resolve-node-bin.mjs` finds a
// package's JS entry and runs it with `process.execPath`. That is not available
// for `npm`/`npx` themselves: they are not resolvable from `node_modules`, and
// locating npm's own CLI relative to `process.execPath` differs across system,
// nvm, and volta installs — trading a quoting bug for a resolution bug. So the
// `npm` calls keep `shell: true`, which on Windows is the ONLY way to start the
// `.cmd` shim at all (Node has refused shell-free `.cmd`/`.bat` spawns since the
// CVE-2024-27980 hardening).
//
// With a shell, Node hands `cmd.exe` one space-joined string rather than an argv
// array, so any argument holding a space or a `cmd.exe` metacharacter is
// re-parsed as syntax. That is not hypothetical for these callers: every path
// they pass is generated under `tmpdir()`, which on Windows sits beneath the
// user profile — `C:\Users\First Last\AppData\Local\Temp` splits in two, and a
// profile name like `C:\Temp(1)` is read as grouping syntax.
/**
* `cmd.exe` characters that make an argument parse as syntax rather than text.
* Deliberately broad — parentheses group, `^` escapes, `!` expands under
* delayed expansion, and a bare space splits. Anything not plainly inert is
* quoted; over-quoting is free, since `cmd.exe` and the CRT argument parser
* behind it both strip the quotes back off before the child sees them.
*/
const NEEDS_QUOTING = /[\s&|<>^()!"'`,;=[\]]/;
/**
* A single argument, safe to pass through `cmd.exe`.
*
* Throws on `%`, which quoting genuinely cannot handle: `cmd.exe` expands
* `%NAME%` *before* it processes quotes, so `"%TEMP%"` is rewritten inside the
* quotes and the child receives a different path than we passed. There is no
* escape for it outside a batch file. A `%` in one of these generated temp
* paths is far-fetched, but silently running against the wrong directory is the
* exact class of failure this work exists to remove — so say so instead.
*/
export function quoteWinArg(arg) {
if (arg.includes("%")) {
throw new Error(
`cannot safely pass an argument containing "%" through cmd.exe (it is ` +
`expanded before quotes are processed): ${arg}`,
);
}
if (!NEEDS_QUOTING.test(arg)) return arg;
// A literal `"` inside a quoted string is doubled, per the CRT parser.
return `"${arg.replace(/"/g, '""')}"`;
}
/**
* `args`, quoted for `cmd.exe` when spawning with `shell: true` on Windows and
* returned untouched everywhere else — POSIX shells are not in play here, since
* these call sites only set `shell` on win32.
*/
export function winShellArgs(args, platform = process.platform) {
if (platform !== "win32") return args;
return args.map(quoteWinArg);
}