Skip to content

Commit 4cafc3f

Browse files
authored
Move away from cross-spawn (#1251)
1 parent 97dc613 commit 4cafc3f

11 files changed

Lines changed: 540 additions & 8 deletions

File tree

docs/windows.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import {execa} from 'execa';
3535
await execa`npm run build`;
3636
```
3737

38-
This means `.cmd` and `.bat` files can be run directly. Unlike Node.js, Execa does not require a [shell](shell.md) (nor a `cmd.exe /c` prefix) for this. Relative paths (such as `./folder/executable`) and files whose path contains spaces also work without a shell, just like on Unix. This is thanks to [`cross-spawn`](https://github.com/moxystudio/node-cross-spawn?tab=readme-ov-file#why).
38+
This means `.cmd` and `.bat` files can be run directly. Unlike Node.js, Execa does not require a [shell](shell.md) (nor a `cmd.exe /c` prefix) for this. Relative paths (such as `./folder/executable`) and files whose path contains spaces also work without a shell, just like on Unix. Execa handles this automatically.
3939

4040
## Signals
4141

lib/arguments/command-file.js

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import {openSync, readSync, closeSync} from 'node:fs';
2+
import {Buffer} from 'node:buffer';
3+
import path from 'node:path';
4+
import process from 'node:process';
5+
import which from 'which';
6+
import pathKey from 'path-key';
7+
8+
/*
9+
On Windows, `node:child_process` cannot natively run many kinds of files (`.cmd`, `.bat`, shebang scripts, ...): without a shell it resolves neither `PATHEXT` nor shebangs, and it does not escape arguments. We resolve the command to its full file path and escape its arguments ourselves, so those work without an explicit shell, just like on Unix, where the OS handles all of this for us.
10+
*/
11+
export const parseCommandFile = (file, commandArguments, options) => {
12+
// The arguments are cloned since a shebang interpreter might be prepended below, which must not mutate the caller's array
13+
const parsed = {file, commandArguments: [...commandArguments], options};
14+
15+
// Under a shell, or on Unix, the OS resolves the file and escapes the arguments itself
16+
if (options.shell || process.platform !== 'win32') {
17+
return parsed;
18+
}
19+
20+
return escapeWindowsCommand(parsed);
21+
};
22+
23+
// Only `.exe` and `.com` files can be spawned directly; anything else needs `cmd.exe`
24+
const directlyExecutableRegExp = /\.(?:com|exe)$/i;
25+
26+
// `.cmd` and `.bat` files re-expand their own arguments via `%*`/`%1`, so metacharacters must survive being interpreted by `cmd.exe` twice: once when the batch file is invoked and once when it forwards the arguments
27+
const batchFileRegExp = /\.(?:bat|cmd)$/i;
28+
29+
const escapeWindowsCommand = parsed => {
30+
// Resolve the file to an absolute path, following its shebang to the interpreter if any
31+
const resolvedFile = resolveWithShebang(parsed);
32+
33+
// A directly executable file is spawned as-is, bypassing `cmd.exe` and its escaping
34+
if (resolvedFile !== undefined && directlyExecutableRegExp.test(resolvedFile)) {
35+
return parsed;
36+
}
37+
38+
/*
39+
`cmd.exe` treats CR and LF as command separators and offers no way to escape them, so allowing either would enable command injection.
40+
Reject them instead.
41+
*/
42+
for (const value of [parsed.file, ...parsed.commandArguments]) {
43+
assertNoLineBreak(value);
44+
}
45+
46+
const doubleEscape = resolvedFile !== undefined && batchFileRegExp.test(resolvedFile);
47+
// POSIX separators must become Windows ones (`foo/bar` -> `foo\bar`), otherwise resolution always fails with ENOENT
48+
const escapedFile = escapeMetaChars(path.normalize(parsed.file));
49+
const escapedArguments = parsed.commandArguments.map(argument => escapeArgument(argument, doubleEscape));
50+
const commandLine = `"${[escapedFile, ...escapedArguments].join(' ')}"`;
51+
52+
// Let `node:child_process` pass the already-escaped command line through untouched
53+
parsed.options.windowsVerbatimArguments = true;
54+
return {
55+
file: process.env.comspec || 'cmd.exe',
56+
commandArguments: ['/d', '/s', '/c', commandLine],
57+
options: parsed.options,
58+
};
59+
};
60+
61+
// Resolve the command's absolute path, then, if it is a shebang script, resolve its interpreter instead, since Windows cannot run shebangs natively
62+
const resolveWithShebang = parsed => {
63+
const resolvedFile = resolvePath(parsed);
64+
const interpreter = resolvedFile !== undefined && readShebang(resolvedFile);
65+
if (!interpreter) {
66+
return resolvedFile;
67+
}
68+
69+
// Run the interpreter with the script as its first argument, then resolve the interpreter's own path
70+
parsed.commandArguments.unshift(resolvedFile);
71+
parsed.file = interpreter;
72+
return resolvePath(parsed);
73+
};
74+
75+
// Search `PATH` for the command, first honoring `PATHEXT`, then ignoring it as a fallback
76+
const resolvePath = parsed => resolveInCwd(parsed, false) || resolveInCwd(parsed, true);
77+
78+
const resolveInCwd = (parsed, ignorePathExtension) => {
79+
const environment = parsed.options.env || process.env;
80+
81+
/*
82+
`which` runs `stat` relative to the current directory but has no `cwd` option, so we temporarily switch into the target directory and restore it afterwards.
83+
84+
`process.chdir()` is disabled in worker threads and might be absent in non-Node runtimes.
85+
*/
86+
const canChangeDirectory = process.chdir !== undefined && !process.chdir.disabled;
87+
const originalDirectory = process.cwd();
88+
if (canChangeDirectory) {
89+
try {
90+
process.chdir(parsed.options.cwd);
91+
} catch {}
92+
}
93+
94+
try {
95+
const resolved = which.sync(parsed.file, {
96+
path: environment[pathKey({env: environment})],
97+
pathExt: ignorePathExtension ? path.delimiter : undefined,
98+
});
99+
// `cwd` is already an absolute path, so this returns an absolute path too
100+
return path.resolve(parsed.options.cwd, resolved);
101+
} catch {
102+
return undefined;
103+
} finally {
104+
if (canChangeDirectory) {
105+
process.chdir(originalDirectory);
106+
}
107+
}
108+
};
109+
110+
const SHEBANG_BYTE_LENGTH = 150;
111+
112+
// Read the file's first bytes to find its shebang interpreter, if it has one
113+
const readShebang = file => {
114+
const buffer = Buffer.alloc(SHEBANG_BYTE_LENGTH);
115+
116+
try {
117+
const fileDescriptor = openSync(file, 'r');
118+
try {
119+
readSync(fileDescriptor, buffer, 0, SHEBANG_BYTE_LENGTH, 0);
120+
} finally {
121+
closeSync(fileDescriptor);
122+
}
123+
} catch {
124+
return undefined;
125+
}
126+
127+
return parseShebang(buffer.toString());
128+
};
129+
130+
const shebangRegExp = /^#!(?<line>.*)/;
131+
132+
/*
133+
Extract the interpreter from a shebang line, e.g. `#!/usr/bin/env node` -> `node`.
134+
*/
135+
const parseShebang = contents => {
136+
const shebangLine = contents.match(shebangRegExp)?.groups.line.trim();
137+
if (!shebangLine) {
138+
return undefined;
139+
}
140+
141+
const [interpreterPath, argument] = shebangLine.split(' ');
142+
const interpreter = interpreterPath.split('/').at(-1);
143+
if (interpreter === 'env') {
144+
return argument;
145+
}
146+
147+
return argument ? `${interpreter} ${argument}` : interpreter;
148+
};
149+
150+
const lineBreakRegExp = /[\n\r]/;
151+
152+
const assertNoLineBreak = value => {
153+
if (lineBreakRegExp.test(value)) {
154+
throw new TypeError(`The command and its arguments cannot contain a line break on Windows without a shell.\nThis would allow a command injection with \`cmd.exe\`.\nInvalid value: ${JSON.stringify(`${value}`)}`);
155+
}
156+
};
157+
158+
// See https://web.archive.org/web/20241220221102/https://www.robvanderwoude.com/escapechars.php
159+
// eslint-disable-next-line regexp/sort-character-class-elements
160+
const metaCharsRegExp = /[()\][%!^"`<>&|;, *?]/g;
161+
162+
// Prefix every `cmd.exe` metacharacter with a caret to neutralize it
163+
const escapeMetaChars = value => value.replaceAll(metaCharsRegExp, '^$&');
164+
165+
const backslashRunRegExp = /\\+/g;
166+
167+
const escapeArgument = (rawArgument, doubleEscape) => {
168+
/*
169+
Escape backslashes and double quotes for `cmd.exe`, following the algorithm at https://web.archive.org/web/20240930203505/https://qntm.org/cmd.
170+
171+
A run of backslashes only needs doubling when it precedes a double quote, or the end of the argument since that becomes a double quote once the argument is wrapped below. Otherwise the backslashes would be taken as escaping that quote. Every double quote is then escaped in turn.
172+
173+
Each backslash run is matched exactly once and consumed, so a long run cannot trigger the quadratic backtracking a naive pattern would, which would be a denial-of-service risk.
174+
*/
175+
const argument = `${rawArgument}`
176+
.replaceAll(backslashRunRegExp, (backslashes, offset, string) => {
177+
const nextCharacter = string[offset + backslashes.length];
178+
const precedesDoubleQuote = nextCharacter === '"' || nextCharacter === undefined;
179+
return precedesDoubleQuote ? backslashes.repeat(2) : backslashes;
180+
})
181+
.replaceAll('"', '\\"');
182+
183+
// Wrap the whole argument in double quotes, then caret-escape the metacharacters, a second time when targeting a cmd-shim
184+
const escapedArgument = escapeMetaChars(`"${argument}"`);
185+
return doubleEscape ? escapeMetaChars(escapedArgument) : escapedArgument;
186+
};

lib/arguments/options.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import path from 'node:path';
22
import process from 'node:process';
3-
import crossSpawn from 'cross-spawn';
43
import {npmRunPathEnv} from 'npm-run-path';
54
import {normalizeForceKillAfterDelay} from '../terminate/kill.js';
65
import {normalizeKillSignal} from '../terminate/signal.js';
@@ -10,6 +9,7 @@ import {validateTimeout} from '../terminate/timeout.js';
109
import {handleNodeOption} from '../methods/node.js';
1110
import {validateIpcInputOption} from '../ipc/ipc-input.js';
1211
import {validateEncoding, BINARY_ENCODINGS} from './encoding-option.js';
12+
import {parseCommandFile} from './command-file.js';
1313
import {normalizeCwd} from './cwd.js';
1414
import {normalizeFileUrl} from './file-url.js';
1515
import {normalizeFdSpecificOptions} from './specific.js';
@@ -22,7 +22,7 @@ export const normalizeOptions = (filePath, rawArguments, rawOptions) => {
2222
sanitizedOptions.cwd = normalizeCwd(sanitizedOptions.cwd);
2323
const [processedFile, processedArguments, processedOptions] = handleNodeOption(filePath, rawArguments, sanitizedOptions);
2424

25-
const {command: file, args: commandArguments, options: initialOptions} = crossSpawn._parse(processedFile, processedArguments, processedOptions);
25+
const {file, commandArguments, options: initialOptions} = parseCommandFile(processedFile, processedArguments, processedOptions);
2626

2727
const fdOptions = normalizeFdSpecificOptions(initialOptions);
2828
const options = addDefaultOptions(fdOptions);

package.json

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,16 +52,17 @@
5252
],
5353
"dependencies": {
5454
"@sindresorhus/merge-streams": "^4.0.0",
55-
"cross-spawn": "^7.0.6",
5655
"figures": "^6.1.0",
5756
"get-stream": "^9.0.1",
5857
"human-signals": "^8.0.1",
5958
"is-plain-obj": "^4.1.0",
6059
"is-stream": "^4.0.1",
6160
"npm-run-path": "^6.0.0",
61+
"path-key": "^4.0.0",
6262
"pretty-ms": "^9.3.0",
6363
"signal-exit": "^4.1.0",
6464
"strip-final-newline": "^4.0.0",
65+
"which": "^7.0.0",
6566
"yoctocolors": "^2.1.2"
6667
},
6768
"devDependencies": {
@@ -73,11 +74,9 @@
7374
"is-running": "^2.1.0",
7475
"log-process-errors": "^12.0.1",
7576
"path-exists": "^5.0.0",
76-
"path-key": "^4.0.0",
7777
"tempfile": "^6.0.1",
7878
"tsd": "^0.33.0",
7979
"typescript": "^6.0.3",
80-
"which": "^7.0.0",
8180
"xo": "^3.0.2"
8281
},
8382
"c8": {

readme.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
</picture>
55
<br>
66

7-
[![Coverage Status](https://codecov.io/gh/sindresorhus/execa/branch/main/graph/badge.svg)](https://codecov.io/gh/sindresorhus/execa)
7+
[![Coverage Status](https://codecov.io/gh/sindresorhus/execa/branch/main/graph/badge.svg)](https://app.codecov.io/gh/sindresorhus/execa)
88

99
> Process execution for humans
1010
@@ -67,7 +67,7 @@ Execa runs commands in your script, application or library. Unlike shells, it is
6767
- [Script](#script) interface.
6868
- [No escaping](docs/escaping.md) nor quoting needed. No risk of shell injection.
6969
- Execute [locally installed binaries](#local-binaries) without `npx`.
70-
- Improved [Windows support](docs/windows.md): [shebangs](docs/windows.md#shebang), [`PATHEXT`](https://ss64.com/nt/path.html#pathext), [graceful termination](#graceful-termination), [and more](https://github.com/moxystudio/node-cross-spawn?tab=readme-ov-file#why).
70+
- Improved [Windows support](docs/windows.md): [shebangs](docs/windows.md#shebang), [`PATHEXT`](https://ss64.com/nt/path.html#pathext), [graceful termination](#graceful-termination), [and more](docs/windows.md).
7171
- [Detailed errors](#detailed-error), [verbose mode](#verbose-mode) and [custom logging](#custom-logging), for [debugging](docs/debugging.md).
7272
- [Pipe multiple subprocesses](#pipe-multiple-subprocesses) better than in shells: retrieve [intermediate results](docs/pipe.md#result), use multiple [sources](docs/pipe.md#multiple-sources-1-destination)/[destinations](docs/pipe.md#1-source-multiple-destinations), [unpipe](docs/pipe.md#unpipe).
7373
- [Split](#split-into-text-lines) the output into text lines, or [iterate](#iterate-over-text-lines) progressively over them.

0 commit comments

Comments
 (0)