mirror of
https://github.com/pnpm/pnpm.git
synced 2026-05-30 19:46:44 -04:00
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:
27
.changeset/auto-collect-minimum-release-age-exclude.md
Normal file
27
.changeset/auto-collect-minimum-release-age-exclude.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
260
installing/commands/src/policyHandlers.ts
Normal file
260
installing/commands/src/policyHandlers.ts
Normal 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.',
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
160
installing/commands/test/policyHandlers.ts
Normal file
160
installing/commands/test/policyHandlers.ts
Normal 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()
|
||||
})
|
||||
@@ -90,6 +90,9 @@
|
||||
{
|
||||
"path": "../../pkg-manifest/utils"
|
||||
},
|
||||
{
|
||||
"path": "../../resolving/npm-resolver"
|
||||
},
|
||||
{
|
||||
"path": "../../resolving/parse-wanted-dependency"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
}))
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
15
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
12
resolving/npm-resolver/src/violationCodes.ts
Normal file
12
resolving/npm-resolver/src/violationCodes.ts
Normal 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'
|
||||
241
resolving/npm-resolver/test/createNpmResolutionVerifier.test.ts
Normal file
241
resolving/npm-resolver/test/createNpmResolutionVerifier.test.ts
Normal 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)
|
||||
})
|
||||
@@ -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' }, {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
30
store/commands/src/store/cleanLockfileVerifiedCache.ts
Normal file
30
store/commands/src/store/cleanLockfileVerifiedCache.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user