@pnpm/resolving.npm-resolver 1101.1.1 → 1101.2.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
@@ -0,0 +1,33 @@
1
+ import type { PackageMeta } from '@pnpm/resolving.registry.types';
2
+ import { type FetchMetadataFromFromRegistryOptions } from './fetch.js';
3
+ export interface FetchMetadataCachedOptions {
4
+ registry: string;
5
+ authHeaderValue?: string;
6
+ /**
7
+ * pnpm's on-disk cache directory. When set, the call issues a conditional
8
+ * GET against the matching mirror the resolver populates: a 304 Not
9
+ * Modified response serves the body from disk, a 200 writes the new body
10
+ * back. Omit to disable caching — every call re-fetches.
11
+ */
12
+ cacheDir?: string;
13
+ }
14
+ export type FetchFullMetadataCachedOptions = FetchMetadataCachedOptions;
15
+ /**
16
+ * Fetch a full registry metadata document for `pkgName`, reusing pnpm's
17
+ * shared on-disk metadata mirror when `cacheDir` is supplied. Built for the
18
+ * `minimumReleaseAge` lockfile revalidation gate, which needs the `time`
19
+ * field that abbreviated metadata omits; the cache reuse keeps repeat
20
+ * installs from re-downloading the same multi-megabyte document for every
21
+ * locked package.
22
+ */
23
+ export declare function fetchFullMetadataCached(fetchOpts: FetchMetadataFromFromRegistryOptions, pkgName: string, opts: FetchFullMetadataCachedOptions): Promise<PackageMeta>;
24
+ /**
25
+ * Sibling of {@link fetchFullMetadataCached} that hits the abbreviated
26
+ * metadata endpoint (`Accept: application/vnd.npm.install-v1+json`) and
27
+ * caches under `ABBREVIATED_META_DIR` — the same mirror the resolver
28
+ * populates by default. Used by the lockfile verification gate as a
29
+ * cheap upper-bound check: if the package's `modified` field is older
30
+ * than the policy cutoff, every version in it predates the cutoff and
31
+ * no per-version timestamp lookup is needed.
32
+ */
33
+ export declare function fetchAbbreviatedMetadataCached(fetchOpts: FetchMetadataFromFromRegistryOptions, pkgName: string, opts: FetchMetadataCachedOptions): Promise<PackageMeta>;
@@ -0,0 +1,63 @@
1
+ import { ABBREVIATED_META_DIR, FULL_META_DIR } from '@pnpm/constants';
2
+ import { PnpmError } from '@pnpm/error';
3
+ import { fetchMetadataFromFromRegistry } from './fetch.js';
4
+ import { getPkgMirrorPath, loadMeta, loadMetaHeaders, prepareJsonForDisk, saveMeta } from './pickPackage.js';
5
+ /**
6
+ * Fetch a full registry metadata document for `pkgName`, reusing pnpm's
7
+ * shared on-disk metadata mirror when `cacheDir` is supplied. Built for the
8
+ * `minimumReleaseAge` lockfile revalidation gate, which needs the `time`
9
+ * field that abbreviated metadata omits; the cache reuse keeps repeat
10
+ * installs from re-downloading the same multi-megabyte document for every
11
+ * locked package.
12
+ */
13
+ export async function fetchFullMetadataCached(fetchOpts, pkgName, opts) {
14
+ return fetchMetadataCached(fetchOpts, pkgName, { ...opts, fullMetadata: true, metaDir: FULL_META_DIR });
15
+ }
16
+ /**
17
+ * Sibling of {@link fetchFullMetadataCached} that hits the abbreviated
18
+ * metadata endpoint (`Accept: application/vnd.npm.install-v1+json`) and
19
+ * caches under `ABBREVIATED_META_DIR` — the same mirror the resolver
20
+ * populates by default. Used by the lockfile verification gate as a
21
+ * cheap upper-bound check: if the package's `modified` field is older
22
+ * than the policy cutoff, every version in it predates the cutoff and
23
+ * no per-version timestamp lookup is needed.
24
+ */
25
+ export async function fetchAbbreviatedMetadataCached(fetchOpts, pkgName, opts) {
26
+ return fetchMetadataCached(fetchOpts, pkgName, { ...opts, fullMetadata: false, metaDir: ABBREVIATED_META_DIR });
27
+ }
28
+ async function fetchMetadataCached(fetchOpts, pkgName, opts) {
29
+ const pkgMirror = opts.cacheDir != null
30
+ ? getPkgMirrorPath(opts.cacheDir, opts.metaDir, opts.registry, pkgName)
31
+ : null;
32
+ const cacheHeaders = pkgMirror != null ? await loadMetaHeaders(pkgMirror) : null;
33
+ const result = await fetchMetadataFromFromRegistry(fetchOpts, pkgName, {
34
+ registry: opts.registry,
35
+ authHeaderValue: opts.authHeaderValue,
36
+ fullMetadata: opts.fullMetadata,
37
+ etag: cacheHeaders?.etag,
38
+ modified: cacheHeaders?.modified,
39
+ });
40
+ if ('notModified' in result && result.notModified) {
41
+ if (pkgMirror == null) {
42
+ // We didn't send conditional headers (no cacheDir), but the registry
43
+ // returned 304 anyway. There's no body to fall back on.
44
+ throw new PnpmError('META_NOT_MODIFIED_WITHOUT_CACHE', `Registry returned 304 for ${pkgName} without an existing cache to refresh.`);
45
+ }
46
+ const meta = await loadMeta(pkgMirror);
47
+ if (meta == null) {
48
+ // Cache file vanished between header-load and meta-load (concurrent
49
+ // store cleanup, antivirus, etc.).
50
+ throw new PnpmError('META_CACHE_MISSING_AFTER_304', `Metadata cache for ${pkgName} disappeared between headers read and full read.`);
51
+ }
52
+ return meta;
53
+ }
54
+ if (pkgMirror != null) {
55
+ // Persist so the next install can do a headers-only conditional GET.
56
+ // Fire-and-forget — a cache-write failure isn't a reason to fail the
57
+ // caller; the next install just won't get the speedup.
58
+ const json = prepareJsonForDisk(result.meta, result.etag, result.jsonText);
59
+ saveMeta(pkgMirror, json).catch(() => { });
60
+ }
61
+ return result.meta;
62
+ }
63
+ //# sourceMappingURL=fetchFullMetadataCached.js.map
package/lib/index.d.ts CHANGED
@@ -12,16 +12,15 @@ export interface NoMatchingVersionErrorOptions {
12
12
  wantedDependency: WantedDependency;
13
13
  packageMeta: PackageMeta;
14
14
  registry: string;
15
- immatureVersion?: string;
16
- publishedBy?: Date;
17
15
  }
18
16
  export declare class NoMatchingVersionError extends PnpmError {
19
17
  readonly packageMeta: PackageMeta;
20
- readonly immatureVersion?: string;
21
18
  constructor(opts: NoMatchingVersionErrorOptions);
22
19
  }
23
20
  export declare function formatTimeAgo(date: Date): string | null;
24
21
  export { BUILTIN_NAMED_REGISTRIES, fetchMetadataFromFromRegistry, type FetchMetadataFromFromRegistryOptions, type PackageMeta, type PackageMetaCache, parseBareSpecifier, pickPackageFromMeta, pickVersionByVersionRange, type RegistryPackageSpec, RegistryResponseError, workspacePrefToNpm, };
22
+ export { createNpmResolutionVerifier, type CreateNpmResolutionVerifierOptions } from './createNpmResolutionVerifier.js';
23
+ export { MINIMUM_RELEASE_AGE_VIOLATION_CODE, TRUST_DOWNGRADE_VIOLATION_CODE, } from './violationCodes.js';
25
24
  export { whichVersionIsPinned } from './whichVersionIsPinned.js';
26
25
  export interface ResolverFactoryOptions {
27
26
  cacheDir: string;
@@ -36,7 +35,6 @@ export interface ResolverFactoryOptions {
36
35
  namedRegistries?: Record<string, string>;
37
36
  saveWorkspaceProtocol?: boolean | 'rolling';
38
37
  preserveAbsolutePaths?: boolean;
39
- strictPublishedByCheck?: boolean;
40
38
  ignoreMissingTimeField?: boolean;
41
39
  fetchWarnTimeoutMs?: number;
42
40
  /** Pre-populated metadata cache. When provided, the resolver uses this
package/lib/index.js CHANGED
@@ -17,28 +17,17 @@ import { BUILTIN_NAMED_REGISTRIES, parseBareSpecifier, parseJsrSpecifierToRegist
17
17
  import { pickPackage, } from './pickPackage.js';
18
18
  import { pickPackageFromMeta, pickVersionByVersionRange } from './pickPackageFromMeta.js';
19
19
  import { failIfTrustDowngraded } from './trustChecks.js';
20
+ import { MINIMUM_RELEASE_AGE_VIOLATION_CODE } from './violationCodes.js';
20
21
  import { whichVersionIsPinned } from './whichVersionIsPinned.js';
21
22
  import { workspacePrefToNpm } from './workspacePrefToNpm.js';
22
23
  export class NoMatchingVersionError extends PnpmError {
23
24
  packageMeta;
24
- immatureVersion;
25
25
  constructor(opts) {
26
26
  const dep = opts.wantedDependency.alias
27
27
  ? `${opts.wantedDependency.alias}@${opts.wantedDependency.bareSpecifier ?? ''}`
28
28
  : opts.wantedDependency.bareSpecifier;
29
- let errorMessage;
30
- if (opts.publishedBy && opts.immatureVersion && opts.packageMeta.time) {
31
- const time = new Date(opts.packageMeta.time[opts.immatureVersion]);
32
- const releaseAgeText = formatTimeAgo(time) ?? 'just now';
33
- const pkgName = opts.wantedDependency.alias ?? opts.packageMeta.name;
34
- errorMessage = `Version ${opts.immatureVersion} (released ${releaseAgeText}) of ${pkgName} does not meet the minimumReleaseAge constraint`;
35
- }
36
- else {
37
- errorMessage = `No matching version found for ${dep} while fetching it from ${opts.registry}`;
38
- }
39
- super(opts.publishedBy ? 'NO_MATURE_MATCHING_VERSION' : 'NO_MATCHING_VERSION', errorMessage);
29
+ super('NO_MATCHING_VERSION', `No matching version found for ${dep} while fetching it from ${opts.registry}`);
40
30
  this.packageMeta = opts.packageMeta;
41
- this.immatureVersion = opts.immatureVersion;
42
31
  }
43
32
  }
44
33
  export function formatTimeAgo(date) {
@@ -71,6 +60,8 @@ export function formatTimeAgo(date) {
71
60
  return 'a few seconds ago';
72
61
  }
73
62
  export { BUILTIN_NAMED_REGISTRIES, fetchMetadataFromFromRegistry, parseBareSpecifier, pickPackageFromMeta, pickVersionByVersionRange, RegistryResponseError, workspacePrefToNpm, };
63
+ export { createNpmResolutionVerifier } from './createNpmResolutionVerifier.js';
64
+ export { MINIMUM_RELEASE_AGE_VIOLATION_CODE, TRUST_DOWNGRADE_VIOLATION_CODE, } from './violationCodes.js';
74
65
  export { whichVersionIsPinned } from './whichVersionIsPinned.js';
75
66
  export function createNpmResolver(fetchFromRegistry, getAuthHeader, opts) {
76
67
  if (typeof opts.cacheDir !== 'string') {
@@ -126,7 +117,6 @@ export function createNpmResolver(fetchFromRegistry, getAuthHeader, opts) {
126
117
  offline: opts.offline,
127
118
  preferOffline: opts.preferOffline,
128
119
  cacheDir: opts.cacheDir,
129
- strictPublishedByCheck: opts.strictPublishedByCheck,
130
120
  ignoreMissingTimeField: opts.ignoreMissingTimeField,
131
121
  }),
132
122
  registries: opts.registries,
@@ -207,6 +197,19 @@ async function resolveNpm(ctx, wantedDependency, opts) {
207
197
  resolution: currentResolution,
208
198
  resolvedVia: 'npm-registry',
209
199
  publishedAt: opts.currentPkg.publishedAt,
200
+ // Loose-mode bypass: a lockfile entry whose publishedAt sits
201
+ // after the maturity cutoff would have been rejected at
202
+ // resolver time, but the peek path skips the maturity check.
203
+ // Report inline so the deps-resolver aggregator surfaces it
204
+ // to the install command.
205
+ policyViolation: detectMinReleaseAgeViolation({
206
+ name: manifest.name,
207
+ version: manifest.version,
208
+ publishedAt: opts.currentPkg.publishedAt,
209
+ resolution: currentResolution,
210
+ publishedBy: opts.publishedBy,
211
+ publishedByExclude: opts.publishedByExclude,
212
+ }),
210
213
  };
211
214
  }
212
215
  }
@@ -267,22 +270,6 @@ async function resolveNpm(ctx, wantedDependency, opts) {
267
270
  // ignore
268
271
  }
269
272
  }
270
- if (opts.publishedBy) {
271
- const immatureVersion = pickVersionByVersionRange({
272
- meta,
273
- versionRange: spec.fetchSpec,
274
- preferredVersionSelectors: opts.preferredVersions?.[spec.name],
275
- });
276
- if (immatureVersion) {
277
- throw new NoMatchingVersionError({
278
- wantedDependency,
279
- packageMeta: meta,
280
- registry,
281
- immatureVersion,
282
- publishedBy: opts.publishedBy,
283
- });
284
- }
285
- }
286
273
  throw new NoMatchingVersionError({ wantedDependency, packageMeta: meta, registry });
287
274
  }
288
275
  else if (opts.trustPolicy === 'no-downgrade') {
@@ -335,14 +322,23 @@ async function resolveNpm(ctx, wantedDependency, opts) {
335
322
  defaultPinnedVersion: opts.pinnedVersion,
336
323
  });
337
324
  }
325
+ const publishedAt = meta.time?.[pickedPackage.version];
338
326
  return {
339
327
  id,
340
328
  latest: meta['dist-tags'].latest,
341
329
  manifest: pickedPackage,
342
330
  resolution,
343
331
  resolvedVia: 'npm-registry',
344
- publishedAt: meta.time?.[pickedPackage.version],
332
+ publishedAt,
345
333
  normalizedBareSpecifier,
334
+ policyViolation: detectMinReleaseAgeViolation({
335
+ name: pickedPackage.name,
336
+ version: pickedPackage.version,
337
+ publishedAt,
338
+ resolution,
339
+ publishedBy: opts.publishedBy,
340
+ publishedByExclude: opts.publishedByExclude,
341
+ }),
346
342
  };
347
343
  }
348
344
  async function resolveJsr(ctx, wantedDependency, opts) {
@@ -439,15 +435,25 @@ async function pickFromSimpleRegistry(ctx, wantedDependency, opts, spec, registr
439
435
  if (pickedPackage == null) {
440
436
  throw new NoMatchingVersionError({ wantedDependency, packageMeta: meta, registry });
441
437
  }
438
+ const resolution = {
439
+ integrity: getIntegrity(pickedPackage.dist),
440
+ tarball: normalizeRegistryUrl(pickedPackage.dist.tarball),
441
+ };
442
+ const publishedAt = meta.time?.[pickedPackage.version];
442
443
  return {
443
444
  id: `${pickedPackage.name}@${pickedPackage.version}`,
444
445
  latest: meta['dist-tags'].latest,
445
446
  manifest: pickedPackage,
446
- resolution: {
447
- integrity: getIntegrity(pickedPackage.dist),
448
- tarball: normalizeRegistryUrl(pickedPackage.dist.tarball),
449
- },
450
- publishedAt: meta.time?.[pickedPackage.version],
447
+ resolution,
448
+ publishedAt,
449
+ policyViolation: detectMinReleaseAgeViolation({
450
+ name: pickedPackage.name,
451
+ version: pickedPackage.version,
452
+ publishedAt,
453
+ resolution,
454
+ publishedBy: opts.publishedBy,
455
+ publishedByExclude: opts.publishedByExclude,
456
+ }),
451
457
  };
452
458
  }
453
459
  // Builds a `<prefix><pkgName>@<range>` specifier (or a bare `<prefix><range>`
@@ -609,6 +615,40 @@ function defaultTagForAlias(alias, defaultTag) {
609
615
  type: 'tag',
610
616
  };
611
617
  }
618
+ /**
619
+ * Inline minimumReleaseAge detection: returns a violation entry when the
620
+ * picked version's publish timestamp is past the policy cutoff (and
621
+ * isn't covered by `publishedByExclude`). The resolver already has the
622
+ * timestamp in hand, so reporting inline saves the install layer from
623
+ * re-walking the resolved tree and re-fetching the same metadata. The
624
+ * deps-resolver aggregates the per-resolve `policyViolation` fields into
625
+ * a single set the install command reacts to.
626
+ *
627
+ * Returns `undefined` for resolutions outside the policy — no policy
628
+ * active, version excluded by pattern, timestamp missing or malformed,
629
+ * or version mature. Specific-version exclusions (`pkg@1.0.0`) and
630
+ * full-name exclusions (`pkg`) are both honored so an entry already on
631
+ * the user's exclude list isn't re-announced every install.
632
+ */
633
+ function detectMinReleaseAgeViolation(args) {
634
+ if (!args.publishedBy || !args.publishedAt)
635
+ return undefined;
636
+ const excludeResult = args.publishedByExclude?.(args.name);
637
+ if (excludeResult === true)
638
+ return undefined;
639
+ if (Array.isArray(excludeResult) && excludeResult.includes(args.version))
640
+ return undefined;
641
+ const ts = new Date(args.publishedAt).getTime();
642
+ if (Number.isNaN(ts) || ts <= args.publishedBy.getTime())
643
+ return undefined;
644
+ return {
645
+ name: args.name,
646
+ version: args.version,
647
+ resolution: args.resolution,
648
+ code: MINIMUM_RELEASE_AGE_VIOLATION_CODE,
649
+ reason: `was published at ${new Date(ts).toISOString()}, within the minimumReleaseAge cutoff (${args.publishedBy.toISOString()})`,
650
+ };
651
+ }
612
652
  function getIntegrity(dist) {
613
653
  if (dist.integrity) {
614
654
  return dist.integrity;
@@ -29,9 +29,40 @@ export declare function pickPackage(ctx: {
29
29
  offline?: boolean;
30
30
  preferOffline?: boolean;
31
31
  filterMetadata?: boolean;
32
- strictPublishedByCheck?: boolean;
33
32
  ignoreMissingTimeField?: boolean;
34
33
  }, spec: RegistryPackageSpec, opts: PickPackageOptions): Promise<{
35
34
  meta: PackageMeta;
36
35
  pickedPackage: PackageInRegistry | null;
37
36
  }>;
37
+ export declare function encodePkgName(pkgName: string): string;
38
+ /**
39
+ * Path of the on-disk JSONL document where pnpm mirrors a package's registry
40
+ * metadata. `metaDir` selects between abbreviated and full caches.
41
+ */
42
+ export declare function getPkgMirrorPath(cacheDir: string, metaDir: string, registry: string, pkgName: string): string;
43
+ /**
44
+ * Formats metadata for disk storage as two-line NDJSON:
45
+ * Line 1: cache headers (etag, modified) — small, fast to read
46
+ * Line 2: the full registry metadata JSON — unchanged from the registry response
47
+ */
48
+ export declare function prepareJsonForDisk(meta: PackageMeta, etag: string | undefined, jsonText?: string): string;
49
+ export declare function warnMissingTimeFieldOnce(pkgName: string): void;
50
+ interface MetaHeaders {
51
+ etag?: string;
52
+ modified?: string;
53
+ }
54
+ /**
55
+ * Reads only the first line of the cached NDJSON metadata file to extract
56
+ * the cache headers (etag, modified). This avoids reading and
57
+ * parsing the full metadata (which can be megabytes for popular packages)
58
+ * when we only need conditional-request headers.
59
+ */
60
+ export declare function loadMetaHeaders(pkgMirror: string): Promise<MetaHeaders | null>;
61
+ /**
62
+ * Reads the full metadata from the cached NDJSON file.
63
+ * Line 1: cache headers (etag, modified)
64
+ * Line 2: registry metadata JSON
65
+ */
66
+ export declare function loadMeta(pkgMirror: string): Promise<PackageMeta | null>;
67
+ export declare function saveMeta(pkgMirror: string, json: string): Promise<void>;
68
+ export {};
@@ -58,12 +58,13 @@ function pickMax(a, b) {
58
58
  const pickHighest = pickPackageFromMeta.bind(null, pickVersionByVersionRange);
59
59
  const pickLowest = pickPackageFromMeta.bind(null, pickLowestVersionByVersionRange);
60
60
  // When minimumReleaseAge is active: try the highest mature version; if none
61
- // and strictPublishedByCheck is off, fall back to the lowest version in range
62
- // without applying the maturity filter.
61
+ // satisfies the range, fall back to the lowest version regardless of maturity
62
+ // so the resolver can report the violation inline and let the install layer
63
+ // (or other caller) decide what to do — never throw at this layer.
63
64
  function pickRespectingMinReleaseAge(pickerOpts, spec, meta) {
64
65
  return runPicker(pickerOpts, spec, (targetSpec) => {
65
66
  const highest = pickHighest(pickerOpts, meta, targetSpec);
66
- if (highest || pickerOpts.strictPublishedByCheck)
67
+ if (highest)
67
68
  return highest;
68
69
  return pickLowest({
69
70
  preferredVersionSelectors: pickerOpts.preferredVersionSelectors,
@@ -111,7 +112,6 @@ export async function pickPackage(ctx, spec, opts) {
111
112
  publishedByExclude: opts.publishedByExclude,
112
113
  pickLowestVersion: opts.pickLowestVersion,
113
114
  includeLatestTag: opts.includeLatestTag,
114
- strictPublishedByCheck: ctx.strictPublishedByCheck,
115
115
  ignoreMissingTimeField: ctx.ignoreMissingTimeField,
116
116
  };
117
117
  validatePackageName(spec.name);
@@ -123,8 +123,7 @@ export async function pickPackage(ctx, spec, opts) {
123
123
  : ABBREVIATED_META_DIR;
124
124
  // Cache key includes fullMetadata to avoid returning abbreviated metadata when full metadata is requested.
125
125
  const cacheKey = fullMetadata ? `${spec.name}:full` : spec.name;
126
- const registryName = getRegistryName(opts.registry);
127
- const pkgMirror = path.join(ctx.cacheDir, metaDir, registryName, `${encodePkgName(spec.name)}.jsonl`);
126
+ const pkgMirror = getPkgMirrorPath(ctx.cacheDir, metaDir, opts.registry, spec.name);
128
127
  const cachedMeta = ctx.metaCache.get(cacheKey);
129
128
  if (cachedMeta != null) {
130
129
  // The in-memory cache may hold abbreviated metadata from an earlier call
@@ -194,10 +193,11 @@ export async function pickPackage(ctx, spec, opts) {
194
193
  };
195
194
  }
196
195
  }
197
- catch (err) {
198
- if (shouldRethrowFromFastPathCache(err, ctx.strictPublishedByCheck)) {
199
- throw err;
200
- }
196
+ catch {
197
+ // Swallow fast-path errors (e.g. ERR_PNPM_MISSING_TIME from
198
+ // abbreviated meta) and fall through to the network fetch, which
199
+ // can upgrade to full metadata and run the maturity check on
200
+ // real `time` data.
201
201
  }
202
202
  }
203
203
  }
@@ -215,10 +215,8 @@ export async function pickPackage(ctx, spec, opts) {
215
215
  };
216
216
  }
217
217
  }
218
- catch (err) {
219
- if (shouldRethrowFromFastPathCache(err, ctx.strictPublishedByCheck)) {
220
- throw err;
221
- }
218
+ catch {
219
+ // Same as above — fall through to the network fetch.
222
220
  }
223
221
  }
224
222
  }
@@ -388,13 +386,6 @@ async function maybeUpgradeAbbreviatedMetaForReleaseAge(ctx, spec, opts, meta) {
388
386
  }
389
387
  return { meta: fullFetchResult.meta, upgradedFrom: fullFetchResult };
390
388
  }
391
- // Returns true when a fast-path cache catch should rethrow under
392
- // strictPublishedByCheck. ERR_PNPM_MISSING_TIME is excluded so callers fall
393
- // through to the network fetch path, which can upgrade abbreviated cached
394
- // metadata to full and run the maturity check on real `time` data.
395
- function shouldRethrowFromFastPathCache(err, strictPublishedByCheck) {
396
- return strictPublishedByCheck === true && !isMissingTimeError(err);
397
- }
398
389
  // Persists upgraded full metadata to the on-disk cache mirror and returns
399
390
  // the meta to store in the in-memory cache. When `filterMetadata` is on, the
400
391
  // in-memory and on-disk forms are both stripped via `clearMeta`; otherwise
@@ -449,18 +440,25 @@ function clearMeta(pkg) {
449
440
  modified: pkg.modified,
450
441
  };
451
442
  }
452
- function encodePkgName(pkgName) {
443
+ export function encodePkgName(pkgName) {
453
444
  if (pkgName !== pkgName.toLowerCase()) {
454
445
  return `${pkgName}_${createHexHash(pkgName)}`;
455
446
  }
456
447
  return pkgName;
457
448
  }
449
+ /**
450
+ * Path of the on-disk JSONL document where pnpm mirrors a package's registry
451
+ * metadata. `metaDir` selects between abbreviated and full caches.
452
+ */
453
+ export function getPkgMirrorPath(cacheDir, metaDir, registry, pkgName) {
454
+ return path.join(cacheDir, metaDir, getRegistryName(registry), `${encodePkgName(pkgName)}.jsonl`);
455
+ }
458
456
  /**
459
457
  * Formats metadata for disk storage as two-line NDJSON:
460
458
  * Line 1: cache headers (etag, modified) — small, fast to read
461
459
  * Line 2: the full registry metadata JSON — unchanged from the registry response
462
460
  */
463
- function prepareJsonForDisk(meta, etag, jsonText) {
461
+ export function prepareJsonForDisk(meta, etag, jsonText) {
464
462
  const modified = meta.modified ?? meta.time?.modified;
465
463
  const headers = JSON.stringify({ etag, modified });
466
464
  const body = jsonText ?? JSON.stringify(meta);
@@ -476,7 +474,7 @@ function isMissingTimeError(err) {
476
474
  // memory via this Set as they resolve ever more distinct packages.
477
475
  const MAX_WARNED_MISSING_TIME = 1024;
478
476
  const warnedMissingTimeFor = new Set();
479
- function warnMissingTimeFieldOnce(pkgName) {
477
+ export function warnMissingTimeFieldOnce(pkgName) {
480
478
  if (warnedMissingTimeFor.has(pkgName))
481
479
  return;
482
480
  if (warnedMissingTimeFor.size >= MAX_WARNED_MISSING_TIME) {
@@ -503,7 +501,7 @@ async function getFileMtime(filePath) {
503
501
  * parsing the full metadata (which can be megabytes for popular packages)
504
502
  * when we only need conditional-request headers.
505
503
  */
506
- async function loadMetaHeaders(pkgMirror) {
504
+ export async function loadMetaHeaders(pkgMirror) {
507
505
  let fh;
508
506
  try {
509
507
  fh = await fs.open(pkgMirror, 'r');
@@ -530,7 +528,7 @@ async function loadMetaHeaders(pkgMirror) {
530
528
  * Line 1: cache headers (etag, modified)
531
529
  * Line 2: registry metadata JSON
532
530
  */
533
- async function loadMeta(pkgMirror) {
531
+ export async function loadMeta(pkgMirror) {
534
532
  try {
535
533
  const data = await gfs.readFile(pkgMirror, 'utf8');
536
534
  const newlineIdx = data.indexOf('\n');
@@ -546,7 +544,7 @@ async function loadMeta(pkgMirror) {
546
544
  }
547
545
  }
548
546
  const createdDirs = new Set();
549
- async function saveMeta(pkgMirror, json) {
547
+ export async function saveMeta(pkgMirror, json) {
550
548
  const dir = path.dirname(pkgMirror);
551
549
  if (!createdDirs.has(dir)) {
552
550
  await fs.mkdir(dir, { recursive: true });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Violation codes the npm resolver attaches to
3
+ * `ResolutionPolicyViolation.code` when an inline policy check rejects
4
+ * a pick. Exported so downstream code (the install command, the strict
5
+ * resolver wrapper, tests) references one source of truth instead of
6
+ * re-typing the string.
7
+ *
8
+ * Lives in its own module — both `index.ts` and `createNpmResolutionVerifier.ts`
9
+ * import it, so keeping the constants here avoids a cycle.
10
+ */
11
+ export declare const MINIMUM_RELEASE_AGE_VIOLATION_CODE = "MINIMUM_RELEASE_AGE_VIOLATION";
12
+ export declare const TRUST_DOWNGRADE_VIOLATION_CODE = "TRUST_DOWNGRADE";
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Violation codes the npm resolver attaches to
3
+ * `ResolutionPolicyViolation.code` when an inline policy check rejects
4
+ * a pick. Exported so downstream code (the install command, the strict
5
+ * resolver wrapper, tests) references one source of truth instead of
6
+ * re-typing the string.
7
+ *
8
+ * Lives in its own module — both `index.ts` and `createNpmResolutionVerifier.ts`
9
+ * import it, so keeping the constants here avoids a cycle.
10
+ */
11
+ export const MINIMUM_RELEASE_AGE_VIOLATION_CODE = 'MINIMUM_RELEASE_AGE_VIOLATION';
12
+ export const TRUST_DOWNGRADE_VIOLATION_CODE = 'TRUST_DOWNGRADE';
13
+ //# sourceMappingURL=violationCodes.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pnpm/resolving.npm-resolver",
3
- "version": "1101.1.1",
3
+ "version": "1101.2.0",
4
4
  "description": "Resolver for npm-hosted packages",
5
5
  "keywords": [
6
6
  "pnpm",
@@ -40,26 +40,27 @@
40
40
  "semver-utils": "^1.1.4",
41
41
  "ssri": "13.0.1",
42
42
  "version-selector-type": "^3.0.0",
43
- "@pnpm/core-loggers": "1100.0.2",
43
+ "@pnpm/config.pick-registry-for-package": "1100.0.3",
44
+ "@pnpm/config.version-policy": "1100.1.0",
44
45
  "@pnpm/constants": "1100.0.0",
46
+ "@pnpm/core-loggers": "1100.1.0",
45
47
  "@pnpm/crypto.hash": "1100.0.1",
46
- "@pnpm/fs.graceful-fs": "1100.1.0",
47
48
  "@pnpm/error": "1100.0.0",
48
49
  "@pnpm/fetching.types": "1100.0.1",
49
- "@pnpm/resolving.registry.types": "1100.0.3",
50
+ "@pnpm/fs.graceful-fs": "1100.1.0",
51
+ "@pnpm/resolving.jsr-specifier-parser": "1100.0.0",
50
52
  "@pnpm/resolving.registry.pkg-metadata-filter": "1100.0.3",
51
- "@pnpm/resolving.resolver-base": "1100.1.3",
52
- "@pnpm/types": "1101.1.0",
53
+ "@pnpm/resolving.registry.types": "1100.0.3",
54
+ "@pnpm/resolving.resolver-base": "1100.2.0",
55
+ "@pnpm/store.cafs": "1100.1.5",
53
56
  "@pnpm/store.index": "1100.1.0",
54
- "@pnpm/store.cafs": "1100.1.4",
55
- "@pnpm/workspace.spec-parser": "1100.0.0",
57
+ "@pnpm/types": "1101.1.0",
56
58
  "@pnpm/workspace.range-resolver": "1100.0.1",
57
- "@pnpm/resolving.jsr-specifier-parser": "1100.0.0",
58
- "@pnpm/config.pick-registry-for-package": "1100.0.3"
59
+ "@pnpm/workspace.spec-parser": "1100.0.0"
59
60
  },
60
61
  "peerDependencies": {
61
62
  "@pnpm/logger": ">=1001.0.0 <1002.0.0",
62
- "@pnpm/worker": "^1100.1.5"
63
+ "@pnpm/worker": "^1100.1.6"
63
64
  },
64
65
  "devDependencies": {
65
66
  "@jest/globals": "30.3.0",
@@ -69,12 +70,11 @@
69
70
  "@types/ssri": "^7.1.5",
70
71
  "load-json-file": "^7.0.1",
71
72
  "tempy": "3.0.0",
72
- "@pnpm/config.version-policy": "1100.0.3",
73
- "@pnpm/network.fetch": "1100.0.4",
74
- "@pnpm/resolving.npm-resolver": "1101.1.1",
75
73
  "@pnpm/logger": "1100.0.0",
74
+ "@pnpm/resolving.npm-resolver": "1101.2.0",
75
+ "@pnpm/network.fetch": "1100.0.5",
76
76
  "@pnpm/test-fixtures": "1100.0.0",
77
- "@pnpm/testing.mock-agent": "1100.0.4"
77
+ "@pnpm/testing.mock-agent": "1100.0.5"
78
78
  },
79
79
  "engines": {
80
80
  "node": ">=22.13"