feat: tighten minimumReleaseAge — auto-exclude, lockfile verification, and interactive prompt (#11705)

Three coordinated changes that close the silent-bypass gap in loose `minimumReleaseAge` mode AND the discover-by-loop UX problem in strict mode (#10488), plus a parallel hardening of the lockfile verifier:

1. **Auto-collect into `minimumReleaseAgeExclude` (loose mode)** — fresh resolutions that fall back to a version newer than the cutoff are auto-recorded into the workspace manifest's `minimumReleaseAgeExclude`. A single info message lists what was persisted. The workspace manifest writer dedupes against existing entries.

2. **Lockfile verifier runs in loose mode too** — `createNpmResolutionVerifier` no longer gates on `minimumReleaseAgeStrict`. With auto-collect keeping the exclude list explicit, every accepted-immature pin must be on the list — same contract strict mode enforces. Lockfiles produced under a weaker (or absent) policy that still hold immature entries are rejected the same way strict mode would.

3. **Strict mode prompts on the aggregate set instead of throwing on the first** — the resolver always collects every immature direct and transitive in one pass; the install command's `handleResolutionPolicyViolations` checkpoint decides what to do with the set. Interactive (TTY) prompts the user once with the full list (default = No) and asks whether to add them all to `minimumReleaseAgeExclude` and proceed. Approve → install continues, persisted at the end. Decline → resolution aborts before the lockfile, package.json, or modules dir is touched. Non-interactive (CI) keeps `ERR_PNPM_NO_MATURE_MATCHING_VERSION` as the exit code but lists every offending entry instead of just the first one the resolver happened to hit.

4. **The lockfile verifier now also covers `trustPolicy: 'no-downgrade'`.** The same post-resolution gate that re-checks `minimumReleaseAge` on lockfile entries now re-runs `failIfTrustDowngraded` for every npm-registry entry whose name isn't on `trustPolicyExclude`. The two checks share a single full-metadata fetch per package, so the extra coverage doesn't cost an extra round trip when both policies are active. Resolver-time trust checks still run as before — this just closes the gap when an entry bypasses resolution (peek path, `--frozen-lockfile`, restored CI cache).

The steady-state flows:

- **Loose mode, `pnpm add foo@immature`**: lockfile clean, verifier no-op, resolver picks via lowest-version fallback, `foo@immature` lands in `minimumReleaseAgeExclude`, install succeeds. Subsequent `pnpm install --frozen-lockfile` in CI verifies against the populated list and succeeds.
- **Strict mode (interactive), security bump to `next@15.5.9`**: resolver collects `next@15.5.9` AND every immature `@next/swc-*@15.5.9` shim. pnpm prompts once with the full list. User approves → install completes, all entries persisted in `pnpm-workspace.yaml`. CI then runs the populated config cleanly.
- **Strict mode (non-interactive / CI)**: aborts with `ERR_PNPM_NO_MATURE_MATCHING_VERSION` listing every immature entry's `name@version` and publish time — no more discover-by-loop dance.
- **Teammate commits a poisoned lockfile**: single-policy batches reject with `ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION` (or `ERR_PNPM_TRUST_DOWNGRADE`); a batch that trips both policies escalates to the generic `ERR_PNPM_LOCKFILE_RESOLUTION_VERIFICATION` and lists each entry's per-policy code in the breakdown.

### Implementation

- The npm resolver always falls back to the lowest matching version when no mature version satisfies the range, and flags the result with `ResolveResult.policyViolation` instead of throwing `NO_MATURE_MATCHING_VERSION`. `deferImmatureDecision` and `strictPublishedByCheck` are gone — every caller (install, dlx, outdated, self-update) inspects the violation and decides what to do.
- `policyViolation` flows from `ResolveResult` → `PackageResponse.body.policyViolation` → a shared accumulator in `ResolutionContext` → the `resolutionPolicyViolations` field on `resolveDependencyTree`'s return → out through `mutateModules` / `addDependenciesToPackage` to the install command.
- The violation type lives in `@pnpm/resolving.resolver-base` as `ResolutionPolicyViolation`; the npm resolver exports the two built-in codes (`MINIMUM_RELEASE_AGE_VIOLATION_CODE`, `TRUST_DOWNGRADE_VIOLATION_CODE`) as constants so consumers reference one source of truth.
- `handleResolutionPolicyViolations` runs between `resolveDependencyTree` and `resolvePeers` — the resolver-agnostic checkpoint where the install command's plan prompts (TTY) or aborts (no-TTY) with the full violation list.
- `setupPolicyHandlers` (in `installing/commands/src/policyHandlers.ts`) composes per-policy handlers behind a uniform plan interface: each handler has its own `handleResolutionPolicyViolations` (filter by code, decide what to do) and `pickManifestUpdates` (return a typed `WorkspaceManifestPolicyUpdates` patch the install command spreads into `updateWorkspaceManifest`). Today the only registered handler is `createMinimumReleaseAgeHandler` — strict + TTY prompts via `enquirer`, strict no-TTY throws `ERR_PNPM_NO_MATURE_MATCHING_VERSION` with every entry listed, loose mode auto-persists at the tail. Strict + `--no-save` is rejected up-front via `ERR_PNPM_STRICT_MIN_RELEASE_AGE_REQUIRES_SAVE`. Future policies plug in via a sibling factory + push into the handlers list, with no changes to `installDeps.ts` / `recursive.ts`.
- `installDeps` / `recursive` drain `pickManifestUpdates` after install and spread the patch into `updateWorkspaceManifest`. Plain `pnpm install` (no `--update`, no params) now still updates the workspace manifest when any handler contributes a patch. The `install` command's CLI schema gained `save: Boolean` so `--no-save` actually flows through to `opts.save = false` instead of being silently dropped by nopt.
- `makeResolutionStrict` (in `installing/client`) wraps a `ResolveFunction` and rethrows any `policyViolation` as a `PnpmError`. Used by `dlx` and `self-update` under strict `minimumReleaseAge` OR `trustPolicy: 'no-downgrade'`, since one-shot callers have nowhere to defer a violation to. Violation-code → error-code mapping lives in one place so future violation kinds get consistent UX.
- `createNpmResolutionVerifier` extends its check to `trustPolicy: 'no-downgrade'` — same per-entry fan-out, same cache key, sharing the full-metadata fetch with the maturity check. Trust-fetch errors now propagate up so the violation reason carries the underlying message (network code, 404 detail) instead of a generic "metadata is unavailable".
- `verifyLockfileResolutions`'s aggregate throw uses the per-policy code when every violation in the batch shares it, and escalates to a generic `LOCKFILE_RESOLUTION_VERIFICATION` (with per-entry codes in the breakdown) for mixed batches.
- The pnpm agent path refuses installs under `trustPolicy: 'no-downgrade'` (`ERR_PNPM_TRUST_POLICY_INCOMPATIBLE_WITH_AGENT`) — the agent has no server-side counterpart to that check yet, so silently allowing it would land a lockfile the local verifier would later reject. `minimumReleaseAge` is forwarded to the agent and enforced server-side, so that combination is fine.

### Pacquet parity

Pacquet only carries a stub reference to `minimumReleaseAgeExclude` (see `pacquet/crates/package-manager/src/version_policy.rs`); the broader `minimumReleaseAge` and `trustPolicy` policies aren't ported yet, so this feature is outside pacquet's current surface area. It'll come along when pacquet ports the policies.

### Closes

- Closes #10488 (resolves the discover-by-loop dance for security bumps without needing `withTransitives`).
This commit is contained in:
Zoltan Kochan
2026-05-18 09:51:11 +02:00
committed by GitHub
parent 02f8138f13
commit 4195766f10
46 changed files with 2183 additions and 300 deletions

View File

@@ -0,0 +1,27 @@
---
"@pnpm/resolving.resolver-base": minor
"@pnpm/store.controller-types": minor
"@pnpm/resolving.npm-resolver": minor
"@pnpm/resolving.default-resolver": minor
"@pnpm/installing.client": minor
"@pnpm/installing.deps-resolver": minor
"@pnpm/installing.deps-installer": minor
"@pnpm/installing.commands": minor
"@pnpm/store.connection-manager": minor
"@pnpm/deps.inspection.outdated": patch
"@pnpm/engine.pm.commands": patch
"@pnpm/exec.commands": patch
"@pnpm/cli.default-reporter": patch
"pnpm": minor
---
Tightened the `minimumReleaseAge` story so the bypass becomes explicit on disk instead of silent, and removed the discover-by-loop dance for strict-mode users:
1. Fresh resolutions in loose mode (`minimumReleaseAgeStrict: false`) that fall back to a version newer than the cutoff auto-collect the picked `name@version` into the workspace manifest's `minimumReleaseAgeExclude`. A single info message lists the additions; entries already on the list are left alone.
2. The post-resolution lockfile verifier introduced in #11583 now runs in loose mode too — every accepted-immature pin must be on `minimumReleaseAgeExclude`, just like strict mode requires. A lockfile produced under a weaker (or absent) policy that still has immature entries is rejected the same way strict mode would reject it.
3. **Strict mode (interactive)** no longer aborts on the first immature pick. The resolver gathers every immature direct *and* transitive in one pass; before peer-dependency resolution runs, pnpm prompts the user with the full list and asks whether to add them all to `minimumReleaseAgeExclude` and proceed. Approve → install continues and the workspace manifest is written at the end. Decline → resolution aborts before the lockfile or package.json is touched (tarballs already in the store stay, since the store is idempotent). This closes the [#10488](https://github.com/pnpm/pnpm/issues/10488) loop where security bumps to packages with platform-specific transitives (e.g. `next` + the `@next/swc-*` shims) made users re-run `pnpm add` once per transitive.
4. **Strict mode (non-interactive / CI)** now aborts with the full immature set in the error message instead of the first pick. The resolver always collects every immature direct + transitive; the install command then throws `ERR_PNPM_NO_MATURE_MATCHING_VERSION` listing each entry's `name@version` and publish time. Deterministic CI behavior is preserved (same exit code, same error code), but the error pinpoints every offending entry instead of forcing the discover-by-loop dance. The expected workflow is interactive approval locally → the lockfile + workspace manifest get committed → CI runs cleanly against the populated exclude list.
5. **The lockfile verifier now also covers `trustPolicy: 'no-downgrade'`.** The same post-resolution gate that re-checks `minimumReleaseAge` on lockfile entries now re-runs `failIfTrustDowngraded` for every npm-registry entry whose name isn't on `trustPolicyExclude`. The two checks share a single full-metadata fetch per package, so the extra coverage doesn't cost an extra round trip when both policies are active. Resolver-time trust checks still run as before — this just closes the gap when an entry bypasses resolution (peek path, `--frozen-lockfile`, restored CI cache).
Pacquet parity: not ported — pacquet's `minimumReleaseAge` policy is itself only stubbed today (see `pacquet/crates/package-manager/src/version_policy.rs`). The auto-exclude, loose-mode verifier, prompt, and the new trust-policy verifier check will travel with the broader policy port whenever that happens.

View File

@@ -19,6 +19,15 @@ packages:
- '!workspace-has-shared-npm-shrinkwrap-json'
sharedWorkspaceLockfile: false
# The fixture lockfiles are pinned to packages from the local registry-mock
# (e.g. `@pnpm.e2e/*`). The v11 default of `minimumReleaseAge: 1440` would
# spin up the lockfile verifier here, which can't reach the mock from this
# install context and rejects the entries as un-checkable. Disable the
# policy for fixture installs — they're test scaffolding, not a real
# project. The actual minimumReleaseAge code paths are covered by the
# unit and e2e tests in their own packages.
minimumReleaseAge: 0
catalog:
# Used in has-outdated-deps-using-catalog-protocol fixture.
is-negative: ^1.0.0

View File

@@ -72,7 +72,12 @@ function getErrorInfo (logObj: Log, config?: Config): ErrorInfo | null {
return { title: err.message, body: 'If you cannot fix this registry issue, then set "resolution-mode" to "highest".' }
case 'ERR_PNPM_NO_MATCHING_VERSION':
case 'ERR_PNPM_NO_MATURE_MATCHING_VERSION':
return formatNoMatchingVersion(err, logObj as unknown as { packageMeta: PackageMeta, immatureVersion?: string })
// ERR_PNPM_NO_MATURE_MATCHING_VERSION used to come from the resolver
// with `packageMeta` attached; it now comes from the install / dlx /
// self-update callers as a plain PnpmError once the resolver has
// surfaced the violations. `packageMeta` may be undefined, in which
// case the formatter falls back to the bare title+message.
return formatNoMatchingVersion(err, logObj as unknown as { packageMeta?: PackageMeta })
case 'ERR_PNPM_RECURSIVE_FAIL':
return formatRecursiveCommandSummary(logObj as any) // eslint-disable-line @typescript-eslint/no-explicit-any
case 'ERR_PNPM_BAD_TARBALL_SIZE':
@@ -134,11 +139,18 @@ interface PackageMeta {
time?: Record<string, string>
}
function formatNoMatchingVersion (err: Error, msg: { packageMeta: PackageMeta, immatureVersion?: string }) {
const meta: PackageMeta = msg.packageMeta
function formatNoMatchingVersion (err: Error, msg: { packageMeta?: PackageMeta }) {
// Errors raised by the install/dlx/self-update layer after the resolver
// surfaces violations may not carry the original packageMeta. In that
// case the error message alone already names every offending entry,
// so we just echo it through without the registry-metadata appendix.
const meta = msg.packageMeta
if (!meta) {
return { title: err.message }
}
const latestVersion = meta['dist-tags'].latest
let output = `The latest release of ${meta.name} is "${latestVersion}".`
const latestTime = msg.packageMeta.time?.[latestVersion]
const latestTime = meta.time?.[latestVersion]
if (latestTime) {
output += ` Published at ${stringifyDate(latestTime)}`
}
@@ -150,7 +162,7 @@ function formatNoMatchingVersion (err: Error, msg: { packageMeta: PackageMeta, i
if (tag !== 'latest') {
const version = meta['dist-tags'][tag]
output += ` * ${tag}: ${version}`
const time = msg.packageMeta.time?.[version]
const time = meta.time?.[version]
if (time) {
output += ` published at ${stringifyDate(time)}`
}
@@ -161,10 +173,6 @@ function formatNoMatchingVersion (err: Error, msg: { packageMeta: PackageMeta, i
output += `${EOL}If you need the full list of all ${Object.keys(meta.versions).length} published versions run "pnpm view ${meta.name} versions".`
if (msg.immatureVersion) {
output += `${EOL}${EOL}If you want to install the matched version ignoring the time it was published, you can add the package name to the minimumReleaseAgeExclude setting. Read more about it: https://pnpm.io/settings#minimumreleaseageexclude`
}
return {
title: err.message,
body: output,

View File

@@ -29,7 +29,6 @@ export function createManifestGetter (
...opts,
configByUri: opts.configByUri,
filterMetadata: false, // We need all the data from metadata for "outdated --long" to work.
strictPublishedByCheck: Boolean(opts.minimumReleaseAge) && opts.minimumReleaseAgeStrict === true,
ignoreMissingTimeField: opts.minimumReleaseAgeIgnoreMissingTime,
})
@@ -58,18 +57,22 @@ export async function getManifest (
publishedBy: opts.publishedBy,
publishedByExclude: opts.publishedByExclude,
})
// No mature version found within range: the resolver fell back to the
// lowest immature pick and flagged it inline. `outdated` shouldn't
// present an immature version as "available", so treat it as no match
// — matching the pre-violation-collection behavior when the resolver
// threw `NO_MATURE_MATCHING_VERSION`.
if (resolution?.policyViolation?.code === 'MINIMUM_RELEASE_AGE_VIOLATION') {
return null
}
return resolution?.manifest ?? null
} catch (err) {
const code = (err as { code?: string }).code
if (opts.publishedBy && (
code === 'ERR_PNPM_NO_MATURE_MATCHING_VERSION' ||
code === 'ERR_PNPM_NO_MATCHING_VERSION'
)) {
// No versions found that meet the minimumReleaseAge requirement.
// This can happen when all published versions (including the one the
// "latest" dist-tag points to) are newer than the minimumReleaseAge
// threshold, causing the resolver to throw NO_MATCHING_VERSION instead
// of NO_MATURE_MATCHING_VERSION.
if (opts.publishedBy && code === 'ERR_PNPM_NO_MATCHING_VERSION') {
// No version satisfies the range at all (not a maturity issue).
// Pre-violation-collection this branch also covered the maturity
// case via `NO_MATURE_MATCHING_VERSION`; with always-defer, that
// case is handled above as a `policyViolation`.
return null
}
throw err

View File

@@ -58,14 +58,30 @@ test('getManifest() with minimumReleaseAge filters latest when too new', async (
const publishedBy = new Date(Date.now() - 10080 * 60 * 1000)
// The resolver no longer throws on immature picks — it falls back to
// the lowest matching version and flags the result with `policyViolation`.
// outdated treats that as "no version available within the policy" and
// returns null, same as the pre-refactor throw path.
const resolve = jest.fn<ResolveFunction>(async (wantedPackage, resolveOpts) => {
expect(wantedPackage.bareSpecifier).toBe('latest')
expect(resolveOpts.publishedBy).toBeInstanceOf(Date)
// Simulate latest version being too new
const error = new Error('No matching version found') as Error & { code?: string }
error.code = 'ERR_PNPM_NO_MATURE_MATCHING_VERSION'
throw error
return {
id: 'foo/2.0.0' as PkgResolutionId,
latest: '2.0.0',
manifest: {
name: 'foo',
version: '2.0.0',
},
resolution: {} as TarballResolution,
resolvedVia: 'npm-registry',
policyViolation: {
name: 'foo',
version: '2.0.0',
resolution: {} as TarballResolution,
code: 'MINIMUM_RELEASE_AGE_VIOLATION',
reason: 'was published within the minimumReleaseAge cutoff',
},
}
})
const result = await getManifest({ ...opts, resolve, publishedBy }, 'foo', 'latest')

View File

@@ -7,7 +7,7 @@ import { docsUrl } from '@pnpm/cli.utils'
import { type Config, type ConfigContext, parsePackageManager, types as allTypes } from '@pnpm/config.reader'
import { getPublishedByPolicy } from '@pnpm/config.version-policy'
import { PnpmError } from '@pnpm/error'
import { createResolver } from '@pnpm/installing.client'
import { createResolver, makeResolutionStrict } from '@pnpm/installing.client'
import { resolvePackageManagerIntegrities } from '@pnpm/installing.env-installer'
import { readEnvLockfile } from '@pnpm/lockfile.fs'
import { globalInfo, globalWarn } from '@pnpm/logger'
@@ -80,12 +80,21 @@ export async function handler (
throw new PnpmError('CANT_SELF_UPDATE_IN_COREPACK', 'You should update pnpm with corepack')
}
globalInfo('Checking for updates...')
const { resolve } = createResolver({
const { resolve: baseResolve } = createResolver({
...opts,
configByUri: opts.configByUri,
strictPublishedByCheck: Boolean(opts.minimumReleaseAge) && opts.minimumReleaseAgeStrict === true,
ignoreMissingTimeField: opts.minimumReleaseAgeIgnoreMissingTime,
})
// self-update has nowhere to "defer to" either — wrap the resolver
// under any policy that wants to reject violations up-front. Strict
// minimumReleaseAge keeps self-update from switching to an immature
// pnpm; `trustPolicy: 'no-downgrade'` keeps it from switching to a
// pnpm whose trust evidence weakened relative to the installed
// version.
const strictResolution =
(Boolean(opts.minimumReleaseAge) && opts.minimumReleaseAgeStrict === true) ||
opts.trustPolicy === 'no-downgrade'
const resolve = strictResolution ? makeResolutionStrict(baseResolve) : baseResolve
const pkgName = 'pnpm'
const { publishedBy, publishedByExclude } = getPublishedByPolicy(opts)
// `pnpm self-update` (no args) defaults to the `latest` dist-tag, but we

View File

@@ -15,7 +15,7 @@ import { type Config, types } from '@pnpm/config.reader'
import { getPublishedByPolicy } from '@pnpm/config.version-policy'
import { createHexHash } from '@pnpm/crypto.hash'
import { PnpmError } from '@pnpm/error'
import { createResolver } from '@pnpm/installing.client'
import { createResolver, makeResolutionStrict } from '@pnpm/installing.client'
import { add } from '@pnpm/installing.commands'
import { readPackageJsonFromDir } from '@pnpm/pkg-manifest.reader'
import { parseWantedDependency } from '@pnpm/resolving.parse-wanted-dependency'
@@ -113,12 +113,11 @@ export async function handler (
) && !opts.registrySupportsTimeField
)
const catalogResolver = resolveFromCatalog.bind(null, opts.catalogs ?? {})
const { resolve } = createResolver({
const { resolve: baseResolve } = createResolver({
...opts,
configByUri: opts.configByUri,
fullMetadata,
filterMetadata: fullMetadata,
strictPublishedByCheck: Boolean(opts.minimumReleaseAge) && opts.minimumReleaseAgeStrict === true,
ignoreMissingTimeField: opts.minimumReleaseAgeIgnoreMissingTime,
retry: {
factor: opts.fetchRetryFactor,
@@ -128,6 +127,17 @@ export async function handler (
},
timeout: opts.fetchTimeout,
})
// dlx has nowhere to "defer to" — it runs the resolved package directly.
// Wrap the resolver under any policy that wants to reject violations
// up-front: strict minimumReleaseAge (refuse immature picks) and
// `trustPolicy: 'no-downgrade'` (refuse versions whose trust evidence
// weakened). Without the trust-policy arm, a downgraded version would
// resolve to a `policyViolation` that dlx silently ignored and then
// executed.
const strictResolution =
(Boolean(opts.minimumReleaseAge) && opts.minimumReleaseAgeStrict === true) ||
opts.trustPolicy === 'no-downgrade'
const resolve = strictResolution ? makeResolutionStrict(baseResolve) : baseResolve
const resolvedPkgAliases: string[] = []
const { publishedBy, publishedByExclude } = getPublishedByPolicy(opts)
const resolvedPkgs = await Promise.all(pkgs.map(async (pkg) => {

View File

@@ -482,7 +482,7 @@ test('dlx should fail when the requested package does not meet the minimum age r
default: 'https://registry.npmjs.org/',
},
}, ['shx@0.3.4'])
).rejects.toThrow(/Version 0\.3\.4 \(released .+\) of shx does not meet the minimumReleaseAge constraint/)
).rejects.toThrow(/shx@0\.3\.4 was published.+minimumReleaseAge cutoff/)
})
test('dlx should respect minimumReleaseAgeExclude', async () => {

View File

@@ -34,6 +34,7 @@
},
"dependencies": {
"@pnpm/engine.runtime.node-resolver": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/fetching.binary-fetcher": "workspace:*",
"@pnpm/fetching.directory-fetcher": "workspace:*",
"@pnpm/fetching.git-fetcher": "workspace:*",
@@ -43,6 +44,7 @@
"@pnpm/network.auth-header": "workspace:*",
"@pnpm/network.fetch": "workspace:*",
"@pnpm/resolving.default-resolver": "workspace:*",
"@pnpm/resolving.npm-resolver": "workspace:*",
"@pnpm/resolving.resolver-base": "workspace:*",
"@pnpm/store.index": "workspace:*",
"@pnpm/types": "workspace:*",

View File

@@ -1,4 +1,5 @@
import { NODE_EXTRAS_IGNORE_PATTERN } from '@pnpm/engine.runtime.node-resolver'
import { PnpmError } from '@pnpm/error'
import { createBinaryFetcher } from '@pnpm/fetching.binary-fetcher'
import { createDirectoryFetcher } from '@pnpm/fetching.directory-fetcher'
import type { BinaryFetcher, DirectoryFetcher, GitFetcher } from '@pnpm/fetching.fetcher-base'
@@ -15,7 +16,8 @@ import {
type ResolveFunction,
type ResolverFactoryOptions,
} from '@pnpm/resolving.default-resolver'
import type { ResolutionVerifier } from '@pnpm/resolving.resolver-base'
import { MINIMUM_RELEASE_AGE_VIOLATION_CODE } from '@pnpm/resolving.npm-resolver'
import type { ResolutionPolicyViolation, ResolutionVerifier } from '@pnpm/resolving.resolver-base'
import type { StoreIndex } from '@pnpm/store.index'
import type { RegistryConfig } from '@pnpm/types'
@@ -38,7 +40,15 @@ export type ClientOptions = {
preserveAbsolutePaths?: boolean
fetchMinSpeedKiBps?: number
} & ResolverFactoryOptions & DispatcherOptions
& Pick<ResolutionVerifierFactoryOptions, 'minimumReleaseAge' | 'minimumReleaseAgeStrict' | 'minimumReleaseAgeExclude'>
& Pick<ResolutionVerifierFactoryOptions,
| 'minimumReleaseAge'
| 'minimumReleaseAgeStrict'
| 'minimumReleaseAgeExclude'
| 'ignoreMissingTimeField'
| 'trustPolicy'
| 'trustPolicyExclude'
| 'trustPolicyIgnoreAfter'
>
export interface Client {
fetchers: Fetchers
@@ -74,6 +84,40 @@ export function createResolver (opts: Omit<ClientOptions, 'storeIndex'>): { reso
return _createResolver(fetchFromRegistry, getAuthHeader, { ...opts, customResolvers: opts.customResolvers })
}
/**
* Wraps a `ResolveFunction` so any inline policy violation surfaced by
* the resolver is rethrown as a `PnpmError` instead of being returned on
* the result. Use this from one-shot callers (dlx, self-update) that
* have nowhere to defer a violation to — the install command leaves
* resolution unwrapped because it aggregates violations across the
* whole tree before deciding what to do.
*
* The error mapping is centralized here so future violation codes
* (today: `MINIMUM_RELEASE_AGE_VIOLATION`) get a consistent error code
* across every strict-mode caller without each call site re-translating.
*/
export function makeResolutionStrict (resolve: ResolveFunction): ResolveFunction {
return (async (wantedDependency, opts) => {
const result = await resolve(wantedDependency, opts)
if (result?.policyViolation) {
throw policyViolationToError(result.policyViolation)
}
return result
}) as ResolveFunction
}
function policyViolationToError (violation: ResolutionPolicyViolation): PnpmError {
const message = `${violation.name}@${violation.version} ${violation.reason}`
// Map the per-violation `code` to the user-facing PnpmError code that
// pre-refactor callers (and `default-reporter`) already recognize.
// Future violation codes get their mapping added here so call sites
// don't have to re-translate.
const errorCode = violation.code === MINIMUM_RELEASE_AGE_VIOLATION_CODE
? 'NO_MATURE_MATCHING_VERSION'
: violation.code
return new PnpmError(errorCode, message)
}
type Fetchers = {
git: GitFetcher
directory: DirectoryFetcher

View File

@@ -9,6 +9,9 @@
"../../__typings__/**/*.d.ts"
],
"references": [
{
"path": "../../core/error"
},
{
"path": "../../core/types"
},
@@ -45,6 +48,9 @@
{
"path": "../../resolving/default-resolver"
},
{
"path": "../../resolving/npm-resolver"
},
{
"path": "../../resolving/resolver-base"
},

View File

@@ -58,6 +58,7 @@
"@pnpm/lockfile.types": "workspace:*",
"@pnpm/pkg-manifest.reader": "workspace:*",
"@pnpm/pkg-manifest.utils": "workspace:*",
"@pnpm/resolving.npm-resolver": "workspace:*",
"@pnpm/resolving.parse-wanted-dependency": "workspace:*",
"@pnpm/resolving.resolver-base": "workspace:*",
"@pnpm/semver-diff": "catalog:",
@@ -80,6 +81,7 @@
"@zkochan/rimraf": "catalog:",
"@zkochan/table": "catalog:",
"chalk": "catalog:",
"ci-info": "catalog:",
"enquirer": "catalog:",
"get-npm-tarball-url": "catalog:",
"is-subdir": "catalog:",
@@ -114,7 +116,6 @@
"@types/ramda": "catalog:",
"@types/yarnpkg__lockfile": "catalog:",
"@types/zkochan__table": "catalog:",
"ci-info": "catalog:",
"delay": "catalog:",
"jest-diff": "catalog:",
"path-name": "catalog:",

View File

@@ -84,6 +84,11 @@ export const cliOptionsTypes = (): Record<string, unknown> => ({
'fix-lockfile': Boolean,
'resolution-only': Boolean,
recursive: Boolean,
// `--no-save` lets `pnpm install` skip writing to package.json /
// pnpm-workspace.yaml. Without registering it here, nopt drops the
// flag, `opts.save` stays undefined, and the auto-add path treats
// it as "save enabled".
save: Boolean,
})
export const shorthands: Record<string, string> = {

View File

@@ -40,6 +40,7 @@ import { updateWorkspaceManifest } from '@pnpm/workspace.workspace-manifest-writ
import { getPinnedVersion } from './getPinnedVersion.js'
import { getSaveType } from './getSaveType.js'
import { handleIgnoredBuilds } from './handleIgnoredBuilds.js'
import { setupPolicyHandlers } from './policyHandlers.js'
import {
type CommandFullName,
createMatcher,
@@ -122,7 +123,8 @@ export type InstallDepsOptions = Pick<Config,
| 'rootProjectManifestDir'
| 'rootProjectManifest'
| 'selectedProjectsGraph'
> & CreateStoreControllerOptions & {
> & Partial<Pick<Config, 'ci'>>
& CreateStoreControllerOptions & {
argv: {
original: string[]
}
@@ -267,6 +269,13 @@ export async function installDeps (
applyRuntimeOnFailOverride(manifest, opts.runtimeOnFail)
}
// `setupPolicyHandlers` composes the per-policy handlers the install
// needs for the current opts (today: minimumReleaseAge; future:
// trustPolicy UX, license policy, etc.). Returns `undefined` when no
// handler is active so the install skips the empty no-op call at
// every checkpoint when no policies are configured.
const policyHandlers = setupPolicyHandlers(opts)
const installOpts: Omit<MutateModulesOptions, 'allProjects'> = {
...opts,
// In case installation is done in a multi-package repository
@@ -282,6 +291,7 @@ export async function installDeps (
resolutionVerifiers: store.resolutionVerifiers,
workspacePackages,
preferredVersions: opts.packageVulnerabilityAudit ? preferNonvulnerablePackageVersions(opts.packageVulnerabilityAudit) : undefined,
handleResolutionPolicyViolations: policyHandlers?.handleResolutionPolicyViolations,
}
let updateMatch: UpdateDepsMatcher | null
@@ -340,14 +350,20 @@ export async function installDeps (
rootDir: opts.dir as ProjectRootDir,
targetDependenciesField: getSaveType(opts),
}
const { updatedCatalogs, updatedProject, ignoredBuilds } = await mutateModulesInSingleProject(mutatedProject, installOpts)
const { updatedCatalogs, updatedProject, ignoredBuilds, resolutionPolicyViolations } = await mutateModulesInSingleProject(mutatedProject, installOpts)
if (opts.save !== false) {
// Only pick entries when we'll actually persist. Otherwise the
// info log would claim we added entries the workspace manifest
// never saw, and the next install would re-prompt or fail
// verification.
const policyUpdates = policyHandlers?.pickManifestUpdates(resolutionPolicyViolations)
await Promise.all([
writeProjectManifest(updatedProject.manifest),
updateWorkspaceManifest(opts.workspaceDir ?? opts.dir, {
updatedCatalogs,
cleanupUnusedCatalogs: opts.cleanupUnusedCatalogs,
allProjects: opts.allProjects,
...policyUpdates,
}),
])
}
@@ -365,20 +381,34 @@ export async function installDeps (
return
}
const { updatedCatalogs, updatedManifest, ignoredBuilds } = await install(manifest, {
const { updatedCatalogs, updatedManifest, ignoredBuilds, resolutionPolicyViolations } = await install(manifest, {
...installOpts,
updatePackageManifest,
updateMatching,
})
if (opts.update === true && opts.save !== false) {
await Promise.all([
writeProjectManifest(updatedManifest),
updateWorkspaceManifest(opts.workspaceDir ?? opts.dir, {
updatedCatalogs,
cleanupUnusedCatalogs: opts.cleanupUnusedCatalogs,
allProjects,
}),
])
// `opts.save === false` (e.g. `--no-save`) means "don't persist anything
// from this install" — both package.json and the workspace manifest.
// Skip the pick so the info log doesn't claim entries were added that
// were never written; the next install will resurface them.
if (opts.save !== false) {
const policyUpdates = policyHandlers?.pickManifestUpdates(resolutionPolicyViolations)
if (opts.update === true) {
await Promise.all([
writeProjectManifest(updatedManifest),
updateWorkspaceManifest(opts.workspaceDir ?? opts.dir, {
updatedCatalogs,
cleanupUnusedCatalogs: opts.cleanupUnusedCatalogs,
allProjects,
...policyUpdates,
}),
])
} else if (policyUpdates != null) {
// Plain `pnpm install` (no --update, no params) wouldn't otherwise touch
// the workspace manifest. Persist the auto-policy patches anyway so any
// loose bypass (today: minimumReleaseAgeExclude) remains explicit on
// subsequent installs.
await updateWorkspaceManifest(opts.workspaceDir ?? opts.dir, policyUpdates)
}
}
await handleIgnoredBuilds(opts, ignoredBuilds)

View File

@@ -0,0 +1,260 @@
import { PnpmError } from '@pnpm/error'
import { globalInfo } from '@pnpm/logger'
import { MINIMUM_RELEASE_AGE_VIOLATION_CODE } from '@pnpm/resolving.npm-resolver'
import { isCI } from 'ci-info'
import enquirer from 'enquirer'
/**
* Shape returned by `installing/deps-installer`'s
* `collectResolutionPolicyViolations` and the inline accumulator on
* the resolveDependencies result. Re-declared locally so the commands
* layer can react without depending on the deps-installer's private
* install types.
*
* Verifier codes (today: `MINIMUM_RELEASE_AGE_VIOLATION` and
* `TRUST_DOWNGRADE`) are the contract surface for downstream UX.
* Each `PolicyHandler` below filters violations by code to decide
* what to do with them (prompt, persist to an exclude list, log,
* abort).
*/
export interface PolicyViolation {
name: string
version: string
code: string
reason: string
}
/**
* Workspace-manifest patch a per-policy handler can request. Each
* field maps to a `pnpm-workspace.yaml` exclude-list array; the
* install command forwards these to `updateWorkspaceManifest` so the
* workspace writer dedupes and appends them in one pass.
*
* New policies that want auto-persistence add their field here AND
* teach `updateWorkspaceManifest` how to honor it.
*/
export interface WorkspaceManifestPolicyUpdates {
addedMinimumReleaseAgeExcludes?: string[]
}
/**
* What the install command asks of each registered policy handler.
* Both hooks are optional — a handler that only wants to abort can
* skip `pickManifestUpdates`; a handler that only wants to persist
* can skip `handleResolutionPolicyViolations`.
*/
interface PolicyHandler {
/**
* Runs between `resolveDependencyTree` and `resolvePeers`. Throw to
* abort the install before any lockfile / package.json /
* modules-dir mutation. Receives the full violations list across
* every policy — handlers filter by `code` for their own.
*/
handleResolutionPolicyViolations?: (violations: readonly PolicyViolation[]) => Promise<void>
/**
* Called at the install's tail to assemble the workspace-manifest
* patch. Returns `undefined` (or an empty object) when this
* handler has nothing to persist for the current batch.
*/
pickManifestUpdates?: (violations: readonly PolicyViolation[]) => WorkspaceManifestPolicyUpdates | undefined
}
/**
* Aggregated plan the install command consumes. The `handleResolutionPolicyViolations`
* call fans out across every registered handler in registration order;
* any handler can throw to abort. `pickManifestUpdates` merges the
* per-handler patches into one bag so the workspace writer runs once.
*/
export interface PolicyHandlersPlan {
handleResolutionPolicyViolations: (violations: readonly PolicyViolation[]) => Promise<void>
pickManifestUpdates: (violations: readonly PolicyViolation[]) => WorkspaceManifestPolicyUpdates | undefined
}
export interface PolicyHandlersOptions {
minimumReleaseAge?: number
minimumReleaseAgeStrict?: boolean
/**
* Pass `false` for `--no-save` installs. Handlers that would
* persist to the workspace manifest refuse to enter modes where
* approval is durably required (today: strict minimumReleaseAge)
* so the prompt never offers an action it can't honor.
*/
save?: boolean
/**
* Override for CI detection. Defaults to `ci-info`'s `isCI` flag.
*/
ci?: boolean
}
/**
* Composes the per-policy handlers the install command needs for the
* current opts. Returns `undefined` only when no handler reports
* activity — saves the install command an empty no-op call at every
* checkpoint when no policies are configured.
*
* Today only the minimumReleaseAge handler is registered. Future
* policies (trustPolicy UX, license policy, etc.) plug in by
* exporting a sibling `create<Name>PolicyHandler(opts)` and getting
* pushed into the `handlers` list below.
*/
export function setupPolicyHandlers (opts: PolicyHandlersOptions): PolicyHandlersPlan | undefined {
const handlers: PolicyHandler[] = []
const minimumReleaseAge = createMinimumReleaseAgeHandler(opts)
if (minimumReleaseAge) handlers.push(minimumReleaseAge)
if (handlers.length === 0) return undefined
return {
handleResolutionPolicyViolations: async (violations) => {
// Sequential, not parallel: a TTY prompt from handler N would
// race with a different prompt from N+1, and we want a clean
// throw to short-circuit before later handlers ask for input.
for (const handler of handlers) {
if (handler.handleResolutionPolicyViolations) {
// eslint-disable-next-line no-await-in-loop
await handler.handleResolutionPolicyViolations(violations)
}
}
},
pickManifestUpdates: (violations) => {
const merged: WorkspaceManifestPolicyUpdates = {}
let any = false
for (const handler of handlers) {
if (!handler.pickManifestUpdates) continue
const patch = handler.pickManifestUpdates(violations)
if (patch == null) continue
// Shallow merge — handlers own disjoint fields by convention,
// so there's no collision policy to encode here yet.
for (const [key, value] of Object.entries(patch)) {
if (value == null) continue
;(merged as Record<string, unknown>)[key] = value
any = true
}
}
return any ? merged : undefined
},
}
}
/**
* minimumReleaseAge policy handler.
*
* Loose mode (`minimumReleaseAgeStrict: false`) lets the resolver
* install versions newer than the cutoff and auto-persists them to
* `minimumReleaseAgeExclude`. Strict mode + an interactive TTY
* surfaces the full set of immature picks (direct AND transitive) at
* once via a confirm prompt — the install proceeds if the user
* approves, otherwise it aborts before touching the lockfile or
* package.json (#10488). Strict mode in CI or any other non-TTY
* context aborts hard with the same violation list so the failure
* pinpoints every offending entry, not just the first one the
* resolver picked.
*
* Strict mode combined with `--no-save` is rejected up-front — the
* approval prompt promises persistence the install command's
* `opts.save !== false` gate would block, leaving the lockfile
* holding approved-but-unlisted immature picks that the next install
* would reject.
*
* Returns `undefined` when minimumReleaseAge is not active.
*/
function createMinimumReleaseAgeHandler (opts: PolicyHandlersOptions): PolicyHandler | undefined {
if (!opts.minimumReleaseAge) return undefined
const strictMode = opts.minimumReleaseAgeStrict === true
const persistenceEnabled = opts.save !== false
const inCi = opts.ci ?? isCI
const canPrompt = !inCi && Boolean(process.stdin.isTTY)
return {
handleResolutionPolicyViolations: async (violations) => {
if (!strictMode) return
const immature = filterImmatureViolations(violations)
if (immature.length === 0) return
if (!persistenceEnabled) {
throw new PnpmError(
'STRICT_MIN_RELEASE_AGE_REQUIRES_SAVE',
'minimumReleaseAgeStrict cannot be combined with --no-save: ' +
'approval would require writing to minimumReleaseAgeExclude in pnpm-workspace.yaml, ' +
'which --no-save prevents.',
{
hint: 'Drop --no-save so the exclude list can be persisted, or set ' +
'minimumReleaseAgeStrict: false to let the install proceed without prompting ' +
'(the lockfile would still trigger the auto-collect on the next normal install).',
}
)
}
if (canPrompt) {
await promptForApproval(immature)
} else {
throw failOnImmature(immature)
}
},
pickManifestUpdates: (violations) => {
const entries = pickImmatureEntries(violations, strictMode)
return entries ? { addedMinimumReleaseAgeExcludes: entries } : undefined
},
}
}
function filterImmatureViolations (violations: readonly PolicyViolation[]): PolicyViolation[] {
return violations.filter((v) => v.code === MINIMUM_RELEASE_AGE_VIOLATION_CODE)
}
function pickImmatureEntries (
violations: readonly PolicyViolation[],
promptRequired: boolean
): string[] | undefined {
const immature = filterImmatureViolations(violations)
if (immature.length === 0) return undefined
const sorted = [...new Set(immature.map((v) => `${v.name}@${v.version}`))].sort()
// Strict-mode picks already passed through the approval prompt, so
// the log here only confirms what was persisted. Loose-mode picks
// haven't been announced anywhere else, so the same log doubles as
// the discovery notice.
const reason = promptRequired
? '(approved at the prompt)'
: '(loose mode allowed these immature versions)'
globalInfo(
`Added ${sorted.length} ${sorted.length === 1 ? 'entry' : 'entries'} to minimumReleaseAgeExclude in pnpm-workspace.yaml ` +
`${reason}:\n ${sorted.join('\n ')}`
)
return sorted
}
function failOnImmature (immature: readonly PolicyViolation[]): PnpmError {
const sorted = [...immature].sort((a, b) => `${a.name}@${a.version}`.localeCompare(`${b.name}@${b.version}`))
const list = sorted.map((v) => ` ${v.name}@${v.version} ${v.reason}`).join('\n')
return new PnpmError(
'NO_MATURE_MATCHING_VERSION',
`${sorted.length} ${sorted.length === 1 ? 'version does' : 'versions do'} not meet the minimumReleaseAge constraint:\n${list}`,
{
hint: 'Run the install interactively to approve these picks, or add them to ' +
'minimumReleaseAgeExclude in pnpm-workspace.yaml, or wait for the packages ' +
'to mature past the configured cutoff.',
}
)
}
async function promptForApproval (immature: readonly PolicyViolation[]): Promise<void> {
const sorted = [...immature].sort((a, b) => `${a.name}@${a.version}`.localeCompare(`${b.name}@${b.version}`))
const message =
`${sorted.length} ${sorted.length === 1 ? 'version does' : 'versions do'} not meet the minimumReleaseAge constraint:\n` +
sorted.map((v) => ` ${v.name}@${v.version}`).join('\n') + '\n' +
'Add to minimumReleaseAgeExclude in pnpm-workspace.yaml and proceed with the install?'
const answer = await enquirer.prompt<{ confirmed: boolean }>({
type: 'confirm',
name: 'confirmed',
message,
initial: false,
})
if (!answer.confirmed) {
throw new PnpmError(
'MINIMUM_RELEASE_AGE_DENIED',
'Aborted: the immature versions were not approved.',
{
hint: 'Re-run the install without `minimumReleaseAgeStrict: true` to allow these versions, ' +
'or wait for the packages to mature past the configured cutoff.',
}
)
}
}

View File

@@ -55,6 +55,7 @@ import pLimit from 'p-limit'
import { getPinnedVersion } from './getPinnedVersion.js'
import { getSaveType } from './getSaveType.js'
import { handleIgnoredBuilds } from './handleIgnoredBuilds.js'
import { type PolicyViolation, setupPolicyHandlers } from './policyHandlers.js'
import { createWorkspaceSpecs, updateToWorkspacePackagesFromManifest } from './updateWorkspaceDependencies.js'
export type RecursiveOptions = CreateStoreControllerOptions & Pick<Config,
@@ -120,6 +121,7 @@ export type RecursiveOptions = CreateStoreControllerOptions & Pick<Config,
pnpmfile: string[]
} & Partial<
Pick<Config,
| 'ci'
| 'sort'
| 'strictDepBuilds'
| 'workspaceConcurrency'
@@ -154,6 +156,12 @@ export async function recursive (
const workspacePackages: WorkspacePackages = arrayOfWorkspacePackagesToMap(allProjects) as WorkspacePackages
const targetDependenciesField = getSaveType(opts)
// See `installDeps.ts` for context; mirrored here so workspace-recursive
// installs also surface immature picks (loose-mode auto-persist or
// strict-mode prompt). The workspace manifest writer dedupes against the
// existing list, so a single drain at the end captures additions across
// every project.
const policyHandlers = setupPolicyHandlers(opts)
const installOpts = Object.assign(opts, {
allProjects: getAllProjects(manifestsByPath, opts.allProjectsGraph, opts.sort),
linkWorkspacePackagesDepth: opts.linkWorkspacePackages === 'deep' ? Infinity : opts.linkWorkspacePackages ? 0 : -1,
@@ -169,6 +177,7 @@ export async function recursive (
targetDependenciesField,
resolutionVerifiers: store.resolutionVerifiers,
workspacePackages,
handleResolutionPolicyViolations: policyHandlers?.handleResolutionPolicyViolations,
}) as InstallOptions
const result: RecursiveSummary = {}
@@ -296,12 +305,18 @@ export async function recursive (
updatedCatalogs,
updatedProjects: mutatedPkgs,
ignoredBuilds,
resolutionPolicyViolations,
} = await mutateModules(mutatedImporters, {
...installOpts,
storeController: store.ctrl,
resolutionVerifiers: store.resolutionVerifiers,
})
if (opts.save !== false) {
// Only pick entries when we'll actually persist. Otherwise the
// info log would claim entries were added that the workspace
// manifest never saw, and the next install would re-prompt or
// fail verification.
const policyUpdates = policyHandlers?.pickManifestUpdates(resolutionPolicyViolations)
const promises: Array<Promise<void>> = mutatedPkgs.map(async ({ originalManifest, manifest, rootDir }) => {
return manifestsByPath[rootDir].writeProjectManifest(originalManifest ?? manifest)
})
@@ -309,6 +324,7 @@ export async function recursive (
updatedCatalogs,
cleanupUnusedCatalogs: opts.cleanupUnusedCatalogs,
allProjects,
...policyUpdates,
}))
await Promise.all(promises)
}
@@ -321,6 +337,10 @@ export async function recursive (
let updatedCatalogs: Catalogs | undefined
const allIgnoredBuilds = new Set<DepPath>()
// Each per-project install returns its own slice of lockfile-resolution
// violations; accumulate them here so the post-loop persist step can
// dedup and write a single batch to the workspace manifest.
const allResolutionPolicyViolations: PolicyViolation[] = []
const limitInstallation = pLimit(getWorkspaceConcurrency(opts.workspaceConcurrency))
await Promise.all(pkgPaths.map(async (rootDir) =>
limitInstallation(async () => {
@@ -368,6 +388,7 @@ export async function recursive (
updatedCatalogs?: Catalogs
updatedManifest: ProjectManifest
ignoredBuilds: IgnoredBuilds | undefined
resolutionPolicyViolations?: PolicyViolation[]
}
type ActionFunction = (manifest: PackageManifest | ProjectManifest, opts: ActionOpts) => Promise<ActionResult>
@@ -387,6 +408,7 @@ export async function recursive (
updatedCatalogs: undefined, // there's no reason to add new or update catalogs on `pnpm remove`
updatedManifest: mutationResult.updatedProjects[0].manifest,
ignoredBuilds: mutationResult.ignoredBuilds,
resolutionPolicyViolations: mutationResult.resolutionPolicyViolations,
}
}
break
@@ -402,6 +424,7 @@ export async function recursive (
updatedCatalogs: newCatalogsAddition,
updatedManifest: newManifest,
ignoredBuilds,
resolutionPolicyViolations,
} = await action(
manifest,
{
@@ -433,6 +456,11 @@ export async function recursive (
allIgnoredBuilds.add(depPath)
}
}
if (resolutionPolicyViolations?.length) {
for (const violation of resolutionPolicyViolations) {
allResolutionPolicyViolations.push(violation)
}
}
result[rootDir].status = 'passed'
} catch (err: any) { // eslint-disable-line
logger.info(err)
@@ -453,11 +481,18 @@ export async function recursive (
})
))
await handleIgnoredBuilds(opts, allIgnoredBuilds.size ? allIgnoredBuilds : undefined)
await updateWorkspaceManifest(opts.workspaceDir, {
updatedCatalogs,
cleanupUnusedCatalogs: opts.cleanupUnusedCatalogs,
allProjects,
})
if (opts.save !== false) {
// Only pick entries when we'll actually persist. Otherwise the
// info log would claim entries were added that the workspace
// manifest never saw, mirroring the gate the shared-lockfile
// branch + installDeps already apply.
await updateWorkspaceManifest(opts.workspaceDir, {
updatedCatalogs,
cleanupUnusedCatalogs: opts.cleanupUnusedCatalogs,
allProjects,
...policyHandlers?.pickManifestUpdates(allResolutionPolicyViolations),
})
}
if (
!opts.lockfileOnly && !opts.ignoreScripts && (

View File

@@ -381,7 +381,7 @@ test('minimumReleaseAge with minimumReleaseAgeStrict enabled makes install fail
minimumReleaseAge,
minimumReleaseAgeStrict: true,
linkWorkspacePackages: false,
}, ['is-odd@0.1.1'])).rejects.toThrow(/Version 0\.1\.1 \(released .+\) of is-odd does not meet the minimumReleaseAge constraint/)
}, ['is-odd@0.1.1'])).rejects.toThrow(/is-odd@0\.1\.1 was published.+minimumReleaseAge cutoff/)
})
describeOnLinuxOnly('filters optional dependencies based on pnpm.supportedArchitectures.libc', () => {

View File

@@ -0,0 +1,160 @@
import { expect, jest, test } from '@jest/globals'
import { type PolicyViolation, setupPolicyHandlers } from '../lib/policyHandlers.js'
function violation (
name: string,
version: string,
code = 'MINIMUM_RELEASE_AGE_VIOLATION'
): PolicyViolation {
return { name, version, code, reason: 'stub reason' }
}
// Swap `process.stdin.isTTY` for the duration of a test, restoring the
// original descriptor — not just the value — so the property's
// configurability/enumerability shape doesn't leak between tests when
// the host process didn't define an own `isTTY` at all.
function withStdinTTY (value: boolean | undefined, fn: () => void | Promise<void>): void | Promise<void> {
const originalDescriptor = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY')
Object.defineProperty(process.stdin, 'isTTY', { value, configurable: true, writable: true })
const restore = (): void => {
if (originalDescriptor) {
Object.defineProperty(process.stdin, 'isTTY', originalDescriptor)
} else {
delete (process.stdin as { isTTY?: boolean }).isTTY
}
}
let result: void | Promise<void>
try {
result = fn()
} catch (err) {
restore()
throw err
}
if (result && typeof (result as Promise<void>).then === 'function') {
return (result as Promise<void>).then(
(v) => {
restore(); return v
},
(err) => {
restore(); throw err
}
)
}
restore()
return result
}
test('setupPolicyHandlers returns undefined when no policy is active', () => {
expect(setupPolicyHandlers({})).toBeUndefined()
})
test('setupPolicyHandlers returns a plan even when strict mode is on without a TTY', () => {
// Pre-refactor this returned undefined and the resolver did the fail-fast
// throw. Now the plan is always returned: the strict-no-TTY case throws
// from the handler with the full violation list, not just the first
// immature pick the resolver happened to hit.
withStdinTTY(false, () => {
expect(setupPolicyHandlers({
minimumReleaseAge: 60,
minimumReleaseAgeStrict: true,
ci: false,
})).toBeDefined()
})
})
test('strict no-TTY plan throws from the hook with the full violation list', async () => {
await withStdinTTY(false, async () => {
const plan = setupPolicyHandlers({
minimumReleaseAge: 60,
minimumReleaseAgeStrict: true,
ci: false,
})!
await expect(plan.handleResolutionPolicyViolations([
violation('foo', '1.0.0'),
violation('bar', '2.3.4'),
])).rejects.toMatchObject({
code: 'ERR_PNPM_NO_MATURE_MATCHING_VERSION',
})
})
})
test('setupPolicyHandlers returns a plan when ci=false and stdin is a TTY', () => {
withStdinTTY(true, () => {
const plan = setupPolicyHandlers({
minimumReleaseAge: 60,
minimumReleaseAgeStrict: true,
ci: false,
})
expect(plan).toBeDefined()
})
})
test('strict + --no-save refuses up-front instead of prompting for approval it cannot persist', async () => {
// The prompt promises to write to minimumReleaseAgeExclude, but the
// install command's `opts.save !== false` gate blocks that under
// --no-save — accepting the prompt would leave the lockfile holding
// approved-but-unlisted picks that the next install rejects.
await withStdinTTY(true, async () => {
const plan = setupPolicyHandlers({
minimumReleaseAge: 60,
minimumReleaseAgeStrict: true,
save: false,
ci: false,
})!
await expect(plan.handleResolutionPolicyViolations([violation('foo', '1.0.0')]))
.rejects.toMatchObject({ code: 'ERR_PNPM_STRICT_MIN_RELEASE_AGE_REQUIRES_SAVE' })
})
})
test('loose + --no-save runs the hook as a no-op (lockfile re-triggers auto-collect later)', async () => {
// Loose mode never persists from the hook anyway — `pickManifestUpdates`
// is what writes the exclude list at the install's tail, and the
// installDeps / recursive `opts.save !== false` gates already skip that
// when --no-save is set.
const plan = setupPolicyHandlers({
minimumReleaseAge: 60,
save: false,
})!
await expect(plan.handleResolutionPolicyViolations([violation('foo', '1.0.0')]))
.resolves.toBeUndefined()
})
test('loose-mode plan emits a workspace patch with sorted unique entries and logs once', () => {
const plan = setupPolicyHandlers({ minimumReleaseAge: 60 })!
const violations = [
violation('foo', '1.0.0'),
violation('foo', '1.0.0'),
violation('bar', '2.3.4'),
// Non-minimumReleaseAge code: the minimumReleaseAge handler ignores it.
// (When more handlers register, each filters its own codes.)
violation('quux', '0.0.1', 'TRUST_DOWNGRADE'),
]
// Avoid leaking console output in test runs.
const infoSpy = jest.spyOn(console, 'log').mockImplementation(() => {})
try {
const updates = plan.pickManifestUpdates(violations)
expect(updates).toEqual({ addedMinimumReleaseAgeExcludes: ['bar@2.3.4', 'foo@1.0.0'] })
} finally {
infoSpy.mockRestore()
}
})
test('pickManifestUpdates returns undefined when no handler contributes anything', () => {
const plan = setupPolicyHandlers({ minimumReleaseAge: 60 })!
expect(plan.pickManifestUpdates([])).toBeUndefined()
// Codes the minimumReleaseAge handler doesn't recognize don't produce a
// patch — and with no other handler registered yet, the merged result
// collapses to undefined so the install command skips the workspace
// writer entirely.
expect(plan.pickManifestUpdates([violation('foo', '1.0.0', 'TRUST_DOWNGRADE')])).toBeUndefined()
})
test('the hook is a no-op in loose mode regardless of violations', async () => {
const plan = setupPolicyHandlers({ minimumReleaseAge: 60 })!
// Loose mode never prompts — picks are persisted from
// `pickManifestUpdates` at the end of the install.
await expect(plan.handleResolutionPolicyViolations([violation('foo', '1.0.0')]))
.resolves.toBeUndefined()
})

View File

@@ -90,6 +90,9 @@
{
"path": "../../pkg-manifest/utils"
},
{
"path": "../../resolving/npm-resolver"
},
{
"path": "../../resolving/parse-wanted-dependency"
},

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 { ResolutionVerifier, WorkspacePackages } from '@pnpm/resolving.resolver-base'
import type { ResolutionPolicyViolation, ResolutionVerifier, WorkspacePackages } from '@pnpm/resolving.resolver-base'
import type { StoreController } from '@pnpm/store.controller-types'
import type {
AllowedDeprecatedVersions,
@@ -175,6 +175,23 @@ export interface StrictInstallOptions {
ci?: boolean
minimumReleaseAge?: number
minimumReleaseAgeExclude?: string[]
/**
* Resolver-agnostic post-tree gate, invoked between
* `resolveDependencyTree` and `resolvePeers` inside
* `resolveDependencies`. Receives the violations the verifier
* fan-out collected from the freshly-resolved tree. Throwing here
* unwinds the install before peer-dep resolution runs — nothing on
* disk has changed, and the (potentially expensive) peer pass is
* skipped on abort.
*
* Intentionally policy-neutral. Each verifier owns its violation
* codes (`MINIMUM_RELEASE_AGE_VIOLATION`, `TRUST_DOWNGRADE`, …); the
* install command filters by code to decide what to do. Future
* resolvers can plug verifiers in without touching this signature.
*/
handleResolutionPolicyViolations?: (
violations: readonly ResolutionPolicyViolation[]
) => Promise<void>
/**
* Resolver-side verifiers that re-check each lockfile-pinned resolution
* against policies configured upstream (today: at most one,

View File

@@ -63,6 +63,7 @@ import { createVersionSpecFromResolvedVersion, getAllDependenciesFromManifest, g
import { parseWantedDependency } from '@pnpm/resolving.parse-wanted-dependency'
import type {
PreferredVersions,
ResolutionPolicyViolation,
} from '@pnpm/resolving.resolver-base'
import type {
AllowBuild,
@@ -159,6 +160,8 @@ export interface InstallResult {
updatedCatalogs: Catalogs | undefined
updatedManifest: ProjectManifest
ignoredBuilds: IgnoredBuilds | undefined
/** Forwarded from {@link MutateModulesResult.resolutionPolicyViolations}. */
resolutionPolicyViolations: ResolutionPolicyViolation[]
}
export async function install (
@@ -173,7 +176,7 @@ export async function install (
return installFromPnpmRegistry(manifest, rootDir, opts)
}
const { updatedCatalogs, updatedProjects: projects, ignoredBuilds } = await mutateModules(
const { updatedCatalogs, updatedProjects: projects, ignoredBuilds, resolutionPolicyViolations } = await mutateModules(
[
{
mutation: 'install',
@@ -195,7 +198,7 @@ export async function install (
}],
}
)
return { updatedCatalogs, updatedManifest: projects[0].manifest, ignoredBuilds }
return { updatedCatalogs, updatedManifest: projects[0].manifest, ignoredBuilds, resolutionPolicyViolations }
}
interface ProjectToBeInstalled {
@@ -219,6 +222,8 @@ export interface MutateModulesInSingleProjectResult {
updatedCatalogs: Catalogs | undefined
updatedProject: UpdatedProject
ignoredBuilds: IgnoredBuilds | undefined
/** Forwarded from {@link MutateModulesResult.resolutionPolicyViolations}. */
resolutionPolicyViolations: ResolutionPolicyViolation[]
}
export async function mutateModulesInSingleProject (
@@ -252,6 +257,7 @@ export async function mutateModulesInSingleProject (
updatedCatalogs: result.updatedCatalogs,
updatedProject: result.updatedProjects[0],
ignoredBuilds: result.ignoredBuilds,
resolutionPolicyViolations: result.resolutionPolicyViolations,
}
}
@@ -261,6 +267,15 @@ export interface MutateModulesResult {
stats: InstallationResultStats
depsRequiringBuild?: DepPath[]
ignoredBuilds: IgnoredBuilds | undefined
/**
* Resolver-policy violations the post-resolution scan found in the
* freshly-resolved lockfile. Each violation carries a verifier code
* (e.g. `MINIMUM_RELEASE_AGE_VIOLATION`, `TRUST_DOWNGRADE`); the
* install command filters by code to decide what to do (persist to
* `minimumReleaseAgeExclude`, log, etc.). Empty array when no
* verifier reported a violation or no policy was active.
*/
resolutionPolicyViolations: ResolutionPolicyViolation[]
}
const pickCatalogSpecifier: CatalogResultMatcher<string | undefined> = {
@@ -452,6 +467,7 @@ export async function mutateModules (
stats: result.stats ?? { added: 0, removed: 0, linkedToRoot: 0 },
depsRequiringBuild: result.depsRequiringBuild,
ignoredBuilds,
resolutionPolicyViolations: result.resolutionPolicyViolations ?? [],
}
interface InnerInstallResult {
@@ -460,6 +476,7 @@ export async function mutateModules (
readonly stats?: InstallationResultStats
readonly depsRequiringBuild?: DepPath[]
readonly ignoredBuilds: IgnoredBuilds | undefined
readonly resolutionPolicyViolations?: ResolutionPolicyViolation[]
}
async function _install (): Promise<InnerInstallResult> {
@@ -799,6 +816,7 @@ export async function mutateModules (
stats: result.stats,
depsRequiringBuild: result.depsRequiringBuild,
ignoredBuilds: result.ignoredBuilds,
resolutionPolicyViolations: result.resolutionPolicyViolations,
}
}
@@ -1158,7 +1176,7 @@ export async function addDependenciesToPackage (
} & InstallMutationOptions
): Promise<InstallResult> {
const rootDir = (opts.dir ?? process.cwd()) as ProjectRootDir
const { updatedCatalogs, updatedProjects: projects, ignoredBuilds } = await mutateModules(
const { updatedCatalogs, updatedProjects: projects, ignoredBuilds, resolutionPolicyViolations } = await mutateModules(
[
{
allowNew: opts.allowNew,
@@ -1186,7 +1204,7 @@ export async function addDependenciesToPackage (
},
],
})
return { updatedCatalogs, updatedManifest: projects[0].manifest, ignoredBuilds }
return { updatedCatalogs, updatedManifest: projects[0].manifest, ignoredBuilds, resolutionPolicyViolations }
}
export type ImporterToUpdate = {
@@ -1217,6 +1235,7 @@ interface InstallFunctionResult {
stats?: InstallationResultStats
depsRequiringBuild: DepPath[]
ignoredBuilds?: IgnoredBuilds
resolutionPolicyViolations: ResolutionPolicyViolation[]
}
type InstallFunction = (
@@ -1331,6 +1350,7 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => {
peerDependencyIssuesByProjects,
wantedToBeSkippedPackageIds,
waitTillAllFetchingsFinish,
resolutionPolicyViolations,
} = await resolveDependencies(
projects,
{
@@ -1387,6 +1407,7 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => {
trustPolicyIgnoreAfter: opts.trustPolicyIgnoreAfter,
blockExoticSubdeps: opts.blockExoticSubdeps,
allProjectIds: Object.values(ctx.projects).map((p) => p.id),
handleResolutionPolicyViolations: opts.handleResolutionPolicyViolations,
}
)
if (!opts.include.optionalDependencies || !opts.include.devDependencies || !opts.include.dependencies) {
@@ -1748,6 +1769,7 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => {
stats,
depsRequiringBuild,
ignoredBuilds,
resolutionPolicyViolations,
}
}
@@ -2173,6 +2195,18 @@ async function installFromPnpmRegistry (
opts: Opts,
allInstallProjects?: Array<{ rootDir: ProjectRootDir, manifest: ProjectManifest }>
): Promise<InstallResult & { stats: InstallationResultStats, lockfile: LockfileObject }> {
// The agent path skips client-side resolution, so resolver-side policies
// can't be enforced locally. `minimumReleaseAge` is forwarded to the
// agent and enforced server-side. `trustPolicy` has no server-side
// counterpart yet, so refuse to run under it instead of silently
// letting through a lockfile the local verifier would reject.
if (opts.trustPolicy === 'no-downgrade') {
throw new PnpmError(
'TRUST_POLICY_INCOMPATIBLE_WITH_AGENT',
'The pnpm agent does not yet enforce `trustPolicy: no-downgrade`, so running an install through the agent under this policy would produce a lockfile that the local verifier rejects.',
{ hint: 'Unset `trustPolicy` for this install, or disable the agent (unset `--agent` / `agent` in pnpm-workspace.yaml) so resolution runs locally and the trust check applies.' }
)
}
const { fetchFromPnpmRegistry } = await import('@pnpm/agent.client')
const { StoreIndex } = await import('@pnpm/store.index')
const { setImportConcurrency } = await import('@pnpm/worker')
@@ -2320,6 +2354,12 @@ async function installFromPnpmRegistry (
ignoredBuilds,
stats,
lockfile,
// Server-side resolution (pnpm agent) enforces `minimumReleaseAge`
// itself — the agent picks only mature versions and the lockfile
// can't contain immature entries to auto-collect. `trustPolicy` is
// guarded above (we refuse to enter this path when it's set), so
// there's nothing for the install command to react to here.
resolutionPolicyViolations: [],
}
} finally {
// Close the storeController to flush queued StoreIndex writes — the

View File

@@ -2,7 +2,11 @@ import { hashObject } from '@pnpm/crypto.object-hasher'
import { PnpmError } from '@pnpm/error'
import type { LockfileObject } from '@pnpm/lockfile.fs'
import { nameVerFromPkgSnapshot } from '@pnpm/lockfile.utils'
import type { Resolution, ResolutionVerifier } from '@pnpm/resolving.resolver-base'
import type {
Resolution,
ResolutionPolicyViolation,
ResolutionVerifier,
} from '@pnpm/resolving.resolver-base'
import type { DepPath } from '@pnpm/types'
import pLimit from 'p-limit'
@@ -11,11 +15,10 @@ import {
tryLockfileVerificationCache,
} from './verifyLockfileResolutionsCache.js'
interface Violation {
pkgId: string
code: string
reason: string
}
// Re-exported for back-compat with the existing import surface.
// The interface itself lives in resolver-base so deps-resolver can
// participate in the same shape; see the doc there.
export type { ResolutionPolicyViolation }
// 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
@@ -110,49 +113,7 @@ export async function verifyLockfileResolutions (
cachePrecomputed = result.precomputed
}
// 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.
//
// Include a serialization of `resolution` in the key so two entries that
// share a (name, version) but differ in *what* was resolved (e.g. one
// pinned via npm, another via a git URL under the same alias) don't
// collapse: if the wrong shape wins the dedup, a protocol-scoped
// verifier short-circuits on the surviving entry and the real one is
// never checked.
const candidates = new Map<string, { name: string, version: string, resolution: Resolution }>()
for (const [depPath, snapshot] of Object.entries(lockfile.packages)) {
const { name, version } = nameVerFromPkgSnapshot(depPath as DepPath, snapshot)
if (!name || !version) continue
const key = `${name}@${version}@${JSON.stringify(snapshot.resolution)}`
candidates.set(key, {
name,
version,
resolution: snapshot.resolution as 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}`
// Fan out across every active verifier; each handles its own
// protocol short-circuit (e.g. the npm verifier returns ok:true for
// git resolutions). We stop at the first failure per entry so a
// multi-verifier setup doesn't produce duplicate violations for the
// same (name, version).
for (const verifier of verifiers) {
// eslint-disable-next-line no-await-in-loop
const result = await verifier.verify(resolution, { name, version })
if (!result.ok) {
violations.push({ pkgId, code: result.code, reason: result.reason })
break
}
}
}))
)
const violations = await iterateLockfileViolations(lockfile, verifiers, options?.concurrency)
if (violations.length === 0) {
// Persist the success so the next install can stat-only the lockfile.
@@ -167,19 +128,27 @@ export async function verifyLockfileResolutions (
}
// Stable order so the error output is deterministic.
violations.sort((a, b) => a.pkgId.localeCompare(b.pkgId))
violations.sort((a, b) => `${a.name}@${a.version}`.localeCompare(`${b.name}@${b.version}`))
// Pick the throw code: a single-code batch keeps the per-policy code
// (so existing handlers / docs / search keywords still route correctly);
// a mixed batch (e.g. minimumReleaseAge + trust-downgrade on the same
// lockfile) escalates to the generic `LOCKFILE_RESOLUTION_VERIFICATION`
// and the per-entry code goes into the breakdown so the user can see
// which policy each entry tripped.
const distinctCodes = new Set(violations.map((v) => v.code))
const isMixed = distinctCodes.size > 1
const errorCode = isMixed ? 'LOCKFILE_RESOLUTION_VERIFICATION' : violations[0].code
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 formatEntry = isMixed
? (v: ResolutionPolicyViolation): string => ` ${v.name}@${v.version} [${v.code}] ${v.reason}`
: (v: ResolutionPolicyViolation): string => ` ${v.name}@${v.version} ${v.reason}`
const breakdown = visible.map(formatEntry).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,
errorCode,
`${violations.length} lockfile entries failed verification:\n${details}`,
{
hint: 'The lockfile contains entries that the active policies reject. ' +
@@ -192,3 +161,76 @@ export async function verifyLockfileResolutions (
}
)
}
/**
* Collect-mode sibling of {@link verifyLockfileResolutions}: runs the
* same fan-out over every verifier and every lockfile entry, but
* returns the violations as data instead of throwing on the first batch.
* No cache lookup or write — the throw-mode `verifyLockfileResolutions`
* is what populates / honors the cache; this is for callers that need
* to inspect violations (auto-collect into `minimumReleaseAgeExclude`,
* the strict-mode interactive prompt, future resolver-specific
* policies).
*
* Returns an empty array when `verifiers` is empty or the lockfile has
* no packages, so callers don't need a separate emptiness check.
*/
export async function collectResolutionPolicyViolations (
lockfile: LockfileObject,
verifiers: ResolutionVerifier[],
options?: Pick<VerifyLockfileResolutionsOptions, 'concurrency'>
): Promise<ResolutionPolicyViolation[]> {
if (verifiers.length === 0) return []
if (!lockfile.packages) return []
return iterateLockfileViolations(lockfile, verifiers, options?.concurrency)
}
async function iterateLockfileViolations (
lockfile: LockfileObject,
verifiers: readonly ResolutionVerifier[],
concurrency: number | undefined
): Promise<ResolutionPolicyViolation[]> {
// 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.
//
// Include a serialization of `resolution` in the key so two entries that
// share a (name, version) but differ in *what* was resolved (e.g. one
// pinned via npm, another via a git URL under the same alias) don't
// collapse: if the wrong shape wins the dedup, a protocol-scoped
// verifier short-circuits on the surviving entry and the real one is
// never checked.
const candidates = new Map<string, { name: string, version: string, resolution: Resolution }>()
for (const [depPath, snapshot] of Object.entries(lockfile.packages ?? {})) {
const { name, version } = nameVerFromPkgSnapshot(depPath as DepPath, snapshot)
if (!name || !version) continue
const key = `${name}@${version}@${JSON.stringify(snapshot.resolution)}`
candidates.set(key, {
name,
version,
resolution: snapshot.resolution as Resolution,
})
}
const violations: ResolutionPolicyViolation[] = []
const limit = pLimit(concurrency ?? DEFAULT_CONCURRENCY)
await Promise.all(
Array.from(candidates.values(), ({ name, version, resolution }) => limit(async () => {
// Fan out across every active verifier; each handles its own
// protocol short-circuit (e.g. the npm verifier returns ok:true for
// git resolutions). We stop at the first failure per entry so a
// multi-verifier setup doesn't produce duplicate violations for the
// same (name, version).
for (const verifier of verifiers) {
// eslint-disable-next-line no-await-in-loop
const result = await verifier.verify(resolution, { name, version })
if (!result.ok) {
violations.push({ name, version, resolution, code: result.code, reason: result.reason })
break
}
}
}))
)
return violations
}

View File

@@ -72,21 +72,33 @@ test('minimumReleaseAge falls back to immature version when no mature version sa
// The fallback picks the lowest matching version (0.1.0), which differs from
// normal resolution without minimumReleaseAge that would pick the highest (0.1.2).
const opts = testDefaults({ minimumReleaseAge: allImmatureMinimumReleaseAge })
const { updatedManifest: manifest } = await addDependenciesToPackage({}, ['is-odd@0.1'], opts)
const { updatedManifest: manifest } = await addDependenciesToPackage({}, ['is-odd@0.1'], {
...opts,
// Acknowledge the policy violations without aborting — this test
// only inspects the resolved manifest. resolveDependencies refuses
// to proceed if violations fire and no handler is wired.
handleResolutionPolicyViolations: async () => {},
})
expect(manifest.dependencies!['is-odd']).toBe('~0.1.0')
})
test('minimumReleaseAge throws when no mature version satisfies the range and strict mode is enabled', async () => {
test('strict minimumReleaseAge surfaces every immature pick via handleResolutionPolicyViolations, then aborts', async () => {
// Pre-refactor strict mode threw at the resolver on the first immature
// pick (forcing a discover-by-loop dance, #10488). With always-defer the
// resolver records every immature pick inline; the install command (here
// simulated via the hook) decides what to do once it has the full set.
prepareEmpty()
await expect(async () => {
const opts = testDefaults(
{ minimumReleaseAge: allImmatureMinimumReleaseAge },
{ strictPublishedByCheck: true }
)
await addDependenciesToPackage({}, ['is-odd@0.1'], opts)
}).rejects.toThrow(/does not meet the minimumReleaseAge constraint/)
const opts = testDefaults({ minimumReleaseAge: allImmatureMinimumReleaseAge })
const seen: string[] = []
await expect(addDependenciesToPackage({}, ['is-odd@0.1'], {
...opts,
handleResolutionPolicyViolations: async (violations) => {
for (const v of violations) seen.push(`${v.name}@${v.version}`)
throw new Error('immature picks rejected')
},
})).rejects.toThrow(/immature picks rejected/)
expect(seen).toContain('is-odd@0.1.0')
})
test('time-based resolution repopulates missing lockfile time entries on re-install', async () => {
@@ -180,17 +192,131 @@ test('minimumReleaseAge is enforced on pre-existing lockfile entries during pnpm
).rejects.toThrow(/minimumReleaseAge/)
})
test('the lockfile minimumReleaseAge gate is inert when strict mode is off (default-value semantics)', async () => {
test('the lockfile minimumReleaseAge gate runs in loose mode too', 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.
// Loose mode no longer skips the verifier — once auto-collect makes every
// accepted-immature pin explicit in `minimumReleaseAgeExclude`, running
// the verifier in loose mode is what keeps the manifest in sync with the
// lockfile. A pre-existing immature lockfile entry that isn't yet on the
// exclude list is rejected here, same as strict mode.
await expect(
install(manifest, testDefaults({ minimumReleaseAge }))
).rejects.toThrow(/minimumReleaseAge/)
})
test('the lockfile minimumReleaseAge gate accepts loose-mode entries already on the exclude list', async () => {
prepareEmpty()
const { updatedManifest: manifest } = await addDependenciesToPackage({}, ['is-odd@0.1.2'], testDefaults())
// is-odd@0.1.2 pulls in is-buffer and kind-of transitively. With the exclude
// list pre-populated (as the auto-collect would have produced on a previous
// install), the loose-mode verifier accepts all three and the install
// completes — the steady-state shape this feature is built around.
await expect(
install(manifest, testDefaults({
minimumReleaseAge,
minimumReleaseAgeStrict: false,
minimumReleaseAgeExclude: ['is-odd@0.1.2', 'is-buffer', 'kind-of'],
}))
).resolves.toBeDefined()
})
test('loose mode surfaces immature fresh picks in the install result', async () => {
prepareEmpty()
// Every version is younger than the cutoff. With strict mode off the
// resolver's lowest-version fallback installs the immature version,
// and the post-resolution scan in `mutateModules` reports it back via
// `resolutionPolicyViolations`. The CLI command filters by code to
// persist the entries to `minimumReleaseAgeExclude`.
const opts = testDefaults({ minimumReleaseAge: allImmatureMinimumReleaseAge })
const result = await addDependenciesToPackage({}, ['is-odd@0.1'], {
...opts,
// Acknowledge the violations without aborting so the install
// proceeds and the result can be inspected.
handleResolutionPolicyViolations: async () => {},
})
expect(result.resolutionPolicyViolations).toContainEqual(
expect.objectContaining({
name: 'is-odd',
version: '0.1.0',
code: 'MINIMUM_RELEASE_AGE_VIOLATION',
})
)
})
test('versions excluded via minimumReleaseAgeExclude are not surfaced as violations', async () => {
prepareEmpty()
const opts = testDefaults({
minimumReleaseAge: allImmatureMinimumReleaseAge,
minimumReleaseAgeExclude: ['is-odd'],
})
// is-odd is excluded, but `is-odd@0.1.2` pulls in is-buffer / is-number /
// kind-of transitively — those still produce policy violations. Wire a
// no-op handler to acknowledge them.
const result = await addDependenciesToPackage({}, ['is-odd@0.1'], {
...opts,
handleResolutionPolicyViolations: async () => {},
})
// is-odd is excluded by policy — the install installed 0.1.2 (the highest in
// range) treating it as fully trusted. The verifier short-circuits on the
// excluded entry, so it doesn't end up in the violations array — otherwise
// every install would re-add the same exclude entry the user just dismissed.
expect(result.resolutionPolicyViolations.find((v) => v.name === 'is-odd')).toBeUndefined()
})
test('handleResolutionPolicyViolations throwing aborts the install before the lockfile is written', async () => {
// Simulates the strict-mode interactive prompt rejecting the immature
// picks. The hook runs after the new lockfile is built but before it's
// written to disk; throwing unwinds the install in its pre-install state.
prepareEmpty()
const opts = testDefaults({ minimumReleaseAge: allImmatureMinimumReleaseAge })
await expect(addDependenciesToPackage({}, ['is-odd@0.1'], {
...opts,
handleResolutionPolicyViolations: async () => {
throw new Error('user denied')
},
})).rejects.toThrow(/user denied/)
// The lockfile must NOT have been written — the throw fires before the
// resolver finishes, so no on-disk side effects.
await expect(readWantedLockfile('.', { ignoreIncompatible: false })).resolves.toBeNull()
})
test('resolveDependencies throws if violations fire but no handleResolutionPolicyViolations is wired', async () => {
// Safety net: the policy contract is "every pick that trips a check
// produces a violation that gets handled". A caller that opted into a
// policy but forgot to wire the handler would otherwise silently drop
// the violations and land policy-rejected versions in the lockfile.
prepareEmpty()
const opts = testDefaults({ minimumReleaseAge: allImmatureMinimumReleaseAge })
await expect(addDependenciesToPackage({}, ['is-odd@0.1'], {
...opts,
// Explicitly omit handleResolutionPolicyViolations.
handleResolutionPolicyViolations: undefined,
})).rejects.toMatchObject({ code: 'ERR_PNPM_RESOLUTION_POLICY_VIOLATIONS_UNHANDLED' })
})
test('handleResolutionPolicyViolations approval lets the install proceed cleanly', async () => {
prepareEmpty()
const opts = testDefaults({ minimumReleaseAge: allImmatureMinimumReleaseAge })
const result = await addDependenciesToPackage({}, ['is-odd@0.1'], {
...opts,
handleResolutionPolicyViolations: async (violations) => {
// The real install command would inspect the violations and run
// an enquirer prompt here. The test just confirms the hook gets a
// full set and returns to approve.
expect(violations.some((v) => v.name === 'is-odd' && v.version === '0.1.0')).toBe(true)
},
})
expect(result.updatedManifest.dependencies!['is-odd']).toBe('~0.1.0')
})

View File

@@ -68,6 +68,29 @@ test('throws with the verifier-supplied code and reason on a single failure', as
})
})
test('throws a generic code with per-entry codes in the breakdown when violations span policies', async () => {
const lockfile = makeLockfile({
'is-odd@0.1.2': { resolution: tarballResolution('sha512-a') },
'untrusted@1.0.0': { resolution: tarballResolution('sha512-b') },
})
const verifier = wrap(async (_, { name }) => {
if (name === 'is-odd') {
return { ok: false, code: 'MINIMUM_RELEASE_AGE_VIOLATION', reason: 'too fresh' }
}
return { ok: false, code: 'TRUST_DOWNGRADE', reason: 'trust weakened' }
})
await expect(verifyLockfileResolutions(lockfile, [verifier])).rejects.toMatchObject({
// Mixed-code batch escalates to the generic LOCKFILE_RESOLUTION_VERIFICATION
// code so downstream handlers don't mis-route on whichever entry happened
// to land first.
code: 'ERR_PNPM_LOCKFILE_RESOLUTION_VERIFICATION',
// Per-entry code is included in the breakdown so the user can see
// which policy each line tripped.
message: expect.stringMatching(/is-odd@0\.1\.2 \[MINIMUM_RELEASE_AGE_VIOLATION\][\s\S]*untrusted@1\.0\.0 \[TRUST_DOWNGRADE\]/),
})
})
test('lists violations in stable order across multiple failures', async () => {
const lockfile = makeLockfile({
'fresh-b@2.0.0': { resolution: tarballResolution('sha512-b') },
@@ -157,14 +180,17 @@ test('the verifier sees the resolution shape verbatim', async () => {
expect(received).toEqual(expect.arrayContaining([npmResolution, gitResolution]))
})
test('uses the first violation\'s code when multiple verifiers fire', async () => {
test('keeps the per-policy code when every violation in the batch shares it', async () => {
// Same code across all violations → throw with that code so existing
// handlers / docs / search routes still match. Mixed-code coverage is
// in the dedicated "throws a generic code …" test above.
const lockfile = makeLockfile({
'a@1.0.0': { resolution: tarballResolution('sha512-a') },
'b@1.0.0': { resolution: tarballResolution('sha512-b') },
})
const verifier = wrap(async (_, { name }) => ({
const verifier = wrap(async () => ({
ok: false,
code: name === 'a' ? 'POLICY_A' : 'POLICY_B',
code: 'POLICY_A',
reason: 'failed',
}))

View File

@@ -6,6 +6,7 @@ import {
} from '@pnpm/core-loggers'
import { findRuntimeNodeVersion, iterateHashedGraphNodes } from '@pnpm/deps.graph-hasher'
import { isRuntimeDepPath } from '@pnpm/deps.path'
import { PnpmError } from '@pnpm/error'
import type {
LockfileObject,
ProjectSnapshot,
@@ -16,6 +17,7 @@ import {
getAllDependenciesFromManifest,
getSpecFromPackageManifest,
} from '@pnpm/pkg-manifest.utils'
import type { ResolutionPolicyViolation } from '@pnpm/resolving.resolver-base'
import {
type AllowBuild,
DEPENDENCIES_FIELDS,
@@ -110,6 +112,17 @@ export interface ResolveDependenciesResult {
peerDependencyIssuesByProjects: PeerDependencyIssuesByProjects
waitTillAllFetchingsFinish: () => Promise<void>
wantedToBeSkippedPackageIds: Set<string>
/**
* Policy violations collected inline during resolution — each
* resolver pushes to the list whenever it picks a version that
* trips one of its own checks (today: `minimumReleaseAge`). The
* install command reacts via `handleResolutionPolicyViolations`
* (prompt / abort) and `mutateModules` forwards the array out so
* the auto-persist path at the install's tail can drain it into
* the workspace manifest. Empty when no policy is active or no
* pick violates.
*/
resolutionPolicyViolations: ResolutionPolicyViolation[]
}
export async function resolveDependencies (
@@ -127,6 +140,17 @@ export async function resolveDependencies (
allowUnusedPatches?: boolean
enableGlobalVirtualStore?: boolean
allProjectIds: string[]
/**
* Generic checkpoint invoked between `resolveDependencyTree` and
* `resolvePeers` once any inline-collected policy violations have
* been gathered. Callers can prompt, persist, or throw based on
* the violations. Throwing unwinds before any peer-dep work,
* lockfile write, package.json update, or modules-dir change.
* Intentionally policy-neutral: each resolver owns its violation
* codes and the hook implementer (install command) decides what
* to do with them.
*/
handleResolutionPolicyViolations?: (violations: readonly ResolutionPolicyViolation[]) => Promise<void>
}
): Promise<ResolveDependenciesResult> {
const _toResolveImporter = toResolveImporter.bind(null, {
@@ -148,8 +172,36 @@ export async function resolveDependencies (
appliedPatches,
time,
allPeerDepNames,
resolutionPolicyViolations,
} = await resolveDependencyTree(projectsToResolve, opts)
// Resolver-policy gate between main resolution and peer-dep
// resolution: every resolver records its own policy violations
// inline as it picks each version, and we hand the accumulated
// list to the install command's hook. The hook throws to abort
// cleanly — nothing on disk has changed yet, and we haven't paid
// the cost of peer resolution. Dispatch stays policy-neutral: each
// resolver owns its violation codes, and the hook implementer
// decides what to do with them.
//
// If violations fired but no hook was wired, throw rather than
// silently dropping them — the resolver-policy contract is "every
// pick that trips a check produces a violation that gets handled";
// a missing handler means the caller forgot to opt in and would
// otherwise see policy-rejected versions land in the lockfile.
if (resolutionPolicyViolations.length > 0) {
if (!opts.handleResolutionPolicyViolations) {
throw new PnpmError(
'RESOLUTION_POLICY_VIOLATIONS_UNHANDLED',
`${resolutionPolicyViolations.length} resolution-policy ${resolutionPolicyViolations.length === 1 ? 'violation was' : 'violations were'} produced but no handleResolutionPolicyViolations callback was wired to react to them.`,
{
hint: 'Internal: resolveDependencies needs a handleResolutionPolicyViolations callback whenever a policy that can produce violations (today: minimumReleaseAge) is active. Wire setupPolicyHandlers (in @pnpm/installing.commands) or supply a callback directly.',
}
)
}
await opts.handleResolutionPolicyViolations(resolutionPolicyViolations)
}
opts.storeController.clearResolutionCache()
// We only check whether patches were applied in cases when the whole lockfile was reanalyzed.
@@ -358,6 +410,7 @@ export async function resolveDependencies (
peerDependencyIssuesByProjects,
waitTillAllFetchingsFinish,
wantedToBeSkippedPackageIds,
resolutionPolicyViolations,
}
}

View File

@@ -28,6 +28,7 @@ import {
type PkgResolutionId,
type PreferredVersions,
type Resolution,
type ResolutionPolicyViolation,
type WorkspacePackages,
} from '@pnpm/resolving.resolver-base'
import type {
@@ -187,6 +188,16 @@ export interface ResolutionContext {
hoistPeers?: boolean
maximumPublishedBy?: Date
publishedByExclude?: PackageVersionPolicy
/**
* Shared accumulator the resolver pushes into when an inline policy
* check (today: minimumReleaseAge in `npm-resolver`) flags a pick.
* resolveDependencyTree hands the populated array back to the install
* command via its return so the post-tree gate can prompt / abort /
* persist without re-walking the resolved tree. Each verifier code
* (`MINIMUM_RELEASE_AGE_VIOLATION`, `TRUST_DOWNGRADE`, …) is the
* contract surface for downstream UX.
*/
resolutionPolicyViolations: ResolutionPolicyViolation[]
trustPolicy?: TrustPolicy
trustPolicyExclude?: PackageVersionPolicy
trustPolicyIgnoreAfter?: number
@@ -1373,7 +1384,7 @@ async function resolveDependency (
bareSpecifier: wantedDependency.bareSpecifier,
version: wantedDependency.alias ? wantedDependency.bareSpecifier : undefined,
}
if (wantedDependency.optional && err.code !== 'ERR_PNPM_TRUST_DOWNGRADE' && err.code !== 'ERR_PNPM_NO_MATURE_MATCHING_VERSION') {
if (wantedDependency.optional && err.code !== 'ERR_PNPM_TRUST_DOWNGRADE') {
skippedOptionalDependencyLogger.debug({
details: err.toString(),
package: wantedDependencyDetails,
@@ -1398,6 +1409,14 @@ async function resolveDependency (
},
})
// Resolver-inline policy violations (e.g. minimumReleaseAge) flow up
// here; collect them onto the shared context so resolveDependencyTree
// can hand the full set to the install command between
// resolveDependencyTree and resolvePeers.
if (pkgResponse.body.policyViolation) {
ctx.resolutionPolicyViolations.push(pkgResponse.body.policyViolation)
}
// Check if exotic dependencies are disallowed in subdependencies
if (
ctx.blockExoticSubdeps &&

View File

@@ -5,7 +5,7 @@ import type { LockfileObject } from '@pnpm/lockfile.types'
import { globalWarn } from '@pnpm/logger'
import type { PatchGroupRecord } from '@pnpm/patching.config'
import { BUILTIN_NAMED_REGISTRIES } from '@pnpm/resolving.npm-resolver'
import type { PreferredVersions, Resolution, WorkspacePackages } from '@pnpm/resolving.resolver-base'
import type { PreferredVersions, Resolution, ResolutionPolicyViolation, WorkspacePackages } from '@pnpm/resolving.resolver-base'
import type { StoreController } from '@pnpm/store.controller-types'
import type {
AllowBuild,
@@ -159,6 +159,14 @@ export interface ResolveDependencyTreeResult {
wantedToBeSkippedPackageIds: Set<string>
appliedPatches: Set<string>
time?: Record<string, string>
/**
* Policy violations collected inline during resolution — the
* resolver pushes to this list whenever it picks a package that
* trips one of its own checks (today: `minimumReleaseAge`). The
* shape mirrors `ResolutionPolicyViolation`; downstream callers
* filter by `code` to decide what to do.
*/
resolutionPolicyViolations: ResolutionPolicyViolation[]
}
export async function resolveDependencyTree<T> (
@@ -220,6 +228,7 @@ export async function resolveDependencyTree<T> (
trustPolicyExclude: opts.trustPolicyExclude ? createPackageVersionPolicyOrThrow(opts.trustPolicyExclude, 'trustPolicyExclude') : undefined,
trustPolicyIgnoreAfter: opts.trustPolicyIgnoreAfter,
blockExoticSubdeps: opts.blockExoticSubdeps,
resolutionPolicyViolations: [],
}
const resolveArgs: ImporterToResolve[] = importers.map((importer) => {
@@ -343,6 +352,7 @@ export async function resolveDependencyTree<T> (
appliedPatches: ctx.appliedPatches,
time,
allPeerDepNames: ctx.allPeerDepNames,
resolutionPolicyViolations: ctx.resolutionPolicyViolations,
}
}

View File

@@ -193,6 +193,7 @@ async function resolveAndFetch (
publishedAt,
normalizedBareSpecifier,
alias,
policyViolation,
} = resolveResult
// Check if the integrity has changed between the current and newly resolved package
@@ -256,6 +257,7 @@ async function resolveAndFetch (
updated,
publishedAt,
alias,
policyViolation,
},
}
}
@@ -319,6 +321,7 @@ async function resolveAndFetch (
updated,
publishedAt,
alias,
policyViolation,
},
fetching: fetchResult.fetching,
filesIndexFile: fetchResult.filesIndexFile,

15
pnpm-lock.yaml generated
View File

@@ -5138,6 +5138,9 @@ importers:
'@pnpm/engine.runtime.node-resolver':
specifier: workspace:*
version: link:../../engine/runtime/node-resolver
'@pnpm/error':
specifier: workspace:*
version: link:../../core/error
'@pnpm/fetching.binary-fetcher':
specifier: workspace:*
version: link:../../fetching/binary-fetcher
@@ -5165,6 +5168,9 @@ importers:
'@pnpm/resolving.default-resolver':
specifier: workspace:*
version: link:../../resolving/default-resolver
'@pnpm/resolving.npm-resolver':
specifier: workspace:*
version: link:../../resolving/npm-resolver
'@pnpm/resolving.resolver-base':
specifier: workspace:*
version: link:../../resolving/resolver-base
@@ -5271,6 +5277,9 @@ importers:
'@pnpm/pkg-manifest.utils':
specifier: workspace:*
version: link:../../pkg-manifest/utils
'@pnpm/resolving.npm-resolver':
specifier: workspace:*
version: link:../../resolving/npm-resolver
'@pnpm/resolving.parse-wanted-dependency':
specifier: workspace:*
version: link:../../resolving/parse-wanted-dependency
@@ -5337,6 +5346,9 @@ importers:
chalk:
specifier: 'catalog:'
version: 5.6.2
ci-info:
specifier: 'catalog:'
version: 4.4.0
enquirer:
specifier: 'catalog:'
version: 2.4.1
@@ -5422,9 +5434,6 @@ importers:
'@types/zkochan__table':
specifier: 'catalog:'
version: '@types/table@6.0.0'
ci-info:
specifier: 'catalog:'
version: 4.4.0
delay:
specifier: 'catalog:'
version: 7.0.0

View File

@@ -108,7 +108,7 @@ describe('minimumReleaseAge from pnpm-workspace.yaml', () => {
], { omitEnvDefaults: ['pnpm_config_minimum_release_age'] })
expect(result.status).toBe(1)
expect(result.stderr.toString()).toMatch(/does not meet the minimumReleaseAge constraint/)
expect(result.stderr.toString()).toMatch(/was published.+minimumReleaseAge cutoff/)
})
test('dlx succeeds when the requested version is older than minimumReleaseAge', () => {
@@ -172,7 +172,7 @@ skipOnWindows('pnpm create respects minimumReleaseAge from pnpm-workspace.yaml',
], { omitEnvDefaults: ['pnpm_config_minimum_release_age'] })
expect(result.status).toBe(1)
expect(result.stderr.toString()).toMatch(/does not meet the minimumReleaseAge constraint/)
expect(result.stderr.toString()).toMatch(/was published.+minimumReleaseAge cutoff/)
})
describe('catalogs inherited from pnpm-workspace.yaml', () => {

View File

@@ -2,7 +2,8 @@ import fs from 'node:fs'
import path from 'node:path'
import { describe, expect, test } from '@jest/globals'
import { prepare } from '@pnpm/prepare'
import { prepare, preparePackages } from '@pnpm/prepare'
import { readYamlFileSync } from 'read-yaml-file'
import { writeYamlFileSync } from 'write-yaml-file'
import { execPnpm, execPnpmSync } from '../utils/index.js'
@@ -119,11 +120,12 @@ describe('lockfile minimumReleaseAge verification', () => {
)
})
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.
test('loose mode rejects immature lockfile entries that are not on minimumReleaseAgeExclude', () => {
// The verifier now runs in loose mode too, so a lockfile produced under
// no policy that still has immature pins is rejected the same way
// strict mode would reject it. The expected workflow is: the loose-mode
// auto-collect (during fresh resolution) populates the exclude list, and
// subsequent installs run cleanly against that list.
prepare({
dependencies: { 'is-odd': '0.1.2' },
})
@@ -134,9 +136,218 @@ describe('lockfile minimumReleaseAge verification', () => {
minimumReleaseAgeStrict: false,
})
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/)
})
test('loose mode auto-adds fresh immature picks to minimumReleaseAgeExclude', () => {
// Fresh resolution under loose mode: the resolver's lowest-version
// fallback picks an immature version, and the install layer surfaces it
// to `minimumReleaseAgeExclude`. The verifier sees an empty lockfile at
// the start (no entries to reject) and the workspace manifest grows.
prepare({
dependencies: { 'is-odd': '0.1.2' },
})
writeYamlFileSync('pnpm-workspace.yaml', {
minimumReleaseAge: IMMATURE_FOR_EVERYTHING,
minimumReleaseAgeStrict: false,
})
execPnpmSync(
[PUBLIC_REGISTRY, 'install'],
{ ...omitMinReleaseAgeEnv, expectSuccess: true }
)
const workspaceManifest = readYamlFileSync<{ minimumReleaseAgeExclude?: string[] }>('pnpm-workspace.yaml')
// is-odd@0.1.2 pulls in is-buffer, is-number, and kind-of transitively;
// every one of those is immature relative to the (deliberately extreme)
// cutoff, so all four end up on the exclude list. Match by package name
// (any version) so the test stays stable across npm-registry republishes
// that shift the transitive pins.
expect(workspaceManifest.minimumReleaseAgeExclude).toEqual(expect.arrayContaining([
'is-odd@0.1.2',
expect.stringMatching(/^is-buffer@/),
expect.stringMatching(/^is-number@/),
expect.stringMatching(/^kind-of@/),
]))
})
test('loose-mode auto-exclude is a no-op when no immature picks occur', () => {
// is-positive@1.0.0 was published in 2014; with a 1-minute cutoff it
// stays mature relative to the policy. Auto-exclude should not touch
// the workspace manifest when there's nothing to add.
prepare({
dependencies: { 'is-positive': '1.0.0' },
})
execPnpmSync([PUBLIC_REGISTRY, 'install'], { expectSuccess: true })
writeYamlFileSync('pnpm-workspace.yaml', {
minimumReleaseAge: 1,
minimumReleaseAgeStrict: false,
})
execPnpmSync(
[PUBLIC_REGISTRY, 'install'],
{ ...omitMinReleaseAgeEnv, expectSuccess: true }
)
const workspaceManifest = readYamlFileSync<{ minimumReleaseAgeExclude?: string[] }>('pnpm-workspace.yaml')
expect(workspaceManifest.minimumReleaseAgeExclude).toBeUndefined()
})
test('subsequent installs run cleanly once the exclude list is populated', () => {
// Round-trip the auto-collect: first install populates the exclude list
// from fresh resolution, the next install runs the verifier against the
// now-populated list and succeeds without re-announcing anything. The
// verifier and the auto-collect together keep the workspace manifest in
// sync with the lockfile across installs.
prepare({
dependencies: { 'is-odd': '0.1.2' },
})
writeYamlFileSync('pnpm-workspace.yaml', {
minimumReleaseAge: IMMATURE_FOR_EVERYTHING,
minimumReleaseAgeStrict: false,
})
execPnpmSync(
[PUBLIC_REGISTRY, 'install'],
{ ...omitMinReleaseAgeEnv, expectSuccess: true }
)
execPnpmSync(
[PUBLIC_REGISTRY, 'install', '--frozen-lockfile'],
{ ...omitMinReleaseAgeEnv, expectSuccess: true }
)
})
test('recursive --no-save leaves the workspace manifest untouched even when picks are collected (shared lockfile)', () => {
// The shared-lockfile recursive branch in recursive.ts: a single
// `mutateModules` call across all importers. Same drain-only-when-
// saving gate has to hold here.
preparePackages([
{
name: 'project-a',
version: '1.0.0',
dependencies: { 'is-odd': '0.1.2' },
},
])
writeYamlFileSync('pnpm-workspace.yaml', {
packages: ['*'],
minimumReleaseAge: IMMATURE_FOR_EVERYTHING,
minimumReleaseAgeStrict: false,
})
execPnpmSync(
[PUBLIC_REGISTRY, '-r', 'install', '--no-save'],
{ ...omitMinReleaseAgeEnv, expectSuccess: true }
)
const workspaceManifest = readYamlFileSync<{ minimumReleaseAgeExclude?: string[] }>('pnpm-workspace.yaml')
expect(workspaceManifest.minimumReleaseAgeExclude).toBeUndefined()
})
test('recursive --no-save leaves the workspace manifest untouched even when picks are collected (per-project lockfiles)', () => {
// The other recursive branch: with sharedWorkspaceLockfile: false
// the per-project loop is taken instead of the single
// mutateModules call. The post-loop updateWorkspaceManifest at the
// tail of recursive.ts also has to honor --no-save.
preparePackages([
{
name: 'project-a',
version: '1.0.0',
dependencies: { 'is-odd': '0.1.2' },
},
])
writeYamlFileSync('pnpm-workspace.yaml', {
packages: ['*'],
sharedWorkspaceLockfile: false,
minimumReleaseAge: IMMATURE_FOR_EVERYTHING,
minimumReleaseAgeStrict: false,
})
execPnpmSync(
[PUBLIC_REGISTRY, '-r', 'install', '--no-save'],
{ ...omitMinReleaseAgeEnv, expectSuccess: true }
)
const workspaceManifest = readYamlFileSync<{ minimumReleaseAgeExclude?: string[] }>('pnpm-workspace.yaml')
expect(workspaceManifest.minimumReleaseAgeExclude).toBeUndefined()
})
test('--no-save leaves the workspace manifest untouched even when picks are collected', () => {
// `--no-save` means "don't persist anything from this install" — the
// auto-add should obey that. Without the gate, the info log would
// claim entries were added that never reached pnpm-workspace.yaml,
// and the next install would either re-prompt or fail verification.
prepare({
dependencies: { 'is-odd': '0.1.2' },
})
writeYamlFileSync('pnpm-workspace.yaml', {
minimumReleaseAge: IMMATURE_FOR_EVERYTHING,
minimumReleaseAgeStrict: false,
})
// First install resolves and populates the lockfile but not the
// workspace manifest (because --no-save).
execPnpmSync(
[PUBLIC_REGISTRY, 'install', '--no-save'],
{ ...omitMinReleaseAgeEnv, expectSuccess: true }
)
const workspaceManifest = readYamlFileSync<{ minimumReleaseAgeExclude?: string[] }>('pnpm-workspace.yaml')
expect(workspaceManifest.minimumReleaseAgeExclude).toBeUndefined()
})
test('verifier cache invalidates when minimumReleaseAgeExclude is shrunk', async () => {
// Removing an entry from the exclude list could expose a violation
// that previously passed verification. The cache record snapshots the
// exclude list and `canTrustPastCheck` rejects the cached run when
// today's list isn't a superset of the cached one — so the next
// install re-verifies and the now-uncovered immature lockfile entry
// is flagged.
prepare({
dependencies: { 'is-odd': '0.1.2' },
})
await execPnpm([PUBLIC_REGISTRY, 'install'])
const cacheDir = path.resolve('pnpm-cache')
writeYamlFileSync('pnpm-workspace.yaml', {
minimumReleaseAge: IMMATURE_FOR_EVERYTHING,
minimumReleaseAgeStrict: true,
minimumReleaseAgeExclude: ['is-odd', 'is-buffer', 'is-number', 'kind-of'],
cacheDir,
})
// Step 1: install with the full exclude list — verifier writes a
// cache record under that policy.
execPnpmSync(
[PUBLIC_REGISTRY, 'install', '--frozen-lockfile'],
{ ...omitMinReleaseAgeEnv, expectSuccess: true }
)
const cacheFile = path.join(cacheDir, 'lockfile-verified.jsonl')
expect(fs.existsSync(cacheFile)).toBe(true)
// Step 2: drop `is-odd` from the exclude list. The cached record
// had it; today doesn't. canTrustPastCheck must reject so the
// re-verification flags is-odd@0.1.2 as immature.
writeYamlFileSync('pnpm-workspace.yaml', {
minimumReleaseAge: IMMATURE_FOR_EVERYTHING,
minimumReleaseAgeStrict: true,
minimumReleaseAgeExclude: ['is-buffer', 'is-number', 'kind-of'],
cacheDir,
})
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/)
})
})

View File

@@ -643,3 +643,46 @@ test('install does not fail when the trust evidence of a package is downgraded b
expect(result.status).toBe(0)
project.has('@pnpm/e2e.test-provenance')
})
test('lockfile verifier rejects a trust-downgraded entry that bypassed resolution', () => {
// Step 1: install with trust policy off. The resolver picks up the
// downgraded version without complaint and writes it to the lockfile.
prepare()
execPnpmSync(
['add', '@pnpm/e2e.test-provenance@0.0.5', '--trust-policy=off'],
{ expectSuccess: true }
)
// Step 2: turn the policy on. The resolver wouldn't be invoked under
// --frozen-lockfile (peek-path takes over), so the trust check would
// be silently bypassed if the verifier weren't running. The lockfile
// verifier catches the same downgrade pattern the resolver-time check
// catches at fresh resolution.
const result = execPnpmSync([
'install',
'--frozen-lockfile',
'--trust-policy=no-downgrade',
])
expect(result.status).toBe(1)
const output = `${result.stdout.toString()}\n${result.stderr.toString()}`
expect(output).toContain('ERR_PNPM_TRUST_DOWNGRADE')
expect(output).toMatch(/@pnpm\/e2e\.test-provenance/)
})
test('lockfile verifier respects trust-policy-exclude on a downgraded lockfile entry', () => {
prepare()
execPnpmSync(
['add', '@pnpm/e2e.test-provenance@0.0.5', '--trust-policy=off'],
{ expectSuccess: true }
)
// With the exclude entry in place, the verifier short-circuits before
// running the trust check on this package — mirrors the resolver-time
// exclude path so users have one consistent way to allow a downgrade.
execPnpmSync([
'install',
'--frozen-lockfile',
'--trust-policy=no-downgrade',
'--trust-policy-exclude=@pnpm/e2e.test-provenance',
], { expectSuccess: true })
})

View File

@@ -151,6 +151,10 @@ export type ResolutionVerifierFactoryOptions =
| 'minimumReleaseAge'
| 'minimumReleaseAgeStrict'
| 'minimumReleaseAgeExclude'
| 'ignoreMissingTimeField'
| 'trustPolicy'
| 'trustPolicyExclude'
| 'trustPolicyIgnoreAfter'
| 'now'
> & {
configByUri?: Record<string, RegistryConfig>
@@ -184,6 +188,10 @@ export function createResolutionVerifiers (
minimumReleaseAge: opts.minimumReleaseAge,
minimumReleaseAgeStrict: opts.minimumReleaseAgeStrict,
minimumReleaseAgeExclude: opts.minimumReleaseAgeExclude,
ignoreMissingTimeField: opts.ignoreMissingTimeField,
trustPolicy: opts.trustPolicy,
trustPolicyExclude: opts.trustPolicyExclude,
trustPolicyIgnoreAfter: opts.trustPolicyIgnoreAfter,
registries: opts.registries,
namedRegistries: opts.namedRegistries,
fetchOpts,

View File

@@ -2,11 +2,12 @@ import { pickRegistryForPackage } from '@pnpm/config.pick-registry-for-package'
import { createPackageVersionPolicy } from '@pnpm/config.version-policy'
import { FULL_META_DIR } from '@pnpm/constants'
import { PnpmError } from '@pnpm/error'
import type { PackageMeta } from '@pnpm/resolving.registry.types'
import type {
Resolution,
ResolutionVerifier,
} from '@pnpm/resolving.resolver-base'
import type { PackageVersionPolicy, Registries } from '@pnpm/types'
import type { PackageVersionPolicy, Registries, TrustPolicy } from '@pnpm/types'
import semver from 'semver'
import type { FetchMetadataFromFromRegistryOptions } from './fetch.js'
@@ -17,7 +18,12 @@ import {
type FetchFullMetadataCachedOptions,
} from './fetchFullMetadataCached.js'
import { BUILTIN_NAMED_REGISTRIES } from './parseBareSpecifier.js'
import { getPkgMirrorPath, loadMeta } from './pickPackage.js'
import { getPkgMirrorPath, loadMeta, warnMissingTimeFieldOnce } from './pickPackage.js'
import { failIfTrustDowngraded } from './trustChecks.js'
import {
MINIMUM_RELEASE_AGE_VIOLATION_CODE,
TRUST_DOWNGRADE_VIOLATION_CODE,
} from './violationCodes.js'
export interface CreateNpmResolutionVerifierOptions {
/**
@@ -26,13 +32,37 @@ export interface CreateNpmResolutionVerifierOptions {
*/
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.
* Retained on the options bag because the resolver path branches on it
* (the lowest-version fallback) and tests forward both fields together.
* The verifier itself no longer gates on this flag — once the loose-mode
* auto-collect makes every accepted-immature pin explicit in
* `minimumReleaseAgeExclude`, running the verifier in loose mode is the
* thing that proves the manifest stays in sync with the lockfile.
*/
minimumReleaseAgeStrict?: boolean
minimumReleaseAgeExclude?: string[]
/**
* When the registry's metadata lacks the per-version `time` field
* (some self-hosted registries strip it), the verifier can't apply
* the maturity cutoff. Set this to `true` to mirror the resolver's
* `pickMatchingVersionFinal` warn-and-skip behavior — the verifier
* passes the entry with a one-time `globalWarn`, instead of failing
* closed. Defaults to `false` so the verifier stays stricter than
* the resolver only when the user has explicitly opted in to the
* skip on the resolver side.
*/
ignoreMissingTimeField?: boolean
/**
* `'no-downgrade'` rejects a lockfile entry whose version has weaker
* trust evidence (no attestations) than an earlier-published version
* had. This mirrors the resolver-time `failIfTrustDowngraded` check
* applied during fresh resolution — the verifier catches the same
* supply-chain signal on entries that bypassed resolution (peek-path,
* frozen lockfile, etc.).
*/
trustPolicy?: TrustPolicy
trustPolicyExclude?: string[]
trustPolicyIgnoreAfter?: number
registries: Registries
/**
* Registries reached via the named-registry resolver chain (e.g. `gh:` →
@@ -55,9 +85,10 @@ export interface CreateNpmResolutionVerifierOptions {
/**
* 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.
* and/or `trustPolicy='no-downgrade'` policies 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
@@ -67,11 +98,20 @@ export interface CreateNpmResolutionVerifierOptions {
export function createNpmResolutionVerifier (
opts: CreateNpmResolutionVerifierOptions
): ResolutionVerifier | undefined {
if (!opts.minimumReleaseAge || !opts.minimumReleaseAgeStrict) return undefined
const ageCheckActive = Boolean(opts.minimumReleaseAge)
const trustCheckActive = opts.trustPolicy === 'no-downgrade'
// No policy → no verifier. Skipping early keeps the install-side fan-out
// empty when nothing is configured.
if (!ageCheckActive && !trustCheckActive) return undefined
const cutoff = (opts.now ?? Date.now()) - opts.minimumReleaseAge * 60 * 1000
const cutoff = ageCheckActive
? (opts.now ?? Date.now()) - opts.minimumReleaseAge! * 60 * 1000
: 0
const excludePolicy = opts.minimumReleaseAgeExclude?.length
? createExcludePolicy(opts.minimumReleaseAgeExclude)
? createExcludePolicy(opts.minimumReleaseAgeExclude, 'minimumReleaseAgeExclude')
: undefined
const trustExcludePolicy = opts.trustPolicyExclude?.length
? createExcludePolicy(opts.trustPolicyExclude, 'trustPolicyExclude')
: undefined
// Pre-normalize named-registry URLs and sort by length so two registries
@@ -98,10 +138,14 @@ export function createNpmResolutionVerifier (
.filter((value): value is string => value != null)
.sort((a, b) => b.length - a.length)
// Per-install dedup of every network/disk fetch the verifier issues
// (see fetchPublishedAt for the lookup order). The on-disk
// conditional-GET cache is handled inside fetch{Abbreviated,Full}MetadataCached
// via the resolver's shared mirrors at opts.cacheDir.
// Per-install dedup of every network/disk fetch the verifier issues.
// The maturity check uses the layered `fetchPublishedAt` lookup; the
// trust check uses an attestation fast-path before falling back to
// the same full-metadata mirror. All maps live here so verifying
// many versions of the same package only pays the disk/network costs
// once. The on-disk conditional-GET cache is handled inside
// fetch{Abbreviated,Full}MetadataCached via the resolver's shared
// mirrors at opts.cacheDir.
const lookupContext: PublishedAtLookupContext = {
fetchOpts: opts.fetchOpts,
getAuthHeaderValueByURI: opts.getAuthHeaderValueByURI,
@@ -111,74 +155,233 @@ export function createNpmResolutionVerifier (
publishedAtCache: new Map(),
localMetaCache: new Map(),
fullMetaCache: new Map(),
fullMetaForTrustCache: new Map(),
}
const minimumReleaseAge = opts.minimumReleaseAge
const minimumReleaseAge = opts.minimumReleaseAge ?? 0
const trustPolicy = opts.trustPolicy
const trustPolicyIgnoreAfter = opts.trustPolicyIgnoreAfter
const verify: ResolutionVerifier['verify'] = 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.
// Neither the age nor the trust policy applies, and a registry lookup
// would 404.
if (!semver.valid(version)) return { ok: true }
if (isExcluded(excludePolicy, name, version)) return { ok: true }
const ageApplies = ageCheckActive && !isExcluded(excludePolicy, name, version)
const trustApplies = trustCheckActive && !isExcluded(trustExcludePolicy, name, version)
if (!ageApplies && !trustApplies) return { ok: true }
const tarballUrl = (resolution as { tarball?: string }).tarball
const registry = pickRegistryForVersion(opts.registries, namedRegistryPrefixes, name, tarballUrl)
let published: string | undefined
try {
published = await fetchPublishedAt(lookupContext, registry, name, version)
} catch (err) {
return {
ok: false,
code: 'MINIMUM_RELEASE_AGE_VIOLATION',
reason: uncheckable(err instanceof Error ? err.message : String(err)),
}
if (ageApplies) {
const ageViolation = await runAgeCheck(lookupContext, registry, name, version, cutoff, opts.ignoreMissingTimeField === true)
if (ageViolation) return ageViolation
}
if (!published) {
// No source — attestation, local mirror, or full metadata —
// surfaced a publish timestamp for this version. Either it's
// unpublished or the registry doesn't expose per-version
// timestamps. 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()})`,
}
if (trustApplies) {
const trustViolation = await runTrustCheck(lookupContext, registry, name, version, {
trustPolicyExclude: trustExcludePolicy,
trustPolicyIgnoreAfter,
})
if (trustViolation) return trustViolation
}
return { ok: true }
}
// Snapshot the exclude lists (sorted, deduped) and require an exact
// match in `canTrustPastCheck`: cache identity == policy identity.
// Any change to either exclude list — adding, removing, or
// substituting an entry — invalidates the cached run. This is
// stricter than a pure correctness check would require (adding to
// either list is more permissive and the cached pass would still
// hold), but it makes the cache contract trivial to reason about and
// removes a class of bypasses where a previously-approved version
// stays trusted after its exclude entry has been pulled.
const sortedMinAgeExcludes = [...new Set(opts.minimumReleaseAgeExclude ?? [])].sort()
const sortedTrustExcludes = [...new Set(opts.trustPolicyExclude ?? [])].sort()
return {
verify,
policy: { minimumReleaseAge },
policy: {
minimumReleaseAge,
minimumReleaseAgeExclude: sortedMinAgeExcludes,
trustPolicy: trustPolicy ?? null,
trustPolicyExclude: sortedTrustExcludes,
trustPolicyIgnoreAfter: trustPolicyIgnoreAfter ?? null,
},
canTrustPastCheck: (cached) => {
// A previously cached run under a larger cutoff (stricter window)
// is trustworthy under a smaller current one — its set of
// accepted versions is a subset of today's. The reverse —
// tightening the cutoff — invalidates the cached run: versions
// that passed before may now be in-window. Non-number cached
// values come from an older record shape and aren't trusted.
// Maturity: a previously cached run under a larger cutoff
// (stricter window) is trustworthy under a smaller current one —
// its set of accepted versions is a subset of today's. The
// reverse — tightening the cutoff — invalidates the cached run:
// versions that passed before may now be in-window. Non-number
// cached values come from an older record shape and aren't trusted.
const past = cached.minimumReleaseAge
return typeof past === 'number' && past >= minimumReleaseAge
const pastNumber = typeof past === 'number' ? past : 0
if (pastNumber < minimumReleaseAge) return false
// Excludes: today's sorted-deduped lists must match the cached
// ones byte for byte. Older records (no field) fall back to an
// empty array, so they only trust today's empty policy.
const pastMinAgeExcludes = Array.isArray(cached.minimumReleaseAgeExclude)
? cached.minimumReleaseAgeExclude
: []
if (JSON.stringify(pastMinAgeExcludes) !== JSON.stringify(sortedMinAgeExcludes)) return false
// Trust policy: any change to `trustPolicy`, the exclude list, or
// the ignore-after cutoff invalidates the cached run. Older
// records (no trust field at all) treat the trust policy as
// absent and are only trusted under an unset-today policy.
const pastTrustPolicy = cached.trustPolicy ?? null
const todayTrustPolicy = trustPolicy ?? null
if (pastTrustPolicy !== todayTrustPolicy) return false
const pastTrustExcludes = Array.isArray(cached.trustPolicyExclude)
? cached.trustPolicyExclude
: []
if (JSON.stringify(pastTrustExcludes) !== JSON.stringify(sortedTrustExcludes)) return false
const pastIgnoreAfter = typeof cached.trustPolicyIgnoreAfter === 'number'
? cached.trustPolicyIgnoreAfter
: null
const todayIgnoreAfter = trustPolicyIgnoreAfter ?? null
if (pastIgnoreAfter !== todayIgnoreAfter) return false
return true
},
}
}
async function runAgeCheck (
context: PublishedAtLookupContext,
registry: string,
name: string,
version: string,
cutoff: number,
ignoreMissingTimeField: boolean
): Promise<{ ok: false, code: string, reason: string } | undefined> {
let published: string | undefined
try {
published = await fetchPublishedAt(context, registry, name, version)
} catch (err) {
return {
ok: false,
code: MINIMUM_RELEASE_AGE_VIOLATION_CODE,
reason: uncheckable('minimumReleaseAge', err instanceof Error ? err.message : String(err)),
}
}
if (!published) {
// No source — attestation, local mirror, or full metadata —
// surfaced a publish timestamp for this version. The resolver's
// pickMatchingVersionFinal honors `minimumReleaseAgeIgnoreMissingTime`
// for the same shape (some self-hosted registries strip per-version
// `time`); the verifier mirrors that so it can't be stricter than
// fresh resolution. Without the flag we still fail closed — better
// a false reject than silent bypass when the user hasn't opted in.
if (ignoreMissingTimeField) {
warnMissingTimeFieldOnce(name)
return undefined
}
return {
ok: false,
code: MINIMUM_RELEASE_AGE_VIOLATION_CODE,
reason: uncheckable('minimumReleaseAge', '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_CODE,
reason: 'publish timestamp is not a valid date',
}
}
if (ts > cutoff) {
return {
ok: false,
code: MINIMUM_RELEASE_AGE_VIOLATION_CODE,
reason: `was published at ${publishedAt.toISOString()}, within the minimumReleaseAge cutoff (${new Date(cutoff).toISOString()})`,
}
}
return undefined
}
/**
* Run the resolver-time `failIfTrustDowngraded` check against the
* pinned lockfile version. The packument is fetched through a
* per-install cache so multiple versions of the same package share
* one fetch.
*
* No attestation fast-path here even though the per-version
* attestation endpoint is cheaper than the packument: presence of
* provenance on the current version is not sufficient to clear a
* downgrade. A package could have shipped earlier versions under a
* `trustedPublisher` (the higher-rank evidence) and then dropped
* back to plain provenance for the version we're verifying —
* `failIfTrustDowngraded` correctly flags that, and a "has any
* attestation → pass" shortcut would silently miss it.
*/
async function runTrustCheck (
context: PublishedAtLookupContext,
registry: string,
name: string,
version: string,
opts: {
trustPolicyExclude?: PackageVersionPolicy
trustPolicyIgnoreAfter?: number
}
): Promise<{ ok: false, code: string, reason: string } | undefined> {
let meta: PackageMeta
try {
meta = await fetchFullMetaForTrust(context, registry, name)
} catch (err) {
// `fetchFullMetadataCached` rejects (network error, 404, etc.); the
// verifier fails closed so a missing manifest can't be mistaken
// for a passing trust check.
return {
ok: false,
code: TRUST_DOWNGRADE_VIOLATION_CODE,
reason: uncheckable('trustPolicy', err instanceof Error ? err.message : String(err)),
}
}
try {
failIfTrustDowngraded(meta, version, opts)
} catch (err) {
return {
ok: false,
code: TRUST_DOWNGRADE_VIOLATION_CODE,
reason: err instanceof Error ? err.message : String(err),
}
}
return undefined
}
function fetchFullMetaForTrust (
context: PublishedAtLookupContext,
registry: string,
name: string
): Promise<PackageMeta> {
const cacheKey = `${registry}\x00${name}`
let cachedPromise = context.fullMetaForTrustCache.get(cacheKey)
if (cachedPromise == null) {
// Don't swallow the fetch rejection here — `runTrustCheck` catches it
// and surfaces the underlying message in the violation reason, which
// is more actionable than the generic "metadata is unavailable" the
// `!meta` fallback emits. The cache still holds the rejected promise
// so repeat verifier calls for the same (registry, name) within one
// install don't refetch a known-failing endpoint.
cachedPromise = fetchFullMetadataCached(context.fetchOpts, name, {
registry,
authHeaderValue: context.getAuthHeaderValueByURI(registry),
cacheDir: context.cacheDir,
})
context.fullMetaForTrustCache.set(cacheKey, cachedPromise)
}
return cachedPromise
}
type PublishedAtTimeMap = Record<string, string | undefined>
interface PublishedAtLookupContext {
@@ -200,7 +403,7 @@ interface PublishedAtLookupContext {
* ~zero cost. Resolves to the parsed metadata or `undefined` on
* failure.
*/
abbreviatedMetaCache: Map<string, Promise<{ modified?: string } | undefined>>
abbreviatedMetaCache: Map<string, Promise<{ modified?: string, versions?: Record<string, unknown> } | undefined>>
/**
* Per-(registry+name+version) memo of the final published-at answer
* the verifier hands to the policy check. One install verifies each
@@ -219,6 +422,14 @@ interface PublishedAtLookupContext {
* attestation endpoint fail to yield a timestamp.
*/
fullMetaCache: Map<string, Promise<PublishedAtTimeMap | undefined>>
/**
* Per-(registry+name) memo of the full packument used by the trust
* check (history walk for `failIfTrustDowngraded`). Kept separate
* from `fullMetaCache` because the trust check needs the whole
* document (`_npmUser`, `dist.attestations` per version) where the
* age check only needs `time`.
*/
fullMetaForTrustCache: Map<string, Promise<PackageMeta>>
}
/**
@@ -263,7 +474,7 @@ async function resolvePublishedAt (
name: string,
version: string
): Promise<string | undefined> {
const abbreviatedShortcut = await tryAbbreviatedModifiedShortcut(context, registry, name)
const abbreviatedShortcut = await tryAbbreviatedModifiedShortcut(context, registry, name, version)
if (abbreviatedShortcut != null) return abbreviatedShortcut
const localTime = await readLocalMetaTime(context, registry, name)
@@ -282,18 +493,25 @@ async function resolvePublishedAt (
/**
* Returns the abbreviated metadata's `modified` timestamp **iff** it
* proves the gate would pass — i.e. modified is strictly older than
* the policy cutoff. In that case every version this package contains
* predates the cutoff, so the caller can short-circuit with `modified`
* as a conservative upper-bound publish time.
* the policy cutoff *and* the pinned version still exists in the
* package's current versions map.
*
* The version check is the fail-closed contract: an unpublished or
* never-published version must not slip through on the package-level
* `modified` timestamp. When the version is missing here we fall
* through to the later layers so the caller eventually surfaces the
* "version not present in registry manifest" violation.
*
* Returns `undefined` otherwise (modified is too recent, the metadata
* lacks a parseable modified field, or the fetch failed) and the
* caller proceeds with per-version lookups.
* lacks a parseable modified field, the version isn't in the abbreviated
* form, or the fetch failed) and the caller proceeds with per-version
* lookups.
*/
async function tryAbbreviatedModifiedShortcut (
context: PublishedAtLookupContext,
registry: string,
name: string
name: string,
version: string
): Promise<string | undefined> {
const meta = await fetchAbbreviatedMeta(context, registry, name)
const modified = meta?.modified
@@ -301,6 +519,11 @@ async function tryAbbreviatedModifiedShortcut (
const modifiedMs = Date.parse(modified)
if (Number.isNaN(modifiedMs)) return undefined
if (modifiedMs >= context.cutoffMs) return undefined
// The shortcut treats `modified` as an upper bound on every version's
// publish time — but only for versions the registry currently lists.
// An unpublished or never-published pin would otherwise pass the gate
// on a stale package-level timestamp.
if (!meta?.versions || !(version in meta.versions)) return undefined
return modified
}
@@ -308,7 +531,7 @@ function fetchAbbreviatedMeta (
context: PublishedAtLookupContext,
registry: string,
name: string
): Promise<{ modified?: string } | undefined> {
): Promise<{ modified?: string, versions?: Record<string, unknown> } | undefined> {
const cacheKey = `${registry}\x00${name}`
let cachedPromise = context.abbreviatedMetaCache.get(cacheKey)
if (cachedPromise == null) {
@@ -395,11 +618,11 @@ function tryParseUrl (url: string): URL | null {
}
}
function uncheckable (why: string): string {
return `could not be checked against minimumReleaseAge (${why})`
function uncheckable (policy: 'minimumReleaseAge' | 'trustPolicy', why: string): string {
return `could not be checked against ${policy} (${why})`
}
function createExcludePolicy (patterns: string[]): PackageVersionPolicy {
function createExcludePolicy (patterns: string[], key: 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.
@@ -408,8 +631,8 @@ function createExcludePolicy (patterns: string[]): PackageVersionPolicy {
} 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}`
`INVALID_${key.replace(/([A-Z])/g, '_$1').toUpperCase()}`,
`Invalid value in ${key}: ${(err as { message: string }).message}`
)
}
}

View File

@@ -12,6 +12,8 @@ import type {
DirectoryResolution,
PkgResolutionId,
PreferredVersions,
Resolution,
ResolutionPolicyViolation,
ResolveResult,
TarballResolution,
WantedDependency,
@@ -55,6 +57,7 @@ import {
} from './pickPackage.js'
import { pickPackageFromMeta, pickVersionByVersionRange } from './pickPackageFromMeta.js'
import { failIfTrustDowngraded } from './trustChecks.js'
import { MINIMUM_RELEASE_AGE_VIOLATION_CODE } from './violationCodes.js'
import { whichVersionIsPinned } from './whichVersionIsPinned.js'
import { workspacePrefToNpm } from './workspacePrefToNpm.js'
@@ -62,29 +65,16 @@ export interface NoMatchingVersionErrorOptions {
wantedDependency: WantedDependency
packageMeta: PackageMeta
registry: string
immatureVersion?: string
publishedBy?: Date
}
export class NoMatchingVersionError extends PnpmError {
public readonly packageMeta: PackageMeta
public readonly immatureVersion?: string
constructor (opts: NoMatchingVersionErrorOptions) {
const dep = opts.wantedDependency.alias
? `${opts.wantedDependency.alias}@${opts.wantedDependency.bareSpecifier ?? ''}`
: opts.wantedDependency.bareSpecifier!
let errorMessage: string
if (opts.publishedBy && opts.immatureVersion && opts.packageMeta.time) {
const time = new Date(opts.packageMeta.time[opts.immatureVersion])
const releaseAgeText = formatTimeAgo(time) ?? 'just now'
const pkgName = opts.wantedDependency.alias ?? opts.packageMeta.name
errorMessage = `Version ${opts.immatureVersion} (released ${releaseAgeText}) of ${pkgName} does not meet the minimumReleaseAge constraint`
} else {
errorMessage = `No matching version found for ${dep} while fetching it from ${opts.registry}`
}
super(opts.publishedBy ? 'NO_MATURE_MATCHING_VERSION' : 'NO_MATCHING_VERSION', errorMessage)
super('NO_MATCHING_VERSION', `No matching version found for ${dep} while fetching it from ${opts.registry}`)
this.packageMeta = opts.packageMeta
this.immatureVersion = opts.immatureVersion
}
}
@@ -130,6 +120,10 @@ export {
workspacePrefToNpm,
}
export { createNpmResolutionVerifier, type CreateNpmResolutionVerifierOptions } from './createNpmResolutionVerifier.js'
export {
MINIMUM_RELEASE_AGE_VIOLATION_CODE,
TRUST_DOWNGRADE_VIOLATION_CODE,
} from './violationCodes.js'
export { whichVersionIsPinned } from './whichVersionIsPinned.js'
export interface ResolverFactoryOptions {
@@ -145,7 +139,6 @@ export interface ResolverFactoryOptions {
namedRegistries?: Record<string, string>
saveWorkspaceProtocol?: boolean | 'rolling'
preserveAbsolutePaths?: boolean
strictPublishedByCheck?: boolean
ignoreMissingTimeField?: boolean
fetchWarnTimeoutMs?: number
/** Pre-populated metadata cache. When provided, the resolver uses this
@@ -249,7 +242,6 @@ export function createNpmResolver (
offline: opts.offline,
preferOffline: opts.preferOffline,
cacheDir: opts.cacheDir,
strictPublishedByCheck: opts.strictPublishedByCheck,
ignoreMissingTimeField: opts.ignoreMissingTimeField,
}),
registries: opts.registries,
@@ -384,6 +376,19 @@ async function resolveNpm (
resolution: currentResolution as TarballResolution,
resolvedVia: 'npm-registry',
publishedAt: opts.currentPkg.publishedAt,
// Loose-mode bypass: a lockfile entry whose publishedAt sits
// after the maturity cutoff would have been rejected at
// resolver time, but the peek path skips the maturity check.
// Report inline so the deps-resolver aggregator surfaces it
// to the install command.
policyViolation: detectMinReleaseAgeViolation({
name: manifest.name,
version: manifest.version,
publishedAt: opts.currentPkg.publishedAt,
resolution: currentResolution,
publishedBy: opts.publishedBy,
publishedByExclude: opts.publishedByExclude,
}),
}
}
}
@@ -443,22 +448,6 @@ async function resolveNpm (
}
}
if (opts.publishedBy) {
const immatureVersion = pickVersionByVersionRange({
meta,
versionRange: spec.fetchSpec,
preferredVersionSelectors: opts.preferredVersions?.[spec.name],
})
if (immatureVersion) {
throw new NoMatchingVersionError({
wantedDependency,
packageMeta: meta,
registry,
immatureVersion,
publishedBy: opts.publishedBy,
})
}
}
throw new NoMatchingVersionError({ wantedDependency, packageMeta: meta, registry })
} else if (opts.trustPolicy === 'no-downgrade') {
failIfTrustDowngraded(meta, pickedPackage.version, opts)
@@ -512,14 +501,23 @@ async function resolveNpm (
defaultPinnedVersion: opts.pinnedVersion,
})
}
const publishedAt = meta.time?.[pickedPackage.version]
return {
id,
latest: meta['dist-tags'].latest,
manifest: pickedPackage,
resolution,
resolvedVia: 'npm-registry',
publishedAt: meta.time?.[pickedPackage.version],
publishedAt,
normalizedBareSpecifier,
policyViolation: detectMinReleaseAgeViolation({
name: pickedPackage.name,
version: pickedPackage.version,
publishedAt,
resolution,
publishedBy: opts.publishedBy,
publishedByExclude: opts.publishedByExclude,
}),
}
}
@@ -632,6 +630,7 @@ async function pickFromSimpleRegistry (
manifest: DependencyManifest
resolution: TarballResolution
publishedAt?: string
policyViolation?: ResolutionPolicyViolation
}> {
const authHeaderValue = ctx.getAuthHeaderValueByURI(registry)
const { meta, pickedPackage } = await ctx.pickPackage(spec, {
@@ -648,15 +647,25 @@ async function pickFromSimpleRegistry (
if (pickedPackage == null) {
throw new NoMatchingVersionError({ wantedDependency, packageMeta: meta, registry })
}
const resolution = {
integrity: getIntegrity(pickedPackage.dist),
tarball: normalizeRegistryUrl(pickedPackage.dist.tarball),
}
const publishedAt = meta.time?.[pickedPackage.version]
return {
id: `${pickedPackage.name}@${pickedPackage.version}` as PkgResolutionId,
latest: meta['dist-tags'].latest,
manifest: pickedPackage,
resolution: {
integrity: getIntegrity(pickedPackage.dist),
tarball: normalizeRegistryUrl(pickedPackage.dist.tarball),
},
publishedAt: meta.time?.[pickedPackage.version],
resolution,
publishedAt,
policyViolation: detectMinReleaseAgeViolation({
name: pickedPackage.name,
version: pickedPackage.version,
publishedAt,
resolution,
publishedBy: opts.publishedBy,
publishedByExclude: opts.publishedByExclude,
}),
}
}
@@ -907,6 +916,44 @@ function defaultTagForAlias (alias: string, defaultTag: string): RegistryPackage
}
}
/**
* Inline minimumReleaseAge detection: returns a violation entry when the
* picked version's publish timestamp is past the policy cutoff (and
* isn't covered by `publishedByExclude`). The resolver already has the
* timestamp in hand, so reporting inline saves the install layer from
* re-walking the resolved tree and re-fetching the same metadata. The
* deps-resolver aggregates the per-resolve `policyViolation` fields into
* a single set the install command reacts to.
*
* Returns `undefined` for resolutions outside the policy — no policy
* active, version excluded by pattern, timestamp missing or malformed,
* or version mature. Specific-version exclusions (`pkg@1.0.0`) and
* full-name exclusions (`pkg`) are both honored so an entry already on
* the user's exclude list isn't re-announced every install.
*/
function detectMinReleaseAgeViolation (args: {
name: string
version: string
publishedAt: string | undefined
resolution: Resolution
publishedBy: Date | undefined
publishedByExclude: PackageVersionPolicy | undefined
}): ResolutionPolicyViolation | undefined {
if (!args.publishedBy || !args.publishedAt) return undefined
const excludeResult = args.publishedByExclude?.(args.name)
if (excludeResult === true) return undefined
if (Array.isArray(excludeResult) && excludeResult.includes(args.version)) return undefined
const ts = new Date(args.publishedAt).getTime()
if (Number.isNaN(ts) || ts <= args.publishedBy.getTime()) return undefined
return {
name: args.name,
version: args.version,
resolution: args.resolution,
code: MINIMUM_RELEASE_AGE_VIOLATION_CODE,
reason: `was published at ${new Date(ts).toISOString()}, within the minimumReleaseAge cutoff (${args.publishedBy.toISOString()})`,
}
}
function getIntegrity (dist: {
integrity?: string
shasum: string

View File

@@ -75,7 +75,6 @@ export interface PickPackageOptions extends PickPackageFromMetaOptions {
interface PickerOptions extends PickPackageFromMetaOptions {
pickLowestVersion?: boolean
includeLatestTag?: boolean
strictPublishedByCheck?: boolean
ignoreMissingTimeField?: boolean
}
@@ -106,8 +105,9 @@ const pickHighest = pickPackageFromMeta.bind(null, pickVersionByVersionRange)
const pickLowest = pickPackageFromMeta.bind(null, pickLowestVersionByVersionRange)
// When minimumReleaseAge is active: try the highest mature version; if none
// and strictPublishedByCheck is off, fall back to the lowest version in range
// without applying the maturity filter.
// satisfies the range, fall back to the lowest version regardless of maturity
// so the resolver can report the violation inline and let the install layer
// (or other caller) decide what to do — never throw at this layer.
function pickRespectingMinReleaseAge (
pickerOpts: PickerOptions,
spec: RegistryPackageSpec,
@@ -115,7 +115,7 @@ function pickRespectingMinReleaseAge (
): PackageInRegistry | null {
return runPicker(pickerOpts, spec, (targetSpec) => {
const highest = pickHighest(pickerOpts, meta, targetSpec)
if (highest || pickerOpts.strictPublishedByCheck) return highest
if (highest) return highest
return pickLowest({
preferredVersionSelectors: pickerOpts.preferredVersionSelectors,
}, meta, targetSpec)
@@ -178,7 +178,6 @@ export async function pickPackage (
offline?: boolean
preferOffline?: boolean
filterMetadata?: boolean
strictPublishedByCheck?: boolean
ignoreMissingTimeField?: boolean
},
spec: RegistryPackageSpec,
@@ -192,7 +191,6 @@ export async function pickPackage (
publishedByExclude: opts.publishedByExclude,
pickLowestVersion: opts.pickLowestVersion,
includeLatestTag: opts.includeLatestTag,
strictPublishedByCheck: ctx.strictPublishedByCheck,
ignoreMissingTimeField: ctx.ignoreMissingTimeField,
}
@@ -279,10 +277,11 @@ export async function pickPackage (
pickedPackage,
}
}
} catch (err: unknown) {
if (shouldRethrowFromFastPathCache(err, ctx.strictPublishedByCheck)) {
throw err
}
} catch {
// Swallow fast-path errors (e.g. ERR_PNPM_MISSING_TIME from
// abbreviated meta) and fall through to the network fetch, which
// can upgrade to full metadata and run the maturity check on
// real `time` data.
}
}
}
@@ -299,10 +298,8 @@ export async function pickPackage (
pickedPackage,
}
}
} catch (err: unknown) {
if (shouldRethrowFromFastPathCache(err, ctx.strictPublishedByCheck)) {
throw err
}
} catch {
// Same as above — fall through to the network fetch.
}
}
}
@@ -495,14 +492,6 @@ async function maybeUpgradeAbbreviatedMetaForReleaseAge (
return { meta: fullFetchResult.meta, upgradedFrom: fullFetchResult }
}
// Returns true when a fast-path cache catch should rethrow under
// strictPublishedByCheck. ERR_PNPM_MISSING_TIME is excluded so callers fall
// through to the network fetch path, which can upgrade abbreviated cached
// metadata to full and run the maturity check on real `time` data.
function shouldRethrowFromFastPathCache (err: unknown, strictPublishedByCheck: boolean | undefined): boolean {
return strictPublishedByCheck === true && !isMissingTimeError(err)
}
// Persists upgraded full metadata to the on-disk cache mirror and returns
// the meta to store in the in-memory cache. When `filterMetadata` is on, the
// in-memory and on-disk forms are both stripped via `clearMeta`; otherwise
@@ -604,7 +593,7 @@ function isMissingTimeError (err: unknown): boolean {
const MAX_WARNED_MISSING_TIME = 1024
const warnedMissingTimeFor = new Set<string>()
function warnMissingTimeFieldOnce (pkgName: string): void {
export function warnMissingTimeFieldOnce (pkgName: string): void {
if (warnedMissingTimeFor.has(pkgName)) return
if (warnedMissingTimeFor.size >= MAX_WARNED_MISSING_TIME) {
// Set preserves insertion order, so the first entry is the oldest.

View File

@@ -0,0 +1,12 @@
/**
* Violation codes the npm resolver attaches to
* `ResolutionPolicyViolation.code` when an inline policy check rejects
* a pick. Exported so downstream code (the install command, the strict
* resolver wrapper, tests) references one source of truth instead of
* re-typing the string.
*
* Lives in its own module — both `index.ts` and `createNpmResolutionVerifier.ts`
* import it, so keeping the constants here avoids a cycle.
*/
export const MINIMUM_RELEASE_AGE_VIOLATION_CODE = 'MINIMUM_RELEASE_AGE_VIOLATION'
export const TRUST_DOWNGRADE_VIOLATION_CODE = 'TRUST_DOWNGRADE'

View File

@@ -0,0 +1,241 @@
import { afterEach, beforeEach, expect, test } from '@jest/globals'
import { createFetchFromRegistry } from '@pnpm/network.fetch'
import { createNpmResolutionVerifier } from '@pnpm/resolving.npm-resolver'
import type { Resolution } from '@pnpm/resolving.resolver-base'
import type { Registries } from '@pnpm/types'
import { temporaryDirectory } from 'tempy'
import { getMockAgent, setupMockAgent, teardownMockAgent } from './utils/index.js'
const registries: Registries = {
default: 'https://registry.npmjs.org/',
}
const fetchFromRegistry = createFetchFromRegistry({})
const getAuthHeaderValueByURI = (): undefined => undefined
function makeVerifierOpts (overrides: Partial<Parameters<typeof createNpmResolutionVerifier>[0]> = {}): Parameters<typeof createNpmResolutionVerifier>[0] {
return {
registries,
fetchOpts: {
fetch: fetchFromRegistry,
retry: { retries: 0 },
timeout: 60_000,
fetchWarnTimeoutMs: 10_000,
},
getAuthHeaderValueByURI,
cacheDir: temporaryDirectory(),
now: Date.UTC(2026, 0, 1),
...overrides,
}
}
function makeTarballResolution (name: string, version: string): Resolution {
return {
integrity: 'sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==',
tarball: `https://registry.npmjs.org/${name}/-/${name}-${version}.tgz`,
} as unknown as Resolution
}
afterEach(async () => {
await teardownMockAgent()
})
beforeEach(async () => {
await setupMockAgent()
})
test('createNpmResolutionVerifier() returns undefined when no policy is active', () => {
expect(createNpmResolutionVerifier(makeVerifierOpts())).toBeUndefined()
})
test('createNpmResolutionVerifier() flags a trustedPublisher → provenance downgrade', async () => {
// 0.0.1 was published by a trustedPublisher → rank 2.
// 0.0.2 is provenance-only (rank 1, weaker) → downgrade vs 0.0.1.
// This is exactly the case the resolver-time trustChecks unit tests
// cover, but routed through the lockfile verifier. The verifier must
// not pass simply because 0.0.2 has *some* attestation.
const meta = {
name: 'demo',
'dist-tags': { latest: '0.0.2' },
versions: {
'0.0.1': {
name: 'demo',
version: '0.0.1',
dist: { tarball: 'https://registry.npmjs.org/demo/-/demo-0.0.1.tgz', shasum: 'aa' },
_npmUser: { trustedPublisher: { id: 'gha', oidcConfigId: 'cfg' } },
},
'0.0.2': {
name: 'demo',
version: '0.0.2',
dist: {
tarball: 'https://registry.npmjs.org/demo/-/demo-0.0.2.tgz',
shasum: 'bb',
attestations: { provenance: { url: 'https://example.org/p' } },
},
},
},
time: {
'0.0.1': '2025-01-01T00:00:00.000Z',
'0.0.2': '2025-06-01T00:00:00.000Z',
},
modified: '2025-06-01T00:00:00.000Z',
}
const pool = getMockAgent().get('https://registry.npmjs.org')
pool.intercept({ path: '/demo', method: 'GET' }).reply(200, meta).persist()
const verifier = createNpmResolutionVerifier(makeVerifierOpts({
trustPolicy: 'no-downgrade',
}))!
expect(verifier).toBeDefined()
const result = await verifier.verify(makeTarballResolution('demo', '0.0.2'), { name: 'demo', version: '0.0.2' })
expect(result).toMatchObject({
ok: false,
code: 'TRUST_DOWNGRADE',
})
})
test('createNpmResolutionVerifier() passes a same-evidence-level version', async () => {
// 0.0.1 had provenance, 0.0.2 still has provenance → no downgrade.
// Verifies the trust check isn't over-aggressive for stable evidence.
const meta = {
name: 'demo',
'dist-tags': { latest: '0.0.2' },
versions: {
'0.0.1': {
name: 'demo',
version: '0.0.1',
dist: {
tarball: 'https://registry.npmjs.org/demo/-/demo-0.0.1.tgz',
shasum: 'aa',
attestations: { provenance: { url: 'https://example.org/p1' } },
},
},
'0.0.2': {
name: 'demo',
version: '0.0.2',
dist: {
tarball: 'https://registry.npmjs.org/demo/-/demo-0.0.2.tgz',
shasum: 'bb',
attestations: { provenance: { url: 'https://example.org/p2' } },
},
},
},
time: {
'0.0.1': '2025-01-01T00:00:00.000Z',
'0.0.2': '2025-06-01T00:00:00.000Z',
},
modified: '2025-06-01T00:00:00.000Z',
}
const pool = getMockAgent().get('https://registry.npmjs.org')
pool.intercept({ path: '/demo', method: 'GET' }).reply(200, meta).persist()
const verifier = createNpmResolutionVerifier(makeVerifierOpts({
trustPolicy: 'no-downgrade',
}))!
const result = await verifier.verify(makeTarballResolution('demo', '0.0.2'), { name: 'demo', version: '0.0.2' })
expect(result).toEqual({ ok: true })
})
test('createNpmResolutionVerifier() abbreviated shortcut requires the pinned version to be in metadata', async () => {
// Package's `modified` is well before the cutoff (default 1-day window
// means modified=2010 is fine), but `0.0.2` was unpublished and is no
// longer in `versions`. The shortcut must NOT return the package-level
// `modified` for that version — that would be a fail-open on a
// missing pin. The verifier should fall through to the deeper layers
// and end up reporting a violation (no source could surface the time).
const abbreviatedMeta = {
name: 'unpublished-pkg',
'dist-tags': {},
versions: {
'0.0.1': {
name: 'unpublished-pkg',
version: '0.0.1',
dist: { tarball: 'https://registry.npmjs.org/unpublished-pkg/-/unpublished-pkg-0.0.1.tgz', shasum: 'aa' },
},
},
modified: '2010-01-01T00:00:00.000Z',
}
const fullMeta = {
...abbreviatedMeta,
time: { '0.0.1': '2010-01-01T00:00:00.000Z' },
}
const pool = getMockAgent().get('https://registry.npmjs.org')
pool.intercept({ path: '/unpublished-pkg', method: 'GET' }).reply(200, abbreviatedMeta).persist()
pool.intercept({ path: '/-/npm/v1/attestations/unpublished-pkg@0.0.2', method: 'GET' }).reply(404, {}).persist()
const verifier = createNpmResolutionVerifier(makeVerifierOpts({
minimumReleaseAge: 1440, // 1 day
}))!
const result = await verifier.verify(
makeTarballResolution('unpublished-pkg', '0.0.2'),
{ name: 'unpublished-pkg', version: '0.0.2' }
)
expect(result).toMatchObject({
ok: false,
code: 'MINIMUM_RELEASE_AGE_VIOLATION',
})
// Sanity check: the unrelated full meta isn't used here because the
// abbreviated shortcut won't fire (version missing), and the deeper
// layers also have no entry for 0.0.2. Keep `fullMeta` in scope so
// future test additions can wire it in without redefining.
expect(fullMeta.versions['0.0.1'].version).toBe('0.0.1')
})
test('createNpmResolutionVerifier() ignoreMissingTimeField passes the entry when no source surfaces a timestamp', async () => {
// Mirrors the resolver-side `pickMatchingVersionFinal` warn-and-skip
// behavior: when the registry strips the per-version `time` field and
// the user has opted into `minimumReleaseAgeIgnoreMissingTime`, the
// verifier shouldn't be stricter than fresh resolution.
const abbreviatedMeta = {
name: 'time-free-pkg',
'dist-tags': {},
versions: {
'1.0.0': {
name: 'time-free-pkg',
version: '1.0.0',
dist: { tarball: 'https://registry.npmjs.org/time-free-pkg/-/time-free-pkg-1.0.0.tgz', shasum: 'aa' },
},
},
modified: '2010-01-01T00:00:00.000Z',
}
const pool = getMockAgent().get('https://registry.npmjs.org')
// Full meta also lacks `time`, so no layer surfaces a publish timestamp.
pool.intercept({ path: '/time-free-pkg', method: 'GET' }).reply(200, abbreviatedMeta).persist()
pool.intercept({ path: '/-/npm/v1/attestations/time-free-pkg@1.0.0', method: 'GET' }).reply(404, {}).persist()
const verifier = createNpmResolutionVerifier(makeVerifierOpts({
minimumReleaseAge: 1440,
ignoreMissingTimeField: true,
}))!
const result = await verifier.verify(
makeTarballResolution('time-free-pkg', '1.0.0'),
{ name: 'time-free-pkg', version: '1.0.0' }
)
expect(result).toEqual({ ok: true })
})
test('createNpmResolutionVerifier() canTrustPastCheck rejects when the trust-exclude list shrinks', () => {
const verifier = createNpmResolutionVerifier(makeVerifierOpts({
trustPolicy: 'no-downgrade',
trustPolicyExclude: ['foo'],
}))!
// Same policy → trust.
expect(verifier.canTrustPastCheck({
minimumReleaseAge: 0,
minimumReleaseAgeExclude: [],
trustPolicy: 'no-downgrade',
trustPolicyExclude: ['foo'],
trustPolicyIgnoreAfter: null,
})).toBe(true)
// Cached run had a wider exclude list (today's is stricter) → invalidate.
expect(verifier.canTrustPastCheck({
minimumReleaseAge: 0,
minimumReleaseAgeExclude: [],
trustPolicy: 'no-downgrade',
trustPolicyExclude: ['foo', 'bar'],
trustPolicyIgnoreAfter: null,
})).toBe(false)
})

View File

@@ -90,12 +90,14 @@ test('request metadata when the one in cache does not have a version satisfying
expect(resolveResult!.id).toBe('bad-dates@1.0.0')
})
test('do not pick version that does not satisfy the date requirement even if it is loaded from cache and requested by exact version', async () => {
test('reports an immature pick via policyViolation even when loaded from cache and requested by exact version', async () => {
const cacheDir = temporaryDirectory()
const fooMeta = {
'dist-tags': {},
versions: {
'1.0.0': {
name: 'foo',
version: '1.0.0',
dist: {
integrity: 'sha512-9Qa5b+9n69IEuxk4FiNcavXqkixb9lD03BLtdTeu2bbORnLZQrw+pR/exiSg7SoODeu08yxS47mdZa9ddodNwQ==',
shasum: '857db584a1ba5d1cb2980527fc3b6c435d37b0fd',
@@ -118,17 +120,25 @@ test('do not pick version that does not satisfy the date requirement even if it
.intercept({ path: '/foo', method: 'GET' })
.reply(200, fooMeta)
// The resolver no longer throws on immature picks — it falls back to the
// lowest match in range and flags the result with `policyViolation`. The
// outer caller (install / dlx / self-update) decides what to do with it.
const { resolveFromNpm } = createResolveFromNpm({
storeDir: temporaryDirectory(),
cacheDir,
filterMetadata: true,
fullMetadata: true,
registries,
strictPublishedByCheck: true,
})
await expect(resolveFromNpm({ alias: 'foo', bareSpecifier: '1.0.0' }, {
const result = await resolveFromNpm({ alias: 'foo', bareSpecifier: '1.0.0' }, {
publishedBy: new Date('2015-08-17T19:26:00.508Z'),
})).rejects.toThrow(/Version 1\.0\.0 \(released .+\) of foo does not meet the minimumReleaseAge constraint/)
})
expect(result!.id).toBe('foo@1.0.0')
expect(result!.policyViolation).toMatchObject({
name: 'foo',
version: '1.0.0',
code: 'MINIMUM_RELEASE_AGE_VIOLATION',
})
})
test('should skip time field validation for excluded packages', async () => {
@@ -320,20 +330,20 @@ test('ignoreMissingTimeField=true skips maturity check from disk-cached metadata
expect(resolveResult!.id).toBe('is-positive@3.1.0')
})
test('strictPublishedByCheck=true does not rethrow ERR_PNPM_MISSING_TIME from the version-spec cache path', async () => {
test('falls through to the registry fetch when cached abbreviated meta lacks time on the version-spec cache path', async () => {
// Regression test for the bug where the version-spec cache fast path
// (`!opts.includeLatestTag && spec.type === 'version'`) in pickPackage
// would rethrow ERR_PNPM_MISSING_TIME under strictPublishedByCheck, instead
// of falling through to the registry-fetch path like the adjacent mtime-gated
// cache block does. The fix makes the two catch blocks consistent.
// would rethrow ERR_PNPM_MISSING_TIME under what used to be
// strictPublishedByCheck, instead of falling through to the registry-fetch
// path like the adjacent mtime-gated cache block does. The fix makes the
// two catch blocks consistent — both now always swallow and fall through.
//
// Setup: cache abbreviated metadata (no per-version `time` field) for the
// package, then request an exact-version pin that IS present in the cached
// meta.versions. The version-spec fast path will try pickMatchingVersionFast
// against the cached meta, which throws MISSING_TIME because the abbreviated
// form lacks `time`. Before the fix this would rethrow. After the fix it
// falls through to the registry fetch, which returns full metadata with time,
// and resolution succeeds.
// form lacks `time`. The catch falls through to the registry fetch, which
// returns full metadata with time, and resolution succeeds.
const cacheDir = temporaryDirectory()
const abbrevCacheDir = path.join(cacheDir, `${ABBREVIATED_META_DIR}/registry.npmjs.org`)
fs.mkdirSync(abbrevCacheDir, { recursive: true })
@@ -363,7 +373,6 @@ test('strictPublishedByCheck=true does not rethrow ERR_PNPM_MISSING_TIME from th
storeDir: temporaryDirectory(),
cacheDir,
registries,
strictPublishedByCheck: true,
ignoreMissingTimeField: true,
})
@@ -377,12 +386,11 @@ test('strictPublishedByCheck=true does not rethrow ERR_PNPM_MISSING_TIME from th
expect(resolveResult!.id).toBe('is-positive@3.0.0')
})
test('strictPublishedByCheck=true with default ignoreMissingTimeField does not rethrow ERR_PNPM_MISSING_TIME from the version-spec cache path', async () => {
test('falls through to the registry fetch even with default ignoreMissingTimeField on the version-spec cache path', async () => {
// Companion to the test above: same scenario but with the default
// ignoreMissingTimeField (false). The catch-block fix must hold regardless
// of the ignore flag — MISSING_TIME from cached abbreviated meta should
// never escape the catch under strict mode, so resolution falls through to
// the registry fetch and succeeds with full (time-bearing) metadata.
// ignoreMissingTimeField (false). MISSING_TIME from cached abbreviated
// meta should never escape the catch — resolution falls through to the
// registry fetch and succeeds with full (time-bearing) metadata.
const cacheDir = temporaryDirectory()
const abbrevCacheDir = path.join(cacheDir, `${ABBREVIATED_META_DIR}/registry.npmjs.org`)
fs.mkdirSync(abbrevCacheDir, { recursive: true })
@@ -406,7 +414,6 @@ test('strictPublishedByCheck=true with default ignoreMissingTimeField does not r
storeDir: temporaryDirectory(),
cacheDir,
registries,
strictPublishedByCheck: true,
})
const resolveResult = await resolveFromNpm({ alias: 'is-positive', bareSpecifier: '3.0.0' }, {

View File

@@ -128,3 +128,39 @@ test('resolveFromJsr() on jsr with packages without scope', async () => {
code: 'ERR_PNPM_MISSING_JSR_PACKAGE_SCOPE',
})
})
test('resolveFromJsr() returns the immature pick with policyViolation when publishedBy excludes it', async () => {
// jsr-rus-greet's 0.0.3 was published 2024-11-16; passing a `publishedBy`
// before that makes the version immature relative to the cutoff. The
// resolver always falls back to the requested version and flags the
// result with `policyViolation`; the install command (or other caller)
// decides what to do with it. This is the named-registry / jsr path's
// coverage for inline violation reporting.
const slash = '%2F'
const defaultPool = getMockAgent().get(registries.default.replace(/\/$/, ''))
defaultPool.intercept({ path: `/@jsr${slash}rus__greet`, method: 'GET' }).reply(404, {})
const jsrPool = getMockAgent().get(registries['@jsr'].replace(/\/$/, ''))
jsrPool.intercept({ path: `/@jsr${slash}rus__greet`, method: 'GET' }).reply(200, jsrRusGreetMeta)
const cacheDir = temporaryDirectory()
const { resolveFromJsr } = createResolveFromNpm({
storeDir: temporaryDirectory(),
cacheDir,
registries,
})
const result = await resolveFromJsr(
{ alias: '@rus/greet', bareSpecifier: 'jsr:0.0.3' },
{
publishedBy: new Date('2020-01-01T00:00:00Z'),
}
)
expect(result).toMatchObject({
id: '@jsr/rus__greet@0.0.3',
policyViolation: {
name: '@jsr/rus__greet',
version: '0.0.3',
code: 'MINIMUM_RELEASE_AGE_VIOLATION',
},
})
})

View File

@@ -129,6 +129,26 @@ export interface ResolutionVerifier {
canTrustPastCheck: (cachedPolicy: Record<string, unknown>) => boolean
}
/**
* A `ResolutionVerifier`'s rejection materialized for one (name,
* version, resolution) entry. The install side aggregates these across
* every active verifier on the freshly-resolved tree and either prompts
* the user, persists them (e.g. into `minimumReleaseAgeExclude`), or
* aborts. Code is the verifier-defined error code
* (`MINIMUM_RELEASE_AGE_VIOLATION`, `TRUST_DOWNGRADE`, etc.) — the
* install command filters by code to decide downstream UX. Lifted here
* (rather than in deps-installer) so both deps-resolver and
* deps-installer can share one shape; future resolver packages plug in
* without needing the deps-installer dependency.
*/
export interface ResolutionPolicyViolation {
name: string
version: string
resolution: Resolution
code: string
reason: string
}
/** Concrete platform selector used when picking a variant from a VariationsResolution. */
export interface PlatformSelector {
os: string
@@ -197,6 +217,22 @@ export interface ResolveResult {
resolvedVia: string
normalizedBareSpecifier?: string
alias?: string
/**
* Set when the resolver picked this version despite a policy
* violation (e.g. immature relative to `publishedBy`, trust
* downgrade detected by `failIfTrustDowngraded`). The resolver
* already has the metadata it needs to decide, so reporting inline
* here avoids the install layer having to re-scan the tree and
* re-fetch the same metadata. The deps-resolver aggregates these
* across every resolve call into a single set the install command
* can react to.
*
* `resolution` on the violation is the same `resolution` field
* above — supplied for symmetry with `ResolutionPolicyViolation`
* entries that flow out of `verifyLockfileResolutions` for
* lockfile-only paths.
*/
policyViolation?: ResolutionPolicyViolation
}
export interface WorkspacePackage {

View File

@@ -0,0 +1,30 @@
import fs from 'node:fs'
import path from 'node:path'
import util from 'node:util'
// Mirrors the constant in
// `installing/deps-installer/src/install/verifyLockfileResolutionsCache.ts`.
// Kept in sync by hand (both sides own a small string; introducing a
// shared package just for this would outweigh the cost of the duplicate).
const LOCKFILE_VERIFIED_CACHE_FILE = 'lockfile-verified.jsonl'
/**
* Remove the lockfile-verification cache JSONL written by the install
* command's resolution-policy verifier. Pruning the store invalidates
* derived state; a stale verification record under a different
* policy/lockfile-content key would otherwise survive into the next
* install (still correct because of the cache's identity comparator,
* but visually leaks "alien" files into `cacheDir`).
*
* Silent on a missing file — prune is idempotent and the cache may
* never have been written in the first place.
*/
export function cleanLockfileVerifiedCache (cacheDir: string): void {
const cacheFilePath = path.join(cacheDir, LOCKFILE_VERIFIED_CACHE_FILE)
try {
fs.unlinkSync(cacheFilePath)
} catch (err: unknown) {
if (util.types.isNativeError(err) && 'code' in err && err.code === 'ENOENT') return
throw err
}
}

View File

@@ -3,6 +3,7 @@ import { streamParser } from '@pnpm/logger'
import type { StoreController } from '@pnpm/store.controller-types'
import { cleanExpiredDlxCache } from './cleanExpiredDlxCache.js'
import { cleanLockfileVerifiedCache } from './cleanLockfileVerifiedCache.js'
import type { ReporterFunction } from './types.js'
export async function storePrune (
@@ -29,6 +30,8 @@ export async function storePrune (
now: new Date(),
})
cleanLockfileVerifiedCache(opts.cacheDir)
if (opts.globalPkgDir) {
cleanOrphanedInstallDirs(opts.globalPkgDir)
}

View File

@@ -51,6 +51,8 @@ export type CreateNewStoreControllerOptions = CreateResolverOptions & Pick<Confi
| 'saveWorkspaceProtocol'
| 'strictSsl'
| 'trustPolicy'
| 'trustPolicyExclude'
| 'trustPolicyIgnoreAfter'
| 'unsafePerm'
| 'userAgent'
| 'verifyStoreIntegrity'
@@ -115,11 +117,13 @@ export async function createNewStoreController (
includeOnlyPackageFiles: !opts.deployAllFiles,
saveWorkspaceProtocol: opts.saveWorkspaceProtocol,
preserveAbsolutePaths: opts.preserveAbsolutePaths,
strictPublishedByCheck: Boolean(opts.minimumReleaseAge) && opts.minimumReleaseAgeStrict === true,
ignoreMissingTimeField: opts.minimumReleaseAgeIgnoreMissingTime,
minimumReleaseAge: opts.minimumReleaseAge,
minimumReleaseAgeStrict: opts.minimumReleaseAgeStrict,
minimumReleaseAgeExclude: opts.minimumReleaseAgeExclude,
trustPolicy: opts.trustPolicy,
trustPolicyExclude: opts.trustPolicyExclude,
trustPolicyIgnoreAfter: opts.trustPolicyIgnoreAfter,
storeIndex,
})
return {

View File

@@ -3,6 +3,7 @@ import type {
PkgResolutionId,
PreferredVersions,
Resolution,
ResolutionPolicyViolation,
WantedDependency,
WorkspacePackages,
} from '@pnpm/resolving.resolver-base'
@@ -152,6 +153,13 @@ export interface PackageResponse {
// resolved package, it is out-of-date.
latest?: string
alias?: string
/**
* Forwarded from the resolver's `ResolveResult.policyViolation`.
* The caller (deps-resolver) aggregates these per-pick into a
* single set the install command can react to — see
* `ResolutionPolicyViolation` in `@pnpm/resolving.resolver-base`.
*/
policyViolation?: ResolutionPolicyViolation
} & (
{
isLocal: true

View File

@@ -30,6 +30,15 @@ export const WORKSPACE_STATE_SETTING_KEYS = [
'ignoredOptionalDependencies',
'injectWorkspacePackages',
'linkWorkspacePackages',
// The lockfile-resolution verifier short-circuits on a per-lockfile
// cache that's keyed by these policy settings; if any of them
// changes (turning a policy on, shrinking an exclude list, etc.) the
// workspace state needs to look stale so `optimisticRepeatInstall`
// doesn't skip the verifier fan-out.
'minimumReleaseAge',
'minimumReleaseAgeStrict',
'minimumReleaseAgeExclude',
'minimumReleaseAgeIgnoreMissingTime',
'nodeLinker',
'optional',
'overrides',
@@ -39,6 +48,9 @@ export const WORKSPACE_STATE_SETTING_KEYS = [
'preferWorkspacePackages',
'production',
'publicHoistPattern',
'trustPolicy',
'trustPolicyExclude',
'trustPolicyIgnoreAfter',
'workspacePackagePatterns',
] as const satisfies ReadonlyArray<keyof Config>