From 6251bc18464e262244f421b9f1121364b2851fd4 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 23 Feb 2026 09:34:18 -0500 Subject: [PATCH] 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. --- .../angular/cli/src/package-managers/host.ts | 7 ++++ .../src/package-managers/package-manager.ts | 6 ++- .../package-managers/package-manager_spec.ts | 42 +++++++++++++++++-- .../src/package-managers/testing/mock-host.ts | 1 + 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/packages/angular/cli/src/package-managers/host.ts b/packages/angular/cli/src/package-managers/host.ts index 433b54414f69..893393970907 100644 --- a/packages/angular/cli/src/package-managers/host.ts +++ b/packages/angular/cli/src/package-managers/host.ts @@ -24,6 +24,12 @@ import { PackageManagerError } from './error'; * An abstraction layer for side-effectful operations. */ export interface Host { + /** + * Whether shell quoting is required for package manager specifiers. + * This is typically true on Windows, where commands are executed in a shell. + */ + readonly requiresQuoting?: boolean; + /** * Creates a directory. * @param path The path to the directory. @@ -101,6 +107,7 @@ export interface Host { */ export const NodeJS_HOST: Host = { stat, + requiresQuoting: platform() === 'win32', mkdir, readFile: (path: string) => readFile(path, { encoding: 'utf8' }), copyFile: (src, dest) => copyFile(src, dest, constants.COPYFILE_FICLONE), diff --git a/packages/angular/cli/src/package-managers/package-manager.ts b/packages/angular/cli/src/package-managers/package-manager.ts index f8f6831485ea..de3172107d8d 100644 --- a/packages/angular/cli/src/package-managers/package-manager.ts +++ b/packages/angular/cli/src/package-managers/package-manager.ts @@ -34,7 +34,7 @@ const METADATA_FIELDS = ['name', 'dist-tags', 'versions', 'time'] as const; * This is a performance optimization to avoid downloading unnecessary data. * These fields are the ones required by the CLI for operations like `ng add` and `ng update`. */ -const MANIFEST_FIELDS = [ +export const MANIFEST_FIELDS = [ 'name', 'version', 'deprecated', @@ -444,7 +444,9 @@ export class PackageManager { version: string, options: { timeout?: number; registry?: string; bypassCache?: boolean } = {}, ): Promise { - const specifier = `${packageName}@${version}`; + const specifier = this.host.requiresQuoting + ? `"${packageName}@${version}"` + : `${packageName}@${version}`; const commandArgs = [...this.descriptor.getManifestCommand, specifier]; const formatter = this.descriptor.viewCommandFieldArgFormatter; if (formatter) { diff --git a/packages/angular/cli/src/package-managers/package-manager_spec.ts b/packages/angular/cli/src/package-managers/package-manager_spec.ts index 802f50fa66ab..8d439d9b3b75 100644 --- a/packages/angular/cli/src/package-managers/package-manager_spec.ts +++ b/packages/angular/cli/src/package-managers/package-manager_spec.ts @@ -6,20 +6,54 @@ * found in the LICENSE file at https://angular.dev/license */ -import { Host } from './host'; -import { PackageManager } from './package-manager'; +import { MANIFEST_FIELDS, PackageManager } from './package-manager'; import { SUPPORTED_PACKAGE_MANAGERS } from './package-manager-descriptor'; import { MockHost } from './testing/mock-host'; describe('PackageManager', () => { - let host: Host; + let host: MockHost; let runCommandSpy: jasmine.Spy; const descriptor = SUPPORTED_PACKAGE_MANAGERS['npm']; beforeEach(() => { host = new MockHost(); runCommandSpy = spyOn(host, 'runCommand').and.resolveTo({ stdout: '1.2.3', stderr: '' }); - host.runCommand = runCommandSpy; + }); + + describe('getRegistryManifest', () => { + it('should quote complex range specifiers when required by the host', async () => { + // Simulate a quoting host + Object.assign(host, { requiresQuoting: true }); + + const pm = new PackageManager(host, '/tmp', descriptor); + const manifest = { name: 'foo', version: '1.0.0' }; + runCommandSpy.and.resolveTo({ stdout: JSON.stringify(manifest), stderr: '' }); + + await pm.getRegistryManifest('foo', '>=1.0.0 <2.0.0'); + + expect(runCommandSpy).toHaveBeenCalledWith( + descriptor.binary, + [...descriptor.getManifestCommand, '"foo@>=1.0.0 <2.0.0"', ...MANIFEST_FIELDS], + jasmine.anything(), + ); + }); + + it('should NOT quote complex range specifiers when not required by the host', async () => { + // Simulate a non-quoting host + Object.assign(host, { requiresQuoting: false }); + + const pm = new PackageManager(host, '/tmp', descriptor); + const manifest = { name: 'foo', version: '1.0.0' }; + runCommandSpy.and.resolveTo({ stdout: JSON.stringify(manifest), stderr: '' }); + + await pm.getRegistryManifest('foo', '>=1.0.0 <2.0.0'); + + expect(runCommandSpy).toHaveBeenCalledWith( + descriptor.binary, + [...descriptor.getManifestCommand, 'foo@>=1.0.0 <2.0.0', ...MANIFEST_FIELDS], + jasmine.anything(), + ); + }); }); describe('getVersion', () => { diff --git a/packages/angular/cli/src/package-managers/testing/mock-host.ts b/packages/angular/cli/src/package-managers/testing/mock-host.ts index ae4476c6501d..46e71be3cf60 100644 --- a/packages/angular/cli/src/package-managers/testing/mock-host.ts +++ b/packages/angular/cli/src/package-managers/testing/mock-host.ts @@ -14,6 +14,7 @@ import { Host } from '../host'; * This class allows for simulating a file system in memory. */ export class MockHost implements Host { + readonly requiresQuoting = false; private readonly fs = new Map(); constructor(files: Record = {}) {