Skip to content

Commit 744498a

Browse files
committed
Fix Windows command extension resolution
1 parent 596ad5d commit 744498a

3 files changed

Lines changed: 57 additions & 3 deletions

File tree

lib/arguments/command-file.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,9 @@ const escapeWindowsCommand = parsed => {
2929
// Resolve the file to an absolute path, following its shebang to the interpreter if any
3030
const resolvedFile = resolveWithShebang(parsed);
3131

32-
// A directly executable file is spawned as-is, bypassing `cmd.exe` and its escaping
32+
// A directly executable file is spawned by its resolved path, bypassing `cmd.exe` and its escaping
3333
if (resolvedFile !== undefined && directlyExecutableRegExp.test(resolvedFile)) {
34+
parsed.file = resolvedFile;
3435
return parsed;
3536
}
3637

@@ -74,10 +75,14 @@ const resolveWithShebang = parsed => {
7475
// Search `PATH` for the command, resolving its Windows executable extension via `PATHEXT`
7576
const resolvePath = parsed => {
7677
const environment = parsed.options.env || process.env;
78+
const environmentPathExt = getWindowsEnvironmentValue(environment, 'PATHEXT');
79+
const commandExtension = path.extname(parsed.file);
80+
// Explicitly named files must be tried verbatim so shebang scripts work even when their extension is excluded from PATHEXT.
81+
const pathExt = commandExtension === '' ? environmentPathExt : `${commandExtension}${path.delimiter}${environmentPathExt ?? ''}`;
7782
return whichCommandSync(parsed.file, {
7883
cwd: parsed.options.cwd,
7984
path: getWindowsEnvironmentValue(environment, 'PATH'),
80-
pathExt: getWindowsEnvironmentValue(environment, 'PATHEXT'),
85+
pathExt,
8186
});
8287
};
8388

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@
5858
"is-plain-obj": "^4.1.0",
5959
"is-stream": "^4.0.1",
6060
"npm-run-path": "^6.0.0",
61-
"path-key": "^4.0.0",
6261
"pretty-ms": "^9.3.0",
6362
"signal-exit": "^4.1.0",
6463
"strip-final-newline": "^4.0.0",
@@ -74,6 +73,7 @@
7473
"is-running": "^2.1.0",
7574
"log-process-errors": "^12.0.1",
7675
"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",

test/arguments/command-resolution.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,26 @@ if (isWindows) {
172172
t.is(stdout, 'Hello World');
173173
});
174174

175+
test('Runs a .com executable selected by PATHEXT', async t => {
176+
const binaryDirectory = path.join(FIXTURES_DIRECTORY, 'node_modules', '.bin');
177+
await mkdir(binaryDirectory, {recursive: true});
178+
await cp(process.execPath, path.join(binaryDirectory, 'node-com.com'));
179+
const options = {
180+
preferLocal: true,
181+
localDir: FIXTURES_DIRECTORY,
182+
extendEnv: false,
183+
env: {
184+
Path: path.dirname(process.execPath),
185+
PathExt: '.COM',
186+
},
187+
};
188+
const {stdout} = await execa('node-com', ['--version'], options);
189+
t.is(stdout, process.version);
190+
191+
const {stdout: stdoutSync} = execaSync('node-com', ['--version'], options);
192+
t.is(stdoutSync, process.version);
193+
});
194+
175195
// A `.cmd` file needs `cmd.exe`, so its forward-slash path must be normalized to
176196
// backslashes, otherwise it fails with ENOENT.
177197
test('Runs a .cmd file given as a relative POSIX-style subpath', async t => {
@@ -201,6 +221,35 @@ if (isWindows) {
201221
t.is(stdoutSync, commandArgument);
202222
});
203223

224+
test('Double-escapes explicit batch files excluded from PATHEXT', async t => {
225+
const commandArgument = '"& whoami &"';
226+
const options = {
227+
extendEnv: false,
228+
env: {
229+
Path: path.dirname(process.execPath),
230+
PathExt: '.EXE',
231+
},
232+
};
233+
const command = path.join(FIXTURES_DIRECTORY, 'echo-shim.cmd');
234+
const {stdout} = await execa(command, [commandArgument], options);
235+
t.is(stdout, commandArgument);
236+
237+
const {stdout: stdoutSync} = execaSync(command, [commandArgument], options);
238+
t.is(stdoutSync, commandArgument);
239+
});
240+
241+
test('Runs an explicit shebang script excluded from PATHEXT', async t => {
242+
const command = path.join(FIXTURES_DIRECTORY, 'echo.js');
243+
const options = {
244+
extendEnv: false,
245+
env: {
246+
Path: path.dirname(process.execPath),
247+
PathExt: '.EXE',
248+
},
249+
};
250+
await testResolvesCommand(t, command, options);
251+
});
252+
204253
test.serial('Double-escapes metacharacters for preferLocal cmd-shims', async t => {
205254
await setupCmdShim();
206255
const commandArgument = 'a&whoami';

0 commit comments

Comments
 (0)