fix: enforce minimumReleaseAge on existing lockfile entries (#11583)

Closes #10438.

## What

Re-verify every entry in `pnpm-lock.yaml` against the policies the resolver chain was configured with — today: `minimumReleaseAge` in strict mode — right after the lockfile is loaded from disk and before any tarball is fetched. A locked version that fails the policy aborts the install with `ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION`; `minimumReleaseAgeExclude` is honored.

## Why

The policy only fires while pnpm is *choosing* a version. Once a version is pinned in the lockfile — e.g. a developer disabled the policy locally and committed a fresh dependency, or a CI cache restored a stale lockfile — every later `pnpm install` (including `--frozen-lockfile` and `pnpm fetch`) installs it without re-checking, which defeats the supply-chain protection the setting is supposed to provide.

The threat model is **a lockfile someone else resolved**, not local resolution: local resolution is already covered by the resolver's own per-version filter. bun fixed the same shape of bug in [oven-sh/bun#30526](https://github.com/oven-sh/bun/pull/30526); this PR is the pnpm side.

## How

The fix introduces a generic `ResolutionVerifier` abstraction in the resolver chain — each resolver factory can ship a sibling verifier factory, exactly the way each resolver ships a `resolve` function. Today there's one verifier (npm); the shape leaves room for future ones (jsr, attestation-based, etc.) without changing the install-side interface.

- **`@pnpm/resolving.resolver-base`** exports the `ResolutionVerifier` / `ResolutionVerification` types — the shared contract.
- **`@pnpm/resolving.npm-resolver`** exports `createNpmResolutionVerifier`. Returns `undefined` when no policy is active, so callers can cheaply decide whether to iterate at all. When active, it inspects each lockfile entry, handles `minimumReleaseAgeExclude`, routes through named-registry prefixes (built-ins like `gh:` merged in), and uses `fetchFullMetadataCached` to fetch full registry metadata — decoupled from the resolver pipeline so neither `peekManifestFromStore` nor abbreviated metadata can hide the publish timestamp.
- **`@pnpm/resolving.default-resolver`** exports `createResolutionVerifier`, a combinator that asks each underlying verifier (today: npm) if it has work and returns `undefined` when none does. Designed so that adding more verifiers later doesn't change the install side.
- **`@pnpm/installing.client`** exposes `verifyResolution` on `Client`, built from the same `fetchFromRegistry` / `getAuthHeader` the resolver chain already uses — **no second fetcher is constructed**.
- **`@pnpm/store.connection-manager`** and **`@pnpm/testing.temp-store`** surface `verifyResolution` alongside the store controller they hand back, so it reaches `mutateModules` through the existing plumbing.
- **`@pnpm/installing.deps-installer`** gains one option on `StrictInstallOptions`: `verifyResolution?: ResolutionVerifier`. `mutateModules` invokes `verifyLockfileResolutions(ctx.wantedLockfile, opts.verifyResolution)` **once**, right after `getContext` returns the on-disk lockfile and before any path branches. When the verifier is `undefined`, the call is a no-op. The iteration is policy-neutral: dedupes by `(name, version)`, applies `pLimit(16)`, sorts violations stably, caps the printed list at 20 with an `…and N more` summary, throws a `PnpmError` carrying the verifier-supplied error code.

The error includes a recovery hint that points at `pnpm clean --lockfile` followed by `pnpm install` — the safe way to throw away a poisoned lockfile and rebuild from fresh resolution.

## Tests

- **9 unit tests** for `verifyLockfileResolutions` against a mock `ResolutionVerifier` — dedup, aggregation, stable ordering, the 20-entry cap, no-op behavior, the verifier-supplied error code surfacing in `PnpmError`.
- **13 integration tests** in `installing/deps-installer/test/install/minimumReleaseAge.ts` via the real `install()` entry — `testDefaults()` wires `verifyResolution` from `createTempStore` → `createClient`, so the npm verifier runs end-to-end at the install boundary. Covers the rejection scenario, `minimumReleaseAgeExclude`, the strict-mode toggle, the existing `minimumReleaseAge` resolver-side suite, and a `pnpm add` scenario where a pre-existing entry would otherwise survive resolution.
- **3 e2e tests** in `pnpm/test/install/minimumReleaseAge.ts` against the bundled CLI: rejection path with the right `ERR_PNPM_*` code and `pnpm clean --lockfile` hint in output, `minimumReleaseAgeExclude` honored, and the strict-off path (which now requires an explicit `minimumReleaseAgeStrict: false` since the config reader auto-enables strict mode when `minimumReleaseAge` is set).
- Existing `frozenLockfile` suite (12 tests) and npm-resolver suite (179 tests) still pass.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
Ryo Matsukawa
2026-05-17 04:38:06 +09:00
committed by GitHub
parent c178d1396f
commit 31538bf8d2
32 changed files with 943 additions and 28 deletions

View File

@@ -0,0 +1,12 @@
---
"@pnpm/resolving.resolver-base": minor
"@pnpm/resolving.npm-resolver": minor
"@pnpm/resolving.default-resolver": minor
"@pnpm/installing.client": minor
"@pnpm/store.connection-manager": minor
"@pnpm/testing.temp-store": minor
"@pnpm/installing.deps-installer": minor
"pnpm": patch
---
Restructured the `minimumReleaseAge` lockfile revalidation gate around a generic `ResolutionVerifier` interface. Each resolver may now export a sibling verifier factory (today: `createNpmResolutionVerifier`) that re-checks an already-resolved lockfile entry against its policies; `createResolver`'s companion `createResolutionVerifier` combines them and the `Client` exposes the combined `verifyResolution` for the install layer to consume. The npm verifier reuses the same on-disk metadata mirror the resolver writes to, so steady-state installs pay only a headers-only conditional GET per locked package [#11675](https://github.com/pnpm/pnpm/issues/11675).

View File

@@ -0,0 +1,6 @@
---
"@pnpm/installing.deps-installer": minor
"pnpm": patch
---
`minimumReleaseAge` is now re-checked against `pnpm-lock.yaml` before any tarball is installed, so a freshly-published version pinned in the lockfile (e.g. by a developer who bypassed the policy locally) is no longer installed silently by other consumers or CI. Violating entries abort the install with `ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION`; `minimumReleaseAgeExclude` is honored. [#10438](https://github.com/pnpm/pnpm/issues/10438).

View File

@@ -9,14 +9,17 @@ import type { CustomFetcher, CustomResolver } from '@pnpm/hooks.types'
import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header'
import { createFetchFromRegistry, type DispatcherOptions } from '@pnpm/network.fetch'
import {
createResolutionVerifier,
createResolver as _createResolver,
type ResolutionVerifierFactoryOptions,
type ResolveFunction,
type ResolverFactoryOptions,
} from '@pnpm/resolving.default-resolver'
import type { ResolutionVerifier } from '@pnpm/resolving.resolver-base'
import type { StoreIndex } from '@pnpm/store.index'
import type { RegistryConfig } from '@pnpm/types'
export type { ResolveFunction }
export type { ResolutionVerifier, ResolveFunction }
export type ClientOptions = {
configByUri: Record<string, RegistryConfig>
@@ -35,11 +38,19 @@ export type ClientOptions = {
preserveAbsolutePaths?: boolean
fetchMinSpeedKiBps?: number
} & ResolverFactoryOptions & DispatcherOptions
& Pick<ResolutionVerifierFactoryOptions, 'minimumReleaseAge' | 'minimumReleaseAgeStrict' | 'minimumReleaseAgeExclude'>
export interface Client {
fetchers: Fetchers
resolve: ResolveFunction
clearResolutionCache: () => void
/**
* Combined verifier across the resolver chain. `undefined` when no
* resolver-level policy is active (today: minimumReleaseAge strict mode).
* Used by the install layer to re-validate an already-resolved lockfile
* entry without re-doing resolution.
*/
verifyResolution?: ResolutionVerifier
}
export function createClient (opts: ClientOptions): Client {
@@ -47,10 +58,12 @@ export function createClient (opts: ClientOptions): Client {
const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri, opts.registries?.default)
const { resolve, clearCache: clearResolutionCache } = _createResolver(fetchFromRegistry, getAuthHeader, { ...opts, customResolvers: opts.customResolvers })
const verifyResolution = createResolutionVerifier(fetchFromRegistry, opts)
return {
fetchers: createFetchers(fetchFromRegistry, getAuthHeader, opts),
resolve,
clearResolutionCache,
verifyResolution,
}
}

View File

@@ -70,6 +70,7 @@ export async function handler (opts: FetchCommandOptions): Promise<void> {
pruneStore: true,
storeController: store.ctrl,
storeDir: store.dir,
verifyResolution: store.verifyResolution,
// Hoisting is skipped anyway,
// so we store these empty patterns in node_modules/.modules.yaml
// to let the subsequent install know that hoisting should be performed.

View File

@@ -186,6 +186,7 @@ export async function handler (
preferredVersions,
storeController: store.ctrl,
storeDir: store.dir,
verifyResolution: store.verifyResolution,
}
await install(manifest, installOpts)
}

View File

@@ -279,6 +279,7 @@ export async function installDeps (
skipRuntimes: opts.runtime === false,
storeController: store.ctrl,
storeDir: store.dir,
verifyResolution: store.verifyResolution,
workspacePackages,
preferredVersions: opts.packageVulnerabilityAudit ? preferNonvulnerablePackageVersions(opts.packageVulnerabilityAudit) : undefined,
}

View File

@@ -32,6 +32,7 @@ import {
import { logger } from '@pnpm/logger'
import { filterDependenciesByType } from '@pnpm/pkg-manifest.utils'
import type { PreferredVersions } from '@pnpm/resolving.resolver-base'
import type { ResolutionVerifier } from '@pnpm/resolving.resolver-base'
import { createStoreController, type CreateStoreControllerOptions } from '@pnpm/store.connection-manager'
import type { StoreController } from '@pnpm/store.controller'
import type {
@@ -114,6 +115,7 @@ export type RecursiveOptions = CreateStoreControllerOptions & Pick<Config,
storeControllerAndDir?: {
ctrl: StoreController
dir: string
verifyResolution?: ResolutionVerifier
}
pnpmfile: string[]
} & Partial<
@@ -165,6 +167,7 @@ export async function recursive (
storeController: store.ctrl,
storeDir: store.dir,
targetDependenciesField,
verifyResolution: store.verifyResolution,
workspacePackages,
}) as InstallOptions
@@ -296,6 +299,7 @@ export async function recursive (
} = await mutateModules(mutatedImporters, {
...installOpts,
storeController: store.ctrl,
verifyResolution: store.verifyResolution,
})
if (opts.save !== false) {
const promises: Array<Promise<void>> = mutatedPkgs.map(async ({ originalManifest, manifest, rootDir }) => {
@@ -414,6 +418,7 @@ export async function recursive (
}),
configByUri: installOpts.configByUri,
storeController: store.ctrl,
verifyResolution: store.verifyResolution,
}
)
if (opts.save !== false) {

View File

@@ -188,6 +188,7 @@ export async function handler (
linkWorkspacePackagesDepth: opts.linkWorkspacePackages === 'deep' ? Infinity : opts.linkWorkspacePackages ? 0 : -1,
storeController: store.ctrl,
storeDir: store.dir,
verifyResolution: store.verifyResolution,
include,
})
const allProjects = opts.allProjects ?? (

View File

@@ -11,7 +11,7 @@ import type { ProjectOptions } from '@pnpm/installing.context'
import type { HoistingLimits } from '@pnpm/installing.deps-restorer'
import type { IncludedDependencies } from '@pnpm/installing.modules-yaml'
import type { LockfileObject } from '@pnpm/lockfile.fs'
import type { WorkspacePackages } from '@pnpm/resolving.resolver-base'
import type { ResolutionVerifier, WorkspacePackages } from '@pnpm/resolving.resolver-base'
import type { StoreController } from '@pnpm/store.controller-types'
import type {
AllowedDeprecatedVersions,
@@ -175,6 +175,15 @@ export interface StrictInstallOptions {
ci?: boolean
minimumReleaseAge?: number
minimumReleaseAgeExclude?: string[]
/**
* Optional verifier that re-checks each lockfile-pinned resolution
* against policies configured upstream (today: minimumReleaseAge strict
* mode). Constructed by `createClient` and surfaced via the
* `createStoreController` return; mutateModules invokes it once, right
* after the lockfile is loaded from disk. When omitted, no revalidation
* runs.
*/
verifyResolution?: ResolutionVerifier
trustPolicy?: TrustPolicy
trustPolicyExclude?: string[]
trustPolicyIgnoreAfter?: number

View File

@@ -95,6 +95,7 @@ import {
import { linkPackages } from './link.js'
import { reportPeerDependencyIssues } from './reportPeerDependencyIssues.js'
import { validateModules } from './validateModules.js'
import { verifyLockfileResolutions } from './verifyLockfileResolutions.js'
class LockfileConfigMismatchError extends PnpmError {
constructor (outdatedLockfileSettingName: string) {
@@ -274,6 +275,11 @@ export async function mutateModules (
maybeOpts: MutateModulesOptions
): Promise<MutateModulesResult> {
const reporter = maybeOpts?.reporter
const detachReporter = (reporter != null) && typeof reporter === 'function'
? () => {
streamParser.removeListener('data', reporter)
}
: () => {}
if ((reporter != null) && typeof reporter === 'function') {
streamParser.on('data', reporter)
}
@@ -328,6 +334,26 @@ export async function mutateModules (
}
}
// Re-validate every entry in the lockfile against the policies the
// resolver chain was built with (today: minimumReleaseAge in strict mode
// via the npm verifier; the abstraction supports other resolvers
// attaching their own verifiers). The threat model is a lockfile that
// someone else resolved — committed to the repo, restored from a CI
// cache, etc. — bypassing the local resolver's policy filters; the local
// resolver's own filters already cover fresh resolution. We run this
// exactly once, right after the lockfile is loaded from disk, before any
// path branches.
try {
await verifyLockfileResolutions(ctx.wantedLockfile, opts.verifyResolution)
} catch (err) {
// verifyLockfileResolutions is the one throw site in this function
// that's part of normal user-facing operation (a rejected lockfile);
// other throws here are unexpected. Detach the reporter listener so
// long-lived processes don't leak it on every rejected install.
detachReporter()
throw err
}
if (opts.hooks.preResolution) {
for (const preResolution of opts.hooks.preResolution) {
// eslint-disable-next-line no-await-in-loop
@@ -415,9 +441,7 @@ export async function mutateModules (
packageNames: ignoredBuilds ? dedupePackageNamesFromIgnoredBuilds(ignoredBuilds) : [],
})
if ((reporter != null) && typeof reporter === 'function') {
streamParser.removeListener('data', reporter)
}
detachReporter()
return {
updatedCatalogs: result.updatedCatalogs,

View File

@@ -0,0 +1,98 @@
import { PnpmError } from '@pnpm/error'
import type { LockfileObject } from '@pnpm/lockfile.fs'
import { nameVerFromPkgSnapshot } from '@pnpm/lockfile.utils'
import type { ResolutionVerifier } from '@pnpm/resolving.resolver-base'
import type { DepPath } from '@pnpm/types'
import pLimit from 'p-limit'
interface Violation {
pkgId: string
code: string
reason: string
}
// Cap the per-entry breakdown so a verifier rejecting hundreds of entries
// (e.g. a poisoned lockfile) doesn't flood the terminal / CI log; the full
// count is in the header and the remainder is summarized at the end.
const MAX_VIOLATIONS_TO_PRINT = 20
// 16 mirrors the floor of pnpm's package-requester network-concurrency
// (Math.min(64, Math.max(workers*3, 16))); keep them aligned so the
// verification pass doesn't push past what the rest of the install respects.
const DEFAULT_CONCURRENCY = 16
/**
* Policy-neutral pass that asks each resolver-supplied {@link ResolutionVerifier}
* to check every entry in a lockfile loaded from disk. Iteration runs
* before resolution decisions are touched and before any tarball is
* fetched, so a lockfile whose entries were resolved elsewhere (committed
* to the repo, restored from a cache, etc.) under a weaker or absent
* policy cannot reach the filesystem. Fresh local resolution is covered
* by the resolver's own per-version filter.
*
* Designed for fail-closed semantics at the verifier level: a verifier that
* can't confirm a resolution is expected to return `{ ok: false }` rather
* than passing silently — otherwise a registry hiccup or an unpublished
* version would re-open the bypass.
*
* No-op when `verifyResolution` is undefined (no active policies).
*/
export async function verifyLockfileResolutions (
lockfile: LockfileObject,
verifyResolution: ResolutionVerifier | undefined,
options?: { concurrency?: number }
): Promise<void> {
if (verifyResolution == null) return
if (!lockfile.packages) return
// depPath can include peer-dependency and patch_hash suffixes (e.g.
// `react@18.0.0(peer)(patch_hash=…)`); the same (name, version) pair may
// therefore appear multiple times. Dedupe so we issue at most one
// verification per package version.
const candidates = new Map<string, { name: string, version: string, resolution: unknown }>()
for (const [depPath, snapshot] of Object.entries(lockfile.packages)) {
const { name, version } = nameVerFromPkgSnapshot(depPath as DepPath, snapshot)
if (!name || !version) continue
candidates.set(`${name}@${version}`, { name, version, resolution: snapshot.resolution })
}
const violations: Violation[] = []
const limit = pLimit(options?.concurrency ?? DEFAULT_CONCURRENCY)
await Promise.all(
Array.from(candidates.values(), ({ name, version, resolution }) => limit(async () => {
const pkgId = `${name}@${version}`
const result = await verifyResolution(resolution as Parameters<ResolutionVerifier>[0], { name, version })
if (!result.ok) {
violations.push({ pkgId, code: result.code, reason: result.reason })
}
}))
)
if (violations.length === 0) return
// Stable order so the error output is deterministic.
violations.sort((a, b) => a.pkgId.localeCompare(b.pkgId))
const visible = violations.slice(0, MAX_VIOLATIONS_TO_PRINT)
const omitted = violations.length - visible.length
const breakdown = visible.map((v) => ` ${v.pkgId} ${v.reason}`).join('\n')
const details = omitted > 0
? `${breakdown}\n …and ${omitted} more`
: breakdown
// Use the code of the first violation — all of today's violations are the
// same shape (one verifier, one code). If multiple verifiers fire later
// with mixed codes, switch to a generic LOCKFILE_RESOLUTION_VERIFICATION
// code and list per-entry codes in the breakdown.
throw new PnpmError(
violations[0].code,
`${violations.length} lockfile entries failed verification:\n${details}`,
{
hint: 'The lockfile contains entries that the active policies reject. ' +
'This can mean the lockfile is stale, or that someone committed a ' +
'lockfile that bypassed the policy locally — inspect recent changes ' +
'to pnpm-lock.yaml before trusting it. If the changes look expected, ' +
'run "pnpm clean --lockfile" and then "pnpm install" to rebuild from ' +
'a fresh resolution. Alternatively, relax the policy that flagged ' +
'them.',
}
)
}

View File

@@ -1,5 +1,5 @@
import { expect, test } from '@jest/globals'
import { addDependenciesToPackage } from '@pnpm/installing.deps-installer'
import { addDependenciesToPackage, install } from '@pnpm/installing.deps-installer'
import { readWantedLockfile, writeWantedLockfile } from '@pnpm/lockfile.fs'
import { prepareEmpty } from '@pnpm/prepare'
@@ -124,3 +124,73 @@ test('throws error when semver range is used in minimumReleaseAgeExclude', async
await addDependenciesToPackage({}, ['is-odd@0.1'], opts)
}).rejects.toThrow(/Invalid versions union/)
})
test('minimumReleaseAge is enforced on an existing lockfile entry that does not meet the cutoff', async () => {
prepareEmpty()
// Generate a lockfile without minimumReleaseAge — picks the latest 0.1.x (= 0.1.2),
// which is immature relative to isOdd011ReleaseDate.
const { updatedManifest: manifest } = await addDependenciesToPackage({}, ['is-odd@0.1.2'], testDefaults())
expect(manifest.dependencies!['is-odd']).toBe('0.1.2')
// Subsequent install enables minimumReleaseAge in strict mode. The lockfile
// already has 0.1.2 so resolution is normally skipped; the revalidation pass
// must catch this. `minimumReleaseAgeStrict` mirrors the CLI config reader's
// auto-true behavior when the user explicitly sets `minimumReleaseAge`.
await expect(
install(manifest, testDefaults({ minimumReleaseAge, minimumReleaseAgeStrict: true }))
).rejects.toThrow(/minimumReleaseAge/)
})
test('minimumReleaseAge revalidation respects minimumReleaseAgeExclude on an existing lockfile entry', async () => {
prepareEmpty()
const { updatedManifest: manifest } = await addDependenciesToPackage({}, ['is-odd@0.1.2'], testDefaults())
expect(manifest.dependencies!['is-odd']).toBe('0.1.2')
// is-odd@0.1.2 brings in is-buffer and kind-of as transitive deps; both were
// published after the cutoff in this test, so all three must be excluded for
// the install to succeed.
await expect(
install(manifest, testDefaults({
minimumReleaseAge,
minimumReleaseAgeStrict: true,
minimumReleaseAgeExclude: ['is-odd@0.1.2', 'is-buffer', 'kind-of'],
}))
).resolves.toBeDefined()
})
test('minimumReleaseAge is enforced on pre-existing lockfile entries during pnpm add', async () => {
prepareEmpty()
// Populate the lockfile with an immature entry without the policy.
const { updatedManifest: manifest } = await addDependenciesToPackage({}, ['is-odd@0.1.2'], testDefaults())
expect(manifest.dependencies!['is-odd']).toBe('0.1.2')
// Subsequent `pnpm add` for an unrelated package would normally let
// is-odd@0.1.2 survive resolution as-is via the resolver's
// peekManifestFromStore fast path, bypassing the policy. The post-resolution
// gate must catch it.
await expect(
addDependenciesToPackage(
manifest,
['is-positive@1.0.0'],
testDefaults({ minimumReleaseAge, minimumReleaseAgeStrict: true })
)
).rejects.toThrow(/minimumReleaseAge/)
})
test('the lockfile minimumReleaseAge gate is inert when strict mode is off (default-value semantics)', async () => {
prepareEmpty()
const { updatedManifest: manifest } = await addDependenciesToPackage({}, ['is-odd@0.1.2'], testDefaults())
expect(manifest.dependencies!['is-odd']).toBe('0.1.2')
// Without explicit strict mode — the same shape as the CLI built-in default
// (1-day release-age window applied without `minimumReleaseAge` being set in
// .npmrc) — the revalidation pass stays inert and the locked version
// installs cleanly.
await expect(
install(manifest, testDefaults({ minimumReleaseAge }))
).resolves.toBeDefined()
})

View File

@@ -0,0 +1,135 @@
import { expect, test } from '@jest/globals'
import type { LockfileObject } from '@pnpm/lockfile.fs'
import type { ResolutionVerifier } from '@pnpm/resolving.resolver-base'
import { verifyLockfileResolutions } from '../../src/install/verifyLockfileResolutions.js'
function makeLockfile (packages: Record<string, { resolution: unknown, version?: string }>): LockfileObject {
return {
lockfileVersion: '9.0',
importers: {},
packages: packages as LockfileObject['packages'],
} as LockfileObject
}
const tarballResolution = (integrity: string = 'sha512-deadbeef') => ({ integrity, tarball: '' })
const okVerifier: ResolutionVerifier = async () => ({ ok: true })
test('no-op when verifyResolution is undefined', async () => {
const lockfile = makeLockfile({
'fresh@1.0.0': { resolution: tarballResolution() },
})
await expect(verifyLockfileResolutions(lockfile, undefined)).resolves.toBeUndefined()
})
test('no-op when lockfile has no packages', async () => {
const lockfile = makeLockfile({})
await expect(verifyLockfileResolutions(lockfile, okVerifier)).resolves.toBeUndefined()
})
test('passes when every entry is verified ok', async () => {
const lockfile = makeLockfile({
'lodash@4.17.21': { resolution: tarballResolution() },
'is-odd@0.1.0': { resolution: tarballResolution() },
})
await expect(verifyLockfileResolutions(lockfile, okVerifier)).resolves.toBeUndefined()
})
test('throws with the verifier-supplied code and reason on a single failure', async () => {
const lockfile = makeLockfile({
'is-odd@0.1.2': { resolution: tarballResolution() },
})
const verifier: ResolutionVerifier = async () => ({
ok: false,
code: 'MINIMUM_RELEASE_AGE_VIOLATION',
reason: 'was published yesterday',
})
await expect(verifyLockfileResolutions(lockfile, verifier)).rejects.toMatchObject({
code: 'ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION',
message: expect.stringMatching(/is-odd@0\.1\.2 was published yesterday/),
})
})
test('lists violations in stable order across multiple failures', async () => {
const lockfile = makeLockfile({
'fresh-b@2.0.0': { resolution: tarballResolution('sha512-b') },
'fresh-a@1.0.0': { resolution: tarballResolution('sha512-a') },
})
const verifier: ResolutionVerifier = async (_, { name, version }) => ({
ok: false,
code: 'POLICY_X',
reason: `${name}@${version} failed`,
})
await expect(verifyLockfileResolutions(lockfile, verifier))
.rejects.toThrow(/fresh-a@1\.0\.0[\s\S]*fresh-b@2\.0\.0/)
})
test('caps printed violations at 20 with an "…and N more" summary', async () => {
const packages: Record<string, { resolution: unknown }> = {}
for (let i = 0; i < 25; i++) {
packages[`pkg-${String(i).padStart(2, '0')}@1.0.0`] = {
resolution: tarballResolution(`sha512-${i}`),
}
}
const lockfile = makeLockfile(packages)
const verifier: ResolutionVerifier = async (_, { name, version }) => ({
ok: false,
code: 'POLICY_X',
reason: `${name}@${version}`,
})
await expect(verifyLockfileResolutions(lockfile, verifier))
.rejects.toThrow(/25 lockfile entries failed verification[\s\S]*…and 5 more/)
})
test('dedupes peer/patch-suffix variants and invokes the verifier once per (name, version)', async () => {
const lockfile = makeLockfile({
'react@18.0.0': { resolution: tarballResolution('sha512-a') },
'react@18.0.0(peer-x)': { resolution: tarballResolution('sha512-a') },
'react@18.0.0(patch_hash=abc)(peer-x)': { resolution: tarballResolution('sha512-a') },
})
const seen: Array<{ name: string, version: string }> = []
const verifier: ResolutionVerifier = async (_, { name, version }) => {
seen.push({ name, version })
return { ok: true }
}
await verifyLockfileResolutions(lockfile, verifier)
expect(seen).toEqual([{ name: 'react', version: '18.0.0' }])
})
test('the verifier sees the resolution shape verbatim', async () => {
const npmResolution = tarballResolution()
const gitResolution = { type: 'git', repo: 'x', commit: 'abc' }
const lockfile = makeLockfile({
'npm-pkg@1.0.0': { resolution: npmResolution },
'git-pkg@1.0.0': { resolution: gitResolution },
})
const received: unknown[] = []
const verifier: ResolutionVerifier = async (resolution) => {
received.push(resolution)
return { ok: true }
}
await verifyLockfileResolutions(lockfile, verifier)
expect(received).toEqual(expect.arrayContaining([npmResolution, gitResolution]))
})
test('uses the first violation\'s code when multiple verifiers fire', async () => {
const lockfile = makeLockfile({
'a@1.0.0': { resolution: tarballResolution('sha512-a') },
'b@1.0.0': { resolution: tarballResolution('sha512-b') },
})
const verifier: ResolutionVerifier = async (_, { name }) => ({
ok: false,
code: name === 'a' ? 'POLICY_A' : 'POLICY_B',
reason: 'failed',
})
await expect(verifyLockfileResolutions(lockfile, verifier)).rejects.toMatchObject({
code: 'ERR_PNPM_POLICY_A',
})
})

View File

@@ -1,6 +1,7 @@
import type { CustomResolver } from '@pnpm/hooks.types'
import type { InstallOptions } from '@pnpm/installing.deps-installer'
import { REGISTRY_MOCK_PORT } from '@pnpm/registry-mock'
import type { ResolutionVerifier } from '@pnpm/resolving.resolver-base'
import type { StoreController } from '@pnpm/store.controller-types'
import { createTempStore } from '@pnpm/testing.temp-store'
import type { Registries } from '@pnpm/types'
@@ -14,6 +15,9 @@ export function testDefaults<T> (
prefix?: string
registries?: Registries
customResolvers?: CustomResolver[]
minimumReleaseAge?: number
minimumReleaseAgeStrict?: boolean
minimumReleaseAgeExclude?: string[]
},
resolveOpts?: any, // eslint-disable-line
fetchOpts?: any, // eslint-disable-line
@@ -24,13 +28,23 @@ export function testDefaults<T> (
registries: Registries
storeController: StoreController
storeDir: string
verifyResolution?: ResolutionVerifier
} &
T {
const { storeController, storeDir, cacheDir } = createTempStore({
// Forward minimumReleaseAge policy into the Client so it builds the
// matching ResolutionVerifier; tests that set these options exercise the
// same code path the CLI command would.
const policyClientOptions = {
...(opts?.minimumReleaseAge != null ? { minimumReleaseAge: opts.minimumReleaseAge } : {}),
...(opts?.minimumReleaseAgeStrict != null ? { minimumReleaseAgeStrict: opts.minimumReleaseAgeStrict } : {}),
...(opts?.minimumReleaseAgeExclude != null ? { minimumReleaseAgeExclude: opts.minimumReleaseAgeExclude } : {}),
}
const { storeController, storeDir, cacheDir, verifyResolution } = createTempStore({
...opts,
clientOptions: {
...(opts?.registries != null ? { registries: opts.registries } : {}),
customResolvers: opts?.customResolvers,
...policyClientOptions,
...resolveOpts,
...fetchOpts,
},
@@ -43,6 +57,7 @@ export function testDefaults<T> (
},
storeController,
storeDir,
verifyResolution,
...opts,
} as (
InstallOptions &
@@ -51,6 +66,7 @@ export function testDefaults<T> (
registries: Registries
storeController: StoreController
storeDir: string
verifyResolution?: ResolutionVerifier
} &
T
)

15
pnpm-lock.yaml generated
View File

@@ -8400,6 +8400,9 @@ importers:
'@pnpm/hooks.types':
specifier: workspace:*
version: link:../../hooks/types
'@pnpm/network.auth-header':
specifier: workspace:*
version: link:../../network/auth-header
'@pnpm/resolving.git-resolver':
specifier: workspace:*
version: link:../git-resolver
@@ -8539,6 +8542,9 @@ importers:
'@pnpm/config.pick-registry-for-package':
specifier: workspace:*
version: link:../../config/pick-registry-for-package
'@pnpm/config.version-policy':
specifier: workspace:*
version: link:../../config/version-policy
'@pnpm/constants':
specifier: workspace:*
version: link:../../core/constants
@@ -8633,9 +8639,6 @@ importers:
'@jest/globals':
specifier: 'catalog:'
version: 30.3.0
'@pnpm/config.version-policy':
specifier: workspace:*
version: link:../../config/version-policy
'@pnpm/logger':
specifier: workspace:*
version: link:../../core/logger
@@ -8986,6 +8989,9 @@ importers:
'@pnpm/installing.client':
specifier: workspace:*
version: link:../../installing/client
'@pnpm/resolving.resolver-base':
specifier: workspace:*
version: link:../../resolving/resolver-base
'@pnpm/store.controller':
specifier: workspace:*
version: link:../controller
@@ -9271,6 +9277,9 @@ importers:
'@pnpm/registry-mock':
specifier: 'catalog:'
version: 6.0.0(encoding@0.1.13)(verdaccio@6.3.2(encoding@0.1.13)(typanion@3.14.0))
'@pnpm/resolving.resolver-base':
specifier: workspace:*
version: link:../../resolving/resolver-base
'@pnpm/store.controller':
specifier: workspace:*
version: link:../../store/controller

View File

@@ -0,0 +1,96 @@
import { describe, expect, test } from '@jest/globals'
import { prepare } from '@pnpm/prepare'
import { writeYamlFileSync } from 'write-yaml-file'
import { execPnpm, execPnpmSync } from '../utils/index.js'
// The public npm registry is used here instead of verdaccio because the
// registry mock doesn't include the per-version `time` field in full-metadata
// responses, which the lockfile verifier needs to evaluate the cutoff.
// This mirrors the workaround in `pnpm/test/dlx.ts`.
const PUBLIC_REGISTRY = '--config.registry=https://registry.npmjs.org/'
// `is-odd@0.1.2` was published in 2017. Setting an extreme minimumReleaseAge
// (~27 years) ensures every locked version is "immature" relative to the
// cutoff — the verifier rejects the entry regardless of when the test runs.
const IMMATURE_FOR_EVERYTHING = 60 * 24 * 365 * 27
// execPnpm's createEnv defaults pnpm_config_minimum_release_age to '0',
// which overrides anything in pnpm-workspace.yaml. Tests that need the
// yaml policy to take effect must omit this default — same workaround
// dlx.ts uses for its minimumReleaseAge tests.
const omitMinReleaseAgeEnv = { omitEnvDefaults: ['pnpm_config_minimum_release_age' as const] }
describe('lockfile minimumReleaseAge verification', () => {
test('install rejects a lockfile entry that does not satisfy the policy in strict mode', async () => {
// Step 1: populate a lockfile under no policy. The resolver picks
// is-odd@0.1.2 (latest 0.1.x) without applying any maturity filter.
prepare({
dependencies: { 'is-odd': '0.1.2' },
})
await execPnpm([PUBLIC_REGISTRY, 'install'])
// Step 2: turn on minimumReleaseAge in strict mode. The lockfile is now
// "poisoned" relative to the new policy — exactly the scenario the
// verifier exists to catch (a teammate committed a lockfile that
// bypassed the policy locally, a CI cache restored a stale lockfile,
// etc.).
writeYamlFileSync('pnpm-workspace.yaml', {
minimumReleaseAge: IMMATURE_FOR_EVERYTHING,
minimumReleaseAgeStrict: true,
})
const result = execPnpmSync(
[PUBLIC_REGISTRY, 'install', '--frozen-lockfile'],
omitMinReleaseAgeEnv
)
expect(result.status).toBe(1)
const output = `${result.stdout.toString()}\n${result.stderr.toString()}`
expect(output).toContain('ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION')
expect(output).toMatch(/is-odd@0\.1\.2/)
// Confirm the recovery hint reaches the user.
expect(output).toContain('pnpm clean --lockfile')
})
test('install respects minimumReleaseAgeExclude during lockfile verification', () => {
prepare({
dependencies: { 'is-odd': '0.1.2' },
})
execPnpmSync([PUBLIC_REGISTRY, 'install'], { expectSuccess: true })
writeYamlFileSync('pnpm-workspace.yaml', {
minimumReleaseAge: IMMATURE_FOR_EVERYTHING,
minimumReleaseAgeStrict: true,
// is-odd@0.1.2 pulls in is-buffer, is-number, and kind-of transitively;
// all four are immature in this test, so all four need exclusion.
minimumReleaseAgeExclude: ['is-odd', 'is-buffer', 'is-number', 'kind-of'],
})
execPnpmSync(
[PUBLIC_REGISTRY, 'install', '--frozen-lockfile'],
{ ...omitMinReleaseAgeEnv, expectSuccess: true }
)
})
test('install is unaffected by minimumReleaseAge when strict mode is explicitly off', () => {
// The config reader auto-enables strict mode the moment a user
// explicitly sets `minimumReleaseAge`, so opting out requires an
// explicit `minimumReleaseAgeStrict: false`. With that, the verifier
// doesn't construct and the lockfile passes through untouched.
prepare({
dependencies: { 'is-odd': '0.1.2' },
})
execPnpmSync([PUBLIC_REGISTRY, 'install'], { expectSuccess: true })
writeYamlFileSync('pnpm-workspace.yaml', {
minimumReleaseAge: IMMATURE_FOR_EVERYTHING,
minimumReleaseAgeStrict: false,
})
execPnpmSync(
[PUBLIC_REGISTRY, 'install', '--frozen-lockfile'],
{ ...omitMinReleaseAgeEnv, expectSuccess: true }
)
})
})

View File

@@ -39,6 +39,7 @@
"@pnpm/error": "workspace:*",
"@pnpm/fetching.types": "workspace:*",
"@pnpm/hooks.types": "workspace:*",
"@pnpm/network.auth-header": "workspace:*",
"@pnpm/resolving.git-resolver": "workspace:*",
"@pnpm/resolving.local-resolver": "workspace:*",
"@pnpm/resolving.npm-resolver": "workspace:*",

View File

@@ -4,9 +4,12 @@ import { type NodeRuntimeResolveResult, resolveNodeRuntime } from '@pnpm/engine.
import { PnpmError } from '@pnpm/error'
import type { FetchFromRegistry, GetAuthHeader } from '@pnpm/fetching.types'
import { checkCustomResolverCanResolve, type CustomResolver } from '@pnpm/hooks.types'
import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header'
import { createGitResolver, type GitResolveResult } from '@pnpm/resolving.git-resolver'
import { type LocalResolveResult, resolveFromLocalPath, resolveFromLocalScheme } from '@pnpm/resolving.local-resolver'
import {
createNpmResolutionVerifier,
type CreateNpmResolutionVerifierOptions,
createNpmResolver,
type JsrResolveResult,
type NamedRegistryResolveResult,
@@ -18,12 +21,14 @@ import {
type WorkspaceResolveResult,
} from '@pnpm/resolving.npm-resolver'
import type {
ResolutionVerifier,
ResolveFunction,
ResolveOptions,
ResolveResult,
WantedDependency,
} from '@pnpm/resolving.resolver-base'
import { resolveFromTarball, type TarballResolveResult } from '@pnpm/resolving.tarball-resolver'
import type { RegistryConfig } from '@pnpm/types'
export type {
PackageMeta,
@@ -139,3 +144,57 @@ export function createResolver (
clearCache,
}
}
export type ResolutionVerifierFactoryOptions =
& Pick<ResolverFactoryOptions, 'cacheDir' | 'registries' | 'namedRegistries' | 'retry' | 'timeout' | 'fetchWarnTimeoutMs'>
& Pick<CreateNpmResolutionVerifierOptions,
| 'minimumReleaseAge'
| 'minimumReleaseAgeStrict'
| 'minimumReleaseAgeExclude'
| 'now'
> & {
configByUri?: Record<string, RegistryConfig>
}
/**
* Companion to {@link createResolver}. Combines the resolver-specific
* verifier factories (today: npm) into a single {@link ResolutionVerifier},
* dispatching by resolution shape. Returns `undefined` when none of the
* underlying resolvers have any active policy — letting callers cheaply
* decide whether to iterate at all.
*/
export function createResolutionVerifier (
fetchFromRegistry: FetchFromRegistry,
opts: ResolutionVerifierFactoryOptions
): ResolutionVerifier | undefined {
const fetchOpts = {
fetch: fetchFromRegistry,
retry: opts.retry ?? {},
timeout: opts.timeout ?? 60_000,
fetchWarnTimeoutMs: opts.fetchWarnTimeoutMs ?? 10_000,
}
const getAuthHeaderValueByURI = createGetAuthHeaderByURI(opts.configByUri ?? {}, opts.registries.default)
const npmVerifier = createNpmResolutionVerifier({
minimumReleaseAge: opts.minimumReleaseAge,
minimumReleaseAgeStrict: opts.minimumReleaseAgeStrict,
minimumReleaseAgeExclude: opts.minimumReleaseAgeExclude,
registries: opts.registries,
namedRegistries: opts.namedRegistries,
fetchOpts,
getAuthHeaderValueByURI,
cacheDir: opts.cacheDir,
now: opts.now,
})
// Future protocols (jsr, git, etc.) plug in here. When every sub-verifier
// is undefined, the combined verifier is too — caller short-circuits.
//
// When a second verifier lands, this combinator needs to dispatch by
// resolution shape (so e.g. a git verifier doesn't run on npm-registry
// entries and vice versa). The classification logic should live as a
// shared helper in `@pnpm/resolving.resolver-base` — `pickFetcher` in
// `fetching/pick-fetcher` already classifies the same shape today
// (resolution.type / tarball / gitHosted / integrity); reconcile both
// call sites onto one classifier rather than re-deriving it per verifier.
if (!npmVerifier) return undefined
return async (resolution, ctx) => npmVerifier(resolution, ctx)
}

View File

@@ -36,6 +36,9 @@
{
"path": "../../hooks/types"
},
{
"path": "../../network/auth-header"
},
{
"path": "../../network/fetch"
},

View File

@@ -34,6 +34,7 @@
},
"dependencies": {
"@pnpm/config.pick-registry-for-package": "workspace:*",
"@pnpm/config.version-policy": "workspace:*",
"@pnpm/constants": "workspace:*",
"@pnpm/core-loggers": "workspace:*",
"@pnpm/crypto.hash": "workspace:*",
@@ -70,7 +71,6 @@
},
"devDependencies": {
"@jest/globals": "catalog:",
"@pnpm/config.version-policy": "workspace:*",
"@pnpm/logger": "workspace:*",
"@pnpm/network.fetch": "workspace:*",
"@pnpm/resolving.npm-resolver": "workspace:*",

View File

@@ -0,0 +1,228 @@
import { pickRegistryForPackage } from '@pnpm/config.pick-registry-for-package'
import { createPackageVersionPolicy } from '@pnpm/config.version-policy'
import { PnpmError } from '@pnpm/error'
import type {
Resolution,
ResolutionVerifier,
} from '@pnpm/resolving.resolver-base'
import type { PackageVersionPolicy, Registries } from '@pnpm/types'
import semver from 'semver'
import type { FetchMetadataFromFromRegistryOptions } from './fetch.js'
import { fetchFullMetadataCached, type FetchFullMetadataCachedOptions } from './fetchFullMetadataCached.js'
import { BUILTIN_NAMED_REGISTRIES } from './parseBareSpecifier.js'
export interface CreateNpmResolutionVerifierOptions {
/**
* Minimum age (in minutes) a published version must reach before it is
* accepted. When unset, the verifier is a no-op for the age check.
*/
minimumReleaseAge?: number
/**
* Gate the age check on strict mode so the built-in default doesn't
* silently enforce for users who never opted in. The verifier factory
* returns `undefined` unless both `minimumReleaseAge > 0` and
* `minimumReleaseAgeStrict` are set.
*/
minimumReleaseAgeStrict?: boolean
minimumReleaseAgeExclude?: string[]
registries: Registries
/**
* Registries reached via the named-registry resolver chain (e.g. `gh:` →
* GitHub Packages). When a lockfile entry's tarball URL falls under one of
* these registry base URLs, route the manifest fetch there instead of the
* scope-derived default.
*/
namedRegistries?: Record<string, string>
/**
* Cache-aware full-metadata fetcher. Decoupled from the resolver pipeline
* so abbreviated metadata and `peekManifestFromStore` fast paths cannot
* hide the publish timestamp.
*/
fetchOpts: FetchMetadataFromFromRegistryOptions
getAuthHeaderValueByURI: (registry: string) => string | undefined
cacheDir?: FetchFullMetadataCachedOptions['cacheDir']
/** Overrides Date.now() for tests. */
now?: number
}
/**
* Returns a `ResolutionVerifier` that re-applies the `minimumReleaseAge`
* policy to npm-registry-resolved lockfile entries, or `undefined` when no
* policy is active. Pairs with `createNpmResolver`: each resolver factory
* may export a sibling verifier factory that the default-resolver combines.
*
* Designed for fail-closed semantics: if the manifest can't be loaded or
* the pinned version is missing from it, the verifier reports a violation
* rather than silently passing. Mirrors the post-resolution gate bun added
* for the same shape of bug in oven-sh/bun#30526.
*/
export function createNpmResolutionVerifier (
opts: CreateNpmResolutionVerifierOptions
): ResolutionVerifier | undefined {
if (!opts.minimumReleaseAge || !opts.minimumReleaseAgeStrict) return undefined
const cutoff = (opts.now ?? Date.now()) - opts.minimumReleaseAge * 60 * 1000
const excludePolicy = opts.minimumReleaseAgeExclude?.length
? createExcludePolicy(opts.minimumReleaseAgeExclude)
: undefined
// Pre-normalize named-registry URLs and sort by length so two registries
// that share a hostname but differ by path (e.g. `https://npm/team-a/` vs
// `https://npm/team-b/`) route to the longest matching prefix — matching
// only `origin` would silently send lookups to the wrong one. Built-in
// aliases (`gh:` → npm.pkg.github.com, etc.) are merged in alongside the
// user-defined ones so the verifier recognizes the same set of named
// registries the resolver does; otherwise a package resolved via `gh:`
// would land in the lockfile with a tarball URL the verifier can't route.
const namedRegistryPrefixes = Object.values({
...BUILTIN_NAMED_REGISTRIES,
...(opts.namedRegistries ?? {}),
})
.map((url) => {
const parsed = tryParseUrl(url)
if (!parsed) return null
// Ensure trailing slash so prefix matching against tarball URLs (which
// always include the package path under the registry root) does not
// accidentally match a sibling registry whose URL shares a prefix string.
const pathname = parsed.pathname.endsWith('/') ? parsed.pathname : `${parsed.pathname}/`
return `${parsed.origin}${pathname}`
})
.filter((value): value is string => value != null)
.sort((a, b) => b.length - a.length)
// In-memory dedup of the time map per (registry, name) for this verifier
// instance. The on-disk conditional-GET cache is handled inside
// fetchFullMetadataCached via the resolver's shared mirror at opts.cacheDir.
const inflight = new Map<string, Promise<Record<string, string | undefined> | undefined>>()
const fetchTimeMap = async (registry: string, name: string): Promise<Record<string, string | undefined> | undefined> => {
const cacheKey = `${registry}\x00${name}`
const cached = inflight.get(cacheKey)
if (cached) return cached
const promise = fetchFullMetadataCached(opts.fetchOpts, name, {
registry,
authHeaderValue: opts.getAuthHeaderValueByURI(registry),
cacheDir: opts.cacheDir,
}).then((meta) => meta.time)
inflight.set(cacheKey, promise)
return promise
}
return async (resolution, { name, version }) => {
if (!isNpmRegistryResolution(resolution)) return { ok: true }
// Non-semver versions identify URL tarballs, file: refs, git refs, etc.
// The age policy doesn't apply and a registry lookup would 404.
if (!semver.valid(version)) return { ok: true }
if (isExcluded(excludePolicy, name, version)) return { ok: true }
const tarballUrl = (resolution as { tarball?: string }).tarball
const registry = pickRegistryForVersion(opts.registries, namedRegistryPrefixes, name, tarballUrl)
let time: Record<string, string | undefined> | undefined
try {
time = await fetchTimeMap(registry, name)
} catch (err) {
return {
ok: false,
code: 'MINIMUM_RELEASE_AGE_VIOLATION',
reason: uncheckable(err instanceof Error ? err.message : String(err)),
}
}
const published = time?.[version]
if (!published) {
// Full metadata is missing this version — either an unpublish or the
// registry doesn't expose per-version timestamps for it. Either way
// the release-age can't be verified, so report a violation rather
// than silently passing.
return {
ok: false,
code: 'MINIMUM_RELEASE_AGE_VIOLATION',
reason: uncheckable('version not present in registry manifest'),
}
}
const publishedAt = new Date(published)
const ts = publishedAt.getTime()
if (Number.isNaN(ts)) {
return {
ok: false,
code: 'MINIMUM_RELEASE_AGE_VIOLATION',
reason: 'publish timestamp is not a valid date',
}
}
if (ts > cutoff) {
return {
ok: false,
code: 'MINIMUM_RELEASE_AGE_VIOLATION',
reason: `was published at ${publishedAt.toISOString()}, within the minimumReleaseAge cutoff (${new Date(cutoff).toISOString()})`,
}
}
return { ok: true }
}
}
function pickRegistryForVersion (
registries: Registries,
namedRegistryPrefixes: string[],
name: string,
tarballUrl: string | undefined
): string {
// If the lockfile records where the tarball lives, prefer that — scope
// routing (`@scope:registry`) only covers scoped packages, but named
// registries (`gh:`, `jsr:` aliases, custom) ship un-scoped packages whose
// origin we'd otherwise miss. Match the longest prefix so that two named
// registries sharing a host but differing by path don't collide.
if (tarballUrl) {
const normalized = tryParseUrl(tarballUrl)?.toString()
if (normalized) {
for (const prefix of namedRegistryPrefixes) {
if (normalized.startsWith(prefix)) return prefix
}
}
}
return pickRegistryForPackage(registries, name)
}
function tryParseUrl (url: string): URL | null {
try {
return new URL(url)
} catch {
return null
}
}
function uncheckable (why: string): string {
return `could not be checked against minimumReleaseAge (${why})`
}
function createExcludePolicy (patterns: string[]): PackageVersionPolicy {
// Mirror the wrapping done by the full-resolution path
// (installing/deps-resolver/src/resolveDependencyTree.ts) so the error
// code is identical regardless of which path surfaced the invalid pattern.
try {
return createPackageVersionPolicy(patterns)
} catch (err) {
if (!err || typeof err !== 'object' || !('message' in err)) throw err
throw new PnpmError(
'INVALID_MINIMUM_RELEASE_AGE_EXCLUDE',
`Invalid value in minimumReleaseAgeExclude: ${(err as { message: string }).message}`
)
}
}
function isExcluded (policy: PackageVersionPolicy | undefined, name: string, version: string): boolean {
if (!policy) return false
const result = policy(name)
if (result === true) return true
if (Array.isArray(result) && result.includes(version)) return true
return false
}
function isNpmRegistryResolution (resolution: Resolution | unknown): boolean {
if (resolution == null || typeof resolution !== 'object') return false
// Only plain tarball resolutions (npm registry / named registries) have no
// `type` field. Git / directory / binary / custom resolutions all carry one.
if ('type' in resolution && (resolution as { type?: unknown }).type != null) return false
// Git-hosted tarballs (codeload/gitlab/bitbucket) are special-cased in
// the resolver and aren't subject to release-age policy.
if ('gitHosted' in resolution && (resolution as { gitHosted?: boolean }).gitHosted) return false
return 'tarball' in resolution || 'integrity' in resolution
}

View File

@@ -0,0 +1,73 @@
import { FULL_META_DIR } from '@pnpm/constants'
import { PnpmError } from '@pnpm/error'
import type { PackageMeta } from '@pnpm/resolving.registry.types'
import { fetchMetadataFromFromRegistry, type FetchMetadataFromFromRegistryOptions } from './fetch.js'
import { getPkgMirrorPath, loadMeta, loadMetaHeaders, prepareJsonForDisk, saveMeta } from './pickPackage.js'
export interface FetchFullMetadataCachedOptions {
registry: string
authHeaderValue?: string
/**
* pnpm's on-disk cache directory. When set, the call issues a conditional
* GET against the same `FULL_META_DIR` mirror the resolver populates: a
* 304 Not Modified response serves the body from disk, a 200 writes the
* new body back. Omit to disable caching — every call re-fetches the
* full manifest.
*/
cacheDir?: string
}
/**
* Fetch a full registry metadata document for `pkgName`, reusing pnpm's
* shared on-disk metadata mirror when `cacheDir` is supplied. Built for the
* `minimumReleaseAge` lockfile revalidation gate, which needs the `time`
* field that abbreviated metadata omits; the cache reuse keeps repeat
* installs from re-downloading the same multi-megabyte document for every
* locked package.
*/
export async function fetchFullMetadataCached (
fetchOpts: FetchMetadataFromFromRegistryOptions,
pkgName: string,
opts: FetchFullMetadataCachedOptions
): Promise<PackageMeta> {
const pkgMirror = opts.cacheDir != null
? getPkgMirrorPath(opts.cacheDir, FULL_META_DIR, opts.registry, pkgName)
: null
const cacheHeaders = pkgMirror != null ? await loadMetaHeaders(pkgMirror) : null
const result = await fetchMetadataFromFromRegistry(fetchOpts, pkgName, {
registry: opts.registry,
authHeaderValue: opts.authHeaderValue,
fullMetadata: true,
etag: cacheHeaders?.etag,
modified: cacheHeaders?.modified,
})
if ('notModified' in result && result.notModified) {
if (pkgMirror == null) {
// We didn't send conditional headers (no cacheDir), but the registry
// returned 304 anyway. There's no body to fall back on.
throw new PnpmError(
'META_NOT_MODIFIED_WITHOUT_CACHE',
`Registry returned 304 for ${pkgName} without an existing cache to refresh.`
)
}
const meta = await loadMeta(pkgMirror)
if (meta == null) {
// Cache file vanished between header-load and meta-load (concurrent
// store cleanup, antivirus, etc.).
throw new PnpmError(
'META_CACHE_MISSING_AFTER_304',
`Metadata cache for ${pkgName} disappeared between headers read and full read.`
)
}
return meta
}
if (pkgMirror != null) {
// Persist so the next install can do a headers-only conditional GET.
// Fire-and-forget — a cache-write failure isn't a reason to fail the
// caller; the next install just won't get the speedup.
const json = prepareJsonForDisk(result.meta, result.etag, result.jsonText)
saveMeta(pkgMirror, json).catch(() => {})
}
return result.meta
}

View File

@@ -129,6 +129,7 @@ export {
RegistryResponseError,
workspacePrefToNpm,
}
export { createNpmResolutionVerifier, type CreateNpmResolutionVerifierOptions } from './createNpmResolutionVerifier.js'
export { whichVersionIsPinned } from './whichVersionIsPinned.js'
export interface ResolverFactoryOptions {

View File

@@ -206,8 +206,7 @@ export async function pickPackage (
: ABBREVIATED_META_DIR
// Cache key includes fullMetadata to avoid returning abbreviated metadata when full metadata is requested.
const cacheKey = fullMetadata ? `${spec.name}:full` : spec.name
const registryName = getRegistryName(opts.registry)
const pkgMirror = path.join(ctx.cacheDir, metaDir, registryName, `${encodePkgName(spec.name)}.jsonl`)
const pkgMirror = getPkgMirrorPath(ctx.cacheDir, metaDir, opts.registry, spec.name)
const cachedMeta = ctx.metaCache.get(cacheKey)
if (cachedMeta != null) {
// The in-memory cache may hold abbreviated metadata from an earlier call
@@ -564,19 +563,27 @@ function clearMeta (pkg: PackageMeta): PackageMeta {
}
}
function encodePkgName (pkgName: string): string {
export function encodePkgName (pkgName: string): string {
if (pkgName !== pkgName.toLowerCase()) {
return `${pkgName}_${createHexHash(pkgName)}`
}
return pkgName
}
/**
* Path of the on-disk JSONL document where pnpm mirrors a package's registry
* metadata. `metaDir` selects between abbreviated and full caches.
*/
export function getPkgMirrorPath (cacheDir: string, metaDir: string, registry: string, pkgName: string): string {
return path.join(cacheDir, metaDir, getRegistryName(registry), `${encodePkgName(pkgName)}.jsonl`)
}
/**
* Formats metadata for disk storage as two-line NDJSON:
* Line 1: cache headers (etag, modified) — small, fast to read
* Line 2: the full registry metadata JSON — unchanged from the registry response
*/
function prepareJsonForDisk (meta: PackageMeta, etag: string | undefined, jsonText?: string): string {
export function prepareJsonForDisk (meta: PackageMeta, etag: string | undefined, jsonText?: string): string {
const modified = meta.modified ?? meta.time?.modified
const headers = JSON.stringify({ etag, modified })
const body = jsonText ?? JSON.stringify(meta)
@@ -628,7 +635,7 @@ interface MetaHeaders {
* parsing the full metadata (which can be megabytes for popular packages)
* when we only need conditional-request headers.
*/
async function loadMetaHeaders (pkgMirror: string): Promise<MetaHeaders | null> {
export async function loadMetaHeaders (pkgMirror: string): Promise<MetaHeaders | null> {
let fh: fs.FileHandle | undefined
try {
fh = await fs.open(pkgMirror, 'r')
@@ -652,7 +659,7 @@ async function loadMetaHeaders (pkgMirror: string): Promise<MetaHeaders | null>
* Line 1: cache headers (etag, modified)
* Line 2: registry metadata JSON
*/
async function loadMeta (pkgMirror: string): Promise<PackageMeta | null> {
export async function loadMeta (pkgMirror: string): Promise<PackageMeta | null> {
try {
const data = await gfs.readFile(pkgMirror, 'utf8')
const newlineIdx = data.indexOf('\n')
@@ -668,7 +675,7 @@ async function loadMeta (pkgMirror: string): Promise<PackageMeta | null> {
const createdDirs = new Set<string>()
async function saveMeta (pkgMirror: string, json: string): Promise<void> {
export async function saveMeta (pkgMirror: string, json: string): Promise<void> {
const dir = path.dirname(pkgMirror)
if (!createdDirs.has(dir)) {
await fs.mkdir(dir, { recursive: true })

View File

@@ -82,6 +82,31 @@ export interface VariationsResolution {
export type Resolution = AtomicResolution | VariationsResolution
/**
* Outcome of asking a `ResolutionVerifier` whether a (name, version,
* resolution) entry from a lockfile is acceptable under whatever policies
* the resolver chain has been configured with. Resolvers that don't have
* an opinion on a given resolution should return `{ ok: true }`.
*/
export type ResolutionVerification =
| { ok: true }
| { ok: false, code: string, reason: string }
/**
* Optional companion to a resolver factory. Lets each resolver enforce
* policies (e.g. minimumReleaseAge for npm) against an already-resolved
* entry from a lockfile without re-doing resolution.
*
* The verifier inspects the `resolution` shape to decide whether the entry
* is within its protocol; for entries outside its protocol it should
* return `{ ok: true }`. Combined verifiers (in default-resolver) dispatch
* across underlying resolver-specific verifiers.
*/
export type ResolutionVerifier = (
resolution: Resolution,
ctx: { name: string, version: string }
) => Promise<ResolutionVerification>
/** Concrete platform selector used when picking a variant from a VariationsResolution. */
export interface PlatformSelector {
os: string

View File

@@ -34,6 +34,7 @@
"@pnpm/cli.meta": "workspace:*",
"@pnpm/config.reader": "workspace:*",
"@pnpm/installing.client": "workspace:*",
"@pnpm/resolving.resolver-base": "workspace:*",
"@pnpm/store.controller": "workspace:*",
"@pnpm/store.index": "workspace:*",
"@pnpm/store.path": "workspace:*",

View File

@@ -3,6 +3,7 @@ import { promises as fs } from 'node:fs'
import { packageManager } from '@pnpm/cli.meta'
import type { Config, ConfigContext } from '@pnpm/config.reader'
import { type ClientOptions, createClient } from '@pnpm/installing.client'
import type { ResolutionVerifier } from '@pnpm/resolving.resolver-base'
import { type CafsLocker, createPackageStore, type StoreController } from '@pnpm/store.controller'
import { StoreIndex } from '@pnpm/store.index'
@@ -34,6 +35,7 @@ export type CreateNewStoreControllerOptions = CreateResolverOptions & Pick<Confi
| 'localAddress'
| 'maxSockets'
| 'minimumReleaseAge'
| 'minimumReleaseAgeExclude'
| 'minimumReleaseAgeIgnoreMissingTime'
| 'minimumReleaseAgeStrict'
| 'networkConcurrency'
@@ -61,7 +63,7 @@ export type CreateNewStoreControllerOptions = CreateResolverOptions & Pick<Confi
export async function createNewStoreController (
opts: CreateNewStoreControllerOptions
): Promise<{ ctrl: StoreController, dir: string }> {
): Promise<{ ctrl: StoreController, dir: string, verifyResolution?: ResolutionVerifier }> {
const fullMetadata = opts.fetchFullMetadata ?? (
(
opts.resolutionMode === 'time-based' ||
@@ -70,7 +72,7 @@ export async function createNewStoreController (
)
await fs.mkdir(opts.storeDir, { recursive: true })
const storeIndex = new StoreIndex(opts.storeDir)
const { resolve, fetchers, clearResolutionCache } = createClient({
const { resolve, fetchers, clearResolutionCache, verifyResolution } = createClient({
customResolvers: opts.hooks?.customResolvers,
customFetchers: opts.hooks?.customFetchers,
unsafePerm: opts.unsafePerm,
@@ -115,6 +117,9 @@ export async function createNewStoreController (
preserveAbsolutePaths: opts.preserveAbsolutePaths,
strictPublishedByCheck: Boolean(opts.minimumReleaseAge) && opts.minimumReleaseAgeStrict === true,
ignoreMissingTimeField: opts.minimumReleaseAgeIgnoreMissingTime,
minimumReleaseAge: opts.minimumReleaseAge,
minimumReleaseAgeStrict: opts.minimumReleaseAgeStrict,
minimumReleaseAgeExclude: opts.minimumReleaseAgeExclude,
storeIndex,
})
return {
@@ -140,5 +145,6 @@ export async function createNewStoreController (
storeIndex,
}),
dir: opts.storeDir,
verifyResolution,
}
}

View File

@@ -1,4 +1,5 @@
import type { Config } from '@pnpm/config.reader'
import type { ResolutionVerifier } from '@pnpm/resolving.resolver-base'
import type { StoreController } from '@pnpm/store.controller'
import { getStorePath } from '@pnpm/store.path'
@@ -13,10 +14,16 @@ export type CreateStoreControllerOptions = Omit<CreateNewStoreControllerOptions,
| 'workspaceDir'
>
export interface StoreControllerHandle {
ctrl: StoreController
dir: string
verifyResolution?: ResolutionVerifier
}
export async function createStoreControllerCached (
storeControllerCache: Map<string, Promise<{ ctrl: StoreController, dir: string }>>,
storeControllerCache: Map<string, Promise<StoreControllerHandle>>,
opts: CreateStoreControllerOptions
): Promise<{ ctrl: StoreController, dir: string }> {
): Promise<StoreControllerHandle> {
const storeDir = await getStorePath({
pkgRoot: opts.dir,
storePath: opts.storeDir,
@@ -25,15 +32,12 @@ export async function createStoreControllerCached (
if (!storeControllerCache.has(storeDir)) {
storeControllerCache.set(storeDir, createStoreController(opts))
}
return await storeControllerCache.get(storeDir) as { ctrl: StoreController, dir: string }
return await storeControllerCache.get(storeDir) as StoreControllerHandle
}
export async function createStoreController (
opts: CreateStoreControllerOptions
): Promise<{
ctrl: StoreController
dir: string
}> {
): Promise<StoreControllerHandle> {
const storeDir = await getStorePath({
pkgRoot: opts.workspaceDir ?? opts.dir,
storePath: opts.storeDir,

View File

@@ -21,6 +21,9 @@
{
"path": "../../installing/client"
},
{
"path": "../../resolving/resolver-base"
},
{
"path": "../controller"
},

View File

@@ -34,6 +34,7 @@
"dependencies": {
"@pnpm/installing.client": "workspace:*",
"@pnpm/registry-mock": "catalog:",
"@pnpm/resolving.resolver-base": "workspace:*",
"@pnpm/store.controller": "workspace:*",
"@pnpm/store.controller-types": "workspace:*",
"@pnpm/store.index": "workspace:*"

View File

@@ -2,6 +2,7 @@ import * as path from 'node:path'
import { type ClientOptions, createClient } from '@pnpm/installing.client'
import { REGISTRY_MOCK_PORT } from '@pnpm/registry-mock'
import type { ResolutionVerifier } from '@pnpm/resolving.resolver-base'
import { createPackageStore, type CreatePackageStoreOptions } from '@pnpm/store.controller'
import type { StoreController } from '@pnpm/store.controller-types'
import { StoreIndex } from '@pnpm/store.index'
@@ -12,6 +13,7 @@ export interface CreateTempStoreResult {
storeController: StoreController
storeDir: string
cacheDir: string
verifyResolution?: ResolutionVerifier
}
export function createTempStore (opts?: {
@@ -24,7 +26,7 @@ export function createTempStore (opts?: {
const cacheDir = path.resolve('cache')
const storeDir = opts?.storeDir ?? path.resolve('.store')
const storeIndex = new StoreIndex(storeDir)
const { resolve, fetchers, clearResolutionCache } = createClient({
const { resolve, fetchers, clearResolutionCache, verifyResolution } = createClient({
configByUri,
retry: {
retries: 4,
@@ -58,5 +60,6 @@ export function createTempStore (opts?: {
storeController,
storeDir,
cacheDir,
verifyResolution,
}
}

View File

@@ -12,6 +12,9 @@
{
"path": "../../installing/client"
},
{
"path": "../../resolving/resolver-base"
},
{
"path": "../../store/controller"
},