@pnpm/resolving.npm-resolver 1103.2.1 → 1104.1.0

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,98 @@
1
1
  # @pnpm/npm-resolver
2
2
 
3
+ ## 1104.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Added explicit registry revision selection with `<version>+rN` and `pnpm update --patches` for refreshing revision artifacts without changing package versions. Registry-backed lockfile policy checks recognize historical revisions, and pnpr now preserves safe revision histories from upstream registries.
8
+
9
+ - Added support for registry replacement tarballs using standard integrity values, explicit revision fields, registry routing from the `registries` setting, non-redirecting integrity-addressed URLs, canonical safe-integer revision numbers, and pnpr proxying for immutable upstream revision artifacts.
10
+
11
+ ### Patch Changes
12
+
13
+ - Updated dependencies:
14
+ - @pnpm/config.normalize-registries@1101.0.1
15
+ - @pnpm/config.pick-registry-for-package@1101.0.1
16
+ - @pnpm/config.version-policy@1100.2.2
17
+ - @pnpm/core-loggers@1100.3.4
18
+ - @pnpm/crypto.hash@1100.0.3
19
+ - @pnpm/deps.path@1101.0.1
20
+ - @pnpm/fs.graceful-fs@1100.2.0
21
+ - @pnpm/pkg-manifest.utils@1100.4.2
22
+ - @pnpm/resolving.registry.pkg-metadata-filter@1100.0.18
23
+ - @pnpm/resolving.registry.types@1100.2.0
24
+ - @pnpm/resolving.resolver-base@1101.2.0
25
+ - @pnpm/resolving.tarball-url@1101.1.0
26
+ - @pnpm/store.cafs@1100.3.0
27
+ - @pnpm/store.index@1100.3.0
28
+ - @pnpm/types@1102.1.0
29
+
30
+ ## 1104.0.0
31
+
32
+ ### Major Changes
33
+
34
+ - The three registry lookups are now named for what they are keyed by, so that none of them is called `registries` — a name the `registries` setting itself has taken:
35
+
36
+ | before | after |
37
+ |---|---|
38
+ | `Config.registries` | `Config.registriesByScope` |
39
+ | `Config.namedRegistries` | `Config.registriesByPrefix` |
40
+ | `Config.registryOptions` | `Config.registryOptionsByUrl` |
41
+
42
+ The same rename applies to the `RegistryContext` fields, the `Registries` and `NamedRegistries` types (now `RegistriesByScope` and `RegistriesByPrefix`), `normalizeRegistries` / `normalizeNamedRegistries` (now `normalizeRegistriesByScope` / `normalizeRegistriesByPrefix`), and the `BUILTIN_NAMED_REGISTRIES` constant (now `BUILTIN_REGISTRIES_BY_PREFIX`).
43
+
44
+ This is an internal rename: no setting, error code, lockfile field, or `.pnpmfile.cjs` hook field changes. A `preResolution` hook still reads `ctx.registries`, which is the name pacquet passes as well. The `registries` and `namedRegistries` settings are read under the names users write them.
45
+
46
+ The pnpr resolve request sends `registriesByPrefix` where it sent `namedRegistries`. A pnpr server and its clients must be on matching versions, which is already the case for an experimental server.
47
+
48
+ ### Minor Changes
49
+
50
+ - A registry can now declare that its abbreviated metadata carries the `time` field, so `resolutionMode: time-based` reads the full metadata document only from the registries that need it:
51
+
52
+ ```yaml
53
+ resolutionMode: time-based
54
+ registries:
55
+ https://npm.internal.example/:
56
+ supportsTimeField: true
57
+ ```
58
+
59
+ `registry.npmjs.org` omits `time` from abbreviated metadata, so a time-based resolution has to fall back to the much larger full document. That fallback used to be all-or-nothing: `registrySupportsTimeField` answered for every registry at once, so a project resolving from both the public registry and a Verdaccio instance either paid for full metadata everywhere or claimed a `time` field npmjs does not serve. The answer is now per registry, and `registrySupportsTimeField` remains the answer for every registry that does not declare one.
60
+
61
+ The declaration is also sent to a pnpr server, which applies it to the resolution it runs on the client's behalf.
62
+
63
+ ### Patch Changes
64
+
65
+ - Re-fetch full registry metadata when `minimumReleaseAge` is enabled and an abbreviated packument's `time` map omits timestamps for some versions. This prevents mature versions from being filtered out and resolution from falling back to the lowest matching version [pnpm/pnpm#13741](https://github.com/pnpm/pnpm/issues/13741).
66
+
67
+ - Reduced registry metadata requests during dependency resolution by reusing cached metadata when lockfile preferences prove that no uncached version can win [pnpm/pnpm#13976](https://github.com/pnpm/pnpm/issues/13976).
68
+
69
+ - Installs are faster in workspaces that declare inter-workspace dependencies with plain ranges (`"*"`, `"^1.2.3"`) rather than the `workspace:` protocol. With `preferWorkspacePackages` enabled, linking such a dependency no longer makes a registry request that cannot change the outcome — and workspace packages that were never published no longer cost a 404 on every install.
70
+
71
+ - Added `fetchWarnTimeoutMs` and `fetchMinSpeedKiBps` to the Rust pnpm CLI and its N-API bindings. Slow registry metadata requests and tarball downloads now emit pnpm-compatible warnings without exposing URL credentials, query parameters, fragments, or control characters [pnpm/pnpm#12042](https://github.com/pnpm/pnpm/issues/12042).
72
+
73
+ - `trustPolicy: no-downgrade` no longer aborts the install with `ERR_PNPM_MISSING_TIME` on registries that serve no per-version `time` field when `minimumReleaseAgeIgnoreMissingTime` is set. The trust check reads the same publish dates the `minimumReleaseAge` check does, so it now honors the same opt-in and skips the affected package with a warning [#12446](https://github.com/pnpm/pnpm/issues/12446).
74
+
75
+ `minimumReleaseAgeIgnoreMissingTime` no longer lets a lockfile entry the registry does not list pass the `minimumReleaseAge` check during lockfile verification. The opt-in covers a registry that cannot date its releases; a packument that does date every version it lists is saying it never published this one, which stays a hard failure.
76
+
77
+ The missing-`time` warning now names the check it is reporting on, so a package whose `minimumReleaseAge` and `trustPolicy` checks are both skipped warns about both instead of only the first.
78
+
79
+ - Updated dependencies:
80
+ - @pnpm/config.normalize-registries@1101.0.0
81
+ - @pnpm/config.pick-registry-for-package@1101.0.0
82
+ - @pnpm/config.version-policy@1100.2.1
83
+ - @pnpm/constants@1102.0.0
84
+ - @pnpm/core-loggers@1100.3.3
85
+ - @pnpm/deps.path@1101.0.0
86
+ - @pnpm/error@1100.1.3
87
+ - @pnpm/pkg-manifest.utils@1100.4.1
88
+ - @pnpm/resolving.jsr-specifier-parser@1100.0.6
89
+ - @pnpm/resolving.registry.pkg-metadata-filter@1100.0.17
90
+ - @pnpm/resolving.registry.types@1100.1.10
91
+ - @pnpm/resolving.resolver-base@1101.1.1
92
+ - @pnpm/store.cafs@1100.2.0
93
+ - @pnpm/store.index@1100.2.5
94
+ - @pnpm/types@1102.0.0
95
+
3
96
  ## 1103.2.1
4
97
 
5
98
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  import type { GetAuthHeader } from '@pnpm/fetching.types';
2
2
  import { type ResolutionVerifier } from '@pnpm/resolving.resolver-base';
3
- import type { Registries, TrustPolicy } from '@pnpm/types';
3
+ import type { RegistriesByScope, TrustPolicy } from '@pnpm/types';
4
4
  import type { FetchMetadataFromFromRegistryOptions } from './fetch.js';
5
5
  import { type FetchFullMetadataCachedOptions } from './fetchFullMetadataCached.js';
6
6
  import type { PackageMetaCache } from './pickPackage.js';
@@ -23,12 +23,15 @@ export interface CreateNpmResolutionVerifierOptions {
23
23
  /**
24
24
  * When the registry's metadata lacks the per-version `time` field
25
25
  * (some self-hosted registries strip it), the verifier can't apply
26
- * the maturity cutoff. Set this to `true` to mirror the resolver's
27
- * `pickMatchingVersionFinal` warn-and-skip behavior — the verifier
28
- * passes the entry with a one-time `globalWarn`, instead of failing
29
- * closed. Defaults to `false` so the verifier stays stricter than
30
- * the resolver only when the user has explicitly opted in to the
31
- * skip on the resolver side.
26
+ * the maturity cutoff, and the trust check has no publish order to
27
+ * walk. Set this to `true` to mirror the resolver's warn-and-skip
28
+ * behavior for both — the verifier passes the entry with a one-time
29
+ * `globalWarn`, instead of failing closed. Defaults to `false` so
30
+ * the verifier stays stricter than the resolver only when the user
31
+ * has explicitly opted in to the skip on the resolver side. Scoped
32
+ * to a packument with no usable `time` map: one that dates every
33
+ * version it lists is saying it never published this pin, which
34
+ * fails closed either way.
32
35
  */
33
36
  ignoreMissingTimeField?: boolean;
34
37
  /**
@@ -42,14 +45,14 @@ export interface CreateNpmResolutionVerifierOptions {
42
45
  trustPolicy?: TrustPolicy;
43
46
  trustPolicyExclude?: string[];
44
47
  trustPolicyIgnoreAfter?: number;
45
- registries: Registries;
48
+ registriesByScope: RegistriesByScope;
46
49
  /**
47
- * Registries reached via the named-registry resolver chain (e.g. `gh:` →
50
+ * RegistriesByScope reached via the named-registry resolver chain (e.g. `gh:` →
48
51
  * GitHub Packages). When a lockfile entry's tarball URL falls under one of
49
52
  * these registry base URLs, route the manifest fetch there instead of the
50
53
  * scope-derived default.
51
54
  */
52
- namedRegistries?: Record<string, string>;
55
+ registriesByPrefix?: Record<string, string>;
53
56
  /**
54
57
  * Cache-aware full-metadata fetcher. Decoupled from the resolver pipeline
55
58
  * so abbreviated metadata and `peekManifestFromStore` fast paths cannot
@@ -1,17 +1,18 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { normalizeNamedRegistries } from '@pnpm/config.normalize-registries';
2
+ import { normalizeRegistriesByPrefix } from '@pnpm/config.normalize-registries';
3
3
  import { namedRegistryTarballPrefixes, pickRegistryForPackage } from '@pnpm/config.pick-registry-for-package';
4
4
  import { createPackageVersionPolicy } from '@pnpm/config.version-policy';
5
5
  import { FULL_META_DIR } from '@pnpm/constants';
6
6
  import { PnpmError } from '@pnpm/error';
7
7
  import { isGitHostedTarballUrl, } from '@pnpm/resolving.resolver-base';
8
+ import { isIntegrityAddressedRegistryTarballUrl, isValidTarballRevision, } from '@pnpm/resolving.tarball-url';
8
9
  import semver from 'semver';
9
10
  import { fetchAttestationPublishedAt } from './fetchAttestationPublishedAt.js';
10
11
  import { fetchAbbreviatedMetadataCached, fetchFullMetadataCached, } from './fetchFullMetadataCached.js';
11
12
  import { normalizeRegistryUrl } from './normalizeRegistryUrl.js';
12
13
  import { getPkgMetaCacheKey, getPkgMirrorPath, loadMeta, warnMissingTimeFieldOnce } from './pickPackage.js';
13
14
  import { failIfTrustDowngraded } from './trustChecks.js';
14
- import { MINIMUM_RELEASE_AGE_VIOLATION_CODE, MISSING_NAMED_REGISTRY_VIOLATION_CODE, MISSING_TARBALL_INTEGRITY_VIOLATION_CODE, TARBALL_URL_MISMATCH_VIOLATION_CODE, TRUST_DOWNGRADE_VIOLATION_CODE, } from './violationCodes.js';
15
+ import { MINIMUM_RELEASE_AGE_VIOLATION_CODE, MISSING_NAMED_REGISTRY_VIOLATION_CODE, MISSING_TARBALL_INTEGRITY_VIOLATION_CODE, TARBALL_REVISION_MISMATCH_VIOLATION_CODE, TARBALL_URL_MISMATCH_VIOLATION_CODE, TRUST_DOWNGRADE_VIOLATION_CODE, } from './violationCodes.js';
15
16
  /**
16
17
  * Returns a `ResolutionVerifier` for npm-registry-resolved lockfile
17
18
  * entries. It always binds each entry's recorded tarball URL to the
@@ -39,8 +40,8 @@ export function createNpmResolutionVerifier(opts) {
39
40
  const trustExcludePolicy = opts.trustPolicyExclude?.length
40
41
  ? createExcludePolicy(opts.trustPolicyExclude, 'trustPolicyExclude')
41
42
  : undefined;
42
- const mergedNamedRegistries = normalizeNamedRegistries(opts.namedRegistries);
43
- const namedRegistryPrefixes = namedRegistryTarballPrefixes(mergedNamedRegistries);
43
+ const mergedRegistriesByPrefix = normalizeRegistriesByPrefix(opts.registriesByPrefix);
44
+ const namedRegistryPrefixes = namedRegistryTarballPrefixes(mergedRegistriesByPrefix);
44
45
  // Per-install dedup of every network/disk fetch the verifier issues.
45
46
  // The maturity check uses the layered `fetchPublishedAt` lookup; the
46
47
  // trust check uses an attestation fast-path before falling back to
@@ -65,6 +66,7 @@ export function createNpmResolutionVerifier(opts) {
65
66
  const minimumReleaseAge = opts.minimumReleaseAge ?? 0;
66
67
  const trustPolicy = opts.trustPolicy;
67
68
  const trustPolicyIgnoreAfter = opts.trustPolicyIgnoreAfter;
69
+ const ignoreMissingTimeField = opts.ignoreMissingTimeField === true;
68
70
  const verify = async (resolution, { name, version, nonSemverVersion, registryName }) => {
69
71
  if (!isRegistryTarballResolution(resolution))
70
72
  return { ok: true };
@@ -105,20 +107,20 @@ export function createNpmResolutionVerifier(opts) {
105
107
  // Registry-qualified entries name their registry in the dep path, so
106
108
  // routing does not depend on a recorded tarball URL (canonical URLs
107
109
  // are omitted from the lockfile in the 12.0 format).
108
- const namedRegistry = mergedNamedRegistries[registryName];
110
+ const namedRegistry = mergedRegistriesByPrefix[registryName];
109
111
  if (!namedRegistry) {
110
112
  // Fail closed: without the registry URL, none of the metadata-backed
111
113
  // checks below can vouch for this entry.
112
114
  return {
113
115
  ok: false,
114
116
  code: MISSING_NAMED_REGISTRY_VIOLATION_CODE,
115
- reason: `was resolved from the named registry '${registryName}:', which is not present in the namedRegistries setting`,
117
+ reason: `has registry prefix '${registryName}:', which is not declared by the registries setting`,
116
118
  };
117
119
  }
118
120
  registry = namedRegistry;
119
121
  }
120
122
  else {
121
- registry = pickRegistryForVersion(opts.registries, namedRegistryPrefixes, name, tarballUrl);
123
+ registry = pickRegistryForVersion(opts.registriesByScope, namedRegistryPrefixes, name, tarballUrl);
122
124
  }
123
125
  // A registry entry that pins an explicit tarball URL must point at the
124
126
  // artifact the registry's own metadata lists. Otherwise a trusted
@@ -128,17 +130,18 @@ export function createNpmResolutionVerifier(opts) {
128
130
  // narrowed by their exclude lists, since it guards integrity rather
129
131
  // than maturity/trust. Registry entries with no tarball URL reconstruct
130
132
  // it from name+version+registry, so they're inherently bound.
131
- if (typeof tarballUrl === 'string') {
132
- const urlViolation = await runTarballUrlCheck(lookupContext, registry, name, version, tarballUrl);
133
- if (urlViolation)
134
- return urlViolation;
135
- }
133
+ const rawRevision = resolution.revision;
136
134
  const ageApplies = ageCheckActive && !isExcluded(excludePolicy, name, version);
137
135
  const trustApplies = trustCheckActive && !isExcluded(trustExcludePolicy, name, version);
136
+ if (tarballUrl != null || (rawRevision != null && (ageApplies || trustApplies))) {
137
+ const artifactViolation = await runRegistryArtifactCheck(lookupContext, registry, name, version, integrity, rawRevision, tarballUrl);
138
+ if (artifactViolation)
139
+ return artifactViolation;
140
+ }
138
141
  if (!ageApplies && !trustApplies)
139
142
  return { ok: true };
140
143
  if (ageApplies) {
141
- const ageViolation = await runAgeCheck(lookupContext, registry, name, version, cutoff, opts.ignoreMissingTimeField === true);
144
+ const ageViolation = await runAgeCheck(lookupContext, registry, name, version, cutoff, ignoreMissingTimeField);
142
145
  if (ageViolation)
143
146
  return ageViolation;
144
147
  }
@@ -146,6 +149,7 @@ export function createNpmResolutionVerifier(opts) {
146
149
  const trustViolation = await runTrustCheck(lookupContext, registry, name, version, {
147
150
  trustPolicyExclude: trustExcludePolicy,
148
151
  trustPolicyIgnoreAfter,
152
+ ignoreMissingTimeField,
149
153
  });
150
154
  if (trustViolation)
151
155
  return trustViolation;
@@ -163,9 +167,9 @@ export function createNpmResolutionVerifier(opts) {
163
167
  // stays trusted after its exclude entry has been pulled.
164
168
  const sortedMinAgeExcludes = [...new Set(opts.minimumReleaseAgeExclude ?? [])].sort();
165
169
  const sortedTrustExcludes = [...new Set(opts.trustPolicyExclude ?? [])].sort();
166
- const sortedNamedRegistries = Object.fromEntries(Object.entries(mergedNamedRegistries).sort(([aliasA], [aliasB]) => aliasA.localeCompare(aliasB)));
170
+ const sortedRegistriesByPrefix = Object.fromEntries(Object.entries(mergedRegistriesByPrefix).sort(([aliasA], [aliasB]) => aliasA.localeCompare(aliasB)));
167
171
  const namedRegistriesRouting = createHash('sha256')
168
- .update(JSON.stringify(sortedNamedRegistries))
172
+ .update(JSON.stringify(sortedRegistriesByPrefix))
169
173
  .digest('hex');
170
174
  return {
171
175
  verify,
@@ -176,6 +180,7 @@ export function createNpmResolutionVerifier(opts) {
176
180
  // applies the binding — otherwise an upgrade could keep trusting a
177
181
  // lockfile that was only ever age/trust-checked.
178
182
  tarballUrlBinding: true,
183
+ revisionHistoryBinding: true,
179
184
  // Same cache identity rule for the missing-integrity structural check.
180
185
  integrityRequired: true,
181
186
  namedRegistriesRouting,
@@ -184,12 +189,15 @@ export function createNpmResolutionVerifier(opts) {
184
189
  trustPolicy: trustPolicy ?? null,
185
190
  trustPolicyExclude: sortedTrustExcludes,
186
191
  trustPolicyIgnoreAfter: trustPolicyIgnoreAfter ?? null,
192
+ minimumReleaseAgeIgnoreMissingTime: ignoreMissingTimeField,
187
193
  },
188
194
  canTrustPastCheck: (cached) => {
189
195
  // The tarball-URL binding is unconditional today; a cached run that
190
196
  // didn't record it can't be trusted to have enforced it.
191
197
  if (cached.tarballUrlBinding !== true)
192
198
  return false;
199
+ if (cached.revisionHistoryBinding !== true)
200
+ return false;
193
201
  // The missing-integrity check is also unconditional; older cache records
194
202
  // without the flag cannot prove they rejected unverifiable tarballs.
195
203
  if (cached.integrityRequired !== true)
@@ -233,6 +241,14 @@ export function createNpmResolutionVerifier(opts) {
233
241
  const todayIgnoreAfter = trustPolicyIgnoreAfter ?? null;
234
242
  if (pastIgnoreAfter !== todayIgnoreAfter)
235
243
  return false;
244
+ // Missing-time tolerance: a cached run that failed closed on an
245
+ // absent `time` field accepted a subset of what today's tolerant
246
+ // policy accepts, so it stays trustworthy. Turning the tolerance
247
+ // off invalidates it — entries the past run waved through are the
248
+ // ones today's policy exists to reject. Older records (no field)
249
+ // read as intolerant, which is the safe direction.
250
+ if (cached.minimumReleaseAgeIgnoreMissingTime === true && !ignoreMissingTimeField)
251
+ return false;
236
252
  return true;
237
253
  },
238
254
  };
@@ -245,15 +261,23 @@ async function runAgeCheck(context, registry, name, version, cutoff, ignoreMissi
245
261
  const published = await fetchPublishedAt(context, registry, name, version);
246
262
  if (!published) {
247
263
  // No source — attestation, local mirror, or full metadata —
248
- // surfaced a publish timestamp for this version. The resolver's
249
- // pickMatchingVersionFinal honors `minimumReleaseAgeIgnoreMissingTime`
250
- // for the same shape (some self-hosted registries strip per-version
251
- // `time`); the verifier mirrors that so it can't be stricter than
252
- // fresh resolution. Without the flag we still fail closed — better
253
- // a false reject than silent bypass when the user hasn't opted in.
264
+ // surfaced a publish timestamp for this version. What
265
+ // `minimumReleaseAgeIgnoreMissingTime` opts out of is a registry that
266
+ // cannot date its releases, so the skip is granted only when the
267
+ // packument carries no usable `time` map at all the same shape the
268
+ // resolver's `pickMatchingVersionFinal` warns and skips on, so the
269
+ // verifier can't be stricter than fresh resolution. A packument that
270
+ // does date every version it lists is instead telling us this pin is
271
+ // not one of them (`dropIncompletePublishTimes` leaves no partial maps
272
+ // for that to be ambiguous), and an unpublished or never-published pin
273
+ // must fail closed however the flag is set.
254
274
  if (ignoreMissingTimeField) {
255
- warnMissingTimeFieldOnce(name);
256
- return undefined;
275
+ // Already awaited by the lookup above, so this is a cache hit.
276
+ const timeMap = await fetchFullMetaTime(context, registry, name);
277
+ if (timeMap == null) {
278
+ warnMissingTimeFieldOnce(name, 'minimumReleaseAge');
279
+ return undefined;
280
+ }
257
281
  }
258
282
  return {
259
283
  ok: false,
@@ -290,7 +314,7 @@ async function runAgeCheck(context, registry, name, version, cutoff, ignoreMissi
290
314
  * a tampered lockfile could smuggle a malicious URL past the check by
291
315
  * pointing it at a `name@version` the registry can't vouch for.
292
316
  */
293
- async function runTarballUrlCheck(context, registry, name, version, lockfileTarball) {
317
+ async function runRegistryArtifactCheck(context, registry, name, version, lockfileIntegrity, rawRevision, lockfileTarball) {
294
318
  const { meta, error } = await fetchAbbreviatedMeta(context, registry, name);
295
319
  if (error != null) {
296
320
  // Couldn't reach the registry to verify (auth/network/5xx). Propagate the
@@ -300,25 +324,114 @@ async function runTarballUrlCheck(context, registry, name, version, lockfileTarb
300
324
  // error — still fail-closed, the entry never reaches the filesystem.
301
325
  throw error;
302
326
  }
303
- const registryTarball = meta?.versionTarballs?.get(version);
304
- if (registryTarball != null && sameTarballUrl(lockfileTarball, registryTarball)) {
305
- return undefined;
327
+ const artifact = meta?.versionArtifacts?.get(version);
328
+ if (artifact == null) {
329
+ if (lockfileTarball == null && rawRevision == null)
330
+ return undefined;
331
+ return {
332
+ ok: false,
333
+ code: lockfileTarball == null
334
+ ? TARBALL_REVISION_MISMATCH_VIOLATION_CODE
335
+ : TARBALL_URL_MISMATCH_VIOLATION_CODE,
336
+ reason: "could not be verified against the registry's published metadata",
337
+ };
306
338
  }
307
- return {
308
- ok: false,
309
- code: TARBALL_URL_MISMATCH_VIOLATION_CODE,
310
- reason: registryTarball == null
311
- ? "could not be verified against the registry's published metadata"
312
- : `has a tarball URL (${lockfileTarball}) that does not match the registry's published metadata (${registryTarball})`,
313
- };
339
+ const metadataRevision = artifact.current.revision;
340
+ const revisionAware = rawRevision != null || metadataRevision !== undefined || artifact.revisions.length > 0;
341
+ if (!revisionAware) {
342
+ if (lockfileTarball == null)
343
+ return undefined;
344
+ if (typeof artifact.current.tarball === 'string' && sameTarballUrl(lockfileTarball, artifact.current.tarball)) {
345
+ return undefined;
346
+ }
347
+ return {
348
+ ok: false,
349
+ code: TARBALL_URL_MISMATCH_VIOLATION_CODE,
350
+ reason: typeof artifact.current.tarball !== 'string'
351
+ ? "could not be verified against the registry's published metadata"
352
+ : `has a tarball URL (${lockfileTarball}) that does not match the registry's published metadata (${artifact.current.tarball})`,
353
+ };
354
+ }
355
+ if (rawRevision != null && !isValidTarballRevision(rawRevision)) {
356
+ return {
357
+ ok: false,
358
+ code: TARBALL_REVISION_MISMATCH_VIOLATION_CODE,
359
+ reason: `has an invalid revision (${String(rawRevision)})`,
360
+ };
361
+ }
362
+ let currentRevision = 0;
363
+ if (metadataRevision !== undefined) {
364
+ if (!isValidTarballRevision(metadataRevision)) {
365
+ return {
366
+ ok: false,
367
+ code: TARBALL_REVISION_MISMATCH_VIOLATION_CODE,
368
+ reason: `registry metadata has an invalid current revision (${String(metadataRevision)})`,
369
+ };
370
+ }
371
+ currentRevision = metadataRevision;
372
+ const currentHistory = artifact.revisions.filter(candidate => candidate.revision === currentRevision);
373
+ if (currentHistory.length !== 1 ||
374
+ currentHistory[0].integrity !== artifact.current.integrity ||
375
+ typeof currentHistory[0].tarball !== 'string' ||
376
+ typeof artifact.current.tarball !== 'string' ||
377
+ typeof artifact.current.integrity !== 'string' ||
378
+ !isIntegrityAddressedRegistryTarballUrl(normalizeRegistryUrl(artifact.current.tarball), artifact.current.integrity, registry) ||
379
+ !sameTarballUrl(currentHistory[0].tarball, artifact.current.tarball)) {
380
+ return {
381
+ ok: false,
382
+ code: TARBALL_REVISION_MISMATCH_VIOLATION_CODE,
383
+ reason: `registry metadata revision ${currentRevision} does not have exactly one matching history entry`,
384
+ };
385
+ }
386
+ }
387
+ const revision = typeof rawRevision === 'number' ? rawRevision : 0;
388
+ const currentMatches = currentRevision === revision;
389
+ const historicalCandidates = artifact.revisions.filter(candidate => candidate.revision === revision);
390
+ if (historicalCandidates.length > 1) {
391
+ return {
392
+ ok: false,
393
+ code: TARBALL_REVISION_MISMATCH_VIOLATION_CODE,
394
+ reason: `revision ${revision} is advertised more than once in the registry's history`,
395
+ };
396
+ }
397
+ const historical = historicalCandidates[0];
398
+ const selected = currentMatches ? artifact.current : historical;
399
+ if (selected == null ||
400
+ selected.integrity !== lockfileIntegrity ||
401
+ (currentMatches && historical != null && historical.integrity !== lockfileIntegrity)) {
402
+ return {
403
+ ok: false,
404
+ code: TARBALL_REVISION_MISMATCH_VIOLATION_CODE,
405
+ reason: `has revision ${revision} with an integrity that does not match the registry's current or historical metadata`,
406
+ };
407
+ }
408
+ if (revision > 0 || !currentMatches) {
409
+ if (typeof selected.tarball !== 'string' ||
410
+ !isIntegrityAddressedRegistryTarballUrl(normalizeRegistryUrl(selected.tarball), lockfileIntegrity, registry)) {
411
+ return {
412
+ ok: false,
413
+ code: TARBALL_REVISION_MISMATCH_VIOLATION_CODE,
414
+ reason: `has revision ${revision} that is not addressed by its complete sha512 integrity`,
415
+ };
416
+ }
417
+ }
418
+ if (lockfileTarball != null && (typeof selected.tarball !== 'string' || !sameTarballUrl(lockfileTarball, selected.tarball))) {
419
+ return {
420
+ ok: false,
421
+ code: TARBALL_URL_MISMATCH_VIOLATION_CODE,
422
+ reason: typeof selected.tarball !== 'string'
423
+ ? "could not be verified against the registry's published metadata"
424
+ : `has a tarball URL (${lockfileTarball}) that does not match the registry's published metadata (${selected.tarball})`,
425
+ };
426
+ }
427
+ return undefined;
314
428
  }
315
429
  function sameTarballUrl(a, b) {
316
430
  return canonicalTarballUrl(a) === canonicalTarballUrl(b);
317
431
  }
318
- // Mirror the tolerance toLockfileResolution applies when it decides whether
319
- // a tarball URL is "the expected one": ignore the protocol and `%2f` scope
320
- // encoding so a benign http/https or encoding difference isn't read as
321
- // tampering. The `%2f` match is case-insensitive because `normalizeRegistryUrl`
432
+ // Both URLs come from the registry, so ignore the protocol and `%2f` scope
433
+ // encoding: a benign http/https or encoding difference isn't tampering. The
434
+ // `%2f` match is case-insensitive because `normalizeRegistryUrl`
322
435
  // (`new URL().toString()`) can upper-case percent-escapes to `%2F`.
323
436
  function canonicalTarballUrl(url) {
324
437
  const normalized = normalizeRegistryUrl(url).replace(/%2f/gi, '/');
@@ -531,7 +644,7 @@ async function tryAbbreviatedModifiedShortcut(context, registry, name, version)
531
644
  // publish time — but only for versions the registry currently lists.
532
645
  // An unpublished or never-published pin would otherwise pass the gate
533
646
  // on a stale package-level timestamp.
534
- if (!meta?.versionTarballs?.has(version))
647
+ if (!meta?.versionArtifacts?.has(version))
535
648
  return undefined;
536
649
  return modified;
537
650
  }
@@ -615,16 +728,29 @@ function validateSharedMeta(meta, name) {
615
728
  // lockfile (see #11860). The full document is GC-able as soon as this
616
729
  // closure returns; only the short tarball-URL strings are retained.
617
730
  function projectAbbreviatedMeta(meta) {
618
- let versionTarballs;
731
+ let versionArtifacts;
619
732
  if (meta.versions) {
620
- versionTarballs = new Map();
733
+ versionArtifacts = new Map();
621
734
  for (const [version, manifest] of Object.entries(meta.versions)) {
622
- versionTarballs.set(version, manifest.dist?.tarball);
735
+ versionArtifacts.set(version, {
736
+ current: {
737
+ revision: manifest.dist?.revision,
738
+ integrity: manifest.dist?.integrity,
739
+ tarball: manifest.dist?.tarball,
740
+ },
741
+ revisions: Array.isArray(manifest.dist?.revisions)
742
+ ? manifest.dist.revisions.map(revision => ({
743
+ revision: revision.revision,
744
+ integrity: revision.integrity,
745
+ tarball: revision.tarball,
746
+ }))
747
+ : [],
748
+ });
623
749
  }
624
750
  }
625
751
  return {
626
752
  modified: meta.modified,
627
- versionTarballs,
753
+ versionArtifacts,
628
754
  };
629
755
  }
630
756
  function readLocalMetaTime(context, registry, name) {
@@ -657,7 +783,7 @@ function fetchFullMetaTime(context, registry, name) {
657
783
  }
658
784
  return cachedPromise;
659
785
  }
660
- function pickRegistryForVersion(registries, namedRegistryPrefixes, name, tarballUrl) {
786
+ function pickRegistryForVersion(registriesByScope, namedRegistryPrefixes, name, tarballUrl) {
661
787
  // If the lockfile records where the tarball lives, prefer that — scope
662
788
  // routing (`@scope:registry`) only covers scoped packages, but named
663
789
  // registries (`gh:`, `jsr:` aliases, custom) ship un-scoped packages whose
@@ -674,7 +800,7 @@ function pickRegistryForVersion(registries, namedRegistryPrefixes, name, tarball
674
800
  return prefix;
675
801
  }
676
802
  }
677
- return pickRegistryForPackage(registries, name);
803
+ return pickRegistryForPackage(registriesByScope, name);
678
804
  }
679
805
  function tryParseUrl(url) {
680
806
  try {
package/lib/fetch.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import url from 'node:url';
2
2
  import util from 'node:util';
3
3
  import { requestRetryLogger } from '@pnpm/core-loggers';
4
- import { FetchError, PnpmError, redactUrlCredentials, } from '@pnpm/error';
4
+ import { FetchError, PnpmError, redactUrlCredentials, redactUrlForDisplay, } from '@pnpm/error';
5
5
  import { globalWarn } from '@pnpm/logger';
6
6
  import * as retry from '@zkochan/retry';
7
7
  import semver from 'semver';
8
8
  import { clearMeta } from './clearMeta.js';
9
+ import { dropIncompletePublishTimes } from './publishTimes.js';
9
10
  /**
10
11
  * Content type of an abbreviated (install-oriented) package metadata document.
11
12
  * A spec-compliant registry echoes this in the response `Content-Type` when it
@@ -147,10 +148,11 @@ export async function fetchMetadataFromFromRegistry(fetchOpts, pkgName, { authHe
147
148
  try {
148
149
  const jsonText = await response.text();
149
150
  const meta = JSON.parse(jsonText);
151
+ dropIncompletePublishTimes(meta);
150
152
  // Check if request took longer than expected
151
153
  const elapsedMs = Date.now() - startTime;
152
154
  if (elapsedMs > fetchOpts.fetchWarnTimeoutMs) {
153
- globalWarn(`Request took ${elapsedMs}ms: ${uri}`);
155
+ globalWarn(`Request took ${elapsedMs}ms: ${redactUrlForDisplay(uri)}`);
154
156
  }
155
157
  resolve({
156
158
  ...normalizeAbbreviatedResponse({ fullMetadata, meta, jsonText, response }),
@@ -200,7 +202,7 @@ export function notModifiedWithoutCacheError(pkgName) {
200
202
  * carry the megabytes of install-irrelevant data (scripts, exports, readme,
201
203
  * custom fields) that a full document contains.
202
204
  *
203
- * Registries that honor the header (e.g. the npm registry) echo the abbreviated
205
+ * RegistriesByScope that honor the header (e.g. the npm registry) echo the abbreviated
204
206
  * `Content-Type`, so this is a no-op for them: no re-serialization, no field
205
207
  * stripping — the happy path pays nothing.
206
208
  */
package/lib/index.d.ts CHANGED
@@ -2,9 +2,9 @@ import { PnpmError } from '@pnpm/error';
2
2
  import type { FetchFromRegistry, GetAuthHeader, RetryTimeoutOptions } from '@pnpm/fetching.types';
3
3
  import type { PackageMeta } from '@pnpm/resolving.registry.types';
4
4
  import type { DirectoryResolution, LatestInfo, LatestQuery, PkgResolutionId, PreferredVersions, ResolveOptions, ResolveResult, TarballResolution, WantedDependency, WorkspacePackages } from '@pnpm/resolving.resolver-base';
5
- import type { DependencyManifest, PackageVersionPolicy, RangeSpecStyle, Registries, TrustPolicy } from '@pnpm/types';
5
+ import type { DependencyManifest, PackageVersionPolicy, RangeSpecStyle, RegistriesByScope, TrustPolicy } from '@pnpm/types';
6
6
  import { fetchMetadataFromFromRegistry, type FetchMetadataFromFromRegistryOptions, RegistryResponseError } from './fetch.js';
7
- import { BUILTIN_NAMED_REGISTRIES, parseBareSpecifier, type RegistryPackageSpec } from './parseBareSpecifier.js';
7
+ import { BUILTIN_REGISTRIES_BY_PREFIX, parseBareSpecifier, type RegistryPackageSpec } from './parseBareSpecifier.js';
8
8
  import { type PackageMetaCache, pickPackage, type PickPackageOptions } from './pickPackage.js';
9
9
  import { pickPackageFromMeta, pickVersionByVersionRange } from './pickPackageFromMeta.js';
10
10
  import { workspacePrefToNpm } from './workspacePrefToNpm.js';
@@ -18,7 +18,7 @@ export declare class NoMatchingVersionError extends PnpmError {
18
18
  constructor(opts: NoMatchingVersionErrorOptions);
19
19
  }
20
20
  export declare function formatTimeAgo(date: Date): string | null;
21
- export { BUILTIN_NAMED_REGISTRIES, fetchMetadataFromFromRegistry, type FetchMetadataFromFromRegistryOptions, type PackageMeta, type PackageMetaCache, parseBareSpecifier, pickPackageFromMeta, pickVersionByVersionRange, type RegistryPackageSpec, RegistryResponseError, workspacePrefToNpm, };
21
+ export { BUILTIN_REGISTRIES_BY_PREFIX, fetchMetadataFromFromRegistry, type FetchMetadataFromFromRegistryOptions, type PackageMeta, type PackageMetaCache, parseBareSpecifier, pickPackageFromMeta, pickVersionByVersionRange, type RegistryPackageSpec, RegistryResponseError, workspacePrefToNpm, };
22
22
  export { createNpmResolutionVerifier, type CreateNpmResolutionVerifierOptions } from './createNpmResolutionVerifier.js';
23
23
  export { MINIMUM_RELEASE_AGE_VIOLATION_CODE, TRUST_DOWNGRADE_VIOLATION_CODE, } from './violationCodes.js';
24
24
  export interface ResolverFactoryOptions {
@@ -26,13 +26,20 @@ export interface ResolverFactoryOptions {
26
26
  storeDir?: string;
27
27
  frozenStore?: boolean;
28
28
  fullMetadata?: boolean;
29
+ /**
30
+ * Asked instead of {@link ResolverFactoryOptions.fullMetadata} when the
31
+ * caller can answer per registry — a registry that declares
32
+ * `supportsTimeField` needs no full metadata for a time-based resolution
33
+ * even when the others do.
34
+ */
35
+ needsFullMetadataFor?: (registry: string) => boolean;
29
36
  filterMetadata?: boolean;
30
37
  offline?: boolean;
31
38
  preferOffline?: boolean;
32
39
  retry?: RetryTimeoutOptions;
33
40
  timeout?: number;
34
- registries: Registries;
35
- namedRegistries?: Record<string, string>;
41
+ registriesByScope: RegistriesByScope;
42
+ registriesByPrefix?: Record<string, string>;
36
43
  saveWorkspaceProtocol?: boolean | 'rolling';
37
44
  preserveAbsolutePaths?: boolean;
38
45
  ignoreMissingTimeField?: boolean;
@@ -83,10 +90,16 @@ export declare function createNpmResolver(fetchFromRegistry: FetchFromRegistry,
83
90
  export interface ResolveFromNpmContext {
84
91
  pickPackage: (spec: RegistryPackageSpec, opts: PickPackageOptions) => ReturnType<typeof pickPackage>;
85
92
  getAuthHeaderValueByURI: GetAuthHeader;
86
- registries: Registries;
87
- namedRegistries: Record<string, string>;
93
+ registriesByScope: RegistriesByScope;
94
+ registriesByPrefix: Record<string, string>;
88
95
  namedRegistryNames: ReadonlySet<string>;
89
96
  saveWorkspaceProtocol?: boolean | 'rolling';
97
+ /**
98
+ * The `minimumReleaseAgeIgnoreMissingTime` opt-in, reaching the trust
99
+ * check as well as the version pick: both read the same per-version
100
+ * `time`, so a registry that strips it takes both down together.
101
+ */
102
+ ignoreMissingTimeField?: boolean;
90
103
  peekManifestFromStore?: (opts: {
91
104
  id: PkgResolutionId;
92
105
  integrity: string;
@@ -110,6 +123,7 @@ export type ResolveFromNpmOptions = {
110
123
  preferredVersions?: PreferredVersions;
111
124
  preferWorkspacePackages?: boolean;
112
125
  update?: false | 'compatible' | 'latest';
126
+ updatePatches?: boolean;
113
127
  updateRequested?: boolean;
114
128
  updateChecksums?: boolean;
115
129
  injectWorkspacePackages?: boolean;