Skip to content

Commit e60ed21

Browse files
committed
fix(@angular/cli): resolve executables strictly from PATH
Update executable invocation logic to resolve system binaries (such as `git` and `which`) strictly from the `PATH` environment variable. This prevents bare command names passed to `execFileSync` / `execFile` from implicitly searching and resolving binaries relative to `process.cwd()` on Windows. Fixes #33755
1 parent b6ed402 commit e60ed21

4 files changed

Lines changed: 120 additions & 2 deletions

File tree

packages/angular/cli/src/commands/update/utilities/git.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88

99
import { execFileSync } from 'node:child_process';
1010
import * as path from 'node:path';
11+
import { findExecutableOnPath } from '../../../utilities/executable';
12+
13+
let cachedGitPath: string | undefined;
1114

1215
/**
1316
* Execute a git command.
@@ -16,7 +19,15 @@ import * as path from 'node:path';
1619
* @returns The output of the command.
1720
*/
1821
function execGit(args: string[], input?: string): string {
19-
return execFileSync('git', args, { encoding: 'utf8', stdio: 'pipe', input });
22+
if (!cachedGitPath) {
23+
const gitPath = findExecutableOnPath('git');
24+
if (!gitPath) {
25+
throw new Error('Git executable not found on PATH.');
26+
}
27+
cachedGitPath = gitPath;
28+
}
29+
30+
return execFileSync(cachedGitPath, args, { encoding: 'utf8', stdio: 'pipe', input });
2031
}
2132

2233
/**

packages/angular/cli/src/utilities/completion.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { getWorkspace } from '../utilities/config';
1616
import { forceAutocomplete } from '../utilities/environment-options';
1717
import { isTTY } from '../utilities/tty';
1818
import { assertIsError } from './error';
19+
import { findExecutableOnPath } from './executable';
1920
import { askConfirmation } from './prompt';
2021

2122
/** Interface for the autocompletion configuration stored in the global workspace. */
@@ -271,7 +272,14 @@ function getShellRunCommandCandidates(shell: string, home: string): string[] | u
271272
export function hasGlobalCliInstall(): Promise<boolean> {
272273
// List all binaries with the `ng` name on the user's `$PATH`.
273274
return new Promise<boolean>((resolve) => {
274-
execFile('which', ['-a', 'ng'], (error, stdout) => {
275+
const whichPath = findExecutableOnPath('which');
276+
if (!whichPath) {
277+
resolve(false);
278+
279+
return;
280+
}
281+
282+
execFile(whichPath, ['-a', 'ng'], (error, stdout) => {
275283
if (error) {
276284
// No instances of `ng` on the user's `$PATH`
277285

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { existsSync } from 'node:fs';
10+
import { delimiter, extname, join } from 'node:path';
11+
12+
/**
13+
* Searches the `PATH` environment variable for a given executable binary name.
14+
* On Windows, checks extensions in `PATHEXT` (e.g. `.exe`, `.cmd`) if no extension is given.
15+
* Returns the absolute path of the binary if found on `PATH`, or `undefined` if not found.
16+
*
17+
* This prevents `execFileSync` / `spawn` from implicitly resolving executables
18+
* relative to `process.cwd()` on Windows when passed bare command names.
19+
*
20+
* @param binaryName Name of the binary to search for (e.g. 'git').
21+
* @returns The absolute path to the binary if found on `PATH`, or `undefined`.
22+
*/
23+
export function findExecutableOnPath(binaryName: string): string | undefined {
24+
const envPath = process.env.PATH || process.env.Path || '';
25+
if (!envPath) {
26+
return undefined;
27+
}
28+
29+
const isWindows = process.platform === 'win32';
30+
const pathExt = process.env.PATHEXT
31+
? process.env.PATHEXT.split(delimiter)
32+
: ['.com', '.exe', '.bat', '.cmd'];
33+
34+
const hasExt = isWindows && extname(binaryName) !== '';
35+
const extensions = isWindows && !hasExt ? pathExt : [''];
36+
37+
for (let dir of envPath.split(delimiter)) {
38+
if (!dir) {
39+
continue;
40+
}
41+
42+
if (isWindows && dir.startsWith('"') && dir.endsWith('"')) {
43+
dir = dir.slice(1, -1);
44+
}
45+
46+
for (const ext of extensions) {
47+
const candidate = join(dir, binaryName + ext);
48+
try {
49+
if (existsSync(candidate)) {
50+
return candidate;
51+
}
52+
} catch {
53+
// Ignore file system errors (e.g. invalid path or permission error)
54+
}
55+
}
56+
}
57+
58+
return undefined;
59+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { findExecutableOnPath } from './executable';
10+
11+
describe('findExecutableOnPath', () => {
12+
it('should find executable on PATH when it exists', () => {
13+
// 'node' binary should be present on PATH in any Node test environment
14+
const nodePath = findExecutableOnPath('node');
15+
expect(nodePath).toBeDefined();
16+
expect(nodePath).toContain('node');
17+
});
18+
19+
it('should return undefined when binary does not exist on PATH', () => {
20+
const nonExistentPath = findExecutableOnPath('non_existent_binary_123456789');
21+
expect(nonExistentPath).toBeUndefined();
22+
});
23+
24+
it('should correctly handle PATH entries wrapped in double quotes', () => {
25+
const nodePath = findExecutableOnPath('node');
26+
if (!nodePath) {
27+
return;
28+
}
29+
30+
const originalPath = process.env.PATH;
31+
try {
32+
const dir = nodePath.substring(0, nodePath.lastIndexOf('/'));
33+
process.env.PATH = `"${dir}"`;
34+
const resolved = findExecutableOnPath('node');
35+
expect(resolved).toBeDefined();
36+
} finally {
37+
process.env.PATH = originalPath;
38+
}
39+
});
40+
});

0 commit comments

Comments
 (0)