mirror of
https://github.com/pnpm/pnpm.git
synced 2026-05-24 16:46:06 -04:00
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:
11
.changeset/trust-lockfile-and-verifier-memory.md
Normal file
11
.changeset/trust-lockfile-and-verifier-memory.md
Normal 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).
|
||||
@@ -267,6 +267,7 @@ export interface Config extends OptionsFromRootManifest {
|
||||
minimumReleaseAgeStrict?: boolean
|
||||
fetchWarnTimeoutMs?: number
|
||||
fetchMinSpeedKiBps?: number
|
||||
trustLockfile?: boolean
|
||||
trustPolicy?: TrustPolicy
|
||||
trustPolicyExclude?: string[]
|
||||
trustPolicyIgnoreAfter?: number
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -86,6 +86,7 @@ const SECURITY_POLICY_CFG_KEYS = [
|
||||
'minimumReleaseAgeExclude',
|
||||
'minimumReleaseAgeIgnoreMissingTime',
|
||||
'minimumReleaseAgeStrict',
|
||||
'trustLockfile',
|
||||
'trustPolicy',
|
||||
'trustPolicyExclude',
|
||||
'trustPolicyIgnoreAfter',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -105,6 +105,7 @@ export type InstallDepsOptions = Pick<Config,
|
||||
| 'sharedWorkspaceLockfile'
|
||||
| 'shellEmulator'
|
||||
| 'tag'
|
||||
| 'trustLockfile'
|
||||
| 'allowBuilds'
|
||||
| 'optional'
|
||||
| 'workspaceConcurrency'
|
||||
|
||||
@@ -88,6 +88,7 @@ export type RecursiveOptions = CreateStoreControllerOptions & Pick<Config,
|
||||
| 'lockfileIncludeTarballUrl'
|
||||
| 'sharedWorkspaceLockfile'
|
||||
| 'tag'
|
||||
| 'trustLockfile'
|
||||
| 'cleanupUnusedCatalogs'
|
||||
| 'packageConfigs'
|
||||
| 'updateConfig'
|
||||
|
||||
@@ -145,6 +145,7 @@ export async function handler (
|
||||
| 'workspacePackagePatterns'
|
||||
| 'sharedWorkspaceLockfile'
|
||||
| 'cleanupUnusedCatalogs'
|
||||
| 'trustLockfile'
|
||||
> & Pick<ConfigContext,
|
||||
| 'allProjects'
|
||||
| 'allProjectsGraph'
|
||||
|
||||
@@ -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
|
||||
/**
|
||||
|
||||
@@ -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({
|
||||
|
||||
55
installing/deps-installer/test/install/trustLockfile.ts
Normal file
55
installing/deps-installer/test/install/trustLockfile.ts
Normal 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()
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user