Skip to content

Commit 25b8a15

Browse files
committed
fix(@angular/cli): quote complex range specifiers in package manager
Complex range specifiers that include shell special characters (e.g., '>', '<') can be misinterpreted when not quoted. This change ensures that version ranges are enclosed in quotes when needed to prevent such issues. A test case has been added to verify that complex specifiers are handled correctly. (cherry picked from commit 85e3ce2)
1 parent 42f7d99 commit 25b8a15

File tree

4 files changed

+50
-6
lines changed

4 files changed

+50
-6
lines changed

packages/angular/cli/src/package-managers/host.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ import { PackageManagerError } from './error';
2424
* An abstraction layer for side-effectful operations.
2525
*/
2626
export interface Host {
27+
/**
28+
* Whether shell quoting is required for package manager specifiers.
29+
* This is typically true on Windows, where commands are executed in a shell.
30+
*/
31+
readonly requiresQuoting?: boolean;
32+
2733
/**
2834
* Creates a directory.
2935
* @param path The path to the directory.
@@ -101,6 +107,7 @@ export interface Host {
101107
*/
102108
export const NodeJS_HOST: Host = {
103109
stat,
110+
requiresQuoting: platform() === 'win32',
104111
mkdir,
105112
readFile: (path: string) => readFile(path, { encoding: 'utf8' }),
106113
copyFile: (src, dest) => copyFile(src, dest, constants.COPYFILE_FICLONE),

packages/angular/cli/src/package-managers/package-manager.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ const METADATA_FIELDS = ['name', 'dist-tags', 'versions', 'time'] as const;
3434
* This is a performance optimization to avoid downloading unnecessary data.
3535
* These fields are the ones required by the CLI for operations like `ng add` and `ng update`.
3636
*/
37-
const MANIFEST_FIELDS = [
37+
export const MANIFEST_FIELDS = [
3838
'name',
3939
'version',
4040
'deprecated',
@@ -444,7 +444,9 @@ export class PackageManager {
444444
version: string,
445445
options: { timeout?: number; registry?: string; bypassCache?: boolean } = {},
446446
): Promise<PackageManifest | null> {
447-
const specifier = `${packageName}@${version}`;
447+
const specifier = this.host.requiresQuoting
448+
? `"${packageName}@${version}"`
449+
: `${packageName}@${version}`;
448450
const commandArgs = [...this.descriptor.getManifestCommand, specifier];
449451
const formatter = this.descriptor.viewCommandFieldArgFormatter;
450452
if (formatter) {

packages/angular/cli/src/package-managers/package-manager_spec.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,54 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import { Host } from './host';
10-
import { PackageManager } from './package-manager';
9+
import { MANIFEST_FIELDS, PackageManager } from './package-manager';
1110
import { SUPPORTED_PACKAGE_MANAGERS } from './package-manager-descriptor';
1211
import { MockHost } from './testing/mock-host';
1312

1413
describe('PackageManager', () => {
15-
let host: Host;
14+
let host: MockHost;
1615
let runCommandSpy: jasmine.Spy;
1716
const descriptor = SUPPORTED_PACKAGE_MANAGERS['npm'];
1817

1918
beforeEach(() => {
2019
host = new MockHost();
2120
runCommandSpy = spyOn(host, 'runCommand').and.resolveTo({ stdout: '1.2.3', stderr: '' });
22-
host.runCommand = runCommandSpy;
21+
});
22+
23+
describe('getRegistryManifest', () => {
24+
it('should quote complex range specifiers when required by the host', async () => {
25+
// Simulate a quoting host
26+
Object.assign(host, { requiresQuoting: true });
27+
28+
const pm = new PackageManager(host, '/tmp', descriptor);
29+
const manifest = { name: 'foo', version: '1.0.0' };
30+
runCommandSpy.and.resolveTo({ stdout: JSON.stringify(manifest), stderr: '' });
31+
32+
await pm.getRegistryManifest('foo', '>=1.0.0 <2.0.0');
33+
34+
expect(runCommandSpy).toHaveBeenCalledWith(
35+
descriptor.binary,
36+
[...descriptor.getManifestCommand, '"foo@>=1.0.0 <2.0.0"', ...MANIFEST_FIELDS],
37+
jasmine.anything(),
38+
);
39+
});
40+
41+
it('should NOT quote complex range specifiers when not required by the host', async () => {
42+
// Simulate a non-quoting host
43+
Object.assign(host, { requiresQuoting: false });
44+
45+
const pm = new PackageManager(host, '/tmp', descriptor);
46+
const manifest = { name: 'foo', version: '1.0.0' };
47+
runCommandSpy.and.resolveTo({ stdout: JSON.stringify(manifest), stderr: '' });
48+
49+
await pm.getRegistryManifest('foo', '>=1.0.0 <2.0.0');
50+
51+
expect(runCommandSpy).toHaveBeenCalledWith(
52+
descriptor.binary,
53+
[...descriptor.getManifestCommand, 'foo@>=1.0.0 <2.0.0', ...MANIFEST_FIELDS],
54+
jasmine.anything(),
55+
);
56+
});
2357
});
2458

2559
describe('getVersion', () => {

packages/angular/cli/src/package-managers/testing/mock-host.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { Host } from '../host';
1414
* This class allows for simulating a file system in memory.
1515
*/
1616
export class MockHost implements Host {
17+
readonly requiresQuoting = false;
1718
private readonly fs = new Map<string, string[] | true>();
1819

1920
constructor(files: Record<string, string[] | true> = {}) {

0 commit comments

Comments
 (0)