Skip to content

pacquet: port lockfile verification (minimumReleaseAge + trustPolicy) #11722

@zkochan

Description

@zkochan

Context

Pnpm v11 added a lockfile verification gate that re-applies the resolver's policy checks to every entry in pnpm-lock.yaml immediately after the lockfile is loaded, before any resolution or fetch happens. The threat model is a lockfile that someone else resolved — committed to the repo, restored from CI cache, or generated by a tool that bypassed local policy. Today two policies plug into the gate:

  • minimumReleaseAge — reject versions younger than the configured cutoff.
  • trustPolicy: 'no-downgrade' — reject versions whose trust evidence (provenance, trusted-publisher) is weaker than an earlier-published version's.

A passing verification is recorded in <cacheDir>/lockfile-verified.jsonl so the next install against an unchanged lockfile under the same policy can stat-and-skip.

Pacquet has none of this yet. No ResolutionVerifier trait, no runner, no JSONL cache, no on-disk packument mirror, no minimumReleaseAge / trustPolicy config fields, no abbreviated/full metadata distinction in pacquet-registry. This issue tracks the full port in PR-sized phases.

Confirmed decisions:

  • Time parsing: promote chrono to a runtime dep (already in workspace as dev-dep).
  • Gate scope: run the gate only when lockfile.is_some() — skip the no-lockfile install path.

Upstream commit pinned for permanent links in code/commits: 2a9bd897bf.

Crate layout

Three new crates, four existing crates extended:

New crate Mirrors upstream Holds
pacquet-resolving-resolver-base @pnpm/resolving.resolver-base (subset) ResolutionVerifier trait, ResolutionVerification, ResolutionPolicyViolation. No async, no IO.
pacquet-resolving-npm-resolver @pnpm/resolving.npm-resolver (subset) NpmResolutionVerifier, trust-rank logic, attestation fetcher, cached full-metadata fetcher, violation codes.
pacquet-lockfile-verification @pnpm/lockfile.verification (de facto) Fan-out runner, JSONL cache, in-memory lockfile hasher.
Existing crate Edit
pacquet-config New fields (cache_dir, minimum_release_age, trust_policy, …), TrustPolicy enum, port createPackageVersionPolicy.
pacquet-reporter New LogEvent::LockfileVerification variant.
pacquet-registry Enrich Package / PackageVersion with time, _npmUser.trustedPublisher, dist.attestations, modified.
pacquet-package-manager Wire the gate into install.rs::Install::run between lockfile load and frozen-lockfile dispatch.

Rationale: upstream splits the trait into resolving/resolver-base/ because future verifiers plug into it; folding it into the runner crate would invert that layering. The npm verifier doesn't fit pacquet-registry (which is bare fetch+deserialize today); a dedicated pacquet-resolving-npm-resolver matches upstream's package and leaves slots for pickPackage etc. when those land.

Source files

Upstream (port from these, paths at SHA 2a9bd897bf)

Pacquet (existing patterns to follow)

  • pacquet/crates/reporter/src/lib.rs:26-178LogEvent enum, channel-rename convention, recording-fake-reporter style.
  • pacquet/crates/reporter/src/lib.rs:684-715Reporter trait (fn emit(event: &LogEvent), sync).
  • pacquet/crates/network/src/lib.rs:49-99ThrottledClient + Semaphore::new(default_network_concurrency()) pattern to mirror for the verifier's 16-way fan-out.
  • pacquet/crates/registry/src/package.rs:29-61Package::fetch_from_registry, the existing one-endpoint baseline we extend.
  • pacquet/crates/config/src/lib.rsConfig struct shape, SmartDefault, serde wiring.
  • pacquet/crates/config/src/matcher.rscreate_matcher for wildcard patterns; reuse for PackageVersionPolicy.
  • pacquet/crates/package-manager/src/version_policy.rsexpand_package_version_specs (the exact-version half already ported). Move + extend in Phase 2.
  • pacquet/crates/lockfile/src/lib.rs:43-91Lockfile shape; PackageKey = PkgNameVerPeer = PkgNameSuffix<PkgVerPeer>.
  • pacquet/crates/lockfile/src/resolution.rs:247-256LockfileResolution::{Tarball, Registry, Directory, Git, Binary, Variations} — what verifiers dispatch on.
  • pacquet/crates/package-manager/src/install.rs:239-253 — where the gate plugs in (after Lockfile::load_current_from_virtual_store_dir, before the pnpm:context emit).
  • pacquet/crates/testing-utils/src/bin.rsCommandTempCwd, AddMockedRegistry — used for the end-to-end CLI test in Phase 7.

Phase-by-phase plan

Phase 1 — Reporter channel + verifier trait crate

Deliverable. Pacquet can emit pnpm:lockfile-verification events (no emit site yet). The verifier trait + violation type exist so later phases can implement it. Install behavior unchanged.

Edits.

  • pacquet/crates/reporter/src/lib.rs: add LogEvent::LockfileVerification(LockfileVerificationLog) variant with #[serde(rename = "pnpm:lockfile-verification")]. Payload shape mirrors ProgressLog — outer level + #[serde(flatten)] message: LockfileVerificationMessage where message is a status-tagged union (Started { entries, lockfile_path }, Done { entries, elapsed_ms, lockfile_path }, Failed { entries, elapsed_ms, lockfile_path }). Mirror upstream's lockfileVerificationLogger.ts:18-46 field-for-field. lockfile_path is Option<String> (camelCase wire lockfilePath).
  • pacquet/crates/reporter/src/tests.rs: byte-exact JSON round-trip for each of the three messages; cover the lockfile_path = None case.

Create. Crate at pacquet/crates/resolving-resolver-base/.

  • Cargo.toml: workspace deps serde, serde_json, derive_more, pacquet-lockfile (for LockfileResolution, PkgName).
  • src/lib.rs:
    • pub trait ResolutionVerifier: Send + Sync with async fn verify(&self, resolution: &LockfileResolution, ctx: VerifyCtx<'_>) -> ResolutionVerification, fn policy(&self) -> &serde_json::Map<String, serde_json::Value>, fn can_trust_past_check(&self, cached: &serde_json::Map<String, serde_json::Value>) -> bool. Native async fn in trait (toolchain is 1.95.0, stable since 1.75).
    • pub enum ResolutionVerification { Ok, Err { code: &'static str, reason: String } }.
    • pub struct ResolutionPolicyViolation { name: PkgName, version: String, resolution: LockfileResolution, code: &'static str, reason: String }.
    • pub struct VerifyCtx<'a> { pub name: &'a PkgName, pub version: &'a str }.

Tests. Reporter wire shape; smoke construction of ResolutionVerification::Err and ResolutionPolicyViolation.

Blocks. Everything downstream. Blocked by. Nothing.


Phase 2 — Config fields, cache_dir, PackageVersionPolicy

Deliverable. .npmrc / pnpm-workspace.yaml parses the seven new keys without error; Config::cache_dir resolves on every platform; createPackageVersionPolicy exists.

Edits.

  • pacquet/crates/config/src/lib.rs: add to Config:
    • pub cache_dir: PathBuf (with default_cache_dir() smart-default).
    • pub minimum_release_age: Option<u64> (milliseconds — match upstream's wire shape).
    • pub minimum_release_age_exclude: Option<Vec<String>>.
    • pub minimum_release_age_ignore_missing_time: bool (default false).
    • pub minimum_release_age_strict: bool (default false).
    • pub trust_policy: TrustPolicy (default Off).
    • pub trust_policy_exclude: Option<Vec<String>>.
    • pub trust_policy_ignore_after: Option<String> (RFC3339 string; parsed at verifier boundary).
  • pacquet/crates/config/src/lib.rs: pub enum TrustPolicy { Off, NoDowngrade } with serde rename_all = \"kebab-case\", default = Off. AGENTS rule 7 (string-literal union → enum).
  • pacquet/crates/config/src/defaults.rs: default_cache_dir() chain — \$PNPM_CACHE_DIR\$XDG_CACHE_HOME/pnpm → platform default. Port from upstream config/reader/src/getCacheDir.ts. Reuse the existing EnvVar/Host DI seam established for default_store_dir.
  • pacquet/crates/config/src/workspace_yaml.rs: deserialize the new keys (camelCase yaml names).

Move + extend.

  • Move parse_exact_versions_union and expand_package_version_specs from pacquet/crates/package-manager/src/version_policy.rs into a new pacquet/crates/config/src/version_policy.rs. Leave a pub use re-export at the old site so allowBuilds callers don't break.
  • Add pub fn create_package_version_policy(patterns: &[String]) -> PackageVersionPolicy (port of upstream config/version-policy/src/index.ts). PackageVersionPolicy::matches(&self, name: &PkgName, version: &Version) -> PolicyMatch where PolicyMatch::{No, AnyVersion, ExactVersions(SmallVec<[Version; 1]>)}. Wildcards via pacquet_config::matcher::create_matcher; version unions via parse_exact_versions_union.

Promote chrono. Edit root Cargo.toml — move chrono from the dev section into [workspace.dependencies].

Tests. Yaml roundtrip for every new key; default_cache_dir() per-platform; port upstream's config/version-policy/test/index.ts.

Blocks. Phase 4. Blocked by. Nothing (independent of Phase 1).


Phase 3 — Packument metadata enrichment in pacquet-registry

Deliverable. Package / PackageVersion expose the fields the npm verifier needs. No new endpoints, no caching, no behavior change to existing fetch path.

Edits.

  • pacquet/crates/registry/src/package.rs:
    • Add pub time: Option<HashMap<String, String>> (per-version ISO timestamps).
    • Add pub modified: Option<String> (package-level last-modified).
  • pacquet/crates/registry/src/package_version.rs:
    • Add pub _npm_user: Option<NpmUser> with #[serde(rename = \"_npmUser\")]. NpmUser { trusted_publisher: Option<TrustedPublisher> }.
    • Extend PackageDistribution with attestations: Option<AttestationsDist> where AttestationsDist { provenance: Option<ProvenanceMeta>, url: Option<String> }. Match _npmUser.trustedPublisher and dist.attestations.provenance paths — what getTrustEvidence reads.

Tests. Fixture-based deserialize using real-shape packuments. One with full provenance, one without _npmUser, one with time missing. Each round-trips through serde.

Blocks. Phase 4. Blocked by. Nothing (independent of Phases 1 and 2).


Phase 4 — pacquet-resolving-npm-resolver crate

Deliverable. Working NpmResolutionVerifier that fetches the full packument on demand (no on-disk cache yet — Phase 5), evaluates both policies, returns ResolutionVerification. Not wired into install yet.

Create. Crate at pacquet/crates/resolving-npm-resolver/.

  • Cargo.toml: pacquet-resolving-resolver-base, pacquet-registry, pacquet-network, pacquet-config, pacquet-lockfile, node-semver, tokio (sync only), reqwest, serde, serde_json, derive_more, miette, chrono.
  • src/violation_codes.rs: 2 consts, verbatim from upstream's 12-line file.
  • src/create_npm_resolution_verifier.rs: constructor + NpmResolutionVerifier struct. Decompose like upstream:
    • pub fn create_npm_resolution_verifier(opts) -> Option<NpmResolutionVerifier>None when neither policy is active.
    • impl ResolutionVerifier for NpmResolutionVerifierverify short-circuits on non-registry resolutions.
    • Private helpers per layer: try_abbreviated_modified_shortcut, read_local_meta_time, fetch_attestation_time, fetch_full_meta_time.
    • policy() returns a per-install-built serde_json::Map with policy snapshot.
    • can_trust_past_check: loosened-can-trust-stricter on minimumReleaseAge; byte-exact match on exclude lists; exact match on trust policy + exclude + ignore-after.
  • src/trust_checks.rs: port trustChecks.ts:1-127. TRUST_RANK const (trusted_publisher=2, provenance=1). fail_if_trust_downgraded(meta, version, opts) -> Result<(), TrustViolation>. Prerelease-aware via node_semver::Version::is_prerelease().
  • src/fetch_attestation_published_at.rs: port. URL {registry}/-/npm/v1/attestations/{name}@{version}. Walk Sigstore bundles → pick earliest integratedTime → RFC3339 via chrono::DateTime::from_timestamp. Returns Option<String>.
  • src/fetch_full_metadata.rs: minimal no-cache fetcher ({registry}{name} with Accept: application/json).
  • src/named_registry.rs: port BUILTIN_NAMED_REGISTRIES + prefix-routing helper.
  • src/lookup_context.rs: per-install dedup caches (Arc<tokio::sync::Mutex<HashMap<...>>> for abbreviated_meta, published_at, local_meta, full_meta, full_meta_for_trust).

Tests. Per-scenario mockito tests for minimum-release-age (pass / fail / missing-time), trust-downgrade (publisher→provenance, provenance→unsigned, ignore-after, prerelease), attestation endpoint (hit / 404 / malformed), non-registry short-circuit, can_trust_past_check.

Blocks. Phases 5 and 6. Blocked by. Phases 1, 2, 3.


Phase 5 — On-disk packument mirror

Deliverable. fetch_full_metadata_cached with conditional GET against a mirror at <cache_dir>/v3/<full-meta-dir>/<scope>/<name>.json. Used by the verifier; regular resolver path untouched.

Edits. Inside pacquet/crates/resolving-npm-resolver/src/:

  • New fetch_full_metadata_cached.rs: port fetchFullMetadataCached.ts:30-99 and helpers from pickPackage.ts (get_pkg_mirror_path, load_meta, load_meta_headers, save_meta, prepare_json_for_disk). Conditional headers + 304 / 200 / 4xx-5xx fallback.
  • Update create_npm_resolution_verifier.rs to call fetch_full_metadata_cached instead of fetch_full_metadata.

Tests. Mockito: cold cache → 200 → mirror written; warm → 304 → mirror used; stale → 200 → overwrite; read-only cache dir → succeeds with debug-logged write failure.

Blocks. Phase 7. Blocked by. Phase 4.


Phase 6 — pacquet-lockfile-verification crate

Deliverable. End-to-end runner + JSONL cache + recorder. A test can call verify_lockfile_resolutions::<SilentReporter>(lockfile, &[verifier], &opts).await? and see violations, log events, and a fresh cache record on disk.

Create. Crate at pacquet/crates/lockfile-verification/.

  • Cargo.toml: pacquet-resolving-resolver-base, pacquet-lockfile, pacquet-reporter, pacquet-diagnostics, tokio (sync + time), futures-util, sha2, serde, serde_json, derive_more, miette. No reqwest / registry deps.
  • src/verify_lockfile_resolutions.rs: port verifyLockfileResolutions.ts:1-288.
    • Public fn pub async fn verify_lockfile_resolutions<R: Reporter>(lockfile, verifiers, opts) -> Result<(), VerifyError>.
    • Public sibling pub async fn collect_resolution_policy_violations(...) (data-returning).
    • collect_candidates(lockfile) iterates lockfile.packages + lockfile.snapshots, keys by (PkgName, version, serde_json::to_string(&resolution)) to dedupe peer-context / patch-hash duplicates.
    • Concurrency: tokio::sync::Semaphore::new(opts.concurrency.unwrap_or(16)) + futures_util::stream::iter(candidates).buffer_unordered(N). Per-candidate future acquires permit, fans across verifiers, stops at first failure per candidate.
    • Error build: sort by format!(\"{name}@{version}\"); cap at 20 visible; escalate to LOCKFILE_RESOLUTION_VERIFICATION when distinct codes > 1.
    • Reporter: emit Started before fan-out, Done or Failed after with elapsed_ms. Skip emit when candidate set is empty.
  • src/cache.rs: port verifyLockfileResolutionsCache.ts:1-428.
    • pub fn try_lockfile_verification_cache(cache_dir, key) -> CacheLookupResult.
    • pub fn record_verification(cache_dir, key, precomputed).
    • Constants: CACHE_FILE_NAME = \"lockfile-verified.jsonl\", MAX_CACHE_ENTRIES = 1000, COMPACT_TRIGGER_BYTES = 1_536_000.
    • Stat: MetadataExt::ino() on unix; 0 on Windows.
    • Compaction: dedupe by (path, hash), reverse-walk to keep newest, trim to MAX_CACHE_ENTRIES, write to <file>.<pid>.tmp then rename.
    • All IO is synchronous (std::fs::*).
  • src/record_lockfile_verified.rs: 5-line wrapper.
  • src/hash_lockfile.rs: pub fn hash_lockfile(lockfile: &Lockfile) -> String. Pacquet's Lockfile uses HashMap; normalize-into-BTreeMap inside hash_lockfile only (don't change the public type). Private Normalize<'a>(&'a Lockfile) newtype implements Serialize by reordering; serde_json::to_writer into Sha256; hex-format.
  • src/errors.rs: VerifyError enum — LockfileResolutionVerification (mixed-code batch), MinimumReleaseAgeViolation, TrustDowngrade. Use derive_more::Display + derive_more::Error + miette::Diagnostic. Help text: port upstream's hint verbatim.

Tests. Two layers — runner (empty list noop, ok-emit, single-violation, mixed-code escalation, 25-violations cap, dedup, concurrency cap) and cache (round-trip, stat-shortcut, content-hash hit, compaction at 1.5MB, concurrent append, policy trust comparison).

Blocks. Phase 7. Blocked by. Phases 1, 4.


Phase 7 — Wire-up in pacquet-package-manager::install

Deliverable. pacquet install --frozen-lockfile runs the gate. End-to-end: a fresh-published version + minimumReleaseAge = 14d in pnpm-workspace.yaml → install fails before any tarball is fetched.

Edits.

  • pacquet/crates/package-manager/Cargo.toml: add pacquet-lockfile-verification, pacquet-resolving-resolver-base, pacquet-resolving-npm-resolver.
  • pacquet/crates/package-manager/src/install.rs:
    • Around line 241 (after Lockfile::load_current_from_virtual_store_dir, before the pnpm:context emit at line 248): when lockfile.is_some(), build the verifier list from config. Use create_npm_resolution_verifier(...)None when neither policy is set; otherwise Some(verifier). Collect into Vec<Arc<dyn ResolutionVerifier>>.
    • Compute lockfile_path from Install::lockfile_path (new field) or derive workspace_root.join(\"pnpm-lock.yaml\").
    • Call verify_lockfile_resolutions::<R>(lockfile, &verifiers, &VerifyOptions { cache_dir: Some(&config.cache_dir), lockfile_path: Some(&lockfile_path), concurrency: None }).await.map_err(InstallError::LockfileVerification)?;.
    • The no-lockfile and lockfile.is_none() paths skip the gate.
  • Install struct: add pub lockfile_path: Option<&'a Path>. CLI fills it.
  • InstallError enum: add #[diagnostic(transparent)] LockfileVerification(#[error(source)] VerifyError). Transparent so the inner miette code is what the user sees.
  • pacquet/crates/cli/src/cli_args/install.rs: pass lockfile_path.

Tests.

  • pacquet/crates/package-manager/src/install/tests.rs: real-fixture TempDir + AddMockedRegistry. Mocked registry returns a packument where the locked version's publish time is < cutoff. Run Install::run::<SilentReporter>. Expect Err(InstallError::LockfileVerification(_)) and assert the violation code.
  • pacquet/crates/cli/tests/cli_lockfile_verification.rs (new): full CLI integration via CommandTempCwd. Assert non-zero exit + stderr contains MINIMUM_RELEASE_AGE_VIOLATION.
  • Recording-fake reporter test: emit order is LockfileVerification(Started) → LockfileVerification(Done) → Context → Stage(ImportingStarted) → ….

Blocks. Nothing. Blocked by. Phases 1, 2, 4, 5, 6.


Phase 8 — Docs + TEST_PORTING checklist (follow-up)

Deliverable. pacquet/plans/TEST_PORTING.md lists upstream tests this work mirrored. Diagnostic help text matches upstream byte-for-byte.

Edits.

  • pacquet/plans/TEST_PORTING.md: add entries for verifyLockfileResolutions.ts, verifyLockfileResolutionsCache.ts, recordLockfileVerified.ts, minimumReleaseAge.ts, createNpmResolutionVerifier.test.ts, trustChecks.test.ts, fetchAttestationPublishedAt.test.ts with line ranges and "ported" checkboxes.
  • pacquet/crates/diagnostics/...: insta snapshot of the rendered error for one violation, three (single-code), three (mixed).

Blocks. Nothing. Blocked by. Phase 7.

Verification (end-to-end)

After Phase 7 lands the full chain is usable. Verify with:

  1. just check — workspace compiles.
  2. cargo nextest run -p pacquet-resolving-resolver-base -p pacquet-resolving-npm-resolver -p pacquet-lockfile-verification — unit tests in new crates.
  3. cargo nextest run -p pacquet-package-manager install::tests::lockfile_verification — package-manager integration test.
  4. cargo nextest run -p pacquet-cli cli_lockfile_verification — CLI end-to-end through mocked registry.
  5. just lint--deny warnings still passes.
  6. just ready — full suite + clippy + fmt + typos.

Manual smoke:

  1. just registry-mock launch + a fixture project with minimumReleaseAge: 1d in pnpm-workspace.yaml and a lockfile entry resolving to a version the mock claims was published 1 hour ago. Run just cli -- install --frozen-lockfile. Confirm stderr names the offending package, exit code != 0.
  2. Repeat with minimumReleaseAge unset — install succeeds, no pnpm:lockfile-verification events fire.
  3. Re-run step 7 twice — second run takes the cache fast path (inspect <cache_dir>/lockfile-verified.jsonl).

Per-PR strategy

One PR per phase. Each PR ships:

  • Conventional-commits title scoped to the touched area: feat(resolving-resolver-base): port ResolutionVerifier trait, feat(config): add minimumReleaseAge and trustPolicy fields, feat(resolving-npm-resolver): port createNpmResolutionVerifier, etc.
  • Reference to the upstream PR / commit being ported.
  • Agent-authored footer.
  • Corresponding ported upstream tests.

Dependency graph:

  • Phases 1, 2, 3 are independent — can be parallel.
  • Phase 4 needs 1+2+3.
  • Phase 5 needs 4.
  • Phase 6 can land in parallel with Phase 5 (both depend on 4; 6 also depends on 1).
  • Phase 7 needs all of 1–6.
  • Phase 8 is follow-up.

Written by an agent (Claude Code, claude-opus-4-7).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions