From 212315de1663a5d210c359866981368fc7e97ddb Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Sat, 23 May 2026 20:33:03 +0200 Subject: [PATCH] fix: cap lockfile verification memory and add trustLockfile opt-out (#11878) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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`. --- .../trust-lockfile-and-verifier-memory.md | 11 + config/reader/src/Config.ts | 1 + config/reader/src/configFileKey.ts | 1 + config/reader/src/localConfig.ts | 1 + config/reader/src/types.ts | 1 + installing/client/src/index.ts | 11 +- installing/commands/src/add.ts | 1 + installing/commands/src/install.ts | 6 + installing/commands/src/installDeps.ts | 1 + installing/commands/src/recursive.ts | 1 + installing/commands/src/remove.ts | 1 + .../src/install/extendInstallOptions.ts | 17 ++ .../deps-installer/src/install/index.ts | 2 +- .../test/install/trustLockfile.ts | 55 +++++ pacquet/crates/cli/src/cli_args/install.rs | 15 ++ .../crates/cli/tests/lockfile_verification.rs | 133 +++++++++++ pacquet/crates/config/src/env_overlay.rs | 1 + pacquet/crates/config/src/lib.rs | 18 ++ pacquet/crates/config/src/workspace_yaml.rs | 10 + .../crates/config/src/workspace_yaml/tests.rs | 3 + pacquet/crates/package-manager/src/add.rs | 1 + .../src/build_resolution_verifiers.rs | 11 +- pacquet/crates/package-manager/src/install.rs | 39 +++- .../package-manager/src/install/tests.rs | 49 ++++ .../install_package_from_registry/tests.rs | 1 + .../src/install_with_fresh_lockfile.rs | 9 +- .../src/create_npm_resolution_verifier.rs | 110 ++++++++- .../create_npm_resolution_verifier/tests.rs | 1 + resolving/default-resolver/src/index.ts | 7 + .../src/createNpmResolutionVerifier.ts | 210 ++++++++++++++++-- resolving/npm-resolver/src/index.ts | 29 ++- 31 files changed, 717 insertions(+), 40 deletions(-) create mode 100644 .changeset/trust-lockfile-and-verifier-memory.md create mode 100644 installing/deps-installer/test/install/trustLockfile.ts diff --git a/.changeset/trust-lockfile-and-verifier-memory.md b/.changeset/trust-lockfile-and-verifier-memory.md new file mode 100644 index 0000000000..cb044210c9 --- /dev/null +++ b/.changeset/trust-lockfile-and-verifier-memory.md @@ -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). diff --git a/config/reader/src/Config.ts b/config/reader/src/Config.ts index 87280d241f..51527cfcc8 100644 --- a/config/reader/src/Config.ts +++ b/config/reader/src/Config.ts @@ -267,6 +267,7 @@ export interface Config extends OptionsFromRootManifest { minimumReleaseAgeStrict?: boolean fetchWarnTimeoutMs?: number fetchMinSpeedKiBps?: number + trustLockfile?: boolean trustPolicy?: TrustPolicy trustPolicyExclude?: string[] trustPolicyIgnoreAfter?: number diff --git a/config/reader/src/configFileKey.ts b/config/reader/src/configFileKey.ts index e2f84fdf74..230b885a71 100644 --- a/config/reader/src/configFileKey.ts +++ b/config/reader/src/configFileKey.ts @@ -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', diff --git a/config/reader/src/localConfig.ts b/config/reader/src/localConfig.ts index 42aa96bdda..c4d5f3b9f7 100644 --- a/config/reader/src/localConfig.ts +++ b/config/reader/src/localConfig.ts @@ -86,6 +86,7 @@ const SECURITY_POLICY_CFG_KEYS = [ 'minimumReleaseAgeExclude', 'minimumReleaseAgeIgnoreMissingTime', 'minimumReleaseAgeStrict', + 'trustLockfile', 'trustPolicy', 'trustPolicyExclude', 'trustPolicyIgnoreAfter', diff --git a/config/reader/src/types.ts b/config/reader/src/types.ts index 7be4a07038..b0a3b69068 100644 --- a/config/reader/src/types.ts +++ b/config/reader/src/types.ts @@ -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, diff --git a/installing/client/src/index.ts b/installing/client/src/index.ts index 91fdfc552f..745ae91c7a 100644 --- a/installing/client/src/index.ts +++ b/installing/client/src/index.ts @@ -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 }), } } diff --git a/installing/commands/src/add.ts b/installing/commands/src/add.ts index 3cd5818c8d..afbaffa033 100644 --- a/installing/commands/src/add.ts +++ b/installing/commands/src/add.ts @@ -79,6 +79,7 @@ export function rcOptionsTypes (): Record { 'side-effects-cache', 'store-dir', 'strict-peer-dependencies', + 'trust-lockfile', 'trust-policy', 'trust-policy-exclude', 'trust-policy-ignore-after', diff --git a/installing/commands/src/install.ts b/installing/commands/src/install.ts index 9942d62c22..dbaeba71f8 100644 --- a/installing/commands/src/install.ts +++ b/installing/commands/src/install.ts @@ -65,6 +65,7 @@ export function rcOptionsTypes (): Record { '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 ', }, + { + 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 & Pick 0 const wantedLockfilePath = cacheActive ? path.resolve(ctx.lockfileDir, await getWantedLockfileName({ diff --git a/installing/deps-installer/test/install/trustLockfile.ts b/installing/deps-installer/test/install/trustLockfile.ts new file mode 100644 index 0000000000..8b50bd0042 --- /dev/null +++ b/installing/deps-installer/test/install/trustLockfile.ts @@ -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() +}) diff --git a/pacquet/crates/cli/src/cli_args/install.rs b/pacquet/crates/cli/src/cli_args/install.rs index c25a0c346e..45ff0aba41 100644 --- a/pacquet/crates/cli/src/cli_args/install.rs +++ b/pacquet/crates/cli/src/cli_args/install.rs @@ -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, diff --git a/pacquet/crates/cli/tests/lockfile_verification.rs b/pacquet/crates/cli/tests/lockfile_verification.rs index ea3ceaa122..f3f7e9b017 100644 --- a/pacquet/crates/cli/tests/lockfile_verification.rs +++ b/pacquet/crates/cli/tests/lockfile_verification.rs @@ -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)); +} diff --git a/pacquet/crates/config/src/env_overlay.rs b/pacquet/crates/config/src/env_overlay.rs index 60881cc6a0..37fb17bca3 100644 --- a/pacquet/crates/config/src/env_overlay.rs +++ b/pacquet/crates/config/src/env_overlay.rs @@ -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"); diff --git a/pacquet/crates/config/src/lib.rs b/pacquet/crates/config/src/lib.rs index b46e797aea..6a29c0c68d 100644 --- a/pacquet/crates/config/src/lib.rs +++ b/pacquet/crates/config/src/lib.rs @@ -787,6 +787,24 @@ pub struct Config { /// [`Self::resolved_minimum_release_age_strict`]. pub minimum_release_age_strict: Option, + /// 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, diff --git a/pacquet/crates/config/src/workspace_yaml.rs b/pacquet/crates/config/src/workspace_yaml.rs index 1586239d7a..f841bbeb86 100644 --- a/pacquet/crates/config/src/workspace_yaml.rs +++ b/pacquet/crates/config/src/workspace_yaml.rs @@ -284,6 +284,13 @@ pub struct WorkspaceSettings { /// `minimumReleaseAgeStrict` from `pnpm-workspace.yaml`. pub minimum_release_age_strict: Option, + /// `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, + /// `trustPolicy` from `pnpm-workspace.yaml`. See [`TrustPolicy`]. pub trust_policy: Option, @@ -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; } diff --git a/pacquet/crates/config/src/workspace_yaml/tests.rs b/pacquet/crates/config/src/workspace_yaml/tests.rs index d3eea259eb..c883abd2fc 100644 --- a/pacquet/crates/config/src/workspace_yaml/tests.rs +++ b/pacquet/crates/config/src/workspace_yaml/tests.rs @@ -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)); diff --git a/pacquet/crates/package-manager/src/add.rs b/pacquet/crates/package-manager/src/add.rs index 7bf52bd7c6..7b48ded438 100644 --- a/pacquet/crates/package-manager/src/add.rs +++ b/pacquet/crates/package-manager/src/add.rs @@ -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, diff --git a/pacquet/crates/package-manager/src/build_resolution_verifiers.rs b/pacquet/crates/package-manager/src/build_resolution_verifiers.rs index 69cad6f337..85ee354865 100644 --- a/pacquet/crates/package-manager/src/build_resolution_verifiers.rs +++ b/pacquet/crates/package-manager/src/build_resolution_verifiers.rs @@ -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, + meta_cache: Option>, ) -> Result>, BuildVerifiersError> { let mut verifiers: Vec> = 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, }; diff --git a/pacquet/crates/package-manager/src/install.rs b/pacquet/crates/package-manager/src/install.rs index 1ba8f4891f..7176050b67 100644 --- a/pacquet/crates/package-manager/src/install.rs +++ b/pacquet/crates/package-manager/src/install.rs @@ -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), + ) + .map_err(InstallError::BuildVerifiers)?; verify_lockfile_resolutions::( 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 diff --git a/pacquet/crates/package-manager/src/install/tests.rs b/pacquet/crates/package-manager/src/install/tests.rs index fa99e8e043..550f106ac5 100644 --- a/pacquet/crates/package-manager/src/install/tests.rs +++ b/pacquet/crates/package-manager/src/install/tests.rs @@ -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(), diff --git a/pacquet/crates/package-manager/src/install_package_from_registry/tests.rs b/pacquet/crates/package-manager/src/install_package_from_registry/tests.rs index 806a8f78cf..6825ac1061 100644 --- a/pacquet/crates/package-manager/src/install_package_from_registry/tests.rs +++ b/pacquet/crates/package-manager/src/install_package_from_registry/tests.rs @@ -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, diff --git a/pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs b/pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs index 727e7e8494..3e01af488a 100644 --- a/pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs +++ b/pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs @@ -126,6 +126,12 @@ pub struct InstallWithFreshLockfile<'a, DependencyGroupList> { /// `update: false` resolver mode at /// . 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, } /// 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 = 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): diff --git a/pacquet/crates/resolving-npm-resolver/src/create_npm_resolution_verifier.rs b/pacquet/crates/resolving-npm-resolver/src/create_npm_resolution_verifier.rs index 57626fea53..d049e43fb8 100644 --- a/pacquet/crates/resolving-npm-resolver/src/create_npm_resolution_verifier.rs +++ b/pacquet/crates/resolving-npm-resolver/src/create_npm_resolution_verifier.rs @@ -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, + /// 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>, /// 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, auth_headers: Arc, cache_dir: Option, + meta_cache: Option>, now: Option>, policy_snapshot: serde_json::Map, 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`) 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; diff --git a/pacquet/crates/resolving-npm-resolver/src/create_npm_resolution_verifier/tests.rs b/pacquet/crates/resolving-npm-resolver/src/create_npm_resolution_verifier/tests.rs index 651bc4c347..c5ed638084 100644 --- a/pacquet/crates/resolving-npm-resolver/src/create_npm_resolution_verifier/tests.rs +++ b/pacquet/crates/resolving-npm-resolver/src/create_npm_resolution_verifier/tests.rs @@ -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, } } diff --git a/resolving/default-resolver/src/index.ts b/resolving/default-resolver/src/index.ts index b97966680a..d4f37ea0d1 100644 --- a/resolving/default-resolver/src/index.ts +++ b/resolving/default-resolver/src/index.ts @@ -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 @@ -224,6 +230,7 @@ export function createResolutionVerifiers ( fetchOpts, getAuthHeaderValueByURI, cacheDir: opts.cacheDir, + metaCache: opts.metaCache, now: opts.now, }) if (npmVerifier) verifiers.push(npmVerifier) diff --git a/resolving/npm-resolver/src/createNpmResolutionVerifier.ts b/resolving/npm-resolver/src/createNpmResolutionVerifier.ts index edafec34c1..fff0422dd8 100644 --- a/resolving/npm-resolver/src/createNpmResolutionVerifier.ts +++ b/resolving/npm-resolver/src/createNpmResolutionVerifier.ts @@ -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 = {} + 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 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 } | undefined>> + abbreviatedMetaCache: Map> /** * 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 } | undefined> { +): Promise { 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 +} + function readLocalMetaTime ( context: PublishedAtLookupContext, registry: string, diff --git a/resolving/npm-resolver/src/index.ts b/resolving/npm-resolver/src/index.ts index a8a87b9c63..b80b546e35 100644 --- a/resolving/npm-resolver/src/index.ts +++ b/resolving/npm-resolver/src/index.ts @@ -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({ - 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>() @@ -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({ + max: 10000, + ttl: 120 * 1000, // 2 minutes + }) +}