Skip to content

Commit 31538bf

Browse files
ryo-manbazkochan
andauthored
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](oven-sh/bun#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>
1 parent c178d13 commit 31538bf

32 files changed

Lines changed: 943 additions & 28 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@pnpm/resolving.resolver-base": minor
3+
"@pnpm/resolving.npm-resolver": minor
4+
"@pnpm/resolving.default-resolver": minor
5+
"@pnpm/installing.client": minor
6+
"@pnpm/store.connection-manager": minor
7+
"@pnpm/testing.temp-store": minor
8+
"@pnpm/installing.deps-installer": minor
9+
"pnpm": patch
10+
---
11+
12+
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).
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@pnpm/installing.deps-installer": minor
3+
"pnpm": patch
4+
---
5+
6+
`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).

installing/client/src/index.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,17 @@ import type { CustomFetcher, CustomResolver } from '@pnpm/hooks.types'
99
import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header'
1010
import { createFetchFromRegistry, type DispatcherOptions } from '@pnpm/network.fetch'
1111
import {
12+
createResolutionVerifier,
1213
createResolver as _createResolver,
14+
type ResolutionVerifierFactoryOptions,
1315
type ResolveFunction,
1416
type ResolverFactoryOptions,
1517
} from '@pnpm/resolving.default-resolver'
18+
import type { ResolutionVerifier } from '@pnpm/resolving.resolver-base'
1619
import type { StoreIndex } from '@pnpm/store.index'
1720
import type { RegistryConfig } from '@pnpm/types'
1821

19-
export type { ResolveFunction }
22+
export type { ResolutionVerifier, ResolveFunction }
2023

2124
export type ClientOptions = {
2225
configByUri: Record<string, RegistryConfig>
@@ -35,22 +38,32 @@ export type ClientOptions = {
3538
preserveAbsolutePaths?: boolean
3639
fetchMinSpeedKiBps?: number
3740
} & ResolverFactoryOptions & DispatcherOptions
41+
& Pick<ResolutionVerifierFactoryOptions, 'minimumReleaseAge' | 'minimumReleaseAgeStrict' | 'minimumReleaseAgeExclude'>
3842

3943
export interface Client {
4044
fetchers: Fetchers
4145
resolve: ResolveFunction
4246
clearResolutionCache: () => void
47+
/**
48+
* Combined verifier across the resolver chain. `undefined` when no
49+
* resolver-level policy is active (today: minimumReleaseAge strict mode).
50+
* Used by the install layer to re-validate an already-resolved lockfile
51+
* entry without re-doing resolution.
52+
*/
53+
verifyResolution?: ResolutionVerifier
4354
}
4455

4556
export function createClient (opts: ClientOptions): Client {
4657
const fetchFromRegistry = createFetchFromRegistry(opts)
4758
const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri, opts.registries?.default)
4859

4960
const { resolve, clearCache: clearResolutionCache } = _createResolver(fetchFromRegistry, getAuthHeader, { ...opts, customResolvers: opts.customResolvers })
61+
const verifyResolution = createResolutionVerifier(fetchFromRegistry, opts)
5062
return {
5163
fetchers: createFetchers(fetchFromRegistry, getAuthHeader, opts),
5264
resolve,
5365
clearResolutionCache,
66+
verifyResolution,
5467
}
5568
}
5669

installing/commands/src/fetch.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export async function handler (opts: FetchCommandOptions): Promise<void> {
7070
pruneStore: true,
7171
storeController: store.ctrl,
7272
storeDir: store.dir,
73+
verifyResolution: store.verifyResolution,
7374
// Hoisting is skipped anyway,
7475
// so we store these empty patterns in node_modules/.modules.yaml
7576
// to let the subsequent install know that hoisting should be performed.

installing/commands/src/import/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ export async function handler (
186186
preferredVersions,
187187
storeController: store.ctrl,
188188
storeDir: store.dir,
189+
verifyResolution: store.verifyResolution,
189190
}
190191
await install(manifest, installOpts)
191192
}

installing/commands/src/installDeps.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ export async function installDeps (
279279
skipRuntimes: opts.runtime === false,
280280
storeController: store.ctrl,
281281
storeDir: store.dir,
282+
verifyResolution: store.verifyResolution,
282283
workspacePackages,
283284
preferredVersions: opts.packageVulnerabilityAudit ? preferNonvulnerablePackageVersions(opts.packageVulnerabilityAudit) : undefined,
284285
}

installing/commands/src/recursive.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
import { logger } from '@pnpm/logger'
3333
import { filterDependenciesByType } from '@pnpm/pkg-manifest.utils'
3434
import type { PreferredVersions } from '@pnpm/resolving.resolver-base'
35+
import type { ResolutionVerifier } from '@pnpm/resolving.resolver-base'
3536
import { createStoreController, type CreateStoreControllerOptions } from '@pnpm/store.connection-manager'
3637
import type { StoreController } from '@pnpm/store.controller'
3738
import type {
@@ -114,6 +115,7 @@ export type RecursiveOptions = CreateStoreControllerOptions & Pick<Config,
114115
storeControllerAndDir?: {
115116
ctrl: StoreController
116117
dir: string
118+
verifyResolution?: ResolutionVerifier
117119
}
118120
pnpmfile: string[]
119121
} & Partial<
@@ -165,6 +167,7 @@ export async function recursive (
165167
storeController: store.ctrl,
166168
storeDir: store.dir,
167169
targetDependenciesField,
170+
verifyResolution: store.verifyResolution,
168171
workspacePackages,
169172
}) as InstallOptions
170173

@@ -296,6 +299,7 @@ export async function recursive (
296299
} = await mutateModules(mutatedImporters, {
297300
...installOpts,
298301
storeController: store.ctrl,
302+
verifyResolution: store.verifyResolution,
299303
})
300304
if (opts.save !== false) {
301305
const promises: Array<Promise<void>> = mutatedPkgs.map(async ({ originalManifest, manifest, rootDir }) => {
@@ -414,6 +418,7 @@ export async function recursive (
414418
}),
415419
configByUri: installOpts.configByUri,
416420
storeController: store.ctrl,
421+
verifyResolution: store.verifyResolution,
417422
}
418423
)
419424
if (opts.save !== false) {

installing/commands/src/remove.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ export async function handler (
188188
linkWorkspacePackagesDepth: opts.linkWorkspacePackages === 'deep' ? Infinity : opts.linkWorkspacePackages ? 0 : -1,
189189
storeController: store.ctrl,
190190
storeDir: store.dir,
191+
verifyResolution: store.verifyResolution,
191192
include,
192193
})
193194
const allProjects = opts.allProjects ?? (

installing/deps-installer/src/install/extendInstallOptions.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type { ProjectOptions } from '@pnpm/installing.context'
1111
import type { HoistingLimits } from '@pnpm/installing.deps-restorer'
1212
import type { IncludedDependencies } from '@pnpm/installing.modules-yaml'
1313
import type { LockfileObject } from '@pnpm/lockfile.fs'
14-
import type { WorkspacePackages } from '@pnpm/resolving.resolver-base'
14+
import type { ResolutionVerifier, WorkspacePackages } from '@pnpm/resolving.resolver-base'
1515
import type { StoreController } from '@pnpm/store.controller-types'
1616
import type {
1717
AllowedDeprecatedVersions,
@@ -175,6 +175,15 @@ export interface StrictInstallOptions {
175175
ci?: boolean
176176
minimumReleaseAge?: number
177177
minimumReleaseAgeExclude?: string[]
178+
/**
179+
* Optional verifier that re-checks each lockfile-pinned resolution
180+
* against policies configured upstream (today: minimumReleaseAge strict
181+
* mode). Constructed by `createClient` and surfaced via the
182+
* `createStoreController` return; mutateModules invokes it once, right
183+
* after the lockfile is loaded from disk. When omitted, no revalidation
184+
* runs.
185+
*/
186+
verifyResolution?: ResolutionVerifier
178187
trustPolicy?: TrustPolicy
179188
trustPolicyExclude?: string[]
180189
trustPolicyIgnoreAfter?: number

installing/deps-installer/src/install/index.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ import {
9595
import { linkPackages } from './link.js'
9696
import { reportPeerDependencyIssues } from './reportPeerDependencyIssues.js'
9797
import { validateModules } from './validateModules.js'
98+
import { verifyLockfileResolutions } from './verifyLockfileResolutions.js'
9899

99100
class LockfileConfigMismatchError extends PnpmError {
100101
constructor (outdatedLockfileSettingName: string) {
@@ -274,6 +275,11 @@ export async function mutateModules (
274275
maybeOpts: MutateModulesOptions
275276
): Promise<MutateModulesResult> {
276277
const reporter = maybeOpts?.reporter
278+
const detachReporter = (reporter != null) && typeof reporter === 'function'
279+
? () => {
280+
streamParser.removeListener('data', reporter)
281+
}
282+
: () => {}
277283
if ((reporter != null) && typeof reporter === 'function') {
278284
streamParser.on('data', reporter)
279285
}
@@ -328,6 +334,26 @@ export async function mutateModules (
328334
}
329335
}
330336

337+
// Re-validate every entry in the lockfile against the policies the
338+
// resolver chain was built with (today: minimumReleaseAge in strict mode
339+
// via the npm verifier; the abstraction supports other resolvers
340+
// attaching their own verifiers). The threat model is a lockfile that
341+
// someone else resolved — committed to the repo, restored from a CI
342+
// cache, etc. — bypassing the local resolver's policy filters; the local
343+
// resolver's own filters already cover fresh resolution. We run this
344+
// exactly once, right after the lockfile is loaded from disk, before any
345+
// path branches.
346+
try {
347+
await verifyLockfileResolutions(ctx.wantedLockfile, opts.verifyResolution)
348+
} catch (err) {
349+
// verifyLockfileResolutions is the one throw site in this function
350+
// that's part of normal user-facing operation (a rejected lockfile);
351+
// other throws here are unexpected. Detach the reporter listener so
352+
// long-lived processes don't leak it on every rejected install.
353+
detachReporter()
354+
throw err
355+
}
356+
331357
if (opts.hooks.preResolution) {
332358
for (const preResolution of opts.hooks.preResolution) {
333359
// eslint-disable-next-line no-await-in-loop
@@ -415,9 +441,7 @@ export async function mutateModules (
415441
packageNames: ignoredBuilds ? dedupePackageNamesFromIgnoredBuilds(ignoredBuilds) : [],
416442
})
417443

418-
if ((reporter != null) && typeof reporter === 'function') {
419-
streamParser.removeListener('data', reporter)
420-
}
444+
detachReporter()
421445

422446
return {
423447
updatedCatalogs: result.updatedCatalogs,

0 commit comments

Comments
 (0)