@pnpm/resolving.npm-resolver 1101.1.1 → 1101.3.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.
@@ -0,0 +1,74 @@
1
+ import type { ResolutionVerifier } from '@pnpm/resolving.resolver-base';
2
+ import type { Registries, TrustPolicy } from '@pnpm/types';
3
+ import type { FetchMetadataFromFromRegistryOptions } from './fetch.js';
4
+ import { type FetchFullMetadataCachedOptions } from './fetchFullMetadataCached.js';
5
+ export interface CreateNpmResolutionVerifierOptions {
6
+ /**
7
+ * Minimum age (in minutes) a published version must reach before it is
8
+ * accepted. When unset, the verifier is a no-op for the age check.
9
+ */
10
+ minimumReleaseAge?: number;
11
+ /**
12
+ * Retained on the options bag because the resolver path branches on it
13
+ * (the lowest-version fallback) and tests forward both fields together.
14
+ * The verifier itself no longer gates on this flag — once the loose-mode
15
+ * auto-collect makes every accepted-immature pin explicit in
16
+ * `minimumReleaseAgeExclude`, running the verifier in loose mode is the
17
+ * thing that proves the manifest stays in sync with the lockfile.
18
+ */
19
+ minimumReleaseAgeStrict?: boolean;
20
+ minimumReleaseAgeExclude?: string[];
21
+ /**
22
+ * When the registry's metadata lacks the per-version `time` field
23
+ * (some self-hosted registries strip it), the verifier can't apply
24
+ * the maturity cutoff. Set this to `true` to mirror the resolver's
25
+ * `pickMatchingVersionFinal` warn-and-skip behavior — the verifier
26
+ * passes the entry with a one-time `globalWarn`, instead of failing
27
+ * closed. Defaults to `false` so the verifier stays stricter than
28
+ * the resolver only when the user has explicitly opted in to the
29
+ * skip on the resolver side.
30
+ */
31
+ ignoreMissingTimeField?: boolean;
32
+ /**
33
+ * `'no-downgrade'` rejects a lockfile entry whose version has weaker
34
+ * trust evidence (no attestations) than an earlier-published version
35
+ * had. This mirrors the resolver-time `failIfTrustDowngraded` check
36
+ * applied during fresh resolution — the verifier catches the same
37
+ * supply-chain signal on entries that bypassed resolution (peek-path,
38
+ * frozen lockfile, etc.).
39
+ */
40
+ trustPolicy?: TrustPolicy;
41
+ trustPolicyExclude?: string[];
42
+ trustPolicyIgnoreAfter?: number;
43
+ registries: Registries;
44
+ /**
45
+ * Registries reached via the named-registry resolver chain (e.g. `gh:` →
46
+ * GitHub Packages). When a lockfile entry's tarball URL falls under one of
47
+ * these registry base URLs, route the manifest fetch there instead of the
48
+ * scope-derived default.
49
+ */
50
+ namedRegistries?: Record<string, string>;
51
+ /**
52
+ * Cache-aware full-metadata fetcher. Decoupled from the resolver pipeline
53
+ * so abbreviated metadata and `peekManifestFromStore` fast paths cannot
54
+ * hide the publish timestamp.
55
+ */
56
+ fetchOpts: FetchMetadataFromFromRegistryOptions;
57
+ getAuthHeaderValueByURI: (registry: string) => string | undefined;
58
+ cacheDir?: FetchFullMetadataCachedOptions['cacheDir'];
59
+ /** Overrides Date.now() for tests. */
60
+ now?: number;
61
+ }
62
+ /**
63
+ * Returns a `ResolutionVerifier` that re-applies the `minimumReleaseAge`
64
+ * and/or `trustPolicy='no-downgrade'` policies to npm-registry-resolved
65
+ * lockfile entries, or `undefined` when no policy is active. Pairs with
66
+ * `createNpmResolver`: each resolver factory may export a sibling
67
+ * verifier factory that the default-resolver combines.
68
+ *
69
+ * Designed for fail-closed semantics: if the manifest can't be loaded or
70
+ * the pinned version is missing from it, the verifier reports a violation
71
+ * rather than silently passing. Mirrors the post-resolution gate bun added
72
+ * for the same shape of bug in oven-sh/bun#30526.
73
+ */
74
+ export declare function createNpmResolutionVerifier(opts: CreateNpmResolutionVerifierOptions): ResolutionVerifier | undefined;
@@ -0,0 +1,473 @@
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 semver from 'semver';
6
+ import { fetchAttestationPublishedAt } from './fetchAttestationPublishedAt.js';
7
+ import { fetchAbbreviatedMetadataCached, fetchFullMetadataCached, } from './fetchFullMetadataCached.js';
8
+ import { BUILTIN_NAMED_REGISTRIES } from './parseBareSpecifier.js';
9
+ import { getPkgMirrorPath, loadMeta, warnMissingTimeFieldOnce } from './pickPackage.js';
10
+ import { failIfTrustDowngraded } from './trustChecks.js';
11
+ import { MINIMUM_RELEASE_AGE_VIOLATION_CODE, TRUST_DOWNGRADE_VIOLATION_CODE, } from './violationCodes.js';
12
+ /**
13
+ * Returns a `ResolutionVerifier` that re-applies the `minimumReleaseAge`
14
+ * and/or `trustPolicy='no-downgrade'` policies to npm-registry-resolved
15
+ * lockfile entries, or `undefined` when no policy is active. Pairs with
16
+ * `createNpmResolver`: each resolver factory may export a sibling
17
+ * verifier factory that the default-resolver combines.
18
+ *
19
+ * Designed for fail-closed semantics: if the manifest can't be loaded or
20
+ * the pinned version is missing from it, the verifier reports a violation
21
+ * rather than silently passing. Mirrors the post-resolution gate bun added
22
+ * for the same shape of bug in oven-sh/bun#30526.
23
+ */
24
+ export function createNpmResolutionVerifier(opts) {
25
+ const ageCheckActive = Boolean(opts.minimumReleaseAge);
26
+ const trustCheckActive = opts.trustPolicy === 'no-downgrade';
27
+ // No policy → no verifier. Skipping early keeps the install-side fan-out
28
+ // empty when nothing is configured.
29
+ if (!ageCheckActive && !trustCheckActive)
30
+ return undefined;
31
+ const cutoff = ageCheckActive
32
+ ? (opts.now ?? Date.now()) - opts.minimumReleaseAge * 60 * 1000
33
+ : 0;
34
+ const excludePolicy = opts.minimumReleaseAgeExclude?.length
35
+ ? createExcludePolicy(opts.minimumReleaseAgeExclude, 'minimumReleaseAgeExclude')
36
+ : undefined;
37
+ const trustExcludePolicy = opts.trustPolicyExclude?.length
38
+ ? createExcludePolicy(opts.trustPolicyExclude, 'trustPolicyExclude')
39
+ : undefined;
40
+ // Pre-normalize named-registry URLs and sort by length so two registries
41
+ // that share a hostname but differ by path (e.g. `https://npm/team-a/` vs
42
+ // `https://npm/team-b/`) route to the longest matching prefix — matching
43
+ // only `origin` would silently send lookups to the wrong one. Built-in
44
+ // aliases (`gh:` → npm.pkg.github.com, etc.) are merged in alongside the
45
+ // user-defined ones so the verifier recognizes the same set of named
46
+ // registries the resolver does; otherwise a package resolved via `gh:`
47
+ // would land in the lockfile with a tarball URL the verifier can't route.
48
+ const namedRegistryPrefixes = Object.values({
49
+ ...BUILTIN_NAMED_REGISTRIES,
50
+ ...(opts.namedRegistries ?? {}),
51
+ })
52
+ .map((url) => {
53
+ const parsed = tryParseUrl(url);
54
+ if (!parsed)
55
+ return null;
56
+ // Ensure trailing slash so prefix matching against tarball URLs (which
57
+ // always include the package path under the registry root) does not
58
+ // accidentally match a sibling registry whose URL shares a prefix string.
59
+ const pathname = parsed.pathname.endsWith('/') ? parsed.pathname : `${parsed.pathname}/`;
60
+ return `${parsed.origin}${pathname}`;
61
+ })
62
+ .filter((value) => value != null)
63
+ .sort((a, b) => b.length - a.length);
64
+ // Per-install dedup of every network/disk fetch the verifier issues.
65
+ // The maturity check uses the layered `fetchPublishedAt` lookup; the
66
+ // trust check uses an attestation fast-path before falling back to
67
+ // the same full-metadata mirror. All maps live here so verifying
68
+ // many versions of the same package only pays the disk/network costs
69
+ // once. The on-disk conditional-GET cache is handled inside
70
+ // fetch{Abbreviated,Full}MetadataCached via the resolver's shared
71
+ // mirrors at opts.cacheDir.
72
+ const lookupContext = {
73
+ fetchOpts: opts.fetchOpts,
74
+ getAuthHeaderValueByURI: opts.getAuthHeaderValueByURI,
75
+ cacheDir: opts.cacheDir,
76
+ cutoffMs: cutoff,
77
+ abbreviatedMetaCache: new Map(),
78
+ publishedAtCache: new Map(),
79
+ localMetaCache: new Map(),
80
+ fullMetaCache: new Map(),
81
+ fullMetaForTrustCache: new Map(),
82
+ };
83
+ const minimumReleaseAge = opts.minimumReleaseAge ?? 0;
84
+ const trustPolicy = opts.trustPolicy;
85
+ const trustPolicyIgnoreAfter = opts.trustPolicyIgnoreAfter;
86
+ const verify = async (resolution, { name, version }) => {
87
+ if (!isNpmRegistryResolution(resolution))
88
+ return { ok: true };
89
+ // Non-semver versions identify URL tarballs, file: refs, git refs, etc.
90
+ // Neither the age nor the trust policy applies, and a registry lookup
91
+ // would 404.
92
+ if (!semver.valid(version))
93
+ return { ok: true };
94
+ const ageApplies = ageCheckActive && !isExcluded(excludePolicy, name, version);
95
+ const trustApplies = trustCheckActive && !isExcluded(trustExcludePolicy, name, version);
96
+ if (!ageApplies && !trustApplies)
97
+ return { ok: true };
98
+ const tarballUrl = resolution.tarball;
99
+ const registry = pickRegistryForVersion(opts.registries, namedRegistryPrefixes, name, tarballUrl);
100
+ if (ageApplies) {
101
+ const ageViolation = await runAgeCheck(lookupContext, registry, name, version, cutoff, opts.ignoreMissingTimeField === true);
102
+ if (ageViolation)
103
+ return ageViolation;
104
+ }
105
+ if (trustApplies) {
106
+ const trustViolation = await runTrustCheck(lookupContext, registry, name, version, {
107
+ trustPolicyExclude: trustExcludePolicy,
108
+ trustPolicyIgnoreAfter,
109
+ });
110
+ if (trustViolation)
111
+ return trustViolation;
112
+ }
113
+ return { ok: true };
114
+ };
115
+ // Snapshot the exclude lists (sorted, deduped) and require an exact
116
+ // match in `canTrustPastCheck`: cache identity == policy identity.
117
+ // Any change to either exclude list — adding, removing, or
118
+ // substituting an entry — invalidates the cached run. This is
119
+ // stricter than a pure correctness check would require (adding to
120
+ // either list is more permissive and the cached pass would still
121
+ // hold), but it makes the cache contract trivial to reason about and
122
+ // removes a class of bypasses where a previously-approved version
123
+ // stays trusted after its exclude entry has been pulled.
124
+ const sortedMinAgeExcludes = [...new Set(opts.minimumReleaseAgeExclude ?? [])].sort();
125
+ const sortedTrustExcludes = [...new Set(opts.trustPolicyExclude ?? [])].sort();
126
+ return {
127
+ verify,
128
+ policy: {
129
+ minimumReleaseAge,
130
+ minimumReleaseAgeExclude: sortedMinAgeExcludes,
131
+ trustPolicy: trustPolicy ?? null,
132
+ trustPolicyExclude: sortedTrustExcludes,
133
+ trustPolicyIgnoreAfter: trustPolicyIgnoreAfter ?? null,
134
+ },
135
+ canTrustPastCheck: (cached) => {
136
+ // Maturity: a previously cached run under a larger cutoff
137
+ // (stricter window) is trustworthy under a smaller current one —
138
+ // its set of accepted versions is a subset of today's. The
139
+ // reverse — tightening the cutoff — invalidates the cached run:
140
+ // versions that passed before may now be in-window. Non-number
141
+ // cached values come from an older record shape and aren't trusted.
142
+ const past = cached.minimumReleaseAge;
143
+ const pastNumber = typeof past === 'number' ? past : 0;
144
+ if (pastNumber < minimumReleaseAge)
145
+ return false;
146
+ // Excludes: today's sorted-deduped lists must match the cached
147
+ // ones byte for byte. Older records (no field) fall back to an
148
+ // empty array, so they only trust today's empty policy.
149
+ const pastMinAgeExcludes = Array.isArray(cached.minimumReleaseAgeExclude)
150
+ ? cached.minimumReleaseAgeExclude
151
+ : [];
152
+ if (JSON.stringify(pastMinAgeExcludes) !== JSON.stringify(sortedMinAgeExcludes))
153
+ return false;
154
+ // Trust policy: any change to `trustPolicy`, the exclude list, or
155
+ // the ignore-after cutoff invalidates the cached run. Older
156
+ // records (no trust field at all) treat the trust policy as
157
+ // absent and are only trusted under an unset-today policy.
158
+ const pastTrustPolicy = cached.trustPolicy ?? null;
159
+ const todayTrustPolicy = trustPolicy ?? null;
160
+ if (pastTrustPolicy !== todayTrustPolicy)
161
+ return false;
162
+ const pastTrustExcludes = Array.isArray(cached.trustPolicyExclude)
163
+ ? cached.trustPolicyExclude
164
+ : [];
165
+ if (JSON.stringify(pastTrustExcludes) !== JSON.stringify(sortedTrustExcludes))
166
+ return false;
167
+ const pastIgnoreAfter = typeof cached.trustPolicyIgnoreAfter === 'number'
168
+ ? cached.trustPolicyIgnoreAfter
169
+ : null;
170
+ const todayIgnoreAfter = trustPolicyIgnoreAfter ?? null;
171
+ if (pastIgnoreAfter !== todayIgnoreAfter)
172
+ return false;
173
+ return true;
174
+ },
175
+ };
176
+ }
177
+ async function runAgeCheck(context, registry, name, version, cutoff, ignoreMissingTimeField) {
178
+ let published;
179
+ try {
180
+ published = await fetchPublishedAt(context, registry, name, version);
181
+ }
182
+ catch (err) {
183
+ return {
184
+ ok: false,
185
+ code: MINIMUM_RELEASE_AGE_VIOLATION_CODE,
186
+ reason: uncheckable('minimumReleaseAge', err instanceof Error ? err.message : String(err)),
187
+ };
188
+ }
189
+ if (!published) {
190
+ // No source — attestation, local mirror, or full metadata —
191
+ // surfaced a publish timestamp for this version. The resolver's
192
+ // pickMatchingVersionFinal honors `minimumReleaseAgeIgnoreMissingTime`
193
+ // for the same shape (some self-hosted registries strip per-version
194
+ // `time`); the verifier mirrors that so it can't be stricter than
195
+ // fresh resolution. Without the flag we still fail closed — better
196
+ // a false reject than silent bypass when the user hasn't opted in.
197
+ if (ignoreMissingTimeField) {
198
+ warnMissingTimeFieldOnce(name);
199
+ return undefined;
200
+ }
201
+ return {
202
+ ok: false,
203
+ code: MINIMUM_RELEASE_AGE_VIOLATION_CODE,
204
+ reason: uncheckable('minimumReleaseAge', 'version not present in registry manifest'),
205
+ };
206
+ }
207
+ const publishedAt = new Date(published);
208
+ const ts = publishedAt.getTime();
209
+ if (Number.isNaN(ts)) {
210
+ return {
211
+ ok: false,
212
+ code: MINIMUM_RELEASE_AGE_VIOLATION_CODE,
213
+ reason: 'publish timestamp is not a valid date',
214
+ };
215
+ }
216
+ if (ts > cutoff) {
217
+ return {
218
+ ok: false,
219
+ code: MINIMUM_RELEASE_AGE_VIOLATION_CODE,
220
+ reason: `was published at ${publishedAt.toISOString()}, within the minimumReleaseAge cutoff (${new Date(cutoff).toISOString()})`,
221
+ };
222
+ }
223
+ return undefined;
224
+ }
225
+ /**
226
+ * Run the resolver-time `failIfTrustDowngraded` check against the
227
+ * pinned lockfile version. The packument is fetched through a
228
+ * per-install cache so multiple versions of the same package share
229
+ * one fetch.
230
+ *
231
+ * No attestation fast-path here even though the per-version
232
+ * attestation endpoint is cheaper than the packument: presence of
233
+ * provenance on the current version is not sufficient to clear a
234
+ * downgrade. A package could have shipped earlier versions under a
235
+ * `trustedPublisher` (the higher-rank evidence) and then dropped
236
+ * back to plain provenance for the version we're verifying —
237
+ * `failIfTrustDowngraded` correctly flags that, and a "has any
238
+ * attestation → pass" shortcut would silently miss it.
239
+ */
240
+ async function runTrustCheck(context, registry, name, version, opts) {
241
+ let meta;
242
+ try {
243
+ meta = await fetchFullMetaForTrust(context, registry, name);
244
+ }
245
+ catch (err) {
246
+ // `fetchFullMetadataCached` rejects (network error, 404, etc.); the
247
+ // verifier fails closed so a missing manifest can't be mistaken
248
+ // for a passing trust check.
249
+ return {
250
+ ok: false,
251
+ code: TRUST_DOWNGRADE_VIOLATION_CODE,
252
+ reason: uncheckable('trustPolicy', err instanceof Error ? err.message : String(err)),
253
+ };
254
+ }
255
+ try {
256
+ failIfTrustDowngraded(meta, version, opts);
257
+ }
258
+ catch (err) {
259
+ return {
260
+ ok: false,
261
+ code: TRUST_DOWNGRADE_VIOLATION_CODE,
262
+ reason: err instanceof Error ? err.message : String(err),
263
+ };
264
+ }
265
+ return undefined;
266
+ }
267
+ function fetchFullMetaForTrust(context, registry, name) {
268
+ const cacheKey = `${registry}\x00${name}`;
269
+ let cachedPromise = context.fullMetaForTrustCache.get(cacheKey);
270
+ if (cachedPromise == null) {
271
+ // Don't swallow the fetch rejection here — `runTrustCheck` catches it
272
+ // and surfaces the underlying message in the violation reason, which
273
+ // is more actionable than the generic "metadata is unavailable" the
274
+ // `!meta` fallback emits. The cache still holds the rejected promise
275
+ // so repeat verifier calls for the same (registry, name) within one
276
+ // install don't refetch a known-failing endpoint.
277
+ cachedPromise = fetchFullMetadataCached(context.fetchOpts, name, {
278
+ registry,
279
+ authHeaderValue: context.getAuthHeaderValueByURI(registry),
280
+ cacheDir: context.cacheDir,
281
+ });
282
+ context.fullMetaForTrustCache.set(cacheKey, cachedPromise);
283
+ }
284
+ return cachedPromise;
285
+ }
286
+ /**
287
+ * Per-(registry, name, version) lookup with a layered fallback:
288
+ *
289
+ * 1. **Abbreviated metadata `modified` shortcut.** This is what the
290
+ * resolver already fetches by default; it's a small document with
291
+ * a package-level last-modified time but no per-version timestamps.
292
+ * If `modified` is older than the policy cutoff, every version in
293
+ * this package was published at least that long ago — return the
294
+ * `modified` timestamp as a conservative upper bound and skip the
295
+ * rest of the chain. Costs one conditional GET that the resolver
296
+ * has usually already paid for.
297
+ * 2. **On-disk full-metadata mirror.** If a previous verification
298
+ * populated `FULL_META_DIR`, take the per-version timestamp from
299
+ * there.
300
+ * 3. **npm attestation endpoint.** Small payload, just this version's
301
+ * Sigstore-anchored timestamp. Wins on cold cache when the package
302
+ * was published with provenance.
303
+ * 4. **Full metadata fetch.** Last resort — only paid when the
304
+ * abbreviated shortcut can't decide, the local full mirror is
305
+ * cold, and there's no attestation.
306
+ */
307
+ async function fetchPublishedAt(context, registry, name, version) {
308
+ const cacheKey = `${registry}\x00${name}\x00${version}`;
309
+ let cachedPromise = context.publishedAtCache.get(cacheKey);
310
+ if (cachedPromise == null) {
311
+ cachedPromise = resolvePublishedAt(context, registry, name, version);
312
+ context.publishedAtCache.set(cacheKey, cachedPromise);
313
+ }
314
+ return cachedPromise;
315
+ }
316
+ async function resolvePublishedAt(context, registry, name, version) {
317
+ const abbreviatedShortcut = await tryAbbreviatedModifiedShortcut(context, registry, name, version);
318
+ if (abbreviatedShortcut != null)
319
+ return abbreviatedShortcut;
320
+ const localTime = await readLocalMetaTime(context, registry, name);
321
+ if (localTime?.[version])
322
+ return localTime[version];
323
+ const attestationTime = await fetchAttestationPublishedAt(context.fetchOpts, name, version, {
324
+ registry,
325
+ authHeaderValue: context.getAuthHeaderValueByURI(registry),
326
+ });
327
+ if (attestationTime != null)
328
+ return attestationTime;
329
+ const fullMetaTime = await fetchFullMetaTime(context, registry, name);
330
+ return fullMetaTime?.[version];
331
+ }
332
+ /**
333
+ * Returns the abbreviated metadata's `modified` timestamp **iff** it
334
+ * proves the gate would pass — i.e. modified is strictly older than
335
+ * the policy cutoff *and* the pinned version still exists in the
336
+ * package's current versions map.
337
+ *
338
+ * The version check is the fail-closed contract: an unpublished or
339
+ * never-published version must not slip through on the package-level
340
+ * `modified` timestamp. When the version is missing here we fall
341
+ * through to the later layers so the caller eventually surfaces the
342
+ * "version not present in registry manifest" violation.
343
+ *
344
+ * Returns `undefined` otherwise (modified is too recent, the metadata
345
+ * lacks a parseable modified field, the version isn't in the abbreviated
346
+ * form, or the fetch failed) and the caller proceeds with per-version
347
+ * lookups.
348
+ */
349
+ async function tryAbbreviatedModifiedShortcut(context, registry, name, version) {
350
+ const meta = await fetchAbbreviatedMeta(context, registry, name);
351
+ const modified = meta?.modified;
352
+ if (typeof modified !== 'string')
353
+ return undefined;
354
+ const modifiedMs = Date.parse(modified);
355
+ if (Number.isNaN(modifiedMs))
356
+ return undefined;
357
+ if (modifiedMs >= context.cutoffMs)
358
+ return undefined;
359
+ // The shortcut treats `modified` as an upper bound on every version's
360
+ // publish time — but only for versions the registry currently lists.
361
+ // An unpublished or never-published pin would otherwise pass the gate
362
+ // on a stale package-level timestamp.
363
+ if (!meta?.versions || !(version in meta.versions))
364
+ return undefined;
365
+ return modified;
366
+ }
367
+ function fetchAbbreviatedMeta(context, registry, name) {
368
+ const cacheKey = `${registry}\x00${name}`;
369
+ let cachedPromise = context.abbreviatedMetaCache.get(cacheKey);
370
+ if (cachedPromise == null) {
371
+ cachedPromise = fetchAbbreviatedMetadataCached(context.fetchOpts, name, {
372
+ registry,
373
+ authHeaderValue: context.getAuthHeaderValueByURI(registry),
374
+ cacheDir: context.cacheDir,
375
+ }).catch(() => undefined);
376
+ context.abbreviatedMetaCache.set(cacheKey, cachedPromise);
377
+ }
378
+ return cachedPromise;
379
+ }
380
+ function readLocalMetaTime(context, registry, name) {
381
+ if (!context.cacheDir)
382
+ return Promise.resolve(undefined);
383
+ const cacheKey = `${registry}\x00${name}`;
384
+ let cachedPromise = context.localMetaCache.get(cacheKey);
385
+ if (cachedPromise == null) {
386
+ cachedPromise = loadLocalMetaTime(context.cacheDir, registry, name);
387
+ context.localMetaCache.set(cacheKey, cachedPromise);
388
+ }
389
+ return cachedPromise;
390
+ }
391
+ async function loadLocalMetaTime(cacheDir, registry, name) {
392
+ const pkgMirror = getPkgMirrorPath(cacheDir, FULL_META_DIR, registry, name);
393
+ const cached = await loadMeta(pkgMirror);
394
+ return cached?.time;
395
+ }
396
+ function fetchFullMetaTime(context, registry, name) {
397
+ const cacheKey = `${registry}\x00${name}`;
398
+ let cachedPromise = context.fullMetaCache.get(cacheKey);
399
+ if (cachedPromise == null) {
400
+ cachedPromise = fetchFullMetadataCached(context.fetchOpts, name, {
401
+ registry,
402
+ authHeaderValue: context.getAuthHeaderValueByURI(registry),
403
+ cacheDir: context.cacheDir,
404
+ }).then((meta) => meta.time);
405
+ context.fullMetaCache.set(cacheKey, cachedPromise);
406
+ }
407
+ return cachedPromise;
408
+ }
409
+ function pickRegistryForVersion(registries, namedRegistryPrefixes, name, tarballUrl) {
410
+ // If the lockfile records where the tarball lives, prefer that — scope
411
+ // routing (`@scope:registry`) only covers scoped packages, but named
412
+ // registries (`gh:`, `jsr:` aliases, custom) ship un-scoped packages whose
413
+ // origin we'd otherwise miss. Match the longest prefix so that two named
414
+ // registries sharing a host but differing by path don't collide.
415
+ if (tarballUrl) {
416
+ const normalized = tryParseUrl(tarballUrl)?.toString();
417
+ if (normalized) {
418
+ for (const prefix of namedRegistryPrefixes) {
419
+ if (normalized.startsWith(prefix))
420
+ return prefix;
421
+ }
422
+ }
423
+ }
424
+ return pickRegistryForPackage(registries, name);
425
+ }
426
+ function tryParseUrl(url) {
427
+ try {
428
+ return new URL(url);
429
+ }
430
+ catch {
431
+ return null;
432
+ }
433
+ }
434
+ function uncheckable(policy, why) {
435
+ return `could not be checked against ${policy} (${why})`;
436
+ }
437
+ function createExcludePolicy(patterns, key) {
438
+ // Mirror the wrapping done by the full-resolution path
439
+ // (installing/deps-resolver/src/resolveDependencyTree.ts) so the error
440
+ // code is identical regardless of which path surfaced the invalid pattern.
441
+ try {
442
+ return createPackageVersionPolicy(patterns);
443
+ }
444
+ catch (err) {
445
+ if (!err || typeof err !== 'object' || !('message' in err))
446
+ throw err;
447
+ throw new PnpmError(`INVALID_${key.replace(/([A-Z])/g, '_$1').toUpperCase()}`, `Invalid value in ${key}: ${err.message}`);
448
+ }
449
+ }
450
+ function isExcluded(policy, name, version) {
451
+ if (!policy)
452
+ return false;
453
+ const result = policy(name);
454
+ if (result === true)
455
+ return true;
456
+ if (Array.isArray(result) && result.includes(version))
457
+ return true;
458
+ return false;
459
+ }
460
+ function isNpmRegistryResolution(resolution) {
461
+ if (resolution == null || typeof resolution !== 'object')
462
+ return false;
463
+ // Only plain tarball resolutions (npm registry / named registries) have no
464
+ // `type` field. Git / directory / binary / custom resolutions all carry one.
465
+ if ('type' in resolution && resolution.type != null)
466
+ return false;
467
+ // Git-hosted tarballs (codeload/gitlab/bitbucket) are special-cased in
468
+ // the resolver and aren't subject to release-age policy.
469
+ if ('gitHosted' in resolution && resolution.gitHosted)
470
+ return false;
471
+ return 'tarball' in resolution || 'integrity' in resolution;
472
+ }
473
+ //# sourceMappingURL=createNpmResolutionVerifier.js.map
@@ -0,0 +1,31 @@
1
+ import type { FetchMetadataFromFromRegistryOptions } from './fetch.js';
2
+ /**
3
+ * Per-version publish timestamp from npm's attestation endpoint —
4
+ * `/-/npm/v1/attestations/<name>@<version>`.
5
+ *
6
+ * The response is a small JSON document containing one or more Sigstore
7
+ * bundles. We read `bundle.verificationMaterial.tlogEntries[].integratedTime`
8
+ * (the Rekor inclusion time) and surface it as an ISO date. This is a
9
+ * couple of seconds after the actual publish — close enough for a
10
+ * release-age policy that operates in minutes/hours/days.
11
+ *
12
+ * We deliberately do **not** verify the Sigstore signature here: the
13
+ * trust model is identical to reading the registry's `time` field on
14
+ * the full metadata document. The win is bandwidth — the attestation
15
+ * payload is tens of KB versus the multi-MB full metadata document, so
16
+ * cold-cache + `--frozen-lockfile` installs against a fleet of
17
+ * provenance-published packages pay far less to verify timestamps.
18
+ *
19
+ * Returns `undefined` when:
20
+ *
21
+ * - The package has no published attestations (`404`).
22
+ * - The response is malformed or missing the timestamp.
23
+ * - The request itself fails (network error, registry 5xx).
24
+ *
25
+ * In all of those cases the caller falls back to fetching full metadata.
26
+ */
27
+ export interface FetchAttestationOptions {
28
+ registry: string;
29
+ authHeaderValue?: string;
30
+ }
31
+ export declare function fetchAttestationPublishedAt(fetchOpts: FetchMetadataFromFromRegistryOptions, pkgName: string, version: string, opts: FetchAttestationOptions): Promise<string | undefined>;
@@ -0,0 +1,99 @@
1
+ import * as retry from '@zkochan/retry';
2
+ export async function fetchAttestationPublishedAt(fetchOpts, pkgName, version, opts) {
3
+ const url = `${opts.registry.replace(/\/$/, '')}/-/npm/v1/attestations/${pkgName}@${version}`;
4
+ const retryOperation = retry.operation(fetchOpts.retry);
5
+ return new Promise((resolve) => {
6
+ retryOperation.attempt(async () => {
7
+ let response;
8
+ try {
9
+ response = await fetchOpts.fetch(url, {
10
+ authHeaderValue: opts.authHeaderValue,
11
+ retry: fetchOpts.retry,
12
+ timeout: fetchOpts.timeout,
13
+ });
14
+ }
15
+ catch {
16
+ // Network errors fall through to the full-metadata path; the
17
+ // caller's `fetchFullMetadataCached` has its own retry policy.
18
+ resolve(undefined);
19
+ return;
20
+ }
21
+ // 404 = package never published attestations. Other 4xx/5xx also
22
+ // mean "can't get an answer from this endpoint, fall back."
23
+ if (response.status >= 400) {
24
+ resolve(undefined);
25
+ return;
26
+ }
27
+ let body;
28
+ try {
29
+ body = await response.json();
30
+ }
31
+ catch {
32
+ resolve(undefined);
33
+ return;
34
+ }
35
+ resolve(extractPublishedAt(body));
36
+ });
37
+ });
38
+ }
39
+ /**
40
+ * Pull the earliest `integratedTime` across every attestation bundle in
41
+ * the response and convert it to an ISO timestamp. Earliest is the
42
+ * conservative choice: if two attestations disagree (e.g. publish
43
+ * v0.1 vs SLSA provenance v1), we attribute the publish to the older
44
+ * Rekor entry. The Rekor timestamp is what tells us when the artifact
45
+ * existed in a transparency log — that's the floor on publish time.
46
+ */
47
+ function extractPublishedAt(body) {
48
+ if (!body || typeof body !== 'object')
49
+ return undefined;
50
+ const attestations = body.attestations;
51
+ if (!Array.isArray(attestations))
52
+ return undefined;
53
+ let earliestSeconds;
54
+ for (const attestation of attestations) {
55
+ const seconds = readEarliestIntegratedTime(attestation);
56
+ if (seconds == null)
57
+ continue;
58
+ if (earliestSeconds == null || seconds < earliestSeconds) {
59
+ earliestSeconds = seconds;
60
+ }
61
+ }
62
+ if (earliestSeconds == null)
63
+ return undefined;
64
+ return new Date(earliestSeconds * 1000).toISOString();
65
+ }
66
+ function readEarliestIntegratedTime(attestation) {
67
+ if (!attestation || typeof attestation !== 'object')
68
+ return undefined;
69
+ const bundle = attestation.bundle;
70
+ if (!bundle || typeof bundle !== 'object')
71
+ return undefined;
72
+ const verificationMaterial = bundle.verificationMaterial;
73
+ if (!verificationMaterial || typeof verificationMaterial !== 'object')
74
+ return undefined;
75
+ const tlogEntries = verificationMaterial.tlogEntries;
76
+ if (!Array.isArray(tlogEntries))
77
+ return undefined;
78
+ let earliest;
79
+ for (const entry of tlogEntries) {
80
+ if (!entry || typeof entry !== 'object')
81
+ continue;
82
+ const rawIntegratedTime = entry.integratedTime;
83
+ // npm serializes integratedTime as a string ("1778583836") to avoid
84
+ // JSON precision loss; accept either string or number defensively.
85
+ const seconds = parseIntegratedTimeSeconds(rawIntegratedTime);
86
+ if (seconds == null)
87
+ continue;
88
+ if (earliest == null || seconds < earliest)
89
+ earliest = seconds;
90
+ }
91
+ return earliest;
92
+ }
93
+ function parseIntegratedTimeSeconds(raw) {
94
+ const seconds = typeof raw === 'string' ? Number(raw) : typeof raw === 'number' ? raw : NaN;
95
+ if (!Number.isFinite(seconds) || seconds <= 0)
96
+ return undefined;
97
+ return seconds;
98
+ }
99
+ //# sourceMappingURL=fetchAttestationPublishedAt.js.map