@pnpm/resolving.npm-resolver 1102.0.1 → 1102.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ import type { PackageMeta } from '@pnpm/resolving.registry.types';
2
+ /**
3
+ * Reduces a package metadata document to the abbreviated field set that the
4
+ * resolver actually reads, dropping install-irrelevant fields (scripts,
5
+ * exports, readme, custom `_`-prefixed fields, etc.).
6
+ *
7
+ * Used in two places:
8
+ * - The network layer (`fetch.ts`) normalizes a registry that ignored the
9
+ * abbreviated `Accept` header and returned a full document.
10
+ * - The resolver (`pickPackage.ts`) narrows a deliberately-fetched full
11
+ * document into the `filterMetadata` cache slot.
12
+ *
13
+ * Null-safe on `versions` so it can be called on an unpublished package (no
14
+ * versions), which the abbreviated path can reach.
15
+ */
16
+ export declare function clearMeta(pkg: PackageMeta): PackageMeta;
@@ -0,0 +1,55 @@
1
+ import { pick } from 'ramda';
2
+ // The list taken from https://github.com/npm/registry/blob/master/docs/responses/package-metadata.md#abbreviated-version-object
3
+ // with the addition of 'libc'
4
+ const ABBREVIATED_VERSION_FIELDS = [
5
+ 'name',
6
+ 'version',
7
+ 'bin',
8
+ 'directories',
9
+ 'devDependencies',
10
+ 'optionalDependencies',
11
+ 'dependencies',
12
+ 'peerDependencies',
13
+ 'dist',
14
+ 'engines',
15
+ 'peerDependenciesMeta',
16
+ 'cpu',
17
+ 'os',
18
+ 'libc',
19
+ 'deprecated',
20
+ 'bundleDependencies',
21
+ 'bundledDependencies',
22
+ 'hasInstallScript',
23
+ '_npmUser',
24
+ ];
25
+ /**
26
+ * Reduces a package metadata document to the abbreviated field set that the
27
+ * resolver actually reads, dropping install-irrelevant fields (scripts,
28
+ * exports, readme, custom `_`-prefixed fields, etc.).
29
+ *
30
+ * Used in two places:
31
+ * - The network layer (`fetch.ts`) normalizes a registry that ignored the
32
+ * abbreviated `Accept` header and returned a full document.
33
+ * - The resolver (`pickPackage.ts`) narrows a deliberately-fetched full
34
+ * document into the `filterMetadata` cache slot.
35
+ *
36
+ * Null-safe on `versions` so it can be called on an unpublished package (no
37
+ * versions), which the abbreviated path can reach.
38
+ */
39
+ export function clearMeta(pkg) {
40
+ // A null prototype so that a registry-controlled version key named
41
+ // `__proto__` becomes a regular own property instead of mutating the
42
+ // prototype of the map (js/prototype-polluting-assignment).
43
+ const versions = Object.create(null);
44
+ for (const [version, info] of Object.entries(pkg.versions ?? {})) {
45
+ versions[version] = pick(ABBREVIATED_VERSION_FIELDS, info);
46
+ }
47
+ return {
48
+ name: pkg.name,
49
+ 'dist-tags': pkg['dist-tags'],
50
+ versions,
51
+ time: pkg.time,
52
+ modified: pkg.modified,
53
+ };
54
+ }
55
+ //# sourceMappingURL=clearMeta.js.map
@@ -1,5 +1,5 @@
1
1
  import type { GetAuthHeader } from '@pnpm/fetching.types';
2
- import type { ResolutionVerifier } from '@pnpm/resolving.resolver-base';
2
+ import { type ResolutionVerifier } from '@pnpm/resolving.resolver-base';
3
3
  import type { Registries, TrustPolicy } from '@pnpm/types';
4
4
  import type { FetchMetadataFromFromRegistryOptions } from './fetch.js';
5
5
  import { type FetchFullMetadataCachedOptions } from './fetchFullMetadataCached.js';
@@ -2,14 +2,15 @@ import { pickRegistryForPackage } from '@pnpm/config.pick-registry-for-package';
2
2
  import { createPackageVersionPolicy } from '@pnpm/config.version-policy';
3
3
  import { FULL_META_DIR } from '@pnpm/constants';
4
4
  import { PnpmError } from '@pnpm/error';
5
+ import { isGitHostedTarballUrl, } from '@pnpm/resolving.resolver-base';
5
6
  import semver from 'semver';
6
7
  import { fetchAttestationPublishedAt } from './fetchAttestationPublishedAt.js';
7
8
  import { fetchAbbreviatedMetadataCached, fetchFullMetadataCached, } from './fetchFullMetadataCached.js';
8
9
  import { normalizeRegistryUrl } from './normalizeRegistryUrl.js';
9
10
  import { BUILTIN_NAMED_REGISTRIES } from './parseBareSpecifier.js';
10
- import { getPkgMirrorPath, loadMeta, warnMissingTimeFieldOnce } from './pickPackage.js';
11
+ import { getPkgMetaCacheKey, getPkgMirrorPath, loadMeta, warnMissingTimeFieldOnce } from './pickPackage.js';
11
12
  import { failIfTrustDowngraded } from './trustChecks.js';
12
- import { MINIMUM_RELEASE_AGE_VIOLATION_CODE, TARBALL_URL_MISMATCH_VIOLATION_CODE, TRUST_DOWNGRADE_VIOLATION_CODE, } from './violationCodes.js';
13
+ import { MINIMUM_RELEASE_AGE_VIOLATION_CODE, MISSING_TARBALL_INTEGRITY_VIOLATION_CODE, TARBALL_URL_MISMATCH_VIOLATION_CODE, TRUST_DOWNGRADE_VIOLATION_CODE, } from './violationCodes.js';
13
14
  /**
14
15
  * Returns a `ResolutionVerifier` for npm-registry-resolved lockfile
15
16
  * entries. It always binds each entry's recorded tarball URL to the
@@ -85,8 +86,17 @@ export function createNpmResolutionVerifier(opts) {
85
86
  const trustPolicy = opts.trustPolicy;
86
87
  const trustPolicyIgnoreAfter = opts.trustPolicyIgnoreAfter;
87
88
  const verify = async (resolution, { name, version, nonSemverVersion }) => {
88
- if (!isNpmRegistryResolution(resolution))
89
+ if (!isRegistryTarballResolution(resolution))
89
90
  return { ok: true };
91
+ // Network-free structural checks must run before registry metadata shortcuts.
92
+ const integrity = resolution.integrity;
93
+ if (typeof integrity !== 'string' || integrity.length === 0) {
94
+ return {
95
+ ok: false,
96
+ code: MISSING_TARBALL_INTEGRITY_VIOLATION_CODE,
97
+ reason: 'has no "integrity" field, so its downloaded tarball cannot be verified',
98
+ };
99
+ }
90
100
  // URL/git-keyed entries are deliberate non-registry deps. They can still
91
101
  // carry a semver `version` copied from the resolved manifest, so the
92
102
  // semver guard below isn't enough on its own — the registry policies and
@@ -94,9 +104,22 @@ export function createNpmResolutionVerifier(opts) {
94
104
  // would 404.
95
105
  if (nonSemverVersion != null)
96
106
  return { ok: true };
97
- if (!semver.valid(version))
98
- return { ok: true };
99
- const tarballUrl = resolution.tarball;
107
+ if (!semver.valid(version)) {
108
+ return {
109
+ ok: false,
110
+ code: TARBALL_URL_MISMATCH_VIOLATION_CODE,
111
+ reason: `has a non-semver version ("${version}") and so cannot be verified against the registry's published metadata`,
112
+ };
113
+ }
114
+ const rawTarball = resolution.tarball;
115
+ if (rawTarball != null && typeof rawTarball !== 'string') {
116
+ return {
117
+ ok: false,
118
+ code: TARBALL_URL_MISMATCH_VIOLATION_CODE,
119
+ reason: 'has a non-string "tarball" field, so its URL cannot be verified',
120
+ };
121
+ }
122
+ const tarballUrl = typeof rawTarball === 'string' ? rawTarball : undefined;
100
123
  const registry = pickRegistryForVersion(opts.registries, namedRegistryPrefixes, name, tarballUrl);
101
124
  // A registry entry that pins an explicit tarball URL must point at the
102
125
  // artifact the registry's own metadata lists. Otherwise a trusted
@@ -150,6 +173,8 @@ export function createNpmResolutionVerifier(opts) {
150
173
  // applies the binding — otherwise an upgrade could keep trusting a
151
174
  // lockfile that was only ever age/trust-checked.
152
175
  tarballUrlBinding: true,
176
+ // Same cache identity rule for the missing-integrity structural check.
177
+ integrityRequired: true,
153
178
  minimumReleaseAge,
154
179
  minimumReleaseAgeExclude: sortedMinAgeExcludes,
155
180
  trustPolicy: trustPolicy ?? null,
@@ -161,6 +186,10 @@ export function createNpmResolutionVerifier(opts) {
161
186
  // didn't record it can't be trusted to have enforced it.
162
187
  if (cached.tarballUrlBinding !== true)
163
188
  return false;
189
+ // The missing-integrity check is also unconditional; older cache records
190
+ // without the flag cannot prove they rejected unverifiable tarballs.
191
+ if (cached.integrityRequired !== true)
192
+ return false;
164
193
  // Maturity: a previously cached run under a larger cutoff
165
194
  // (stricter window) is trustworthy under a smaller current one —
166
195
  // its set of accepted versions is a subset of today's. The
@@ -203,17 +232,11 @@ export function createNpmResolutionVerifier(opts) {
203
232
  };
204
233
  }
205
234
  async function runAgeCheck(context, registry, name, version, cutoff, ignoreMissingTimeField) {
206
- let published;
207
- try {
208
- published = await fetchPublishedAt(context, registry, name, version);
209
- }
210
- catch (err) {
211
- return {
212
- ok: false,
213
- code: MINIMUM_RELEASE_AGE_VIOLATION_CODE,
214
- reason: uncheckable('minimumReleaseAge', err instanceof Error ? err.message : String(err)),
215
- };
216
- }
235
+ // A transport failure (auth/network/5xx) propagates the registry's own fetch
236
+ // error (e.g. ERR_PNPM_FETCH_403); the gate aborts the install with it rather
237
+ // than folding it into a policy violation. A successful fetch that simply
238
+ // lacks a publish timestamp for this version is handled below.
239
+ const published = await fetchPublishedAt(context, registry, name, version);
217
240
  if (!published) {
218
241
  // No source — attestation, local mirror, or full metadata —
219
242
  // surfaced a publish timestamp for this version. The resolver's
@@ -262,7 +285,15 @@ async function runAgeCheck(context, registry, name, version, cutoff, ignoreMissi
262
285
  * pointing it at a `name@version` the registry can't vouch for.
263
286
  */
264
287
  async function runTarballUrlCheck(context, registry, name, version, lockfileTarball) {
265
- const meta = await fetchAbbreviatedMeta(context, registry, name);
288
+ const { meta, error } = await fetchAbbreviatedMeta(context, registry, name);
289
+ if (error != null) {
290
+ // Couldn't reach the registry to verify (auth/network/5xx). Propagate the
291
+ // registry's own fetch error (e.g. ERR_PNPM_FETCH_403, which already
292
+ // explains the auth situation) instead of mislabeling a transport failure
293
+ // as a tampering-style URL mismatch. The gate aborts the install with that
294
+ // error — still fail-closed, the entry never reaches the filesystem.
295
+ throw error;
296
+ }
266
297
  const registryTarball = meta?.versionTarballs?.get(version);
267
298
  if (registryTarball != null && sameTarballUrl(lockfileTarball, registryTarball)) {
268
299
  return undefined;
@@ -304,20 +335,11 @@ function canonicalTarballUrl(url) {
304
335
  * attestation → pass" shortcut would silently miss it.
305
336
  */
306
337
  async function runTrustCheck(context, registry, name, version, opts) {
307
- let meta;
308
- try {
309
- meta = await fetchFullMetaForTrust(context, registry, name);
310
- }
311
- catch (err) {
312
- // `fetchFullMetadataCached` rejects (network error, 404, etc.); the
313
- // verifier fails closed so a missing manifest can't be mistaken
314
- // for a passing trust check.
315
- return {
316
- ok: false,
317
- code: TRUST_DOWNGRADE_VIOLATION_CODE,
318
- reason: uncheckable('trustPolicy', err instanceof Error ? err.message : String(err)),
319
- };
320
- }
338
+ // A transport failure (auth/network/5xx) propagates the registry's own fetch
339
+ // error; the gate aborts the install with it rather than folding it into a
340
+ // policy violation. Still fail-closed: a missing manifest can't be mistaken
341
+ // for a passing trust check because the install never proceeds.
342
+ const meta = await fetchFullMetaForTrust(context, registry, name);
321
343
  try {
322
344
  failIfTrustDowngraded(meta, version, opts);
323
345
  }
@@ -335,20 +357,13 @@ function fetchFullMetaForTrust(context, registry, name) {
335
357
  let cachedPromise = context.fullMetaForTrustCache.get(cacheKey);
336
358
  if (cachedPromise == null) {
337
359
  // Fast path: if the resolver already upgraded to full meta for this
338
- // name during the same install (e.g. minimumReleaseAge active),
339
- // reuse that document. Abbreviated meta is rejected here — it lacks
340
- // per-version `time` and per-version trust evidence, both required
341
- // by failIfTrustDowngraded.
342
- //
343
- // Limitation: the resolver's `metaCache` keys by `${name}:full` —
344
- // it doesn't include the registry (pickPackage.ts cacheKey shape).
345
- // If two registries serve packages of the same name in one install
346
- // the resolver itself silently keeps the first fetch; the verifier
347
- // here inherits that scope. The name check below is a defensive
348
- // guard against accidental cache mixups; tightening this to a
349
- // registry-qualified read needs the resolver's `metaCache` key
350
- // shape to change first.
351
- const shared = readSharedMetaForTrust(context.sharedMetaCache, name);
360
+ // (registry, name) during the same install (e.g. minimumReleaseAge
361
+ // active), reuse that document. Abbreviated meta is rejected here —
362
+ // it lacks per-version `time` and per-version trust evidence, both
363
+ // required by failIfTrustDowngraded. The read is registry-qualified
364
+ // (see `getPkgMetaCacheKey`), so a package of the same name served by
365
+ // a different registry can't be returned here.
366
+ const shared = readSharedMetaForTrust(context.sharedMetaCache, registry, name);
352
367
  if (shared != null) {
353
368
  cachedPromise = Promise.resolve(projectTrustMeta(shared));
354
369
  }
@@ -492,7 +507,9 @@ async function resolvePublishedAt(context, registry, name, version) {
492
507
  * lookups.
493
508
  */
494
509
  async function tryAbbreviatedModifiedShortcut(context, registry, name, version) {
495
- const meta = await fetchAbbreviatedMeta(context, registry, name);
510
+ // A fetch failure here is fine: ignore `error` and fall back to per-version
511
+ // lookups, the same as a successful-but-uninformative metadata response.
512
+ const { meta } = await fetchAbbreviatedMeta(context, registry, name);
496
513
  const modified = meta?.modified;
497
514
  if (typeof modified !== 'string')
498
515
  return undefined;
@@ -516,49 +533,59 @@ function fetchAbbreviatedMeta(context, registry, name) {
516
533
  // Fast path: the resolver's per-install LRU already holds this
517
534
  // packument from its own pickPackage pass — abbreviated or full.
518
535
  // Project it for the shortcut and skip the disk/network round-trip.
519
- // Mismatch on `name` is the same risk the resolver carries today
520
- // (its cache key omits the registry), so reuse is no less correct
521
- // than the resolver's own get.
522
- const shared = readSharedMeta(context.sharedMetaCache, name);
536
+ // The read is registry-qualified (see `getPkgMetaCacheKey`), so it
537
+ // can only return this registry's own packument.
538
+ const shared = readSharedMeta(context.sharedMetaCache, registry, name);
523
539
  if (shared != null) {
524
- cachedPromise = Promise.resolve(projectAbbreviatedMeta(shared));
540
+ cachedPromise = Promise.resolve({ meta: projectAbbreviatedMeta(shared) });
525
541
  }
526
542
  else {
543
+ // Carry a fetch failure (auth/network/5xx) as `error` instead of
544
+ // collapsing it to `undefined`: the tarball-URL check rethrows it (so the
545
+ // registry's own error surfaces, not a tampering-style mismatch) while
546
+ // the age shortcut ignores it and falls back to per-version lookups.
547
+ // Keeping it a resolved value — not a rejected promise — lets the two
548
+ // callers share one cached promise without an unhandled rejection.
527
549
  cachedPromise = fetchAbbreviatedMetadataCached(context.fetchOpts, name, {
528
550
  registry,
529
551
  authHeaderValue: context.getAuthHeaderValueByURI(registry, { pkgName: name }),
530
552
  cacheDir: context.cacheDir,
531
- }).then(projectAbbreviatedMeta, () => undefined);
553
+ }).then((meta) => ({ meta: projectAbbreviatedMeta(meta) }), (error) => ({ error }));
532
554
  }
533
555
  context.abbreviatedMetaCache.set(cacheKey, cachedPromise);
534
556
  }
535
557
  return cachedPromise;
536
558
  }
537
- function readSharedMeta(cache, name) {
559
+ function readSharedMeta(cache, registry, name) {
538
560
  if (cache == null)
539
561
  return undefined;
540
- // Prefer the full entry — a `name:full` hit subsumes the abbreviated
541
- // hit (full meta carries every field the abbreviated form does, plus
542
- // `time` and per-version trust evidence the trust check needs). The
543
- // resolver only populates `name:full` when the install ran with
544
- // `minimumReleaseAge` configured, otherwise the bare `name` key holds
562
+ // Prefer a full entry — it carries every field the abbreviated form
563
+ // does, plus `time` and per-version trust evidence the trust check
564
+ // needs. The resolver only populates a full key when the install ran
565
+ // with `minimumReleaseAge` configured, otherwise the bare key holds
545
566
  // the abbreviated form.
546
- return validateSharedMeta(cache.get(`${name}:full`), name) ??
547
- validateSharedMeta(cache.get(name), name);
567
+ return readSharedFullMeta(cache, registry, name) ??
568
+ validateSharedMeta(cache.get(getPkgMetaCacheKey(registry, name, false, false)), name);
548
569
  }
549
- function readSharedMetaForTrust(cache, name) {
570
+ function readSharedMetaForTrust(cache, registry, name) {
550
571
  if (cache == null)
551
572
  return undefined;
552
573
  // Abbreviated meta is rejected for the trust check — it lacks
553
574
  // per-version `time` and per-version trust evidence.
554
- return validateSharedMeta(cache.get(`${name}:full`), name);
575
+ return readSharedFullMeta(cache, registry, name);
555
576
  }
556
- // Defensive guard against the resolver's `name`-only cache key
557
- // returning something unexpected. The known correctness gap (two
558
- // registries serving the same package name share one cache slot)
559
- // is inherited from the resolver itself see the `pickPackage.ts`
560
- // cacheKey shape; the verifier can't be stricter than the resolver
561
- // without changing both. This name check at least catches accidental
577
+ // The resolver keys full metadata as either filtered or unfiltered
578
+ // depending on its own `filterMetadata` setting; the verifier doesn't
579
+ // know which, and a filtered full packument keeps everything the
580
+ // verifier reads (`time`, per-version `_npmUser`, `dist`), so try both.
581
+ function readSharedFullMeta(cache, registry, name) {
582
+ return validateSharedMeta(cache.get(getPkgMetaCacheKey(registry, name, true, false)), name) ??
583
+ validateSharedMeta(cache.get(getPkgMetaCacheKey(registry, name, true, true)), name);
584
+ }
585
+ // Defensive guard against the resolver's `metaCache` returning an
586
+ // unexpected entry. The cache key is registry-qualified (see
587
+ // `getPkgMetaCacheKey`), so a package of the same name from another
588
+ // registry can't be returned; this name check catches accidental
562
589
  // returns of a different package (cache corruption, factory misuse)
563
590
  // rather than silently feeding wrong data to the trust / age check.
564
591
  function validateSharedMeta(meta, name) {
@@ -672,25 +699,26 @@ function isExcluded(policy, name, version) {
672
699
  return true;
673
700
  return false;
674
701
  }
675
- function isNpmRegistryResolution(resolution) {
702
+ function isRegistryTarballResolution(resolution) {
676
703
  if (resolution == null || typeof resolution !== 'object')
677
704
  return false;
678
705
  // Only plain tarball resolutions (npm registry / named registries) have no
679
706
  // `type` field. Git / directory / binary / custom resolutions all carry one.
680
707
  if ('type' in resolution && resolution.type != null)
681
708
  return false;
682
- // Git-hosted tarballs (codeload/gitlab/bitbucket) are special-cased in
683
- // the resolver and aren't subject to release-age policy.
684
- if ('gitHosted' in resolution && resolution.gitHosted)
685
- return false;
686
709
  const tarball = resolution.tarball;
687
710
  if (typeof tarball === 'string') {
711
+ // Git-hosted tarballs (codeload/gitlab/bitbucket) are special-cased in
712
+ // the resolver and aren't subject to registry policy.
713
+ if (isGitHostedTarballUrl(tarball))
714
+ return false;
688
715
  // Local/non-registry tarballs (for example `file:`) have no packument
689
716
  // metadata, so minimumReleaseAge/trustPolicy verification cannot apply.
690
717
  const protocol = tryParseUrl(tarball)?.protocol;
691
718
  if (protocol != null && protocol !== 'http:' && protocol !== 'https:')
692
719
  return false;
693
720
  }
694
- return 'tarball' in resolution || 'integrity' in resolution;
721
+ // Canonical registry entries may omit both `tarball` and `integrity`.
722
+ return true;
695
723
  }
696
724
  //# sourceMappingURL=createNpmResolutionVerifier.js.map
package/lib/fetch.js CHANGED
@@ -1,9 +1,19 @@
1
1
  import url from 'node:url';
2
+ import util from 'node:util';
2
3
  import { requestRetryLogger } from '@pnpm/core-loggers';
3
- import { FetchError, PnpmError, } from '@pnpm/error';
4
+ import { FetchError, PnpmError, redactUrlCredentials, } from '@pnpm/error';
4
5
  import { globalWarn } from '@pnpm/logger';
5
6
  import * as retry from '@zkochan/retry';
6
7
  import semver from 'semver';
8
+ import { clearMeta } from './clearMeta.js';
9
+ /**
10
+ * Content type of an abbreviated (install-oriented) package metadata document.
11
+ * A spec-compliant registry echoes this in the response `Content-Type` when it
12
+ * honors the abbreviated `Accept` header. Its absence signals that the registry
13
+ * ignored the header and served the full document instead.
14
+ * https://github.com/npm/registry/blob/main/docs/responses/package-metadata.md
15
+ */
16
+ const ABBREVIATED_META_CONTENT_TYPE = 'application/vnd.npm.install-v1+json';
7
17
  export class RegistryResponseError extends FetchError {
8
18
  pkgName;
9
19
  constructor(request, response, pkgName) {
@@ -87,7 +97,18 @@ export async function fetchMetadataFromFromRegistry(fetchOpts, pkgName, { authHe
87
97
  });
88
98
  }
89
99
  catch (error) { // eslint-disable-line
90
- reject(new PnpmError('META_FETCH_FAIL', `GET ${uri}: ${error.message}`, { attempts: attempt, cause: error }));
100
+ // Redact credentials embedded in the URL from the cause as well, not
101
+ // just the top-level message: a reporter or debugger that renders
102
+ // `error.cause` would otherwise print the raw URL-bearing message. The
103
+ // `stack` string embeds the original (pre-mutation) message, so redact
104
+ // it too — mutating `message` alone leaves the credentials in `stack`.
105
+ if (util.types.isNativeError(error)) {
106
+ if (typeof error.message === 'string')
107
+ error.message = redactUrlCredentials(error.message);
108
+ if (typeof error.stack === 'string')
109
+ error.stack = redactUrlCredentials(error.stack);
110
+ }
111
+ reject(new PnpmError('META_FETCH_FAIL', redactUrlCredentials(`GET ${uri}: ${error.message}`), { attempts: attempt, cause: error }));
91
112
  return;
92
113
  }
93
114
  if (response.status === 304) {
@@ -113,8 +134,7 @@ export async function fetchMetadataFromFromRegistry(fetchOpts, pkgName, { authHe
113
134
  globalWarn(`Request took ${elapsedMs}ms: ${uri}`);
114
135
  }
115
136
  resolve({
116
- meta,
117
- jsonText,
137
+ ...normalizeAbbreviatedResponse({ fullMetadata, meta, jsonText, response }),
118
138
  etag: response.headers.get('etag') ?? undefined,
119
139
  });
120
140
  }
@@ -144,6 +164,38 @@ export async function fetchMetadataFromFromRegistry(fetchOpts, pkgName, { authHe
144
164
  });
145
165
  });
146
166
  }
167
+ /**
168
+ * When the resolver asked for abbreviated metadata but the registry ignored the
169
+ * `Accept` header and returned the full document (detected via the response
170
+ * `Content-Type`), strip it down to the abbreviated field set so downstream
171
+ * consumers — the in-memory cache, the on-disk mirror, and the resolver — never
172
+ * carry the megabytes of install-irrelevant data (scripts, exports, readme,
173
+ * custom fields) that a full document contains.
174
+ *
175
+ * Registries that honor the header (e.g. the npm registry) echo the abbreviated
176
+ * `Content-Type`, so this is a no-op for them: no re-serialization, no field
177
+ * stripping — the happy path pays nothing.
178
+ */
179
+ function normalizeAbbreviatedResponse({ fullMetadata, meta, jsonText, response }) {
180
+ if (fullMetadata)
181
+ return { meta, jsonText };
182
+ if (parseMediaType(response.headers.get('content-type')) === ABBREVIATED_META_CONTENT_TYPE)
183
+ return { meta, jsonText };
184
+ const normalized = clearMeta(meta);
185
+ return { meta: normalized, jsonText: JSON.stringify(normalized) };
186
+ }
187
+ /**
188
+ * Extracts the media type from a `Content-Type` header value, dropping
189
+ * parameters such as `; charset=utf-8`. Media types are case-insensitive
190
+ * (RFC 9110 §8.3.1), so the result is lowercased for comparison.
191
+ */
192
+ function parseMediaType(contentType) {
193
+ if (contentType == null)
194
+ return undefined;
195
+ const semicolonIndex = contentType.indexOf(';');
196
+ const mediaType = semicolonIndex === -1 ? contentType : contentType.slice(0, semicolonIndex);
197
+ return mediaType.trim().toLowerCase();
198
+ }
147
199
  function toUri(pkgName, registry) {
148
200
  let encodedName;
149
201
  if (pkgName[0] === '@') {
package/lib/index.d.ts CHANGED
@@ -94,6 +94,8 @@ export interface ResolveFromNpmContext {
94
94
  name?: string;
95
95
  version?: string;
96
96
  }) => Promise<DependencyManifest | undefined>;
97
+ /** Deduplicates the held-back-update warning per `(name, picked, preferred)`. */
98
+ warnedHeldBackUpdates: Set<string>;
97
99
  }
98
100
  export type ResolveFromNpmOptions = {
99
101
  alwaysTryWorkspacePackages?: boolean;
@@ -109,6 +111,7 @@ export type ResolveFromNpmOptions = {
109
111
  preferredVersions?: PreferredVersions;
110
112
  preferWorkspacePackages?: boolean;
111
113
  update?: false | 'compatible' | 'latest';
114
+ updateRequested?: boolean;
112
115
  updateChecksums?: boolean;
113
116
  injectWorkspacePackages?: boolean;
114
117
  calcSpecifier?: boolean;
package/lib/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import path from 'node:path';
2
2
  import { pickRegistryForPackage } from '@pnpm/config.pick-registry-for-package';
3
3
  import { PnpmError } from '@pnpm/error';
4
+ import { globalWarn } from '@pnpm/logger';
5
+ import { EXISTING_VERSION_SELECTOR_WEIGHT, } from '@pnpm/resolving.resolver-base';
4
6
  import { storeIndexKey } from '@pnpm/store.index';
5
7
  import { readPkgFromCafs, } from '@pnpm/worker';
6
8
  import { resolveWorkspaceRange } from '@pnpm/workspace.range-resolver';
@@ -130,6 +132,7 @@ export function createNpmResolver(fetchFromRegistry, getAuthHeader, opts) {
130
132
  namedRegistryNames,
131
133
  saveWorkspaceProtocol: opts.saveWorkspaceProtocol,
132
134
  peekManifestFromStore,
135
+ warnedHeldBackUpdates: new Set(),
133
136
  };
134
137
  const boundResolveFromNpm = resolveNpm.bind(null, ctx);
135
138
  const boundResolveFromJsr = resolveJsr.bind(null, ctx);
@@ -150,6 +153,96 @@ export function createNpmResolver(fetchFromRegistry, getAuthHeader, opts) {
150
153
  },
151
154
  };
152
155
  }
156
+ /**
157
+ * The preferred-version selectors to hand the package picker for `pkgName`.
158
+ *
159
+ * When this package is the user's update target (`updateRequested`), the
160
+ * lockfile's contribution to its selectors is removed so the target
161
+ * re-resolves exactly the way a fresh install would after its lockfile
162
+ * entries were deleted. Everything a fresh install applies is preserved:
163
+ * manifest pins, the versions propagated down the dependency chain, and the
164
+ * negative-weight `range` penalties that `pnpm audit --fix` injects to steer
165
+ * resolution away from vulnerable versions.
166
+ */
167
+ function preferredVersionSelectorsFor(opts, pkgName) {
168
+ const selectors = opts.preferredVersions?.[pkgName];
169
+ if (!opts.updateRequested)
170
+ return selectors;
171
+ return stripLockfileVersionPins(selectors);
172
+ }
173
+ /**
174
+ * Remove the lockfile-derived part of the selectors: the concrete pins
175
+ * `getPreferredVersionsFromLockfileAndManifests` seeds at
176
+ * `EXISTING_VERSION_SELECTOR_WEIGHT` — added onto the manifest weight when a
177
+ * manifest entry pins the same version, so the lockfile weight is subtracted
178
+ * rather than the selector dropped, leaving the manifest contribution in
179
+ * effect. Selectors a fresh install would also apply (manifest pins,
180
+ * chain-propagated versions, `range`/`tag` selectors) pass through unchanged.
181
+ * Returns `undefined` when nothing remains.
182
+ */
183
+ function stripLockfileVersionPins(selectors) {
184
+ if (selectors == null)
185
+ return undefined;
186
+ let kept;
187
+ for (const [selector, value] of Object.entries(selectors)) {
188
+ let keptValue = value;
189
+ if (typeof value !== 'string' && value.selectorType === 'version' && value.weight >= EXISTING_VERSION_SELECTOR_WEIGHT) {
190
+ const manifestWeight = value.weight - EXISTING_VERSION_SELECTOR_WEIGHT;
191
+ if (manifestWeight <= 0)
192
+ continue;
193
+ keptValue = { selectorType: 'version', weight: manifestWeight };
194
+ }
195
+ // Null-prototype: selector keys come from manifests and the lockfile,
196
+ // and a dist-tag named `__proto__` is a valid selector key.
197
+ kept ??= Object.create(null);
198
+ kept[selector] = keptValue;
199
+ }
200
+ return kept;
201
+ }
202
+ /**
203
+ * During a targeted update the picker still honors the preferred versions a
204
+ * fresh install would apply (manifest pins and versions propagated down the
205
+ * dependency chain), so the target can legitimately settle below the highest
206
+ * version its range admits. Surface that once per package: reaching the
207
+ * newer version everywhere is an override's job, not an update's.
208
+ *
209
+ * The baseline for "held back" is the pick with only the non-pin selectors
210
+ * applied — `range`/`tag` selectors such as the `pnpm audit --fix`
211
+ * vulnerability penalties steer the baseline too, so the warning never
212
+ * recommends a version those selectors avoid.
213
+ *
214
+ * The recommended override is scoped to the declared range being resolved
215
+ * (`name@<range>`), so applying it can never violate any consumer's range:
216
+ * only declarations of exactly this range match the selector, and the
217
+ * recommended version satisfies it by construction.
218
+ */
219
+ function warnOnceOnHeldBackUpdate(ctx, opts, spec, meta, pickedVersion) {
220
+ if (!opts.updateRequested || spec.type !== 'range')
221
+ return;
222
+ const selectors = preferredVersionSelectorsFor(opts, spec.name);
223
+ if (selectors == null)
224
+ return;
225
+ let nonPinSelectors;
226
+ for (const [selector, value] of Object.entries(selectors)) {
227
+ if ((typeof value === 'string' ? value : value.selectorType) === 'version')
228
+ continue;
229
+ // Null-prototype for the same reason as in `stripLockfileVersionPins`.
230
+ nonPinSelectors ??= Object.create(null);
231
+ nonPinSelectors[selector] = value;
232
+ }
233
+ const preferred = pickVersionByVersionRange({
234
+ meta,
235
+ versionRange: spec.fetchSpec,
236
+ preferredVersionSelectors: nonPinSelectors,
237
+ });
238
+ if (preferred == null || preferred === pickedVersion)
239
+ return;
240
+ const key = `${spec.name}@${spec.fetchSpec}:${pickedVersion}<${preferred}`;
241
+ if (ctx.warnedHeldBackUpdates.has(key))
242
+ return;
243
+ ctx.warnedHeldBackUpdates.add(key);
244
+ globalWarn(`"${spec.name}@${spec.fetchSpec}" was updated to ${pickedVersion}, not ${preferred}, to match the version preferred by your manifests and already installed dependencies. To use ${preferred}, add an override to pnpm-workspace.yaml: overrides: { "${spec.name}@${spec.fetchSpec}": "${preferred}" }`);
245
+ }
153
246
  function isNpmSpec(query, defaultRegistry) {
154
247
  const { alias, bareSpecifier } = query.wantedDependency;
155
248
  if (!bareSpecifier)
@@ -285,7 +378,7 @@ async function resolveNpm(ctx, wantedDependency, opts) {
285
378
  publishedByExclude: opts.publishedByExclude,
286
379
  authHeaderValue,
287
380
  dryRun: opts.dryRun === true,
288
- preferredVersionSelectors: opts.preferredVersions?.[spec.name],
381
+ preferredVersionSelectors: preferredVersionSelectorsFor(opts, spec.name),
289
382
  registry,
290
383
  includeLatestTag: opts.update === 'latest',
291
384
  updateChecksums: opts.updateChecksums,
@@ -306,8 +399,13 @@ async function resolveNpm(ctx, wantedDependency, opts) {
306
399
  pinnedVersion: opts.pinnedVersion,
307
400
  });
308
401
  }
309
- catch {
310
- // ignore
402
+ catch (workspaceErr) {
403
+ // When the registry doesn't have the package and the workspace has it
404
+ // only at non-matching versions, the mismatch error (which lists the
405
+ // available workspace versions) is more actionable than the raw 404.
406
+ if (err.code === 'ERR_PNPM_FETCH_404' && workspaceErr.code === 'ERR_PNPM_NO_MATCHING_VERSION_INSIDE_WORKSPACE') {
407
+ throw workspaceErr;
408
+ }
311
409
  }
312
410
  }
313
411
  throw err;
@@ -328,8 +426,13 @@ async function resolveNpm(ctx, wantedDependency, opts) {
328
426
  pinnedVersion: opts.pinnedVersion,
329
427
  });
330
428
  }
331
- catch {
332
- // ignore
429
+ catch (workspaceErr) {
430
+ // Neither the registry nor the workspace has a matching version; the
431
+ // workspace mismatch error carries the available local versions,
432
+ // which is the actionable detail here.
433
+ if (workspaceErr.code === 'ERR_PNPM_NO_MATCHING_VERSION_INSIDE_WORKSPACE') {
434
+ throw workspaceErr;
435
+ }
333
436
  }
334
437
  }
335
438
  throw new NoMatchingVersionError({ wantedDependency, packageMeta: meta, registry });
@@ -370,6 +473,7 @@ async function resolveNpm(ctx, wantedDependency, opts) {
370
473
  };
371
474
  }
372
475
  }
476
+ warnOnceOnHeldBackUpdate(ctx, opts, spec, meta, pickedPackage.version);
373
477
  const id = `${pickedPackage.name}@${pickedPackage.version}`;
374
478
  const resolution = {
375
479
  integrity: getIntegrity(pickedPackage.dist),
@@ -489,7 +593,7 @@ async function pickFromSimpleRegistry(ctx, wantedDependency, opts, spec, registr
489
593
  publishedByExclude: opts.publishedByExclude,
490
594
  authHeaderValue,
491
595
  dryRun: opts.dryRun === true,
492
- preferredVersionSelectors: opts.preferredVersions?.[spec.name],
596
+ preferredVersionSelectors: preferredVersionSelectorsFor(opts, spec.name),
493
597
  registry,
494
598
  includeLatestTag: opts.update === 'latest',
495
599
  updateChecksums: opts.updateChecksums,
@@ -498,6 +602,7 @@ async function pickFromSimpleRegistry(ctx, wantedDependency, opts, spec, registr
498
602
  if (pickedPackage == null) {
499
603
  throw new NoMatchingVersionError({ wantedDependency, packageMeta: meta, registry });
500
604
  }
605
+ warnOnceOnHeldBackUpdate(ctx, opts, spec, meta, pickedPackage.version);
501
606
  const resolution = {
502
607
  integrity: getIntegrity(pickedPackage.dist),
503
608
  tarball: normalizeRegistryUrl(pickedPackage.dist.tarball),
@@ -2,6 +2,7 @@ import { PnpmError } from '@pnpm/error';
2
2
  import { parseJsrSpecifier } from '@pnpm/resolving.jsr-specifier-parser';
3
3
  import { parseNpmTarballUrl } from 'parse-npm-tarball-url';
4
4
  import semver from 'semver';
5
+ import validateNpmPackageName from 'validate-npm-package-name';
5
6
  import getVersionSelectorType from 'version-selector-type';
6
7
  export function parseBareSpecifier(bareSpecifier, alias, defaultTag, registry) {
7
8
  let name = alias;
@@ -69,6 +70,9 @@ export const BUILTIN_NAMED_REGISTRIES = Object.freeze({
69
70
  // Parses a named-registry specifier of the shape `<alias>:<body>` into a
70
71
  // RegistryPackageSpec. Returns `null` when the specifier does not use one of
71
72
  // the configured aliases, so the caller can fall through to other resolvers.
73
+ // Throws INVALID_NAMED_REGISTRY_PACKAGE_NAME when the alias matches but the
74
+ // package name is malformed (missing or empty scope/name segments, path
75
+ // separators inside the name).
72
76
  // Supported shapes:
73
77
  // - `<alias>:[@<owner>/]<name>[@<version_selector>]`
74
78
  // - `<alias>:<version_selector>` paired with a package alias
@@ -100,9 +104,6 @@ export function parseNamedRegistrySpecifierToRegistryPackageSpec(rawSpecifier, k
100
104
  pkgName = body.substring(0, index);
101
105
  versionSelector = body.substring(index + '@'.length);
102
106
  }
103
- if (pkgName.indexOf('/') === -1 || pkgName.endsWith('/')) {
104
- throw new PnpmError('INVALID_NAMED_REGISTRY_PACKAGE_NAME', `The package name '${pkgName}' in named registry '${registryName}:' is invalid`);
105
- }
106
107
  }
107
108
  else if (packageAlias?.startsWith('@')) {
108
109
  // `<alias>:<tag>` paired with a scoped alias — body is a version
@@ -124,6 +125,11 @@ export function parseNamedRegistrySpecifierToRegistryPackageSpec(rawSpecifier, k
124
125
  if (!pkgName)
125
126
  return null;
126
127
  }
128
+ // The name is used in registry URLs and metadata cache file paths, so
129
+ // anything that is not a valid npm package name must never make it through.
130
+ if (!validateNpmPackageName(pkgName).validForOldPackages) {
131
+ throw new PnpmError('INVALID_NAMED_REGISTRY_PACKAGE_NAME', `The package name '${pkgName}' in named registry '${registryName}:' is invalid`);
132
+ }
127
133
  const selector = getVersionSelectorType(versionSelector ?? defaultTag);
128
134
  if (selector == null)
129
135
  return null;
@@ -3,6 +3,14 @@ import type { FetchMetadataNotModifiedResult, FetchMetadataResult } from './fetc
3
3
  import type { RegistryPackageSpec } from './parseBareSpecifier.js';
4
4
  import { type PickPackageFromMetaOptions } from './pickPackageFromMeta.js';
5
5
  export interface PackageMetaCache {
6
+ /**
7
+ * Must return the same object reference that `set` stored for the key: the
8
+ * resolver tracks whether a cached packument was validated against the
9
+ * registry by object identity (see `unverifiedDiskPackuments`). In a cache
10
+ * that clones or deserializes on read, that provenance is lost and recovery
11
+ * degrades — a stale disk-promoted entry that can't satisfy a spec fails
12
+ * the pick instead of falling through to the registry.
13
+ */
6
14
  get: (key: string) => PackageMeta | undefined;
7
15
  set: (key: string, meta: PackageMeta) => void;
8
16
  has: (key: string) => boolean;
@@ -15,12 +23,14 @@ export interface PickPackageOptions extends PickPackageFromMetaOptions {
15
23
  includeLatestTag?: boolean;
16
24
  optional?: boolean;
17
25
  /**
18
- * When true, skip the on-disk exact-version cache fast path so a
19
- * stale on-disk packument can't satisfy the call without a
20
- * conditional registry request. The in-memory cache is left alone:
21
- * its entries can only be populated by this install's own fresh
22
- * network fetches, so they're authoritative for second-and-onward
23
- * lookups within the same install.
26
+ * When true, force a conditional registry request so a stale on-disk
27
+ * packument can't satisfy the call: the on-disk exact-version fast
28
+ * path is skipped, and the in-memory cache is bypassed too. The fast
29
+ * path now promotes disk-loaded packuments into the in-memory cache,
30
+ * so an entry there can no longer be assumed to come from this
31
+ * install's own fresh network fetch — on a shared or long-lived
32
+ * resolver it might be disk-sourced, which would short-circuit the
33
+ * revalidation updateChecksums exists to force.
24
34
  */
25
35
  updateChecksums?: boolean;
26
36
  }
@@ -44,6 +54,28 @@ export declare function pickPackage(ctx: {
44
54
  pickedPackage: PackageInRegistry | null;
45
55
  }>;
46
56
  export declare function encodePkgName(pkgName: string): string;
57
+ /**
58
+ * Key for the in-memory `metaCache` holding a package's registry metadata. The
59
+ * registry is part of the key so that a package of the same name served by two
60
+ * registries in one install can't collide on a single slot (which would resolve
61
+ * the wrong tarball/integrity). `fullMetadata` and `filterMetadata` keep the
62
+ * abbreviated, full, and filtered-full documents in distinct slots, mirroring
63
+ * the on-disk `metaDir` split: a `filterMetadata` resolver stores a `clearMeta`-
64
+ * stripped packument, so it must not share a slot with an unfiltered full one
65
+ * (reachable only when a `metaCache` is shared across resolvers with different
66
+ * settings). `filterMetadata` only narrows the full slot — abbreviated metadata
67
+ * shares one on-disk mirror regardless, so its key carries no filtered variant.
68
+ * `\x00` can't appear in a registry URL or a package name, so it's an
69
+ * unambiguous separator. The verifier reads this same cache and must build the
70
+ * key with this function.
71
+ *
72
+ * The registry is canonicalized to its origin plus a trailing-slashed path, so
73
+ * the resolver (which may pass a configured named-registry URL verbatim) and
74
+ * the verifier (which routes through trailing-slashed prefixes) converge on one
75
+ * key for the same logical registry instead of creating duplicate slots. Origin
76
+ * and path are preserved, so two registries that genuinely differ never collapse.
77
+ */
78
+ export declare function getPkgMetaCacheKey(registry: string, pkgName: string, fullMetadata: boolean, filterMetadata: boolean): string;
47
79
  /**
48
80
  * Path of the on-disk JSONL document where pnpm mirrors a package's registry
49
81
  * metadata. `metaDir` selects between abbreviated and full caches.
@@ -8,9 +8,9 @@ import { globalWarn, logger } from '@pnpm/logger';
8
8
  import getRegistryName from 'encode-registry';
9
9
  import pLimit, {} from 'p-limit';
10
10
  import { fastPathTemp as pathTemp } from 'path-temp';
11
- import { pick } from 'ramda';
12
11
  import { renameOverwrite } from 'rename-overwrite';
13
12
  import semver from 'semver';
13
+ import { clearMeta } from './clearMeta.js';
14
14
  import { pickLowestVersionByVersionRange, pickPackageFromMeta, pickVersionByVersionRange, } from './pickPackageFromMeta.js';
15
15
  import { toRaw } from './toRaw.js';
16
16
  /**
@@ -104,6 +104,29 @@ function pickMatchingVersionFinal(pickerOpts, spec, meta) {
104
104
  throw err;
105
105
  }
106
106
  }
107
+ /**
108
+ * Packuments promoted into the in-memory cache straight from the on-disk
109
+ * mirror, without registry validation. The mirror may predate versions the
110
+ * registry has, so when a cache hit on such an entry can't satisfy the
111
+ * requested spec (and the resolver isn't offline), `pickPackage` falls
112
+ * through to the regular flow — a conditional registry request — instead of
113
+ * failing the pick, exactly as it would have before the entry was promoted.
114
+ * Network-fetched and 304-revalidated packuments are never in this set, so
115
+ * hits on them keep returning directly even when the pick fails (the caller
116
+ * then falls back to workspace packages or reports no matching version).
117
+ */
118
+ const unverifiedDiskPackuments = new WeakSet();
119
+ /**
120
+ * Promote a packument parsed from the on-disk mirror into the in-memory
121
+ * cache, so repeat resolutions of the same package (common across a large
122
+ * dependency graph) don't re-read and re-parse the mirror. The entry is
123
+ * remembered as disk-sourced (see {@link unverifiedDiskPackuments}) because it
124
+ * never went through registry validation.
125
+ */
126
+ function cacheDiskLoadedMeta(metaCache, cacheKey, meta) {
127
+ unverifiedDiskPackuments.add(meta);
128
+ metaCache.set(cacheKey, meta);
129
+ }
107
130
  export async function pickPackage(ctx, spec, opts) {
108
131
  opts = opts || {};
109
132
  const pickerOpts = {
@@ -121,10 +144,16 @@ export async function pickPackage(ctx, spec, opts) {
121
144
  const metaDir = fullMetadata
122
145
  ? (ctx.filterMetadata ? FULL_FILTERED_META_DIR : FULL_META_DIR)
123
146
  : ABBREVIATED_META_DIR;
124
- // Cache key includes fullMetadata to avoid returning abbreviated metadata when full metadata is requested.
125
- const cacheKey = fullMetadata ? `${spec.name}:full` : spec.name;
147
+ // Cache key includes the registry so a package of the same name served by two
148
+ // registries in one install can't share a slot (which would resolve the wrong
149
+ // tarball/integrity), plus fullMetadata/filterMetadata so a request is never
150
+ // served a less-detailed or differently-stripped document than it asked for.
151
+ const cacheKey = getPkgMetaCacheKey(opts.registry, spec.name, fullMetadata, ctx.filterMetadata === true);
126
152
  const pkgMirror = getPkgMirrorPath(ctx.cacheDir, metaDir, opts.registry, spec.name);
127
- const cachedMeta = ctx.metaCache.get(cacheKey);
153
+ // updateChecksums must reach the conditional registry request below, so it
154
+ // can't be served from the in-memory cache — which may hold a disk-promoted
155
+ // entry rather than a fresh network fetch (see the updateChecksums doc).
156
+ const cachedMeta = opts.updateChecksums ? undefined : ctx.metaCache.get(cacheKey);
128
157
  if (cachedMeta != null) {
129
158
  // The in-memory cache may hold abbreviated metadata from an earlier call
130
159
  // that didn't need `time` (no publishedBy then). If this call has
@@ -141,21 +170,31 @@ export async function pickPackage(ctx, spec, opts) {
141
170
  : persistUpgradedMeta(ctx, pkgMirror, upgrade.upgradedFrom);
142
171
  ctx.metaCache.set(cacheKey, metaForCache);
143
172
  }
144
- return {
145
- meta: metaForCache,
146
- pickedPackage: pickMatchingVersionFinal(pickerOpts, spec, metaForCache),
147
- };
173
+ const pickedPackage = pickMatchingVersionFinal(pickerOpts, spec, metaForCache);
174
+ if (pickedPackage != null || ctx.offline === true || !unverifiedDiskPackuments.has(metaForCache)) {
175
+ return {
176
+ meta: metaForCache,
177
+ pickedPackage,
178
+ };
179
+ }
180
+ // Disk-promoted meta that can't satisfy the spec: fall through and
181
+ // revalidate against the registry (see unverifiedDiskPackuments).
148
182
  }
149
183
  return runLimited(pkgMirror, async (limit) => {
150
184
  let metaCachedInStore;
151
185
  if (ctx.offline === true || ctx.preferOffline === true || opts.pickLowestVersion) {
152
186
  metaCachedInStore = await limit(async () => loadMeta(pkgMirror));
153
187
  if (ctx.offline) {
154
- if (metaCachedInStore != null)
188
+ if (metaCachedInStore != null) {
189
+ // maybeUpgradeAbbreviatedMetaForReleaseAge short-circuits when
190
+ // offline, so a later in-memory cache hit returns this same meta
191
+ // without any network access.
192
+ cacheDiskLoadedMeta(ctx.metaCache, cacheKey, metaCachedInStore);
155
193
  return {
156
194
  meta: metaCachedInStore,
157
195
  pickedPackage: pickMatchingVersionFinal(pickerOpts, spec, metaCachedInStore),
158
196
  };
197
+ }
159
198
  throw new PnpmError('NO_OFFLINE_META', `Failed to resolve ${toRaw(spec)} in package mirror ${pkgMirror}`);
160
199
  }
161
200
  if (metaCachedInStore != null) {
@@ -172,6 +211,14 @@ export async function pickPackage(ctx, spec, opts) {
172
211
  }
173
212
  const pickedPackage = pickMatchingVersionFinal(pickerOpts, spec, metaCachedInStore);
174
213
  if (pickedPackage) {
214
+ // A cache hit re-runs maybeUpgradeAbbreviatedMetaForReleaseAge, so
215
+ // serving this meta from memory can't bypass the release-age
216
+ // upgrade. When the upgrade branch above already cached the
217
+ // registry-validated upgraded meta, don't overwrite it with a
218
+ // disk-sourced marking.
219
+ if (upgrade.upgradedFrom == null) {
220
+ cacheDiskLoadedMeta(ctx.metaCache, cacheKey, metaCachedInStore);
221
+ }
175
222
  return {
176
223
  meta: metaCachedInStore,
177
224
  pickedPackage,
@@ -187,6 +234,7 @@ export async function pickPackage(ctx, spec, opts) {
187
234
  try {
188
235
  const pickedPackage = pickMatchingVersionFast(pickerOpts, spec, metaCachedInStore);
189
236
  if (pickedPackage) {
237
+ cacheDiskLoadedMeta(ctx.metaCache, cacheKey, metaCachedInStore);
190
238
  return {
191
239
  meta: metaCachedInStore,
192
240
  pickedPackage,
@@ -424,47 +472,49 @@ function persistUpgradedMeta(ctx, pkgMirror, upgradedFrom) {
424
472
  }));
425
473
  return metaForCache;
426
474
  }
427
- function clearMeta(pkg) {
428
- const versions = {};
429
- for (const [version, info] of Object.entries(pkg.versions)) {
430
- // The list taken from https://github.com/npm/registry/blob/master/docs/responses/package-metadata.md#abbreviated-version-object
431
- // with the addition of 'libc'
432
- versions[version] = pick([
433
- 'name',
434
- 'version',
435
- 'bin',
436
- 'directories',
437
- 'devDependencies',
438
- 'optionalDependencies',
439
- 'dependencies',
440
- 'peerDependencies',
441
- 'dist',
442
- 'engines',
443
- 'peerDependenciesMeta',
444
- 'cpu',
445
- 'os',
446
- 'libc',
447
- 'deprecated',
448
- 'bundleDependencies',
449
- 'bundledDependencies',
450
- 'hasInstallScript',
451
- '_npmUser',
452
- ], info);
453
- }
454
- return {
455
- name: pkg.name,
456
- 'dist-tags': pkg['dist-tags'],
457
- versions,
458
- time: pkg.time,
459
- modified: pkg.modified,
460
- };
461
- }
462
475
  export function encodePkgName(pkgName) {
463
476
  if (pkgName !== pkgName.toLowerCase()) {
464
477
  return `${pkgName}_${createHexHash(pkgName)}`;
465
478
  }
466
479
  return pkgName;
467
480
  }
481
+ /**
482
+ * Key for the in-memory `metaCache` holding a package's registry metadata. The
483
+ * registry is part of the key so that a package of the same name served by two
484
+ * registries in one install can't collide on a single slot (which would resolve
485
+ * the wrong tarball/integrity). `fullMetadata` and `filterMetadata` keep the
486
+ * abbreviated, full, and filtered-full documents in distinct slots, mirroring
487
+ * the on-disk `metaDir` split: a `filterMetadata` resolver stores a `clearMeta`-
488
+ * stripped packument, so it must not share a slot with an unfiltered full one
489
+ * (reachable only when a `metaCache` is shared across resolvers with different
490
+ * settings). `filterMetadata` only narrows the full slot — abbreviated metadata
491
+ * shares one on-disk mirror regardless, so its key carries no filtered variant.
492
+ * `\x00` can't appear in a registry URL or a package name, so it's an
493
+ * unambiguous separator. The verifier reads this same cache and must build the
494
+ * key with this function.
495
+ *
496
+ * The registry is canonicalized to its origin plus a trailing-slashed path, so
497
+ * the resolver (which may pass a configured named-registry URL verbatim) and
498
+ * the verifier (which routes through trailing-slashed prefixes) converge on one
499
+ * key for the same logical registry instead of creating duplicate slots. Origin
500
+ * and path are preserved, so two registries that genuinely differ never collapse.
501
+ */
502
+ export function getPkgMetaCacheKey(registry, pkgName, fullMetadata, filterMetadata) {
503
+ const key = `${canonicalizeRegistry(registry)}\x00${pkgName}`;
504
+ if (!fullMetadata)
505
+ return key;
506
+ return filterMetadata ? `${key}:full:filtered` : `${key}:full`;
507
+ }
508
+ function canonicalizeRegistry(registry) {
509
+ try {
510
+ const parsed = new URL(registry);
511
+ const pathname = parsed.pathname.endsWith('/') ? parsed.pathname : `${parsed.pathname}/`;
512
+ return `${parsed.origin}${pathname}`;
513
+ }
514
+ catch {
515
+ return registry;
516
+ }
517
+ }
468
518
  /**
469
519
  * Path of the on-disk JSONL document where pnpm mirrors a package's registry
470
520
  * metadata. `metaDir` selects between abbreviated and full caches.
@@ -11,3 +11,4 @@
11
11
  export declare const MINIMUM_RELEASE_AGE_VIOLATION_CODE = "MINIMUM_RELEASE_AGE_VIOLATION";
12
12
  export declare const TRUST_DOWNGRADE_VIOLATION_CODE = "TRUST_DOWNGRADE";
13
13
  export declare const TARBALL_URL_MISMATCH_VIOLATION_CODE = "TARBALL_URL_MISMATCH";
14
+ export declare const MISSING_TARBALL_INTEGRITY_VIOLATION_CODE = "MISSING_TARBALL_INTEGRITY";
@@ -11,4 +11,5 @@
11
11
  export const MINIMUM_RELEASE_AGE_VIOLATION_CODE = 'MINIMUM_RELEASE_AGE_VIOLATION';
12
12
  export const TRUST_DOWNGRADE_VIOLATION_CODE = 'TRUST_DOWNGRADE';
13
13
  export const TARBALL_URL_MISMATCH_VIOLATION_CODE = 'TARBALL_URL_MISMATCH';
14
+ export const MISSING_TARBALL_INTEGRITY_VIOLATION_CODE = 'MISSING_TARBALL_INTEGRITY';
14
15
  //# sourceMappingURL=violationCodes.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pnpm/resolving.npm-resolver",
3
- "version": "1102.0.1",
3
+ "version": "1102.1.1",
4
4
  "description": "Resolver for npm-hosted packages",
5
5
  "keywords": [
6
6
  "pnpm",
@@ -12,9 +12,9 @@
12
12
  "funding": "https://opencollective.com/pnpm",
13
13
  "repository": {
14
14
  "type": "git",
15
- "url": "https://github.com/pnpm/pnpm/tree/main/resolving/npm-resolver"
15
+ "url": "https://github.com/pnpm/pnpm/tree/main/pnpm11/resolving/npm-resolver"
16
16
  },
17
- "homepage": "https://github.com/pnpm/pnpm/tree/main/resolving/npm-resolver#readme",
17
+ "homepage": "https://github.com/pnpm/pnpm/tree/main/pnpm11/resolving/npm-resolver#readme",
18
18
  "bugs": {
19
19
  "url": "https://github.com/pnpm/pnpm/issues"
20
20
  },
@@ -42,28 +42,29 @@
42
42
  "semver": "^7.8.4",
43
43
  "semver-utils": "^1.1.4",
44
44
  "ssri": "13.0.1",
45
+ "validate-npm-package-name": "7.0.2",
45
46
  "version-selector-type": "^3.0.0",
46
- "@pnpm/core-loggers": "1100.2.1",
47
+ "@pnpm/config.pick-registry-for-package": "1100.0.9",
48
+ "@pnpm/config.version-policy": "1100.1.6",
47
49
  "@pnpm/constants": "1100.0.0",
50
+ "@pnpm/core-loggers": "1100.2.1",
48
51
  "@pnpm/crypto.hash": "1100.0.1",
49
- "@pnpm/error": "1100.0.0",
52
+ "@pnpm/error": "1100.0.1",
53
+ "@pnpm/fetching.types": "1100.0.2",
50
54
  "@pnpm/fs.graceful-fs": "1100.1.0",
51
- "@pnpm/resolving.resolver-base": "1100.4.2",
52
- "@pnpm/resolving.jsr-specifier-parser": "1100.0.0",
53
- "@pnpm/resolving.registry.types": "1100.1.3",
54
55
  "@pnpm/resolving.registry.pkg-metadata-filter": "1100.0.9",
55
- "@pnpm/fetching.types": "1100.0.2",
56
- "@pnpm/store.cafs": "1100.1.10",
57
- "@pnpm/store.index": "1100.2.0",
56
+ "@pnpm/resolving.jsr-specifier-parser": "1100.0.2",
57
+ "@pnpm/resolving.registry.types": "1100.1.3",
58
+ "@pnpm/store.cafs": "1100.1.12",
59
+ "@pnpm/resolving.resolver-base": "1100.5.1",
60
+ "@pnpm/store.index": "1100.2.1",
58
61
  "@pnpm/types": "1101.3.2",
59
- "@pnpm/workspace.spec-parser": "1100.0.0",
60
- "@pnpm/config.pick-registry-for-package": "1100.0.9",
61
- "@pnpm/config.version-policy": "1100.1.5",
62
- "@pnpm/workspace.range-resolver": "1100.0.2"
62
+ "@pnpm/workspace.range-resolver": "1100.0.2",
63
+ "@pnpm/workspace.spec-parser": "1100.0.0"
63
64
  },
64
65
  "peerDependencies": {
65
66
  "@pnpm/logger": "^1100.0.0",
66
- "@pnpm/worker": "^1100.2.1"
67
+ "@pnpm/worker": "^1100.2.3"
67
68
  },
68
69
  "devDependencies": {
69
70
  "@jest/globals": "30.4.1",
@@ -71,13 +72,14 @@
71
72
  "@types/ramda": "0.31.1",
72
73
  "@types/semver": "7.7.1",
73
74
  "@types/ssri": "^7.1.5",
75
+ "@types/validate-npm-package-name": "^4.0.2",
74
76
  "load-json-file": "^7.0.1",
75
77
  "tempy": "3.0.0",
76
78
  "@pnpm/logger": "1100.0.0",
77
- "@pnpm/network.fetch": "1100.1.3",
78
- "@pnpm/resolving.npm-resolver": "1102.0.1",
79
- "@pnpm/testing.mock-agent": "1101.0.3",
80
- "@pnpm/test-fixtures": "1100.0.0"
79
+ "@pnpm/resolving.npm-resolver": "1102.1.1",
80
+ "@pnpm/test-fixtures": "1100.0.0",
81
+ "@pnpm/network.fetch": "1100.1.4",
82
+ "@pnpm/testing.mock-agent": "1101.0.4"
81
83
  },
82
84
  "engines": {
83
85
  "node": ">=22.13"