@pnpm/resolving.npm-resolver 1102.1.2 → 1102.1.4

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.
@@ -1,724 +0,0 @@
1
- import { pickRegistryForPackage } from '@pnpm/config.pick-registry-for-package';
2
- import { createPackageVersionPolicy } from '@pnpm/config.version-policy';
3
- import { FULL_META_DIR } from '@pnpm/constants';
4
- import { PnpmError } from '@pnpm/error';
5
- import { isGitHostedTarballUrl, } from '@pnpm/resolving.resolver-base';
6
- import semver from 'semver';
7
- import { fetchAttestationPublishedAt } from './fetchAttestationPublishedAt.js';
8
- import { fetchAbbreviatedMetadataCached, fetchFullMetadataCached, } from './fetchFullMetadataCached.js';
9
- import { normalizeRegistryUrl } from './normalizeRegistryUrl.js';
10
- import { BUILTIN_NAMED_REGISTRIES } from './parseBareSpecifier.js';
11
- import { getPkgMetaCacheKey, getPkgMirrorPath, loadMeta, warnMissingTimeFieldOnce } from './pickPackage.js';
12
- import { failIfTrustDowngraded } from './trustChecks.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';
14
- /**
15
- * Returns a `ResolutionVerifier` for npm-registry-resolved lockfile
16
- * entries. It always binds each entry's recorded tarball URL to the
17
- * artifact the registry's metadata lists (an anti-tamper check that does
18
- * not depend on any policy), and additionally re-applies the
19
- * `minimumReleaseAge` and/or `trustPolicy='no-downgrade'` policies when
20
- * those are configured. Pairs with `createNpmResolver`: each resolver
21
- * factory may export a sibling verifier factory that the default-resolver
22
- * combines.
23
- *
24
- * Designed for fail-closed semantics: if the manifest can't be loaded or
25
- * the pinned version is missing from it, the verifier reports a violation
26
- * rather than silently passing. Mirrors the post-resolution gate bun added
27
- * for the same shape of bug in oven-sh/bun#30526.
28
- */
29
- export function createNpmResolutionVerifier(opts) {
30
- const ageCheckActive = Boolean(opts.minimumReleaseAge);
31
- const trustCheckActive = opts.trustPolicy === 'no-downgrade';
32
- const cutoff = ageCheckActive
33
- ? (opts.now ?? Date.now()) - opts.minimumReleaseAge * 60 * 1000
34
- : 0;
35
- const excludePolicy = opts.minimumReleaseAgeExclude?.length
36
- ? createExcludePolicy(opts.minimumReleaseAgeExclude, 'minimumReleaseAgeExclude')
37
- : undefined;
38
- const trustExcludePolicy = opts.trustPolicyExclude?.length
39
- ? createExcludePolicy(opts.trustPolicyExclude, 'trustPolicyExclude')
40
- : undefined;
41
- // Pre-normalize named-registry URLs and sort by length so two registries
42
- // that share a hostname but differ by path (e.g. `https://npm/team-a/` vs
43
- // `https://npm/team-b/`) route to the longest matching prefix — matching
44
- // only `origin` would silently send lookups to the wrong one. Built-in
45
- // aliases (`gh:` → npm.pkg.github.com, etc.) are merged in alongside the
46
- // user-defined ones so the verifier recognizes the same set of named
47
- // registries the resolver does; otherwise a package resolved via `gh:`
48
- // would land in the lockfile with a tarball URL the verifier can't route.
49
- const namedRegistryPrefixes = Object.values({
50
- ...BUILTIN_NAMED_REGISTRIES,
51
- ...(opts.namedRegistries ?? {}),
52
- })
53
- .map((url) => {
54
- const parsed = tryParseUrl(url);
55
- if (!parsed)
56
- return null;
57
- // Ensure trailing slash so prefix matching against tarball URLs (which
58
- // always include the package path under the registry root) does not
59
- // accidentally match a sibling registry whose URL shares a prefix string.
60
- const pathname = parsed.pathname.endsWith('/') ? parsed.pathname : `${parsed.pathname}/`;
61
- return `${parsed.origin}${pathname}`;
62
- })
63
- .filter((value) => value != null)
64
- .sort((a, b) => b.length - a.length);
65
- // Per-install dedup of every network/disk fetch the verifier issues.
66
- // The maturity check uses the layered `fetchPublishedAt` lookup; the
67
- // trust check uses an attestation fast-path before falling back to
68
- // the same full-metadata mirror. All maps live here so verifying
69
- // many versions of the same package only pays the disk/network costs
70
- // once. The on-disk conditional-GET cache is handled inside
71
- // fetch{Abbreviated,Full}MetadataCached via the resolver's shared
72
- // mirrors at opts.cacheDir.
73
- const lookupContext = {
74
- fetchOpts: opts.fetchOpts,
75
- getAuthHeaderValueByURI: opts.getAuthHeaderValueByURI,
76
- cacheDir: opts.cacheDir,
77
- cutoffMs: cutoff,
78
- sharedMetaCache: opts.metaCache,
79
- abbreviatedMetaCache: new Map(),
80
- publishedAtCache: new Map(),
81
- localMetaCache: new Map(),
82
- fullMetaCache: new Map(),
83
- fullMetaForTrustCache: new Map(),
84
- };
85
- const minimumReleaseAge = opts.minimumReleaseAge ?? 0;
86
- const trustPolicy = opts.trustPolicy;
87
- const trustPolicyIgnoreAfter = opts.trustPolicyIgnoreAfter;
88
- const verify = async (resolution, { name, version, nonSemverVersion }) => {
89
- if (!isRegistryTarballResolution(resolution))
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
- }
100
- // URL/git-keyed entries are deliberate non-registry deps. They can still
101
- // carry a semver `version` copied from the resolved manifest, so the
102
- // semver guard below isn't enough on its own — the registry policies and
103
- // the tarball-URL binding don't apply to them, and a registry lookup
104
- // would 404.
105
- if (nonSemverVersion != null)
106
- return { ok: true };
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;
123
- const registry = pickRegistryForVersion(opts.registries, namedRegistryPrefixes, name, tarballUrl);
124
- // A registry entry that pins an explicit tarball URL must point at the
125
- // artifact the registry's own metadata lists. Otherwise a trusted
126
- // `name@version` could front bytes from an attacker-chosen URL (with a
127
- // matching integrity for those bytes). This binding is unconditional —
128
- // it does not depend on `minimumReleaseAge`/`trustPolicy` and isn't
129
- // narrowed by their exclude lists, since it guards integrity rather
130
- // than maturity/trust. Registry entries with no tarball URL reconstruct
131
- // it from name+version+registry, so they're inherently bound.
132
- if (typeof tarballUrl === 'string') {
133
- const urlViolation = await runTarballUrlCheck(lookupContext, registry, name, version, tarballUrl);
134
- if (urlViolation)
135
- return urlViolation;
136
- }
137
- const ageApplies = ageCheckActive && !isExcluded(excludePolicy, name, version);
138
- const trustApplies = trustCheckActive && !isExcluded(trustExcludePolicy, name, version);
139
- if (!ageApplies && !trustApplies)
140
- return { ok: true };
141
- if (ageApplies) {
142
- const ageViolation = await runAgeCheck(lookupContext, registry, name, version, cutoff, opts.ignoreMissingTimeField === true);
143
- if (ageViolation)
144
- return ageViolation;
145
- }
146
- if (trustApplies) {
147
- const trustViolation = await runTrustCheck(lookupContext, registry, name, version, {
148
- trustPolicyExclude: trustExcludePolicy,
149
- trustPolicyIgnoreAfter,
150
- });
151
- if (trustViolation)
152
- return trustViolation;
153
- }
154
- return { ok: true };
155
- };
156
- // Snapshot the exclude lists (sorted, deduped) and require an exact
157
- // match in `canTrustPastCheck`: cache identity == policy identity.
158
- // Any change to either exclude list — adding, removing, or
159
- // substituting an entry — invalidates the cached run. This is
160
- // stricter than a pure correctness check would require (adding to
161
- // either list is more permissive and the cached pass would still
162
- // hold), but it makes the cache contract trivial to reason about and
163
- // removes a class of bypasses where a previously-approved version
164
- // stays trusted after its exclude entry has been pulled.
165
- const sortedMinAgeExcludes = [...new Set(opts.minimumReleaseAgeExclude ?? [])].sort();
166
- const sortedTrustExcludes = [...new Set(opts.trustPolicyExclude ?? [])].sort();
167
- return {
168
- verify,
169
- policy: {
170
- // Marks runs that enforced the tarball-URL binding. A cache record
171
- // written before this rule existed lacks the flag, so
172
- // `canTrustPastCheck` rejects it and forces a re-verification that
173
- // applies the binding — otherwise an upgrade could keep trusting a
174
- // lockfile that was only ever age/trust-checked.
175
- tarballUrlBinding: true,
176
- // Same cache identity rule for the missing-integrity structural check.
177
- integrityRequired: true,
178
- minimumReleaseAge,
179
- minimumReleaseAgeExclude: sortedMinAgeExcludes,
180
- trustPolicy: trustPolicy ?? null,
181
- trustPolicyExclude: sortedTrustExcludes,
182
- trustPolicyIgnoreAfter: trustPolicyIgnoreAfter ?? null,
183
- },
184
- canTrustPastCheck: (cached) => {
185
- // The tarball-URL binding is unconditional today; a cached run that
186
- // didn't record it can't be trusted to have enforced it.
187
- if (cached.tarballUrlBinding !== true)
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;
193
- // Maturity: a previously cached run under a larger cutoff
194
- // (stricter window) is trustworthy under a smaller current one —
195
- // its set of accepted versions is a subset of today's. The
196
- // reverse — tightening the cutoff — invalidates the cached run:
197
- // versions that passed before may now be in-window. Non-number
198
- // cached values come from an older record shape and aren't trusted.
199
- const past = cached.minimumReleaseAge;
200
- const pastNumber = typeof past === 'number' ? past : 0;
201
- if (pastNumber < minimumReleaseAge)
202
- return false;
203
- // Excludes: today's sorted-deduped lists must match the cached
204
- // ones byte for byte. Older records (no field) fall back to an
205
- // empty array, so they only trust today's empty policy.
206
- const pastMinAgeExcludes = Array.isArray(cached.minimumReleaseAgeExclude)
207
- ? cached.minimumReleaseAgeExclude
208
- : [];
209
- if (JSON.stringify(pastMinAgeExcludes) !== JSON.stringify(sortedMinAgeExcludes))
210
- return false;
211
- // Trust policy: any change to `trustPolicy`, the exclude list, or
212
- // the ignore-after cutoff invalidates the cached run. Older
213
- // records (no trust field at all) treat the trust policy as
214
- // absent and are only trusted under an unset-today policy.
215
- const pastTrustPolicy = cached.trustPolicy ?? null;
216
- const todayTrustPolicy = trustPolicy ?? null;
217
- if (pastTrustPolicy !== todayTrustPolicy)
218
- return false;
219
- const pastTrustExcludes = Array.isArray(cached.trustPolicyExclude)
220
- ? cached.trustPolicyExclude
221
- : [];
222
- if (JSON.stringify(pastTrustExcludes) !== JSON.stringify(sortedTrustExcludes))
223
- return false;
224
- const pastIgnoreAfter = typeof cached.trustPolicyIgnoreAfter === 'number'
225
- ? cached.trustPolicyIgnoreAfter
226
- : null;
227
- const todayIgnoreAfter = trustPolicyIgnoreAfter ?? null;
228
- if (pastIgnoreAfter !== todayIgnoreAfter)
229
- return false;
230
- return true;
231
- },
232
- };
233
- }
234
- async function runAgeCheck(context, registry, name, version, cutoff, ignoreMissingTimeField) {
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);
240
- if (!published) {
241
- // No source — attestation, local mirror, or full metadata —
242
- // surfaced a publish timestamp for this version. The resolver's
243
- // pickMatchingVersionFinal honors `minimumReleaseAgeIgnoreMissingTime`
244
- // for the same shape (some self-hosted registries strip per-version
245
- // `time`); the verifier mirrors that so it can't be stricter than
246
- // fresh resolution. Without the flag we still fail closed — better
247
- // a false reject than silent bypass when the user hasn't opted in.
248
- if (ignoreMissingTimeField) {
249
- warnMissingTimeFieldOnce(name);
250
- return undefined;
251
- }
252
- return {
253
- ok: false,
254
- code: MINIMUM_RELEASE_AGE_VIOLATION_CODE,
255
- reason: uncheckable('minimumReleaseAge', 'version not present in registry manifest'),
256
- };
257
- }
258
- const publishedAt = new Date(published);
259
- const ts = publishedAt.getTime();
260
- if (Number.isNaN(ts)) {
261
- return {
262
- ok: false,
263
- code: MINIMUM_RELEASE_AGE_VIOLATION_CODE,
264
- reason: 'publish timestamp is not a valid date',
265
- };
266
- }
267
- if (ts > cutoff) {
268
- return {
269
- ok: false,
270
- code: MINIMUM_RELEASE_AGE_VIOLATION_CODE,
271
- reason: `was published at ${publishedAt.toISOString()}, within the minimumReleaseAge cutoff (${new Date(cutoff).toISOString()})`,
272
- };
273
- }
274
- return undefined;
275
- }
276
- /**
277
- * Confirm the lockfile-pinned tarball URL is the artifact the registry's
278
- * own metadata lists for this exact `name@version`.
279
- *
280
- * Fail-closed: the entry passes only when the registry metadata
281
- * affirmatively lists this version with a matching tarball URL. If the
282
- * metadata can't be fetched, doesn't list the version, or omits
283
- * `dist.tarball`, the entry can't be confirmed and is rejected — otherwise
284
- * a tampered lockfile could smuggle a malicious URL past the check by
285
- * pointing it at a `name@version` the registry can't vouch for.
286
- */
287
- async function runTarballUrlCheck(context, registry, name, version, lockfileTarball) {
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
- }
297
- const registryTarball = meta?.versionTarballs?.get(version);
298
- if (registryTarball != null && sameTarballUrl(lockfileTarball, registryTarball)) {
299
- return undefined;
300
- }
301
- return {
302
- ok: false,
303
- code: TARBALL_URL_MISMATCH_VIOLATION_CODE,
304
- reason: registryTarball == null
305
- ? "could not be verified against the registry's published metadata"
306
- : `has a tarball URL (${lockfileTarball}) that does not match the registry's published metadata (${registryTarball})`,
307
- };
308
- }
309
- function sameTarballUrl(a, b) {
310
- return canonicalTarballUrl(a) === canonicalTarballUrl(b);
311
- }
312
- // Mirror the tolerance toLockfileResolution applies when it decides whether
313
- // a tarball URL is "the expected one": ignore the protocol and `%2f` scope
314
- // encoding so a benign http/https or encoding difference isn't read as
315
- // tampering. The `%2f` match is case-insensitive because `normalizeRegistryUrl`
316
- // (`new URL().toString()`) can upper-case percent-escapes to `%2F`.
317
- function canonicalTarballUrl(url) {
318
- const normalized = normalizeRegistryUrl(url).replace(/%2f/gi, '/');
319
- const schemeEnd = normalized.indexOf('://');
320
- return schemeEnd === -1 ? normalized : normalized.slice(schemeEnd + 3);
321
- }
322
- /**
323
- * Run the resolver-time `failIfTrustDowngraded` check against the
324
- * pinned lockfile version. The packument is fetched through a
325
- * per-install cache so multiple versions of the same package share
326
- * one fetch.
327
- *
328
- * No attestation fast-path here even though the per-version
329
- * attestation endpoint is cheaper than the packument: presence of
330
- * provenance on the current version is not sufficient to clear a
331
- * downgrade. A package could have shipped earlier versions under a
332
- * `trustedPublisher` with provenance (the higher-rank evidence) and
333
- * then dropped back to plain provenance for the version we're verifying —
334
- * `failIfTrustDowngraded` correctly flags that, and a "has any
335
- * attestation → pass" shortcut would silently miss it.
336
- */
337
- async function runTrustCheck(context, registry, name, version, opts) {
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);
343
- try {
344
- failIfTrustDowngraded(meta, version, opts);
345
- }
346
- catch (err) {
347
- return {
348
- ok: false,
349
- code: TRUST_DOWNGRADE_VIOLATION_CODE,
350
- reason: err instanceof Error ? err.message : String(err),
351
- };
352
- }
353
- return undefined;
354
- }
355
- function fetchFullMetaForTrust(context, registry, name) {
356
- const cacheKey = `${registry}\x00${name}`;
357
- let cachedPromise = context.fullMetaForTrustCache.get(cacheKey);
358
- if (cachedPromise == null) {
359
- // Fast path: if the resolver already upgraded to full meta for this
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);
367
- if (shared != null) {
368
- cachedPromise = Promise.resolve(projectTrustMeta(shared));
369
- }
370
- else {
371
- // Don't swallow the fetch rejection here — `runTrustCheck` catches it
372
- // and surfaces the underlying message in the violation reason, which
373
- // is more actionable than the generic "metadata is unavailable" the
374
- // `!meta` fallback emits. The cache still holds the rejected promise
375
- // so repeat verifier calls for the same (registry, name) within one
376
- // install don't refetch a known-failing endpoint.
377
- //
378
- // The fetched packument is projected down to just the trust-relevant
379
- // fields (per-version `_npmUser.trustedPublisher` and
380
- // `dist.attestations.provenance`, plus the package-level `time` map)
381
- // before being stored. The full document — dependency maps, scripts,
382
- // READMEs for every version — would otherwise stay resident in this
383
- // map for the entire install, which on multi-thousand-entry
384
- // workspaces OOMs CI runners with a 2GB heap (see #11860).
385
- cachedPromise = fetchFullMetadataCached(context.fetchOpts, name, {
386
- registry,
387
- authHeaderValue: context.getAuthHeaderValueByURI(registry, { pkgName: name }),
388
- cacheDir: context.cacheDir,
389
- }).then(projectTrustMeta);
390
- }
391
- context.fullMetaForTrustCache.set(cacheKey, cachedPromise);
392
- }
393
- return cachedPromise;
394
- }
395
- // Project the full packument to a minimal `PackageMeta`-shaped view
396
- // that exposes only the fields `failIfTrustDowngraded` reads:
397
- // • `name` and `modified` for error messages and cache keys
398
- // • `time` for the per-version publish-date walk
399
- // • `versions[v]._npmUser.trustedPublisher`
400
- // • `versions[v].dist.attestations.provenance`
401
- // The shape is still a valid `PackageMeta` so the downstream consumer
402
- // doesn't have to special-case it — only the bulk fields (dependency
403
- // graph, scripts, README, etc.) are dropped.
404
- function projectTrustMeta(meta) {
405
- const versions = {};
406
- for (const [version, manifest] of Object.entries(meta.versions ?? {})) {
407
- versions[version] = projectTrustManifest(manifest);
408
- }
409
- return {
410
- name: meta.name,
411
- 'dist-tags': {},
412
- versions,
413
- time: meta.time,
414
- modified: meta.modified,
415
- etag: meta.etag,
416
- };
417
- }
418
- function projectTrustManifest(manifest) {
419
- // Drop everything except the trust-evidence fields. `PackageInRegistry.dist`
420
- // is typed as requiring `shasum` and `tarball`, but the trust check never
421
- // reads them; cast away the unsoundness so callers see the same nominal
422
- // shape without the per-version dependency graph / scripts / README bulk
423
- // carrying through. `_npmUser` is similarly narrowed to just
424
- // `trustedPublisher` and `approver` — the only sub-fields the trust check
425
- // inspects — so we don't keep maintainer name/email PII resident in the
426
- // cache.
427
- const approver = manifest._npmUser?.approver;
428
- const trustedPublisher = manifest._npmUser?.trustedPublisher;
429
- const provenance = manifest.dist?.attestations?.provenance;
430
- let npmUser = undefined;
431
- if (approver) {
432
- npmUser ||= {};
433
- npmUser.approver = {};
434
- }
435
- if (trustedPublisher) {
436
- npmUser ||= {};
437
- npmUser.trustedPublisher = trustedPublisher;
438
- }
439
- return {
440
- _npmUser: npmUser,
441
- dist: provenance != null
442
- ? { attestations: { provenance } }
443
- : undefined,
444
- };
445
- }
446
- /**
447
- * Per-(registry, name, version) lookup with a layered fallback:
448
- *
449
- * 1. **Abbreviated metadata `modified` shortcut.** This is what the
450
- * resolver already fetches by default; it's a small document with
451
- * a package-level last-modified time but no per-version timestamps.
452
- * If `modified` is older than the policy cutoff, every version in
453
- * this package was published at least that long ago — return the
454
- * `modified` timestamp as a conservative upper bound and skip the
455
- * rest of the chain. Costs one conditional GET that the resolver
456
- * has usually already paid for.
457
- * 2. **On-disk full-metadata mirror.** If a previous verification
458
- * populated `FULL_META_DIR`, take the per-version timestamp from
459
- * there.
460
- * 3. **npm attestation endpoint.** Small payload, just this version's
461
- * Sigstore-anchored timestamp. Wins on cold cache when the package
462
- * was published with provenance.
463
- * 4. **Full metadata fetch.** Last resort — only paid when the
464
- * abbreviated shortcut can't decide, the local full mirror is
465
- * cold, and there's no attestation.
466
- */
467
- async function fetchPublishedAt(context, registry, name, version) {
468
- const cacheKey = `${registry}\x00${name}\x00${version}`;
469
- let cachedPromise = context.publishedAtCache.get(cacheKey);
470
- if (cachedPromise == null) {
471
- cachedPromise = resolvePublishedAt(context, registry, name, version);
472
- context.publishedAtCache.set(cacheKey, cachedPromise);
473
- }
474
- return cachedPromise;
475
- }
476
- async function resolvePublishedAt(context, registry, name, version) {
477
- const abbreviatedShortcut = await tryAbbreviatedModifiedShortcut(context, registry, name, version);
478
- if (abbreviatedShortcut != null)
479
- return abbreviatedShortcut;
480
- const localTime = await readLocalMetaTime(context, registry, name);
481
- if (localTime?.[version])
482
- return localTime[version];
483
- const attestationTime = await fetchAttestationPublishedAt(context.fetchOpts, name, version, {
484
- registry,
485
- authHeaderValue: context.getAuthHeaderValueByURI(registry, { pkgName: name }),
486
- });
487
- if (attestationTime != null)
488
- return attestationTime;
489
- const fullMetaTime = await fetchFullMetaTime(context, registry, name);
490
- return fullMetaTime?.[version];
491
- }
492
- /**
493
- * Returns the abbreviated metadata's `modified` timestamp **iff** it
494
- * proves the gate would pass — i.e. modified is strictly older than
495
- * the policy cutoff *and* the pinned version still exists in the
496
- * package's current versions map.
497
- *
498
- * The version check is the fail-closed contract: an unpublished or
499
- * never-published version must not slip through on the package-level
500
- * `modified` timestamp. When the version is missing here we fall
501
- * through to the later layers so the caller eventually surfaces the
502
- * "version not present in registry manifest" violation.
503
- *
504
- * Returns `undefined` otherwise (modified is too recent, the metadata
505
- * lacks a parseable modified field, the version isn't in the abbreviated
506
- * form, or the fetch failed) and the caller proceeds with per-version
507
- * lookups.
508
- */
509
- async function tryAbbreviatedModifiedShortcut(context, registry, name, version) {
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);
513
- const modified = meta?.modified;
514
- if (typeof modified !== 'string')
515
- return undefined;
516
- const modifiedMs = Date.parse(modified);
517
- if (Number.isNaN(modifiedMs))
518
- return undefined;
519
- if (modifiedMs >= context.cutoffMs)
520
- return undefined;
521
- // The shortcut treats `modified` as an upper bound on every version's
522
- // publish time — but only for versions the registry currently lists.
523
- // An unpublished or never-published pin would otherwise pass the gate
524
- // on a stale package-level timestamp.
525
- if (!meta?.versionTarballs?.has(version))
526
- return undefined;
527
- return modified;
528
- }
529
- function fetchAbbreviatedMeta(context, registry, name) {
530
- const cacheKey = `${registry}\x00${name}`;
531
- let cachedPromise = context.abbreviatedMetaCache.get(cacheKey);
532
- if (cachedPromise == null) {
533
- // Fast path: the resolver's per-install LRU already holds this
534
- // packument from its own pickPackage pass — abbreviated or full.
535
- // Project it for the shortcut and skip the disk/network round-trip.
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);
539
- if (shared != null) {
540
- cachedPromise = Promise.resolve({ meta: projectAbbreviatedMeta(shared) });
541
- }
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.
549
- cachedPromise = fetchAbbreviatedMetadataCached(context.fetchOpts, name, {
550
- registry,
551
- authHeaderValue: context.getAuthHeaderValueByURI(registry, { pkgName: name }),
552
- cacheDir: context.cacheDir,
553
- }).then((meta) => ({ meta: projectAbbreviatedMeta(meta) }), (error) => ({ error }));
554
- }
555
- context.abbreviatedMetaCache.set(cacheKey, cachedPromise);
556
- }
557
- return cachedPromise;
558
- }
559
- function readSharedMeta(cache, registry, name) {
560
- if (cache == null)
561
- return undefined;
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
566
- // the abbreviated form.
567
- return readSharedFullMeta(cache, registry, name) ??
568
- validateSharedMeta(cache.get(getPkgMetaCacheKey(registry, name, false, false)), name);
569
- }
570
- function readSharedMetaForTrust(cache, registry, name) {
571
- if (cache == null)
572
- return undefined;
573
- // Abbreviated meta is rejected for the trust check — it lacks
574
- // per-version `time` and per-version trust evidence.
575
- return readSharedFullMeta(cache, registry, name);
576
- }
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
589
- // returns of a different package (cache corruption, factory misuse)
590
- // rather than silently feeding wrong data to the trust / age check.
591
- function validateSharedMeta(meta, name) {
592
- if (meta == null)
593
- return undefined;
594
- if (meta.name !== name)
595
- return undefined;
596
- return meta;
597
- }
598
- // Project the abbreviated packument down to the few fields the verifier
599
- // actually reads — package-level `modified`, plus a per-version map of
600
- // `dist.tarball` (whose keys double as the version-existence set for the
601
- // `tryAbbreviatedModifiedShortcut` check and the tarball-URL binding). The
602
- // resolver populates the abbreviated mirror with every version's
603
- // dependency / engine / dist info, which can run to hundreds of KB per
604
- // package and accumulate to many GB across a multi-thousand-entry
605
- // lockfile (see #11860). The full document is GC-able as soon as this
606
- // closure returns; only the short tarball-URL strings are retained.
607
- function projectAbbreviatedMeta(meta) {
608
- let versionTarballs;
609
- if (meta.versions) {
610
- versionTarballs = new Map();
611
- for (const [version, manifest] of Object.entries(meta.versions)) {
612
- versionTarballs.set(version, manifest.dist?.tarball);
613
- }
614
- }
615
- return {
616
- modified: meta.modified,
617
- versionTarballs,
618
- };
619
- }
620
- function readLocalMetaTime(context, registry, name) {
621
- if (!context.cacheDir)
622
- return Promise.resolve(undefined);
623
- const cacheKey = `${registry}\x00${name}`;
624
- let cachedPromise = context.localMetaCache.get(cacheKey);
625
- if (cachedPromise == null) {
626
- cachedPromise = loadLocalMetaTime(context.cacheDir, registry, name);
627
- context.localMetaCache.set(cacheKey, cachedPromise);
628
- }
629
- return cachedPromise;
630
- }
631
- async function loadLocalMetaTime(cacheDir, registry, name) {
632
- const pkgMirror = getPkgMirrorPath(cacheDir, FULL_META_DIR, registry, name);
633
- const cached = await loadMeta(pkgMirror);
634
- return cached?.time;
635
- }
636
- function fetchFullMetaTime(context, registry, name) {
637
- const cacheKey = `${registry}\x00${name}`;
638
- let cachedPromise = context.fullMetaCache.get(cacheKey);
639
- if (cachedPromise == null) {
640
- cachedPromise = fetchFullMetadataCached(context.fetchOpts, name, {
641
- registry,
642
- authHeaderValue: context.getAuthHeaderValueByURI(registry, { pkgName: name }),
643
- cacheDir: context.cacheDir,
644
- }).then((meta) => meta.time);
645
- context.fullMetaCache.set(cacheKey, cachedPromise);
646
- }
647
- return cachedPromise;
648
- }
649
- function pickRegistryForVersion(registries, namedRegistryPrefixes, name, tarballUrl) {
650
- // If the lockfile records where the tarball lives, prefer that — scope
651
- // routing (`@scope:registry`) only covers scoped packages, but named
652
- // registries (`gh:`, `jsr:` aliases, custom) ship un-scoped packages whose
653
- // origin we'd otherwise miss. Match the longest prefix so that two named
654
- // registries sharing a host but differing by path don't collide.
655
- if (tarballUrl) {
656
- // Match on the same canonical form the tarball comparison uses, so a
657
- // named-registry tarball that differs from the configured base only by
658
- // scheme or `%2f` encoding still routes to its registry instead of
659
- // falling back (and then failing closed against the wrong packument).
660
- const normalized = canonicalTarballUrl(tarballUrl);
661
- for (const prefix of namedRegistryPrefixes) {
662
- if (normalized.startsWith(canonicalTarballUrl(prefix)))
663
- return prefix;
664
- }
665
- }
666
- return pickRegistryForPackage(registries, name);
667
- }
668
- function tryParseUrl(url) {
669
- try {
670
- return new URL(url);
671
- }
672
- catch {
673
- return null;
674
- }
675
- }
676
- function uncheckable(policy, why) {
677
- return `could not be checked against ${policy} (${why})`;
678
- }
679
- function createExcludePolicy(patterns, key) {
680
- // Mirror the wrapping done by the full-resolution path
681
- // (installing/deps-resolver/src/resolveDependencyTree.ts) so the error
682
- // code is identical regardless of which path surfaced the invalid pattern.
683
- try {
684
- return createPackageVersionPolicy(patterns);
685
- }
686
- catch (err) {
687
- if (!err || typeof err !== 'object' || !('message' in err))
688
- throw err;
689
- throw new PnpmError(`INVALID_${key.replace(/([A-Z])/g, '_$1').toUpperCase()}`, `Invalid value in ${key}: ${err.message}`);
690
- }
691
- }
692
- function isExcluded(policy, name, version) {
693
- if (!policy)
694
- return false;
695
- const result = policy(name);
696
- if (result === true)
697
- return true;
698
- if (Array.isArray(result) && result.includes(version))
699
- return true;
700
- return false;
701
- }
702
- function isRegistryTarballResolution(resolution) {
703
- if (resolution == null || typeof resolution !== 'object')
704
- return false;
705
- // Only plain tarball resolutions (npm registry / named registries) have no
706
- // `type` field. Git / directory / binary / custom resolutions all carry one.
707
- if ('type' in resolution && resolution.type != null)
708
- return false;
709
- const tarball = resolution.tarball;
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;
715
- // Local/non-registry tarballs (for example `file:`) have no packument
716
- // metadata, so minimumReleaseAge/trustPolicy verification cannot apply.
717
- const protocol = tryParseUrl(tarball)?.protocol;
718
- if (protocol != null && protocol !== 'http:' && protocol !== 'https:')
719
- return false;
720
- }
721
- // Canonical registry entries may omit both `tarball` and `integrity`.
722
- return true;
723
- }
724
- //# sourceMappingURL=createNpmResolutionVerifier.js.map