fix: cap lockfile verification memory and add trustLockfile opt-out (#11878)

* fix: cap lockfile verification memory and add trustLockfile opt-out

Verifying a multi-thousand-entry lockfile against `minimumReleaseAge`
or `trustPolicy: no-downgrade` retained every fetched packument in a
per-install cache for the entire install. On large workspaces this
OOM'd CI runners with a 2GB heap cap. Project both caches down to just
the fields each check reads (per-version trust evidence + the `time`
map for trust; package-level `modified` + version-name set for the
abbreviated shortcut) so the bulk packument is GC'd as soon as the
fetch returns.

Also adds a `trustLockfile` setting (default `false`) that skips the
verification pass entirely for environments where the lockfile is
already part of the trusted base. Mirrored in pacquet. Closes #11860.

* perf: share resolver packument cache with the lockfile verifier

The verifier kept its own per-install dedup Maps and re-fetched every
packument the resolver had already pulled during the same install.
Plumb the resolver's per-install `PackageMetaCache` through to the
verifier (via `createNpmResolutionVerifier` / `build_resolution_verifiers`)
so a name already in the resolver's LRU short-circuits the verifier's
disk/network round-trip — fast path only, the cached document is
projected for the trust check so the verifier's memory footprint stays
bounded.

In pnpm, `installing/client` now constructs one LRU and hands it to
both `createResolver` and `createResolutionVerifiers`. In pacquet, the
`InMemoryPackageMetaCache` is lifted to `Install::dispatch` and passed
to both `build_resolution_verifiers` and `InstallWithFreshLockfile`.
This commit is contained in:
Zoltan Kochan
2026-05-23 20:33:03 +02:00
committed by GitHub
parent a5b1ac783f
commit 212315de16
31 changed files with 717 additions and 40 deletions

View File

@@ -0,0 +1,11 @@
---
"@pnpm/config": minor
"@pnpm/installing.deps-installer": minor
"@pnpm/installing.commands": minor
"@pnpm/resolving.npm-resolver": patch
"pnpm": minor
---
Added a new setting `trustLockfile`. When `true`, `pnpm install` skips the supply-chain verification pass that re-applies `minimumReleaseAge` / `trustPolicy='no-downgrade'` to every entry in the loaded lockfile. The install treats the lockfile as already-trusted — useful for closed-source projects where every commit comes from a trusted author, or for CI runs against an already-verified lockfile. Defaults to `false`; verification stays on by default. Set in `pnpm-workspace.yaml`.
Also cut the memory footprint of the verification pass itself: the per-(registry, name) trust-meta cache previously retained the full packument — dependency graphs, scripts, README, and per-version manifests — for the entire install. On large workspaces (`~4k` lockfile entries with `minimumReleaseAge` + `trustPolicy: no-downgrade` enabled) this could OOM CI runners with a 2GB heap cap. The cache now stores only the fields the trust check actually reads (`time`, per-version `_npmUser.trustedPublisher`, `dist.attestations.provenance`). The abbreviated-metadata cache is similarly projected to just the package-level `modified` field and the set of currently-listed version names. Fixes [#11860](https://github.com/pnpm/pnpm/issues/11860).

View File

@@ -267,6 +267,7 @@ export interface Config extends OptionsFromRootManifest {
minimumReleaseAgeStrict?: boolean
fetchWarnTimeoutMs?: number
fetchMinSpeedKiBps?: number
trustLockfile?: boolean
trustPolicy?: TrustPolicy
trustPolicyExclude?: string[]
trustPolicyIgnoreAfter?: number

View File

@@ -61,6 +61,7 @@ export const pnpmConfigFileKeys = [
'state-dir',
'store-dir',
'strict-dep-builds',
'trust-lockfile',
'trust-policy',
'trust-policy-exclude',
'trust-policy-ignore-after',

View File

@@ -86,6 +86,7 @@ const SECURITY_POLICY_CFG_KEYS = [
'minimumReleaseAgeExclude',
'minimumReleaseAgeIgnoreMissingTime',
'minimumReleaseAgeStrict',
'trustLockfile',
'trustPolicy',
'trustPolicyExclude',
'trustPolicyIgnoreAfter',

View File

@@ -119,6 +119,7 @@ export const pnpmTypes = {
'strict-dep-builds': Boolean,
'strict-store-pkg-content-check': Boolean,
'strict-peer-dependencies': Boolean,
'trust-lockfile': Boolean,
'trust-policy': ['off', 'no-downgrade'] satisfies TrustPolicy[],
'trust-policy-exclude': [String, Array],
'trust-policy-ignore-after': Number,

View File

@@ -10,6 +10,7 @@ import type { CustomFetcher, CustomResolver } from '@pnpm/hooks.types'
import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header'
import { createFetchFromRegistry, type DispatcherOptions } from '@pnpm/network.fetch'
import {
createDefaultPackageMetaCache,
createResolutionVerifiers,
createResolver as _createResolver,
type ResolutionVerifierFactoryOptions,
@@ -69,12 +70,18 @@ export function createClient (opts: ClientOptions): Client {
const fetchFromRegistry = createFetchFromRegistry(opts)
const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri, opts.registries?.default)
const { resolve, clearCache: clearResolutionCache } = _createResolver(fetchFromRegistry, getAuthHeader, { ...opts, customResolvers: opts.customResolvers })
// One per-install LRU shared with both the resolver's pickPackage
// pass and the verifier's lookup chain. When the resolver populates
// an entry for a given `name`, a later verify of the same name
// (e.g. the post-resolution gate, or a second `mutateModules` call
// in the same long-lived process) reuses it instead of re-fetching.
const metaCache = createDefaultPackageMetaCache()
const { resolve, clearCache: clearResolutionCache } = _createResolver(fetchFromRegistry, getAuthHeader, { ...opts, metaCache, customResolvers: opts.customResolvers })
return {
fetchers: createFetchers(fetchFromRegistry, getAuthHeader, opts),
resolve,
clearResolutionCache,
resolutionVerifiers: createResolutionVerifiers(fetchFromRegistry, opts),
resolutionVerifiers: createResolutionVerifiers(fetchFromRegistry, { ...opts, metaCache }),
}
}

View File

@@ -79,6 +79,7 @@ export function rcOptionsTypes (): Record<string, unknown> {
'side-effects-cache',
'store-dir',
'strict-peer-dependencies',
'trust-lockfile',
'trust-policy',
'trust-policy-exclude',
'trust-policy-ignore-after',

View File

@@ -65,6 +65,7 @@ export function rcOptionsTypes (): Record<string, unknown> {
'side-effects-cache',
'store-dir',
'strict-peer-dependencies',
'trust-lockfile',
'trust-policy',
'trust-policy-exclude',
'trust-policy-ignore-after',
@@ -230,6 +231,10 @@ by any dependencies, so it is an emulation of a flat node_modules',
description: 'Ignore trust downgrades for packages published more than specified minutes ago',
name: '--trust-policy-ignore-after <minutes>',
},
{
description: 'Trust the lockfile and skip the supply-chain verification step that re-applies minimumReleaseAge / trustPolicy to each lockfile entry. Use only when the lockfile is part of the trusted base (closed-source projects, CI runs against an already-verified lockfile)',
name: '--trust-lockfile',
},
{
description: 'Clones/hardlinks or copies packages. The selected method depends from the file system',
name: '--package-import-method auto',
@@ -324,6 +329,7 @@ export type InstallCommandOptions = Pick<Config,
| 'sort'
| 'sharedWorkspaceLockfile'
| 'tag'
| 'trustLockfile'
| 'allowBuilds'
| 'optional'
| 'virtualStoreDir'

View File

@@ -105,6 +105,7 @@ export type InstallDepsOptions = Pick<Config,
| 'sharedWorkspaceLockfile'
| 'shellEmulator'
| 'tag'
| 'trustLockfile'
| 'allowBuilds'
| 'optional'
| 'workspaceConcurrency'

View File

@@ -88,6 +88,7 @@ export type RecursiveOptions = CreateStoreControllerOptions & Pick<Config,
| 'lockfileIncludeTarballUrl'
| 'sharedWorkspaceLockfile'
| 'tag'
| 'trustLockfile'
| 'cleanupUnusedCatalogs'
| 'packageConfigs'
| 'updateConfig'

View File

@@ -145,6 +145,7 @@ export async function handler (
| 'workspacePackagePatterns'
| 'sharedWorkspaceLockfile'
| 'cleanupUnusedCatalogs'
| 'trustLockfile'
> & Pick<ConfigContext,
| 'allProjects'
| 'allProjectsGraph'

View File

@@ -213,6 +213,23 @@ export interface StrictInstallOptions {
trustPolicy?: TrustPolicy
trustPolicyExclude?: string[]
trustPolicyIgnoreAfter?: number
/**
* Skip the lockfile supply-chain verification pass entirely. When
* true, `verifyLockfileResolutions` is not called even if
* `resolutionVerifiers` is non-empty — the install trusts the
* lockfile as-is. Trade-off: a poisoned lockfile (e.g. one a
* contributor authored under a weaker policy than CI enforces) can
* slip through. Use only in environments where the lockfile is
* effectively part of the trusted base — closed-source projects
* where every commit comes from a trusted author, fully reproducible
* CI runs against an already-verified lockfile, etc.
*
* Added for #11860: on workspaces with thousands of locked entries,
* the verification pass holds the per-package registry metadata
* needed for the trust check resident in memory and can OOM CI
* runners with a 2GB heap cap.
*/
trustLockfile?: boolean
packageVulnerabilityAudit?: PackageVulnerabilityAudit
blockExoticSubdeps?: boolean
/**

View File

@@ -383,7 +383,7 @@ export async function mutateModules (
!ctx.lockfileHadConflicts &&
ctx.existsNonEmptyWantedLockfile &&
(opts.frozenLockfile === true || opts.frozenLockfileIfExists === true)
if (!willDelegateToPacquet) {
if (!willDelegateToPacquet && !opts.trustLockfile) {
const cacheActive = opts.cacheDir != null && opts.resolutionVerifiers.length > 0
const wantedLockfilePath = cacheActive
? path.resolve(ctx.lockfileDir, await getWantedLockfileName({

View File

@@ -0,0 +1,55 @@
import { expect, test } from '@jest/globals'
import { install } from '@pnpm/installing.deps-installer'
import { prepareEmpty } from '@pnpm/prepare'
import type { ResolutionVerifier } from '@pnpm/resolving.resolver-base'
import { testDefaults } from '../utils/index.js'
const rejectingVerifier: ResolutionVerifier = {
policy: {},
canTrustPastCheck: () => false,
verify: async (_resolution, { name, version }) => ({
ok: false,
code: 'TEST_REJECT',
reason: `${name}@${version} rejected by test verifier`,
}),
}
test('install rejects the lockfile when a verifier fails and trustLockfile is unset', async () => {
prepareEmpty()
await install(
{ dependencies: { 'is-positive': '1.0.0' } },
testDefaults()
)
await expect(
install(
{ dependencies: { 'is-positive': '1.0.0' } },
testDefaults({
frozenLockfile: true,
resolutionVerifiers: [rejectingVerifier],
})
)
).rejects.toMatchObject({ code: 'ERR_PNPM_TEST_REJECT' })
})
test('install skips lockfile verification when trustLockfile is true even if a verifier rejects', async () => {
prepareEmpty()
await install(
{ dependencies: { 'is-positive': '1.0.0' } },
testDefaults()
)
await expect(
install(
{ dependencies: { 'is-positive': '1.0.0' } },
testDefaults({
frozenLockfile: true,
trustLockfile: true,
resolutionVerifiers: [rejectingVerifier],
})
)
).resolves.toBeDefined()
})

View File

@@ -166,6 +166,13 @@ pub struct InstallArgs {
/// path the way upstream does.
#[clap(long)]
pub prefer_offline: bool,
/// Skip the lockfile supply-chain verification pass entirely.
/// Overrides `pnpm-workspace.yaml#trustLockfile`. Mirrors pnpm's
/// `--trust-lockfile`. See [`pacquet_config::Config::trust_lockfile`].
/// Added for [pnpm/pnpm#11860](https://github.com/pnpm/pnpm/issues/11860).
#[clap(long = "trust-lockfile")]
pub trust_lockfile: bool,
}
impl InstallArgs {
@@ -183,6 +190,7 @@ impl InstallArgs {
node_linker,
offline: _,
prefer_offline: _,
trust_lockfile,
} = self;
// `--prefer-frozen-lockfile` / `--no-prefer-frozen-lockfile`
@@ -213,6 +221,12 @@ impl InstallArgs {
// matching pnpm's stance on the same flag.
let skip_runtimes = config.skip_runtimes || no_runtime;
// Same shape as `skip_runtimes`: yaml `trustLockfile: true`
// or the CLI flag turns the verification skip on. There's no
// CLI inverse — relax the yaml value if you need to flip it
// back off for a single invocation.
let trust_lockfile = config.trust_lockfile || trust_lockfile;
// `--node-linker` flag (if passed) overrides the
// yaml/npmrc value for this invocation. Mirrors pnpm's
// override-on-explicit-flag semantics.
@@ -238,6 +252,7 @@ impl InstallArgs {
prefer_frozen_lockfile,
ignore_manifest_check,
skip_runtimes,
trust_lockfile,
resolved_packages,
supported_architectures,
node_linker,

View File

@@ -93,3 +93,136 @@ fn install_fails_under_huge_minimum_release_age() {
drop((root, mock_instance));
}
/// `trustLockfile: true` short-circuits the verification gate so a
/// lockfile that would otherwise trip the policy
/// (`minimumReleaseAge: 100 years` rejects every published version)
/// bypasses the verification step. Confirms the opt-out path runs
/// end-to-end through the CLI and that no
/// `MINIMUM_RELEASE_AGE_VIOLATION` error leaks into stderr — the test
/// stops short of asserting full install success, see the inline
/// comment above the assertion below.
#[test]
fn trust_lockfile_skips_verification() {
let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } =
CommandTempCwd::init().add_mocked_registry();
let AddMockedRegistry { mock_instance, .. } = npmrc_info;
let manifest_path = workspace.join("package.json");
let package_json = serde_json::json!({
"dependencies": {
"@pnpm.e2e/hello-world-js-bin": "1.0.0",
},
});
fs::write(&manifest_path, package_json.to_string()).expect("write package.json");
// Same provocation as the gated test above: 100 years of
// minimumReleaseAge rejects every version the mocked registry
// serves. `trustLockfile: true` is the opt-out that makes the
// install ignore the gate entirely.
let workspace_yaml_path = workspace.join("pnpm-workspace.yaml");
let workspace_yaml = format!(
"{}\nminimumReleaseAge: {}\ntrustLockfile: true\n",
fs::read_to_string(&workspace_yaml_path).expect("read workspace yaml seed"),
60 * 24 * 365 * 100,
);
fs::write(&workspace_yaml_path, workspace_yaml).expect("write pnpm-workspace.yaml");
let lockfile = "lockfileVersion: '9.0'\n\
importers:\n \
.:\n \
dependencies:\n \
'@pnpm.e2e/hello-world-js-bin':\n \
specifier: 1.0.0\n \
version: 1.0.0\n\
packages:\n \
'@pnpm.e2e/hello-world-js-bin@1.0.0':\n \
resolution: {integrity: sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==}\n\
snapshots:\n \
'@pnpm.e2e/hello-world-js-bin@1.0.0': {}\n";
fs::write(workspace.join("pnpm-lock.yaml"), lockfile).expect("write lockfile");
let output = pacquet
.with_args(["install", "--frozen-lockfile"])
.output()
.expect("spawn pacquet install");
// Asserting only on the absence of the verifier error code, not
// `output.status.success()`: the test fixture's `pnpm-lock.yaml`
// is hand-rolled with a placeholder integrity hash, so the
// install fails the tarball integrity check downstream of the
// verification pass. That's irrelevant to what's being tested —
// the contract here is "the supply-chain gate doesn't fire",
// not "the install completes". Asserting success would require a
// real lockfile generated against the mocked registry first
// (see hoist.rs's `generate_lockfile` pattern); not worth the
// extra wiring for a smoke test of the opt-out switch.
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
assert!(
!stderr.contains("ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION"),
"trustLockfile must skip the verification gate; got:\n{stderr}",
);
drop((root, mock_instance));
}
/// `--trust-lockfile` CLI flag short-circuits the verification gate
/// the same way `trustLockfile: true` in `pnpm-workspace.yaml` does.
/// Same provocation as the yaml-based test above, with the yaml
/// override removed so the gate would normally fire — the flag is
/// what makes the verification gate skip. Like that test, this only
/// asserts the absence of `MINIMUM_RELEASE_AGE_VIOLATION` (not full
/// install success); see the inline comment above the assertion.
#[test]
fn trust_lockfile_cli_flag_skips_verification() {
let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } =
CommandTempCwd::init().add_mocked_registry();
let AddMockedRegistry { mock_instance, .. } = npmrc_info;
let manifest_path = workspace.join("package.json");
let package_json = serde_json::json!({
"dependencies": {
"@pnpm.e2e/hello-world-js-bin": "1.0.0",
},
});
fs::write(&manifest_path, package_json.to_string()).expect("write package.json");
let workspace_yaml_path = workspace.join("pnpm-workspace.yaml");
let workspace_yaml = format!(
"{}\nminimumReleaseAge: {}\n",
fs::read_to_string(&workspace_yaml_path).expect("read workspace yaml seed"),
60 * 24 * 365 * 100,
);
fs::write(&workspace_yaml_path, workspace_yaml).expect("write pnpm-workspace.yaml");
let lockfile = "lockfileVersion: '9.0'\n\
importers:\n \
.:\n \
dependencies:\n \
'@pnpm.e2e/hello-world-js-bin':\n \
specifier: 1.0.0\n \
version: 1.0.0\n\
packages:\n \
'@pnpm.e2e/hello-world-js-bin@1.0.0':\n \
resolution: {integrity: sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==}\n\
snapshots:\n \
'@pnpm.e2e/hello-world-js-bin@1.0.0': {}\n";
fs::write(workspace.join("pnpm-lock.yaml"), lockfile).expect("write lockfile");
let output = pacquet
.with_args(["install", "--frozen-lockfile", "--trust-lockfile"])
.output()
.expect("spawn pacquet install");
// Same reasoning as the yaml-opt-out test above: not asserting
// `output.status.success()` because the hand-rolled lockfile's
// placeholder integrity trips the downstream tarball check. The
// contract being tested is gate-skipped, not install-succeeded.
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
assert!(
!stderr.contains("ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION"),
"--trust-lockfile must skip the verification gate; got:\n{stderr}",
);
drop((root, mock_instance));
}

View File

@@ -170,6 +170,7 @@ impl WorkspaceSettings {
"MINIMUM_RELEASE_AGE_IGNORE_MISSING_TIME"
);
json_field!(minimum_release_age_strict, "MINIMUM_RELEASE_AGE_STRICT");
json_field!(trust_lockfile, "TRUST_LOCKFILE");
enum_field!(trust_policy, "TRUST_POLICY", TrustPolicy);
json_field!(trust_policy_exclude, "TRUST_POLICY_EXCLUDE");
json_field!(trust_policy_ignore_after, "TRUST_POLICY_IGNORE_AFTER");

View File

@@ -787,6 +787,24 @@ pub struct Config {
/// [`Self::resolved_minimum_release_age_strict`].
pub minimum_release_age_strict: Option<bool>,
/// Skip the lockfile supply-chain verification pass entirely. When
/// `true`, the install trusts the lockfile as-is and never calls
/// `verify_lockfile_resolutions`, even if other policies
/// (`minimum_release_age`, `trust_policy`) are active. Use only in
/// environments where the lockfile is effectively part of the
/// trusted base — closed-source projects with trusted committers,
/// fully reproducible CI against an already-verified lockfile. A
/// poisoned lockfile (e.g. one a contributor authored under a
/// weaker policy than CI enforces) will slip through. Mirrors
/// pnpm's [`trustLockfile`](https://github.com/pnpm/pnpm/blob/main/config/reader/src/Config.ts).
///
/// Added for [#11860](https://github.com/pnpm/pnpm/issues/11860):
/// on multi-thousand-entry workspaces, the verification pass holds
/// the per-package registry metadata needed for the trust check
/// resident in memory and can OOM CI runners with a 2GB heap cap.
/// Default `false` — verification stays on by default.
pub trust_lockfile: bool,
/// Trust-evidence policy applied to lockfile entries; see
/// [`TrustPolicy`].
pub trust_policy: TrustPolicy,

View File

@@ -284,6 +284,13 @@ pub struct WorkspaceSettings {
/// `minimumReleaseAgeStrict` from `pnpm-workspace.yaml`.
pub minimum_release_age_strict: Option<bool>,
/// `trustLockfile` from `pnpm-workspace.yaml`. When `true`, the
/// install skips the supply-chain verification pass entirely
/// (see [`Config::trust_lockfile`]).
///
/// [`Config::trust_lockfile`]: crate::Config::trust_lockfile
pub trust_lockfile: Option<bool>,
/// `trustPolicy` from `pnpm-workspace.yaml`. See [`TrustPolicy`].
pub trust_policy: Option<TrustPolicy>,
@@ -606,6 +613,9 @@ impl WorkspaceSettings {
if let Some(v) = self.minimum_release_age_strict {
config.minimum_release_age_strict = Some(v);
}
if let Some(v) = self.trust_lockfile {
config.trust_lockfile = v;
}
if let Some(v) = self.trust_policy {
config.trust_policy = v;
}

View File

@@ -865,6 +865,7 @@ minimumReleaseAgeExclude:
- "is-*"
minimumReleaseAgeIgnoreMissingTime: true
minimumReleaseAgeStrict: true
trustLockfile: true
trustPolicy: no-downgrade
trustPolicyExclude:
- "@scope/legacy"
@@ -879,6 +880,7 @@ trustPolicyIgnoreAfter: 525600
);
assert_eq!(settings.minimum_release_age_ignore_missing_time, Some(true));
assert_eq!(settings.minimum_release_age_strict, Some(true));
assert_eq!(settings.trust_lockfile, Some(true));
assert_eq!(settings.trust_policy, Some(TrustPolicy::NoDowngrade));
assert_eq!(settings.trust_policy_exclude.as_deref(), Some(&["@scope/legacy".to_string()][..]));
assert_eq!(settings.trust_policy_ignore_after, Some(525_600));
@@ -894,6 +896,7 @@ trustPolicyIgnoreAfter: 525600
assert!(config.minimum_release_age_ignore_missing_time);
assert_eq!(config.minimum_release_age_strict, Some(true));
assert!(config.resolved_minimum_release_age_strict());
assert!(config.trust_lockfile);
assert_eq!(config.trust_policy, TrustPolicy::NoDowngrade);
assert_eq!(config.trust_policy_exclude.as_deref(), Some(&["@scope/legacy".to_string()][..]));
assert_eq!(config.trust_policy_ignore_after, Some(525_600));

View File

@@ -105,6 +105,7 @@ where
prefer_frozen_lockfile: Some(false),
ignore_manifest_check: false,
skip_runtimes: config.skip_runtimes,
trust_lockfile: config.trust_lockfile,
resolved_packages,
supported_architectures,
node_linker: config.node_linker,

View File

@@ -25,7 +25,7 @@ use pacquet_config::{
};
use pacquet_network::ThrottledClient;
use pacquet_resolving_npm_resolver::{
CreateNpmResolutionVerifierOptions, create_npm_resolution_verifier,
CreateNpmResolutionVerifierOptions, PackageMetaCache, create_npm_resolution_verifier,
};
use pacquet_resolving_resolver_base::ResolutionVerifier;
@@ -58,9 +58,17 @@ pub enum BuildVerifiersError {
/// Assemble the verifier list for this install. Returns an empty
/// `Vec` when neither policy is active — the runner short-circuits
/// on an empty list, so the caller doesn't need a separate guard.
///
/// `meta_cache` is the optional per-install packument cache shared
/// with the resolver. When provided, the verifier reads it before
/// fetching: a `(registry, name)` the resolver already pulled
/// during the same install yields the cached document instead of a
/// fresh round-trip. Pass `None` from contexts where no resolver
/// runs alongside (the frozen-install path, unit tests).
pub fn build_resolution_verifiers(
config: &Config,
http_client: Arc<ThrottledClient>,
meta_cache: Option<Arc<dyn PackageMetaCache>>,
) -> Result<Vec<Arc<dyn ResolutionVerifier>>, BuildVerifiersError> {
let mut verifiers: Vec<Arc<dyn ResolutionVerifier>> = Vec::new();
@@ -111,6 +119,7 @@ pub fn build_resolution_verifiers(
http_client,
auth_headers: Arc::clone(&config.auth_headers),
cache_dir: Some(config.cache_dir.clone()),
meta_cache,
now: None,
};

View File

@@ -28,6 +28,7 @@ use pacquet_reporter::{
ContextLog, LogEvent, LogLevel, PackageManifestLog, PackageManifestMessage, Reporter, Stage,
StageLog, SummaryLog,
};
use pacquet_resolving_npm_resolver::InMemoryPackageMetaCache;
use pacquet_tarball::MemCache;
use pacquet_workspace_state::{
NodeLinker as WorkspaceStateNodeLinker, ProjectEntry, UpdateWorkspaceStateError,
@@ -97,6 +98,15 @@ where
/// the install proceeds normally. See
/// `pacquet_config::Config::skip_runtimes`.
pub skip_runtimes: bool,
/// Effective `trustLockfile` value for *this* invocation. The CLI
/// layer ORs the `--trust-lockfile` flag with `config.trust_lockfile`
/// so a yaml `true` can't be overridden back to `false` from the
/// CLI — matching pnpm's stance on similar flags. Threaded as a
/// separate field for the same reason [`Self::skip_runtimes`] is:
/// `state.config` is a shared `&'static Config`, so the CLI
/// override merge happens in the caller and lands here as a
/// fully-resolved value.
pub trust_lockfile: bool,
/// `supportedArchitectures` after merging
/// `Config::supported_architectures` from `pnpm-workspace.yaml`
/// with the CLI per-axis overrides (`--cpu` / `--os` / `--libc`).
@@ -287,6 +297,7 @@ where
prefer_frozen_lockfile,
ignore_manifest_check,
skip_runtimes,
trust_lockfile,
supported_architectures,
node_linker,
} = self;
@@ -385,12 +396,31 @@ where
// `lockfile.is_none()` (writable-lockfile path) skips the
// gate entirely — fresh local resolution is already filtered
// by the resolver's per-version gate (when pacquet's
// resolver lands).
if let Some(loaded_lockfile) = lockfile {
// resolver lands). `trust_lockfile` (the OR of yaml's
// `trustLockfile` and the `--trust-lockfile` CLI flag,
// resolved in [`crate::cli_args::install::InstallArgs::run`])
// is the opt-out for environments where the install can
// treat the on-disk lockfile as already-trusted (see [#11860]).
//
// [#11860]: https://github.com/pnpm/pnpm/issues/11860
// One per-install packument cache shared with both the
// lockfile-verifier (below) and the resolver in
// `install_with_fresh_lockfile` (further down). The
// single instance lets a name the resolver fetched during this
// install short-circuit the verifier's own fetch chain, and
// vice versa. Mirrors pnpm's `installing/client` wiring.
let meta_cache = Arc::new(InMemoryPackageMetaCache::default());
if let Some(loaded_lockfile) = lockfile.filter(|_| !trust_lockfile) {
let derived_lockfile_path = lockfile_path
.map_or_else(|| workspace_root.join(Lockfile::FILE_NAME), Path::to_path_buf);
let verifiers = build_resolution_verifiers(config, Arc::clone(&http_client_arc))
.map_err(InstallError::BuildVerifiers)?;
let verifiers = build_resolution_verifiers(
config,
Arc::clone(&http_client_arc),
Some(Arc::clone(&meta_cache)
as Arc<dyn pacquet_resolving_npm_resolver::PackageMetaCache>),
)
.map_err(InstallError::BuildVerifiers)?;
verify_lockfile_resolutions::<Reporter>(
loaded_lockfile,
&verifiers,
@@ -645,6 +675,7 @@ where
catalogs,
lockfile_dir: &workspace_root,
workspace_packages,
meta_cache: Arc::clone(&meta_cache),
// States 3 and 4 of the dispatch share this branch.
// State 3 (lockfile present but stale or
// `preferFrozenLockfile: false`) passes the existing

View File

@@ -61,6 +61,7 @@ async fn should_install_dependencies() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -128,6 +129,7 @@ async fn should_error_when_frozen_lockfile_is_requested_but_none_exists() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -199,6 +201,7 @@ async fn frozen_lockfile_flag_overrides_config_lockfile_false() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -263,6 +266,7 @@ async fn npm_alias_dependency_installs_under_alias_key() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -344,6 +348,7 @@ async fn unversioned_npm_alias_defaults_to_latest() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -410,6 +415,7 @@ async fn frozen_lockfile_flag_with_no_lockfile_errors() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -496,6 +502,7 @@ async fn install_emits_pnpm_event_sequence() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -639,6 +646,7 @@ async fn install_writes_modules_yaml() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -738,6 +746,7 @@ async fn install_writes_workspace_state() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -854,6 +863,7 @@ async fn install_optional_failing_postinstall_dep_via_registry_mock_succeeds() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -975,6 +985,7 @@ async fn warm_reinstall_skips_snapshot_when_current_lockfile_matches() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -1071,6 +1082,7 @@ async fn warm_reinstall_emits_broken_modules_when_dir_is_missing() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -1175,6 +1187,7 @@ async fn context_log_reflects_current_lockfile_after_first_install() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -1223,6 +1236,7 @@ async fn context_log_reflects_current_lockfile_after_first_install() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -1313,6 +1327,7 @@ async fn warm_reinstall_reports_added_zero_and_emits_no_imported_events() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -1405,6 +1420,7 @@ async fn frozen_lockfile_errors_when_manifest_drifts_from_lockfile() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
resolved_packages: &Default::default(),
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
@@ -1467,6 +1483,7 @@ async fn ignore_manifest_check_bypasses_manifest_freshness_gate() {
prefer_frozen_lockfile: None,
ignore_manifest_check: true,
skip_runtimes: false,
trust_lockfile: false,
resolved_packages: &Default::default(),
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
@@ -1530,6 +1547,7 @@ async fn frozen_lockfile_errors_when_overrides_drift_from_lockfile() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
resolved_packages: &Default::default(),
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
@@ -1619,6 +1637,7 @@ async fn frozen_lockfile_applies_overrides_to_manifest_before_freshness_check()
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
resolved_packages: &Default::default(),
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
@@ -1724,6 +1743,7 @@ async fn frozen_lockfile_resolves_catalog_protocol_in_overrides_before_freshness
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
resolved_packages: &Default::default(),
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
@@ -1783,6 +1803,7 @@ async fn frozen_lockfile_errors_when_lockfile_has_no_root_importer() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
resolved_packages: &Default::default(),
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
@@ -1869,6 +1890,7 @@ async fn frozen_lockfile_under_gvs_registers_project_and_runs_clean() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
resolved_packages: &Default::default(),
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
@@ -1945,6 +1967,7 @@ async fn frozen_lockfile_with_gvs_off_skips_project_registry() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
resolved_packages: &Default::default(),
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
@@ -2027,6 +2050,7 @@ async fn frozen_lockfile_under_gvs_registers_each_workspace_importer() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
resolved_packages: &Default::default(),
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
@@ -2227,6 +2251,7 @@ async fn frozen_install_preserves_seeded_skipped_across_reinstall() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -2349,6 +2374,7 @@ async fn frozen_install_silently_swallows_unreachable_optional_tarball() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -2447,6 +2473,7 @@ async fn frozen_install_propagates_non_optional_fetch_failure() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -2551,6 +2578,7 @@ async fn frozen_install_no_optional_drops_optional_only_snapshots() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -2640,6 +2668,7 @@ async fn frozen_install_optional_included_surfaces_missing_metadata() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -2732,6 +2761,7 @@ async fn frozen_install_no_optional_keeps_shared_non_optional_snapshot() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -2821,6 +2851,7 @@ async fn hoisted_node_linker_empty_lockfile_writes_modules_yaml() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::Hoisted,
resolved_packages: &Default::default(),
@@ -2907,6 +2938,7 @@ async fn hoisted_node_linker_does_not_create_virtual_store_root() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::Hoisted,
resolved_packages: &Default::default(),
@@ -2999,6 +3031,7 @@ async fn frozen_lockfile_install_errors_when_no_variant_matches_host() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
resolved_packages: &Default::default(),
node_linker: pacquet_config::NodeLinker::default(),
@@ -3089,6 +3122,7 @@ async fn frozen_lockfile_install_skips_runtime_when_skip_runtimes_set() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: true,
trust_lockfile: false,
supported_architectures: None,
resolved_packages: &Default::default(),
node_linker: pacquet_config::NodeLinker::default(),
@@ -3185,6 +3219,7 @@ async fn install_rejects_invalid_minimum_release_age_exclude_pattern() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -3283,6 +3318,7 @@ async fn frozen_lockfile_gate_rejects_under_huge_minimum_release_age() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -3367,6 +3403,7 @@ async fn fresh_install_writes_pnpm_lock_yaml_with_expected_shape() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -3449,6 +3486,7 @@ async fn fresh_install_splits_dev_and_prod_dependency_sections() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -3517,6 +3555,7 @@ async fn fresh_install_records_user_written_specifier() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -3581,6 +3620,7 @@ async fn fresh_install_lockfile_round_trips_through_load_save_load() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -3644,6 +3684,7 @@ async fn fresh_install_with_lockfile_disabled_does_not_write_a_lockfile() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -3710,6 +3751,7 @@ async fn fresh_install_also_writes_current_lockfile_under_virtual_store() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -3792,6 +3834,7 @@ async fn fresh_install_with_lockfile_disabled_skips_current_lockfile_too() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -3852,6 +3895,7 @@ async fn fresh_install_marks_optional_snapshots_in_pnpm_lock_yaml() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -3935,6 +3979,7 @@ async fn fresh_install_refuses_hoisted_node_linker_before_writing_state() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::Hoisted,
resolved_packages: &Default::default(),
@@ -3984,6 +4029,7 @@ async fn fresh_install_refuses_skip_runtimes_before_writing_state() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: true,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -4053,6 +4099,7 @@ async fn prefer_frozen_lockfile_takes_frozen_path_when_lockfile_is_fresh() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -4123,6 +4170,7 @@ async fn no_prefer_frozen_lockfile_flag_forces_fresh_resolve() {
prefer_frozen_lockfile: Some(false),
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
@@ -4187,6 +4235,7 @@ async fn stale_lockfile_under_no_flag_falls_through_to_fresh_resolve() {
prefer_frozen_lockfile: None,
ignore_manifest_check: false,
skip_runtimes: false,
trust_lockfile: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),

View File

@@ -75,6 +75,7 @@ fn create_config(store_dir: &Path, modules_dir: &Path, virtual_store_dir: &Path)
minimum_release_age_exclude: None,
minimum_release_age_ignore_missing_time: true,
minimum_release_age_strict: None,
trust_lockfile: false,
trust_policy: Default::default(),
trust_policy_exclude: None,
trust_policy_ignore_after: None,

View File

@@ -126,6 +126,12 @@ pub struct InstallWithFreshLockfile<'a, DependencyGroupList> {
/// `update: false` resolver mode at
/// <https://github.com/pnpm/pnpm/blob/097983fbca/lockfile/preferred-versions/src/index.ts#L13-L33>.
pub wanted_lockfile: Option<&'a Lockfile>,
/// Per-install packument cache shared with the lockfile-verifier
/// constructed in [`Install::run`](crate::Install::run). The
/// resolver writes to it during `pick_package`; the verifier reads
/// from it to skip duplicate fetches when both touch the same
/// `(registry, name)`.
pub meta_cache: Arc<InMemoryPackageMetaCache>,
}
/// Error type of [`InstallWithFreshLockfile`].
@@ -268,6 +274,7 @@ impl<'a, DependencyGroupList> InstallWithFreshLockfile<'a, DependencyGroupList>
lockfile_dir,
workspace_packages,
wanted_lockfile,
meta_cache,
} = self;
// Materialise the caller's iterator into a `Vec` so the same
// group set can be replayed into both the resolver (consumes
@@ -311,8 +318,6 @@ impl<'a, DependencyGroupList> InstallWithFreshLockfile<'a, DependencyGroupList>
let named_registry_aliases: std::collections::HashSet<String> =
merged_named_registries.keys().cloned().collect();
let meta_cache = Arc::new(InMemoryPackageMetaCache::default());
// One per-cache-key packument fetch serializer shared between
// the npm and named-registry resolvers. Ports upstream's
// [`metafileOperationLimits`](https://github.com/pnpm/pnpm/blob/f657b5cb44/resolving/npm-resolver/src/pickPackage.ts#L42-L44):

View File

@@ -29,7 +29,7 @@ use chrono::{DateTime, Utc};
use pacquet_config::{TrustPolicy, version_policy::PackageVersionPolicy};
use pacquet_lockfile::{LockfileResolution, PkgName};
use pacquet_network::{AuthHeaders, ThrottledClient};
use pacquet_registry::Package;
use pacquet_registry::{NpmUser, Package, PackageDistribution, PackageVersion};
use pacquet_resolving_resolver_base::{
ResolutionVerification, ResolutionVerifier, VerifyCtx, VerifyFuture,
};
@@ -41,6 +41,7 @@ use crate::{
fetch_attestation_published_at, fetch_full_metadata_cached,
lookup_context::{PublishedAtLookupContext, PublishedAtTimeMap, package_key, version_key},
named_registry::{build_named_registry_prefixes, pick_registry_for_package},
pick_package::PackageMetaCache,
trust_checks::fail_if_trust_downgraded,
violation_codes::{MINIMUM_RELEASE_AGE_VIOLATION_CODE, TRUST_DOWNGRADE_VIOLATION_CODE},
};
@@ -51,7 +52,6 @@ use crate::{
///
/// The verifier owns the option bag once constructed — these fields
/// flow into [`NpmResolutionVerifier`] verbatim.
#[derive(Debug)]
pub struct CreateNpmResolutionVerifierOptions {
/// Minimum age in **minutes** a published version must reach
/// before it is accepted. `None` disables the age check.
@@ -97,6 +97,14 @@ pub struct CreateNpmResolutionVerifierOptions {
/// writes 200 responses back; when `None`, every fetch is
/// unconditional. Mirrors upstream's `cacheDir` option.
pub cache_dir: Option<PathBuf>,
/// Per-install [`PackageMetaCache`] shared with the npm resolver.
/// When provided, the verifier reads a cached packument before
/// fetching — a name the resolver already pulled during the same
/// install yields the cached document instead of a fresh
/// disk/network round-trip. Optional: frozen-install paths and
/// unit tests don't have a resolver running alongside, in which
/// case the verifier falls back to its own fetch chain.
pub meta_cache: Option<Arc<dyn PackageMetaCache>>,
/// Override for `Utc::now()` when computing the age cutoff and
/// the `trustPolicyIgnoreAfter` window. `None` falls back to
/// wall-clock at construction time.
@@ -125,6 +133,7 @@ pub struct NpmResolutionVerifier {
http_client: Arc<ThrottledClient>,
auth_headers: Arc<AuthHeaders>,
cache_dir: Option<PathBuf>,
meta_cache: Option<Arc<dyn PackageMetaCache>>,
now: Option<DateTime<Utc>>,
policy_snapshot: serde_json::Map<String, JsonValue>,
lookup_context: PublishedAtLookupContext,
@@ -206,6 +215,7 @@ pub fn create_npm_resolution_verifier(
http_client: opts.http_client,
auth_headers: opts.auth_headers,
cache_dir: opts.cache_dir,
meta_cache: opts.meta_cache,
now: opts.now,
policy_snapshot,
lookup_context: PublishedAtLookupContext::new(),
@@ -535,7 +545,30 @@ impl NpmResolutionVerifier {
return entry.clone();
}
}
let result = self.fetch_full_meta(registry, name).await.map(Arc::new);
// Fast path: if the resolver already pulled the full packument
// during the same install (`{registry}\x00{name}:full` key in
// the shared metaCache, populated when `pickPackage` upgrades
// for `minimumReleaseAge`), reuse it. Abbreviated entries are
// rejected here — `fail_if_trust_downgraded` needs per-version
// `time` and per-version trust evidence, both of which only
// the full form carries.
let shared = self.meta_cache.as_ref().and_then(|cache| cache.get(&format!("{key}:full")));
let result = if let Some(meta) = shared {
Ok(Arc::new(project_trust_meta(meta.as_ref())))
} else {
// Project the packument to just the fields `fail_if_trust_downgraded`
// reads before stashing in the cache. The full document — dependency
// graphs, dist-tags, scripts, READMEs for every version — would
// otherwise stay resident in this map for the entire install, which
// on multi-thousand-entry workspaces OOMs CI runners with a 2GB heap
// cap (see [#11860]).
//
// [#11860]: https://github.com/pnpm/pnpm/issues/11860
self.fetch_full_meta(registry, name)
.await
.map(|meta| project_trust_meta(&meta))
.map(Arc::new)
};
let mut cache = self.lookup_context.full_meta_for_trust.lock().await;
cache.entry(key).or_insert(result).clone()
}
@@ -647,5 +680,76 @@ fn build_policy_snapshot(
map
}
/// Build a [`Package`] that retains only the fields
/// [`fail_if_trust_downgraded`] reads: the package name, the per-version
/// `time` map, and per-version trust evidence (`_npmUser.trustedPublisher`
/// and `dist.attestations.provenance`). Drops everything else — dependency
/// graphs, scripts, READMEs — so the per-install trust-meta cache stays
/// bounded by the trust-evidence footprint, not the full packument size.
///
/// Mirrors pnpm's `projectTrustMeta` in
/// [`createNpmResolutionVerifier.ts`](https://github.com/pnpm/pnpm/blob/main/resolving/npm-resolver/src/createNpmResolutionVerifier.ts).
///
/// [`fail_if_trust_downgraded`]: crate::trust_checks::fail_if_trust_downgraded
fn project_trust_meta(meta: &Package) -> Package {
// Borrowed `meta` so the shared-cache fast path (which only holds
// `Arc<Package>`) doesn't pay for a full deep-clone of the
// packument it's about to discard. Only the fields downstream
// reads are cloned out; the bulk of the document (per-version
// dependency maps, scripts, README) drops on the original.
let versions = meta
.versions
.iter()
.map(|(version, manifest)| (version.clone(), project_trust_package_version(manifest)))
.collect();
Package {
name: meta.name.clone(),
dist_tags: std::collections::HashMap::new(),
versions,
time: meta.time.clone(),
modified: meta.modified.clone(),
etag: meta.etag.clone(),
mutex: std::sync::Arc::new(std::sync::Mutex::new(0)),
}
}
fn project_trust_package_version(version: &PackageVersion) -> PackageVersion {
let attestations =
version.dist.attestations.as_ref().and_then(|att| att.provenance.as_ref()).map(|prov| {
pacquet_registry::AttestationsDist { provenance: Some(prov.clone()), url: None }
});
// `get_trust_evidence` only reads `npm_user.trusted_publisher`; drop
// the maintainer `name` / `email` PII so the projected cache entry
// doesn't hold per-version publisher metadata that downstream
// doesn't need.
let npm_user =
version.npm_user.as_ref().and_then(|user| user.trusted_publisher.as_ref()).map(|trusted| {
NpmUser { name: None, email: None, trusted_publisher: Some(trusted.clone()) }
});
PackageVersion {
// `fail_if_trust_downgraded` keys off the outer `meta.versions`
// map and the version-level npm_user / attestations fields. The
// per-version `name`, `version`, and `dist` non-attestation fields
// are never read, so empty placeholders are fine — clone of the
// parsed semver keeps the typed shape valid without paying for
// the upstream dependency graph.
name: String::new(),
version: version.version.clone(),
dist: PackageDistribution {
integrity: None,
shasum: None,
tarball: String::new(),
file_count: None,
unpacked_size: None,
attestations,
},
dependencies: None,
dev_dependencies: None,
peer_dependencies: None,
npm_user,
deprecated: None,
}
}
#[cfg(test)]
mod tests;

View File

@@ -47,6 +47,7 @@ fn default_opts(registry_url: &str) -> CreateNpmResolutionVerifierOptions {
http_client: Arc::new(ThrottledClient::default()),
auth_headers: Arc::new(AuthHeaders::default()),
cache_dir: None,
meta_cache: None,
now: None,
}
}

View File

@@ -8,6 +8,7 @@ import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header'
import { createGitResolver, type GitResolveResult, resolveLatestFromGit } from '@pnpm/resolving.git-resolver'
import { type LocalResolveResult, resolveFromLocalPath, resolveFromLocalScheme, resolveLatestFromLocal } from '@pnpm/resolving.local-resolver'
import {
createDefaultPackageMetaCache,
createNpmResolutionVerifier,
type CreateNpmResolutionVerifierOptions,
createNpmResolver,
@@ -32,6 +33,10 @@ import type {
import { resolveFromTarball, resolveLatestFromTarball, type TarballResolveResult } from '@pnpm/resolving.tarball-resolver'
import type { RegistryConfig } from '@pnpm/types'
export {
createDefaultPackageMetaCache,
}
export type {
PackageMeta,
PackageMetaCache,
@@ -182,6 +187,7 @@ export type ResolutionVerifierFactoryOptions =
| 'trustPolicy'
| 'trustPolicyExclude'
| 'trustPolicyIgnoreAfter'
| 'metaCache'
| 'now'
> & {
configByUri?: Record<string, RegistryConfig>
@@ -224,6 +230,7 @@ export function createResolutionVerifiers (
fetchOpts,
getAuthHeaderValueByURI,
cacheDir: opts.cacheDir,
metaCache: opts.metaCache,
now: opts.now,
})
if (npmVerifier) verifiers.push(npmVerifier)

View File

@@ -2,7 +2,7 @@ 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 { PackageInRegistry, PackageMeta } from '@pnpm/resolving.registry.types'
import type {
Resolution,
ResolutionVerifier,
@@ -18,6 +18,7 @@ import {
type FetchFullMetadataCachedOptions,
} from './fetchFullMetadataCached.js'
import { BUILTIN_NAMED_REGISTRIES } from './parseBareSpecifier.js'
import type { PackageMetaCache } from './pickPackage.js'
import { getPkgMirrorPath, loadMeta, warnMissingTimeFieldOnce } from './pickPackage.js'
import { failIfTrustDowngraded } from './trustChecks.js'
import {
@@ -79,6 +80,16 @@ export interface CreateNpmResolutionVerifierOptions {
fetchOpts: FetchMetadataFromFromRegistryOptions
getAuthHeaderValueByURI: (registry: string) => string | undefined
cacheDir?: FetchFullMetadataCachedOptions['cacheDir']
/**
* Per-install LRU shared with the npm resolver's `pickPackage`
* (`{ get, set }` over `PackageMeta`). When provided, the verifier
* consults it before fetching: a name the resolver already pulled
* during the same install yields the cached packument instead of a
* fresh disk/network round-trip. Optional — frozen-install paths and
* unit tests don't have a resolver running alongside, in which case
* the verifier falls back to its own fetch chain.
*/
metaCache?: PackageMetaCache
/** Overrides Date.now() for tests. */
now?: number
}
@@ -151,6 +162,7 @@ export function createNpmResolutionVerifier (
getAuthHeaderValueByURI: opts.getAuthHeaderValueByURI,
cacheDir: opts.cacheDir,
cutoffMs: cutoff,
sharedMetaCache: opts.metaCache,
abbreviatedMetaCache: new Map(),
publishedAtCache: new Map(),
localMetaCache: new Map(),
@@ -366,22 +378,91 @@ function fetchFullMetaForTrust (
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,
})
// Fast path: if the resolver already upgraded to full meta for this
// name during the same install (e.g. minimumReleaseAge active),
// reuse that document. Abbreviated meta is rejected here — it lacks
// per-version `time` and per-version trust evidence, both required
// by failIfTrustDowngraded.
//
// Limitation: the resolver's `metaCache` keys by `${name}:full` —
// it doesn't include the registry (pickPackage.ts cacheKey shape).
// If two registries serve packages of the same name in one install
// the resolver itself silently keeps the first fetch; the verifier
// here inherits that scope. The name check below is a defensive
// guard against accidental cache mixups; tightening this to a
// registry-qualified read needs the resolver's `metaCache` key
// shape to change first.
const shared = readSharedMetaForTrust(context.sharedMetaCache, name)
if (shared != null) {
cachedPromise = Promise.resolve(projectTrustMeta(shared))
} else {
// 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.
//
// The fetched packument is projected down to just the trust-relevant
// fields (per-version `_npmUser.trustedPublisher` and
// `dist.attestations.provenance`, plus the package-level `time` map)
// before being stored. The full document — dependency maps, scripts,
// READMEs for every version — would otherwise stay resident in this
// map for the entire install, which on multi-thousand-entry
// workspaces OOMs CI runners with a 2GB heap (see #11860).
cachedPromise = fetchFullMetadataCached(context.fetchOpts, name, {
registry,
authHeaderValue: context.getAuthHeaderValueByURI(registry),
cacheDir: context.cacheDir,
}).then(projectTrustMeta)
}
context.fullMetaForTrustCache.set(cacheKey, cachedPromise)
}
return cachedPromise
}
// Project the full packument to a minimal `PackageMeta`-shaped view
// that exposes only the fields `failIfTrustDowngraded` reads:
// • `name` and `modified` for error messages and cache keys
// • `time` for the per-version publish-date walk
// • `versions[v]._npmUser.trustedPublisher`
// • `versions[v].dist.attestations.provenance`
// The shape is still a valid `PackageMeta` so the downstream consumer
// doesn't have to special-case it — only the bulk fields (dependency
// graph, scripts, README, etc.) are dropped.
function projectTrustMeta (meta: PackageMeta): PackageMeta {
const versions: Record<string, PackageInRegistry> = {}
for (const [version, manifest] of Object.entries(meta.versions ?? {})) {
versions[version] = projectTrustManifest(manifest)
}
return {
name: meta.name,
'dist-tags': {},
versions,
time: meta.time,
modified: meta.modified,
etag: meta.etag,
}
}
function projectTrustManifest (manifest: PackageInRegistry): PackageInRegistry {
// Drop everything except the trust-evidence fields. `PackageInRegistry.dist`
// is typed as requiring `shasum` and `tarball`, but the trust check never
// reads them; cast away the unsoundness so callers see the same nominal
// shape without the per-version dependency graph / scripts / README bulk
// carrying through. `_npmUser` is similarly narrowed to just
// `trustedPublisher` — the only sub-field the trust check inspects — so
// we don't keep maintainer name/email PII resident in the cache.
const trustedPublisher = manifest._npmUser?.trustedPublisher
const provenance = manifest.dist?.attestations?.provenance
return {
_npmUser: trustedPublisher != null ? { trustedPublisher } : undefined,
dist: provenance != null
? { attestations: { provenance } }
: undefined,
} as unknown as PackageInRegistry
}
type PublishedAtTimeMap = Record<string, string | undefined>
interface PublishedAtLookupContext {
@@ -396,14 +477,29 @@ interface PublishedAtLookupContext {
* version it contains is too.
*/
cutoffMs: number
/**
* Resolver-owned LRU (per-install) keyed by `${name}` (abbreviated)
* or `${name}:full` (full meta). When the resolver has already
* fetched a package during this install, the verifier reuses that
* packument instead of re-paying the disk/network round-trip — the
* fresh-install path otherwise fetches every entry twice. Optional:
* the frozen-install path runs without a resolver and never
* populates this cache, so the verifier's own fetch chain still
* carries the cold case.
*/
sharedMetaCache?: PackageMetaCache
/**
* Per-(registry+name) memo of the abbreviated metadata fetch.
* Abbreviated is what the resolver populates by default, so on a
* non-frozen install the conditional GET hits the disk mirror at
* ~zero cost. Resolves to the parsed metadata or `undefined` on
* failure.
* ~zero cost. Stores only the two fields the shortcut reads —
* package-level `modified` plus the set of currently-listed version
* names — so the multi-hundred-KB packument can be GC'd as soon as
* the fetch returns (the cache only needs to dedupe network/disk
* round-trips, not full document storage). Resolves to `undefined`
* on failure.
*/
abbreviatedMetaCache: Map<string, Promise<{ modified?: string, versions?: Record<string, unknown> } | undefined>>
abbreviatedMetaCache: Map<string, Promise<AbbreviatedMetaProjection | undefined>>
/**
* Per-(registry+name+version) memo of the final published-at answer
* the verifier hands to the policy check. One install verifies each
@@ -523,7 +619,7 @@ async function tryAbbreviatedModifiedShortcut (
// 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
if (!meta?.versionNames?.has(version)) return undefined
return modified
}
@@ -531,20 +627,90 @@ function fetchAbbreviatedMeta (
context: PublishedAtLookupContext,
registry: string,
name: string
): Promise<{ modified?: string, versions?: Record<string, unknown> } | undefined> {
): Promise<AbbreviatedMetaProjection | undefined> {
const cacheKey = `${registry}\x00${name}`
let cachedPromise = context.abbreviatedMetaCache.get(cacheKey)
if (cachedPromise == null) {
cachedPromise = fetchAbbreviatedMetadataCached(context.fetchOpts, name, {
registry,
authHeaderValue: context.getAuthHeaderValueByURI(registry),
cacheDir: context.cacheDir,
}).catch(() => undefined)
// Fast path: the resolver's per-install LRU already holds this
// packument from its own pickPackage pass — abbreviated or full.
// Project it for the shortcut and skip the disk/network round-trip.
// Mismatch on `name` is the same risk the resolver carries today
// (its cache key omits the registry), so reuse is no less correct
// than the resolver's own get.
const shared = readSharedMeta(context.sharedMetaCache, name)
if (shared != null) {
cachedPromise = Promise.resolve(projectAbbreviatedMeta(shared))
} else {
cachedPromise = fetchAbbreviatedMetadataCached(context.fetchOpts, name, {
registry,
authHeaderValue: context.getAuthHeaderValueByURI(registry),
cacheDir: context.cacheDir,
}).then(projectAbbreviatedMeta, () => undefined)
}
context.abbreviatedMetaCache.set(cacheKey, cachedPromise)
}
return cachedPromise
}
function readSharedMeta (
cache: PackageMetaCache | undefined,
name: string
): PackageMeta | undefined {
if (cache == null) return undefined
// Prefer the full entry — a `name:full` hit subsumes the abbreviated
// hit (full meta carries every field the abbreviated form does, plus
// `time` and per-version trust evidence the trust check needs). The
// resolver only populates `name:full` when the install ran with
// `minimumReleaseAge` configured, otherwise the bare `name` key holds
// the abbreviated form.
return validateSharedMeta(cache.get(`${name}:full`), name) ??
validateSharedMeta(cache.get(name), name)
}
function readSharedMetaForTrust (
cache: PackageMetaCache | undefined,
name: string
): PackageMeta | undefined {
if (cache == null) return undefined
// Abbreviated meta is rejected for the trust check — it lacks
// per-version `time` and per-version trust evidence.
return validateSharedMeta(cache.get(`${name}:full`), name)
}
// Defensive guard against the resolver's `name`-only cache key
// returning something unexpected. The known correctness gap (two
// registries serving the same package name share one cache slot)
// is inherited from the resolver itself — see the `pickPackage.ts`
// cacheKey shape; the verifier can't be stricter than the resolver
// without changing both. This name check at least catches accidental
// returns of a different package (cache corruption, factory misuse)
// rather than silently feeding wrong data to the trust / age check.
function validateSharedMeta (meta: PackageMeta | undefined, name: string): PackageMeta | undefined {
if (meta == null) return undefined
if (meta.name !== name) return undefined
return meta
}
// Project the abbreviated packument down to the two fields the verifier
// actually reads — package-level `modified` and the set of version names
// for the existence check inside `tryAbbreviatedModifiedShortcut`. The
// resolver populates the abbreviated mirror with every version's
// dependency / engine / dist info, which can run to hundreds of KB per
// package and accumulate to many GB across a multi-thousand-entry
// lockfile (see #11860). The full document is GC-able as soon as this
// closure returns.
function projectAbbreviatedMeta (meta: PackageMeta): AbbreviatedMetaProjection {
return {
modified: meta.modified,
versionNames: meta.versions ? new Set(Object.keys(meta.versions)) : undefined,
}
}
interface AbbreviatedMetaProjection {
modified?: string
versionNames?: Set<string>
}
function readLocalMetaTime (
context: PublishedAtLookupContext,
registry: string,

View File

@@ -214,10 +214,15 @@ export function createNpmResolver (
const fetch = pMemoize(fetchMetadataFromFromRegistry.bind(null, fetchOpts), {
cacheKey: (...args) => JSON.stringify(args),
})
const metaCache: PackageMetaCache = opts.metaCache ?? new LRUCache<string, PackageMeta>({
max: 10000,
ttl: 120 * 1000, // 2 minutes
})
// Track ownership so `clearCache()` below only wipes the in-memory
// cache when this factory created it. A caller-supplied
// `opts.metaCache` may be shared with another resolver instance (or
// outlive this resolver entirely — e.g. a long-lived agent process
// that keeps one cache across many install requests); clearing it
// here would silently evict entries that other consumers are still
// using.
const ownsMetaCache = opts.metaCache == null
const metaCache: PackageMetaCache = opts.metaCache ?? createDefaultPackageMetaCache()
// Create peek function if storeDir is provided
const storeDir = opts.storeDir
const peekLockerForPeek = new Map<string, Promise<DependencyManifest | undefined>>()
@@ -280,7 +285,7 @@ export function createNpmResolver (
resolveLatestFromNamedRegistry: createResolveLatest(boundResolveFromNamedRegistry,
(query) => isNamedRegistrySpec(query, ctx.namedRegistryNames)),
clearCache: () => {
if ('clear' in metaCache && typeof metaCache.clear === 'function') {
if (ownsMetaCache && 'clear' in metaCache && typeof metaCache.clear === 'function') {
metaCache.clear()
}
pMemoizeClear(fetch)
@@ -1074,3 +1079,17 @@ function createVersionSpec (version: string, pinnedVersion?: PinnedVersion): str
throw new PnpmError('BAD_PINNED_VERSION', `Cannot pin '${pinnedVersion ?? 'undefined'}'`)
}
}
/**
* Construct the LRU `PackageMetaCache` instance the resolver uses by
* default. Exported so the install layer can build one cache and hand
* the same reference to both the resolver and the verifier — the
* verifier's fast path reads from it when the resolver has already
* fetched a packument during the same install.
*/
export function createDefaultPackageMetaCache (): PackageMetaCache {
return new LRUCache<string, PackageMeta>({
max: 10000,
ttl: 120 * 1000, // 2 minutes
})
}