diff --git a/packages/angular/cli/src/package-managers/package-manager-descriptor.ts b/packages/angular/cli/src/package-managers/package-manager-descriptor.ts index edf2e1830d87..92cf4d688e7a 100644 --- a/packages/angular/cli/src/package-managers/package-manager-descriptor.ts +++ b/packages/angular/cli/src/package-managers/package-manager-descriptor.ts @@ -31,6 +31,7 @@ import { parseYarnClassicMetadata, parseYarnModernDependencies, parseYarnModernError, + parseYarnModernManifest, parseYarnReleaseAge, } from './parsers'; @@ -137,7 +138,11 @@ export interface PackageManagerDescriptor { ) => Map; /** A function to parse the output of `getManifestCommand` for a specific version. */ - getRegistryManifest: (stdout: string, logger?: Logger) => PackageManifest | null; + getRegistryManifest: ( + stdout: string, + logger?: Logger, + options?: { version?: string }, + ) => PackageManifest | null; /** A function to parse the output of `getManifestCommand` for the full package metadata. */ getRegistryMetadata: (stdout: string, logger?: Logger) => PackageMetadata | null; @@ -227,10 +232,15 @@ export const SUPPORTED_PACKAGE_MANAGERS = { listDependenciesCommand: ['info', '--name-only', '--json'], getReleaseAgeConfigCommand: ['config', 'get', 'npmMinimalAgeGate'], getManifestCommand: ['npm', 'info', '--json'], - viewCommandFieldArgFormatter: (fields) => ['--fields', fields.join(',')], + // Always include `dist-tags` so `parseYarnModernManifest` can detect when `yarn npm info` + // silently falls back to `latest` for a non-existent dist-tag. + viewCommandFieldArgFormatter: (fields) => [ + '--fields', + [...new Set([...fields, 'dist-tags'])].join(','), + ], outputParsers: { listDependencies: parseYarnModernDependencies, - getRegistryManifest: parseNpmLikeManifest, + getRegistryManifest: parseYarnModernManifest, getRegistryMetadata: parseNpmLikeMetadata, getError: parseYarnModernError, getReleaseAge: parseYarnReleaseAge, diff --git a/packages/angular/cli/src/package-managers/package-manager.ts b/packages/angular/cli/src/package-managers/package-manager.ts index a9bbde8a4b2a..f40133e84856 100644 --- a/packages/angular/cli/src/package-managers/package-manager.ts +++ b/packages/angular/cli/src/package-managers/package-manager.ts @@ -533,6 +533,18 @@ export class PackageManager { return metadata; } + /** + * Creates the in-memory cache key for a registry manifest. + * + * The key is intentionally based on the unquoted `name@version` specifier so that it is + * stable regardless of whether the host requires shell quoting for command arguments. + */ + #getManifestCacheKey(packageName: string, version: string, registry?: string): string { + const specifier = `${packageName}@${version}`; + + return registry ? `${specifier}|${registry}` : specifier; + } + /** * Fetches the registry manifest for a specific version of a package. * The manifest is similar to the package's `package.json` file. @@ -558,30 +570,40 @@ export class PackageManager { commandArgs.push(...formatter(MANIFEST_FIELDS)); } - const cacheKey = options.registry ? `${specifier}|${options.registry}` : specifier; + const cacheKey = this.#getManifestCacheKey(packageName, version, options.registry); + const normalizedRange = validRange(version); const manifest = await this.#fetchAndParse( commandArgs, - (stdout, logger) => this.descriptor.outputParsers.getRegistryManifest(stdout, logger), + (stdout, logger) => { + const parsed = this.descriptor.outputParsers.getRegistryManifest(stdout, logger, { + version, + }); + // Modern yarn returns the latest version instead of an error when a requested + // version or range does not exist. Validating inside the parser ensures the rejected + // result is cached as `null`. Dist-tag fallbacks cannot be detected here and are + // validated by the descriptor's parser instead (see `parseYarnModernManifest`). + if ( + parsed && + normalizedRange && + !satisfies(parsed.version, normalizedRange, { + includePrerelease: normalizedRange === '*', + }) + ) { + return null; + } + + return parsed; + }, { ...options, cache: this.#manifestCache, cacheKey }, ); - // Modern yarn returns the latest version instead of an error when the requested one does not exist. - if ( - manifest && - validRange(version) && - !satisfies(manifest.version, version, { includePrerelease: true }) - ) { - return null; - } - // If the provided version was not a specific version, also cache the specific fetched version if (manifest && manifest.version !== version) { - const manifestSpecifier = `${manifest.name}@${manifest.version}`; - const manifestCacheKey = options.registry - ? `${manifestSpecifier}|${options.registry}` - : manifestSpecifier; - this.#manifestCache.set(manifestCacheKey, manifest); + this.#manifestCache.set( + this.#getManifestCacheKey(manifest.name, manifest.version, options.registry), + manifest, + ); } return manifest; 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 aaf6cb88c254..876ab4f95854 100644 --- a/packages/angular/cli/src/package-managers/package-manager_spec.ts +++ b/packages/angular/cli/src/package-managers/package-manager_spec.ts @@ -32,11 +32,120 @@ describe('PackageManager', () => { expect(await pm.getRegistryManifest('foo', '99.99.99')).toBeNull(); expect(await pm.getRegistryManifest('foo', '^99.0.0')).toBeNull(); + expect(await pm.getRegistryManifest('foo', '2.0.0-rc.1')).toBeNull(); expect(await pm.getRegistryManifest('foo', '^2.0.0')).toEqual( jasmine.objectContaining({ version: '2.0.0' }), ); }); + it('should cache null for mismatched versions without caching the fallback manifest', async () => { + const pm = new PackageManager(host, '/tmp', SUPPORTED_PACKAGE_MANAGERS['yarn']); + runCommandSpy.and.resolveTo({ + stdout: JSON.stringify({ name: 'foo', version: '2.0.0' }), + stderr: '', + }); + + expect(await pm.getRegistryManifest('foo', '99.99.99')).toBeNull(); + expect(await pm.getRegistryManifest('foo', '99.99.99')).toBeNull(); + expect(runCommandSpy).toHaveBeenCalledTimes(1); + + // Ensure the rejected fallback manifest was not cached under `foo@2.0.0`. + runCommandSpy.and.resolveTo({ + stdout: JSON.stringify({ name: 'foo', version: '2.0.0', homepage: 'https://angular.dev' }), + stderr: '', + }); + expect(await pm.getRegistryManifest('foo', '2.0.0')).toEqual({ + name: 'foo', + version: '2.0.0', + homepage: 'https://angular.dev', + }); + expect(runCommandSpy).toHaveBeenCalledTimes(2); + }); + + it('should validate dist-tags against fetched dist-tags metadata and strip dist-tags from the manifest', async () => { + // Modern yarn falls back to `latest` when a requested dist-tag does not exist. + const pm = new PackageManager(host, '/tmp', SUPPORTED_PACKAGE_MANAGERS['yarn']); + runCommandSpy.and.resolveTo({ + stdout: JSON.stringify({ + name: 'foo', + version: '2.0.0', + 'dist-tags': { latest: '2.0.0', next: '2.1.0-next.0' }, + }), + stderr: '', + }); + + expect(await pm.getRegistryManifest('foo', 'nonexistent-tag')).toBeNull(); + expect(await pm.getManifest('foo@nonexistent-tag')).toBeNull(); + // If a package manager returned `2.0.0` when `next` (`2.1.0-next.0`) was requested, reject it. + expect(await pm.getRegistryManifest('foo', 'next')).toBeNull(); + + expect(await pm.getRegistryManifest('foo', 'latest')).toEqual({ + name: 'foo', + version: '2.0.0', + }); + + expect(runCommandSpy).toHaveBeenCalledWith( + 'yarn', + [ + 'npm', + 'info', + '--json', + 'foo@latest', + '--fields', + [...MANIFEST_FIELDS, 'dist-tags'].join(','), + ], + jasmine.anything(), + ); + }); + + it('should handle prerelease manifests accurately for wildcard, prerelease, and stable ranges', async () => { + const pm = new PackageManager(host, '/tmp', SUPPORTED_PACKAGE_MANAGERS['yarn']); + runCommandSpy.and.resolveTo({ + stdout: JSON.stringify({ name: 'foo', version: '2.0.0-beta.1' }), + stderr: '', + }); + + // Unmet stable major/x-ranges should not accept a prerelease `latest` fallback. + expect(await pm.getRegistryManifest('foo', '2')).toBeNull(); + expect(await pm.getRegistryManifest('foo', '2.x')).toBeNull(); + expect(await pm.getRegistryManifest('foo', '^2.0.0')).toBeNull(); + + // Wildcard (`*`, produced by bare specifiers) and matching prerelease specs should succeed. + expect(await pm.getRegistryManifest('foo', '*')).toEqual({ + name: 'foo', + version: '2.0.0-beta.1', + }); + expect(await pm.getRegistryManifest('foo', '2.0.0-beta.1')).toEqual({ + name: 'foo', + version: '2.0.0-beta.1', + }); + expect(await pm.getRegistryManifest('foo', '^2.0.0-beta.0')).toEqual({ + name: 'foo', + version: '2.0.0-beta.1', + }); + }); + + it('should apply the same prerelease range handling to non-yarn package managers', async () => { + const pm = new PackageManager(host, '/tmp', descriptor); + runCommandSpy.and.resolveTo({ + stdout: JSON.stringify({ name: 'foo', version: '2.0.0-beta.1' }), + stderr: '', + }); + + // A prerelease does not satisfy a stable range without `includePrerelease`. + expect(await pm.getRegistryManifest('foo', '^2.0.0')).toBeNull(); + + // Wildcard and matching prerelease ranges accept the prerelease. + expect(await pm.getRegistryManifest('foo', '*')).toEqual({ + name: 'foo', + version: '2.0.0-beta.1', + }); + expect(await pm.getRegistryManifest('foo', '^2.0.0-beta.0')).toEqual({ + name: 'foo', + version: '2.0.0-beta.1', + }); + }); + it('should quote complex range specifiers when required by the host', async () => { // Simulate a quoting host Object.assign(host, { requiresQuoting: true }); @@ -71,6 +180,23 @@ describe('PackageManager', () => { ); }); + it('should reuse the resolved version cache entry when the host requires quoting', async () => { + Object.assign(host, { requiresQuoting: true }); + + const pm = new PackageManager(host, '/tmp', descriptor); + runCommandSpy.and.resolveTo({ + stdout: JSON.stringify({ name: 'foo', version: '1.5.0' }), + stderr: '', + }); + + await pm.getRegistryManifest('foo', '^1.0.0'); + expect(await pm.getRegistryManifest('foo', '1.5.0')).toEqual({ + name: 'foo', + version: '1.5.0', + }); + expect(runCommandSpy).toHaveBeenCalledTimes(1); + }); + it('should return null when bun pm view fails with No matching version found', async () => { const pm = new PackageManager(host, '/tmp', SUPPORTED_PACKAGE_MANAGERS['bun']); runCommandSpy.and.rejectWith( @@ -557,6 +683,27 @@ describe('PackageManager', () => { 'The remote server failed to provide the requested resource', ); }); + + it('should not duplicate the dist-tags field when fetching yarn metadata', async () => { + const pm = new PackageManager(host, '/tmp', SUPPORTED_PACKAGE_MANAGERS['yarn']); + runCommandSpy.and.resolveTo({ + stdout: JSON.stringify({ + name: 'foo', + 'dist-tags': { latest: '1.0.0' }, + versions: ['1.0.0'], + time: {}, + }), + stderr: '', + }); + + await pm.getRegistryMetadata('foo'); + + expect(runCommandSpy).toHaveBeenCalledWith( + 'yarn', + ['npm', 'info', '--json', 'foo', '--fields', 'name,dist-tags,versions,time'], + jasmine.anything(), + ); + }); }); describe('getCurrentPackageName', () => { diff --git a/packages/angular/cli/src/package-managers/parsers.ts b/packages/angular/cli/src/package-managers/parsers.ts index 68be3c71ceb4..89dac11ffe11 100644 --- a/packages/angular/cli/src/package-managers/parsers.ts +++ b/packages/angular/cli/src/package-managers/parsers.ts @@ -13,7 +13,7 @@ */ import { stripVTControlCharacters } from 'node:util'; -import { compare, valid } from 'semver'; +import { compare, valid, validRange } from 'semver'; import { ErrorInfo } from './error'; import { Logger } from './logger'; import { PackageManifest, PackageMetadata } from './package-metadata'; @@ -442,6 +442,75 @@ export function parseYarnClassicManifest(stdout: string, logger?: Logger): Packa return manifest; } +/** + * Parses the output of `yarn npm info` (modern yarn) to get a package manifest. + * + * Modern yarn silently falls back to `latest` when a requested dist-tag does not exist. + * When `options.version` is a dist-tag (not a valid SemVer range), this parser verifies + * that the tag exists in the `dist-tags` output field and resolves to the returned manifest + * version. The `dist-tags` field is always stripped from the returned manifest. + * + * NOTE: This only handles the dist-tag case. The equivalent fallback for SemVer versions and + * ranges is handled for all package managers by `PackageManager.getRegistryManifest`. + * + * @param stdout The standard output of the command. + * @param logger An optional logger instance. + * @param options Optional context including the requested version or dist-tag. + * @returns The package manifest object, or `null` if not found or if the requested + * dist-tag could not be verified. + */ +export function parseYarnModernManifest( + stdout: string, + logger?: Logger, + options?: { version?: string }, +): PackageManifest | null { + const parsed = parseNpmLikeManifest(stdout, logger); + if (!parsed) { + return null; + } + + // Extract `dist-tags` (requested via `--fields` in the yarn descriptor) and omit it + // from the returned `PackageManifest` so packument-level metadata does not leak out. + const { 'dist-tags': distTags, ...manifest } = parsed as PackageManifest & { + 'dist-tags'?: unknown; + }; + + // SemVer versions and ranges are validated by `PackageManager.getRegistryManifest`. + // When `requestedVersion` is not a valid SemVer range, treat it as a dist-tag and verify + // that `yarn npm info` resolved that tag rather than silently falling back to `latest`. + const requestedVersion = options?.version; + if (requestedVersion === undefined || validRange(requestedVersion)) { + return manifest; + } + + // The yarn descriptor always requests `dist-tags`, so a missing or malformed field is + // unexpected. Fail closed since the tag cannot be verified. + if (!distTags || typeof distTags !== 'object' || Array.isArray(distTags)) { + logger?.debug( + ` Cannot verify requested dist-tag '${requestedVersion}': 'dist-tags' is missing or invalid.`, + ); + + return null; + } + + // Use `Object.hasOwn` to avoid matching inherited `Object.prototype` keys (e.g., `toString`). + const taggedVersion = Object.hasOwn(distTags, requestedVersion) + ? (distTags as Record)[requestedVersion] + : undefined; + + // Reject if the tag does not exist in `dist-tags` or points to a different normalized + // SemVer version than the manifest that was returned. + if (typeof taggedVersion !== 'string' || valid(taggedVersion) !== valid(manifest.version)) { + logger?.debug( + ` Requested dist-tag '${requestedVersion}' does not match manifest version '${manifest.version}'.`, + ); + + return null; + } + + return manifest; +} + /** * Parses the output of `yarn info` (classic) to get package metadata. * @param stdout The standard output of the command. diff --git a/packages/angular/cli/src/package-managers/parsers_spec.ts b/packages/angular/cli/src/package-managers/parsers_spec.ts index 93a2975845b6..bd4adc173bab 100644 --- a/packages/angular/cli/src/package-managers/parsers_spec.ts +++ b/packages/angular/cli/src/package-managers/parsers_spec.ts @@ -20,6 +20,7 @@ import { parseYarnClassicManifest, parseYarnModernDependencies, parseYarnModernError, + parseYarnModernManifest, parseYarnReleaseAge, } from './parsers'; @@ -434,6 +435,55 @@ describe('parsers', () => { }); }); + describe('parseYarnModernManifest', () => { + const stdout = JSON.stringify({ + name: 'foo', + version: '2.0.0', + 'dist-tags': { latest: '2.0.0', next: '2.1.0-next.0' }, + }); + + it('should return manifest with dist-tags stripped when requested dist-tag matches', () => { + expect(parseYarnModernManifest(stdout, undefined, { version: 'latest' })).toEqual({ + name: 'foo', + version: '2.0.0', + }); + }); + + it('should return null when requested dist-tag does not exist', () => { + expect(parseYarnModernManifest(stdout, undefined, { version: 'nonexistent-tag' })).toBeNull(); + }); + + it('should return null when requested dist-tag matches an inherited Object.prototype key', () => { + expect(parseYarnModernManifest(stdout, undefined, { version: 'toString' })).toBeNull(); + expect(parseYarnModernManifest(stdout, undefined, { version: 'constructor' })).toBeNull(); + }); + + it('should return null when requested dist-tag resolves to a different version than the manifest', () => { + expect(parseYarnModernManifest(stdout, undefined, { version: 'next' })).toBeNull(); + }); + + it('should return null when a dist-tag is requested but dist-tags is missing or invalid', () => { + const withoutDistTags = JSON.stringify({ name: 'foo', version: '2.0.0' }); + expect(parseYarnModernManifest(withoutDistTags, undefined, { version: 'latest' })).toBeNull(); + + const arrayDistTags = JSON.stringify({ name: 'foo', version: '2.0.0', 'dist-tags': [] }); + expect(parseYarnModernManifest(arrayDistTags, undefined, { version: 'latest' })).toBeNull(); + }); + + it('should skip dist-tag validation for SemVer versions and ranges and still strip dist-tags', () => { + expect(parseYarnModernManifest(stdout, undefined, { version: '^2.0.0' })).toEqual({ + name: 'foo', + version: '2.0.0', + }); + expect(parseYarnModernManifest(stdout)).toEqual({ name: 'foo', version: '2.0.0' }); + }); + + it('should return null for empty or invalid stdout', () => { + expect(parseYarnModernManifest('')).toBeNull(); + expect(parseYarnModernManifest(JSON.stringify({ name: 'foo' }))).toBeNull(); + }); + }); + describe('parseYarnClassicError', () => { it('should parse a 404 from verbose logs', () => { const stdout =