Skip to content

Commit 05eb47c

Browse files
committed
fix(@angular/cli): validate dist-tags and manifest caching under modern yarn
Modern yarn (`yarn npm info`) silently falls back to the `latest` version with exit code 0 not only when a requested SemVer version or range is unmet, but also when a requested dist-tag does not exist. Previously, `getRegistryManifest` only checked valid SemVer ranges, allowing unknown dist-tags to resolve to `latest`. Include `dist-tags` in modern yarn's requested `--fields` and add `parseYarnModernManifest` to verify that non-range version specifiers exist in `dist-tags` and resolve to the returned manifest version before stripping `dist-tags` from the result. Move the SemVer range validation inside `#fetchAndParse`'s parser callback so rejected fallback manifests are cached directly as `null` rather than storing the mismatched manifest object, and scope `includePrerelease` to wildcard (`*`) specifiers so unmet major or x-ranges do not accept prerelease `latest` fallbacks.
1 parent cff0716 commit 05eb47c

5 files changed

Lines changed: 284 additions & 14 deletions

File tree

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

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
parseYarnClassicMetadata,
3232
parseYarnModernDependencies,
3333
parseYarnModernError,
34+
parseYarnModernManifest,
3435
parseYarnReleaseAge,
3536
} from './parsers';
3637

@@ -137,7 +138,11 @@ export interface PackageManagerDescriptor {
137138
) => Map<string, InstalledPackage>;
138139

139140
/** A function to parse the output of `getManifestCommand` for a specific version. */
140-
getRegistryManifest: (stdout: string, logger?: Logger) => PackageManifest | null;
141+
getRegistryManifest: (
142+
stdout: string,
143+
logger?: Logger,
144+
options?: { version?: string },
145+
) => PackageManifest | null;
141146

142147
/** A function to parse the output of `getManifestCommand` for the full package metadata. */
143148
getRegistryMetadata: (stdout: string, logger?: Logger) => PackageMetadata | null;
@@ -227,10 +232,15 @@ export const SUPPORTED_PACKAGE_MANAGERS = {
227232
listDependenciesCommand: ['info', '--name-only', '--json'],
228233
getReleaseAgeConfigCommand: ['config', 'get', 'npmMinimalAgeGate'],
229234
getManifestCommand: ['npm', 'info', '--json'],
230-
viewCommandFieldArgFormatter: (fields) => ['--fields', fields.join(',')],
235+
// Always include `dist-tags` so `parseYarnModernManifest` can detect when `yarn npm info`
236+
// silently falls back to `latest` for a non-existent dist-tag.
237+
viewCommandFieldArgFormatter: (fields) => [
238+
'--fields',
239+
[...new Set([...fields, 'dist-tags'])].join(','),
240+
],
231241
outputParsers: {
232242
listDependencies: parseYarnModernDependencies,
233-
getRegistryManifest: parseNpmLikeManifest,
243+
getRegistryManifest: parseYarnModernManifest,
234244
getRegistryMetadata: parseNpmLikeMetadata,
235245
getError: parseYarnModernError,
236246
getReleaseAge: parseYarnReleaseAge,

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

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -559,22 +559,33 @@ export class PackageManager {
559559
}
560560

561561
const cacheKey = options.registry ? `${specifier}|${options.registry}` : specifier;
562+
const normalizedRange = validRange(version);
562563

563564
const manifest = await this.#fetchAndParse(
564565
commandArgs,
565-
(stdout, logger) => this.descriptor.outputParsers.getRegistryManifest(stdout, logger),
566+
(stdout, logger) => {
567+
const parsed = this.descriptor.outputParsers.getRegistryManifest(stdout, logger, {
568+
version,
569+
});
570+
// Modern yarn returns the latest version instead of an error when a requested
571+
// version or range does not exist. Validating inside the parser ensures the rejected
572+
// result is cached as `null`. Dist-tag fallbacks cannot be detected here and are
573+
// validated by the descriptor's parser instead (see `parseYarnModernManifest`).
574+
if (
575+
parsed &&
576+
normalizedRange &&
577+
!satisfies(parsed.version, normalizedRange, {
578+
includePrerelease: normalizedRange === '*',
579+
})
580+
) {
581+
return null;
582+
}
583+
584+
return parsed;
585+
},
566586
{ ...options, cache: this.#manifestCache, cacheKey },
567587
);
568588

569-
// Modern yarn returns the latest version instead of an error when the requested one does not exist.
570-
if (
571-
manifest &&
572-
validRange(version) &&
573-
!satisfies(manifest.version, version, { includePrerelease: true })
574-
) {
575-
return null;
576-
}
577-
578589
// If the provided version was not a specific version, also cache the specific fetched version
579590
if (manifest && manifest.version !== version) {
580591
const manifestSpecifier = `${manifest.name}@${manifest.version}`;

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

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,120 @@ describe('PackageManager', () => {
3232

3333
expect(await pm.getRegistryManifest('foo', '99.99.99')).toBeNull();
3434
expect(await pm.getRegistryManifest('foo', '^99.0.0')).toBeNull();
35+
expect(await pm.getRegistryManifest('foo', '2.0.0-rc.1')).toBeNull();
3536
expect(await pm.getRegistryManifest('foo', '^2.0.0')).toEqual(
3637
jasmine.objectContaining({ version: '2.0.0' }),
3738
);
3839
});
3940

41+
it('should cache null for mismatched versions without caching the fallback manifest', async () => {
42+
const pm = new PackageManager(host, '/tmp', SUPPORTED_PACKAGE_MANAGERS['yarn']);
43+
runCommandSpy.and.resolveTo({
44+
stdout: JSON.stringify({ name: 'foo', version: '2.0.0' }),
45+
stderr: '',
46+
});
47+
48+
expect(await pm.getRegistryManifest('foo', '99.99.99')).toBeNull();
49+
expect(await pm.getRegistryManifest('foo', '99.99.99')).toBeNull();
50+
expect(runCommandSpy).toHaveBeenCalledTimes(1);
51+
52+
// Ensure the rejected fallback manifest was not cached under `foo@2.0.0`.
53+
runCommandSpy.and.resolveTo({
54+
stdout: JSON.stringify({ name: 'foo', version: '2.0.0', homepage: 'https://angular.dev' }),
55+
stderr: '',
56+
});
57+
expect(await pm.getRegistryManifest('foo', '2.0.0')).toEqual({
58+
name: 'foo',
59+
version: '2.0.0',
60+
homepage: 'https://angular.dev',
61+
});
62+
expect(runCommandSpy).toHaveBeenCalledTimes(2);
63+
});
64+
65+
it('should validate dist-tags against fetched dist-tags metadata and strip dist-tags from the manifest', async () => {
66+
// Modern yarn falls back to `latest` when a requested dist-tag does not exist.
67+
const pm = new PackageManager(host, '/tmp', SUPPORTED_PACKAGE_MANAGERS['yarn']);
68+
runCommandSpy.and.resolveTo({
69+
stdout: JSON.stringify({
70+
name: 'foo',
71+
version: '2.0.0',
72+
'dist-tags': { latest: '2.0.0', next: '2.1.0-next.0' },
73+
}),
74+
stderr: '',
75+
});
76+
77+
expect(await pm.getRegistryManifest('foo', 'nonexistent-tag')).toBeNull();
78+
expect(await pm.getManifest('foo@nonexistent-tag')).toBeNull();
79+
// If a package manager returned `2.0.0` when `next` (`2.1.0-next.0`) was requested, reject it.
80+
expect(await pm.getRegistryManifest('foo', 'next')).toBeNull();
81+
82+
expect(await pm.getRegistryManifest('foo', 'latest')).toEqual({
83+
name: 'foo',
84+
version: '2.0.0',
85+
});
86+
87+
expect(runCommandSpy).toHaveBeenCalledWith(
88+
'yarn',
89+
[
90+
'npm',
91+
'info',
92+
'--json',
93+
'foo@latest',
94+
'--fields',
95+
[...MANIFEST_FIELDS, 'dist-tags'].join(','),
96+
],
97+
jasmine.anything(),
98+
);
99+
});
100+
101+
it('should handle prerelease manifests accurately for wildcard, prerelease, and stable ranges', async () => {
102+
const pm = new PackageManager(host, '/tmp', SUPPORTED_PACKAGE_MANAGERS['yarn']);
103+
runCommandSpy.and.resolveTo({
104+
stdout: JSON.stringify({ name: 'foo', version: '2.0.0-beta.1' }),
105+
stderr: '',
106+
});
107+
108+
// Unmet stable major/x-ranges should not accept a prerelease `latest` fallback.
109+
expect(await pm.getRegistryManifest('foo', '2')).toBeNull();
110+
expect(await pm.getRegistryManifest('foo', '2.x')).toBeNull();
111+
expect(await pm.getRegistryManifest('foo', '^2.0.0')).toBeNull();
112+
113+
// Wildcard (`*`, produced by bare specifiers) and matching prerelease specs should succeed.
114+
expect(await pm.getRegistryManifest('foo', '*')).toEqual({
115+
name: 'foo',
116+
version: '2.0.0-beta.1',
117+
});
118+
expect(await pm.getRegistryManifest('foo', '2.0.0-beta.1')).toEqual({
119+
name: 'foo',
120+
version: '2.0.0-beta.1',
121+
});
122+
expect(await pm.getRegistryManifest('foo', '^2.0.0-beta.0')).toEqual({
123+
name: 'foo',
124+
version: '2.0.0-beta.1',
125+
});
126+
});
127+
128+
it('should apply the same prerelease range handling to non-yarn package managers', async () => {
129+
const pm = new PackageManager(host, '/tmp', descriptor);
130+
runCommandSpy.and.resolveTo({
131+
stdout: JSON.stringify({ name: 'foo', version: '2.0.0-beta.1' }),
132+
stderr: '',
133+
});
134+
135+
// A prerelease does not satisfy a stable range without `includePrerelease`.
136+
expect(await pm.getRegistryManifest('foo', '^2.0.0')).toBeNull();
137+
138+
// Wildcard and matching prerelease ranges accept the prerelease.
139+
expect(await pm.getRegistryManifest('foo', '*')).toEqual({
140+
name: 'foo',
141+
version: '2.0.0-beta.1',
142+
});
143+
expect(await pm.getRegistryManifest('foo', '^2.0.0-beta.0')).toEqual({
144+
name: 'foo',
145+
version: '2.0.0-beta.1',
146+
});
147+
});
148+
40149
it('should quote complex range specifiers when required by the host', async () => {
41150
// Simulate a quoting host
42151
Object.assign(host, { requiresQuoting: true });
@@ -525,6 +634,27 @@ describe('PackageManager', () => {
525634

526635
expect(await pm.getRegistryMetadata('non-existent')).toBeNull();
527636
});
637+
638+
it('should not duplicate the dist-tags field when fetching yarn metadata', async () => {
639+
const pm = new PackageManager(host, '/tmp', SUPPORTED_PACKAGE_MANAGERS['yarn']);
640+
runCommandSpy.and.resolveTo({
641+
stdout: JSON.stringify({
642+
name: 'foo',
643+
'dist-tags': { latest: '1.0.0' },
644+
versions: ['1.0.0'],
645+
time: {},
646+
}),
647+
stderr: '',
648+
});
649+
650+
await pm.getRegistryMetadata('foo');
651+
652+
expect(runCommandSpy).toHaveBeenCalledWith(
653+
'yarn',
654+
['npm', 'info', '--json', 'foo', '--fields', 'name,dist-tags,versions,time'],
655+
jasmine.anything(),
656+
);
657+
});
528658
});
529659

530660
describe('getCurrentPackageName', () => {

‎packages/angular/cli/src/package-managers/parsers.ts‎

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
* into their own file improves modularity and allows for focused testing.
1313
*/
1414

15-
import { compare, valid } from 'semver';
15+
import { compare, valid, validRange } from 'semver';
1616
import { ErrorInfo } from './error';
1717
import { Logger } from './logger';
1818
import { PackageManifest, PackageMetadata } from './package-metadata';
@@ -441,6 +441,75 @@ export function parseYarnClassicManifest(stdout: string, logger?: Logger): Packa
441441
return manifest;
442442
}
443443

444+
/**
445+
* Parses the output of `yarn npm info` (modern yarn) to get a package manifest.
446+
*
447+
* Modern yarn silently falls back to `latest` when a requested dist-tag does not exist.
448+
* When `options.version` is a dist-tag (not a valid SemVer range), this parser verifies
449+
* that the tag exists in the `dist-tags` output field and resolves to the returned manifest
450+
* version. The `dist-tags` field is always stripped from the returned manifest.
451+
*
452+
* NOTE: This only handles the dist-tag case. The equivalent fallback for SemVer versions and
453+
* ranges is handled for all package managers by `PackageManager.getRegistryManifest`.
454+
*
455+
* @param stdout The standard output of the command.
456+
* @param logger An optional logger instance.
457+
* @param options Optional context including the requested version or dist-tag.
458+
* @returns The package manifest object, or `null` if not found or if the requested
459+
* dist-tag could not be verified.
460+
*/
461+
export function parseYarnModernManifest(
462+
stdout: string,
463+
logger?: Logger,
464+
options?: { version?: string },
465+
): PackageManifest | null {
466+
const parsed = parseNpmLikeManifest(stdout, logger);
467+
if (!parsed) {
468+
return null;
469+
}
470+
471+
// Extract `dist-tags` (requested via `--fields` in the yarn descriptor) and omit it
472+
// from the returned `PackageManifest` so packument-level metadata does not leak out.
473+
const { 'dist-tags': distTags, ...manifest } = parsed as PackageManifest & {
474+
'dist-tags'?: unknown;
475+
};
476+
477+
// SemVer versions and ranges are validated by `PackageManager.getRegistryManifest`.
478+
// When `requestedVersion` is not a valid SemVer range, treat it as a dist-tag and verify
479+
// that `yarn npm info` resolved that tag rather than silently falling back to `latest`.
480+
const requestedVersion = options?.version;
481+
if (requestedVersion === undefined || validRange(requestedVersion)) {
482+
return manifest;
483+
}
484+
485+
// The yarn descriptor always requests `dist-tags`, so a missing or malformed field is
486+
// unexpected. Fail closed since the tag cannot be verified.
487+
if (!distTags || typeof distTags !== 'object' || Array.isArray(distTags)) {
488+
logger?.debug(
489+
` Cannot verify requested dist-tag '${requestedVersion}': 'dist-tags' is missing or invalid.`,
490+
);
491+
492+
return null;
493+
}
494+
495+
// Use `Object.hasOwn` to avoid matching inherited `Object.prototype` keys (e.g., `toString`).
496+
const taggedVersion = Object.hasOwn(distTags, requestedVersion)
497+
? (distTags as Record<string, unknown>)[requestedVersion]
498+
: undefined;
499+
500+
// Reject if the tag does not exist in `dist-tags` or points to a different normalized
501+
// SemVer version than the manifest that was returned.
502+
if (typeof taggedVersion !== 'string' || valid(taggedVersion) !== valid(manifest.version)) {
503+
logger?.debug(
504+
` Requested dist-tag '${requestedVersion}' does not match manifest version '${manifest.version}'.`,
505+
);
506+
507+
return null;
508+
}
509+
510+
return manifest;
511+
}
512+
444513
/**
445514
* Parses the output of `yarn info` (classic) to get package metadata.
446515
* @param stdout The standard output of the command.

‎packages/angular/cli/src/package-managers/parsers_spec.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
parseYarnClassicManifest,
2121
parseYarnModernDependencies,
2222
parseYarnModernError,
23+
parseYarnModernManifest,
2324
parseYarnReleaseAge,
2425
} from './parsers';
2526

@@ -434,6 +435,55 @@ describe('parsers', () => {
434435
});
435436
});
436437

438+
describe('parseYarnModernManifest', () => {
439+
const stdout = JSON.stringify({
440+
name: 'foo',
441+
version: '2.0.0',
442+
'dist-tags': { latest: '2.0.0', next: '2.1.0-next.0' },
443+
});
444+
445+
it('should return manifest with dist-tags stripped when requested dist-tag matches', () => {
446+
expect(parseYarnModernManifest(stdout, undefined, { version: 'latest' })).toEqual({
447+
name: 'foo',
448+
version: '2.0.0',
449+
});
450+
});
451+
452+
it('should return null when requested dist-tag does not exist', () => {
453+
expect(parseYarnModernManifest(stdout, undefined, { version: 'nonexistent-tag' })).toBeNull();
454+
});
455+
456+
it('should return null when requested dist-tag matches an inherited Object.prototype key', () => {
457+
expect(parseYarnModernManifest(stdout, undefined, { version: 'toString' })).toBeNull();
458+
expect(parseYarnModernManifest(stdout, undefined, { version: 'constructor' })).toBeNull();
459+
});
460+
461+
it('should return null when requested dist-tag resolves to a different version than the manifest', () => {
462+
expect(parseYarnModernManifest(stdout, undefined, { version: 'next' })).toBeNull();
463+
});
464+
465+
it('should return null when a dist-tag is requested but dist-tags is missing or invalid', () => {
466+
const withoutDistTags = JSON.stringify({ name: 'foo', version: '2.0.0' });
467+
expect(parseYarnModernManifest(withoutDistTags, undefined, { version: 'latest' })).toBeNull();
468+
469+
const arrayDistTags = JSON.stringify({ name: 'foo', version: '2.0.0', 'dist-tags': [] });
470+
expect(parseYarnModernManifest(arrayDistTags, undefined, { version: 'latest' })).toBeNull();
471+
});
472+
473+
it('should skip dist-tag validation for SemVer versions and ranges and still strip dist-tags', () => {
474+
expect(parseYarnModernManifest(stdout, undefined, { version: '^2.0.0' })).toEqual({
475+
name: 'foo',
476+
version: '2.0.0',
477+
});
478+
expect(parseYarnModernManifest(stdout)).toEqual({ name: 'foo', version: '2.0.0' });
479+
});
480+
481+
it('should return null for empty or invalid stdout', () => {
482+
expect(parseYarnModernManifest('')).toBeNull();
483+
expect(parseYarnModernManifest(JSON.stringify({ name: 'foo' }))).toBeNull();
484+
});
485+
});
486+
437487
describe('parseYarnClassicError', () => {
438488
it('should parse a 404 from verbose logs', () => {
439489
const stdout =

0 commit comments

Comments
 (0)