Skip to content

Commit 3d77820

Browse files
committed
Fix Windows command resolution regressions
1 parent 744498a commit 3d77820

2 files changed

Lines changed: 130 additions & 30 deletions

File tree

lib/arguments/command-file.js

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ const escapeWindowsCommand = parsed => {
3131

3232
// A directly executable file is spawned by its resolved path, bypassing `cmd.exe` and its escaping
3333
if (resolvedFile !== undefined && directlyExecutableRegExp.test(resolvedFile)) {
34+
if (parsed.options.argv0 === undefined) {
35+
parsed.options.argv0 = parsed.file;
36+
}
37+
3438
parsed.file = resolvedFile;
3539
return parsed;
3640
}
@@ -75,15 +79,41 @@ const resolveWithShebang = parsed => {
7579
// Search `PATH` for the command, resolving its Windows executable extension via `PATHEXT`
7680
const resolvePath = parsed => {
7781
const environment = parsed.options.env || process.env;
82+
const cwd = parsed.options.cwd ?? process.cwd();
7883
const environmentPathExt = getWindowsEnvironmentValue(environment, 'PATHEXT');
7984
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.
85+
// Commands with an explicit extension must be tried verbatim so shebang scripts work even when their extension is excluded from PATHEXT.
8186
const pathExt = commandExtension === '' ? environmentPathExt : `${commandExtension}${path.delimiter}${environmentPathExt ?? ''}`;
82-
return whichCommandSync(parsed.file, {
83-
cwd: parsed.options.cwd,
84-
path: getWindowsEnvironmentValue(environment, 'PATH'),
87+
if (hasWindowsPathSeparator(parsed.file)) {
88+
return whichCommandSync(path.resolve(cwd, parsed.file), {cwd, pathExt});
89+
}
90+
91+
const searchPath = getWindowsEnvironmentValue(environment, 'PATH') ?? getWindowsEnvironmentValue(process.env, 'PATH') ?? '';
92+
const resolveOptions = {
93+
cwd,
94+
path: searchPath,
8595
pathExt,
86-
});
96+
};
97+
98+
return shouldSearchCurrentDirectory() ? whichCommandSync(parsed.file, resolveOptions) : resolvePathDirectories(parsed.file, resolveOptions);
99+
};
100+
101+
const hasWindowsPathSeparator = file => file.includes('/') || file.includes('\\') || file.includes(':');
102+
103+
const shouldSearchCurrentDirectory = () => getWindowsEnvironmentValue(process.env, 'NODEFAULTCURRENTDIRECTORYINEXEPATH') === undefined;
104+
105+
const resolvePathDirectories = (file, {cwd, path: searchPath, pathExt}) => {
106+
for (const directory of searchPath.split(path.delimiter)) {
107+
const unquotedDirectory = directory.length > 1 && directory.startsWith('"') && directory.endsWith('"') ? directory.slice(1, -1) : directory;
108+
if (unquotedDirectory === '') {
109+
continue;
110+
}
111+
112+
const resolvedFile = whichCommandSync(path.resolve(cwd, unquotedDirectory, file), {cwd, pathExt});
113+
if (resolvedFile !== undefined) {
114+
return resolvedFile;
115+
}
116+
}
87117
};
88118

89119
// Node sorts Windows environment keys and uses the first case-insensitive match when spawning.

test/arguments/command-resolution.js

Lines changed: 95 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {cp, mkdir} from 'node:fs/promises';
1+
import {cp, mkdir, unlink} from 'node:fs/promises';
22
import path from 'node:path';
33
import process from 'node:process';
44
import test from 'ava';
@@ -166,30 +166,114 @@ test('Does not mutate arguments nor options', testNoMutation, {});
166166
test('Does not mutate arguments nor options with a shell', testNoMutation, {shell: true});
167167

168168
if (isWindows) {
169+
const nodeOnlyOptions = {
170+
extendEnv: false,
171+
env: {
172+
Path: path.dirname(process.execPath),
173+
PathExt: '.EXE',
174+
},
175+
};
176+
169177
// A bare command name without an extension is resolved using `PATHEXT`.
170178
test('Resolves command extension using PATHEXT (sync)', t => {
171179
const {stdout} = execaSync('hello');
172180
t.is(stdout, 'Hello World');
173181
});
174182

175-
test('Runs a .com executable selected by PATHEXT', async t => {
183+
test('Uses PATHEXT extension order for direct executables', async t => {
176184
const binaryDirectory = path.join(FIXTURES_DIRECTORY, 'node_modules', '.bin');
177185
await mkdir(binaryDirectory, {recursive: true});
178-
await cp(process.execPath, path.join(binaryDirectory, 'node-com.com'));
186+
const command = 'node-extension-order';
187+
const executablePath = path.join(binaryDirectory, `${command}.exe`);
188+
const comPath = path.join(binaryDirectory, `${command}.com`);
189+
await Promise.all([
190+
cp(process.execPath, executablePath),
191+
cp(process.execPath, comPath),
192+
]);
193+
t.teardown(async () => {
194+
await Promise.all([unlink(executablePath), unlink(comPath)]);
195+
});
179196
const options = {
180197
preferLocal: true,
181198
localDir: FIXTURES_DIRECTORY,
182199
extendEnv: false,
183200
env: {
184201
Path: path.dirname(process.execPath),
185-
PathExt: '.COM',
202+
PathExt: '.EXE;.COM',
203+
},
204+
};
205+
const nodeExpression = 'JSON.stringify({executablePath: process.execPath, argv0: process.argv0})';
206+
const {stdout} = await execa(command, ['--print', nodeExpression], options);
207+
const {executablePath: actualExecutablePath, argv0} = JSON.parse(stdout);
208+
t.is(actualExecutablePath.toLowerCase(), executablePath.toLowerCase());
209+
t.is(argv0, command);
210+
211+
const {stdout: stdoutSync} = execaSync(command, ['--print', nodeExpression], options);
212+
const {executablePath: actualExecutablePathSync, argv0: argv0Sync} = JSON.parse(stdoutSync);
213+
t.is(actualExecutablePathSync.toLowerCase(), executablePath.toLowerCase());
214+
t.is(argv0Sync, command);
215+
});
216+
217+
test.serial('Respects NoDefaultCurrentDirectoryInExePath', async t => {
218+
const binaryDirectory = path.join(FIXTURES_DIRECTORY, 'node_modules', '.bin');
219+
await mkdir(binaryDirectory, {recursive: true});
220+
const command = 'node-current-directory';
221+
const currentDirectoryExecutable = path.join(FIXTURES_DIRECTORY, `${command}.exe`);
222+
const pathExecutable = path.join(binaryDirectory, `${command}.exe`);
223+
await Promise.all([
224+
cp(process.execPath, currentDirectoryExecutable),
225+
cp(process.execPath, pathExecutable),
226+
]);
227+
228+
const environmentName = 'NoDefaultCurrentDirectoryInExePath';
229+
const originalValue = process.env[environmentName];
230+
process.env[environmentName] = '1';
231+
t.teardown(async () => {
232+
if (originalValue === undefined) {
233+
delete process.env[environmentName];
234+
} else {
235+
process.env[environmentName] = originalValue;
236+
}
237+
238+
await Promise.all([unlink(currentDirectoryExecutable), unlink(pathExecutable)]);
239+
});
240+
241+
const options = {
242+
cwd: FIXTURES_DIRECTORY,
243+
extendEnv: false,
244+
env: {
245+
Path: binaryDirectory,
246+
PathExt: '.EXE',
186247
},
187248
};
188-
const {stdout} = await execa('node-com', ['--version'], options);
189-
t.is(stdout, process.version);
249+
const {stdout} = await execa(command, ['--print', 'process.execPath'], options);
250+
t.is(stdout.toLowerCase(), pathExecutable.toLowerCase());
251+
252+
const {stdout: stdoutSync} = execaSync(command, ['--print', 'process.execPath'], options);
253+
t.is(stdoutSync.toLowerCase(), pathExecutable.toLowerCase());
254+
});
190255

191-
const {stdout: stdoutSync} = execaSync('node-com', ['--version'], options);
192-
t.is(stdoutSync, process.version);
256+
test('Does not search PATH for drive-relative commands', async t => {
257+
const binaryDirectory = path.join(FIXTURES_DIRECTORY, 'node_modules', '.bin');
258+
await mkdir(binaryDirectory, {recursive: true});
259+
const commandName = 'node-drive-relative';
260+
const pathExecutable = path.join(binaryDirectory, `${commandName}.exe`);
261+
await cp(process.execPath, pathExecutable);
262+
t.teardown(async () => {
263+
await unlink(pathExecutable);
264+
});
265+
const drive = path.parse(FIXTURES_DIRECTORY).root.slice(0, 2);
266+
const command = `${drive}${commandName}`;
267+
const options = {
268+
cwd: FIXTURES_DIRECTORY,
269+
extendEnv: false,
270+
env: {
271+
Path: binaryDirectory,
272+
PathExt: '.EXE',
273+
},
274+
};
275+
await t.throwsAsync(execa(command, [], options));
276+
t.throws(() => execaSync(command, [], options));
193277
});
194278

195279
// A `.cmd` file needs `cmd.exe`, so its forward-slash path must be normalized to
@@ -223,31 +307,17 @@ if (isWindows) {
223307

224308
test('Double-escapes explicit batch files excluded from PATHEXT', async t => {
225309
const commandArgument = '"& whoami &"';
226-
const options = {
227-
extendEnv: false,
228-
env: {
229-
Path: path.dirname(process.execPath),
230-
PathExt: '.EXE',
231-
},
232-
};
233310
const command = path.join(FIXTURES_DIRECTORY, 'echo-shim.cmd');
234-
const {stdout} = await execa(command, [commandArgument], options);
311+
const {stdout} = await execa(command, [commandArgument], nodeOnlyOptions);
235312
t.is(stdout, commandArgument);
236313

237-
const {stdout: stdoutSync} = execaSync(command, [commandArgument], options);
314+
const {stdout: stdoutSync} = execaSync(command, [commandArgument], nodeOnlyOptions);
238315
t.is(stdoutSync, commandArgument);
239316
});
240317

241318
test('Runs an explicit shebang script excluded from PATHEXT', async t => {
242319
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);
320+
await testResolvesCommand(t, command, nodeOnlyOptions);
251321
});
252322

253323
test.serial('Double-escapes metacharacters for preferLocal cmd-shims', async t => {

0 commit comments

Comments
 (0)