feat(pacquet): port lockfile verification (minimumReleaseAge + trustPolicy) (#11729)

Ports pnpm's lockfile-verification gate to pacquet, tracking #11722. The gate re-applies the resolver's policy checks (`minimumReleaseAge`, `trustPolicy='no-downgrade'`) to every entry in `pnpm-lock.yaml` immediately after the lockfile is loaded, before any resolution or fetch happens — so a lockfile resolved elsewhere (committed to the repo, restored from CI cache, or generated by a tool that bypassed local policy) can't reach the filesystem under a weaker policy.

Three new crates and four extended ones:

- **`pacquet-resolving-resolver-base`** — `ResolutionVerifier` trait + types.
- **`pacquet-resolving-npm-resolver`** — `NpmResolutionVerifier`, trust-rank logic, attestation fetcher, cached full-metadata fetcher, mirror helpers (path / IO).
- **`pacquet-lockfile-verification`** — fan-out runner, in-memory lockfile hasher, JSONL stat-and-skip cache + `recordLockfileVerified` wrapper.
- `pacquet-config` — new `cacheDir`, `minimumReleaseAge*`, `trustPolicy*` fields + `TrustPolicy` enum + `createPackageVersionPolicy`. Defaults match upstream byte-for-byte: `minimumReleaseAge` defaults to `1440` minutes (24 h) and `minimumReleaseAgeIgnoreMissingTime` defaults to `true`.
- `pacquet-reporter` — `pnpm:lockfile-verification` channel.
- `pacquet-registry` — enriches `Package` / `PackageVersion` with `time`, `_npmUser.trustedPublisher`, `dist.attestations`, `modified`.
- `pacquet-package-manager::install` — wires the gate between lockfile load and the frozen-lockfile dispatch.

Phase-by-phase landing order matches the plan in #11722. Ports against upstream pnpm at commit `2a9bd897bf`.
This commit is contained in:
Zoltan Kochan
2026-05-18 23:32:12 +02:00
committed by GitHub
parent dfd8fbf709
commit cddf522cfa
70 changed files with 8557 additions and 327 deletions

63
Cargo.lock generated
View File

@@ -2028,6 +2028,7 @@ dependencies = [
"indexmap",
"libc",
"miette 7.6.0",
"node-semver",
"pacquet-network",
"pacquet-package-is-installable",
"pacquet-patching",
@@ -2162,6 +2163,29 @@ dependencies = [
"text-block-macros",
]
[[package]]
name = "pacquet-lockfile-verification"
version = "0.0.1"
dependencies = [
"chrono",
"derive_more",
"futures-util",
"insta",
"miette 7.6.0",
"pacquet-diagnostics",
"pacquet-lockfile",
"pacquet-reporter",
"pacquet-resolving-resolver-base",
"pretty_assertions",
"serde",
"serde-saphyr",
"serde_json",
"sha2",
"ssri",
"tempfile",
"tokio",
]
[[package]]
name = "pacquet-micro-benchmark"
version = "0.0.0"
@@ -2249,6 +2273,7 @@ dependencies = [
"pacquet-git-fetcher",
"pacquet-graph-hasher",
"pacquet-lockfile",
"pacquet-lockfile-verification",
"pacquet-modules-yaml",
"pacquet-network",
"pacquet-package-is-installable",
@@ -2258,6 +2283,8 @@ dependencies = [
"pacquet-registry",
"pacquet-registry-mock",
"pacquet-reporter",
"pacquet-resolving-npm-resolver",
"pacquet-resolving-resolver-base",
"pacquet-store-dir",
"pacquet-tarball",
"pacquet-testing-utils",
@@ -2367,6 +2394,42 @@ dependencies = [
"serde_json",
]
[[package]]
name = "pacquet-resolving-npm-resolver"
version = "0.0.1"
dependencies = [
"chrono",
"derive_more",
"miette 7.6.0",
"mockito",
"node-semver",
"pacquet-config",
"pacquet-lockfile",
"pacquet-network",
"pacquet-registry",
"pacquet-resolving-resolver-base",
"pipe-trait",
"pretty_assertions",
"reqwest",
"serde",
"serde_json",
"sha2",
"ssri",
"tempfile",
"tokio",
"tracing",
]
[[package]]
name = "pacquet-resolving-resolver-base"
version = "0.0.1"
dependencies = [
"pacquet-lockfile",
"serde_json",
"ssri",
"tokio",
]
[[package]]
name = "pacquet-store-dir"
version = "0.0.1"

View File

@@ -13,30 +13,33 @@ repository = "https://github.com/pnpm/pacquet"
[workspace.dependencies]
# Crates
pacquet-cli = { path = "pacquet/crates/cli" }
pacquet-cmd-shim = { path = "pacquet/crates/cmd-shim" }
pacquet-fs = { path = "pacquet/crates/fs" }
pacquet-registry = { path = "pacquet/crates/registry" }
pacquet-tarball = { path = "pacquet/crates/tarball" }
pacquet-testing-utils = { path = "pacquet/crates/testing-utils" }
pacquet-package-manifest = { path = "pacquet/crates/package-manifest" }
pacquet-package-manager = { path = "pacquet/crates/package-manager" }
pacquet-package-is-installable = { path = "pacquet/crates/package-is-installable" }
pacquet-lockfile = { path = "pacquet/crates/lockfile" }
pacquet-modules-yaml = { path = "pacquet/crates/modules-yaml" }
pacquet-network = { path = "pacquet/crates/network" }
pacquet-config = { path = "pacquet/crates/config" }
pacquet-executor = { path = "pacquet/crates/executor" }
pacquet-directory-fetcher = { path = "pacquet/crates/directory-fetcher" }
pacquet-git-fetcher = { path = "pacquet/crates/git-fetcher" }
pacquet-diagnostics = { path = "pacquet/crates/diagnostics" }
pacquet-graph-hasher = { path = "pacquet/crates/graph-hasher" }
pacquet-store-dir = { path = "pacquet/crates/store-dir" }
pacquet-reporter = { path = "pacquet/crates/reporter" }
pacquet-patching = { path = "pacquet/crates/patching" }
pacquet-real-hoist = { path = "pacquet/crates/real-hoist" }
pacquet-workspace = { path = "pacquet/crates/workspace" }
pacquet-workspace-state = { path = "pacquet/crates/workspace-state" }
pacquet-cli = { path = "pacquet/crates/cli" }
pacquet-cmd-shim = { path = "pacquet/crates/cmd-shim" }
pacquet-fs = { path = "pacquet/crates/fs" }
pacquet-registry = { path = "pacquet/crates/registry" }
pacquet-tarball = { path = "pacquet/crates/tarball" }
pacquet-testing-utils = { path = "pacquet/crates/testing-utils" }
pacquet-package-manifest = { path = "pacquet/crates/package-manifest" }
pacquet-package-manager = { path = "pacquet/crates/package-manager" }
pacquet-package-is-installable = { path = "pacquet/crates/package-is-installable" }
pacquet-lockfile = { path = "pacquet/crates/lockfile" }
pacquet-lockfile-verification = { path = "pacquet/crates/lockfile-verification" }
pacquet-modules-yaml = { path = "pacquet/crates/modules-yaml" }
pacquet-network = { path = "pacquet/crates/network" }
pacquet-config = { path = "pacquet/crates/config" }
pacquet-executor = { path = "pacquet/crates/executor" }
pacquet-directory-fetcher = { path = "pacquet/crates/directory-fetcher" }
pacquet-git-fetcher = { path = "pacquet/crates/git-fetcher" }
pacquet-diagnostics = { path = "pacquet/crates/diagnostics" }
pacquet-graph-hasher = { path = "pacquet/crates/graph-hasher" }
pacquet-store-dir = { path = "pacquet/crates/store-dir" }
pacquet-reporter = { path = "pacquet/crates/reporter" }
pacquet-patching = { path = "pacquet/crates/patching" }
pacquet-real-hoist = { path = "pacquet/crates/real-hoist" }
pacquet-resolving-npm-resolver = { path = "pacquet/crates/resolving-npm-resolver" }
pacquet-resolving-resolver-base = { path = "pacquet/crates/resolving-resolver-base" }
pacquet-workspace = { path = "pacquet/crates/workspace" }
pacquet-workspace-state = { path = "pacquet/crates/workspace-state" }
# Tasks
pacquet-registry-mock = { path = "pacquet/tasks/registry-mock" }
@@ -46,6 +49,7 @@ async-recursion = { version = "1.1.1" }
clap = { version = "4", features = ["derive", "string"] }
command-extra = { version = "1.0.0" }
base64 = { version = "0.22.1" }
chrono = { version = "0.4.44", default-features = false, features = ["clock"] }
dashmap = { version = "6.1.0" }
derive_more = { version = "2.1.1", features = ["full"] }
diffy = { version = "0.5.0" }
@@ -100,7 +104,6 @@ zune-inflate = { version = "0.2.54" }
# Dev dependencies
assert_cmd = { version = "2.2.1" }
chrono = { version = "0.4.44", default-features = false, features = ["clock"] }
criterion = { version = "0.8.2", features = ["async_tokio"] }
pretty_assertions = { version = "1.4.1" }
project-root = { version = "0.2.2" }

View File

@@ -104,12 +104,18 @@ impl AddArgs {
let supported_architectures =
self.supported_architectures.apply_to(config.supported_architectures.clone());
let lockfile_path = manifest
.path()
.parent()
.map(|parent| parent.join(pacquet_lockfile::Lockfile::FILE_NAME));
Add {
tarball_mem_cache,
http_client,
http_client_arc: std::sync::Arc::clone(http_client),
config,
manifest,
lockfile: lockfile.as_ref(),
lockfile_path: lockfile_path.as_deref(),
list_dependency_groups: || self.dependency_options.dependency_groups(),
package_name: &self.package_name,
save_exact: self.save_exact,

View File

@@ -165,12 +165,22 @@ impl InstallArgs {
// yaml/npmrc value for this invocation. Mirrors pnpm's
// override-on-explicit-flag semantics.
let node_linker = node_linker.map(NodeLinkerArg::into_config).unwrap_or(config.node_linker);
// The lockfile-verification gate keys its on-disk cache off
// `<manifest_dir>/pnpm-lock.yaml`. Once workspace support
// lands (pacquet#431), this becomes `workspace_root` to
// match where the lockfile actually lives.
let lockfile_path = manifest
.path()
.parent()
.map(|parent| parent.join(pacquet_lockfile::Lockfile::FILE_NAME));
Install {
tarball_mem_cache,
http_client,
http_client_arc: std::sync::Arc::clone(http_client),
config,
manifest,
lockfile: lockfile.as_ref(),
lockfile_path: lockfile_path.as_deref(),
dependency_groups: dependency_options.dependency_groups(),
frozen_lockfile,
skip_runtimes,

View File

@@ -13,8 +13,11 @@ use std::path::PathBuf;
pub struct State {
/// Shared cache that store downloaded tarballs.
pub tarball_mem_cache: MemCache,
/// HTTP client to make HTTP requests.
pub http_client: ThrottledClient,
/// HTTP client to make HTTP requests. Held behind [`std::sync::Arc`] so
/// the lockfile-verification gate can own a clone for the
/// `NpmResolutionVerifier`'s lifetime while every install
/// sub-pipeline takes a borrowed `&ThrottledClient` via deref.
pub http_client: std::sync::Arc<ThrottledClient>,
/// Merged runtime configuration: built-in defaults, with overlays from
/// the auth subset of `.npmrc` and from `pnpm-workspace.yaml`.
pub config: &'static Config,
@@ -62,12 +65,10 @@ impl State {
.map_err(InitStateError::Manifest)?,
lockfile: call_load_lockfile(should_load, Lockfile::load_from_current_dir)
.map_err(InitStateError::Lockfile)?,
http_client: ThrottledClient::for_installs(
&config.proxy,
&config.tls,
&config.tls_by_uri,
)
.map_err(InitStateError::Network)?,
http_client: std::sync::Arc::new(
ThrottledClient::for_installs(&config.proxy, &config.tls, &config.tls_by_uri)
.map_err(InitStateError::Network)?,
),
tarball_mem_cache: MemCache::new(),
resolved_packages: ResolvedPackages::new(),
})

View File

@@ -0,0 +1,95 @@
//! End-to-end CLI integration test for the lockfile-verification
//! gate ported in Phase 7. Spawns the `pacquet` binary against a
//! pnpm-workspace.yaml that activates the verifier and confirms the
//! gate fires through the real install path — non-zero exit, the
//! upstream-canonical diagnostic code in stderr.
//!
//! Doesn't try to exercise every branch — the unit tests in
//! `pacquet-lockfile-verification` and `pacquet-resolving-npm-resolver`
//! already do that. This file pins the user-visible contract: the
//! gate runs from the CLI, the install fails when policy is
//! tripped, and the error envelope carries the upstream code so
//! `pnpm errors` documentation routes to the right entry.
pub mod _utils;
pub use _utils::*;
use command_extra::CommandExtra;
use pacquet_testing_utils::bin::{AddMockedRegistry, CommandTempCwd};
use std::fs;
/// `minimumReleaseAge` set to 100 years rejects every version the
/// mocked registry has ever served. The install fails before any
/// tarball is fetched; stderr names the
/// `ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION` code so log consumers
/// and `pnpm errors` URL routing both work.
#[test]
fn install_fails_under_huge_minimum_release_age() {
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");
// The mocked registry's packument times are real-world (years
// old), so a `minimumReleaseAge` set in the millions of minutes
// catches every version regardless of when the mock was
// populated. The yaml entry shape matches upstream's
// pnpm-workspace.yaml settings keys byte-for-byte.
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");
// Hand-rolled minimal v9 lockfile pinning the same package the
// manifest above declares. The placeholder integrity is fine:
// the gate rejects the entry before the tarball is verified.
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");
assert!(
!output.status.success(),
"the gate must reject the install (stderr: {})",
String::from_utf8_lossy(&output.stderr),
);
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
assert!(
stderr.contains("ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION"),
"stderr must name the upstream-canonical diagnostic code; got:\n{stderr}",
);
// No `node_modules/.pnpm/...` slot must materialize for the
// gated package — proves the failure short-circuits before
// tarball fetch.
assert!(
!workspace.join("node_modules/.pnpm/@pnpm.e2e+hello-world-js-bin@1.0.0").exists(),
"the gate must fail before any virtual-store materialization",
);
drop((root, mock_instance));
}

View File

@@ -20,6 +20,7 @@ derive_more = { workspace = true }
home = { workspace = true }
indexmap = { workspace = true }
miette = { workspace = true }
node-semver = { workspace = true }
pipe-trait = { workspace = true }
serde = { workspace = true }
serde-saphyr = { workspace = true }

View File

@@ -123,6 +123,41 @@ pub fn default_modules_dir() -> PathBuf {
env::current_dir().expect("current directory is unavailable").join("node_modules")
}
/// Resolve the default packument-cache directory.
///
/// Port of pnpm's
/// [`getCacheDir`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/config/reader/src/dirs.ts#L4-L23).
/// Resolution order:
///
/// 1. `$XDG_CACHE_HOME/pnpm` — set on Linux desktops following the
/// XDG base-dir spec.
/// 2. macOS: `~/Library/Caches/pnpm`.
/// 3. Other non-Windows: `~/.cache/pnpm`.
/// 4. Windows: `%LOCALAPPDATA%/pnpm-cache`, falling back to
/// `~/.pnpm-cache` when `LOCALAPPDATA` is unset.
///
/// Generic over [`EnvVar`] and the home-dir closure for the same
/// reason as [`default_store_dir`]: unit tests drive every branch
/// without mutating the process environment. Production callers
/// pass [`crate::Host`] with `home::home_dir`.
pub fn default_cache_dir<Sys, HomeDir>(home_dir: HomeDir) -> PathBuf
where
Sys: EnvVar,
HomeDir: FnOnce() -> Option<PathBuf>,
{
if let Some(xdg_cache_home) = Sys::var("XDG_CACHE_HOME") {
return PathBuf::from(xdg_cache_home).join("pnpm");
}
let home_dir = home_dir().expect("Home directory is not available");
match env::consts::OS {
"macos" => home_dir.join("Library/Caches/pnpm"),
"windows" => Sys::var("LOCALAPPDATA")
.map(|local_app_data| PathBuf::from(local_app_data).join("pnpm-cache"))
.unwrap_or_else(|| home_dir.join(".pnpm-cache")),
_ => home_dir.join(".cache/pnpm"),
}
}
pub fn default_virtual_store_dir() -> PathBuf {
// TODO: find directory with package.json
env::current_dir().expect("current directory is unavailable").join("node_modules/.pnpm")

View File

@@ -1,6 +1,7 @@
use super::{
default_child_concurrency_with_parallelism, default_store_dir, default_unsafe_perm,
is_unsafe_perm_posix, resolve_child_concurrency, resolve_child_concurrency_with_parallelism,
default_cache_dir, default_child_concurrency_with_parallelism, default_store_dir,
default_unsafe_perm, is_unsafe_perm_posix, resolve_child_concurrency,
resolve_child_concurrency_with_parallelism,
};
use crate::api::EnvVar;
use pacquet_store_dir::StoreDir;
@@ -99,6 +100,50 @@ fn test_default_store_dir_falls_back_to_home_dir() {
assert_eq!(display_store_dir(&store_dir), expected);
}
/// `$XDG_CACHE_HOME/pnpm` wins the cache-dir resolution chain.
/// Mirrors upstream
/// [`getCacheDir`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/config/reader/src/dirs.ts#L4-L23):
/// `XDG_CACHE_HOME` is the first branch and takes precedence over
/// the platform-default fallback. Exercised through the same
/// dependency-injection seam `default_store_dir` uses — no
/// `std::env::set_var`, no `EnvGuard` lock. The home-dir closure
/// is `unreachable!` because the `XDG_CACHE_HOME` branch short-
/// circuits before it is consumed.
#[test]
fn test_default_cache_dir_with_xdg_cache_home_env() {
struct EnvWithXdgCacheHome;
impl EnvVar for EnvWithXdgCacheHome {
fn var(name: &str) -> Option<String> {
(name == "XDG_CACHE_HOME").then(|| "/tmp/xdg-cache-home".to_owned())
}
}
let cache_dir = default_cache_dir::<EnvWithXdgCacheHome, _>(|| {
unreachable!("home_dir must not be called when XDG_CACHE_HOME is set")
});
let display = cache_dir.display().to_string().replace('\\', "/");
assert_eq!(display, "/tmp/xdg-cache-home/pnpm");
}
/// Without `XDG_CACHE_HOME`, the resolver falls back to the
/// platform default. On macOS that's `~/Library/Caches/pnpm`; on
/// other Unix-y platforms it's `~/.cache/pnpm`. This test pins
/// those two branches together — the Windows branch needs
/// `LOCALAPPDATA` handling that's not portable here and is left to
/// manual / CI-based verification.
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[test]
fn test_default_cache_dir_falls_back_to_platform_default() {
use std::path::PathBuf;
let cache_dir = default_cache_dir::<NoEnv, _>(|| Some(PathBuf::from("/home/test-user")));
let expected = if cfg!(target_os = "macos") {
PathBuf::from("/home/test-user/Library/Caches/pnpm")
} else {
PathBuf::from("/home/test-user/.cache/pnpm")
};
assert_eq!(cache_dir, expected);
}
/// Port of upstream
/// [`'getDefaultWorkspaceConcurrency: cpu num < 4'`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/config/reader/src/concurrency.test.ts#L25-L28).
/// On a 1-core host, the default caps at 1 (not 4).

View File

@@ -3,6 +3,7 @@ mod defaults;
mod env_replace;
pub mod matcher;
mod npmrc_auth;
pub mod version_policy;
mod workspace_yaml;
pub use crate::api::{EnvVar, Host};
@@ -24,10 +25,11 @@ pub use crate::defaults::{
resolve_child_concurrency,
};
use crate::defaults::{
default_child_concurrency, default_enable_global_virtual_store, default_fetch_retries,
default_fetch_retry_factor, default_fetch_retry_maxtimeout, default_fetch_retry_mintimeout,
default_hoist_pattern, default_modules_cache_max_age, default_modules_dir,
default_public_hoist_pattern, default_registry, default_store_dir, default_virtual_store_dir,
default_cache_dir, default_child_concurrency, default_enable_global_virtual_store,
default_fetch_retries, default_fetch_retry_factor, default_fetch_retry_maxtimeout,
default_fetch_retry_mintimeout, default_hoist_pattern, default_modules_cache_max_age,
default_modules_dir, default_public_hoist_pattern, default_registry, default_store_dir,
default_virtual_store_dir,
};
pub use workspace_yaml::{
LoadWorkspaceYamlError, WORKSPACE_MANIFEST_FILENAME, WorkspaceSettings, workspace_root_or,
@@ -50,6 +52,25 @@ pub enum NodeLinker {
Pnp,
}
/// Supply-chain trust policy applied to lockfile entries.
///
/// Mirrors pnpm's
/// [`TrustPolicy`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/core/types/src/config.ts#L5)
/// (`'no-downgrade' | 'off'`) and drives the
/// `pacquet-resolving-npm-resolver` verifier: under
/// [`TrustPolicy::NoDowngrade`] the verifier rejects any version
/// whose trust evidence (`_npmUser.trustedPublisher` or
/// `dist.attestations.provenance`) is weaker than an earlier-published
/// version's. Defaults to [`TrustPolicy::Off`] so installs without an
/// explicit policy don't change behavior.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TrustPolicy {
#[default]
Off,
NoDowngrade,
}
/// Tri-state mirror of `pacquet_executor::ScriptsPrependNodePath`
/// with serde wiring. The executor crate keeps its own enum free of
/// serde so config concerns don't leak into the spawn-path. Converted
@@ -628,6 +649,87 @@ pub struct Config {
/// [`getOutdatedLockfileSetting.ts:58-60`](https://github.com/pnpm/pnpm/blob/94240bc046/lockfile/settings-checker/src/getOutdatedLockfileSetting.ts#L58-L60).
pub ignored_optional_dependencies: Option<Vec<String>>,
/// pnpm's packument cache directory. Used by the lockfile
/// verification gate to memoize past results in
/// `<cache_dir>/lockfile-verified.jsonl`, and by the npm verifier
/// to mirror full-metadata responses for conditional GETs.
///
/// Mirrors pnpm's
/// [`cacheDir`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/config/reader/src/Config.ts#L159);
/// the default resolution chain ports
/// [`getCacheDir`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/config/reader/src/dirs.ts#L4-L23).
#[default(_code = "default_cache_dir::<Host, _>(home::home_dir)")]
pub cache_dir: PathBuf,
/// Minimum age, in **minutes**, a published version must reach
/// before pacquet accepts it. Drives the
/// `MINIMUM_RELEASE_AGE_VIOLATION` verifier check on every
/// `(name, version)` entry the lockfile loads under this policy.
/// `None` disables the check entirely.
///
/// Default: `Some(1440)` (24 hours), matching upstream pnpm's
/// built-in at
/// [`config/reader/src/index.ts:176`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/config/reader/src/index.ts#L176).
/// Mirrors pnpm's
/// [`minimumReleaseAge`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/config/reader/src/Config.ts#L264)
/// in minutes — the same unit pnpm's CLI / yaml accept and pnpm
/// forwards verbatim through `extendInstallOptions` to the
/// verifier.
#[default(_code = "Some(24 * 60)")]
pub minimum_release_age: Option<u64>,
/// Glob-style `name[@version]` patterns that opt specific packages
/// out of the [`minimum_release_age`] check. Empty / `None` means
/// no exclusions. Mirrors pnpm's
/// [`minimumReleaseAgeExclude`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/config/reader/src/Config.ts#L265).
///
/// [`minimum_release_age`]: Self::minimum_release_age
pub minimum_release_age_exclude: Option<Vec<String>>,
/// When the registry's metadata lacks the per-version `time`
/// field (some self-hosted registries strip it), the verifier
/// cannot enforce the maturity cutoff. With this flag set,
/// uncheckable entries pass with a one-time `globalWarn` instead
/// of failing closed. Mirrors pnpm's
/// [`minimumReleaseAgeIgnoreMissingTime`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/config/reader/src/Config.ts#L266),
/// which defaults to `true` so a registry that strips `time`
/// (a self-hosted Verdaccio without provenance plugin, for
/// example) doesn't lock the user out.
#[default = true]
pub minimum_release_age_ignore_missing_time: bool,
/// When `true`, picks fresher-than-cutoff versions still abort
/// rather than auto-collect into [`Self::minimum_release_age_exclude`].
/// Used by the resolver path; the verifier itself does not gate
/// on this flag. Mirrors pnpm's
/// [`minimumReleaseAgeStrict`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/config/reader/src/Config.ts#L267).
///
/// Upstream conditional default: `true` when
/// `minimumReleaseAge` is explicitly configured, `false`
/// otherwise. Modeled as [`Option`] here so the deserializer can
/// distinguish "unset" from "explicit `false`"; the install path
/// resolves the effective value via
/// [`Self::resolved_minimum_release_age_strict`].
pub minimum_release_age_strict: Option<bool>,
/// Trust-evidence policy applied to lockfile entries; see
/// [`TrustPolicy`].
pub trust_policy: TrustPolicy,
/// Glob-style `name[@version]` patterns that opt specific packages
/// out of the [`trust_policy`] check. Mirrors pnpm's
/// [`trustPolicyExclude`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/config/reader/src/Config.ts#L271).
///
/// [`trust_policy`]: Self::trust_policy
pub trust_policy_exclude: Option<Vec<String>>,
/// Cutoff in minutes after which the trust check skips a
/// version that's old enough — once a package has been published
/// for long enough, the supply-chain assumption is that any
/// downgrade would have already surfaced. Mirrors pnpm's
/// [`trustPolicyIgnoreAfter`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/config/reader/src/Config.ts#L272).
pub trust_policy_ignore_after: Option<u64>,
/// Per-registry `Authorization` header lookup, populated from
/// `.npmrc` auth keys (`_auth`, `_authToken`, `username`/`_password`,
/// scoped variants). Threaded through the network and tarball
@@ -641,6 +743,26 @@ impl Config {
Self::default()
}
/// Effective value of [`Self::minimum_release_age_strict`].
/// Returns the user-supplied value when set, else `false`.
///
/// Upstream pnpm flips this to `true` when the user *explicitly*
/// set `minimumReleaseAge` (see
/// [`config/reader/src/index.ts`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/config/reader/src/index.ts)'s
/// post-parse hook), but the "explicitly set vs default" check
/// relies on the `explicitlySetKeys` tracker pnpm's reader
/// maintains, which pacquet's config layer doesn't have yet.
/// Without that, distinguishing the built-in 1440-minute default
/// from a user-typed `minimumReleaseAge: 1440` isn't possible,
/// so this resolver stays conservative: explicit `true` /
/// `false` from yaml wins, otherwise `false`. The verifier
/// itself doesn't gate on this flag — it's resolver-only — so
/// the conservative default is dormant until pacquet ports the
/// resolver and the `explicitlySetKeys` mechanism alongside it.
pub fn resolved_minimum_release_age_strict(&self) -> bool {
self.minimum_release_age_strict.unwrap_or(false)
}
/// Whether the install should consult the side-effects cache.
/// Mirrors upstream's
/// [`sideEffectsCacheRead = sideEffectsCache ?? sideEffectsCacheReadonly`](https://github.com/pnpm/pnpm/blob/7e3145f9fc/config/reader/src/index.ts#L614).

View File

@@ -0,0 +1,254 @@
//! Parse `<name>[@<version>[||<version>...]]` specs from
//! `pnpm-workspace.yaml`'s `allowBuilds`, `minimumReleaseAgeExclude`,
//! `trustPolicyExclude`, and similar policy keys.
//!
//! Ports the relevant halves of upstream's
//! [`config/version-policy`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/config/version-policy/src/index.ts):
//!
//! - [`expand_package_version_specs`] expands every spec into one or
//! more literal `name` / `name@version` strings, matching upstream's
//! `expandPackageVersionSpecs`. Used by `allowBuilds`.
//! - [`create_package_version_policy`] returns a matcher-based policy
//! that evaluates a `pkg_name` against a set of rules, mirroring
//! upstream's `createPackageVersionPolicy`. Used by
//! `minimumReleaseAgeExclude` and `trustPolicyExclude` — wildcards
//! in the name (`is-*`, `@scope/*`) match real package names via the
//! shared [`crate::matcher`].
//!
//! What this module supports:
//!
//! - Bare name → `foo`, `@scope/foo`.
//! - Exact version → `foo@1.0.0`, `@scope/foo@1.0.0`.
//! - Exact-version union → `foo@1.0.0 || 2.0.0`. Each version is
//! parsed strictly (upstream uses `semver.valid`); whitespace
//! around `||` and within versions is trimmed.
//! - Wildcards in the name **without** a version part —
//! [`expand_package_version_specs`] keeps them verbatim (matches
//! upstream's `.has()` semantics where the literal lands in the
//! `Set`), and [`create_package_version_policy`] runs them through
//! [`crate::matcher`] so they match real package names.
//!
//! Combining a `*` wildcard in the name with a version part is
//! explicitly rejected as
//! [`VersionPolicyError::NamePatternInVersionUnion`].
use crate::matcher::{Matcher, create_matcher};
use derive_more::{Display, Error};
use miette::Diagnostic;
use node_semver::Version;
use std::collections::HashSet;
/// Error from [`expand_package_version_specs`] or
/// [`create_package_version_policy`]. Mirrors the two upstream codes
/// at <https://github.com/pnpm/pnpm/blob/2a9bd897bf/config/version-policy/src/index.ts#L67-L75>.
#[derive(Debug, Display, Error, Diagnostic)]
pub enum VersionPolicyError {
/// One of the versions in a `||` union didn't parse as a valid
/// exact semver. Mirrors upstream's
/// [`ERR_PNPM_INVALID_VERSION_UNION`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/config/version-policy/src/index.ts#L67-L69).
#[display("Invalid versions union. Found: \"{pattern}\". Use exact versions only.")]
#[diagnostic(code(ERR_PNPM_INVALID_VERSION_UNION))]
InvalidVersionUnion {
#[error(not(source))]
pattern: String,
},
/// A `*` wildcard in the package name AND a version part were
/// combined. Upstream rejects this because the resulting matcher
/// would have inconsistent semantics with the rest of the rule
/// set. Mirrors
/// [`ERR_PNPM_NAME_PATTERN_IN_VERSION_UNION`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/config/version-policy/src/index.ts#L73-L75).
#[display("Name patterns are not allowed with version unions. Found: \"{pattern}\"")]
#[diagnostic(code(ERR_PNPM_NAME_PATTERN_IN_VERSION_UNION))]
NamePatternInVersionUnion {
#[error(not(source))]
pattern: String,
},
}
/// Expand each spec into one or more `name` / `name@version` literal
/// strings.
///
/// Output shape:
///
/// - Bare `foo` → `{"foo"}`.
/// - `foo@1.0.0` → `{"foo@1.0.0"}`.
/// - `foo@1.0.0 || 2.0.0` → `{"foo@1.0.0", "foo@2.0.0"}`.
/// - `@scope/foo@1.0.0` → `{"@scope/foo@1.0.0"}`.
///
/// Ports upstream's
/// [`expandPackageVersionSpecs`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/config/version-policy/src/index.ts#L59-L72).
/// Callers feed the result into a `HashSet::contains` check, so a
/// pattern like `is-*` lands in the set as the literal string
/// `"is-*"` and never matches a real package name (matches upstream
/// behavior exactly — see `should not allow patterns in allowBuilds`
/// in `building/policy/test/index.ts`).
pub fn expand_package_version_specs<Iter, Spec>(
specs: Iter,
) -> Result<HashSet<String>, VersionPolicyError>
where
Iter: IntoIterator<Item = Spec>,
Spec: AsRef<str>,
{
let mut out: HashSet<String> = HashSet::new();
for spec in specs {
let parsed = parse_version_policy_rule(spec.as_ref())?;
if parsed.exact_versions.is_empty() {
out.insert(parsed.package_name.to_string());
} else {
for version in parsed.exact_versions {
out.insert(format!("{}@{}", parsed.package_name, version));
}
}
}
Ok(out)
}
/// Decision a [`PackageVersionPolicy`] reaches for a given package name.
/// Mirrors upstream's `boolean | string[]` return shape at
/// [`evaluateVersionPolicy`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/config/version-policy/src/index.ts#L74-L85).
///
/// - [`PolicyMatch::No`] — no rule matched the name (upstream's `false`).
/// - [`PolicyMatch::AnyVersion`] — a bare-name rule matched (upstream's
/// `true`). Every version of the package is covered.
/// - [`PolicyMatch::ExactVersions`] — a name+version rule matched. Only
/// the listed versions are covered.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PolicyMatch {
No,
AnyVersion,
ExactVersions(Vec<String>),
}
/// Matcher-based version policy built from a list of
/// `<name-pattern>[@<version>||<version>...]` rules. Rules are walked
/// in order; the first whose name matcher accepts the input package
/// name wins. Mirrors upstream's [`createPackageVersionPolicy`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/config/version-policy/src/index.ts#L6-L13).
///
/// Used by `minimumReleaseAgeExclude` and `trustPolicyExclude`, both
/// of which need wildcard name patterns (`is-*`, `@scope/*`) AND
/// exact version unions (`lodash@4.17.21 || 4.17.22`) — different from
/// `allowBuilds`, which lands as a literal set via
/// [`expand_package_version_specs`].
pub struct PackageVersionPolicy {
rules: Vec<VersionPolicyRule>,
}
impl std::fmt::Debug for PackageVersionPolicy {
// `Matcher` doesn't expose its compiled pattern set, so the
// most useful thing the debug rendering can show is the rule
// count and each rule's exact-versions list.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PackageVersionPolicy")
.field("rules", &self.rules.iter().map(|rule| &rule.exact_versions).collect::<Vec<_>>())
.finish()
}
}
struct VersionPolicyRule {
name_matcher: Matcher,
exact_versions: Vec<String>,
}
impl PackageVersionPolicy {
/// Evaluate the policy against a package name. Returns the
/// matching rule's payload (`AnyVersion` for a bare-name rule,
/// `ExactVersions` for `name@versions...`), or `PolicyMatch::No`
/// when no rule matched.
pub fn matches(&self, pkg_name: &str) -> PolicyMatch {
for rule in &self.rules {
if !rule.name_matcher.matches(pkg_name) {
continue;
}
return if rule.exact_versions.is_empty() {
PolicyMatch::AnyVersion
} else {
PolicyMatch::ExactVersions(rule.exact_versions.clone())
};
}
PolicyMatch::No
}
}
/// Compile a list of `<name-pattern>[@<version>||<version>...]` rules
/// into a [`PackageVersionPolicy`]. Port of upstream's
/// [`createPackageVersionPolicy`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/config/version-policy/src/index.ts#L6-L13).
///
/// Errors mirror upstream:
///
/// - A `||` union that contains a non-semver value →
/// [`VersionPolicyError::InvalidVersionUnion`].
/// - A `*` wildcard in the name combined with a version part →
/// [`VersionPolicyError::NamePatternInVersionUnion`].
pub fn create_package_version_policy<Iter, Spec>(
patterns: Iter,
) -> Result<PackageVersionPolicy, VersionPolicyError>
where
Iter: IntoIterator<Item = Spec>,
Spec: AsRef<str>,
{
let mut rules: Vec<VersionPolicyRule> = Vec::new();
for pattern in patterns {
let parsed = parse_version_policy_rule(pattern.as_ref())?;
// [`create_matcher`] takes a slice of patterns; we pass a single
// entry per rule so the rule's own matcher returns true on a
// name hit and falls through otherwise.
let name_matcher = create_matcher(&[parsed.package_name.to_string()]);
rules.push(VersionPolicyRule { name_matcher, exact_versions: parsed.exact_versions });
}
Ok(PackageVersionPolicy { rules })
}
/// Parsed `<name>[@<version-union>]` rule. Either `exact_versions`
/// is empty (bare name) or it contains one or more concrete semver
/// strings. Mixing a `*` wildcard in the name with a version part
/// is rejected by [`parse_version_policy_rule`] before this struct
/// is returned.
struct ParsedRule<'a> {
package_name: &'a str,
exact_versions: Vec<String>,
}
fn parse_version_policy_rule(pattern: &str) -> Result<ParsedRule<'_>, VersionPolicyError> {
// Scoped name (`@scope/foo`) starts with `@`, so the version
// separator is the *second* `@`. Otherwise the first.
let at_index = if pattern.starts_with('@') {
pattern.char_indices().skip(1).find_map(|(i, c)| (c == '@').then_some(i))
} else {
pattern.find('@')
};
let Some(at) = at_index else {
return Ok(ParsedRule { package_name: pattern, exact_versions: Vec::new() });
};
let package_name = &pattern[..at];
let versions_part = &pattern[at + 1..];
let exact_versions = parse_exact_versions_union(versions_part)
.ok_or_else(|| VersionPolicyError::InvalidVersionUnion { pattern: pattern.to_string() })?;
if package_name.contains('*') {
return Err(VersionPolicyError::NamePatternInVersionUnion { pattern: pattern.to_string() });
}
Ok(ParsedRule { package_name, exact_versions })
}
/// Parse `v1 || v2 || …` into a list of strict semver versions.
/// Returns `None` if any component fails to parse — the caller
/// surfaces that as `ERR_PNPM_INVALID_VERSION_UNION`. Whitespace
/// around `||` and around each version is trimmed before parsing
/// (matches Node-semver's `valid()` which trims internally).
fn parse_exact_versions_union(versions_str: &str) -> Option<Vec<String>> {
let mut out: Vec<String> = Vec::new();
for raw in versions_str.split("||") {
let trimmed = raw.trim();
let version = Version::parse(trimmed).ok()?;
out.push(version.to_string());
}
Some(out)
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,218 @@
use crate::version_policy::{
PolicyMatch, VersionPolicyError, create_package_version_policy, expand_package_version_specs,
};
use pretty_assertions::assert_eq;
fn expand(specs: &[&str]) -> Vec<String> {
let mut out: Vec<String> =
expand_package_version_specs(specs.iter().copied()).unwrap().into_iter().collect();
out.sort();
out
}
#[test]
fn bare_name_expands_verbatim() {
assert_eq!(expand(&["foo"]), vec!["foo".to_string()]);
}
#[test]
fn scoped_bare_name_expands_verbatim() {
assert_eq!(expand(&["@scope/foo"]), vec!["@scope/foo".to_string()]);
}
#[test]
fn name_at_exact_version_expands_to_one_literal() {
assert_eq!(expand(&["foo@1.0.0"]), vec!["foo@1.0.0".to_string()]);
}
#[test]
fn scoped_name_at_exact_version_expands_to_one_literal() {
assert_eq!(expand(&["@scope/foo@1.2.3"]), vec!["@scope/foo@1.2.3".to_string()]);
}
/// Mirrors the upstream test case
/// [`'should allowBuilds with true value'`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/building/policy/test/index.ts#L5-L15)
/// — the spec `qar@1.0.0 || 2.0.0` expands into two literal
/// `qar@1.0.0` and `qar@2.0.0` entries.
#[test]
fn version_union_expands_into_separate_literals() {
let result = expand(&["qar@1.0.0 || 2.0.0"]);
assert_eq!(result, vec!["qar@1.0.0".to_string(), "qar@2.0.0".to_string()]);
}
#[test]
fn version_union_trims_whitespace_around_each_version() {
// Extra whitespace around `||` and around each version. Mirrors
// semver-js's `valid()` which trims internally before parsing.
let result = expand(&["foo@ 1.0.0 || 2.0.0 "]);
assert_eq!(result, vec!["foo@1.0.0".to_string(), "foo@2.0.0".to_string()]);
}
/// Mirrors upstream's
/// [`'should not allow patterns in allowBuilds'`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/building/policy/test/index.ts#L28-L34)
/// — a pattern with `*` in the name lands in the expanded set as
/// the literal string `is-*`. Downstream callers use
/// `HashSet::contains` (mirroring upstream's `.has()`), so a real
/// package name like `is-odd` does NOT match `is-*`. The expansion
/// itself doesn't error.
#[test]
fn name_with_wildcard_alone_is_kept_verbatim() {
assert_eq!(expand(&["is-*"]), vec!["is-*".to_string()]);
}
/// Combining a wildcard in the name with a version part is
/// explicitly an error (`ERR_PNPM_NAME_PATTERN_IN_VERSION_UNION`).
/// Mirrors upstream
/// [`config/version-policy/src/index.ts:73-75`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/config/version-policy/src/index.ts#L73-L75).
#[test]
fn wildcard_name_with_version_errors() {
let err = expand_package_version_specs(["foo*@1.0.0"]).expect_err("must reject");
assert!(matches!(err, VersionPolicyError::NamePatternInVersionUnion { .. }), "got: {err:?}");
}
/// Mirrors upstream
/// [`config/version-policy/src/index.ts:67-69`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/config/version-policy/src/index.ts#L67-L69)
/// — a `||` member that isn't valid semver triggers
/// `ERR_PNPM_INVALID_VERSION_UNION`.
#[test]
fn non_semver_version_in_union_errors() {
let err = expand_package_version_specs(["foo@not-a-version"]).expect_err("must reject");
assert!(matches!(err, VersionPolicyError::InvalidVersionUnion { .. }), "got: {err:?}");
}
#[test]
fn mixed_valid_invalid_union_errors() {
let err =
expand_package_version_specs(["foo@1.0.0 || not-a-version"]).expect_err("must reject");
assert!(matches!(err, VersionPolicyError::InvalidVersionUnion { .. }), "got: {err:?}");
}
#[test]
fn empty_input_yields_empty_set() {
let result = expand_package_version_specs::<_, &str>([]).unwrap();
assert!(result.is_empty());
}
#[test]
fn duplicate_specs_collapse_in_set() {
let result = expand(&["foo", "foo", "foo@1.0.0 || 1.0.0"]);
assert_eq!(result, vec!["foo".to_string(), "foo@1.0.0".to_string()]);
}
// ─── create_package_version_policy ────────────────────────────────────
//
// Ports the upstream test cases at
// <https://github.com/pnpm/pnpm/blob/2a9bd897bf/config/version-policy/test/index.ts#L8-L55>.
/// Exact-version rule: matching name returns the version list,
/// non-matching name returns `No`. Upstream:
/// `expect(match('axios')).toStrictEqual(['1.12.2'])`.
#[test]
fn create_policy_exact_version_match_returns_versions() {
let policy = create_package_version_policy(["axios@1.12.2"]).unwrap();
assert_eq!(policy.matches("axios"), PolicyMatch::ExactVersions(vec!["1.12.2".to_string()]));
assert_eq!(policy.matches("is-odd"), PolicyMatch::No);
}
/// Wildcard name rule (no version): every matching name returns
/// `AnyVersion`; non-matching names return `No`. Upstream:
/// `expect(match('is-odd')).toBe(true)`.
#[test]
fn create_policy_wildcard_name_matches_via_matcher() {
let policy = create_package_version_policy(["is-*"]).unwrap();
assert_eq!(policy.matches("is-odd"), PolicyMatch::AnyVersion);
assert_eq!(policy.matches("is-even"), PolicyMatch::AnyVersion);
assert_eq!(policy.matches("lodash"), PolicyMatch::No);
}
/// Scoped name with exact version. Upstream:
/// `expect(match('@babel/core')).toStrictEqual(['7.20.0'])`.
#[test]
fn create_policy_scoped_name_at_exact_version() {
let policy = create_package_version_policy(["@babel/core@7.20.0"]).unwrap();
assert_eq!(
policy.matches("@babel/core"),
PolicyMatch::ExactVersions(vec!["7.20.0".to_string()]),
);
}
/// Scoped bare name returns `AnyVersion`. Upstream:
/// `expect(match('@babel/core')).toBe(true)`.
#[test]
fn create_policy_scoped_bare_name_returns_any_version() {
let policy = create_package_version_policy(["@babel/core"]).unwrap();
assert_eq!(policy.matches("@babel/core"), PolicyMatch::AnyVersion);
}
/// Multiple rules: the first matching rule wins. Upstream:
/// `expect(match('axios')).toStrictEqual(['1.12.2'])` for a list
/// containing `axios@1.12.2`, `lodash@4.17.21`, `is-*`.
#[test]
fn create_policy_first_matching_rule_wins() {
let policy = create_package_version_policy(["axios@1.12.2", "lodash@4.17.21", "is-*"]).unwrap();
assert_eq!(policy.matches("axios"), PolicyMatch::ExactVersions(vec!["1.12.2".to_string()]));
assert_eq!(policy.matches("lodash"), PolicyMatch::ExactVersions(vec!["4.17.21".to_string()]));
assert_eq!(policy.matches("is-odd"), PolicyMatch::AnyVersion);
}
/// Non-exact semver in a name@version rule errors. Upstream:
/// `expect(() => createPackageVersionPolicy(['lodash@^4.17.0']))
/// .toThrow(/Invalid versions union/)`.
#[test]
fn create_policy_range_specifier_in_version_errors() {
let err = create_package_version_policy(["lodash@^4.17.0"]).expect_err("must reject");
assert!(matches!(err, VersionPolicyError::InvalidVersionUnion { .. }), "got: {err:?}");
let err = create_package_version_policy(["lodash@~4.17.0"]).expect_err("must reject");
assert!(matches!(err, VersionPolicyError::InvalidVersionUnion { .. }), "got: {err:?}");
let err = create_package_version_policy(["react@>=18.0.0"]).expect_err("must reject");
assert!(matches!(err, VersionPolicyError::InvalidVersionUnion { .. }), "got: {err:?}");
}
/// Wildcard + version combo errors. Upstream:
/// `expect(() => createPackageVersionPolicy(['is-*@1.0.0']))
/// .toThrow(/Name patterns are not allowed/)`.
#[test]
fn create_policy_wildcard_with_version_errors() {
let err = create_package_version_policy(["is-*@1.0.0"]).expect_err("must reject");
assert!(matches!(err, VersionPolicyError::NamePatternInVersionUnion { .. }), "got: {err:?}");
}
/// Version unions on the unscoped path. Upstream:
/// `expect(match('axios')).toStrictEqual(['1.12.0', '1.12.1'])` for
/// `axios@1.12.0 || 1.12.1`.
#[test]
fn create_policy_version_union_unscoped() {
let policy = create_package_version_policy(["axios@1.12.0 || 1.12.1"]).unwrap();
assert_eq!(
policy.matches("axios"),
PolicyMatch::ExactVersions(vec!["1.12.0".to_string(), "1.12.1".to_string()]),
);
}
/// Version unions on the scoped path. Upstream:
/// `expect(match('@scope/pkg')).toStrictEqual(['1.0.0', '1.0.1'])`.
#[test]
fn create_policy_version_union_scoped() {
let policy = create_package_version_policy(["@scope/pkg@1.0.0 || 1.0.1"]).unwrap();
assert_eq!(
policy.matches("@scope/pkg"),
PolicyMatch::ExactVersions(vec!["1.0.0".to_string(), "1.0.1".to_string()]),
);
}
/// Three-version union with non-standard whitespace. Upstream:
/// `expect(match('pkg')).toStrictEqual(['1.0.0', '1.0.1', '1.0.2'])`.
#[test]
fn create_policy_version_union_handles_whitespace() {
let policy = create_package_version_policy(["pkg@1.0.0||1.0.1 || 1.0.2"]).unwrap();
assert_eq!(
policy.matches("pkg"),
PolicyMatch::ExactVersions(vec![
"1.0.0".to_string(),
"1.0.1".to_string(),
"1.0.2".to_string(),
]),
);
}

View File

@@ -1,5 +1,6 @@
use crate::{
Config, NodeLinker, PackageImportMethod, ScriptsPrependNodePath, resolve_child_concurrency,
Config, NodeLinker, PackageImportMethod, ScriptsPrependNodePath, TrustPolicy,
resolve_child_concurrency,
};
use derive_more::{Display, Error};
use indexmap::IndexMap;
@@ -223,6 +224,34 @@ pub struct WorkspaceSettings {
/// and the lockfile-side drift check at
/// [`getOutdatedLockfileSetting.ts:58-60`](https://github.com/pnpm/pnpm/blob/94240bc046/lockfile/settings-checker/src/getOutdatedLockfileSetting.ts#L58-L60).
pub ignored_optional_dependencies: Option<Vec<String>>,
/// `cacheDir` from `pnpm-workspace.yaml`. Resolved against the
/// workspace dir like the other path-valued fields. Drives
/// the lockfile-verified JSONL cache + packument mirror used
/// by the verifier.
pub cache_dir: Option<String>,
/// `minimumReleaseAge` from `pnpm-workspace.yaml`. Milliseconds;
/// see [`Config::minimum_release_age`].
pub minimum_release_age: Option<u64>,
/// `minimumReleaseAgeExclude` from `pnpm-workspace.yaml`.
pub minimum_release_age_exclude: Option<Vec<String>>,
/// `minimumReleaseAgeIgnoreMissingTime` from `pnpm-workspace.yaml`.
pub minimum_release_age_ignore_missing_time: Option<bool>,
/// `minimumReleaseAgeStrict` from `pnpm-workspace.yaml`.
pub minimum_release_age_strict: Option<bool>,
/// `trustPolicy` from `pnpm-workspace.yaml`. See [`TrustPolicy`].
pub trust_policy: Option<TrustPolicy>,
/// `trustPolicyExclude` from `pnpm-workspace.yaml`.
pub trust_policy_exclude: Option<Vec<String>>,
/// `trustPolicyIgnoreAfter` from `pnpm-workspace.yaml`. Minutes.
pub trust_policy_ignore_after: Option<u64>,
}
/// Basename of the file pnpm reads; exported for test use.
@@ -409,6 +438,30 @@ impl WorkspaceSettings {
if let Some(v) = self.ignored_optional_dependencies {
config.ignored_optional_dependencies = Some(v);
}
if let Some(v) = self.cache_dir {
config.cache_dir = resolve(base_dir, &v);
}
if let Some(v) = self.minimum_release_age {
config.minimum_release_age = Some(v);
}
if let Some(v) = self.minimum_release_age_exclude {
config.minimum_release_age_exclude = Some(v);
}
if let Some(v) = self.minimum_release_age_ignore_missing_time {
config.minimum_release_age_ignore_missing_time = v;
}
if let Some(v) = self.minimum_release_age_strict {
config.minimum_release_age_strict = Some(v);
}
if let Some(v) = self.trust_policy {
config.trust_policy = v;
}
if let Some(v) = self.trust_policy_exclude {
config.trust_policy_exclude = Some(v);
}
if let Some(v) = self.trust_policy_ignore_after {
config.trust_policy_ignore_after = Some(v);
}
}
}

View File

@@ -1,5 +1,5 @@
use super::{LoadWorkspaceYamlError, WORKSPACE_MANIFEST_FILENAME, WorkspaceSettings};
use crate::{Config, NodeLinker, ScriptsPrependNodePath};
use crate::{Config, NodeLinker, ScriptsPrependNodePath, TrustPolicy};
use pacquet_store_dir::StoreDir;
use pipe_trait::Pipe;
use pretty_assertions::assert_eq;
@@ -713,3 +713,75 @@ fn omitting_hoisting_limits_and_external_dependencies_keeps_defaults() {
assert!(config.hoisting_limits.is_empty());
assert!(config.external_dependencies.is_empty());
}
/// Lockfile-verification policy keys all live in `pnpm-workspace.yaml`
/// alongside the rest of the install settings. This test asserts the
/// camelCase rename + `apply_to` wiring for every new field
/// introduced by the gate: `cacheDir` (path-resolved against the
/// workspace dir), `minimumReleaseAge` / `…Exclude` / `…Strict` /
/// `…IgnoreMissingTime`, and `trustPolicy` / `…Exclude` /
/// `…IgnoreAfter`. Mirrors the upstream key list at
/// <https://github.com/pnpm/pnpm/blob/2a9bd897bf/config/reader/src/Config.ts#L264-L272>.
#[test]
fn parses_supply_chain_policy_settings_from_yaml_and_applies() {
let yaml = r#"
cacheDir: ./.pacquet-cache
minimumReleaseAge: 1440
minimumReleaseAgeExclude:
- lodash
- "is-*"
minimumReleaseAgeIgnoreMissingTime: true
minimumReleaseAgeStrict: true
trustPolicy: no-downgrade
trustPolicyExclude:
- "@scope/legacy"
trustPolicyIgnoreAfter: 525600
"#;
let settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap();
assert_eq!(settings.cache_dir.as_deref(), Some("./.pacquet-cache"));
assert_eq!(settings.minimum_release_age, Some(1440));
assert_eq!(
settings.minimum_release_age_exclude.as_deref(),
Some(&["lodash".to_string(), "is-*".to_string()][..]),
);
assert_eq!(settings.minimum_release_age_ignore_missing_time, Some(true));
assert_eq!(settings.minimum_release_age_strict, 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));
let mut config = Config::new();
settings.apply_to(&mut config, Path::new("/proj"));
assert_eq!(config.cache_dir, Path::new("/proj/.pacquet-cache"));
assert_eq!(config.minimum_release_age, Some(1440));
assert_eq!(
config.minimum_release_age_exclude.as_deref(),
Some(&["lodash".to_string(), "is-*".to_string()][..]),
);
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_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));
}
/// `trustPolicy` accepts the two upstream string values; an absent
/// key leaves the [`TrustPolicy::Off`] default in place.
#[test]
fn trust_policy_yaml_values_round_trip() {
let yaml = "trustPolicy: off\n";
let settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap();
assert_eq!(settings.trust_policy, Some(TrustPolicy::Off));
let yaml = "trustPolicy: no-downgrade\n";
let settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap();
assert_eq!(settings.trust_policy, Some(TrustPolicy::NoDowngrade));
let yaml = "";
let settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap();
assert!(settings.trust_policy.is_none());
let mut config = Config::new();
settings.apply_to(&mut config, Path::new("/irrelevant"));
assert_eq!(config.trust_policy, TrustPolicy::Off, "default stays off when key is absent");
}

View File

@@ -0,0 +1,37 @@
[package]
name = "pacquet-lockfile-verification"
version = "0.0.1"
publish = false
authors.workspace = true
description.workspace = true
edition.workspace = true
homepage.workspace = true
keywords.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
pacquet-diagnostics = { workspace = true }
pacquet-lockfile = { workspace = true }
pacquet-reporter = { workspace = true }
pacquet-resolving-resolver-base = { workspace = true }
chrono = { workspace = true }
derive_more = { workspace = true }
futures-util = { workspace = true }
miette = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
tokio = { workspace = true, features = ["sync"] }
[dev-dependencies]
insta = { workspace = true }
pretty_assertions = { workspace = true }
serde-saphyr = { workspace = true }
ssri = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] }
[lints]
workspace = true

View File

@@ -0,0 +1,437 @@
//! On-disk verification cache.
//!
//! Verbatim port of pnpm's
//! [`verifyLockfileResolutionsCache.ts`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/installing/deps-installer/src/install/verifyLockfileResolutionsCache.ts).
//!
//! Two-index JSONL log at `<cache_dir>/lockfile-verified.jsonl`:
//!
//! - **by content hash** — recognizes the same lockfile across paths
//! (worktrees, CI checkouts that reset stat fields, lockfile
//! copies).
//! - **by absolute path** — same-machine stat shortcut. When the
//! cached entry's `(size, mtime_ns, inode)` matches the current
//! stat, trust the cached hash and skip reading the lockfile
//! altogether.
//!
//! Every active verifier must still agree that the cached policy
//! snapshot is trustworthy under what it currently demands — that's
//! what [`ResolutionVerifier::can_trust_past_check`] decides.
//!
//! All IO is synchronous. The cache is consulted once before the
//! verifier fan-out and recorded once after; the brief blocking
//! `read` / `stat` calls don't overlap with any other in-flight
//! install work.
//!
//! [`ResolutionVerifier::can_trust_past_check`]: pacquet_resolving_resolver_base::ResolutionVerifier::can_trust_past_check
use std::{
collections::HashMap,
fs::{self, OpenOptions},
io::{self, Write},
path::{Path, PathBuf},
sync::Arc,
sync::atomic::{AtomicU64, Ordering},
time::SystemTime,
};
use chrono::{SecondsFormat, Utc};
use pacquet_resolving_resolver_base::ResolutionVerifier;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
/// File name of the cache, relative to `cache_dir`. Matches
/// upstream's `CACHE_FILE_NAME` so a pnpm-populated cache file is
/// readable from pacquet and vice versa.
pub const CACHE_FILE_NAME: &str = "lockfile-verified.jsonl";
/// Hard cap on records the cache file holds after compaction.
/// Matches upstream's `MAX_CACHE_ENTRIES`. A developer machine that
/// touches a thousand distinct `(path, content)` tuples is far past
/// steady state.
pub const MAX_CACHE_ENTRIES: usize = 1000;
/// Compaction trigger in bytes. Records cluster around a few hundred
/// bytes; a 1.5 KiB-per-entry budget translates to ~1.5 MB with
/// generous slack so we don't trigger a rewrite on every append once
/// the cap is crossed. Matches upstream's `COMPACT_TRIGGER_BYTES`.
pub const COMPACT_TRIGGER_BYTES: u64 = (MAX_CACHE_ENTRIES as u64) * 1024 * 3 / 2;
/// One verified lockfile snapshot persisted to the JSONL log. Wire
/// shape matches upstream's `CacheRecord` field-for-field so the two
/// stacks share a cache file (pacquet reads pnpm's records and vice
/// versa — even though the hash values are unlikely to collide, the
/// stat shortcut still hits across both).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CacheRecord {
pub lockfile: CacheLockfile,
/// ISO-8601 timestamp of when the verification ran.
#[serde(rename = "verifiedAt")]
pub verified_at: String,
/// Merged policy snapshot that passed when the verification ran.
/// Every active [`ResolutionVerifier`]'s `policy()` contribution
/// merges into the same map; same-key conflicts go to the last
/// verifier in the list (a config bug we don't try to reconcile).
pub policy: serde_json::Map<String, JsonValue>,
}
/// Lockfile identity slot inside a [`CacheRecord`]. Stat fields are
/// stringified because they can exceed `Number.MAX_SAFE_INTEGER` on
/// large filesystems / older nodes; pacquet keeps the same wire shape
/// for cross-stack compat. `inode = "0"` on platforms where the
/// concept doesn't apply (Windows).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CacheLockfile {
/// sha256-hex of the lockfile content. Primary index key.
pub hash: String,
/// Absolute path the cache last saw this content at. Secondary
/// index key for the stat fast path.
pub path: String,
/// File size in bytes.
pub size: u64,
/// Lockfile mtime in nanoseconds, stringified (JSON numbers lose
/// ns precision).
#[serde(rename = "mtimeNs")]
pub mtime_ns: String,
/// Filesystem inode, stringified. `"0"` on platforms without
/// inodes (Windows).
pub inode: String,
}
/// Result of a [`try_lockfile_verification_cache`] lookup. `hit ==
/// true` lets the caller skip the verifier fan-out; `precomputed`
/// carries the stat + hash that the lookup already computed so the
/// matching [`record_verification`] call can skip re-doing them on
/// the miss-then-record path.
#[derive(Debug, Default, Clone)]
pub struct CacheLookupResult {
pub hit: bool,
pub precomputed: CachePrecomputed,
}
/// Precomputed lockfile fingerprint values shared between
/// [`try_lockfile_verification_cache`] and [`record_verification`].
#[derive(Debug, Default, Clone)]
pub struct CachePrecomputed {
pub stat: Option<LockfileStat>,
pub hash: Option<String>,
}
/// Stat fields the cache compares to decide whether the file at a
/// previously-cached path is still the file we saw. Cross-machine
/// values are meaningless; a fresh CI checkout that resets mtime
/// falls through to the content-hash lookup, which is the point.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LockfileStat {
pub size: u64,
pub mtime_ns: String,
pub inode: String,
}
/// Try to confirm a cached verification covers the lockfile as it
/// currently sits on disk **and** the policies currently in effect.
/// Returns `hit: true` so the caller can skip the verifier fan-out;
/// `hit: false` means the caller should run the gate and persist the
/// result with [`record_verification`].
///
/// Lookup order mirrors upstream:
///
/// 1. **Stat shortcut** — same path + same stat → trust the cached
/// hash; skip reading the lockfile.
/// 2. **Content lookup** — hash the lockfile and look up by hash.
/// Catches worktrees, CI checkouts where stat fields got reset.
/// On hit, refresh the path/stat slot so the next install at this
/// path takes the stat shortcut above.
///
/// `hash_lockfile` is a lazy closure: it's invoked only when the
/// stat shortcut doesn't apply, so a warm-stat install never pays
/// the hash cost. The closure is `FnMut` so callers can wrap a
/// memoised hash that's reused between the lookup and a
/// downstream [`record_verification`] call.
pub fn try_lockfile_verification_cache(
cache_dir: &Path,
lockfile_path: &Path,
verifiers: &[Arc<dyn ResolutionVerifier>],
mut hash_lockfile: impl FnMut() -> String,
) -> CacheLookupResult {
let indexes = match read_cache(cache_dir) {
Ok(indexes) => indexes,
Err(_) => {
// A corrupt cache file should never block the install;
// fall through to verification so the gate still runs.
return CacheLookupResult::default();
}
};
let Some(stat) = stat_lockfile(lockfile_path) else {
return CacheLookupResult::default();
};
let path_key = lockfile_path.to_string_lossy().to_string();
// Stat shortcut: same path + same stat means the cached hash is
// still correct without reading the file.
if let Some(record) = indexes.by_path.get(&path_key)
&& stat_matches(&stat, &record.lockfile)
{
return CacheLookupResult {
hit: every_verifier_trusts_cached_run(record, verifiers),
precomputed: CachePrecomputed {
stat: Some(stat),
hash: Some(record.lockfile.hash.clone()),
},
};
}
let hash = hash_lockfile();
let Some(record) = indexes.by_hash.get(&hash) else {
return CacheLookupResult {
hit: false,
precomputed: CachePrecomputed { stat: Some(stat), hash: Some(hash) },
};
};
if !every_verifier_trusts_cached_run(record, verifiers) {
return CacheLookupResult {
hit: false,
precomputed: CachePrecomputed { stat: Some(stat), hash: Some(hash) },
};
}
// Refresh the byPath slot so the next install at this path takes
// the stat shortcut. Failure here is best-effort: even if the
// append fails, the cache contract still holds (we just won't
// get the speedup at the new path).
let refreshed = CacheRecord {
lockfile: CacheLockfile {
hash: record.lockfile.hash.clone(),
path: path_key,
size: stat.size,
mtime_ns: stat.mtime_ns.clone(),
inode: stat.inode.clone(),
},
verified_at: record.verified_at.clone(),
policy: record.policy.clone(),
};
let _ = append_record(cache_dir, &refreshed);
CacheLookupResult {
hit: true,
precomputed: CachePrecomputed { stat: Some(stat), hash: Some(hash) },
}
}
/// Persist a successful verification. Mirrors upstream's
/// [`recordVerification`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/installing/deps-installer/src/install/verifyLockfileResolutionsCache.ts#L320-L349).
///
/// Reuses `precomputed.stat` and `precomputed.hash` from a prior
/// [`try_lockfile_verification_cache`] call so the miss-then-record
/// path doesn't re-stat / re-hash the lockfile. When either is
/// missing, the function falls back to computing it. A
/// stat-after-the-fact failure (file disappeared since verification
/// began) silently drops the record — the gate already passed, the
/// install proceeds without the speedup next time.
pub fn record_verification(
cache_dir: &Path,
lockfile_path: &Path,
verifiers: &[Arc<dyn ResolutionVerifier>],
mut hash_lockfile: impl FnMut() -> String,
precomputed: CachePrecomputed,
) {
let stat = match precomputed.stat.or_else(|| stat_lockfile(lockfile_path)) {
Some(stat) => stat,
None => return,
};
let hash = precomputed.hash.unwrap_or_else(&mut hash_lockfile);
let record = CacheRecord {
lockfile: CacheLockfile {
hash,
path: lockfile_path.to_string_lossy().to_string(),
size: stat.size,
mtime_ns: stat.mtime_ns,
inode: stat.inode,
},
verified_at: now_rfc3339(),
policy: merge_policies(verifiers),
};
if append_record(cache_dir, &record).is_err() {
return;
}
maybe_compact_cache(cache_dir);
}
struct CacheIndexes {
/// Latest record per content hash — primary lookup.
by_hash: HashMap<String, CacheRecord>,
/// Latest record per absolute path — same-machine stat fast path.
by_path: HashMap<String, CacheRecord>,
}
/// Read the cache file, building both indexes in one pass. Records
/// are walked in file order so the last record for any key wins
/// — matches upstream's `for (const line of contents.split('\n'))`
/// reduce. Returns empty indexes on `NotFound`; propagates other
/// IO errors so the caller can downgrade them to "no cache".
fn read_cache(cache_dir: &Path) -> io::Result<CacheIndexes> {
let cache_file_path = cache_dir.join(CACHE_FILE_NAME);
let contents = match fs::read_to_string(&cache_file_path) {
Ok(contents) => contents,
Err(error) if error.kind() == io::ErrorKind::NotFound => {
return Ok(CacheIndexes { by_hash: HashMap::new(), by_path: HashMap::new() });
}
Err(error) => return Err(error),
};
let mut by_hash = HashMap::new();
let mut by_path = HashMap::new();
for line in contents.lines() {
if line.is_empty() {
continue;
}
let parsed: CacheRecord = match serde_json::from_str(line) {
Ok(value) => value,
// Skip malformed lines; the next clean append still works.
Err(_) => continue,
};
if parsed.lockfile.hash.is_empty() || parsed.lockfile.path.is_empty() {
continue;
}
by_hash.insert(parsed.lockfile.hash.clone(), parsed.clone());
by_path.insert(parsed.lockfile.path.clone(), parsed);
}
Ok(CacheIndexes { by_hash, by_path })
}
fn stat_lockfile(lockfile_path: &Path) -> Option<LockfileStat> {
let metadata = fs::metadata(lockfile_path).ok()?;
let size = metadata.len();
let mtime_ns = metadata
.modified()
.ok()
.and_then(|modified| modified.duration_since(SystemTime::UNIX_EPOCH).ok())
.map(|duration| duration.as_nanos().to_string())
.unwrap_or_else(|| "0".to_string());
let inode = inode_of(&metadata);
Some(LockfileStat { size, mtime_ns, inode })
}
#[cfg(unix)]
fn inode_of(metadata: &fs::Metadata) -> String {
use std::os::unix::fs::MetadataExt;
metadata.ino().to_string()
}
#[cfg(not(unix))]
fn inode_of(_metadata: &fs::Metadata) -> String {
// Windows has no inode equivalent the cache wants to compare
// against; matching upstream's behavior leaves the slot empty
// ("0") so the stat shortcut still works on Unix and degrades
// to content-hash on Windows.
"0".to_string()
}
fn stat_matches(stat: &LockfileStat, lockfile: &CacheLockfile) -> bool {
stat.size == lockfile.size && stat.mtime_ns == lockfile.mtime_ns && stat.inode == lockfile.inode
}
fn every_verifier_trusts_cached_run(
record: &CacheRecord,
verifiers: &[Arc<dyn ResolutionVerifier>],
) -> bool {
verifiers.iter().all(|verifier| verifier.can_trust_past_check(&record.policy))
}
fn merge_policies(verifiers: &[Arc<dyn ResolutionVerifier>]) -> serde_json::Map<String, JsonValue> {
// Later verifiers overwrite earlier ones on conflict — a
// shared-field convention; mismatch is a config bug we don't
// try to reconcile.
let mut merged = serde_json::Map::new();
for verifier in verifiers {
for (key, value) in verifier.policy() {
merged.insert(key.clone(), value.clone());
}
}
merged
}
fn now_rfc3339() -> String {
Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
}
/// Append a record to the cache file. Single-line writes are atomic
/// on POSIX and NTFS, so concurrent pnpm / pacquet processes can
/// write without coordination.
fn append_record(cache_dir: &Path, record: &CacheRecord) -> io::Result<()> {
fs::create_dir_all(cache_dir)?;
let line = format!("{}\n", serde_json::to_string(record).map_err(io::Error::other)?);
let cache_file_path = cache_dir.join(CACHE_FILE_NAME);
OpenOptions::new().create(true).append(true).open(&cache_file_path)?.write_all(line.as_bytes())
}
fn maybe_compact_cache(cache_dir: &Path) {
let cache_file_path = cache_dir.join(CACHE_FILE_NAME);
let size = match fs::metadata(&cache_file_path) {
Ok(meta) => meta.len(),
Err(error) if error.kind() == io::ErrorKind::NotFound => return,
Err(_) => return,
};
if size <= COMPACT_TRIGGER_BYTES {
return;
}
let contents = match fs::read_to_string(&cache_file_path) {
Ok(contents) => contents,
Err(_) => return,
};
// Walk reverse so the newest record per (path, hash) wins, drop
// older duplicates, then trim to MAX_CACHE_ENTRIES.
let lines: Vec<&str> = contents.lines().filter(|line| !line.is_empty()).collect();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut reversed: Vec<String> = Vec::new();
for line in lines.iter().rev() {
let parsed: CacheRecord = match serde_json::from_str(line) {
Ok(value) => value,
Err(_) => continue,
};
if parsed.lockfile.hash.is_empty() || parsed.lockfile.path.is_empty() {
continue;
}
let tuple_key = format!("{}\x00{}", parsed.lockfile.path, parsed.lockfile.hash);
if !seen.insert(tuple_key) {
continue;
}
reversed.push((*line).to_string());
}
reversed.reverse();
let start = reversed.len().saturating_sub(MAX_CACHE_ENTRIES);
let kept = &reversed[start..];
// Write to a sibling tempfile + rename so a concurrent install
// can't observe a half-written file.
let temp_path = compact_temp_path(&cache_file_path);
let mut new_contents = String::with_capacity(size as usize);
for line in kept {
new_contents.push_str(line);
new_contents.push('\n');
}
if fs::write(&temp_path, new_contents.as_bytes()).is_err() {
return;
}
if fs::rename(&temp_path, &cache_file_path).is_err() {
let _ = fs::remove_file(&temp_path);
}
}
static COMPACT_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
fn compact_temp_path(target: &Path) -> PathBuf {
let counter = COMPACT_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let suffix = format!(".{pid}.{counter}.tmp");
let mut name = match target.file_name().and_then(|n| n.to_str()) {
Some(name) => name.to_string(),
None => "lockfile-verified".to_string(),
};
name.push_str(&suffix);
target.with_file_name(name)
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,332 @@
use std::{
fs,
path::{Path, PathBuf},
sync::Arc,
};
use pacquet_lockfile::LockfileResolution;
use pacquet_resolving_resolver_base::{
ResolutionVerification, ResolutionVerifier, VerifyCtx, VerifyFuture,
};
use serde_json::Value as JsonValue;
use tempfile::TempDir;
use super::{
CACHE_FILE_NAME, CacheLockfile, CachePrecomputed, CacheRecord, MAX_CACHE_ENTRIES,
record_verification, try_lockfile_verification_cache,
};
/// Trivial verifier that records what policy it advertises and
/// whether it trusts a cached policy snapshot.
struct Stub {
policy: serde_json::Map<String, JsonValue>,
trusts_past: bool,
}
impl Stub {
fn new(trusts_past: bool) -> Arc<Self> {
Arc::new(Self { policy: serde_json::Map::new(), trusts_past })
}
fn with_policy(trusts_past: bool, policy: serde_json::Map<String, JsonValue>) -> Arc<Self> {
Arc::new(Self { policy, trusts_past })
}
}
impl ResolutionVerifier for Stub {
fn verify<'a>(
&'a self,
_resolution: &'a LockfileResolution,
_ctx: VerifyCtx<'a>,
) -> VerifyFuture<'a> {
Box::pin(async { ResolutionVerification::Ok })
}
fn policy(&self) -> &serde_json::Map<String, JsonValue> {
&self.policy
}
fn can_trust_past_check(&self, _cached: &serde_json::Map<String, JsonValue>) -> bool {
self.trusts_past
}
}
fn touch_lockfile(dir: &Path, contents: &str) -> PathBuf {
fs::create_dir_all(dir).expect("mkdir for lockfile");
let path = dir.join("pnpm-lock.yaml");
fs::write(&path, contents).expect("write lockfile");
path
}
/// Hash builder helper: deterministic per `text` so tests can pin
/// the expected hash without depending on the actual hash_lockfile.
fn hashed(text: &str) -> impl FnMut() -> String + use<'_> {
move || format!("hash-of-{text}")
}
/// Cold cache (no file) → miss. The function returns hit=false
/// with a populated `stat` (the lockfile is real); `hash` may be
/// `None` since no work needs the hash yet.
#[test]
fn cold_cache_misses_with_populated_stat() {
let dir = TempDir::new().expect("tempdir");
let lockfile = touch_lockfile(dir.path(), "lockfileVersion: '9.0'\n");
let result = try_lockfile_verification_cache(dir.path(), &lockfile, &[], hashed("foo"));
assert!(!result.hit);
assert!(result.precomputed.stat.is_some(), "stat populated on cold miss");
}
/// After a successful `record_verification`, a follow-up lookup at
/// the same path hits the stat shortcut — same size + mtime_ns +
/// inode → no rehash. Verifies the `byPath` index.
#[test]
fn stat_shortcut_hits_same_path_same_stat() {
let dir = TempDir::new().expect("tempdir");
let lockfile = touch_lockfile(dir.path(), "lockfileVersion: '9.0'\nfoo: bar\n");
let verifiers: Vec<Arc<dyn ResolutionVerifier>> =
vec![Stub::new(true) as Arc<dyn ResolutionVerifier>];
record_verification(
dir.path(),
&lockfile,
&verifiers,
|| "deterministic-hash".to_string(),
CachePrecomputed::default(),
);
let mut calls = 0;
let mut hash_lockfile = || {
calls += 1;
"should-not-be-called".to_string()
};
let result =
try_lockfile_verification_cache(dir.path(), &lockfile, &verifiers, &mut hash_lockfile);
assert!(result.hit, "same path + same stat → hit");
assert_eq!(calls, 0, "stat shortcut skipped the hash call");
}
/// The content-hash index hits the same lockfile content at a
/// different path. The first install records under path A; the
/// second install at path B (with the same bytes) hits via hash.
#[test]
fn content_hash_lookup_finds_same_lockfile_at_different_path() {
let dir = TempDir::new().expect("tempdir");
let yaml = "lockfileVersion: '9.0'\nfoo: bar\n";
let lockfile_a = touch_lockfile(&dir.path().join("worktree-a"), yaml);
let lockfile_b = {
let p = dir.path().join("worktree-b");
fs::create_dir_all(&p).expect("mkdir b");
let path = p.join("pnpm-lock.yaml");
fs::write(&path, yaml).expect("write b");
path
};
let verifiers: Vec<Arc<dyn ResolutionVerifier>> =
vec![Stub::new(true) as Arc<dyn ResolutionVerifier>];
// Record under path A with the canonical hash.
record_verification(
dir.path(),
&lockfile_a,
&verifiers,
|| "shared-hash".to_string(),
CachePrecomputed::default(),
);
// Lookup at path B yields the same hash → hit via byHash.
let result = try_lockfile_verification_cache(dir.path(), &lockfile_b, &verifiers, || {
"shared-hash".to_string()
});
assert!(result.hit, "byHash hit at different path: {result:?}");
}
/// A verifier whose policy snapshot stops being trustworthy (e.g.
/// the user tightened the cutoff) invalidates the hit, even when
/// stat shortcut would otherwise have matched.
#[test]
fn policy_invalidation_misses_even_when_stat_matches() {
let dir = TempDir::new().expect("tempdir");
let lockfile = touch_lockfile(dir.path(), "lockfileVersion: '9.0'\n");
let trusting_verifier: Vec<Arc<dyn ResolutionVerifier>> =
vec![Stub::new(true) as Arc<dyn ResolutionVerifier>];
record_verification(
dir.path(),
&lockfile,
&trusting_verifier,
|| "h".to_string(),
CachePrecomputed::default(),
);
// Same path + stat, but the verifier now rejects the cached
// snapshot (tightened policy).
let strict_verifier: Vec<Arc<dyn ResolutionVerifier>> =
vec![Stub::new(false) as Arc<dyn ResolutionVerifier>];
let result = try_lockfile_verification_cache(dir.path(), &lockfile, &strict_verifier, || {
"h".to_string()
});
assert!(!result.hit);
}
/// `record_verification` merges every active verifier's `policy()`
/// into one bag — same-key conflicts go to the last verifier.
#[test]
fn record_verification_merges_policies() {
let dir = TempDir::new().expect("tempdir");
let lockfile = touch_lockfile(dir.path(), "lockfileVersion: '9.0'\n");
let mut policy_a = serde_json::Map::new();
policy_a.insert("minimumReleaseAge".to_string(), 60.into());
let mut policy_b = serde_json::Map::new();
policy_b.insert("trustPolicy".to_string(), JsonValue::String("no-downgrade".into()));
// `minimumReleaseAge` collides — the later verifier wins.
policy_b.insert("minimumReleaseAge".to_string(), 120.into());
let verifiers: Vec<Arc<dyn ResolutionVerifier>> =
vec![Stub::with_policy(true, policy_a), Stub::with_policy(true, policy_b)];
record_verification(
dir.path(),
&lockfile,
&verifiers,
|| "merged-hash".to_string(),
CachePrecomputed::default(),
);
let line = fs::read_to_string(dir.path().join(CACHE_FILE_NAME)).expect("read cache");
let record: CacheRecord = serde_json::from_str(line.trim_end()).expect("parse cache record");
assert_eq!(record.policy.get("minimumReleaseAge").and_then(JsonValue::as_u64), Some(120));
assert_eq!(record.policy.get("trustPolicy").and_then(JsonValue::as_str), Some("no-downgrade"));
}
/// `record_verification` is idempotent on the same `(path, hash)`
/// in the sense that every successful call appends a fresh record
/// — the byHash / byPath indexes always see the latest one on
/// read. Two distinct hashes produce two records.
#[test]
fn append_only_log_records_each_call() {
let dir = TempDir::new().expect("tempdir");
let lockfile = touch_lockfile(dir.path(), "lockfileVersion: '9.0'\n");
let verifiers: Vec<Arc<dyn ResolutionVerifier>> =
vec![Stub::new(true) as Arc<dyn ResolutionVerifier>];
for i in 0..3 {
record_verification(
dir.path(),
&lockfile,
&verifiers,
|| format!("hash-{i}"),
CachePrecomputed::default(),
);
}
let contents = fs::read_to_string(dir.path().join(CACHE_FILE_NAME)).expect("read cache");
let lines: Vec<&str> = contents.lines().filter(|line| !line.is_empty()).collect();
assert_eq!(lines.len(), 3);
}
/// Compaction kicks in past the byte threshold: a poisoned log full
/// of duplicate-key records gets reduced to the latest record per
/// `(path, hash)` and trimmed to `MAX_CACHE_ENTRIES`.
#[test]
fn compaction_dedupes_by_path_and_hash() {
let dir = TempDir::new().expect("tempdir");
let lockfile = touch_lockfile(dir.path(), "lockfileVersion: '9.0'\n");
let cache_path = dir.path().join(CACHE_FILE_NAME);
// Pre-seed with a >1.5 MB file of duplicate records (same path,
// same hash). After the next `record_verification`, compaction
// kicks in and trims to the latest entry. Serialize each record
// through serde so the path field round-trips correctly across
// platforms (Windows backslashes need JSON escaping).
let mut seed = String::with_capacity(2 * 1024 * 1024);
let mut counter: u64 = 0;
while (seed.len() as u64) <= super::COMPACT_TRIGGER_BYTES {
let record = CacheRecord {
lockfile: CacheLockfile {
hash: "dup".into(),
path: lockfile.to_string_lossy().into_owned(),
size: 0,
mtime_ns: "0".into(),
inode: "0".into(),
},
verified_at: counter.to_string(),
policy: serde_json::Map::new(),
};
seed.push_str(&serde_json::to_string(&record).expect("serialize record"));
seed.push('\n');
counter += 1;
}
fs::write(&cache_path, &seed).expect("seed cache");
assert!(seed.len() as u64 > super::COMPACT_TRIGGER_BYTES, "seed must trigger compaction");
let verifiers: Vec<Arc<dyn ResolutionVerifier>> =
vec![Stub::new(true) as Arc<dyn ResolutionVerifier>];
record_verification(
dir.path(),
&lockfile,
&verifiers,
|| "new-hash".to_string(),
CachePrecomputed::default(),
);
let contents = fs::read_to_string(&cache_path).expect("read post-compact");
let lines: Vec<&str> = contents.lines().filter(|line| !line.is_empty()).collect();
assert!(lines.len() <= MAX_CACHE_ENTRIES + 1, "trimmed past cap: {}", lines.len());
// Original duplicates collapsed to one (path, hash="dup"), plus
// the freshly-recorded line with hash="new-hash".
assert!(lines.len() <= 2, "duplicates collapsed: got {} lines", lines.len());
}
/// Malformed JSONL lines are skipped without failing the lookup
/// (other lines still parse). Mirrors upstream's "skip; the next
/// clean append will still work" tolerance.
#[test]
fn malformed_lines_are_tolerated_on_read() {
let dir = TempDir::new().expect("tempdir");
let lockfile = touch_lockfile(dir.path(), "lockfileVersion: '9.0'\n");
// Build the record via the typed shape so the path encodes as
// a JSON string with proper escaping (matters on Windows where
// the lockfile path contains backslashes).
let record = CacheRecord {
lockfile: CacheLockfile {
hash: "H".into(),
path: lockfile.to_string_lossy().into_owned(),
size: 0,
mtime_ns: "0".into(),
inode: "0".into(),
},
verified_at: "now".into(),
policy: serde_json::Map::new(),
};
let good = serde_json::to_string(&record).expect("serialize record");
let cache_path = dir.path().join(CACHE_FILE_NAME);
let contents = format!("garbage line\n{good}\nmore garbage\n");
fs::write(&cache_path, contents).expect("seed");
// We didn't write the actual stat values, so the stat shortcut
// misses; the content-hash route needs the verifier to trust
// the recorded policy.
let verifiers: Vec<Arc<dyn ResolutionVerifier>> =
vec![Stub::new(true) as Arc<dyn ResolutionVerifier>];
let result =
try_lockfile_verification_cache(dir.path(), &lockfile, &verifiers, || "H".to_string());
assert!(result.hit, "good record found despite surrounding garbage");
}
/// `CacheLockfile` round-trips through serde with the camelCase
/// field names upstream uses, so pacquet writes records pnpm can
/// read (and vice versa).
#[test]
fn cache_lockfile_serializes_with_camelcase_fields() {
let record = CacheRecord {
lockfile: CacheLockfile {
hash: "h".into(),
path: "/p".into(),
size: 42,
mtime_ns: "100".into(),
inode: "5".into(),
},
verified_at: "now".into(),
policy: serde_json::Map::new(),
};
let json = serde_json::to_string(&record).expect("serialize");
assert!(json.contains(r#""mtimeNs":"100""#), "got: {json}");
assert!(json.contains(r#""verifiedAt":"now""#), "got: {json}");
}

View File

@@ -0,0 +1,159 @@
//! Error surface for the lockfile-verification gate.
//!
//! Mirrors the three error codes pnpm raises from
//! [`buildVerificationError`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/installing/deps-installer/src/install/verifyLockfileResolutions.ts#L172-L206):
//!
//! - `MINIMUM_RELEASE_AGE_VIOLATION` — every violation in the batch
//! tripped the maturity check.
//! - `TRUST_DOWNGRADE` — every violation tripped the trust check.
//! - `LOCKFILE_RESOLUTION_VERIFICATION` — mixed batch (more than one
//! distinct violation code). The per-entry code goes into the
//! breakdown so the user can see which policy each entry tripped.
//!
//! The breakdown caps visible entries at 20 (matching upstream's
//! `MAX_VIOLATIONS_TO_PRINT`) and summarizes the remainder. Each
//! variant carries a `help` string verbatim from upstream so the
//! `pnpm errors` catalogue text matches.
use derive_more::{Display, Error};
use miette::Diagnostic;
/// Upstream's `MAX_VIOLATIONS_TO_PRINT`. Keeps a poisoned lockfile
/// from flooding the terminal with hundreds of rejection lines.
pub const MAX_VIOLATIONS_TO_PRINT: usize = 20;
const HINT: &str = "The lockfile contains entries that the active policies reject. \
This can mean the lockfile is stale, or that someone committed a \
lockfile that bypassed the policy locally — inspect recent changes \
to pnpm-lock.yaml before trusting it. If the changes look expected, \
run \"pnpm clean --lockfile\" and then \"pnpm install\" to rebuild from \
a fresh resolution. Alternatively, relax the policy that flagged \
them.";
/// One verifier rejection rendered for the error breakdown.
/// Internal-only data shape — the runner builds these from
/// `ResolutionPolicyViolation` after sorting.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedViolation {
pub name: String,
pub version: String,
pub code: &'static str,
pub reason: String,
}
/// Errors raised by [`crate::verify_lockfile_resolutions()`]. Each
/// variant maps to the matching upstream `PnpmError` code. The
/// formatted message includes the count + per-entry breakdown,
/// trimmed to `MAX_VIOLATIONS_TO_PRINT`.
#[derive(Debug, Display, Error, Diagnostic)]
#[non_exhaustive]
pub enum VerifyError {
/// Every violation in the batch tripped
/// `MINIMUM_RELEASE_AGE_VIOLATION`. Per-policy code preserved so
/// existing handlers / docs route correctly.
#[display("{count} lockfile entries failed verification:\n{breakdown}")]
#[diagnostic(code(ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION), help("{HINT}"))]
MinimumReleaseAgeViolation {
#[error(not(source))]
count: usize,
breakdown: String,
},
/// Every violation tripped `TRUST_DOWNGRADE`.
#[display("{count} lockfile entries failed verification:\n{breakdown}")]
#[diagnostic(code(ERR_PNPM_TRUST_DOWNGRADE), help("{HINT}"))]
TrustDowngrade {
#[error(not(source))]
count: usize,
breakdown: String,
},
/// Mixed batch — at least two distinct violation codes — so the
/// throw code escalates to the generic
/// `LOCKFILE_RESOLUTION_VERIFICATION` and each entry's code goes
/// into the breakdown.
#[display("{count} lockfile entries failed verification:\n{breakdown}")]
#[diagnostic(code(ERR_PNPM_LOCKFILE_RESOLUTION_VERIFICATION), help("{HINT}"))]
LockfileResolutionVerification {
#[error(not(source))]
count: usize,
breakdown: String,
},
}
impl VerifyError {
/// Build the appropriate variant from a list of rendered
/// violations. The list is **already sorted** by `name@version`
/// (the runner sorts before calling). Empty input is a logic
/// error — callers must check before constructing.
pub fn from_rendered(violations: Vec<RenderedViolation>) -> Self {
debug_assert!(!violations.is_empty(), "no violations → no error");
let distinct_codes: std::collections::BTreeSet<&str> =
violations.iter().map(|violation| violation.code).collect();
let mixed = distinct_codes.len() > 1;
let count = violations.len();
let visible_count = count.min(MAX_VIOLATIONS_TO_PRINT);
let omitted = count.saturating_sub(visible_count);
let mut breakdown = String::new();
for violation in violations.iter().take(visible_count) {
if mixed {
breakdown.push_str(&format!(
" {name}@{version} [{code}] {reason}\n",
name = violation.name,
version = violation.version,
code = violation.code,
reason = violation.reason,
));
} else {
breakdown.push_str(&format!(
" {name}@{version} {reason}\n",
name = violation.name,
version = violation.version,
reason = violation.reason,
));
}
}
if omitted > 0 {
breakdown.push_str(&format!(" …and {omitted} more"));
} else if breakdown.ends_with('\n') {
// Drop the final newline so the formatted error doesn't
// carry trailing whitespace into log lines.
breakdown.pop();
}
if mixed {
VerifyError::LockfileResolutionVerification { count, breakdown }
} else {
// Safe: distinct_codes has exactly one element.
let code = *distinct_codes.iter().next().expect("at least one code");
match code {
pacquet_resolving_npm_resolver_violation_codes::MINIMUM_RELEASE_AGE_VIOLATION => {
VerifyError::MinimumReleaseAgeViolation { count, breakdown }
}
pacquet_resolving_npm_resolver_violation_codes::TRUST_DOWNGRADE => {
VerifyError::TrustDowngrade { count, breakdown }
}
// Unknown verifier code (future-proofing): fall back
// to the generic envelope rather than fabricating a
// variant we don't have.
_ => VerifyError::LockfileResolutionVerification { count, breakdown },
}
}
}
}
/// Aliases the violation codes the npm verifier defines, so this
/// crate doesn't take a runtime dependency on
/// `pacquet-resolving-npm-resolver` just to compare two `&'static str`
/// constants. Keep the values byte-identical to the canonical
/// definitions over there.
mod pacquet_resolving_npm_resolver_violation_codes {
/// Matches `pacquet_resolving_npm_resolver::MINIMUM_RELEASE_AGE_VIOLATION_CODE`.
pub const MINIMUM_RELEASE_AGE_VIOLATION: &str = "MINIMUM_RELEASE_AGE_VIOLATION";
/// Matches `pacquet_resolving_npm_resolver::TRUST_DOWNGRADE_VIOLATION_CODE`.
pub const TRUST_DOWNGRADE: &str = "TRUST_DOWNGRADE";
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,7 @@
---
source: pacquet/crates/lockfile-verification/src/errors/tests.rs
assertion_line: 128
expression: err.to_string()
---
1 lockfile entries failed verification:
acme@1.0.0 was published at 2025-11-30T22:00:00.000Z, within the minimumReleaseAge cutoff (2025-11-30T00:00:00.000Z)

View File

@@ -0,0 +1,9 @@
---
source: pacquet/crates/lockfile-verification/src/errors/tests.rs
assertion_line: 186
expression: err.to_string()
---
3 lockfile entries failed verification:
acme@1.0.0 [MINIMUM_RELEASE_AGE_VIOLATION] was published at 2025-11-30T22:00:00.000Z, within the minimumReleaseAge cutoff (2025-11-30T00:00:00.000Z)
bravo@2.0.0 [TRUST_DOWNGRADE] High-risk trust downgrade for "bravo@2.0.0" (possible package takeover)
charlie@3.0.0 [MINIMUM_RELEASE_AGE_VIOLATION] could not be checked against minimumReleaseAge (version not present in registry manifest)

View File

@@ -0,0 +1,9 @@
---
source: pacquet/crates/lockfile-verification/src/errors/tests.rs
assertion_line: 156
expression: err.to_string()
---
3 lockfile entries failed verification:
acme@1.0.0 was published at 2025-11-30T22:00:00.000Z, within the minimumReleaseAge cutoff (2025-11-30T00:00:00.000Z)
bravo@2.0.0 was published at 2025-11-30T22:30:00.000Z, within the minimumReleaseAge cutoff (2025-11-30T00:00:00.000Z)
charlie@3.0.0 was published at 2025-11-30T23:00:00.000Z, within the minimumReleaseAge cutoff (2025-11-30T00:00:00.000Z)

View File

@@ -0,0 +1,187 @@
use super::{MAX_VIOLATIONS_TO_PRINT, RenderedViolation, VerifyError};
fn rendered(name: &str, version: &str, code: &'static str, reason: &str) -> RenderedViolation {
RenderedViolation {
name: name.to_string(),
version: version.to_string(),
code,
reason: reason.to_string(),
}
}
/// A batch where every violation tripped `MINIMUM_RELEASE_AGE_VIOLATION`
/// resolves to the per-policy variant; existing error handlers /
/// docs that match on the code still route correctly.
#[test]
fn single_min_age_violation_picks_min_age_variant() {
let err = VerifyError::from_rendered(vec![rendered(
"acme",
"1.0.0",
"MINIMUM_RELEASE_AGE_VIOLATION",
"was published yesterday",
)]);
assert!(matches!(err, VerifyError::MinimumReleaseAgeViolation { .. }), "got: {err:?}");
}
/// A trust-only batch picks the trust variant.
#[test]
fn single_trust_violation_picks_trust_variant() {
let err = VerifyError::from_rendered(vec![rendered(
"acme",
"1.0.0",
"TRUST_DOWNGRADE",
"evidence dropped",
)]);
assert!(matches!(err, VerifyError::TrustDowngrade { .. }), "got: {err:?}");
}
/// A batch with two distinct codes escalates to the generic
/// `LOCKFILE_RESOLUTION_VERIFICATION` variant; the per-entry code
/// shows up in the breakdown line.
#[test]
fn mixed_codes_escalate_and_render_code_per_entry() {
let err = VerifyError::from_rendered(vec![
rendered("acme", "1.0.0", "MINIMUM_RELEASE_AGE_VIOLATION", "young"),
rendered("bravo", "2.0.0", "TRUST_DOWNGRADE", "downgrade"),
]);
let VerifyError::LockfileResolutionVerification { count, breakdown } = err else {
panic!("expected LockfileResolutionVerification");
};
assert_eq!(count, 2);
assert!(breakdown.contains("[MINIMUM_RELEASE_AGE_VIOLATION]"));
assert!(breakdown.contains("[TRUST_DOWNGRADE]"));
assert!(breakdown.contains("acme@1.0.0"));
assert!(breakdown.contains("bravo@2.0.0"));
}
/// Single-code batches do NOT include the code in each line — the
/// envelope's `code` carries that information.
#[test]
fn single_code_breakdown_omits_per_line_code() {
let err = VerifyError::from_rendered(vec![
rendered("acme", "1.0.0", "MINIMUM_RELEASE_AGE_VIOLATION", "young"),
rendered("bravo", "2.0.0", "MINIMUM_RELEASE_AGE_VIOLATION", "also young"),
]);
let VerifyError::MinimumReleaseAgeViolation { breakdown, .. } = err else {
panic!("expected MinimumReleaseAgeViolation");
};
assert!(!breakdown.contains("[MINIMUM_RELEASE_AGE_VIOLATION]"), "got: {breakdown}");
}
/// More than `MAX_VIOLATIONS_TO_PRINT` entries trims the visible
/// list and adds the `…and N more` summary line. Without the trim, a
/// poisoned lockfile would flood the terminal.
#[test]
fn over_cap_adds_and_n_more_summary() {
let mut violations = Vec::new();
let n = MAX_VIOLATIONS_TO_PRINT + 5;
for i in 0..n {
violations.push(rendered(
&format!("pkg-{i}"),
"1.0.0",
"MINIMUM_RELEASE_AGE_VIOLATION",
"young",
));
}
let VerifyError::MinimumReleaseAgeViolation { count, breakdown } =
VerifyError::from_rendered(violations)
else {
panic!("expected MinimumReleaseAgeViolation");
};
assert_eq!(count, n);
assert!(breakdown.contains("…and 5 more"), "got: {breakdown}");
// The breakdown shows exactly MAX visible lines plus the summary.
let visible_lines = breakdown.lines().filter(|line| !line.starts_with(" …and")).count();
assert_eq!(visible_lines, MAX_VIOLATIONS_TO_PRINT);
}
/// A 1-entry batch builds an error with no trailing newline in the
/// breakdown — matters for clean log lines.
#[test]
fn single_entry_breakdown_has_no_trailing_newline() {
let err = VerifyError::from_rendered(vec![rendered(
"acme",
"1.0.0",
"MINIMUM_RELEASE_AGE_VIOLATION",
"young",
)]);
let VerifyError::MinimumReleaseAgeViolation { breakdown, .. } = err else {
panic!("expected MinimumReleaseAgeViolation");
};
assert!(!breakdown.ends_with('\n'), "breakdown: {breakdown:?}");
}
/// One-entry single-code rendering — the canonical "the user
/// committed a lockfile with one immature pin" shape. Insta-snapshot
/// pins the user-facing Display text so a future format change
/// surfaces as a reviewable diff. Mirrors pnpm's `PnpmError` Display
/// shape; the per-policy code lives on the envelope, the breakdown
/// is single-column.
#[test]
fn renders_single_entry_single_code() {
let err = VerifyError::from_rendered(vec![rendered(
"acme",
"1.0.0",
"MINIMUM_RELEASE_AGE_VIOLATION",
"was published at 2025-11-30T22:00:00.000Z, within the minimumReleaseAge cutoff (2025-11-30T00:00:00.000Z)",
)]);
insta::assert_snapshot!("single_entry_single_code", err.to_string());
}
/// Three-entry single-code rendering — every entry tripped the same
/// policy. The envelope's `code` carries the policy; the breakdown
/// lists `<name>@<version> <reason>` without per-line code prefixes.
#[test]
fn renders_three_entries_single_code() {
let err = VerifyError::from_rendered(vec![
rendered(
"acme",
"1.0.0",
"MINIMUM_RELEASE_AGE_VIOLATION",
"was published at 2025-11-30T22:00:00.000Z, within the minimumReleaseAge cutoff (2025-11-30T00:00:00.000Z)",
),
rendered(
"bravo",
"2.0.0",
"MINIMUM_RELEASE_AGE_VIOLATION",
"was published at 2025-11-30T22:30:00.000Z, within the minimumReleaseAge cutoff (2025-11-30T00:00:00.000Z)",
),
rendered(
"charlie",
"3.0.0",
"MINIMUM_RELEASE_AGE_VIOLATION",
"was published at 2025-11-30T23:00:00.000Z, within the minimumReleaseAge cutoff (2025-11-30T00:00:00.000Z)",
),
]);
insta::assert_snapshot!("three_entries_single_code", err.to_string());
}
/// Three-entry mixed-code rendering — at least two distinct
/// violation codes in the batch. The envelope escalates to
/// `LOCKFILE_RESOLUTION_VERIFICATION`; the breakdown carries the
/// per-line code prefix so the user can see which policy each entry
/// tripped.
#[test]
fn renders_three_entries_mixed_codes() {
let err = VerifyError::from_rendered(vec![
rendered(
"acme",
"1.0.0",
"MINIMUM_RELEASE_AGE_VIOLATION",
"was published at 2025-11-30T22:00:00.000Z, within the minimumReleaseAge cutoff (2025-11-30T00:00:00.000Z)",
),
rendered(
"bravo",
"2.0.0",
"TRUST_DOWNGRADE",
r#"High-risk trust downgrade for "bravo@2.0.0" (possible package takeover)"#,
),
rendered(
"charlie",
"3.0.0",
"MINIMUM_RELEASE_AGE_VIOLATION",
"could not be checked against minimumReleaseAge (version not present in registry manifest)",
),
]);
insta::assert_snapshot!("three_entries_mixed_codes", err.to_string());
}

View File

@@ -0,0 +1,85 @@
//! Stable content hash of an in-memory [`Lockfile`].
//!
//! Used by the verification cache (Phase 6 slice 2) to recognise the
//! same lockfile across paths — committed-then-restored CI checkouts,
//! parallel git worktrees, lockfile copies. The same parsed
//! [`Lockfile`] must yield the same hash every time regardless of
//! how the underlying YAML was ordered when written.
//!
//! Upstream uses `@pnpm/crypto.object-hasher`'s `hashObject` (a
//! sha256-base64 streamed through the `object-hash` npm package with
//! `unorderedObjects: true`). Pacquet's implementation is functionally
//! equivalent but format-divergent: stream the lockfile through
//! `serde_json` with every map normalized to sorted key order, hash
//! the bytes with sha256, output **hex** (not base64). Cross-stack
//! cache hits are not expected — each stack reads its own records out
//! of the shared JSONL — and the per-stack determinism is what the
//! cache contract actually requires.
use std::io;
use pacquet_lockfile::Lockfile;
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
/// Sha256 hex digest of the lockfile content. Stable across runs:
/// any two `Lockfile`s that compare equal (deserialized from the
/// same YAML, or from two YAMLs that parse to the same shape)
/// produce the same hash.
pub fn hash_lockfile(lockfile: &Lockfile) -> String {
let value = serde_json::to_value(lockfile)
.expect("Lockfile serializes; serde_json::Value supports all JSON-shape variants");
let normalized = normalize(value);
let mut hasher = HashWriter(Sha256::new());
serde_json::to_writer(&mut hasher, &normalized)
.expect("HashWriter is infallible; serde_json::to_writer cannot fail otherwise");
format!("{:x}", hasher.0.finalize())
}
/// Walk a [`Value`] and rebuild every map with sorted keys. Arrays
/// are left in place — the lockfile's only array-shaped fields are
/// `ignoredOptionalDependencies` (which is semantically a set the
/// install treats as ordered for diff stability) and dependency-name
/// lists inside individual entries (where order matches the manifest
/// section it came from).
///
/// `serde_json::Map` is backed by `IndexMap` under the
/// `preserve_order` workspace feature, so a fresh `Map` populated in
/// sorted-key order serialises in that order.
fn normalize(value: Value) -> Value {
match value {
Value::Object(map) => {
let mut keys: Vec<String> = map.keys().cloned().collect();
keys.sort();
let mut sorted = Map::with_capacity(keys.len());
for key in keys {
let inner =
map.get(&key).cloned().expect("key came from the same map we're walking");
sorted.insert(key, normalize(inner));
}
Value::Object(sorted)
}
Value::Array(items) => Value::Array(items.into_iter().map(normalize).collect()),
other => other,
}
}
/// `io::Write` adapter that feeds bytes into a [`Sha256`] as they
/// arrive from `serde_json::to_writer`, so the full normalized JSON
/// never materializes in memory. Mirrors upstream's streaming
/// behavior — the lockfile can be megabytes for large monorepos.
struct HashWriter(Sha256);
impl io::Write for HashWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.update(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,85 @@
use pacquet_lockfile::Lockfile;
use super::hash_lockfile;
const LOCKFILE_YAML: &str = "lockfileVersion: '9.0'
importers:
.:
dependencies:
lodash:
specifier: ^4.17.21
version: 4.17.21
react:
specifier: ^17.0.2
version: 17.0.2
";
const LOCKFILE_YAML_REORDERED: &str = "lockfileVersion: '9.0'
importers:
.:
dependencies:
react:
specifier: ^17.0.2
version: 17.0.2
lodash:
specifier: ^4.17.21
version: 4.17.21
";
fn parse(yaml: &str) -> Lockfile {
serde_saphyr::from_str(yaml).expect("parse fixture lockfile")
}
/// The same in-memory `Lockfile` hashes the same regardless of how
/// many times we ask. This is the floor of the cache contract — a
/// successive run on the same lockfile must produce the same key.
#[test]
fn hash_is_stable_across_calls() {
let lockfile = parse(LOCKFILE_YAML);
let a = hash_lockfile(&lockfile);
let b = hash_lockfile(&lockfile);
assert_eq!(a, b);
assert_eq!(a.len(), 64, "sha256 hex digest is 64 chars");
}
/// Lockfiles that parse to the same logical content but were
/// written with different YAML key orders produce the same hash.
/// HashMap key iteration is non-deterministic; the normalize step
/// is what makes the hash stable.
#[test]
fn key_order_in_yaml_does_not_affect_hash() {
let original = parse(LOCKFILE_YAML);
let reordered = parse(LOCKFILE_YAML_REORDERED);
assert_eq!(hash_lockfile(&original), hash_lockfile(&reordered));
}
/// A meaningful change to the lockfile (a new dependency entry)
/// flips the hash. Without this guarantee the cache could falsely
/// short-circuit on a drifted lockfile.
#[test]
fn semantic_changes_flip_the_hash() {
let original = parse(LOCKFILE_YAML);
let drifted = parse(
"lockfileVersion: '9.0'
importers:
.:
dependencies:
lodash:
specifier: ^4.17.21
version: 4.17.21
react:
specifier: ^17.0.2
version: 17.0.2
vite:
specifier: ^5.0.0
version: 5.0.0
",
);
assert_ne!(hash_lockfile(&original), hash_lockfile(&drifted));
}

View File

@@ -0,0 +1,40 @@
//! Pacquet port of pnpm's lockfile-verification gate.
//!
//! Runs every active [`ResolutionVerifier`] against every entry in a
//! lockfile loaded from disk, before any resolver decision or fetch
//! happens. A lockfile whose entries were resolved elsewhere
//! (committed to the repo, restored from CI cache, generated by a tool
//! that skipped local policy) cannot reach the install path under a
//! weaker or absent policy.
//!
//! Mirrors upstream pnpm at commit `2a9bd897bf`:
//!
//! - [`verifyLockfileResolutions.ts`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/installing/deps-installer/src/install/verifyLockfileResolutions.ts)
//! — the fan-out runner.
//! - [`verifyLockfileResolutionsCache.ts`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/installing/deps-installer/src/install/verifyLockfileResolutionsCache.ts)
//! — the JSONL stat-and-skip cache (lands in a follow-up slice).
//!
//! Public surface today: [`verify_lockfile_resolutions()`],
//! [`collect_resolution_policy_violations()`], [`hash_lockfile()`], and
//! [`VerifyError`].
//!
//! [`ResolutionVerifier`]: pacquet_resolving_resolver_base::ResolutionVerifier
mod cache;
mod errors;
mod hash_lockfile;
mod record_lockfile_verified;
mod verify_lockfile_resolutions;
pub use cache::{
CACHE_FILE_NAME, COMPACT_TRIGGER_BYTES, CacheLockfile, CacheLookupResult, CachePrecomputed,
CacheRecord, LockfileStat, MAX_CACHE_ENTRIES, record_verification,
try_lockfile_verification_cache,
};
pub use errors::VerifyError;
pub use hash_lockfile::hash_lockfile;
pub use record_lockfile_verified::record_lockfile_verified;
pub use verify_lockfile_resolutions::{
VerifyLockfileResolutionsOptions, collect_resolution_policy_violations,
verify_lockfile_resolutions,
};

View File

@@ -0,0 +1,53 @@
//! Thin wrapper around [`record_verification`] for callers that hold
//! a freshly-written lockfile.
//!
//! Mirrors pnpm's
//! [`recordLockfileVerified.ts`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/installing/deps-installer/src/install/recordLockfileVerified.ts).
//!
//! After resolution writes a new lockfile, the install path uses this
//! wrapper to mark the new lockfile as already-verified so the
//! *next* install can take the cache fast path. Skipping the gate on
//! the next run is safe: fresh local picks went through the
//! resolver's per-version filter, and any carried-over entries
//! already passed the gate at the top of the same install.
//!
//! The function is a no-op when caching is disabled, when no
//! verifiers are active, or when the lockfile has no `packages:`
//! section — same guards upstream uses.
use std::{path::Path, sync::Arc};
use pacquet_lockfile::Lockfile;
use pacquet_resolving_resolver_base::ResolutionVerifier;
use crate::{cache::record_verification, hash_lockfile};
/// Persist the post-resolution lockfile as already-verified.
/// Inputs match upstream's `RecordLockfileVerifiedOptions`:
/// `cache_dir` enables the cache, `lockfile_path` is the absolute
/// path of the file the next install will read, `lockfile` is the
/// canonical in-memory shape that round-trips through the writer
/// (NOT the raw write object, since YAML drops `undefined` fields
/// and a hash of the raw shape would never match the parsed shape on
/// the next install).
pub fn record_lockfile_verified(
cache_dir: Option<&Path>,
lockfile_path: &Path,
lockfile: &Lockfile,
verifiers: &[Arc<dyn ResolutionVerifier>],
) {
let Some(cache_dir) = cache_dir else { return };
if verifiers.is_empty() {
return;
}
if lockfile.packages.is_none() {
return;
}
record_verification(
cache_dir,
lockfile_path,
verifiers,
|| hash_lockfile(lockfile),
Default::default(),
);
}

View File

@@ -0,0 +1,371 @@
//! Fan-out runner for the lockfile-verification gate.
//!
//! Verbatim port of pnpm's
//! [`verifyLockfileResolutions.ts`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/installing/deps-installer/src/install/verifyLockfileResolutions.ts).
//!
//! Walks every entry in `lockfile.packages`, dedupes by
//! `(name, version, resolution)`, and asks every active verifier to
//! evaluate each candidate. Verifiers handle their own protocol
//! short-circuit by returning [`ResolutionVerification::Ok`] for
//! resolutions outside their scope; the runner is policy-neutral and
//! dispatch-free at this layer.
//!
//! Cache lookup / record are out of scope for this slice (Phase 6
//! splits the runner from the JSONL cache). The shape that supports
//! the cache — `lockfile_path` on the options bag, the runner-side
//! emit boundaries — is in place so the cache slice only needs to
//! plug into the existing call sites.
use std::{collections::BTreeMap, path::Path, sync::Arc, time::Instant};
use futures_util::{StreamExt, stream::FuturesUnordered};
use pacquet_lockfile::{Lockfile, LockfileResolution, PkgName};
use pacquet_reporter::{
LockfileVerificationLog, LockfileVerificationMessage, LogEvent, LogLevel, Reporter,
};
use pacquet_resolving_resolver_base::{
ResolutionPolicyViolation, ResolutionVerification, ResolutionVerifier, VerifyCtx,
};
use tokio::sync::Semaphore;
use crate::{
cache::{CachePrecomputed, record_verification, try_lockfile_verification_cache},
errors::{RenderedViolation, VerifyError},
hash_lockfile,
};
/// Default concurrency cap for the per-candidate fan-out. Mirrors
/// upstream's `DEFAULT_CONCURRENCY = 16` (the floor of pnpm's
/// `package-requester` network-concurrency formula).
const DEFAULT_CONCURRENCY: usize = 16;
/// Options bundle for [`verify_lockfile_resolutions`]. Mirrors
/// upstream's
/// [`VerifyLockfileResolutionsOptions`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/installing/deps-installer/src/install/verifyLockfileResolutions.ts#L34-L47).
#[derive(Debug, Default, Clone)]
pub struct VerifyLockfileResolutionsOptions<'a> {
/// Cap on concurrent verifier futures. `None` falls back to
/// the internal `DEFAULT_CONCURRENCY` (`16`, matching upstream).
pub concurrency: Option<usize>,
/// Absolute path of the lockfile being verified. Required for
/// the on-disk verification cache (the stat shortcut + per-path
/// index key off it) and surfaced in the
/// `pnpm:lockfile-verification` reporter payload.
pub lockfile_path: Option<&'a Path>,
/// Pnpm's on-disk cache directory. When set together with
/// `lockfile_path`, a successful run is memoised in
/// `<cache_dir>/lockfile-verified.jsonl` and the gate
/// short-circuits on a repeat run against an unchanged lockfile
/// (under the same or stricter policy). Omitting either field
/// disables the cache (every call rehashes + reruns the gate).
pub cache_dir: Option<&'a Path>,
}
/// Run every active [`ResolutionVerifier`] against every entry in
/// `lockfile.packages`. No-op when `verifiers` is empty or the
/// lockfile carries no `packages:` map.
///
/// Verifiers fan out across candidates with the runner's concurrency
/// cap; each candidate stops at the first verifier that rejects it,
/// so a multi-verifier setup never emits duplicate violations for
/// the same `(name, version)` pair.
///
/// Reporter events fire only when the fan-out actually runs — an
/// empty candidate set skips both `Started` and `Done`. On the
/// non-empty path, a `Started` always pairs with exactly one
/// terminal `Done` (success) or `Failed` (rejection), even if the
/// fan-out panics; the failure variant of the emit is fired from the
/// drop guard so the reporter never leaves a hanging "Verifying…"
/// frame.
pub async fn verify_lockfile_resolutions<Reporter: self::Reporter>(
lockfile: &Lockfile,
verifiers: &[Arc<dyn ResolutionVerifier>],
opts: &VerifyLockfileResolutionsOptions<'_>,
) -> Result<(), VerifyError> {
if verifiers.is_empty() || lockfile.packages.is_none() {
return Ok(());
}
// Caching activates only when both `cache_dir` and
// `lockfile_path` are supplied. Production wiring always passes
// both; tests that skip them exercise the gate without
// memoization (and still cover the runner's emit + violation
// logic via the same code path).
let cache_inputs = opts.cache_dir.zip(opts.lockfile_path);
// Memoised content hash. Used by both the lookup (when the
// stat-shortcut doesn't apply) and the recorder (after the
// gate passes). The closure is `FnMut` so multiple lazy calls
// share the computed string.
let mut cached_hash: Option<String> = None;
let mut hash_once = || {
if let Some(hash) = cached_hash.as_ref() {
return hash.clone();
}
let hash = hash_lockfile(lockfile);
cached_hash = Some(hash.clone());
hash
};
let mut cache_precomputed: CachePrecomputed = CachePrecomputed::default();
if let Some((cache_dir, lockfile_path)) = cache_inputs {
let result =
try_lockfile_verification_cache(cache_dir, lockfile_path, verifiers, &mut hash_once);
if result.hit {
return Ok(());
}
cache_precomputed = result.precomputed;
}
let candidates = collect_candidates(lockfile);
let lockfile_path_str = opts.lockfile_path.map(|path| path.to_string_lossy().into_owned());
if candidates.is_empty() {
// Persist the success so the next install can stat-only the
// lockfile. Matches upstream's behavior at
// `verifyLockfileResolutions.ts:124-132` — empty fan-out is
// still a successful run.
if let Some((cache_dir, lockfile_path)) = cache_inputs {
record_verification(
cache_dir,
lockfile_path,
verifiers,
&mut hash_once,
cache_precomputed,
);
}
return Ok(());
}
let entries = candidates.len() as u64;
let started_at = Instant::now();
emit::<Reporter>(
LogLevel::Debug,
LockfileVerificationMessage::Started { entries, lockfile_path: lockfile_path_str.clone() },
);
// The drop guard fires `Failed` for early-return / panic paths.
// The success path replaces it with the `Done` payload before
// returning, so the guard's drop only fires on a panic or on the
// throw-violations branch.
let mut emit_guard =
TerminalEmitGuard::<Reporter>::failed(entries, started_at, lockfile_path_str.clone());
let violations = run_fan_out(candidates, verifiers, opts.concurrency).await;
if violations.is_empty() {
emit_guard.cancel(LockfileVerificationMessage::Done {
entries,
elapsed_ms: started_at.elapsed().as_millis() as u64,
lockfile_path: lockfile_path_str,
});
if let Some((cache_dir, lockfile_path)) = cache_inputs {
record_verification(
cache_dir,
lockfile_path,
verifiers,
&mut hash_once,
cache_precomputed,
);
}
return Ok(());
}
Err(build_verification_error(violations))
}
/// Collect-mode sibling of [`verify_lockfile_resolutions`] that
/// returns violations as data instead of throwing on the first batch.
/// No reporter emits, no cache wiring — for callers that need to
/// inspect violations (auto-collect into `minimumReleaseAgeExclude`,
/// strict-mode prompts, future custom policies).
pub async fn collect_resolution_policy_violations(
lockfile: &Lockfile,
verifiers: &[Arc<dyn ResolutionVerifier>],
concurrency: Option<usize>,
) -> Vec<ResolutionPolicyViolation> {
if verifiers.is_empty() || lockfile.packages.is_none() {
return Vec::new();
}
let candidates = collect_candidates(lockfile);
run_fan_out(candidates, verifiers, concurrency).await
}
/// One `(name, version, resolution)` tuple deduplicated from
/// `lockfile.packages`. Mirrors upstream's inline `Candidate`
/// interface.
struct Candidate {
name: PkgName,
version: String,
resolution: LockfileResolution,
}
/// Walk `lockfile.packages` and dedupe by
/// `(name, version, resolution-json)`. Mirrors upstream's
/// [`collectCandidates`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/installing/deps-installer/src/install/verifyLockfileResolutions.ts#L248-L261).
///
/// The serialized resolution is part of the key so two entries that
/// share a `(name, version)` but differ in *what* was resolved (npm
/// vs git URL under the same alias) don't collapse into one.
/// `BTreeMap` over a serialized key gives deterministic iteration
/// order for tests; the fan-out runs across the value iter so order
/// doesn't affect correctness, only the reproducibility of failures.
fn collect_candidates(lockfile: &Lockfile) -> Vec<Candidate> {
let Some(packages) = lockfile.packages.as_ref() else {
return Vec::new();
};
let mut deduped: BTreeMap<String, Candidate> = BTreeMap::new();
for (key, metadata) in packages {
let name = key.name.clone();
let version = key.suffix.version().to_string();
// Every `LockfileResolution` variant derives `Serialize`, and
// the wire shape never contains non-string keys or non-finite
// numbers — the only way this `expect` could fire is a future
// variant that breaks the contract. Fail loudly rather than
// skipping the candidate, which would silently bypass
// verification for that lockfile entry.
let resolution_json = serde_json::to_string(&metadata.resolution)
.expect("LockfileResolution must serialize for candidate dedupe");
let key = format!("{name}@{version}@{resolution_json}");
deduped.entry(key).or_insert(Candidate {
name,
version,
resolution: metadata.resolution.clone(),
});
}
deduped.into_values().collect()
}
/// Run every active verifier against every candidate with a
/// concurrency cap. Each candidate stops at the first verifier that
/// rejects it.
async fn run_fan_out(
candidates: Vec<Candidate>,
verifiers: &[Arc<dyn ResolutionVerifier>],
concurrency: Option<usize>,
) -> Vec<ResolutionPolicyViolation> {
let limit = concurrency.unwrap_or(DEFAULT_CONCURRENCY).max(1);
let semaphore = Arc::new(Semaphore::new(limit));
let mut futures = FuturesUnordered::new();
for candidate in candidates {
let semaphore = Arc::clone(&semaphore);
let verifiers: Vec<Arc<dyn ResolutionVerifier>> =
verifiers.iter().map(Arc::clone).collect();
futures.push(async move {
// Holding the permit across every verifier .await keeps
// the effective in-flight count bounded by the semaphore.
// Releasing per-verifier would let N candidates × M
// verifiers race past the cap.
let _permit = semaphore.acquire().await.expect("semaphore not closed during fan-out");
evaluate_candidate(candidate, &verifiers).await
});
}
let mut violations = Vec::new();
while let Some(result) = futures.next().await {
if let Some(violation) = result {
violations.push(violation);
}
}
violations
}
async fn evaluate_candidate(
candidate: Candidate,
verifiers: &[Arc<dyn ResolutionVerifier>],
) -> Option<ResolutionPolicyViolation> {
for verifier in verifiers {
let ctx = VerifyCtx { name: &candidate.name, version: &candidate.version };
match verifier.verify(&candidate.resolution, ctx).await {
ResolutionVerification::Ok => continue,
ResolutionVerification::Err { code, reason } => {
return Some(ResolutionPolicyViolation {
name: candidate.name,
version: candidate.version,
resolution: candidate.resolution,
code,
reason,
});
}
}
}
None
}
/// Sort violations by `name@version` and build the matching
/// [`VerifyError`]. Mirrors upstream's
/// [`buildVerificationError`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/installing/deps-installer/src/install/verifyLockfileResolutions.ts#L172-L206).
fn build_verification_error(mut violations: Vec<ResolutionPolicyViolation>) -> VerifyError {
violations.sort_by(|left, right| {
format!("{}@{}", left.name, left.version).cmp(&format!("{}@{}", right.name, right.version))
});
let rendered: Vec<RenderedViolation> = violations
.into_iter()
.map(|violation| RenderedViolation {
name: violation.name.to_string(),
version: violation.version,
code: violation.code,
reason: violation.reason,
})
.collect();
VerifyError::from_rendered(rendered)
}
fn emit<Reporter: self::Reporter>(level: LogLevel, message: LockfileVerificationMessage) {
Reporter::emit(&LogEvent::LockfileVerification(LockfileVerificationLog { level, message }));
}
/// Drop guard that fires the terminal `Failed` payload when the
/// runner panics or returns early through `?`. On the success path
/// the runner calls [`Self::cancel`] with the `Done` payload, which
/// replaces the queued message and emits it on drop instead.
struct TerminalEmitGuard<Reporter: self::Reporter> {
pending: Option<LockfileVerificationMessage>,
/// `Started` instant captured at runner entry. The Drop impl uses
/// it to refresh `elapsed_ms` on the Failed branch (the field is
/// stale on the `pending` payload — it was built at guard
/// construction, before the fan-out ran). `Done` payloads land
/// via [`Self::cancel`] with their own up-to-date `elapsed_ms`.
started_at: Instant,
_reporter: std::marker::PhantomData<Reporter>,
}
impl<Reporter: self::Reporter> TerminalEmitGuard<Reporter> {
fn failed(entries: u64, started_at: Instant, lockfile_path: Option<String>) -> Self {
Self {
pending: Some(LockfileVerificationMessage::Failed {
entries,
// Placeholder; the Drop impl overwrites this with
// the real elapsed when the guard actually fires.
elapsed_ms: 0,
lockfile_path,
}),
started_at,
_reporter: std::marker::PhantomData,
}
}
fn cancel(&mut self, success: LockfileVerificationMessage) {
self.pending = Some(success);
}
}
impl<Reporter: self::Reporter> Drop for TerminalEmitGuard<Reporter> {
fn drop(&mut self) {
if let Some(message) = self.pending.take() {
// Refresh `elapsed_ms` on the Failed branch only — the
// success branch already filled the up-to-date value via
// `cancel(Done { elapsed_ms: <now> })`.
let message = match message {
LockfileVerificationMessage::Failed { entries, lockfile_path, .. } => {
LockfileVerificationMessage::Failed {
entries,
elapsed_ms: self.started_at.elapsed().as_millis() as u64,
lockfile_path,
}
}
other => other,
};
emit::<Reporter>(LogLevel::Debug, message);
}
}
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,482 @@
use std::{
path::Path,
sync::{
Arc, Mutex,
atomic::{AtomicUsize, Ordering},
},
};
use pacquet_lockfile::{Lockfile, LockfileResolution, PkgName};
use pacquet_reporter::{LockfileVerificationMessage, LogEvent, Reporter, SilentReporter};
use pacquet_resolving_resolver_base::{
ResolutionVerification, ResolutionVerifier, VerifyCtx, VerifyFuture,
};
use tempfile::TempDir;
use super::{
VerifyLockfileResolutionsOptions, collect_resolution_policy_violations,
verify_lockfile_resolutions,
};
use crate::VerifyError;
const SINGLE_PKG_LOCKFILE: &str = "lockfileVersion: '9.0'
importers:
.:
dependencies:
react:
specifier: ^17.0.2
version: 17.0.2
packages:
react@17.0.2:
resolution: {integrity: sha512-TIE61hcgbI/SlJh/0c1sT1SZbBlpg7WiZcs65WPJhoIZQPhH1SCpcGA7LgrVXT15lwN3HV4GQM/MJ9aKEn3Qfg==}
snapshots:
react@17.0.2: {}
";
const TWO_PKG_LOCKFILE: &str = "lockfileVersion: '9.0'
importers:
.:
dependencies:
acme:
specifier: ^1.0.0
version: 1.0.0
bravo:
specifier: ^2.0.0
version: 2.0.0
packages:
acme@1.0.0:
resolution: {integrity: sha512-TIE61hcgbI/SlJh/0c1sT1SZbBlpg7WiZcs65WPJhoIZQPhH1SCpcGA7LgrVXT15lwN3HV4GQM/MJ9aKEn3Qfg==}
bravo@2.0.0:
resolution: {integrity: sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==}
snapshots:
acme@1.0.0: {}
bravo@2.0.0: {}
";
fn parse(yaml: &str) -> Lockfile {
serde_saphyr::from_str(yaml).expect("parse fixture lockfile")
}
/// Reject every candidate with the given code/reason.
struct AlwaysFail {
code: &'static str,
reason: &'static str,
policy: serde_json::Map<String, serde_json::Value>,
}
impl AlwaysFail {
fn new(code: &'static str, reason: &'static str) -> Arc<Self> {
Arc::new(Self { code, reason, policy: serde_json::Map::new() })
}
}
impl ResolutionVerifier for AlwaysFail {
fn verify<'a>(
&'a self,
_resolution: &'a LockfileResolution,
_ctx: VerifyCtx<'a>,
) -> VerifyFuture<'a> {
let code = self.code;
let reason = self.reason.to_string();
Box::pin(async move { ResolutionVerification::Err { code, reason } })
}
fn policy(&self) -> &serde_json::Map<String, serde_json::Value> {
&self.policy
}
fn can_trust_past_check(&self, _cached: &serde_json::Map<String, serde_json::Value>) -> bool {
true
}
}
/// Reject only specific package names; pass everything else.
struct FailFor {
code: &'static str,
reason: &'static str,
names: Vec<&'static str>,
policy: serde_json::Map<String, serde_json::Value>,
}
impl FailFor {
fn new(code: &'static str, reason: &'static str, names: Vec<&'static str>) -> Arc<Self> {
Arc::new(Self { code, reason, names, policy: serde_json::Map::new() })
}
}
impl ResolutionVerifier for FailFor {
fn verify<'a>(
&'a self,
_resolution: &'a LockfileResolution,
ctx: VerifyCtx<'a>,
) -> VerifyFuture<'a> {
let name = ctx.name.to_string();
let triggers = self.names.contains(&name.as_str());
let code = self.code;
let reason = self.reason.to_string();
Box::pin(async move {
if triggers {
ResolutionVerification::Err { code, reason }
} else {
ResolutionVerification::Ok
}
})
}
fn policy(&self) -> &serde_json::Map<String, serde_json::Value> {
&self.policy
}
fn can_trust_past_check(&self, _cached: &serde_json::Map<String, serde_json::Value>) -> bool {
true
}
}
/// Empty verifier list is a no-op — neither emits nor errors. Lets
/// the install path skip the gate when no policy is configured.
#[tokio::test]
async fn no_verifiers_is_a_noop() {
static EVENTS: Mutex<Vec<LogEvent>> = Mutex::new(Vec::new());
EVENTS.lock().unwrap().clear();
struct RecordingReporter;
impl Reporter for RecordingReporter {
fn emit(event: &LogEvent) {
EVENTS.lock().unwrap().push(event.clone());
}
}
let lockfile = parse(SINGLE_PKG_LOCKFILE);
let result = verify_lockfile_resolutions::<RecordingReporter>(
&lockfile,
&[],
&VerifyLockfileResolutionsOptions::default(),
)
.await;
assert!(result.is_ok());
assert!(EVENTS.lock().unwrap().is_empty(), "no-op must not emit");
}
/// Lockfile without `packages:` is a no-op — there's nothing to
/// verify. Mirrors upstream's `!lockfile.packages` guard.
#[tokio::test]
async fn no_packages_section_is_a_noop() {
let yaml = "lockfileVersion: '9.0'\n\nimporters:\n\n .: {}\n";
let lockfile = parse(yaml);
let verifier = AlwaysFail::new("WHATEVER", "should never run");
let result = verify_lockfile_resolutions::<SilentReporter>(
&lockfile,
&[verifier as Arc<dyn ResolutionVerifier>],
&VerifyLockfileResolutionsOptions::default(),
)
.await;
assert!(result.is_ok());
}
/// All verifiers pass → success path returns `Ok`, fires
/// `Started` + `Done`, no `Failed`.
#[tokio::test]
async fn all_ok_emits_started_then_done() {
static EVENTS: Mutex<Vec<LogEvent>> = Mutex::new(Vec::new());
EVENTS.lock().unwrap().clear();
struct RecordingReporter;
impl Reporter for RecordingReporter {
fn emit(event: &LogEvent) {
EVENTS.lock().unwrap().push(event.clone());
}
}
let lockfile = parse(SINGLE_PKG_LOCKFILE);
// Verifier that always returns Ok.
let verifier = FailFor::new("UNUSED", "n/a", vec![]);
let lockfile_path = Path::new("/p/lock.yaml");
let opts = VerifyLockfileResolutionsOptions {
lockfile_path: Some(lockfile_path),
..Default::default()
};
verify_lockfile_resolutions::<RecordingReporter>(
&lockfile,
&[verifier as Arc<dyn ResolutionVerifier>],
&opts,
)
.await
.expect("all-ok must succeed");
let captured = EVENTS.lock().unwrap();
assert_eq!(captured.len(), 2, "expected Started + Done, got: {captured:?}");
match &captured[0] {
LogEvent::LockfileVerification(log) => match &log.message {
LockfileVerificationMessage::Started { entries, lockfile_path } => {
assert_eq!(*entries, 1);
assert_eq!(lockfile_path.as_deref(), Some("/p/lock.yaml"));
}
other => panic!("expected Started, got {other:?}"),
},
other => panic!("expected LockfileVerification, got {other:?}"),
}
match &captured[1] {
LogEvent::LockfileVerification(log) => match &log.message {
LockfileVerificationMessage::Done { entries, .. } => assert_eq!(*entries, 1),
other => panic!("expected Done, got {other:?}"),
},
other => panic!("expected LockfileVerification, got {other:?}"),
}
}
/// Single MIN_AGE violation → resolves to the per-policy variant
/// with that one entry in the breakdown.
#[tokio::test]
async fn single_violation_picks_per_policy_variant() {
let lockfile = parse(SINGLE_PKG_LOCKFILE);
let verifier = AlwaysFail::new("MINIMUM_RELEASE_AGE_VIOLATION", "was published yesterday");
let err = verify_lockfile_resolutions::<SilentReporter>(
&lockfile,
&[verifier as Arc<dyn ResolutionVerifier>],
&VerifyLockfileResolutionsOptions::default(),
)
.await
.expect_err("violation must surface as Err");
let VerifyError::MinimumReleaseAgeViolation { count, breakdown } = err else {
panic!("expected MinimumReleaseAgeViolation, got: {err:?}");
};
assert_eq!(count, 1);
assert!(breakdown.contains("react@17.0.2"), "got: {breakdown}");
}
/// Two verifiers with different codes both rejecting → mixed batch
/// escalates to LockfileResolutionVerification.
#[tokio::test]
async fn mixed_code_batch_escalates() {
let lockfile = parse(TWO_PKG_LOCKFILE);
let min_age = FailFor::new("MINIMUM_RELEASE_AGE_VIOLATION", "young", vec!["acme"]);
let trust = FailFor::new("TRUST_DOWNGRADE", "downgrade", vec!["bravo"]);
let err = verify_lockfile_resolutions::<SilentReporter>(
&lockfile,
&[min_age as Arc<dyn ResolutionVerifier>, trust as Arc<dyn ResolutionVerifier>],
&VerifyLockfileResolutionsOptions::default(),
)
.await
.expect_err("mixed batch must surface as Err");
let VerifyError::LockfileResolutionVerification { count, breakdown } = err else {
panic!("expected LockfileResolutionVerification, got: {err:?}");
};
assert_eq!(count, 2);
assert!(breakdown.contains("[MINIMUM_RELEASE_AGE_VIOLATION]"));
assert!(breakdown.contains("[TRUST_DOWNGRADE]"));
// Sorted by name@version: acme before bravo.
let acme = breakdown.find("acme").expect("acme present");
let bravo = breakdown.find("bravo").expect("bravo present");
assert!(acme < bravo, "expected acme before bravo: {breakdown}");
}
/// Per-candidate fan-out stops at the first verifier that rejects —
/// a single (name, version) never produces two violations even when
/// multiple verifiers would have flagged it.
#[tokio::test]
async fn per_candidate_fan_out_stops_at_first_failure() {
let lockfile = parse(SINGLE_PKG_LOCKFILE);
let first = AlwaysFail::new("MINIMUM_RELEASE_AGE_VIOLATION", "first");
let second = AlwaysFail::new("TRUST_DOWNGRADE", "second");
let violations = collect_resolution_policy_violations(
&lockfile,
&[first as Arc<dyn ResolutionVerifier>, second as Arc<dyn ResolutionVerifier>],
None,
)
.await;
assert_eq!(violations.len(), 1, "stop at first failing verifier");
assert_eq!(violations[0].code, "MINIMUM_RELEASE_AGE_VIOLATION");
}
/// `collect_resolution_policy_violations` returns the data without
/// short-circuiting on the first batch. Used by auto-collect and
/// strict-mode prompt callers.
#[tokio::test]
async fn collect_returns_data_for_all_violations() {
let lockfile = parse(TWO_PKG_LOCKFILE);
let verifier = AlwaysFail::new("MINIMUM_RELEASE_AGE_VIOLATION", "young");
let violations = collect_resolution_policy_violations(
&lockfile,
&[verifier as Arc<dyn ResolutionVerifier>],
None,
)
.await;
assert_eq!(violations.len(), 2);
}
/// Failed-path emit fires — the `Failed` variant pairs with the
/// `Started` even when the runner returns Err.
#[tokio::test]
async fn failed_path_emits_failed_terminator() {
static EVENTS: Mutex<Vec<LogEvent>> = Mutex::new(Vec::new());
EVENTS.lock().unwrap().clear();
struct RecordingReporter;
impl Reporter for RecordingReporter {
fn emit(event: &LogEvent) {
EVENTS.lock().unwrap().push(event.clone());
}
}
let lockfile = parse(SINGLE_PKG_LOCKFILE);
let verifier = AlwaysFail::new("MINIMUM_RELEASE_AGE_VIOLATION", "young");
let _ = verify_lockfile_resolutions::<RecordingReporter>(
&lockfile,
&[verifier as Arc<dyn ResolutionVerifier>],
&VerifyLockfileResolutionsOptions::default(),
)
.await;
let captured = EVENTS.lock().unwrap();
assert_eq!(captured.len(), 2, "expected Started + Failed, got: {captured:?}");
match &captured[1] {
LogEvent::LockfileVerification(log) => assert!(
matches!(log.message, LockfileVerificationMessage::Failed { .. }),
"expected Failed, got: {:?}",
log.message,
),
other => panic!("expected LockfileVerification, got {other:?}"),
}
}
/// The deduplicating-by-(name, version, resolution) candidate
/// collector means a snapshot key like `react@17.0.2(react@17.0.2)`
/// alongside the bare `react@17.0.2` would still only verify once.
/// Pacquet's `Lockfile` keeps `packages:` keyed by the bare
/// `react@17.0.2` already, so the duplicate would have to live in
/// `snapshots:` — which today's collector doesn't walk. This guards
/// the contract that a single packages-section entry produces a
/// single verification call.
#[tokio::test]
async fn one_packages_entry_yields_one_verification() {
use std::sync::atomic::{AtomicUsize, Ordering};
static CALLS: AtomicUsize = AtomicUsize::new(0);
CALLS.store(0, Ordering::SeqCst);
struct Counting {
policy: serde_json::Map<String, serde_json::Value>,
}
impl ResolutionVerifier for Counting {
fn verify<'a>(
&'a self,
_resolution: &'a LockfileResolution,
_ctx: VerifyCtx<'a>,
) -> VerifyFuture<'a> {
CALLS.fetch_add(1, Ordering::SeqCst);
Box::pin(async { ResolutionVerification::Ok })
}
fn policy(&self) -> &serde_json::Map<String, serde_json::Value> {
&self.policy
}
fn can_trust_past_check(
&self,
_cached: &serde_json::Map<String, serde_json::Value>,
) -> bool {
true
}
}
let lockfile = parse(SINGLE_PKG_LOCKFILE);
let verifier: Arc<dyn ResolutionVerifier> =
Arc::new(Counting { policy: serde_json::Map::new() });
verify_lockfile_resolutions::<SilentReporter>(
&lockfile,
&[verifier],
&VerifyLockfileResolutionsOptions::default(),
)
.await
.expect("all-ok");
assert_eq!(CALLS.load(Ordering::SeqCst), 1);
}
/// End-to-end cache wiring: a successful first run records the
/// verification; a second run against the same lockfile +
/// trustworthy verifier policies hits the cache and never invokes
/// `verify` again.
#[tokio::test]
async fn second_run_with_cache_skips_fan_out() {
static CALLS: AtomicUsize = AtomicUsize::new(0);
CALLS.store(0, Ordering::SeqCst);
struct Counting {
policy: serde_json::Map<String, serde_json::Value>,
}
impl ResolutionVerifier for Counting {
fn verify<'a>(
&'a self,
_resolution: &'a LockfileResolution,
_ctx: VerifyCtx<'a>,
) -> VerifyFuture<'a> {
CALLS.fetch_add(1, Ordering::SeqCst);
Box::pin(async { ResolutionVerification::Ok })
}
fn policy(&self) -> &serde_json::Map<String, serde_json::Value> {
&self.policy
}
fn can_trust_past_check(
&self,
_cached: &serde_json::Map<String, serde_json::Value>,
) -> bool {
true
}
}
let dir = TempDir::new().expect("tempdir");
let lockfile_path = dir.path().join("pnpm-lock.yaml");
std::fs::write(&lockfile_path, SINGLE_PKG_LOCKFILE).expect("write lockfile");
let lockfile = parse(SINGLE_PKG_LOCKFILE);
let cache_dir = dir.path().join("cache");
let verifier: Arc<dyn ResolutionVerifier> =
Arc::new(Counting { policy: serde_json::Map::new() });
let opts = VerifyLockfileResolutionsOptions {
lockfile_path: Some(&lockfile_path),
cache_dir: Some(&cache_dir),
..Default::default()
};
verify_lockfile_resolutions::<SilentReporter>(
&lockfile,
std::slice::from_ref(&verifier),
&opts,
)
.await
.expect("first run");
assert_eq!(CALLS.load(Ordering::SeqCst), 1, "first run ran the verifier");
verify_lockfile_resolutions::<SilentReporter>(
&lockfile,
std::slice::from_ref(&verifier),
&opts,
)
.await
.expect("second run");
assert_eq!(CALLS.load(Ordering::SeqCst), 1, "second run skipped via cache");
}
/// Catches a regression where `PkgName` would be passed into the
/// ctx as a borrow whose lifetime didn't outlive the future.
#[tokio::test]
async fn ctx_borrows_have_expected_lifetimes() {
let lockfile = parse(SINGLE_PKG_LOCKFILE);
let _: PkgName = "react".parse().expect("PkgName parses");
let result = verify_lockfile_resolutions::<SilentReporter>(
&lockfile,
&[],
&VerifyLockfileResolutionsOptions::default(),
)
.await;
assert!(result.is_ok());
}

View File

@@ -11,26 +11,29 @@ license.workspace = true
repository.workspace = true
[dependencies]
pacquet-cmd-shim = { workspace = true }
pacquet-directory-fetcher = { workspace = true }
pacquet-executor = { workspace = true }
pacquet-fs = { workspace = true }
pacquet-git-fetcher = { workspace = true }
pacquet-lockfile = { workspace = true }
pacquet-modules-yaml = { workspace = true }
pacquet-network = { workspace = true }
pacquet-config = { workspace = true }
pacquet-graph-hasher = { workspace = true }
pacquet-package-manifest = { workspace = true }
pacquet-package-is-installable = { workspace = true }
pacquet-patching = { workspace = true }
pacquet-real-hoist = { workspace = true }
pacquet-registry = { workspace = true }
pacquet-reporter = { workspace = true }
pacquet-store-dir = { workspace = true }
pacquet-tarball = { workspace = true }
pacquet-workspace = { workspace = true }
pacquet-workspace-state = { workspace = true }
pacquet-cmd-shim = { workspace = true }
pacquet-directory-fetcher = { workspace = true }
pacquet-executor = { workspace = true }
pacquet-fs = { workspace = true }
pacquet-git-fetcher = { workspace = true }
pacquet-lockfile = { workspace = true }
pacquet-lockfile-verification = { workspace = true }
pacquet-modules-yaml = { workspace = true }
pacquet-network = { workspace = true }
pacquet-config = { workspace = true }
pacquet-graph-hasher = { workspace = true }
pacquet-package-manifest = { workspace = true }
pacquet-package-is-installable = { workspace = true }
pacquet-patching = { workspace = true }
pacquet-real-hoist = { workspace = true }
pacquet-registry = { workspace = true }
pacquet-reporter = { workspace = true }
pacquet-resolving-npm-resolver = { workspace = true }
pacquet-resolving-resolver-base = { workspace = true }
pacquet-store-dir = { workspace = true }
pacquet-tarball = { workspace = true }
pacquet-workspace = { workspace = true }
pacquet-workspace-state = { workspace = true }
async-recursion = { workspace = true }
dashmap = { workspace = true }

View File

@@ -20,9 +20,11 @@ where
pub tarball_mem_cache: &'a MemCache,
pub resolved_packages: &'a ResolvedPackages,
pub http_client: &'a ThrottledClient,
pub http_client_arc: std::sync::Arc<ThrottledClient>,
pub config: &'static Config,
pub manifest: &'a mut PackageManifest,
pub lockfile: Option<&'a Lockfile>,
pub lockfile_path: Option<&'a std::path::Path>,
pub list_dependency_groups: ListDependencyGroups, // must be a function because it is called multiple times
pub package_name: &'a str, // TODO: 1. support version range, 2. multiple arguments, 3. name this `packages`
pub save_exact: bool, // TODO: add `save-exact` to `.npmrc`, merge configs, and remove this
@@ -53,9 +55,11 @@ where
let Add {
tarball_mem_cache,
http_client,
http_client_arc,
config,
manifest,
lockfile,
lockfile_path,
list_dependency_groups,
package_name,
save_exact,
@@ -83,9 +87,11 @@ where
Install {
tarball_mem_cache,
http_client,
http_client_arc,
config,
manifest,
lockfile,
lockfile_path,
dependency_groups: list_dependency_groups(),
frozen_lockfile: false,
skip_runtimes: config.skip_runtimes,

View File

@@ -0,0 +1,132 @@
//! Build the per-install list of [`ResolutionVerifier`]s the lockfile
//! gate fans out across. Currently only the npm-resolver verifier
//! plugs in; future resolver-side verifiers append to the same vec.
//!
//! Returning `Vec<Arc<dyn ResolutionVerifier>>` matches the runner's
//! input shape ([`pacquet_lockfile_verification::verify_lockfile_resolutions()`])
//! and lets the install path skip the call entirely when the vec is
//! empty (the runner is a no-op on `&[]`). The function never returns
//! an error; an invalid exclude pattern surfaces from
//! [`pacquet_config::version_policy::create_package_version_policy()`]
//! and propagates via [`BuildVerifiersError`].
//!
//! Mirrors the install-site wiring at
//! [`installing/deps-installer/src/install/index.ts:355-383`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/installing/deps-installer/src/install/index.ts#L355-L383),
//! where pnpm builds the verifier list from the same set of config
//! fields just before invoking `verifyLockfileResolutions`.
use std::{collections::HashMap, sync::Arc};
use derive_more::{Display, Error};
use miette::Diagnostic;
use pacquet_config::{
Config, TrustPolicy,
version_policy::{PackageVersionPolicy, VersionPolicyError, create_package_version_policy},
};
use pacquet_network::ThrottledClient;
use pacquet_resolving_npm_resolver::{
CreateNpmResolutionVerifierOptions, create_npm_resolution_verifier,
};
use pacquet_resolving_resolver_base::ResolutionVerifier;
/// Error from [`build_resolution_verifiers`]. Today the only thing
/// that can fail is `create_package_version_policy` rejecting an
/// invalid `minimumReleaseAgeExclude` / `trustPolicyExclude`
/// pattern. Wraps the inner error so the install command can route
/// the upstream diagnostic code (`ERR_PNPM_INVALID_VERSION_UNION`,
/// `ERR_PNPM_NAME_PATTERN_IN_VERSION_UNION`) without re-wrapping.
#[derive(Debug, Display, Error, Diagnostic)]
#[non_exhaustive]
pub enum BuildVerifiersError {
/// `minimumReleaseAgeExclude` had an invalid pattern.
#[display("Invalid value in minimumReleaseAgeExclude: {source}")]
#[diagnostic(code(ERR_PNPM_INVALID_MINIMUM_RELEASE_AGE_EXCLUDE))]
InvalidMinimumReleaseAgeExclude {
#[error(source)]
source: VersionPolicyError,
},
/// `trustPolicyExclude` had an invalid pattern.
#[display("Invalid value in trustPolicyExclude: {source}")]
#[diagnostic(code(ERR_PNPM_INVALID_TRUST_POLICY_EXCLUDE))]
InvalidTrustPolicyExclude {
#[error(source)]
source: VersionPolicyError,
},
}
/// 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.
pub fn build_resolution_verifiers(
config: &Config,
http_client: Arc<ThrottledClient>,
) -> Result<Vec<Arc<dyn ResolutionVerifier>>, BuildVerifiersError> {
let mut verifiers: Vec<Arc<dyn ResolutionVerifier>> = Vec::new();
let min_age_exclude = build_policy(
config.minimum_release_age_exclude.as_deref(),
BuildVerifiersError::invalid_minimum_release_age_exclude,
)?;
let trust_exclude = build_policy(
config.trust_policy_exclude.as_deref(),
BuildVerifiersError::invalid_trust_policy_exclude,
)?;
// Pacquet's `Config` carries a single registry URL; multi-scope
// routing lives in `.npmrc` parsing pacquet doesn't surface here
// yet. Build the minimal `{"default": registry}` map the verifier
// expects, so scope routing degrades to "always default".
let mut registries = HashMap::with_capacity(1);
registries.insert("default".to_string(), config.registry.clone());
let opts = CreateNpmResolutionVerifierOptions {
minimum_release_age: config.minimum_release_age,
minimum_release_age_exclude: min_age_exclude,
minimum_release_age_exclude_patterns: config
.minimum_release_age_exclude
.clone()
.unwrap_or_default(),
ignore_missing_time_field: config.minimum_release_age_ignore_missing_time,
trust_policy: match config.trust_policy {
TrustPolicy::Off => None,
TrustPolicy::NoDowngrade => Some(TrustPolicy::NoDowngrade),
},
trust_policy_exclude: trust_exclude,
trust_policy_exclude_patterns: config.trust_policy_exclude.clone().unwrap_or_default(),
trust_policy_ignore_after: config.trust_policy_ignore_after,
registries,
named_registries: HashMap::new(),
http_client,
auth_headers: Arc::clone(&config.auth_headers),
cache_dir: Some(config.cache_dir.clone()),
now: None,
};
if let Some(verifier) = create_npm_resolution_verifier(opts) {
verifiers.push(Arc::new(verifier));
}
Ok(verifiers)
}
fn build_policy(
patterns: Option<&[String]>,
wrap_error: fn(VersionPolicyError) -> BuildVerifiersError,
) -> Result<Option<PackageVersionPolicy>, BuildVerifiersError> {
let Some(patterns) = patterns else { return Ok(None) };
if patterns.is_empty() {
return Ok(None);
}
create_package_version_policy(patterns).map(Some).map_err(wrap_error)
}
impl BuildVerifiersError {
fn invalid_minimum_release_age_exclude(source: VersionPolicyError) -> Self {
BuildVerifiersError::InvalidMinimumReleaseAgeExclude { source }
}
fn invalid_trust_policy_exclude(source: VersionPolicyError) -> Self {
BuildVerifiersError::InvalidTrustPolicyExclude { source }
}
}

View File

@@ -22,10 +22,12 @@ fn make_package(name: &str, version: &str) -> PackageVersion {
tarball: format!("https://registry.npmjs.org/{name}/-/{name}-{version}.tgz"),
file_count: None,
unpacked_size: None,
attestations: None,
},
dependencies: None,
dev_dependencies: None,
peer_dependencies: None,
npm_user: None,
}
}

View File

@@ -1,8 +1,9 @@
use std::{collections::BTreeMap, sync::atomic::AtomicU8, time::SystemTime};
use std::{collections::BTreeMap, path::Path, sync::Arc, sync::atomic::AtomicU8, time::SystemTime};
use crate::{
HoistedDependencies, InstallFrozenLockfile, InstallFrozenLockfileError, InstallWithoutLockfile,
InstallWithoutLockfileError, ResolvedPackages,
BuildVerifiersError, HoistedDependencies, InstallFrozenLockfile, InstallFrozenLockfileError,
InstallWithoutLockfile, InstallWithoutLockfileError, ResolvedPackages,
build_resolution_verifiers,
};
use derive_more::{Display, Error};
use miette::Diagnostic;
@@ -10,6 +11,9 @@ use pacquet_config::{Config, NodeLinker};
use pacquet_lockfile::{
LoadLockfileError, Lockfile, SaveLockfileError, StalenessReason, satisfies_package_manifest,
};
use pacquet_lockfile_verification::{
VerifyError, VerifyLockfileResolutionsOptions, verify_lockfile_resolutions,
};
use pacquet_modules_yaml::{
DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH, Host, IncludedDependencies, LayoutVersion, Modules,
NodeLinker as ModulesNodeLinker, WriteModulesError, write_modules_manifest,
@@ -35,9 +39,26 @@ where
pub tarball_mem_cache: &'a MemCache,
pub resolved_packages: &'a ResolvedPackages,
pub http_client: &'a ThrottledClient,
/// Same client behind an [`Arc`] for the lockfile-verification
/// gate (which owns its `ThrottledClient` to outlive the
/// per-call lifetime of [`Self::http_client`]). The CLI builds
/// both from a single source; the duplicate is the smallest
/// change that bridges the borrowed `&` shape every existing
/// sub-installer expects with the owned `Arc` the verifier
/// needs.
pub http_client_arc: Arc<ThrottledClient>,
pub config: &'static Config,
pub manifest: &'a PackageManifest,
pub lockfile: Option<&'a Lockfile>,
/// Absolute path of the loaded `pnpm-lock.yaml`. Threaded into
/// the lockfile-verification gate so the per-path stat shortcut
/// in `<cache_dir>/lockfile-verified.jsonl` can fire on repeat
/// installs, and into the `pnpm:lockfile-verification` reporter
/// payload. `None` disables the cache for this run (every call
/// re-verifies) and falls back to deriving the path from
/// `workspace_root`. Mirrors upstream's `lockfilePath` argument
/// at <https://github.com/pnpm/pnpm/blob/2a9bd897bf/installing/deps-installer/src/install/index.ts#L355-L383>.
pub lockfile_path: Option<&'a Path>,
pub dependency_groups: DependencyGroupList,
pub frozen_lockfile: bool,
/// When `true`, runtime dependencies (`node@runtime:` /
@@ -145,6 +166,24 @@ pub enum InstallError {
#[diagnostic(transparent)]
FindWorkspaceDir(#[error(source)] pacquet_workspace::FindWorkspaceDirError),
/// Building the verifier list from config rejected a
/// `minimumReleaseAgeExclude` or `trustPolicyExclude` pattern.
/// Mirrors upstream's `INVALID_MINIMUM_RELEASE_AGE_EXCLUDE` /
/// `INVALID_TRUST_POLICY_EXCLUDE` codes; the inner diagnostic
/// carries the offending pattern.
#[diagnostic(transparent)]
BuildVerifiers(#[error(source)] BuildVerifiersError),
/// The lockfile-verification gate rejected one or more lockfile
/// entries — the lockfile contains versions weaker than the
/// active `minimumReleaseAge` / `trustPolicy='no-downgrade'`
/// policies allow. Transparent so the inner miette code
/// (`MINIMUM_RELEASE_AGE_VIOLATION`, `TRUST_DOWNGRADE`,
/// `LOCKFILE_RESOLUTION_VERIFICATION`) is what the user sees,
/// matching upstream's `PnpmError` codes byte-for-byte.
#[diagnostic(transparent)]
LockfileVerification(#[error(source)] VerifyError),
/// Surfaces a failure to persist `.pnpm-workspace-state-v1.json`.
/// Missing or unreadable state forces `pnpm run`'s
/// `verifyDepsBeforeRun` check to fall back to "outdated", which
@@ -165,9 +204,11 @@ where
tarball_mem_cache,
resolved_packages,
http_client,
http_client_arc,
config,
manifest,
lockfile,
lockfile_path,
dependency_groups,
frozen_lockfile,
skip_runtimes,
@@ -240,6 +281,33 @@ where
Lockfile::load_current_from_virtual_store_dir(&config.virtual_store_dir)
.map_err(InstallError::LoadCurrentLockfile)?;
// Lockfile-verification gate: re-apply `minimumReleaseAge` /
// `trustPolicy='no-downgrade'` to every entry in the loaded
// `pnpm-lock.yaml` before any resolver or fetcher runs.
// Mirrors upstream's wiring at
// <https://github.com/pnpm/pnpm/blob/2a9bd897bf/installing/deps-installer/src/install/index.ts#L355-L383>.
// `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 {
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)?;
verify_lockfile_resolutions::<Reporter>(
loaded_lockfile,
&verifiers,
&VerifyLockfileResolutionsOptions {
concurrency: None,
lockfile_path: Some(&derived_lockfile_path),
cache_dir: Some(&config.cache_dir),
},
)
.await
.map_err(InstallError::LockfileVerification)?;
}
// `pnpm:context` carries the directories pnpm's reporter prints
// in the install header. `currentLockfileExists` mirrors
// upstream's <https://github.com/pnpm/pnpm/blob/94240bc046/installing/context/src/index.ts#L196>:

View File

@@ -51,9 +51,11 @@ async fn should_install_dependencies() {
Install {
tarball_mem_cache: &Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: None,
lockfile_path: None,
dependency_groups: [DependencyGroup::Prod, DependencyGroup::Dev, DependencyGroup::Optional],
frozen_lockfile: false,
skip_runtimes: false,
@@ -106,9 +108,11 @@ async fn should_error_when_frozen_lockfile_is_requested_but_none_exists() {
let result = Install {
tarball_mem_cache: &Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: None,
lockfile_path: None,
dependency_groups: [DependencyGroup::Prod],
frozen_lockfile: true,
skip_runtimes: false,
@@ -144,9 +148,11 @@ async fn should_error_when_writable_lockfile_mode_is_used() {
let result = Install {
tarball_mem_cache: &Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: None,
lockfile_path: None,
dependency_groups: [DependencyGroup::Prod],
frozen_lockfile: false,
skip_runtimes: false,
@@ -211,9 +217,11 @@ async fn frozen_lockfile_flag_overrides_config_lockfile_false() {
Install {
tarball_mem_cache: &Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: Some(&lockfile),
lockfile_path: None,
dependency_groups: [DependencyGroup::Prod],
frozen_lockfile: true,
skip_runtimes: false,
@@ -271,9 +279,11 @@ async fn npm_alias_dependency_installs_under_alias_key() {
Install {
tarball_mem_cache: &Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: None,
lockfile_path: None,
dependency_groups: [DependencyGroup::Prod],
frozen_lockfile: false,
skip_runtimes: false,
@@ -348,9 +358,11 @@ async fn unversioned_npm_alias_defaults_to_latest() {
Install {
tarball_mem_cache: &Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: None,
lockfile_path: None,
dependency_groups: [DependencyGroup::Prod],
frozen_lockfile: false,
skip_runtimes: false,
@@ -410,9 +422,11 @@ async fn frozen_lockfile_flag_with_no_lockfile_errors() {
let result = Install {
tarball_mem_cache: &Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: None,
lockfile_path: None,
dependency_groups: [DependencyGroup::Prod],
frozen_lockfile: true,
skip_runtimes: false,
@@ -492,9 +506,11 @@ async fn install_emits_pnpm_event_sequence() {
Install {
tarball_mem_cache: &Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: Some(&lockfile),
lockfile_path: None,
dependency_groups: [DependencyGroup::Prod],
frozen_lockfile: true,
skip_runtimes: false,
@@ -628,9 +644,11 @@ async fn install_writes_modules_yaml() {
Install {
tarball_mem_cache: &Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: Some(&lockfile),
lockfile_path: None,
// Drive a non-default `included`: prod + optional, no dev,
// so the assertion below pins the mapping of dispatched
// groups to the on-disk `included` field.
@@ -723,9 +741,11 @@ async fn install_writes_workspace_state() {
Install {
tarball_mem_cache: &Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: Some(&lockfile),
lockfile_path: None,
// Same `included` shape as `install_writes_modules_yaml` so the
// dev/optional/production assertions below line up with the
// dispatched groups.
@@ -838,9 +858,11 @@ async fn install_optional_failing_postinstall_dep_via_registry_mock_succeeds() {
Install {
tarball_mem_cache: &Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: None,
lockfile_path: None,
dependency_groups: [DependencyGroup::Prod, DependencyGroup::Optional],
frozen_lockfile: false,
skip_runtimes: false,
@@ -955,9 +977,11 @@ async fn warm_reinstall_skips_snapshot_when_current_lockfile_matches() {
Install {
tarball_mem_cache: &Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: Some(&lockfile),
lockfile_path: None,
dependency_groups: [DependencyGroup::Prod],
frozen_lockfile: true,
skip_runtimes: false,
@@ -1047,9 +1071,11 @@ async fn warm_reinstall_emits_broken_modules_when_dir_is_missing() {
let _ = Install {
tarball_mem_cache: &Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: Some(&lockfile),
lockfile_path: None,
dependency_groups: [DependencyGroup::Prod],
frozen_lockfile: true,
skip_runtimes: false,
@@ -1147,9 +1173,11 @@ async fn context_log_reflects_current_lockfile_after_first_install() {
Install {
tarball_mem_cache: &Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: Some(&lockfile),
lockfile_path: None,
dependency_groups: [DependencyGroup::Prod],
frozen_lockfile: true,
skip_runtimes: false,
@@ -1191,9 +1219,11 @@ async fn context_log_reflects_current_lockfile_after_first_install() {
Install {
tarball_mem_cache: &Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: Some(&lockfile),
lockfile_path: None,
dependency_groups: [DependencyGroup::Prod],
frozen_lockfile: true,
skip_runtimes: false,
@@ -1277,9 +1307,11 @@ async fn warm_reinstall_reports_added_zero_and_emits_no_imported_events() {
Install {
tarball_mem_cache: &Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: Some(&lockfile),
lockfile_path: None,
dependency_groups: [DependencyGroup::Prod],
frozen_lockfile: true,
skip_runtimes: false,
@@ -1365,9 +1397,11 @@ async fn frozen_lockfile_errors_when_manifest_drifts_from_lockfile() {
let result = Install {
tarball_mem_cache: &Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: Some(&lockfile),
lockfile_path: None,
dependency_groups: [DependencyGroup::Prod],
frozen_lockfile: true,
skip_runtimes: false,
@@ -1417,9 +1451,11 @@ async fn frozen_lockfile_errors_when_lockfile_has_no_root_importer() {
let result = Install {
tarball_mem_cache: &Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: Some(&lockfile),
lockfile_path: None,
dependency_groups: [DependencyGroup::Prod],
frozen_lockfile: true,
skip_runtimes: false,
@@ -1499,9 +1535,11 @@ async fn frozen_lockfile_under_gvs_registers_project_and_runs_clean() {
Install {
tarball_mem_cache: &Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: Some(&lockfile),
lockfile_path: None,
dependency_groups: [DependencyGroup::Prod],
frozen_lockfile: true,
skip_runtimes: false,
@@ -1571,9 +1609,11 @@ async fn frozen_lockfile_with_gvs_off_skips_project_registry() {
Install {
tarball_mem_cache: &Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: Some(&lockfile),
lockfile_path: None,
dependency_groups: [DependencyGroup::Prod],
frozen_lockfile: true,
skip_runtimes: false,
@@ -1649,9 +1689,11 @@ async fn frozen_lockfile_under_gvs_registers_each_workspace_importer() {
Install {
tarball_mem_cache: &Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: Some(&lockfile),
lockfile_path: None,
dependency_groups: [DependencyGroup::Prod],
frozen_lockfile: true,
skip_runtimes: false,
@@ -1845,9 +1887,11 @@ async fn frozen_install_preserves_seeded_skipped_across_reinstall() {
Install {
tarball_mem_cache: &Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: Some(&lockfile),
lockfile_path: None,
dependency_groups: [DependencyGroup::Prod],
frozen_lockfile: true,
skip_runtimes: false,
@@ -1948,6 +1992,13 @@ async fn frozen_install_silently_swallows_unreachable_optional_tarball() {
// Keep retries minimal — 127.0.0.1:1 fails immediately on every
// try, but a long retry schedule would dominate the test runtime.
config.fetch_retries = 0;
// The lockfile-verification gate is unrelated to what this test
// exercises (optional-tarball swallow path). Disable
// `minimumReleaseAge` so the gate doesn't try to fetch metadata
// for `broken-pkg` against the unreachable default registry
// (which would fail closed with a verifier violation and abort
// the install before the optional-snapshot code path runs).
config.minimum_release_age = None;
let config = config.leak();
let lockfile: Lockfile = serde_saphyr::from_str(BROKEN_OPTIONAL_LOCKFILE)
@@ -1956,9 +2007,11 @@ async fn frozen_install_silently_swallows_unreachable_optional_tarball() {
Install {
tarball_mem_cache: &Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: Some(&lockfile),
lockfile_path: None,
dependency_groups: [DependencyGroup::Prod, DependencyGroup::Optional],
frozen_lockfile: true,
skip_runtimes: false,
@@ -2050,9 +2103,11 @@ async fn frozen_install_propagates_non_optional_fetch_failure() {
let result = Install {
tarball_mem_cache: &Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: Some(&lockfile),
lockfile_path: None,
dependency_groups: [DependencyGroup::Prod],
frozen_lockfile: true,
skip_runtimes: false,
@@ -2150,9 +2205,11 @@ async fn frozen_install_no_optional_drops_optional_only_snapshots() {
Install {
tarball_mem_cache: &Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: Some(&lockfile),
lockfile_path: None,
dependency_groups: [DependencyGroup::Prod],
frozen_lockfile: true,
skip_runtimes: false,
@@ -2235,9 +2292,11 @@ async fn frozen_install_optional_included_surfaces_missing_metadata() {
let result = Install {
tarball_mem_cache: &Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: Some(&lockfile),
lockfile_path: None,
dependency_groups: [DependencyGroup::Prod, DependencyGroup::Optional],
frozen_lockfile: true,
skip_runtimes: false,
@@ -2322,9 +2381,11 @@ async fn frozen_install_no_optional_keeps_shared_non_optional_snapshot() {
let result = Install {
tarball_mem_cache: &Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: Some(&lockfile),
lockfile_path: None,
// `--no-optional` shape: Optional NOT in the dispatch list.
dependency_groups: [DependencyGroup::Prod],
frozen_lockfile: true,
@@ -2408,9 +2469,11 @@ async fn hoisted_node_linker_empty_lockfile_writes_modules_yaml() {
Install {
tarball_mem_cache: &Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: Some(&lockfile),
lockfile_path: None,
dependency_groups: [DependencyGroup::Prod],
frozen_lockfile: true,
skip_runtimes: false,
@@ -2490,9 +2553,11 @@ async fn hoisted_node_linker_does_not_create_virtual_store_root() {
Install {
tarball_mem_cache: &Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: Some(&lockfile),
lockfile_path: None,
dependency_groups: [DependencyGroup::Prod],
frozen_lockfile: true,
skip_runtimes: false,
@@ -2578,9 +2643,11 @@ async fn frozen_lockfile_install_errors_when_no_variant_matches_host() {
let err = Install {
tarball_mem_cache: &Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: Some(&lockfile),
lockfile_path: None,
dependency_groups: [DependencyGroup::Prod, DependencyGroup::Optional],
frozen_lockfile: true,
skip_runtimes: false,
@@ -2664,9 +2731,11 @@ async fn frozen_lockfile_install_skips_runtime_when_skip_runtimes_set() {
Install {
tarball_mem_cache: &Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: Some(&lockfile),
lockfile_path: None,
dependency_groups: [DependencyGroup::Prod, DependencyGroup::Optional],
frozen_lockfile: true,
skip_runtimes: true,
@@ -2702,3 +2771,191 @@ async fn frozen_lockfile_install_skips_runtime_when_skip_runtimes_set() {
drop(dir);
}
/// End-to-end wiring smoke for the lockfile-verification gate
/// (Phase 7). An invalid `minimumReleaseAgeExclude` pattern (the
/// glob form is rejected when paired with a version part, per
/// upstream's `ERR_PNPM_NAME_PATTERN_IN_VERSION_UNION` arm) trips
/// `build_resolution_verifiers` before the frozen-lockfile dispatch
/// runs. The resulting `InstallError::BuildVerifiers` proves:
///
/// 1. `build_resolution_verifiers` actually fires during install.
/// 2. The error short-circuits the install — no virtual-store
/// materialization, no registry round-trip.
///
/// The gate's positive / negative `verify_lockfile_resolutions`
/// branches are exercised by the unit tests in
/// `pacquet-lockfile-verification`; this test pins only the install
/// wiring so it stays fast and doesn't depend on the mocked
/// packument shape.
#[tokio::test]
async fn install_rejects_invalid_minimum_release_age_exclude_pattern() {
let dir = tempdir().unwrap();
let store_dir = dir.path().join("pacquet-store");
let project_root = dir.path().join("project");
let modules_dir = project_root.join("node_modules");
let virtual_store_dir = modules_dir.join(".pacquet");
let manifest_path = dir.path().join("package.json");
let manifest = PackageManifest::create_if_needed(manifest_path).unwrap();
let mut config = Config::new();
config.store_dir = store_dir.into();
config.modules_dir = modules_dir;
config.virtual_store_dir = virtual_store_dir;
// Activate the verifier with an invalid exclude entry — the
// version-part-with-wildcard combination is rejected by
// `create_package_version_policy`.
config.minimum_release_age = Some(60);
config.minimum_release_age_exclude = Some(vec!["is-*@1.0.0".to_string()]);
let config = config.leak();
// Empty lockfile is enough — the gate runs as soon as
// `lockfile.is_some()` regardless of the snapshot count.
let lockfile: Lockfile = serde_saphyr::from_str(text_block! {
"lockfileVersion: '9.0'"
"importers:"
" .:"
" dependencies: {}"
"packages: {}"
"snapshots: {}"
})
.expect("parse minimal v9 lockfile");
let result = Install {
tarball_mem_cache: &Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: Some(&lockfile),
lockfile_path: None,
dependency_groups: [DependencyGroup::Prod],
frozen_lockfile: true,
skip_runtimes: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
}
.run::<SilentReporter>()
.await;
let err = result.expect_err("invalid exclude pattern must surface");
assert!(matches!(err, InstallError::BuildVerifiers(_)), "expected BuildVerifiers, got {err:?}");
// The build error must short-circuit the install before any
// virtual-store materialization runs.
assert!(
!project_root.join("node_modules/.pacquet").exists(),
"BuildVerifiers must abort before virtual-store materialization",
);
drop(dir);
}
/// Positive-path proof that `verify_lockfile_resolutions` runs from
/// inside `Install::run`. With `minimumReleaseAge` set absurdly high
/// (100 years), every version the mocked registry knows about is
/// inside the cutoff, so the gate rejects every lockfile entry
/// before any tarball is fetched.
///
/// Asserts:
///
/// 1. `Install::run` returns `Err(InstallError::LockfileVerification(...))`
/// with the inner `VerifyError::MinimumReleaseAgeViolation` —
/// i.e. the verifier code path actually ran and returned a
/// violation, the wiring is correct, and the dispatch maps the
/// inner code to the per-policy variant rather than collapsing
/// to the generic envelope.
/// 2. No virtual-store materialization. The gate fails before
/// `InstallFrozenLockfile` runs, so neither the slot nor the
/// project's `node_modules` symlink exist.
#[tokio::test]
async fn frozen_lockfile_gate_rejects_under_huge_minimum_release_age() {
let mock_instance = AutoMockInstance::load_or_init();
let dir = tempdir().unwrap();
let store_dir = dir.path().join("pacquet-store");
let project_root = dir.path().join("project");
let modules_dir = project_root.join("node_modules");
let virtual_store_dir = modules_dir.join(".pacquet");
let manifest_path = dir.path().join("package.json");
let mut manifest = PackageManifest::create_if_needed(manifest_path).unwrap();
manifest
.add_dependency("@pnpm.e2e/hello-world-js-bin", "1.0.0", DependencyGroup::Prod)
.unwrap();
manifest.save().unwrap();
let mut config = Config::new();
config.store_dir = store_dir.into();
config.modules_dir = modules_dir;
config.virtual_store_dir = virtual_store_dir;
config.registry = mock_instance.url();
// 100 years in minutes. Anything the registry has shipped to
// date is inside the cutoff, so the publish-time check rejects
// every lockfile entry regardless of what the mocked packument's
// `time` map actually says.
config.minimum_release_age = Some(60 * 24 * 365 * 100);
let config = config.leak();
// The integrity hash here is placeholder text — the gate fails
// before the tarball is fetched, so checksum verification never
// runs and the value doesn't have to match the mock's actual
// payload. The lockfile only needs to deserialize.
let lockfile: Lockfile = serde_saphyr::from_str(text_block! {
"lockfileVersion: '9.0'"
"importers:"
" .:"
" dependencies:"
" '@pnpm.e2e/hello-world-js-bin':"
" specifier: 1.0.0"
" version: 1.0.0"
"packages:"
" '@pnpm.e2e/hello-world-js-bin@1.0.0':"
" resolution: {integrity: sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==}"
"snapshots:"
" '@pnpm.e2e/hello-world-js-bin@1.0.0': {}"
})
.expect("parse lockfile fixture");
let result = Install {
tarball_mem_cache: &Default::default(),
http_client: &Default::default(),
http_client_arc: std::sync::Arc::new(Default::default()),
config,
manifest: &manifest,
lockfile: Some(&lockfile),
lockfile_path: None,
dependency_groups: [DependencyGroup::Prod],
frozen_lockfile: true,
skip_runtimes: false,
supported_architectures: None,
node_linker: pacquet_config::NodeLinker::default(),
resolved_packages: &Default::default(),
}
.run::<SilentReporter>()
.await;
let err = result.expect_err("100-year cutoff must reject every entry");
let InstallError::LockfileVerification(ref verify_err) = err else {
panic!("expected InstallError::LockfileVerification, got {err:?}");
};
assert!(
matches!(
verify_err,
pacquet_lockfile_verification::VerifyError::MinimumReleaseAgeViolation { .. }
),
"expected MinimumReleaseAgeViolation, got {verify_err:?}",
);
// The gate must short-circuit before any virtual-store
// materialization — no slot, no project-side symlink.
let slot = project_root.join("node_modules/.pacquet/@pnpm.e2e+hello-world-js-bin@1.0.0");
assert!(!slot.exists(), "the gate must fail before any virtual-store materialization");
assert!(
!project_root.join("node_modules/@pnpm.e2e/hello-world-js-bin").exists(),
"the gate must fail before any project-side symlinks are created",
);
drop((dir, mock_instance));
}

View File

@@ -59,6 +59,14 @@ fn create_config(store_dir: &Path, modules_dir: &Path, virtual_store_dir: &Path)
git_shallow_hosts: pacquet_config::default_git_shallow_hosts(),
supported_architectures: None,
ignored_optional_dependencies: None,
cache_dir: tempdir().unwrap().keep(),
minimum_release_age: None,
minimum_release_age_exclude: None,
minimum_release_age_ignore_missing_time: true,
minimum_release_age_strict: None,
trust_policy: Default::default(),
trust_policy_exclude: None,
trust_policy_ignore_after: None,
auth_headers: Default::default(),
proxy: Default::default(),
tls: Default::default(),

View File

@@ -1,5 +1,6 @@
mod add;
mod build_modules;
mod build_resolution_verifiers;
mod build_sequence;
mod build_snapshot;
mod create_symlink_layout;
@@ -29,6 +30,7 @@ mod virtual_store_layout;
pub use add::*;
pub use build_modules::*;
pub use build_resolution_verifiers::*;
pub use build_sequence::*;
pub use build_snapshot::*;
pub use create_symlink_layout::*;

View File

@@ -1,159 +1,12 @@
//! Parse and expand `<name>[@<version>[||<version>...]]` specs from
//! `pnpm-workspace.yaml`'s `allowBuilds` (and analogous policy keys).
//! Back-compat re-export of the `expand_package_version_specs`
//! function that originally lived here.
//!
//! Ports the [`expandPackageVersionSpecs`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/config/version-policy/src/index.ts#L17-L29)
//! half of upstream's `config/version-policy` crate, plus the
//! `parseVersionPolicyRule` and `parseExactVersionsUnion` helpers
//! it builds on
//! ([`config/version-policy/src/index.ts:56-91`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/config/version-policy/src/index.ts#L56-L91)).
//!
//! What this supports:
//!
//! - Bare name → `foo`, `@scope/foo`.
//! - Exact version → `foo@1.0.0`, `@scope/foo@1.0.0`.
//! - Exact-version union → `foo@1.0.0 || 2.0.0`. Each version is
//! parsed strictly (the JS-side uses `semver.valid`); whitespace
//! around `||` and within versions is trimmed.
//!
//! What this does NOT support — matching upstream's
//! [`'should not allow patterns in allowBuilds'`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/building/policy/test/index.ts#L28-L34)
//! test:
//!
//! - Wildcards in the name (`is-*`, `@scope/*`). Upstream's
//! `createAllowBuildFunction` uses `.has()` on the expanded set,
//! so a `*` in the spec ends up as a literal `*` in the set and
//! never matches a real package name. Pacquet preserves that
//! semantics — `*` is allowed in the parsed name when there's no
//! version part (so the literal string lands in the output set),
//! but it does NOT match real packages. Combining `*` with a
//! version is explicitly rejected as
//! [`ERR_PNPM_NAME_PATTERN_IN_VERSION_UNION`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/config/version-policy/src/index.ts#L73-L75)
//! (the same diagnostic code upstream emits).
//!
//! Note: `createPackageVersionPolicy` (which DOES support
//! wildcards via `Matcher`) is a separate upstream function used
//! by `minimumReleaseAgeExclude` / `dlx` — pacquet doesn't have
//! those features yet, so only `expand_package_version_specs` is
//! ported.
//! The implementation moved to
//! [`pacquet_config::version_policy`] so the matcher-based
//! [`create_package_version_policy`](pacquet_config::version_policy::create_package_version_policy)
//! sibling — needed by `minimumReleaseAgeExclude` /
//! `trustPolicyExclude` — can share the same parser. This module
//! keeps the old import path working for `build_modules.rs`'s
//! `allowBuilds` consumer.
use derive_more::{Display, Error};
use miette::Diagnostic;
use node_semver::Version;
use std::collections::HashSet;
/// Error from [`expand_package_version_specs`].
#[derive(Debug, Display, Error, Diagnostic)]
pub enum VersionPolicyError {
/// One of the versions in a `||` union didn't parse as a valid
/// exact semver. Mirrors upstream's
/// [`ERR_PNPM_INVALID_VERSION_UNION`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/config/version-policy/src/index.ts#L67-L69).
#[display("Invalid versions union. Found: \"{pattern}\". Use exact versions only.")]
#[diagnostic(code(ERR_PNPM_INVALID_VERSION_UNION))]
InvalidVersionUnion {
#[error(not(source))]
pattern: String,
},
/// A `*` wildcard in the package name AND a version part were
/// combined. Upstream rejects this because the matcher built on
/// top of the expanded set is `.has()` — wildcards would be
/// non-functional. Mirrors
/// [`ERR_PNPM_NAME_PATTERN_IN_VERSION_UNION`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/config/version-policy/src/index.ts#L73-L75).
#[display("Name patterns are not allowed with version unions. Found: \"{pattern}\"")]
#[diagnostic(code(ERR_PNPM_NAME_PATTERN_IN_VERSION_UNION))]
NamePatternInVersionUnion {
#[error(not(source))]
pattern: String,
},
}
/// Expand each spec into one or more `name` / `name@version` literal
/// strings.
///
/// Output shape:
///
/// - Bare `foo` → `{"foo"}`.
/// - `foo@1.0.0` → `{"foo@1.0.0"}`.
/// - `foo@1.0.0 || 2.0.0` → `{"foo@1.0.0", "foo@2.0.0"}`.
/// - `@scope/foo@1.0.0` → `{"@scope/foo@1.0.0"}`.
///
/// Ports upstream's
/// [`expandPackageVersionSpecs`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/config/version-policy/src/index.ts#L17-L29).
/// Callers feed the result into a `HashSet::contains` check, so a
/// pattern like `is-*` lands in the set as the literal string
/// `"is-*"` and never matches a real package name (matches upstream
/// behavior exactly — see `should not allow patterns in allowBuilds`
/// in `building/policy/test/index.ts`).
pub fn expand_package_version_specs<Iter, Spec>(
specs: Iter,
) -> Result<HashSet<String>, VersionPolicyError>
where
Iter: IntoIterator<Item = Spec>,
Spec: AsRef<str>,
{
let mut out: HashSet<String> = HashSet::new();
for spec in specs {
let parsed = parse_version_policy_rule(spec.as_ref())?;
if parsed.exact_versions.is_empty() {
out.insert(parsed.package_name.to_string());
} else {
for version in parsed.exact_versions {
out.insert(format!("{}@{}", parsed.package_name, version));
}
}
}
Ok(out)
}
/// Parsed `<name>[@<version-union>]` rule. Either `exact_versions`
/// is empty (bare name) or it contains one or more concrete semver
/// strings. Mixing a `*` wildcard in the name with a version part
/// is rejected by the caller before this struct is returned.
struct ParsedRule<'a> {
package_name: &'a str,
exact_versions: Vec<String>,
}
fn parse_version_policy_rule(pattern: &str) -> Result<ParsedRule<'_>, VersionPolicyError> {
// Scoped name (`@scope/foo`) starts with `@`, so the version
// separator is the *second* `@`. Otherwise the first.
let at_index = if pattern.starts_with('@') {
pattern.char_indices().skip(1).find_map(|(i, c)| (c == '@').then_some(i))
} else {
pattern.find('@')
};
let Some(at) = at_index else {
return Ok(ParsedRule { package_name: pattern, exact_versions: Vec::new() });
};
let package_name = &pattern[..at];
let versions_part = &pattern[at + 1..];
let exact_versions = parse_exact_versions_union(versions_part)
.ok_or_else(|| VersionPolicyError::InvalidVersionUnion { pattern: pattern.to_string() })?;
if package_name.contains('*') {
return Err(VersionPolicyError::NamePatternInVersionUnion { pattern: pattern.to_string() });
}
Ok(ParsedRule { package_name, exact_versions })
}
/// Parse `v1 || v2 || …` into a list of strict semver versions.
/// Returns `None` if any component fails to parse — the caller
/// surfaces that as `ERR_PNPM_INVALID_VERSION_UNION`. Whitespace
/// around `||` and around each version is trimmed before parsing
/// (matches Node-semver's `valid()` which trims internally).
fn parse_exact_versions_union(versions_str: &str) -> Option<Vec<String>> {
let mut out: Vec<String> = Vec::new();
for raw in versions_str.split("||") {
let trimmed = raw.trim();
let version = Version::parse(trimmed).ok()?;
out.push(version.to_string());
}
Some(out)
}
#[cfg(test)]
mod tests;
pub use pacquet_config::version_policy::{VersionPolicyError, expand_package_version_specs};

View File

@@ -1,98 +0,0 @@
use crate::version_policy::{VersionPolicyError, expand_package_version_specs};
use pretty_assertions::assert_eq;
fn expand(specs: &[&str]) -> Vec<String> {
let mut out: Vec<String> =
expand_package_version_specs(specs.iter().copied()).unwrap().into_iter().collect();
out.sort();
out
}
#[test]
fn bare_name_expands_verbatim() {
assert_eq!(expand(&["foo"]), vec!["foo".to_string()]);
}
#[test]
fn scoped_bare_name_expands_verbatim() {
assert_eq!(expand(&["@scope/foo"]), vec!["@scope/foo".to_string()]);
}
#[test]
fn name_at_exact_version_expands_to_one_literal() {
assert_eq!(expand(&["foo@1.0.0"]), vec!["foo@1.0.0".to_string()]);
}
#[test]
fn scoped_name_at_exact_version_expands_to_one_literal() {
assert_eq!(expand(&["@scope/foo@1.2.3"]), vec!["@scope/foo@1.2.3".to_string()]);
}
/// Mirrors the upstream test case
/// [`'should allowBuilds with true value'`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/building/policy/test/index.ts#L5-L15)
/// — the spec `qar@1.0.0 || 2.0.0` expands into two literal
/// `qar@1.0.0` and `qar@2.0.0` entries.
#[test]
fn version_union_expands_into_separate_literals() {
let result = expand(&["qar@1.0.0 || 2.0.0"]);
assert_eq!(result, vec!["qar@1.0.0".to_string(), "qar@2.0.0".to_string()]);
}
#[test]
fn version_union_trims_whitespace_around_each_version() {
// Extra whitespace around `||` and around each version. Mirrors
// semver-js's `valid()` which trims internally before parsing.
let result = expand(&["foo@ 1.0.0 || 2.0.0 "]);
assert_eq!(result, vec!["foo@1.0.0".to_string(), "foo@2.0.0".to_string()]);
}
/// Mirrors upstream's
/// [`'should not allow patterns in allowBuilds'`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/building/policy/test/index.ts#L28-L34)
/// — a pattern with `*` in the name lands in the expanded set as
/// the literal string `is-*`. Downstream callers use
/// `HashSet::contains` (mirroring upstream's `.has()`), so a real
/// package name like `is-odd` does NOT match `is-*`. The expansion
/// itself doesn't error.
#[test]
fn name_with_wildcard_alone_is_kept_verbatim() {
assert_eq!(expand(&["is-*"]), vec!["is-*".to_string()]);
}
/// Combining a wildcard in the name with a version part is
/// explicitly an error (`ERR_PNPM_NAME_PATTERN_IN_VERSION_UNION`).
/// Mirrors upstream
/// [`config/version-policy/src/index.ts:73-75`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/config/version-policy/src/index.ts#L73-L75).
#[test]
fn wildcard_name_with_version_errors() {
let err = expand_package_version_specs(["foo*@1.0.0"]).expect_err("must reject");
assert!(matches!(err, VersionPolicyError::NamePatternInVersionUnion { .. }), "got: {err:?}");
}
/// Mirrors upstream
/// [`config/version-policy/src/index.ts:67-69`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/config/version-policy/src/index.ts#L67-L69)
/// — a `||` member that isn't valid semver triggers
/// `ERR_PNPM_INVALID_VERSION_UNION`.
#[test]
fn non_semver_version_in_union_errors() {
let err = expand_package_version_specs(["foo@not-a-version"]).expect_err("must reject");
assert!(matches!(err, VersionPolicyError::InvalidVersionUnion { .. }), "got: {err:?}");
}
#[test]
fn mixed_valid_invalid_union_errors() {
let err =
expand_package_version_specs(["foo@1.0.0 || not-a-version"]).expect_err("must reject");
assert!(matches!(err, VersionPolicyError::InvalidVersionUnion { .. }), "got: {err:?}");
}
#[test]
fn empty_input_yields_empty_set() {
let result = expand_package_version_specs::<_, &str>([]).unwrap();
assert!(result.is_empty());
}
#[test]
fn duplicate_specs_collapse_in_set() {
let result = expand(&["foo", "foo", "foo@1.0.0 || 1.0.0"]);
assert_eq!(result, vec!["foo".to_string(), "foo@1.0.0".to_string()]);
}

View File

@@ -4,9 +4,9 @@ mod package_tag;
mod package_version;
pub use package::Package;
pub use package_distribution::PackageDistribution;
pub use package_distribution::{AttestationsDist, PackageDistribution, ProvenanceMeta};
pub use package_tag::PackageTag;
pub use package_version::PackageVersion;
pub use package_version::{NpmUser, PackageVersion, TrustedPublisher};
use derive_more::{Display, Error, From};
use miette::Diagnostic;

View File

@@ -16,10 +16,59 @@ pub struct Package {
dist_tags: HashMap<String, String>,
pub versions: HashMap<String, PackageVersion>,
/// Per-version publish timestamps as the npm registry reports
/// them. Each key is either a version string (value: ISO-8601
/// timestamp) or the reserved `unpublished` key (value: object).
/// The map is typed as `serde_json::Value` so the reserved key's
/// object value can round-trip alongside the per-version
/// timestamps without a custom deserializer.
///
/// Mirrors pnpm's
/// [`PackageMeta.time`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/registry/types/src/index.ts#L11)
/// and is the input to the `minimumReleaseAge` verifier. Use
/// [`Self::published_at`] for the typed per-version lookup.
///
/// Optional — abbreviated metadata responses (`application/vnd.npm.install-v1+json`)
/// omit this field; only the full-metadata fetcher used by the
/// verifier sees it populated.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub time: Option<HashMap<String, serde_json::Value>>,
/// Package-level "last modified" timestamp the abbreviated
/// metadata endpoint sends. The verifier's
/// `tryAbbreviatedModifiedShortcut` reads this as a conservative
/// upper bound on every version's publish time — if `modified`
/// is older than the policy cutoff, every version in this
/// package was published at least that long ago.
///
/// Mirrors pnpm's
/// [`PackageMeta.modified`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/registry/types/src/index.ts#L12).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub modified: Option<String>,
/// Last `ETag` the registry returned when this manifest was
/// fetched. Threaded into `If-None-Match` on the next
/// conditional GET by the cached metadata fetcher (Phase 5).
///
/// Mirrors pnpm's
/// [`PackageMeta.etag`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/registry/types/src/index.ts#L13).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub etag: Option<String>,
#[serde(skip_serializing, skip_deserializing)]
pub mutex: Arc<Mutex<u8>>,
}
impl Package {
/// Resolved publish timestamp for `version`, or `None` when the
/// registry didn't report one for that pin. Filters out the
/// reserved `unpublished` key (which is an object, not a string)
/// and any version slot whose value isn't a string.
pub fn published_at(&self, version: &str) -> Option<&str> {
self.time.as_ref()?.get(version)?.as_str()
}
}
impl PartialEq for Package {
fn eq(&self, other: &Self) -> bool {
self.name == other.name

View File

@@ -19,6 +19,7 @@ pub fn package_version_should_include_peers() {
dependencies: Some(dependencies),
dev_dependencies: None,
peer_dependencies: Some(peer_dependencies),
npm_user: None,
};
let dependencies = |peer| version.dependencies(peer).collect::<HashMap<_, _>>();
@@ -38,6 +39,7 @@ pub fn serialized_according_to_params() {
dependencies: None,
dev_dependencies: None,
peer_dependencies: None,
npm_user: None,
};
assert_eq!(version.serialize(true), "3.2.1");
@@ -91,13 +93,22 @@ fn package_with_versions(name: &str, versions: &[&str], latest: &str) -> Package
dependencies: None,
dev_dependencies: None,
peer_dependencies: None,
npm_user: None,
},
)
})
.collect();
let mut dist_tags = HashMap::new();
dist_tags.insert("latest".to_string(), latest.to_string());
Package { name: name.to_string(), dist_tags, versions: versions_map, mutex: Default::default() }
Package {
name: name.to_string(),
dist_tags,
versions: versions_map,
time: None,
modified: None,
etag: None,
mutex: Default::default(),
}
}
/// `Package` equality is by `name` only; the mutex and versions
@@ -141,3 +152,134 @@ fn pinned_version_returns_none_when_no_match() {
let pkg = package_with_versions("acme", &["1.0.0", "1.2.0"], "1.2.0");
assert!(pkg.pinned_version("^2.0.0").is_none());
}
/// A real-shape packument carries `time`, `modified`, `_npmUser`,
/// and `dist.attestations.provenance` — the four bits the
/// `minimumReleaseAge` + `trustPolicy='no-downgrade'` verifier
/// consults. All four must round-trip through serde, and the
/// per-version `time` lookup must resolve through
/// [`Package::published_at`].
#[test]
fn package_deserializes_full_provenance_packument() {
let body = r#"{
"name": "acme",
"dist-tags": { "latest": "1.0.0" },
"modified": "2025-01-15T12:00:00.000Z",
"etag": "\"abc123\"",
"time": {
"created": "2025-01-01T00:00:00.000Z",
"modified": "2025-01-15T12:00:00.000Z",
"1.0.0": "2025-01-10T08:30:00.000Z"
},
"versions": {
"1.0.0": {
"name": "acme",
"version": "1.0.0",
"_npmUser": {
"name": "alice",
"email": "alice@example.com",
"trustedPublisher": {
"id": "github",
"oidcConfigId": "release-pipeline"
}
},
"dist": {
"integrity": "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"shasum": "0000000000000000000000000000000000000000",
"tarball": "https://registry/acme-1.0.0.tgz",
"attestations": {
"provenance": { "predicateType": "https://slsa.dev/provenance/v1" },
"url": "https://registry/-/npm/v1/attestations/acme@1.0.0"
}
}
}
}
}"#;
let pkg: Package = serde_json::from_str(body).expect("deserialize full packument");
assert_eq!(pkg.name, "acme");
assert_eq!(pkg.modified.as_deref(), Some("2025-01-15T12:00:00.000Z"));
assert_eq!(pkg.etag.as_deref(), Some(r#""abc123""#));
assert_eq!(pkg.published_at("1.0.0"), Some("2025-01-10T08:30:00.000Z"));
assert_eq!(pkg.published_at("9.9.9"), None);
let version = pkg.versions.get("1.0.0").expect("1.0.0 deserialized");
let user = version.npm_user.as_ref().expect("_npmUser present");
let publisher = user.trusted_publisher.as_ref().expect("trustedPublisher present");
assert_eq!(publisher.id, "github");
assert_eq!(publisher.oidc_config_id, "release-pipeline");
let attestations = version.dist.attestations.as_ref().expect("attestations present");
let provenance = attestations.provenance.as_ref().expect("provenance present");
assert_eq!(provenance.predicate_type.as_deref(), Some("https://slsa.dev/provenance/v1"));
}
/// A packument that doesn't ship `_npmUser` or `attestations` (the
/// common case for older registries) still deserializes; the
/// trust-evidence fields land as `None` and the trust check that
/// reads them treats absence as "no evidence".
#[test]
fn package_deserializes_without_npm_user_or_attestations() {
let body = r#"{
"name": "acme",
"dist-tags": { "latest": "1.0.0" },
"time": { "1.0.0": "2025-01-10T08:30:00.000Z" },
"versions": {
"1.0.0": {
"name": "acme",
"version": "1.0.0",
"dist": {
"integrity": "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"shasum": "0000000000000000000000000000000000000000",
"tarball": "https://registry/acme-1.0.0.tgz"
}
}
}
}"#;
let pkg: Package = serde_json::from_str(body).expect("deserialize minimal packument");
let version = pkg.versions.get("1.0.0").expect("1.0.0 deserialized");
assert!(version.npm_user.is_none(), "missing _npmUser stays None");
assert!(version.dist.attestations.is_none(), "missing attestations stays None");
assert!(pkg.modified.is_none(), "missing modified stays None");
assert!(pkg.etag.is_none(), "missing etag stays None");
}
/// A packument missing the `time` field entirely still
/// deserializes — abbreviated metadata responses omit it, and the
/// verifier falls through to the attestation / full-metadata
/// layers in that case rather than failing closed.
#[test]
fn package_deserializes_without_time_field() {
let body = r#"{
"name": "acme",
"dist-tags": { "latest": "1.0.0" },
"modified": "2025-01-15T12:00:00.000Z",
"versions": {}
}"#;
let pkg: Package = serde_json::from_str(body).expect("deserialize without time");
assert!(pkg.time.is_none(), "missing time stays None");
assert!(pkg.published_at("1.0.0").is_none(), "no per-version lookup possible");
}
/// The reserved `time.unpublished` key carries an object value
/// (not a string). [`Package::published_at`] must ignore it
/// instead of returning the object's serialized form. Mirrors the
/// upstream shape at
/// <https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/registry/types/src/index.ts#L20-L25>.
#[test]
fn published_at_skips_reserved_unpublished_object() {
let body = r#"{
"name": "acme",
"dist-tags": {},
"time": {
"1.0.0": "2025-01-10T08:30:00.000Z",
"unpublished": {
"time": "2025-02-01T00:00:00.000Z",
"versions": ["0.9.0"]
}
},
"versions": {}
}"#;
let pkg: Package = serde_json::from_str(body).expect("deserialize");
assert_eq!(pkg.published_at("1.0.0"), Some("2025-01-10T08:30:00.000Z"));
assert_eq!(pkg.published_at("unpublished"), None, "object value isn't a string");
}

View File

@@ -9,6 +9,46 @@ pub struct PackageDistribution {
pub tarball: String,
pub file_count: Option<usize>,
pub unpacked_size: Option<usize>,
/// Sigstore-based supply-chain evidence the npm registry attaches
/// to a published version. When `provenance` is present the
/// version was published with a Sigstore attestation linking it
/// to its source repo and CI run; `url` points at the
/// `/-/npm/v1/attestations/<name>@<version>` endpoint that serves
/// the raw bundle.
///
/// Mirrors pnpm's
/// [`PackageInRegistry.dist.attestations`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/registry/types/src/index.ts#L52-L57).
/// Read by the `trustPolicy='no-downgrade'` verifier when it
/// decides whether a version's trust evidence is weaker than
/// an earlier-published one's.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attestations: Option<AttestationsDist>,
}
/// Container for the attestation evidence a version exposes on its
/// `dist.attestations` field. Right now the only value the verifier
/// reads is `provenance`; the `url` field is the registry's link to
/// the raw Sigstore bundle and is kept for round-trip parity.
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AttestationsDist {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<ProvenanceMeta>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
/// Provenance attestation marker. The presence of this object on
/// `dist.attestations.provenance` is what counts as the "provenance"
/// rank for [`getTrustEvidence`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/trustChecks.ts#L119-L127);
/// the inner `predicateType` field is kept for round-trip parity but
/// the verifier itself does not inspect it.
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProvenanceMeta {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub predicate_type: Option<String>,
}
impl PartialEq for PackageDistribution {

View File

@@ -15,6 +15,50 @@ pub struct PackageVersion {
pub dependencies: Option<HashMap<String, String>>,
pub dev_dependencies: Option<HashMap<String, String>>,
pub peer_dependencies: Option<HashMap<String, String>>,
/// npm registry's per-version publisher metadata. When
/// `trusted_publisher` is present the version was published
/// through an OIDC-backed trusted-publisher integration, which
/// counts as the higher (`trustedPublisher`) trust rank that
/// upstream's [`getTrustEvidence`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/trustChecks.ts#L119-L127)
/// checks before falling back to the `provenance` attestation
/// rank.
///
/// Mirrors pnpm's
/// [`PackageInRegistry._npmUser`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/registry/types/src/index.ts#L29-L36)
/// (note the leading underscore on the wire).
#[serde(
default,
rename = "_npmUser",
skip_serializing_if = "Option::is_none",
alias = "_npm_user"
)]
pub npm_user: Option<NpmUser>,
}
/// `_npmUser` field on a per-version manifest. The verifier reads
/// `trusted_publisher` to assign the higher of the two trust ranks
/// (`trustedPublisher` > `provenance` > none). `name` / `email` are
/// kept for round-trip parity.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NpmUser {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trusted_publisher: Option<TrustedPublisher>,
}
/// OIDC trusted-publisher record on `_npmUser.trustedPublisher`.
/// The verifier only checks for the field's presence; the inner
/// values are kept for round-trip parity.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TrustedPublisher {
pub id: String,
pub oidc_config_id: String,
}
impl PartialEq for PackageVersion {

View File

@@ -175,6 +175,18 @@ pub enum LogEvent {
/// (per-snapshot emit site).
#[serde(rename = "pnpm:_broken_node_modules")]
BrokenModules(BrokenModulesLog),
/// Lockfile-verification gate progress (`pnpm:lockfile-verification`).
/// One `started` event before the fan-out, followed by exactly one
/// terminal `done` (success) or `failed` (policy violation or
/// unexpected throw). Fires only when the candidate set is
/// non-empty — a lockfile whose snapshots all fail name/version
/// extraction produces no events.
///
/// Upstream: <https://github.com/pnpm/pnpm/blob/2a9bd897bf/core/core-loggers/src/lockfileVerificationLogger.ts>.
/// Emit site: <https://github.com/pnpm/pnpm/blob/2a9bd897bf/installing/deps-installer/src/install/verifyLockfileResolutions.ts#L134-L168>.
#[serde(rename = "pnpm:lockfile-verification")]
LockfileVerification(LockfileVerificationLog),
}
/// `pnpm:context` payload.
@@ -645,6 +657,54 @@ pub struct BrokenModulesLog {
pub missing: String,
}
/// `pnpm:lockfile-verification` payload. The [bunyan]-envelope `level`
/// is a fixed outer field; the rest of the record is a status-tagged
/// union via `#[serde(flatten)]` so the wire shape stays flat
/// (matching pnpm's `LockfileVerificationMessage` discriminator on
/// `status`).
///
/// [bunyan]: https://github.com/trentm/node-bunyan
#[derive(Debug, Clone, Serialize)]
pub struct LockfileVerificationLog {
pub level: LogLevel,
#[serde(flatten)]
pub message: LockfileVerificationMessage,
}
/// `pnpm:lockfile-verification` discriminated payload. `Started`
/// fires once before the per-candidate fan-out begins; exactly one
/// terminal `Done` or `Failed` fires after, with `elapsed_ms`
/// measured against the matching `Started`.
///
/// `lockfile_path` is the absolute path of the lockfile being
/// verified. It's `Option` because the runner is invoked without a
/// path in unit tests that skip the cache wiring; production code
/// paths always supply it. Mirrors upstream's
/// [`LockfileVerificationMessageBase.lockfilePath`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/core/core-loggers/src/lockfileVerificationLogger.ts#L11-L16).
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum LockfileVerificationMessage {
Started {
entries: u64,
#[serde(rename = "lockfilePath", skip_serializing_if = "Option::is_none")]
lockfile_path: Option<String>,
},
Done {
entries: u64,
#[serde(rename = "elapsedMs")]
elapsed_ms: u64,
#[serde(rename = "lockfilePath", skip_serializing_if = "Option::is_none")]
lockfile_path: Option<String>,
},
Failed {
entries: u64,
#[serde(rename = "elapsedMs")]
elapsed_ms: u64,
#[serde(rename = "lockfilePath", skip_serializing_if = "Option::is_none")]
lockfile_path: Option<String>,
},
}
/// Severity level on the [bunyan]-shaped envelope.
///
/// pnpm's logger uses the [bole] library, which writes one of these strings

View File

@@ -7,11 +7,11 @@ use serde_json::Value;
use crate::{
AddedRoot, BrokenModulesLog, ContextLog, DependencyType, Envelope, FetchingProgressLog,
FetchingProgressMessage, GetHostName, Host, IgnoredScriptsLog, LifecycleLog, LifecycleMessage,
LifecycleStdio, LogEvent, LogLevel, PackageImportMethod, PackageImportMethodLog,
PackageManifestLog, PackageManifestMessage, ProgressLog, ProgressMessage, RemovedRoot,
Reporter, RequestRetryError, RequestRetryLog, RootLog, RootMessage, SilentReporter,
SkippedOptionalDependencyLog, SkippedOptionalPackage, SkippedOptionalReason, Stage, StageLog,
StatsLog, StatsMessage, SummaryLog,
LifecycleStdio, LockfileVerificationLog, LockfileVerificationMessage, LogEvent, LogLevel,
PackageImportMethod, PackageImportMethodLog, PackageManifestLog, PackageManifestMessage,
ProgressLog, ProgressMessage, RemovedRoot, Reporter, RequestRetryError, RequestRetryLog,
RootLog, RootMessage, SilentReporter, SkippedOptionalDependencyLog, SkippedOptionalPackage,
SkippedOptionalReason, Stage, StageLog, StatsLog, StatsMessage, SummaryLog,
};
/// Context log serializes with the camelCase field names
@@ -800,6 +800,108 @@ fn recording_fake_captures_emitted_events() {
assert!(matches!(&captured[1], LogEvent::Stage(StageLog { stage: Stage::ImportingDone, .. })));
}
/// `pnpm:lockfile-verification` `started` event carries `entries` and
/// the camelCase `lockfilePath`, both flattened into the envelope
/// alongside `status: "started"`. Mirrors upstream's emit at
/// <https://github.com/pnpm/pnpm/blob/2a9bd897bf/installing/deps-installer/src/install/verifyLockfileResolutions.ts#L135-L139>.
#[test]
fn lockfile_verification_started_event_matches_pnpm_wire_shape() {
let event = LogEvent::LockfileVerification(LockfileVerificationLog {
level: LogLevel::Debug,
message: LockfileVerificationMessage::Started {
entries: 12,
lockfile_path: Some("/proj/pnpm-lock.yaml".to_string()),
},
});
let envelope = Envelope { time: 1_700_000_000_000, hostname: "host", pid: 4242, event: &event };
let json: Value = envelope
.pipe_ref(serde_json::to_string)
.expect("serialize envelope")
.pipe_as_ref(serde_json::from_str)
.expect("parse JSON");
assert_eq!(json["name"], "pnpm:lockfile-verification");
assert_eq!(json["level"], "debug");
assert_eq!(json["status"], "started");
assert_eq!(json["entries"], 12);
assert_eq!(json["lockfilePath"], "/proj/pnpm-lock.yaml");
assert!(json.get("elapsedMs").is_none(), "elapsedMs must be absent on started");
}
/// `pnpm:lockfile-verification` `done` event adds `elapsedMs` in
/// camelCase, with `status: "done"`. Matches upstream's emit at
/// <https://github.com/pnpm/pnpm/blob/2a9bd897bf/installing/deps-installer/src/install/verifyLockfileResolutions.ts#L163-L168>.
#[test]
fn lockfile_verification_done_event_matches_pnpm_wire_shape() {
let event = LogEvent::LockfileVerification(LockfileVerificationLog {
level: LogLevel::Debug,
message: LockfileVerificationMessage::Done {
entries: 12,
elapsed_ms: 234,
lockfile_path: Some("/proj/pnpm-lock.yaml".to_string()),
},
});
let envelope = Envelope { time: 1_700_000_000_000, hostname: "host", pid: 4242, event: &event };
let json: Value = envelope
.pipe_ref(serde_json::to_string)
.expect("serialize envelope")
.pipe_as_ref(serde_json::from_str)
.expect("parse JSON");
assert_eq!(json["name"], "pnpm:lockfile-verification");
assert_eq!(json["status"], "done");
assert_eq!(json["entries"], 12);
assert_eq!(json["elapsedMs"], 234);
assert_eq!(json["lockfilePath"], "/proj/pnpm-lock.yaml");
}
/// `pnpm:lockfile-verification` `failed` mirrors the `done` shape
/// except for the discriminator. Upstream sends it whenever the gate
/// emitted `started` but didn't reach `done` — policy violations and
/// unexpected throws alike — so the reporter can close out the
/// transient frame.
#[test]
fn lockfile_verification_failed_event_matches_pnpm_wire_shape() {
let event = LogEvent::LockfileVerification(LockfileVerificationLog {
level: LogLevel::Debug,
message: LockfileVerificationMessage::Failed {
entries: 12,
elapsed_ms: 999,
lockfile_path: Some("/proj/pnpm-lock.yaml".to_string()),
},
});
let envelope = Envelope { time: 1_700_000_000_000, hostname: "host", pid: 4242, event: &event };
let json: Value = envelope
.pipe_ref(serde_json::to_string)
.expect("serialize envelope")
.pipe_as_ref(serde_json::from_str)
.expect("parse JSON");
assert_eq!(json["status"], "failed");
assert_eq!(json["entries"], 12);
assert_eq!(json["elapsedMs"], 999);
assert_eq!(json["lockfilePath"], "/proj/pnpm-lock.yaml");
}
/// `lockfilePath` is upstream-optional (undefined in test paths that
/// skip the cache wiring). When `None`, the field must be omitted
/// rather than rendered as `null` — pnpm's reporter dispatches on
/// presence to decide whether to render the path suffix.
#[test]
fn lockfile_verification_omits_absent_lockfile_path() {
let event = LogEvent::LockfileVerification(LockfileVerificationLog {
level: LogLevel::Debug,
message: LockfileVerificationMessage::Started { entries: 1, lockfile_path: None },
});
let envelope = Envelope { time: 1_700_000_000_000, hostname: "host", pid: 4242, event: &event };
let json: Value = envelope
.pipe_ref(serde_json::to_string)
.expect("serialize envelope")
.pipe_as_ref(serde_json::from_str)
.expect("parse JSON");
assert!(
json.get("lockfilePath").is_none(),
"lockfilePath must be omitted when absent, got {json:?}",
);
}
/// A test fake of [`GetHostName`] returns whatever value its impl
/// declares. This proves the capability trait is dispatchable from a
/// test, which is what consumers of the trait need to know.

View File

@@ -0,0 +1,40 @@
[package]
name = "pacquet-resolving-npm-resolver"
version = "0.0.1"
publish = false
authors.workspace = true
description.workspace = true
edition.workspace = true
homepage.workspace = true
keywords.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
pacquet-config = { workspace = true }
pacquet-lockfile = { workspace = true }
pacquet-network = { workspace = true }
pacquet-registry = { workspace = true }
pacquet-resolving-resolver-base = { workspace = true }
chrono = { workspace = true }
derive_more = { workspace = true }
miette = { workspace = true }
node-semver = { workspace = true }
pipe-trait = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
mockito = { workspace = true }
pretty_assertions = { workspace = true }
ssri = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt"] }
[lints]
workspace = true

View File

@@ -0,0 +1,639 @@
//! Npm-side implementation of the [`ResolutionVerifier`] trait.
//!
//! Verbatim port of pnpm's
//! [`createNpmResolutionVerifier.ts`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/createNpmResolutionVerifier.ts).
//!
//! The factory takes the install-time policy (cutoff time, exclude
//! patterns, trust policy, named registries) and returns a verifier
//! when at least one policy is active. The verifier inspects each
//! npm-registry-resolved lockfile entry, applies the
//! `minimumReleaseAge` and/or `trustPolicy='no-downgrade'` checks,
//! and surfaces violations through [`ResolutionVerification::Err`].
//!
//! The publish-timestamp lookup walks a 4-layer fallback chain
//! (abbreviated-modified shortcut → local mirror → attestation
//! endpoint → full packument fetch); the trust check separately
//! reads the full packument to walk version history. Per-install
//! dedup of every network/disk call lives in
//! [`PublishedAtLookupContext`] so verifying many pinned versions of
//! the same package costs at most one fetch per layer.
//!
//! Phase 4 stubs the abbreviated-shortcut and on-disk-mirror layers
//! (no cached fetcher / no mirror yet); Phase 5 ports
//! `fetchFullMetadataCached.ts` and swaps the full-meta calls behind
//! that wrapper without changing this module's call sites.
use std::{collections::HashMap, path::PathBuf, sync::Arc};
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_resolving_resolver_base::{
ResolutionVerification, ResolutionVerifier, VerifyCtx, VerifyFuture,
};
use pipe_trait::Pipe;
use serde_json::Value as JsonValue;
use crate::{
FetchAttestationOptions, FetchFullMetadataCachedOptions, TrustCheckOptions, TrustViolation,
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},
trust_checks::fail_if_trust_downgraded,
violation_codes::{MINIMUM_RELEASE_AGE_VIOLATION_CODE, TRUST_DOWNGRADE_VIOLATION_CODE},
};
/// Options bundle for [`create_npm_resolution_verifier`]. Mirrors
/// upstream's
/// [`CreateNpmResolutionVerifierOptions`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/createNpmResolutionVerifier.ts#L28-L84).
///
/// 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.
pub minimum_release_age: Option<u64>,
/// Wildcard / exact-version patterns whose packages skip the age
/// check. `None` (or empty) means "no exclusions".
pub minimum_release_age_exclude: Option<PackageVersionPolicy>,
/// Raw spec strings backing [`Self::minimum_release_age_exclude`].
/// The verifier keeps the strings — not the compiled policy — for
/// the cache snapshot in `policy()` so the persisted record can be
/// compared byte-for-byte across runs.
pub minimum_release_age_exclude_patterns: Vec<String>,
/// `true` mirrors the resolver's
/// `minimumReleaseAgeIgnoreMissingTime` opt-in: when the registry
/// strips per-version `time`, the verifier passes the entry
/// instead of failing closed. Default `false`.
pub ignore_missing_time_field: bool,
/// `'no-downgrade'` enables the trust check;
/// [`TrustPolicy::Off`] disables it. Stored as an [`Option`] to
/// mirror upstream's `trustPolicy?: 'no-downgrade'` — `None` and
/// `Some(Off)` both disable the check, but they're snapshotted
/// differently for `policy()` (matching upstream's
/// `trustPolicy ?? null`).
pub trust_policy: Option<TrustPolicy>,
pub trust_policy_exclude: Option<PackageVersionPolicy>,
pub trust_policy_exclude_patterns: Vec<String>,
/// Maximum age (in minutes) before which the trust check still
/// applies. `None` ("always check") mirrors upstream's
/// `undefined`.
pub trust_policy_ignore_after: Option<u64>,
/// `default` + per-scope registry map. Keyed by `"default"` or
/// `"@scope"`; mirrors pnpm's `Registries` shape.
pub registries: HashMap<String, String>,
/// User-defined named-registry aliases (e.g. `gh:` →
/// `https://npm.pkg.github.com/`). Merged with
/// [`crate::BUILTIN_NAMED_REGISTRIES`].
pub named_registries: HashMap<String, String>,
pub http_client: Arc<ThrottledClient>,
pub auth_headers: Arc<AuthHeaders>,
/// Root of pnpm's on-disk metadata mirror. When set, the verifier
/// reads conditional headers from
/// `<cache_dir>/v11/metadata-full/<registry>/<pkg>.jsonl` and
/// writes 200 responses back; when `None`, every fetch is
/// unconditional. Mirrors upstream's `cacheDir` option.
pub cache_dir: Option<PathBuf>,
/// Override for `Utc::now()` when computing the age cutoff and
/// the `trustPolicyIgnoreAfter` window. `None` falls back to
/// wall-clock at construction time.
pub now: Option<DateTime<Utc>>,
}
/// Verifier returned by [`create_npm_resolution_verifier`]. Stores
/// the resolved cutoff, the named-registry prefix list, the dedup
/// caches, and the pre-built policy snapshot the cache reads via
/// [`ResolutionVerifier::policy`].
pub struct NpmResolutionVerifier {
minimum_release_age_minutes: Option<u64>,
cutoff: Option<DateTime<Utc>>,
minimum_release_age_exclude: Option<PackageVersionPolicy>,
ignore_missing_time_field: bool,
trust_policy: Option<TrustPolicy>,
trust_policy_exclude: Option<PackageVersionPolicy>,
trust_policy_ignore_after: Option<u64>,
/// Saved copy of the trust-exclude patterns so [`TrustCheckOptions`]
/// can borrow them per-call without reconstructing the policy.
/// Kept in sync with `trust_policy_exclude`.
sorted_min_age_excludes: Vec<String>,
sorted_trust_excludes: Vec<String>,
registries: HashMap<String, String>,
named_registry_prefixes: Vec<String>,
http_client: Arc<ThrottledClient>,
auth_headers: Arc<AuthHeaders>,
cache_dir: Option<PathBuf>,
now: Option<DateTime<Utc>>,
policy_snapshot: serde_json::Map<String, JsonValue>,
lookup_context: PublishedAtLookupContext,
}
impl std::fmt::Debug for NpmResolutionVerifier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NpmResolutionVerifier")
.field("minimum_release_age_minutes", &self.minimum_release_age_minutes)
.field("cutoff", &self.cutoff)
.field("ignore_missing_time_field", &self.ignore_missing_time_field)
.field("trust_policy", &self.trust_policy)
.field("trust_policy_ignore_after", &self.trust_policy_ignore_after)
.field("sorted_min_age_excludes", &self.sorted_min_age_excludes)
.field("sorted_trust_excludes", &self.sorted_trust_excludes)
.field("policy_snapshot", &self.policy_snapshot)
.finish_non_exhaustive()
}
}
/// Returns an [`NpmResolutionVerifier`] when at least one policy is
/// active, [`None`] otherwise. The empty case lets the install side
/// skip building a verifier list, which collapses the fan-out to a
/// straight pass — every lockfile entry yields `Ok`.
///
/// Mirrors upstream's
/// [`createNpmResolutionVerifier`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/createNpmResolutionVerifier.ts#L98-L253).
pub fn create_npm_resolution_verifier(
opts: CreateNpmResolutionVerifierOptions,
) -> Option<NpmResolutionVerifier> {
let age_check_active = opts.minimum_release_age.is_some_and(|minutes| minutes > 0);
let trust_check_active = matches!(opts.trust_policy, Some(TrustPolicy::NoDowngrade));
if !age_check_active && !trust_check_active {
return None;
}
let cutoff = if age_check_active {
let minutes = opts.minimum_release_age.unwrap_or(0);
let now = opts.now.unwrap_or_else(Utc::now);
Some(now - chrono::Duration::minutes(minutes as i64))
} else {
None
};
let named_registry_prefixes = build_named_registry_prefixes(&opts.named_registries);
let sorted_min_age_excludes = sorted_unique(&opts.minimum_release_age_exclude_patterns);
let sorted_trust_excludes = sorted_unique(&opts.trust_policy_exclude_patterns);
let policy_snapshot = build_policy_snapshot(
opts.minimum_release_age.unwrap_or(0),
&sorted_min_age_excludes,
opts.trust_policy,
&sorted_trust_excludes,
opts.trust_policy_ignore_after,
);
Some(NpmResolutionVerifier {
minimum_release_age_minutes: opts.minimum_release_age,
cutoff,
minimum_release_age_exclude: opts.minimum_release_age_exclude,
ignore_missing_time_field: opts.ignore_missing_time_field,
trust_policy: opts.trust_policy,
trust_policy_exclude: opts.trust_policy_exclude,
trust_policy_ignore_after: opts.trust_policy_ignore_after,
sorted_min_age_excludes,
sorted_trust_excludes,
registries: opts.registries,
named_registry_prefixes,
http_client: opts.http_client,
auth_headers: opts.auth_headers,
cache_dir: opts.cache_dir,
now: opts.now,
policy_snapshot,
lookup_context: PublishedAtLookupContext::new(),
})
}
impl ResolutionVerifier for NpmResolutionVerifier {
fn verify<'a>(
&'a self,
resolution: &'a LockfileResolution,
ctx: VerifyCtx<'a>,
) -> VerifyFuture<'a> {
Box::pin(self.verify_impl(resolution, ctx))
}
fn policy(&self) -> &serde_json::Map<String, JsonValue> {
&self.policy_snapshot
}
fn can_trust_past_check(&self, cached_policy: &serde_json::Map<String, JsonValue>) -> bool {
// Maturity: a previously cached run under a larger cutoff
// (stricter window) is trustworthy under a smaller current one
// — the set of accepted versions is a subset of today's.
// Tightening the cutoff invalidates the cached run.
let past_min_age =
cached_policy.get("minimumReleaseAge").and_then(JsonValue::as_u64).unwrap_or(0);
if past_min_age < self.minimum_release_age_minutes.unwrap_or(0) {
return false;
}
let past_min_age_excludes = cached_policy
.get("minimumReleaseAgeExclude")
.and_then(JsonValue::as_array)
.map(|arr| {
arr.iter()
.filter_map(|value| value.as_str().map(str::to_string))
.collect::<Vec<_>>()
})
.unwrap_or_default();
if past_min_age_excludes != self.sorted_min_age_excludes {
return false;
}
let past_trust_policy = cached_policy.get("trustPolicy").and_then(JsonValue::as_str);
let today_trust_policy = self.trust_policy_wire_str();
if past_trust_policy != today_trust_policy {
return false;
}
let past_trust_excludes = cached_policy
.get("trustPolicyExclude")
.and_then(JsonValue::as_array)
.map(|arr| {
arr.iter()
.filter_map(|value| value.as_str().map(str::to_string))
.collect::<Vec<_>>()
})
.unwrap_or_default();
if past_trust_excludes != self.sorted_trust_excludes {
return false;
}
let past_ignore_after =
cached_policy.get("trustPolicyIgnoreAfter").and_then(JsonValue::as_u64);
if past_ignore_after != self.trust_policy_ignore_after {
return false;
}
true
}
}
impl NpmResolutionVerifier {
async fn verify_impl(
&self,
resolution: &LockfileResolution,
ctx: VerifyCtx<'_>,
) -> ResolutionVerification {
let Some(tarball_url) = npm_registry_tarball(resolution) else {
return ResolutionVerification::Ok;
};
// Non-semver versions identify URL tarballs, file: refs, git refs,
// etc. Neither policy applies, and a registry lookup would 404.
if node_semver::Version::parse(ctx.version).is_err() {
return ResolutionVerification::Ok;
}
let age_applies = self.age_check_active()
&& !is_excluded(self.minimum_release_age_exclude.as_ref(), ctx.name, ctx.version);
let trust_applies = self.trust_check_active()
&& !is_excluded(self.trust_policy_exclude.as_ref(), ctx.name, ctx.version);
if !age_applies && !trust_applies {
return ResolutionVerification::Ok;
}
let registry = self.pick_registry(ctx.name, tarball_url);
if age_applies
&& let Some(violation) = self.run_age_check(&registry, ctx.name, ctx.version).await
{
return violation;
}
if trust_applies
&& let Some(violation) = self.run_trust_check(&registry, ctx.name, ctx.version).await
{
return violation;
}
ResolutionVerification::Ok
}
fn age_check_active(&self) -> bool {
self.minimum_release_age_minutes.is_some_and(|minutes| minutes > 0)
}
fn trust_check_active(&self) -> bool {
matches!(self.trust_policy, Some(TrustPolicy::NoDowngrade))
}
fn trust_policy_wire_str(&self) -> Option<&'static str> {
match self.trust_policy {
Some(TrustPolicy::NoDowngrade) => Some("no-downgrade"),
Some(TrustPolicy::Off) | None => None,
}
}
fn pick_registry(&self, name: &PkgName, tarball_url: Option<&str>) -> String {
if let Some(url) = tarball_url
&& let Ok(parsed) = reqwest::Url::parse(url)
{
let normalized = parsed.as_str();
for prefix in &self.named_registry_prefixes {
if normalized.starts_with(prefix) {
return prefix.clone();
}
}
}
pick_registry_for_package(&self.registries, &name.to_string())
}
async fn run_age_check(
&self,
registry: &str,
name: &PkgName,
version: &str,
) -> Option<ResolutionVerification> {
let cutoff = self.cutoff.expect("cutoff is Some when age check is active");
let published = match self.fetch_published_at(registry, name, version).await {
Ok(value) => value,
Err(reason) => {
return Some(ResolutionVerification::Err {
code: MINIMUM_RELEASE_AGE_VIOLATION_CODE,
reason: uncheckable("minimumReleaseAge", &reason),
});
}
};
let Some(published) = published else {
// No source surfaced a publish timestamp; mirror the
// resolver's `minimumReleaseAgeIgnoreMissingTime` opt-in.
if self.ignore_missing_time_field {
return None;
}
return Some(ResolutionVerification::Err {
code: MINIMUM_RELEASE_AGE_VIOLATION_CODE,
reason: uncheckable(
"minimumReleaseAge",
"version not present in registry manifest",
),
});
};
let Ok(parsed) = DateTime::parse_from_rfc3339(&published) else {
return Some(ResolutionVerification::Err {
code: MINIMUM_RELEASE_AGE_VIOLATION_CODE,
reason: "publish timestamp is not a valid date".to_string(),
});
};
let parsed = parsed.with_timezone(&Utc);
if parsed > cutoff {
return Some(ResolutionVerification::Err {
code: MINIMUM_RELEASE_AGE_VIOLATION_CODE,
reason: format!(
"was published at {published}, within the minimumReleaseAge cutoff ({cutoff})",
cutoff = cutoff.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
),
});
}
None
}
/// Run the resolver-time `failIfTrustDowngraded` check against the
/// pinned lockfile version. Mirrors upstream's
/// [`runTrustCheck`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/createNpmResolutionVerifier.ts#L325-L359).
///
/// No attestation fast-path: presence of provenance on the current
/// version is not sufficient to clear a downgrade. The package may
/// have shipped earlier versions under a `trustedPublisher` (the
/// higher-rank evidence) and then dropped to plain provenance —
/// `fail_if_trust_downgraded` correctly flags that.
async fn run_trust_check(
&self,
registry: &str,
name: &PkgName,
version: &str,
) -> Option<ResolutionVerification> {
let meta = match self.fetch_full_meta_for_trust(registry, name).await {
Ok(meta) => meta,
Err(reason) => {
return Some(ResolutionVerification::Err {
code: TRUST_DOWNGRADE_VIOLATION_CODE,
reason: uncheckable("trustPolicy", &reason),
});
}
};
let trust_opts = TrustCheckOptions {
trust_policy_exclude: self.trust_policy_exclude.as_ref(),
trust_policy_ignore_after_minutes: self.trust_policy_ignore_after,
now: self.now,
};
match fail_if_trust_downgraded(&meta, version, &trust_opts) {
Ok(()) => None,
Err(err) => Some(ResolutionVerification::Err {
code: TRUST_DOWNGRADE_VIOLATION_CODE,
reason: format_trust_violation(err),
}),
}
}
/// Per-`(registry, name, version)` lookup with a layered fallback.
/// Ports upstream's
/// [`fetchPublishedAt`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/createNpmResolutionVerifier.ts#L456-L491).
///
/// Phase 4 stubs the abbreviated-shortcut and on-disk-mirror layers
/// (return `None`); Phase 5 swaps the full-meta call behind the
/// cached fetcher and ports the abbreviated/mirror layers.
async fn fetch_published_at(
&self,
registry: &str,
name: &PkgName,
version: &str,
) -> Result<Option<String>, String> {
let key = version_key(registry, &name.to_string(), version);
{
let cache = self.lookup_context.published_at.lock().await;
if let Some(value) = cache.get(&key) {
return Ok(value.clone());
}
}
let value = self.resolve_published_at(registry, name, version).await?;
let mut cache = self.lookup_context.published_at.lock().await;
Ok(cache.entry(key).or_insert(value).clone())
}
/// Phase 4 walks two of the four upstream layers: the attestation
/// endpoint, then the full packument. The abbreviated-`modified`
/// shortcut and the on-disk-mirror read land in Phase 5 alongside
/// the cached fetchers (`fetchAbbreviatedMetadataCached` /
/// `loadMeta`) they depend on. Mirrors upstream's
/// [`resolvePublishedAt`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/createNpmResolutionVerifier.ts#L471-L491)
/// with the first two `if` blocks skipped.
async fn resolve_published_at(
&self,
registry: &str,
name: &PkgName,
version: &str,
) -> Result<Option<String>, String> {
if let Some(value) = self.fetch_attestation_time(registry, name, version).await? {
return Ok(Some(value));
}
let full_meta_time = self.fetch_full_meta_time(registry, name).await?;
Ok(full_meta_time.and_then(|map| map.get(version).cloned()))
}
async fn fetch_attestation_time(
&self,
registry: &str,
name: &PkgName,
version: &str,
) -> Result<Option<String>, String> {
let opts = FetchAttestationOptions {
registry,
http_client: &self.http_client,
auth_headers: &self.auth_headers,
};
fetch_attestation_published_at(&name.to_string(), version, &opts)
.await
.map_err(|err| err.to_string())
}
async fn fetch_full_meta_time(
&self,
registry: &str,
name: &PkgName,
) -> Result<Option<Arc<PublishedAtTimeMap>>, String> {
let key = package_key(registry, &name.to_string());
{
let cache = self.lookup_context.full_meta.lock().await;
if let Some(entry) = cache.get(&key) {
return Ok(entry.clone());
}
}
let pkg = match self.fetch_full_meta(registry, name).await {
Ok(pkg) => pkg,
Err(reason) => return Err(reason),
};
let time_map = pkg.time.as_ref().map(|raw| {
raw.iter()
.filter_map(|(version, value)| {
value.as_str().map(|ts| (version.clone(), ts.to_string()))
})
.collect::<PublishedAtTimeMap>()
.pipe(Arc::new)
});
let mut cache = self.lookup_context.full_meta.lock().await;
Ok(cache.entry(key).or_insert(time_map).clone())
}
async fn fetch_full_meta_for_trust(
&self,
registry: &str,
name: &PkgName,
) -> Result<Arc<Package>, String> {
let key = package_key(registry, &name.to_string());
{
let cache = self.lookup_context.full_meta_for_trust.lock().await;
if let Some(entry) = cache.get(&key) {
return entry.clone();
}
}
let result = self.fetch_full_meta(registry, name).await.map(Arc::new);
let mut cache = self.lookup_context.full_meta_for_trust.lock().await;
cache.entry(key).or_insert(result).clone()
}
async fn fetch_full_meta(&self, registry: &str, name: &PkgName) -> Result<Package, String> {
let opts = FetchFullMetadataCachedOptions {
registry,
http_client: &self.http_client,
auth_headers: &self.auth_headers,
cache_dir: self.cache_dir.as_deref(),
};
fetch_full_metadata_cached(&name.to_string(), &opts).await.map_err(|err| err.to_string())
}
}
/// Tarball URL recorded on an npm-registry resolution. The verifier
/// uses it for prefix-matching against named registries; absence
/// alone doesn't disqualify the entry (Registry / Tarball variants
/// without a URL still go through scope routing).
fn npm_registry_tarball(resolution: &LockfileResolution) -> Option<Option<&str>> {
match resolution {
// Registry-resolved entries carry only `integrity`; the tarball
// URL is reconstructed at fetch time. They still qualify for
// verification.
LockfileResolution::Registry(_) => Some(None),
LockfileResolution::Tarball(t) => {
// Git-hosted tarballs (codeload / gitlab / bitbucket) are
// not subject to the release-age policy and don't have a
// packument lookup; skip them.
if t.git_hosted.unwrap_or(false) {
return None;
}
Some(Some(t.tarball.as_str()))
}
LockfileResolution::Directory(_)
| LockfileResolution::Git(_)
| LockfileResolution::Binary(_)
| LockfileResolution::Variations(_) => None,
}
}
fn is_excluded(policy: Option<&PackageVersionPolicy>, name: &PkgName, version: &str) -> bool {
let Some(policy) = policy else { return false };
match policy.matches(&name.to_string()) {
pacquet_config::version_policy::PolicyMatch::No => false,
pacquet_config::version_policy::PolicyMatch::AnyVersion => true,
pacquet_config::version_policy::PolicyMatch::ExactVersions(versions) => {
versions.iter().any(|exact| exact == version)
}
}
}
fn uncheckable(policy: &str, why: &str) -> String {
format!("could not be checked against {policy} ({why})")
}
fn format_trust_violation(err: TrustViolation) -> String {
match err {
TrustViolation::TrustCheckFailed { reason } => uncheckable("trustPolicy", &reason),
other => other.to_string(),
}
}
fn sorted_unique(values: &[String]) -> Vec<String> {
let mut deduped: Vec<String> = values.to_vec();
deduped.sort();
deduped.dedup();
deduped
}
fn build_policy_snapshot(
minimum_release_age: u64,
sorted_min_age_excludes: &[String],
trust_policy: Option<TrustPolicy>,
sorted_trust_excludes: &[String],
trust_policy_ignore_after: Option<u64>,
) -> serde_json::Map<String, JsonValue> {
let mut map = serde_json::Map::new();
map.insert("minimumReleaseAge".to_string(), JsonValue::from(minimum_release_age));
map.insert(
"minimumReleaseAgeExclude".to_string(),
JsonValue::Array(
sorted_min_age_excludes.iter().map(|spec| JsonValue::String(spec.clone())).collect(),
),
);
map.insert(
"trustPolicy".to_string(),
match trust_policy {
Some(TrustPolicy::NoDowngrade) => JsonValue::String("no-downgrade".to_string()),
Some(TrustPolicy::Off) | None => JsonValue::Null,
},
);
map.insert(
"trustPolicyExclude".to_string(),
JsonValue::Array(
sorted_trust_excludes.iter().map(|spec| JsonValue::String(spec.clone())).collect(),
),
);
map.insert(
"trustPolicyIgnoreAfter".to_string(),
match trust_policy_ignore_after {
Some(value) => JsonValue::from(value),
None => JsonValue::Null,
},
);
map
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,637 @@
use std::{collections::HashMap, sync::Arc};
use chrono::{DateTime, Utc};
use pacquet_config::{TrustPolicy, version_policy::create_package_version_policy};
use pacquet_lockfile::{LockfileResolution, PkgName, RegistryResolution, TarballResolution};
use pacquet_network::{AuthHeaders, ThrottledClient};
use pacquet_resolving_resolver_base::{ResolutionVerification, ResolutionVerifier, VerifyCtx};
use pretty_assertions::assert_eq;
use ssri::Integrity;
use super::{CreateNpmResolutionVerifierOptions, create_npm_resolution_verifier};
const FAKE_INTEGRITY: &str = "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==";
fn now_at(date: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(date).expect("parse rfc3339").with_timezone(&Utc)
}
fn fake_integrity() -> Integrity {
FAKE_INTEGRITY.parse::<Integrity>().expect("parse fake integrity")
}
fn registry_resolution() -> LockfileResolution {
LockfileResolution::Registry(RegistryResolution { integrity: fake_integrity() })
}
fn registries_with_default(default: &str) -> HashMap<String, String> {
let mut map = HashMap::new();
map.insert("default".to_string(), default.to_string());
map
}
/// Build a default `CreateNpmResolutionVerifierOptions` with the
/// given registry URL. Tests override individual fields after.
fn default_opts(registry_url: &str) -> CreateNpmResolutionVerifierOptions {
CreateNpmResolutionVerifierOptions {
minimum_release_age: None,
minimum_release_age_exclude: None,
minimum_release_age_exclude_patterns: Vec::new(),
ignore_missing_time_field: false,
trust_policy: None,
trust_policy_exclude: None,
trust_policy_exclude_patterns: Vec::new(),
trust_policy_ignore_after: None,
registries: registries_with_default(registry_url),
named_registries: HashMap::new(),
http_client: Arc::new(ThrottledClient::default()),
auth_headers: Arc::new(AuthHeaders::default()),
cache_dir: None,
now: None,
}
}
/// Wire-shape full-metadata document with a single `time` slot and
/// no provenance. Used for the minimumReleaseAge path; the trust
/// check needs a richer fixture (see `trust_packument_json`).
fn min_age_packument_json(name: &str, version: &str, published_at: &str) -> serde_json::Value {
serde_json::json!({
"name": name,
"dist-tags": { "latest": version },
"time": { version: published_at },
"versions": {
version: {
"name": name,
"version": version,
"dist": {
"integrity": FAKE_INTEGRITY,
"shasum": "0000000000000000000000000000000000000000",
"tarball": format!("https://registry/{name}-{version}.tgz"),
}
}
}
})
}
/// Packument with two versions: earlier (`prior_version`) has
/// `_npmUser.trustedPublisher`, current has only `dist.attestations.provenance`.
/// This is the canonical "trusted-publisher → provenance" downgrade.
fn trust_downgrade_packument(name: &str) -> serde_json::Value {
serde_json::json!({
"name": name,
"dist-tags": { "latest": "1.1.0" },
"time": {
"1.0.0": "2025-01-01T00:00:00.000Z",
"1.1.0": "2025-02-01T00:00:00.000Z"
},
"versions": {
"1.0.0": {
"name": name,
"version": "1.0.0",
"_npmUser": { "name": "alice", "trustedPublisher": { "id": "github", "oidcConfigId": "release" } },
"dist": {
"integrity": FAKE_INTEGRITY,
"shasum": "0000000000000000000000000000000000000000",
"tarball": format!("https://registry/{name}-1.0.0.tgz")
}
},
"1.1.0": {
"name": name,
"version": "1.1.0",
"dist": {
"integrity": FAKE_INTEGRITY,
"shasum": "0000000000000000000000000000000000000000",
"tarball": format!("https://registry/{name}-1.1.0.tgz"),
"attestations": { "provenance": { "predicateType": "https://slsa.dev/provenance/v1" } }
}
}
}
})
}
/// Packument where every published version carries the same
/// (provenance) evidence — verifying any of them must NOT raise
/// a trust downgrade.
fn stable_trust_packument(name: &str) -> serde_json::Value {
serde_json::json!({
"name": name,
"dist-tags": { "latest": "1.1.0" },
"time": {
"1.0.0": "2025-01-01T00:00:00.000Z",
"1.1.0": "2025-02-01T00:00:00.000Z"
},
"versions": {
"1.0.0": {
"name": name,
"version": "1.0.0",
"dist": {
"integrity": FAKE_INTEGRITY,
"shasum": "0000000000000000000000000000000000000000",
"tarball": format!("https://registry/{name}-1.0.0.tgz"),
"attestations": { "provenance": { "predicateType": "https://slsa.dev/provenance/v1" } }
}
},
"1.1.0": {
"name": name,
"version": "1.1.0",
"dist": {
"integrity": FAKE_INTEGRITY,
"shasum": "0000000000000000000000000000000000000000",
"tarball": format!("https://registry/{name}-1.1.0.tgz"),
"attestations": { "provenance": { "predicateType": "https://slsa.dev/provenance/v1" } }
}
}
}
})
}
/// No-op `ctx` builder that ties the borrowed `name` to the call
/// site's lifetime.
fn ctx<'a>(name: &'a PkgName, version: &'a str) -> VerifyCtx<'a> {
VerifyCtx { name, version }
}
/// `create_npm_resolution_verifier` returns `None` when neither
/// the maturity nor trust check is active — the install side then
/// skips fan-out entirely.
#[test]
fn returns_none_when_no_policy_active() {
let server_url = "https://registry.example/";
let opts = default_opts(server_url);
assert!(create_npm_resolution_verifier(opts).is_none());
}
/// `minimum_release_age = 0` is treated the same as `None` — upstream's
/// `Boolean(opts.minimumReleaseAge)` returns `false` for `0`, so the
/// verifier stays inactive.
#[test]
fn returns_none_when_min_age_is_zero() {
let server_url = "https://registry.example/";
let mut opts = default_opts(server_url);
opts.minimum_release_age = Some(0);
assert!(create_npm_resolution_verifier(opts).is_none());
}
/// `trust_policy = Off` keeps the verifier inactive even when set
/// explicitly. Mirrors upstream's `opts.trustPolicy === 'no-downgrade'`
/// gating.
#[test]
fn returns_none_when_trust_policy_off() {
let server_url = "https://registry.example/";
let mut opts = default_opts(server_url);
opts.trust_policy = Some(TrustPolicy::Off);
assert!(create_npm_resolution_verifier(opts).is_none());
}
/// Setting `minimum_release_age` returns an active verifier even
/// without a trust policy.
#[test]
fn returns_some_when_min_age_set() {
let server_url = "https://registry.example/";
let mut opts = default_opts(server_url);
opts.minimum_release_age = Some(60 * 24);
assert!(create_npm_resolution_verifier(opts).is_some());
}
/// `trust_policy = NoDowngrade` alone activates the verifier.
#[test]
fn returns_some_when_trust_policy_no_downgrade() {
let server_url = "https://registry.example/";
let mut opts = default_opts(server_url);
opts.trust_policy = Some(TrustPolicy::NoDowngrade);
assert!(create_npm_resolution_verifier(opts).is_some());
}
/// A git / directory / binary resolution short-circuits to
/// `Ok` without issuing any network calls — neither policy applies
/// outside the npm-registry protocol.
#[tokio::test]
async fn verify_short_circuits_non_registry_resolution() {
let mut opts = default_opts("https://registry.example/");
opts.minimum_release_age = Some(60 * 24 * 365);
let verifier = create_npm_resolution_verifier(opts).expect("verifier");
let directory = LockfileResolution::Directory(pacquet_lockfile::DirectoryResolution {
directory: "/some/path".into(),
});
let name: PkgName = "acme".parse().expect("parse");
let result = verifier.verify(&directory, ctx(&name, "1.0.0")).await;
assert_eq!(result, ResolutionVerification::Ok);
}
/// Non-semver version (URL spec, git ref, file: ref) → pass without
/// asking the registry. Mirrors upstream's `!semver.valid(version)`
/// gate.
#[tokio::test]
async fn verify_short_circuits_non_semver_version() {
let mut opts = default_opts("https://registry.example/");
opts.minimum_release_age = Some(60 * 24 * 365);
let verifier = create_npm_resolution_verifier(opts).expect("verifier");
let resolution = registry_resolution();
let name: PkgName = "acme".parse().expect("parse");
let result = verifier.verify(&resolution, ctx(&name, "not-semver")).await;
assert_eq!(result, ResolutionVerification::Ok);
}
/// When the exclude policy covers the package, age check skips —
/// the version is treated as opted out regardless of its publish
/// timestamp.
#[tokio::test]
async fn verify_skips_age_check_when_package_excluded() {
// No mockito needed: if the exclude were ignored, the verifier
// would issue a network call to the bogus URL and fail.
let mut opts = default_opts("http://nonexistent.example.invalid/");
opts.minimum_release_age = Some(60 * 24 * 365);
opts.minimum_release_age_exclude =
Some(create_package_version_policy(["acme".to_string()]).expect("policy"));
opts.minimum_release_age_exclude_patterns = vec!["acme".to_string()];
let verifier = create_npm_resolution_verifier(opts).expect("verifier");
let resolution = registry_resolution();
let name: PkgName = "acme".parse().expect("parse");
let result = verifier.verify(&resolution, ctx(&name, "1.0.0")).await;
assert_eq!(result, ResolutionVerification::Ok);
}
/// Full-metadata path: registry reports a publish time well before
/// the cutoff → verify returns `Ok`.
#[tokio::test]
async fn min_age_pass_when_published_before_cutoff() {
let mut server = mockito::Server::new_async().await;
let registry = format!("{}/", server.url());
// Attestation endpoint returns 404, forcing the full-metadata
// layer to answer.
let _attestation_mock = server
.mock("GET", "/-/npm/v1/attestations/acme@1.0.0")
.with_status(404)
.expect(1)
.create_async()
.await;
let _full_mock = server
.mock("GET", "/acme")
.match_header("accept", "application/json")
.with_status(200)
.with_body(min_age_packument_json("acme", "1.0.0", "2024-01-01T00:00:00.000Z").to_string())
.expect(1)
.create_async()
.await;
let mut opts = default_opts(&registry);
opts.minimum_release_age = Some(60 * 24); // 1 day
opts.now = Some(now_at("2025-12-01T00:00:00Z"));
let verifier = create_npm_resolution_verifier(opts).expect("verifier");
let result = verifier
.verify(&registry_resolution(), ctx(&"acme".parse::<PkgName>().expect("parse"), "1.0.0"))
.await;
assert_eq!(result, ResolutionVerification::Ok);
}
/// Within-cutoff publish time → fail with the verifier's
/// `MINIMUM_RELEASE_AGE_VIOLATION` code.
#[tokio::test]
async fn min_age_fail_when_published_within_cutoff() {
let mut server = mockito::Server::new_async().await;
let registry = format!("{}/", server.url());
let _attestation_mock = server
.mock("GET", "/-/npm/v1/attestations/acme@1.0.0")
.with_status(404)
.expect(1)
.create_async()
.await;
let _full_mock = server
.mock("GET", "/acme")
.with_status(200)
.with_body(min_age_packument_json("acme", "1.0.0", "2025-11-30T22:00:00.000Z").to_string())
.expect(1)
.create_async()
.await;
let mut opts = default_opts(&registry);
opts.minimum_release_age = Some(60 * 24); // 1 day
opts.now = Some(now_at("2025-12-01T00:00:00Z"));
let verifier = create_npm_resolution_verifier(opts).expect("verifier");
let result = verifier
.verify(&registry_resolution(), ctx(&"acme".parse::<PkgName>().expect("parse"), "1.0.0"))
.await;
let ResolutionVerification::Err { code, reason } = result else {
panic!("expected Err, got {result:?}");
};
assert_eq!(code, "MINIMUM_RELEASE_AGE_VIOLATION");
assert!(reason.contains("within the minimumReleaseAge cutoff"), "got reason: {reason}");
}
/// Registry strips per-version `time`. With `ignore_missing_time_field`
/// off (the default), the verifier fails closed.
#[tokio::test]
async fn min_age_missing_time_fails_closed_by_default() {
let mut server = mockito::Server::new_async().await;
let registry = format!("{}/", server.url());
let body = serde_json::json!({
"name": "acme",
"dist-tags": { "latest": "1.0.0" },
"versions": {
"1.0.0": {
"name": "acme",
"version": "1.0.0",
"dist": {
"integrity": FAKE_INTEGRITY,
"shasum": "0000000000000000000000000000000000000000",
"tarball": "https://registry/acme-1.0.0.tgz"
}
}
}
});
let _attestation_mock = server
.mock("GET", "/-/npm/v1/attestations/acme@1.0.0")
.with_status(404)
.expect(1)
.create_async()
.await;
let _full_mock = server
.mock("GET", "/acme")
.with_status(200)
.with_body(body.to_string())
.expect(1)
.create_async()
.await;
let mut opts = default_opts(&registry);
opts.minimum_release_age = Some(60 * 24);
opts.now = Some(now_at("2025-12-01T00:00:00Z"));
let verifier = create_npm_resolution_verifier(opts).expect("verifier");
let result = verifier
.verify(&registry_resolution(), ctx(&"acme".parse::<PkgName>().expect("parse"), "1.0.0"))
.await;
let ResolutionVerification::Err { code, reason } = result else {
panic!("expected Err, got {result:?}");
};
assert_eq!(code, "MINIMUM_RELEASE_AGE_VIOLATION");
assert!(
reason.contains("could not be checked against minimumReleaseAge"),
"got reason: {reason}",
);
}
/// Opting in to `ignore_missing_time_field` flips the missing-time
/// case from a fail-closed violation to a pass. Mirrors upstream's
/// `minimumReleaseAgeIgnoreMissingTime` resolver flag.
#[tokio::test]
async fn min_age_missing_time_passes_when_ignored() {
let mut server = mockito::Server::new_async().await;
let registry = format!("{}/", server.url());
let body = serde_json::json!({
"name": "acme",
"dist-tags": { "latest": "1.0.0" },
"versions": {
"1.0.0": {
"name": "acme",
"version": "1.0.0",
"dist": {
"integrity": FAKE_INTEGRITY,
"shasum": "0000000000000000000000000000000000000000",
"tarball": "https://registry/acme-1.0.0.tgz"
}
}
}
});
let _attestation_mock = server
.mock("GET", "/-/npm/v1/attestations/acme@1.0.0")
.with_status(404)
.expect(1)
.create_async()
.await;
let _full_mock = server
.mock("GET", "/acme")
.with_status(200)
.with_body(body.to_string())
.expect(1)
.create_async()
.await;
let mut opts = default_opts(&registry);
opts.minimum_release_age = Some(60 * 24);
opts.ignore_missing_time_field = true;
opts.now = Some(now_at("2025-12-01T00:00:00Z"));
let verifier = create_npm_resolution_verifier(opts).expect("verifier");
let result = verifier
.verify(&registry_resolution(), ctx(&"acme".parse::<PkgName>().expect("parse"), "1.0.0"))
.await;
assert_eq!(result, ResolutionVerification::Ok);
}
/// `trust_policy = NoDowngrade` rejects a version whose evidence is
/// weaker than an earlier-published version's. Earlier 1.0.0 had
/// `trustedPublisher`; current 1.1.0 has only `provenance` →
/// `TRUST_DOWNGRADE`.
#[tokio::test]
async fn trust_downgrade_publisher_to_provenance_fails() {
let mut server = mockito::Server::new_async().await;
let registry = format!("{}/", server.url());
let _full_mock = server
.mock("GET", "/acme")
.with_status(200)
.with_body(trust_downgrade_packument("acme").to_string())
.expect(1)
.create_async()
.await;
let mut opts = default_opts(&registry);
opts.trust_policy = Some(TrustPolicy::NoDowngrade);
opts.now = Some(now_at("2025-12-01T00:00:00Z"));
let verifier = create_npm_resolution_verifier(opts).expect("verifier");
let result = verifier
.verify(&registry_resolution(), ctx(&"acme".parse::<PkgName>().expect("parse"), "1.1.0"))
.await;
let ResolutionVerification::Err { code, reason } = result else {
panic!("expected Err, got {result:?}");
};
assert_eq!(code, "TRUST_DOWNGRADE");
assert!(reason.contains("trust downgrade"), "got reason: {reason}");
}
/// When every prior version's evidence equals or precedes the
/// current version's, the trust check passes.
#[tokio::test]
async fn trust_downgrade_pass_when_no_weaker_evidence() {
let mut server = mockito::Server::new_async().await;
let registry = format!("{}/", server.url());
let _full_mock = server
.mock("GET", "/acme")
.with_status(200)
.with_body(stable_trust_packument("acme").to_string())
.expect(1)
.create_async()
.await;
let mut opts = default_opts(&registry);
opts.trust_policy = Some(TrustPolicy::NoDowngrade);
opts.now = Some(now_at("2025-12-01T00:00:00Z"));
let verifier = create_npm_resolution_verifier(opts).expect("verifier");
let result = verifier
.verify(&registry_resolution(), ctx(&"acme".parse::<PkgName>().expect("parse"), "1.1.0"))
.await;
assert_eq!(result, ResolutionVerification::Ok);
}
/// Tarball resolutions whose URL falls under a named-registry
/// prefix route to that registry's metadata endpoint. Here `gh:` →
/// the mocked GitHub Packages base URL.
#[tokio::test]
async fn verify_routes_via_named_registry_prefix() {
let mut server = mockito::Server::new_async().await;
let server_url = server.url();
let _attestation_mock = server
.mock("GET", "/-/npm/v1/attestations/acme@1.0.0")
.with_status(404)
.expect(1)
.create_async()
.await;
let _full_mock = server
.mock("GET", "/acme")
.with_status(200)
.with_body(min_age_packument_json("acme", "1.0.0", "2024-01-01T00:00:00.000Z").to_string())
.expect(1)
.create_async()
.await;
let mut named = HashMap::new();
named.insert("internal".to_string(), format!("{server_url}/"));
// Default registry is bogus — if the named-registry routing
// breaks, the request would target the bogus URL and the test
// would fail with a connection error instead of finding the mock.
let mut opts = default_opts("http://nonexistent.example.invalid/");
opts.named_registries = named;
opts.minimum_release_age = Some(60 * 24);
opts.now = Some(now_at("2025-12-01T00:00:00Z"));
let verifier = create_npm_resolution_verifier(opts).expect("verifier");
let tarball = LockfileResolution::Tarball(TarballResolution {
tarball: format!("{server_url}/acme/-/acme-1.0.0.tgz"),
integrity: Some(fake_integrity()),
git_hosted: None,
path: None,
});
let result =
verifier.verify(&tarball, ctx(&"acme".parse::<PkgName>().expect("parse"), "1.0.0")).await;
assert_eq!(result, ResolutionVerification::Ok);
}
/// `policy()` returns the snapshot the verification cache hashes
/// alongside the lockfile. Each field is sorted/deduped where the
/// upstream contract requires it.
#[test]
fn policy_snapshot_records_all_fields_sorted_and_deduped() {
let mut opts = default_opts("https://registry.example/");
opts.minimum_release_age = Some(60 * 24);
opts.minimum_release_age_exclude_patterns =
vec!["lodash".to_string(), "acme".to_string(), "lodash".to_string()];
opts.minimum_release_age_exclude = Some(
create_package_version_policy(["lodash".to_string(), "acme".to_string()]).expect("policy"),
);
opts.trust_policy = Some(TrustPolicy::NoDowngrade);
opts.trust_policy_exclude_patterns = vec!["@scope/foo".to_string()];
opts.trust_policy_exclude =
Some(create_package_version_policy(["@scope/foo".to_string()]).expect("policy"));
opts.trust_policy_ignore_after = Some(60 * 24 * 30);
let verifier = create_npm_resolution_verifier(opts).expect("verifier");
let policy = verifier.policy();
assert_eq!(policy.get("minimumReleaseAge").and_then(|value| value.as_u64()), Some(60 * 24));
let min_age_excludes =
policy.get("minimumReleaseAgeExclude").and_then(|value| value.as_array()).expect("array");
assert_eq!(
min_age_excludes
.iter()
.filter_map(|value| value.as_str().map(str::to_string))
.collect::<Vec<_>>(),
vec!["acme".to_string(), "lodash".to_string()],
"sorted + deduped",
);
assert_eq!(policy.get("trustPolicy").and_then(|value| value.as_str()), Some("no-downgrade"));
assert_eq!(
policy.get("trustPolicyIgnoreAfter").and_then(|value| value.as_u64()),
Some(60 * 24 * 30),
);
}
/// A previously-cached run with a stricter (larger) cutoff stays
/// trustworthy under today's looser policy — the set of accepted
/// versions is a subset of today's.
#[test]
fn can_trust_past_check_accepts_looser_min_age() {
let mut opts = default_opts("https://registry.example/");
opts.minimum_release_age = Some(60 * 24); // today: 1 day
let verifier = create_npm_resolution_verifier(opts).expect("verifier");
let mut cached = serde_json::Map::new();
cached.insert("minimumReleaseAge".to_string(), (60 * 24 * 7).into()); // past: 7 days
cached.insert("minimumReleaseAgeExclude".to_string(), serde_json::Value::Array(vec![]));
cached.insert("trustPolicy".to_string(), serde_json::Value::Null);
cached.insert("trustPolicyExclude".to_string(), serde_json::Value::Array(vec![]));
cached.insert("trustPolicyIgnoreAfter".to_string(), serde_json::Value::Null);
assert!(verifier.can_trust_past_check(&cached));
}
/// Tightening the cutoff invalidates the cached run — versions
/// that passed under a looser cutoff may now be in the new
/// (narrower) window.
#[test]
fn can_trust_past_check_rejects_tighter_min_age() {
let mut opts = default_opts("https://registry.example/");
opts.minimum_release_age = Some(60 * 24 * 7); // today: 7 days
let verifier = create_npm_resolution_verifier(opts).expect("verifier");
let mut cached = serde_json::Map::new();
cached.insert("minimumReleaseAge".to_string(), (60 * 24).into()); // past: 1 day
cached.insert("minimumReleaseAgeExclude".to_string(), serde_json::Value::Array(vec![]));
cached.insert("trustPolicy".to_string(), serde_json::Value::Null);
cached.insert("trustPolicyExclude".to_string(), serde_json::Value::Array(vec![]));
cached.insert("trustPolicyIgnoreAfter".to_string(), serde_json::Value::Null);
assert!(!verifier.can_trust_past_check(&cached));
}
/// Any drift in the exclude list invalidates the cached run, even
/// when the drift would have been more permissive (an extra entry).
/// Mirrors upstream's stricter-than-necessary identity check.
#[test]
fn can_trust_past_check_rejects_changed_exclude_list() {
let mut opts = default_opts("https://registry.example/");
opts.minimum_release_age = Some(60 * 24);
opts.minimum_release_age_exclude_patterns = vec!["acme".to_string()];
opts.minimum_release_age_exclude =
Some(create_package_version_policy(["acme".to_string()]).expect("policy"));
let verifier = create_npm_resolution_verifier(opts).expect("verifier");
let mut cached = serde_json::Map::new();
cached.insert("minimumReleaseAge".to_string(), (60 * 24).into());
cached.insert("minimumReleaseAgeExclude".to_string(), serde_json::Value::Array(vec![]));
cached.insert("trustPolicy".to_string(), serde_json::Value::Null);
cached.insert("trustPolicyExclude".to_string(), serde_json::Value::Array(vec![]));
cached.insert("trustPolicyIgnoreAfter".to_string(), serde_json::Value::Null);
assert!(!verifier.can_trust_past_check(&cached));
}
/// Switching trust policy on or off invalidates the cached run.
#[test]
fn can_trust_past_check_rejects_changed_trust_policy() {
let mut opts = default_opts("https://registry.example/");
opts.trust_policy = Some(TrustPolicy::NoDowngrade);
let verifier = create_npm_resolution_verifier(opts).expect("verifier");
let mut cached = serde_json::Map::new();
cached.insert("minimumReleaseAge".to_string(), 0.into());
cached.insert("minimumReleaseAgeExclude".to_string(), serde_json::Value::Array(vec![]));
cached.insert("trustPolicy".to_string(), serde_json::Value::Null);
cached.insert("trustPolicyExclude".to_string(), serde_json::Value::Array(vec![]));
cached.insert("trustPolicyIgnoreAfter".to_string(), serde_json::Value::Null);
assert!(!verifier.can_trust_past_check(&cached));
}
/// Changing `trustPolicyIgnoreAfter` (or going from set to unset)
/// invalidates the cache.
#[test]
fn can_trust_past_check_rejects_changed_ignore_after() {
let mut opts = default_opts("https://registry.example/");
opts.trust_policy = Some(TrustPolicy::NoDowngrade);
opts.trust_policy_ignore_after = Some(60 * 24 * 14);
let verifier = create_npm_resolution_verifier(opts).expect("verifier");
let mut cached = serde_json::Map::new();
cached.insert("minimumReleaseAge".to_string(), 0.into());
cached.insert("minimumReleaseAgeExclude".to_string(), serde_json::Value::Array(vec![]));
cached.insert("trustPolicy".to_string(), serde_json::Value::String("no-downgrade".into()));
cached.insert("trustPolicyExclude".to_string(), serde_json::Value::Array(vec![]));
cached.insert("trustPolicyIgnoreAfter".to_string(), serde_json::Value::Null);
assert!(!verifier.can_trust_past_check(&cached));
}

View File

@@ -0,0 +1,55 @@
//! Error types for the npm verifier's network / parsing surface.
use derive_more::{Display, Error};
use miette::Diagnostic;
/// Failure to fetch a registry metadata document. Used by
/// [`crate::fetch_full_metadata()`] and
/// [`crate::fetch_full_metadata_cached()`]; flows up through the
/// verifier's `verify` and is folded into a violation with
/// [`crate::MINIMUM_RELEASE_AGE_VIOLATION_CODE`] or
/// [`crate::TRUST_DOWNGRADE_VIOLATION_CODE`] depending on which
/// policy triggered the lookup.
#[derive(Debug, Display, Error, Diagnostic)]
#[non_exhaustive]
pub enum FetchMetadataError {
#[display("Failed to fetch metadata from {url}: {error}")]
#[diagnostic(code(pacquet_resolving_npm_resolver::network_error))]
Network {
url: String,
#[error(source)]
error: reqwest::Error,
},
#[display("Failed to decode metadata from {url}: {error}")]
#[diagnostic(code(pacquet_resolving_npm_resolver::decode_error))]
Decode {
url: String,
#[error(source)]
error: serde_json::Error,
},
/// Mirrors upstream's `META_NOT_MODIFIED_WITHOUT_CACHE`. Surfaces
/// only when a stale-but-removed mirror plus an `If-None-Match`
/// header the caller-provided cache headers carried (impossible
/// in pacquet's chain because we always read headers off a
/// present mirror) would trip a 304 reply we have no body to
/// satisfy — a defense-in-depth check for hand-edited caches.
#[display("Registry returned 304 for {pkg_name} without an existing cache to refresh.")]
#[diagnostic(code(pacquet_resolving_npm_resolver::not_modified_without_cache))]
NotModifiedWithoutCache {
#[error(not(source))]
pkg_name: String,
},
/// Mirrors upstream's `META_CACHE_MISSING_AFTER_304`. The mirror
/// existed when we read its headers but vanished before the
/// full read on a 304 response — concurrent cache cleanup,
/// antivirus, etc.
#[display("Metadata cache for {pkg_name} disappeared between headers read and full read.")]
#[diagnostic(code(pacquet_resolving_npm_resolver::cache_missing_after_304))]
CacheMissingAfter304 {
#[error(not(source))]
pkg_name: String,
},
}

View File

@@ -0,0 +1,138 @@
//! Per-version publish timestamp from npm's attestation endpoint —
//! `/-/npm/v1/attestations/<name>@<version>`.
//!
//! The endpoint serves a small JSON document containing one or more
//! Sigstore bundles. We read
//! `bundle.verificationMaterial.tlogEntries[].integratedTime`
//! (the Rekor inclusion time) and surface it as an ISO timestamp.
//! This is a few seconds after the actual publish — close enough for
//! a release-age policy that operates in minutes/hours/days, and
//! tens of kilobytes versus the multi-megabyte full-metadata fetch.
//!
//! We deliberately do **not** verify the Sigstore signature here:
//! the trust model is identical to reading the registry's `time`
//! field on the full metadata document.
//!
//! Returns `Ok(None)` (not `Err`) on every "no answer" condition:
//! 4xx/5xx responses, malformed JSON, missing timestamps. The
//! verifier falls back to the next layer of the publish-time lookup
//! chain. Real network errors propagate as
//! [`crate::FetchMetadataError::Network`] so the verifier can fold
//! them into a violation reason instead of swallowing.
//!
//! Verbatim port of upstream's
//! [`fetchAttestationPublishedAt.ts`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/fetchAttestationPublishedAt.ts).
use chrono::DateTime;
use pacquet_network::{AuthHeaders, ThrottledClient};
use crate::FetchMetadataError;
/// Options bundle for [`fetch_attestation_published_at`].
#[derive(Debug, Clone)]
pub struct FetchAttestationOptions<'a> {
pub registry: &'a str,
pub http_client: &'a ThrottledClient,
pub auth_headers: &'a AuthHeaders,
}
/// Fetch the earliest Rekor `integratedTime` across the attestation
/// bundles for `<name>@<version>`. Returns `Ok(Some(rfc3339))` on a
/// 2xx response with a parseable timestamp; `Ok(None)` for any
/// "no answer" condition (4xx/5xx, malformed body, no timestamps);
/// `Err(_)` only when the underlying request fails before reaching
/// the server.
pub async fn fetch_attestation_published_at(
pkg_name: &str,
version: &str,
opts: &FetchAttestationOptions<'_>,
) -> Result<Option<String>, FetchMetadataError> {
// Strip a trailing `/` from the registry root before assembling
// the endpoint URL — `<registry>/-/npm/v1/attestations/...`
// produces `//` otherwise, which some self-hosted registries
// reject as a malformed path. Matches upstream's
// `opts.registry.replace(/\/$/, '')`.
let registry = opts.registry.trim_end_matches('/');
let url = format!("{registry}/-/npm/v1/attestations/{pkg_name}@{version}");
let mut request = opts.http_client.acquire_for_url(&url).await.get(&url);
if let Some(value) = opts.auth_headers.for_url(&url) {
request = request.header("authorization", value);
}
let response = match request.send().await {
Ok(response) => response,
Err(error) => {
// Mirror upstream's `catch` swallow — return None on
// network errors so the caller falls through to the
// full-metadata layer. Surfacing the error would be more
// informative but inconsistent with upstream.
tracing::debug!(target: "pacquet_resolving_npm_resolver::attestation", ?error, %url, "attestation fetch failed; falling back");
return Ok(None);
}
};
// Anything outside the 2xx range = no answer. 404 means the
// version isn't signed; 5xx means the registry can't say; we
// fall through either way.
if !response.status().is_success() {
return Ok(None);
}
let body: serde_json::Value = match response.json().await {
Ok(body) => body,
Err(error) => {
tracing::debug!(target: "pacquet_resolving_npm_resolver::attestation", ?error, %url, "attestation body parse failed; falling back");
return Ok(None);
}
};
Ok(extract_published_at(&body))
}
/// Pull the earliest `integratedTime` across every attestation
/// bundle in the response and convert it to an ISO timestamp.
/// Earliest is the conservative choice: if two attestations
/// disagree (e.g. publish v0.1 vs SLSA provenance v1) the older
/// Rekor entry is what tells us when the artifact existed in a
/// transparency log, which is the floor on publish time.
fn extract_published_at(body: &serde_json::Value) -> Option<String> {
let attestations = body.get("attestations")?.as_array()?;
let mut earliest: Option<i64> = None;
for attestation in attestations {
let seconds = match read_earliest_integrated_time(attestation) {
Some(seconds) => seconds,
None => continue,
};
earliest = Some(earliest.map_or(seconds, |current| current.min(seconds)));
}
let seconds = earliest?;
// `DateTime::from_timestamp` accepts an i64 of seconds-since-epoch.
// Any seconds value within the chrono-supported range survives the
// conversion; the registry isn't going to send pre-1970 timestamps.
let dt = DateTime::from_timestamp(seconds, 0)?;
Some(dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true))
}
fn read_earliest_integrated_time(attestation: &serde_json::Value) -> Option<i64> {
let bundle = attestation.get("bundle")?;
let verification_material = bundle.get("verificationMaterial")?;
let tlog_entries = verification_material.get("tlogEntries")?.as_array()?;
let mut earliest: Option<i64> = None;
for entry in tlog_entries {
// npm serializes integratedTime as a string ("1778583836")
// to avoid JSON precision loss; accept either string or
// number defensively.
let seconds = parse_integrated_time_seconds(entry.get("integratedTime")?)?;
earliest = Some(earliest.map_or(seconds, |current| current.min(seconds)));
}
earliest
}
fn parse_integrated_time_seconds(value: &serde_json::Value) -> Option<i64> {
if let Some(text) = value.as_str() {
return text.parse::<i64>().ok().filter(|&seconds| seconds > 0);
}
if let Some(seconds) = value.as_i64() {
return Some(seconds).filter(|&s| s > 0);
}
None
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,242 @@
use pacquet_network::{AuthHeaders, ThrottledClient};
use super::{FetchAttestationOptions, fetch_attestation_published_at};
fn opts<'a>(
registry: &'a str,
http_client: &'a ThrottledClient,
auth_headers: &'a AuthHeaders,
) -> FetchAttestationOptions<'a> {
FetchAttestationOptions { registry, http_client, auth_headers }
}
/// A successful 200 with one bundle and one `tlogEntries` entry
/// yields the matching ISO timestamp. Mirrors the happy-path
/// upstream test.
#[tokio::test]
async fn finds_publish_time_from_single_bundle() {
let mut server = mockito::Server::new_async().await;
// 2024-01-01T00:00:00Z = 1704067200
let body = r#"{
"attestations": [
{
"bundle": {
"verificationMaterial": {
"tlogEntries": [{ "integratedTime": "1704067200" }]
}
}
}
]
}"#;
let mock = server
.mock("GET", "/-/npm/v1/attestations/acme@1.0.0")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(body)
.expect(1)
.create_async()
.await;
let registry = format!("{}/", server.url());
let http_client = ThrottledClient::default();
let auth_headers = AuthHeaders::default();
let result = fetch_attestation_published_at(
"acme",
"1.0.0",
&opts(&registry, &http_client, &auth_headers),
)
.await
.expect("network ok");
assert_eq!(result.as_deref(), Some("2024-01-01T00:00:00.000Z"));
mock.assert_async().await;
}
/// Multiple bundles → earliest wins. Mirrors upstream's
/// `extractPublishedAt` test that asserts the floor across
/// disagreeing entries.
#[tokio::test]
async fn earliest_wins_across_multiple_bundles() {
let mut server = mockito::Server::new_async().await;
let body = r#"{
"attestations": [
{ "bundle": { "verificationMaterial": { "tlogEntries": [{ "integratedTime": "1735689600" }] } } },
{ "bundle": { "verificationMaterial": { "tlogEntries": [{ "integratedTime": "1704067200" }, { "integratedTime": "1735689600" }] } } }
]
}"#;
let mock = server
.mock("GET", "/-/npm/v1/attestations/acme@1.0.0")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(body)
.expect(1)
.create_async()
.await;
let registry = format!("{}/", server.url());
let http_client = ThrottledClient::default();
let auth_headers = AuthHeaders::default();
let result = fetch_attestation_published_at(
"acme",
"1.0.0",
&opts(&registry, &http_client, &auth_headers),
)
.await
.expect("network ok");
assert_eq!(
result.as_deref(),
Some("2024-01-01T00:00:00.000Z"),
"earliest integratedTime across all bundles wins",
);
mock.assert_async().await;
}
/// 404 = the version isn't signed; the verifier falls through to
/// the next layer of the publish-time lookup chain, so we return
/// `Ok(None)`.
#[tokio::test]
async fn returns_none_on_404() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/-/npm/v1/attestations/acme@1.0.0")
.with_status(404)
.expect(1)
.create_async()
.await;
let registry = format!("{}/", server.url());
let http_client = ThrottledClient::default();
let auth_headers = AuthHeaders::default();
let result = fetch_attestation_published_at(
"acme",
"1.0.0",
&opts(&registry, &http_client, &auth_headers),
)
.await
.expect("network ok");
assert_eq!(result, None);
mock.assert_async().await;
}
/// A 5xx response also yields `Ok(None)` — the registry can't tell
/// us, but the verifier still has other layers to consult. Matches
/// upstream's "anything outside 2xx = fall back" semantics.
#[tokio::test]
async fn returns_none_on_5xx() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/-/npm/v1/attestations/acme@1.0.0")
.with_status(503)
.expect(1)
.create_async()
.await;
let registry = format!("{}/", server.url());
let http_client = ThrottledClient::default();
let auth_headers = AuthHeaders::default();
let result = fetch_attestation_published_at(
"acme",
"1.0.0",
&opts(&registry, &http_client, &auth_headers),
)
.await
.expect("network ok");
assert_eq!(result, None);
mock.assert_async().await;
}
/// A malformed body (missing the `attestations` array entirely)
/// returns `None` rather than erroring — verifier still falls
/// through.
#[tokio::test]
async fn returns_none_on_malformed_body() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/-/npm/v1/attestations/acme@1.0.0")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{ "unrelated": true }"#)
.expect(1)
.create_async()
.await;
let registry = format!("{}/", server.url());
let http_client = ThrottledClient::default();
let auth_headers = AuthHeaders::default();
let result = fetch_attestation_published_at(
"acme",
"1.0.0",
&opts(&registry, &http_client, &auth_headers),
)
.await
.expect("network ok");
assert_eq!(result, None);
mock.assert_async().await;
}
/// `integratedTime` arrives as a JSON number (rather than the
/// canonical npm string). The parser accepts both — matches
/// upstream's defensive handling.
#[tokio::test]
async fn accepts_integrated_time_as_number() {
let mut server = mockito::Server::new_async().await;
let body = r#"{
"attestations": [
{ "bundle": { "verificationMaterial": { "tlogEntries": [{ "integratedTime": 1704067200 }] } } }
]
}"#;
let mock = server
.mock("GET", "/-/npm/v1/attestations/acme@1.0.0")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(body)
.expect(1)
.create_async()
.await;
let registry = format!("{}/", server.url());
let http_client = ThrottledClient::default();
let auth_headers = AuthHeaders::default();
let result = fetch_attestation_published_at(
"acme",
"1.0.0",
&opts(&registry, &http_client, &auth_headers),
)
.await
.expect("network ok");
assert_eq!(result.as_deref(), Some("2024-01-01T00:00:00.000Z"));
mock.assert_async().await;
}
/// Registry root with a trailing slash should not produce a double
/// slash in the assembled endpoint URL. The matcher pinned on
/// `/-/npm/v1/attestations/acme@1.0.0` would 404 if pacquet sent
/// `//-/npm/v1/...` — the `trim_end_matches('/')` line on the
/// registry root is what avoids that.
#[tokio::test]
async fn trims_trailing_slash_from_registry_root() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/-/npm/v1/attestations/acme@1.0.0")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{ "attestations": [{ "bundle": { "verificationMaterial": { "tlogEntries": [{ "integratedTime": "1704067200" }] } } }] }"#,
)
.expect(1)
.create_async()
.await;
// Mockito's `server.url()` already lacks a trailing slash; force one.
let registry = format!("{}/", server.url());
let http_client = ThrottledClient::default();
let auth_headers = AuthHeaders::default();
let result = fetch_attestation_published_at(
"acme",
"1.0.0",
&opts(&registry, &http_client, &auth_headers),
)
.await
.expect("network ok");
assert_eq!(result.as_deref(), Some("2024-01-01T00:00:00.000Z"));
mock.assert_async().await;
}

View File

@@ -0,0 +1,75 @@
//! No-cache full-metadata fetcher.
//!
//! Issues a GET against `<registry>/<package>` with the *full*
//! metadata `Accept` header (`application/json; q=1.0`) — distinct
//! from the abbreviated `application/vnd.npm.install-v1+json`
//! endpoint that pnpm's resolver uses for fast picks. The verifier
//! needs the full document because the abbreviated form omits the
//! `time` map, `_npmUser`, and `dist.attestations` — the three
//! fields the `minimumReleaseAge` and `trustPolicy='no-downgrade'`
//! checks read.
//!
//! Caching (conditional GETs + on-disk mirror) lands in a follow-up
//! phase. This module is the no-cache baseline that
//! [`crate::create_npm_resolution_verifier()`] consumes today; the
//! cached variant wraps it without changing the call site.
//!
//! Ports the no-cache half of upstream's
//! [`fetchFullMetadataCached.ts`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/fetchFullMetadataCached.ts).
use pacquet_network::{AuthHeaders, ThrottledClient};
use pacquet_registry::Package;
use crate::{FetchMetadataError, registry_url::to_registry_url};
/// Options bundle for [`fetch_full_metadata`]. Mirrors upstream's
/// `FetchFullMetadataCachedOptions` minus the cache fields, which the
/// cached variant (Phase 5) will layer on.
#[derive(Debug, Clone)]
pub struct FetchFullMetadataOptions<'a> {
pub registry: &'a str,
pub http_client: &'a ThrottledClient,
pub auth_headers: &'a AuthHeaders,
}
/// Fetch the **full** registry metadata document for `pkg_name`.
/// The full document carries `time`, per-version `_npmUser`, and
/// `dist.attestations` — the abbreviated install-v1 endpoint pnpm's
/// resolver normally uses omits all three.
pub async fn fetch_full_metadata(
pkg_name: &str,
opts: &FetchFullMetadataOptions<'_>,
) -> Result<Package, FetchMetadataError> {
// Format once and reuse for the request, the auth-header lookup,
// and the error mapper. Mirrors upstream's
// [`toUri`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/fetch.ts)
// — scoped names get the `/` after the `@scope` percent-encoded
// so the registry routes the request to the package as a single
// path segment, not two.
let url = to_registry_url(opts.registry, pkg_name);
let mut request =
opts.http_client.acquire_for_url(&url).await.get(&url).header("accept", "application/json");
if let Some(value) = opts.auth_headers.for_url(&url) {
request = request.header("authorization", value);
}
let response = request
.send()
.await
.map_err(|error| FetchMetadataError::Network { url: url.clone(), error })?
.error_for_status()
.map_err(|error| FetchMetadataError::Network { url: url.clone(), error })?;
// Decode in two steps so a JSON-shape mismatch surfaces as
// `FetchMetadataError::Decode` (with the serde_json error), not
// as `Network` (which `.json::<T>()` would do, conflating
// transport and parse failures and losing the
// `decode_error` diagnostic code).
let raw_body = response
.text()
.await
.map_err(|error| FetchMetadataError::Network { url: url.clone(), error })?;
serde_json::from_str(&raw_body)
.map_err(|error| FetchMetadataError::Decode { url: url.clone(), error })
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,178 @@
use pacquet_network::{AuthHeaders, ThrottledClient};
use super::{FetchFullMetadataOptions, fetch_full_metadata};
/// Fetches against a real `mockito` server that asserts the request
/// arrives with the *full*-metadata `Accept` header
/// (`application/json`) and the registry-keyed `Authorization`
/// header. The 200 body carries `time`, `_npmUser`, and
/// `dist.attestations` so the test also confirms the deserialization
/// surfaces those fields end-to-end.
#[tokio::test]
async fn fetch_full_metadata_targets_full_endpoint_with_auth() {
let mut server = mockito::Server::new_async().await;
let body = r#"{
"name": "acme",
"dist-tags": { "latest": "1.0.0" },
"modified": "2025-01-15T12:00:00.000Z",
"time": { "1.0.0": "2025-01-10T08:30:00.000Z" },
"versions": {
"1.0.0": {
"name": "acme",
"version": "1.0.0",
"_npmUser": {
"name": "alice",
"trustedPublisher": { "id": "github", "oidcConfigId": "release" }
},
"dist": {
"integrity": "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"shasum": "0000000000000000000000000000000000000000",
"tarball": "https://registry/acme-1.0.0.tgz",
"attestations": {
"provenance": { "predicateType": "https://slsa.dev/provenance/v1" }
}
}
}
}
}"#;
let mock = server
.mock("GET", "/acme")
.match_header("accept", "application/json")
.match_header("authorization", "Bearer top-secret")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(body)
.expect(1)
.create_async()
.await;
let registry = format!("{}/", server.url());
let http_client = ThrottledClient::default();
let auth_headers = AuthHeaders::from_creds_map(
[(pacquet_network::nerf_dart(&registry), "Bearer top-secret".to_owned())],
None,
);
let opts = FetchFullMetadataOptions {
registry: &registry,
http_client: &http_client,
auth_headers: &auth_headers,
};
let pkg = fetch_full_metadata("acme", &opts).await.expect("server returns 200");
assert_eq!(pkg.name, "acme");
assert_eq!(pkg.published_at("1.0.0"), Some("2025-01-10T08:30:00.000Z"));
let version = pkg.versions.get("1.0.0").expect("version present");
assert!(version.npm_user.as_ref().and_then(|user| user.trusted_publisher.as_ref()).is_some());
assert!(version.dist.attestations.as_ref().and_then(|att| att.provenance.as_ref()).is_some());
mock.assert_async().await;
}
/// A 5xx response propagates as a [`super::FetchMetadataError::Network`]
/// rather than panicking or silently returning a default-valued
/// `Package`. Mirrors upstream's `fetchFullMetadataCached`
/// fail-closed behavior — the verifier surfaces the underlying
/// message as the violation reason.
#[tokio::test]
async fn fetch_full_metadata_surfaces_5xx_as_network_error() {
let mut server = mockito::Server::new_async().await;
let mock = server.mock("GET", "/acme").with_status(503).expect(1).create_async().await;
let registry = format!("{}/", server.url());
let http_client = ThrottledClient::default();
let auth_headers = AuthHeaders::default();
let opts = FetchFullMetadataOptions {
registry: &registry,
http_client: &http_client,
auth_headers: &auth_headers,
};
let err = fetch_full_metadata("acme", &opts).await.expect_err("503 must surface");
assert!(
matches!(err, super::FetchMetadataError::Network { .. }),
"expected Network variant, got: {err:?}",
);
let text = format!("{err:?}");
assert!(text.contains("acme"), "error mentions the failing URL: {text}");
mock.assert_async().await;
}
/// Scoped names percent-encode the `/` between the `@scope` prefix
/// and the bare name so the registry routes the request as a
/// single path segment — matches upstream's `toUri`. The mockito
/// `match` rule below uses the encoded path; if the encoding
/// regresses (raw slash), mockito returns 501 and the test fails.
#[tokio::test]
async fn fetch_full_metadata_encodes_scoped_name() {
let mut server = mockito::Server::new_async().await;
let body = r#"{
"name": "@scope/pkg",
"dist-tags": { "latest": "1.0.0" },
"time": { "1.0.0": "2025-01-10T08:30:00.000Z" },
"versions": {
"1.0.0": {
"name": "@scope/pkg",
"version": "1.0.0",
"dist": {
"integrity": "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"shasum": "0000000000000000000000000000000000000000",
"tarball": "https://registry/scope-pkg-1.0.0.tgz"
}
}
}
}"#;
let mock = server
.mock("GET", "/@scope%2Fpkg")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(body)
.expect(1)
.create_async()
.await;
let registry = format!("{}/", server.url());
let http_client = ThrottledClient::default();
let auth_headers = AuthHeaders::default();
let opts = FetchFullMetadataOptions {
registry: &registry,
http_client: &http_client,
auth_headers: &auth_headers,
};
let pkg =
fetch_full_metadata("@scope/pkg", &opts).await.expect("encoded scoped name reaches mock");
assert_eq!(pkg.name, "@scope/pkg");
mock.assert_async().await;
}
/// A 200 response with a malformed body surfaces as
/// [`super::FetchMetadataError::Decode`] (not `Network`), so the
/// install-side diagnostic code routes to `decode_error` rather
/// than `network_error`. Mirrors upstream's split between transport
/// and decode failures.
#[tokio::test]
async fn fetch_full_metadata_surfaces_decode_failure_distinctly() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/acme")
.with_status(200)
.with_body("definitely not JSON")
.expect(1)
.create_async()
.await;
let registry = format!("{}/", server.url());
let http_client = ThrottledClient::default();
let auth_headers = AuthHeaders::default();
let opts = FetchFullMetadataOptions {
registry: &registry,
http_client: &http_client,
auth_headers: &auth_headers,
};
let err = fetch_full_metadata("acme", &opts).await.expect_err("malformed JSON must surface");
assert!(
matches!(err, super::FetchMetadataError::Decode { .. }),
"expected Decode variant, got: {err:?}",
);
mock.assert_async().await;
}

View File

@@ -0,0 +1,191 @@
//! Cache-aware full-metadata fetcher.
//!
//! Ports pnpm's
//! [`fetchFullMetadataCached`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/fetchFullMetadataCached.ts).
//!
//! When a cache directory is configured, the fetcher consults a
//! shared mirror at `<cache_dir>/v11/metadata-full/<registry>/<pkg>.jsonl`,
//! issues a conditional GET against the upstream registry, and either
//! reads the cached body (304) or writes the new body back (2xx).
//! Without a cache directory it falls through to a plain GET — the
//! same behavior callers got before Phase 5 from
//! [`crate::fetch_full_metadata()`].
//!
//! The cache layout matches pnpm's so a pnpm-populated mirror is
//! usable from pacquet (and vice versa). The two-line NDJSON shape
//! lives in [`crate::mirror`].
use std::path::Path;
use pacquet_network::{AuthHeaders, ThrottledClient};
use pacquet_registry::Package;
use pipe_trait::Pipe;
use reqwest::{StatusCode, header};
use crate::{
FetchMetadataError,
mirror::{
FULL_META_DIR, get_pkg_mirror_path, load_meta, load_meta_headers, prepare_json_for_disk,
save_meta,
},
registry_url::to_registry_url,
};
/// Options bundle for [`fetch_full_metadata_cached`]. Mirrors
/// upstream's `FetchFullMetadataCachedOptions` — same fields, same
/// optionality. `cache_dir` is the only addition over the no-cache
/// [`crate::FetchFullMetadataOptions`].
#[derive(Debug, Clone)]
pub struct FetchFullMetadataCachedOptions<'a> {
pub registry: &'a str,
pub http_client: &'a ThrottledClient,
pub auth_headers: &'a AuthHeaders,
/// When `Some`, the fetcher consults the on-disk mirror at
/// `<cache_dir>/v11/metadata-full/...`. When `None`, the fetcher
/// short-circuits to an unconditional GET.
pub cache_dir: Option<&'a Path>,
}
/// Fetch the full registry metadata document for `pkg_name`, reusing
/// the shared on-disk mirror when `cache_dir` is supplied. Ports
/// upstream's
/// [`fetchFullMetadataCached`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/fetchFullMetadataCached.ts#L30-L36).
///
/// Flow:
///
/// 1. **Compute mirror path** (when `cache_dir` is set). Failures
/// in this step degrade silently — a malformed registry URL or
/// other path-encoding error just disables the cache for this
/// call; the fetch still issues an unconditional GET.
/// 2. **Read cache headers** off the mirror's first line via the
/// internal `load_meta_headers` helper. Missing file /
/// unreadable / malformed → no conditional headers; the GET is
/// unconditional.
/// 3. **Issue the GET** with `If-None-Match` /
/// `If-Modified-Since` headers when both ETag/Last-Modified were
/// available, plus the per-URL `Authorization` header from
/// [`AuthHeaders`].
/// 4. **On `304 Not Modified`**: re-read the mirror via the
/// internal `load_meta` helper.
/// A 304 with no mirror present propagates as [`FetchMetadataError::NotModifiedWithoutCache`]
/// (matches upstream's `META_NOT_MODIFIED_WITHOUT_CACHE`);
/// a 304 whose mirror vanishes between the headers read and the
/// full read propagates as [`FetchMetadataError::CacheMissingAfter304`]
/// (matches `META_CACHE_MISSING_AFTER_304`).
/// 5. **On `2xx`**: parse the response into [`Package`], write the
/// body + new headers to the mirror best-effort (a cache-write
/// failure logs at debug but never fails the call — the install
/// proceeds without the speedup on the next run), and return.
/// 6. **On non-2xx / non-304**: surface
/// [`FetchMetadataError::Network`].
pub async fn fetch_full_metadata_cached(
pkg_name: &str,
opts: &FetchFullMetadataCachedOptions<'_>,
) -> Result<Package, FetchMetadataError> {
// Encoding the mirror path can fail only on a malformed registry
// URL (no host, unparsable). Either case is a config bug; we
// log and proceed without a cache so the user still gets metadata
// on this install instead of a hard error.
let mirror_path = match opts.cache_dir {
Some(dir) => match get_pkg_mirror_path(dir, FULL_META_DIR, opts.registry, pkg_name) {
Ok(path) => Some(path),
Err(error) => {
tracing::debug!(
target: "pacquet_resolving_npm_resolver::cache",
?error,
registry = opts.registry,
pkg_name,
"could not encode mirror path; bypassing cache for this call",
);
None
}
},
None => None,
};
let cache_headers = mirror_path.as_deref().and_then(load_meta_headers);
let url = to_registry_url(opts.registry, pkg_name);
let mut request = opts
.http_client
.acquire_for_url(&url)
.await
.get(&url)
.header(header::ACCEPT, "application/json");
if let Some(value) = opts.auth_headers.for_url(&url) {
request = request.header(header::AUTHORIZATION, value);
}
if let Some(headers) = cache_headers.as_ref() {
if let Some(etag) = headers.etag.as_deref() {
request = request.header(header::IF_NONE_MATCH, etag);
}
if let Some(modified) = headers.modified.as_deref() {
request = request.header(header::IF_MODIFIED_SINCE, modified);
}
}
let response = request
.send()
.await
.map_err(|error| FetchMetadataError::Network { url: url.clone(), error })?;
if response.status() == StatusCode::NOT_MODIFIED {
let Some(path) = mirror_path else {
// 304 without an existing cache to fall back on — the
// registry over-reached on `If-None-Match: <stale>`.
// Mirrors upstream's `META_NOT_MODIFIED_WITHOUT_CACHE`.
return Err(FetchMetadataError::NotModifiedWithoutCache {
pkg_name: pkg_name.to_string(),
});
};
return load_meta(&path).ok_or_else(|| FetchMetadataError::CacheMissingAfter304 {
pkg_name: pkg_name.to_string(),
});
}
let response = response
.error_for_status()
.map_err(|error| FetchMetadataError::Network { url: url.clone(), error })?;
let etag = response
.headers()
.get(header::ETAG)
.and_then(|value| value.to_str().ok())
.map(str::to_string);
let raw_body = response
.text()
.await
.map_err(|error| FetchMetadataError::Network { url: url.clone(), error })?;
let meta: Package = serde_json::from_str(&raw_body)
.map_err(|error| FetchMetadataError::Decode { url: url.clone(), error })?;
if let Some(path) = mirror_path.as_deref() {
match prepare_json_for_disk(&meta, etag.as_deref(), Some(&raw_body)) {
Ok(json) => {
if let Err(error) = save_meta(path, &json) {
// Fire-and-forget — a read-only cache dir or a
// shared-store contention shouldn't fail the
// install. The user just won't see the warm-cache
// speedup next time.
tracing::debug!(
target: "pacquet_resolving_npm_resolver::cache",
?error,
path = %path.display(),
"could not persist mirror; bypassing cache write",
);
}
}
Err(error) => {
tracing::debug!(
target: "pacquet_resolving_npm_resolver::cache",
?error,
path = %path.display(),
"could not serialize mirror entry; bypassing cache write",
);
}
}
}
meta.pipe(Ok)
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,221 @@
use pacquet_network::{AuthHeaders, ThrottledClient};
use tempfile::TempDir;
use super::{FetchFullMetadataCachedOptions, fetch_full_metadata_cached};
use crate::mirror::{FULL_META_DIR, get_pkg_mirror_path, load_meta, load_meta_headers};
const PACKAGE_BODY: &str = r#"{
"name": "acme",
"dist-tags": { "latest": "1.0.0" },
"modified": "2025-01-15T12:00:00.000Z",
"time": { "1.0.0": "2025-01-10T08:30:00.000Z" },
"versions": {
"1.0.0": {
"name": "acme",
"version": "1.0.0",
"dist": {
"integrity": "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"shasum": "0000000000000000000000000000000000000000",
"tarball": "https://registry/acme-1.0.0.tgz"
}
}
}
}"#;
/// Cold cache (no mirror file) → registry returns 200 → mirror is
/// populated with the response body + etag.
#[tokio::test]
async fn cold_cache_writes_mirror_on_200() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/acme")
.match_header("accept", "application/json")
.with_status(200)
.with_header("etag", r#"W/"fresh""#)
.with_body(PACKAGE_BODY)
.expect(1)
.create_async()
.await;
let cache = TempDir::new().expect("tempdir");
let registry = format!("{}/", server.url());
let http_client = ThrottledClient::default();
let auth_headers = AuthHeaders::default();
let opts = FetchFullMetadataCachedOptions {
registry: &registry,
http_client: &http_client,
auth_headers: &auth_headers,
cache_dir: Some(cache.path()),
};
let pkg = fetch_full_metadata_cached("acme", &opts).await.expect("200 → ok");
assert_eq!(pkg.name, "acme");
mock.assert_async().await;
let mirror_path =
get_pkg_mirror_path(cache.path(), FULL_META_DIR, &registry, "acme").expect("path");
assert!(mirror_path.exists(), "mirror file written");
let headers = load_meta_headers(&mirror_path).expect("headers readable");
assert_eq!(headers.etag.as_deref(), Some(r#"W/"fresh""#));
}
/// Warm cache + matching `If-None-Match` → 304 → response served
/// from disk; no parse against the empty body.
#[tokio::test]
async fn warm_cache_serves_from_mirror_on_304() {
let mut server = mockito::Server::new_async().await;
// First call: 200, mirror written.
let first = server
.mock("GET", "/acme")
.match_header("accept", "application/json")
.with_status(200)
.with_header("etag", r#"W/"v1""#)
.with_body(PACKAGE_BODY)
.expect(1)
.create_async()
.await;
// Second call: must carry If-None-Match: W/"v1" — registry replies 304.
let second = server
.mock("GET", "/acme")
.match_header("if-none-match", r#"W/"v1""#)
.with_status(304)
.expect(1)
.create_async()
.await;
let cache = TempDir::new().expect("tempdir");
let registry = format!("{}/", server.url());
let http_client = ThrottledClient::default();
let auth_headers = AuthHeaders::default();
let opts = FetchFullMetadataCachedOptions {
registry: &registry,
http_client: &http_client,
auth_headers: &auth_headers,
cache_dir: Some(cache.path()),
};
let _first_pkg = fetch_full_metadata_cached("acme", &opts).await.expect("200 populates cache");
first.assert_async().await;
let second_pkg =
fetch_full_metadata_cached("acme", &opts).await.expect("304 reads from mirror");
second.assert_async().await;
assert_eq!(second_pkg.name, "acme");
assert_eq!(second_pkg.published_at("1.0.0"), Some("2025-01-10T08:30:00.000Z"));
}
/// Warm cache + stale `If-None-Match` → 200 → mirror is overwritten
/// with the new body + new etag.
#[tokio::test]
async fn stale_cache_refreshes_mirror_on_200() {
let mut server = mockito::Server::new_async().await;
let first = server
.mock("GET", "/acme")
.with_status(200)
.with_header("etag", r#"W/"v1""#)
.with_body(PACKAGE_BODY)
.expect(1)
.create_async()
.await;
let updated_body = PACKAGE_BODY.replace("2025-01-10T08:30:00.000Z", "2025-03-01T00:00:00.000Z");
let second = server
.mock("GET", "/acme")
.match_header("if-none-match", r#"W/"v1""#)
.with_status(200)
.with_header("etag", r#"W/"v2""#)
.with_body(updated_body)
.expect(1)
.create_async()
.await;
let cache = TempDir::new().expect("tempdir");
let registry = format!("{}/", server.url());
let http_client = ThrottledClient::default();
let auth_headers = AuthHeaders::default();
let opts = FetchFullMetadataCachedOptions {
registry: &registry,
http_client: &http_client,
auth_headers: &auth_headers,
cache_dir: Some(cache.path()),
};
let _ = fetch_full_metadata_cached("acme", &opts).await.expect("populate");
first.assert_async().await;
let pkg = fetch_full_metadata_cached("acme", &opts).await.expect("refresh");
second.assert_async().await;
assert_eq!(pkg.published_at("1.0.0"), Some("2025-03-01T00:00:00.000Z"));
let mirror = get_pkg_mirror_path(cache.path(), FULL_META_DIR, &registry, "acme").expect("path");
let reloaded = load_meta(&mirror).expect("mirror readable");
assert_eq!(reloaded.etag.as_deref(), Some(r#"W/"v2""#));
}
/// `cache_dir = None` → straight unconditional GET, no mirror IO. A
/// 200 response yields the parsed package as before; nothing is
/// written to disk.
#[tokio::test]
async fn no_cache_dir_skips_mirror_io() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/acme")
.match_header("accept", "application/json")
.with_status(200)
.with_body(PACKAGE_BODY)
.expect(1)
.create_async()
.await;
let registry = format!("{}/", server.url());
let http_client = ThrottledClient::default();
let auth_headers = AuthHeaders::default();
let opts = FetchFullMetadataCachedOptions {
registry: &registry,
http_client: &http_client,
auth_headers: &auth_headers,
cache_dir: None,
};
let pkg = fetch_full_metadata_cached("acme", &opts).await.expect("200 → ok");
assert_eq!(pkg.name, "acme");
mock.assert_async().await;
}
/// `cache_dir` points at a read-only directory. The fetch still
/// succeeds with the parsed body — cache writes are fire-and-forget,
/// failures only suppress the next-install speedup. Mirrors
/// upstream's `saveMeta(...).catch(() => {})`.
#[cfg(unix)]
#[tokio::test]
async fn read_only_cache_dir_does_not_fail_the_call() {
use std::{fs, os::unix::fs::PermissionsExt};
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/acme")
.with_status(200)
.with_body(PACKAGE_BODY)
.expect(1)
.create_async()
.await;
let cache = TempDir::new().expect("tempdir");
let mode = cache.path().metadata().expect("stat").permissions().mode();
fs::set_permissions(cache.path(), fs::Permissions::from_mode(0o555)).expect("set read-only");
let registry = format!("{}/", server.url());
let http_client = ThrottledClient::default();
let auth_headers = AuthHeaders::default();
let opts = FetchFullMetadataCachedOptions {
registry: &registry,
http_client: &http_client,
auth_headers: &auth_headers,
cache_dir: Some(cache.path()),
};
let pkg = fetch_full_metadata_cached("acme", &opts).await.expect("read-only must not fail");
assert_eq!(pkg.name, "acme");
mock.assert_async().await;
// Restore so TempDir's drop can clean up.
fs::set_permissions(cache.path(), fs::Permissions::from_mode(mode)).ok();
}

View File

@@ -0,0 +1,41 @@
//! Pacquet port of the verifier surface of pnpm's
//! [`@pnpm/resolving.npm-resolver`](https://github.com/pnpm/pnpm/tree/2a9bd897bf/resolving/npm-resolver/src/).
//!
//! Today this crate ports the [`createNpmResolutionVerifier`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/createNpmResolutionVerifier.ts)
//! pipeline: a [`pacquet_resolving_resolver_base::ResolutionVerifier`]
//! that re-applies `minimumReleaseAge` and `trustPolicy='no-downgrade'`
//! to every npm-resolved lockfile entry the install loads.
//!
//! The full upstream package also exposes resolution helpers
//! (`pickPackage`, `parseBareSpecifier`, …) that pacquet doesn't have
//! a use for yet — those land alongside a real resolver when one
//! arrives.
mod create_npm_resolution_verifier;
mod errors;
mod fetch_attestation_published_at;
mod fetch_full_metadata;
mod fetch_full_metadata_cached;
mod lookup_context;
mod mirror;
mod named_registry;
mod registry_url;
mod trust_checks;
mod violation_codes;
pub use create_npm_resolution_verifier::{
CreateNpmResolutionVerifierOptions, NpmResolutionVerifier, create_npm_resolution_verifier,
};
pub use errors::FetchMetadataError;
pub use fetch_attestation_published_at::{FetchAttestationOptions, fetch_attestation_published_at};
pub use fetch_full_metadata::{FetchFullMetadataOptions, fetch_full_metadata};
pub use fetch_full_metadata_cached::{FetchFullMetadataCachedOptions, fetch_full_metadata_cached};
pub use mirror::{ABBREVIATED_META_DIR, FULL_META_DIR};
pub use named_registry::{
BUILTIN_NAMED_REGISTRIES, build_named_registry_prefixes, pick_registry_for_package,
pick_registry_for_version,
};
pub use trust_checks::{
TrustCheckOptions, TrustEvidence, TrustViolation, fail_if_trust_downgraded, get_trust_evidence,
};
pub use violation_codes::{MINIMUM_RELEASE_AGE_VIOLATION_CODE, TRUST_DOWNGRADE_VIOLATION_CODE};

View File

@@ -0,0 +1,65 @@
//! Per-install dedup caches for the npm verifier's
//! publish-timestamp and trust-history lookups.
//!
//! Ports upstream's
//! [`PublishedAtLookupContext`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/createNpmResolutionVerifier.ts#L387-L433)
//! inline struct. Verifying many `(name, version)` pairs in one
//! install should pay the disk/network costs at most once per
//! `(registry, name)` pair (for package-scoped lookups) or once per
//! `(registry, name, version)` triple (for the final published-at
//! answer). The maps live behind `tokio::sync::Mutex` so the
//! buffer-unordered fan-out the lockfile-verification runner uses
//! can share one context across concurrent tasks without contending
//! on the publishing record itself.
//!
//! Phase 4 only carries the caches the layered lookup actually
//! consults — the on-disk `local_meta` mirror and the abbreviated
//! `modified` shortcut land in Phase 5 alongside the cached
//! fetchers they depend on. Adding them here ahead of their callers
//! would trip the workspace's `--deny warnings` lint on dead fields.
use std::{collections::HashMap, sync::Arc};
use pacquet_registry::Package;
use tokio::sync::Mutex;
/// Per-version time map keyed by version string. The verifier only
/// reads the publish timestamp for a specific version, so storing
/// `String` per entry is enough — the rest of the package's `time`
/// payload (`created`, `modified`, the reserved `unpublished` key)
/// is irrelevant to the policy.
pub(crate) type PublishedAtTimeMap = HashMap<String, String>;
/// Per-install dedup of the lookups the verifier issues. Mirrors
/// upstream's
/// [`PublishedAtLookupContext`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/createNpmResolutionVerifier.ts#L387-L433).
///
/// Each `HashMap` is keyed by a per-cache string composed from
/// `registry`, `name`, and (for `published_at`) `version`. Upstream
/// joins them with a `\x00` separator to sidestep any collision with
/// legal URL/name characters; we keep the same convention so cache
/// keys remain identical across stacks.
#[derive(Debug, Default)]
pub(crate) struct PublishedAtLookupContext {
pub published_at: Mutex<HashMap<String, Option<String>>>,
pub full_meta: Mutex<HashMap<String, Option<Arc<PublishedAtTimeMap>>>>,
pub full_meta_for_trust: Mutex<HashMap<String, Result<Arc<Package>, String>>>,
}
impl PublishedAtLookupContext {
pub(crate) fn new() -> Self {
Self::default()
}
}
/// `\x00`-joined cache key for `(registry, name)` package-scoped
/// lookups. Matches upstream's `${registry}\x00${name}` template.
pub(crate) fn package_key(registry: &str, name: &str) -> String {
format!("{registry}\x00{name}")
}
/// `\x00`-joined cache key for `(registry, name, version)` lookups.
/// Matches upstream's `${registry}\x00${name}\x00${version}`.
pub(crate) fn version_key(registry: &str, name: &str, version: &str) -> String {
format!("{registry}\x00{name}\x00{version}")
}

View File

@@ -0,0 +1,282 @@
//! On-disk packument-mirror helpers.
//!
//! Ports the cache-path and IO helpers in pnpm's
//! [`pickPackage.ts`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/pickPackage.ts)
//! the verifier needs to share the resolver's metadata mirror:
//!
//! - [`get_pkg_mirror_path`] — `<cache_dir>/<meta_dir>/<registry-encoded>/<encoded-name>.jsonl`.
//! - [`prepare_json_for_disk`] — two-line NDJSON shape (header line +
//! body line) the registry-metadata cache uses.
//! - [`load_meta_headers`] — read just the first line (etag, modified)
//! to feed conditional GETs without paying for the body parse.
//! - [`load_meta`] — read both lines and reconstruct a [`Package`]
//! with its etag back-filled.
//! - [`save_meta`] — atomic write via temp + rename so a torn write
//! never leaks a half-formed mirror to the next install.
//!
//! Plus the constants and name-encoding rules:
//!
//! - [`FULL_META_DIR`] / [`ABBREVIATED_META_DIR`] — directory slugs
//! pnpm and pacquet share.
//! - [`encode_pkg_name`] — mixed-case package names get a sha256 hex
//! suffix so case-insensitive filesystems (HFS+, NTFS by default)
//! can't collide two distinct package names onto one mirror file.
//! - [`get_registry_name`] — `host[:port]` with `:` → `+` (the
//! filesystem-safe encoding the npm `encode-registry` package
//! produces).
use std::{
fs::{self, File, OpenOptions},
io::{self, Read, Write},
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
};
use derive_more::{Display, Error};
use miette::Diagnostic;
use pacquet_registry::Package;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
/// Mirror directory for the **abbreviated** metadata cache. Mirrors
/// upstream's
/// [`ABBREVIATED_META_DIR`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/core/constants/src/index.ts#L21).
pub const ABBREVIATED_META_DIR: &str = "v11/metadata";
/// Mirror directory for the **full** metadata cache. Mirrors
/// upstream's
/// [`FULL_META_DIR`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/core/constants/src/index.ts#L22).
pub const FULL_META_DIR: &str = "v11/metadata-full";
/// Cached headers persisted as the mirror's first line. The cached
/// metadata fetcher feeds these into `If-None-Match` /
/// `If-Modified-Since` on the next request. Both fields are
/// optional because some registries omit one or the other; the
/// fetcher tolerates a partial header set and only sends the headers
/// it has.
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MetaHeaders {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub etag: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub modified: Option<String>,
}
/// Error from [`save_meta`]. Surfaced to callers that care about
/// individual write failures (tests, in particular); production
/// callers ignore it and treat cache writes as fire-and-forget.
#[derive(Debug, Display, Error, Diagnostic)]
#[non_exhaustive]
pub enum SaveMetaError {
#[display("Failed to create mirror directory {dir:?}: {error}")]
#[diagnostic(code(pacquet_resolving_npm_resolver::mirror::create_dir))]
CreateDir {
dir: PathBuf,
#[error(source)]
error: io::Error,
},
#[display("Failed to write mirror temp file {temp:?}: {error}")]
#[diagnostic(code(pacquet_resolving_npm_resolver::mirror::write_temp))]
WriteTemp {
temp: PathBuf,
#[error(source)]
error: io::Error,
},
#[display("Failed to rename mirror temp {temp:?} → {target:?}: {error}")]
#[diagnostic(code(pacquet_resolving_npm_resolver::mirror::rename))]
Rename {
temp: PathBuf,
target: PathBuf,
#[error(source)]
error: io::Error,
},
}
/// On-disk path of the JSONL document where pacquet (and pnpm)
/// mirrors a package's registry metadata. Matches pnpm's
/// [`getPkgMirrorPath`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/pickPackage.ts#L566-L568).
pub fn get_pkg_mirror_path(
cache_dir: &Path,
meta_dir: &str,
registry: &str,
pkg_name: &str,
) -> Result<PathBuf, EncodeRegistryError> {
let registry_name = get_registry_name(registry)?;
let encoded_name = encode_pkg_name(pkg_name);
Ok(cache_dir.join(meta_dir).join(registry_name).join(format!("{encoded_name}.jsonl")))
}
/// Failure parsing a registry URL into a filesystem-safe slug.
/// Real-world registries always carry a host; this only triggers on
/// malformed config.
#[derive(Debug, Display, Error, Diagnostic)]
#[non_exhaustive]
pub enum EncodeRegistryError {
#[display("Failed to parse registry URL {url:?}: {error}")]
#[diagnostic(code(pacquet_resolving_npm_resolver::mirror::parse_registry))]
ParseUrl {
#[error(not(source))]
url: String,
error: String,
},
#[display("Registry URL {url:?} has no host")]
#[diagnostic(code(pacquet_resolving_npm_resolver::mirror::missing_host))]
MissingHost {
#[error(not(source))]
url: String,
},
}
/// `host[:port]` form of a registry URL with `:` rewritten to `+` so
/// the result is filesystem-safe. Mirrors the npm
/// [`encode-registry`](https://github.com/zkochan/packages/tree/main/encode-registry)
/// package pnpm consumes — `https://npm.example:8443/` becomes
/// `npm.example+8443`, `https://registry.npmjs.org/` becomes
/// `registry.npmjs.org`. Only an explicit port participates; the
/// implicit-default port stays out of the slug so a registry served
/// on its scheme default hashes consistently across configs.
pub fn get_registry_name(registry: &str) -> Result<String, EncodeRegistryError> {
let parsed = reqwest::Url::parse(registry).map_err(|error| EncodeRegistryError::ParseUrl {
url: registry.to_string(),
error: error.to_string(),
})?;
let host = parsed
.host_str()
.ok_or_else(|| EncodeRegistryError::MissingHost { url: registry.to_string() })?;
Ok(match parsed.port() {
Some(port) => format!("{host}+{port}"),
None => host.to_string(),
})
}
/// Filesystem-safe form of a package name. A mixed-case name (e.g.
/// `LRUCache`) gets a sha256 hex suffix so case-insensitive
/// filesystems (HFS+, NTFS by default) can't collide it with a
/// lowercase sibling. Mirrors pnpm's
/// [`encodePkgName`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/pickPackage.ts#L555-L560).
pub fn encode_pkg_name(pkg_name: &str) -> String {
let lowered = pkg_name.to_lowercase();
if pkg_name == lowered {
return pkg_name.to_string();
}
let digest = Sha256::digest(pkg_name.as_bytes());
format!("{pkg_name}_{digest:x}")
}
/// Serialize the cache record for disk. Two-line NDJSON: the first
/// line is the [`MetaHeaders`] JSON, the second is the registry
/// response body — verbatim when the caller supplies `raw_body`, else
/// re-serialized from the parsed `meta`. Mirrors pnpm's
/// [`prepareJsonForDisk`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/pickPackage.ts#L575-L580).
///
/// Passing `raw_body` is the fast path on a 200 response where the
/// caller already has the response bytes — round-tripping through
/// the parsed shape risks dropping registry-specific fields the
/// pacquet `Package` shape doesn't model (and would diff a later
/// install's cache against pnpm's byte-for-byte). The `meta` /
/// `raw_body` fallback is only for tests that build the record from
/// a typed value.
pub fn prepare_json_for_disk(
meta: &Package,
etag: Option<&str>,
raw_body: Option<&str>,
) -> Result<String, serde_json::Error> {
let modified = meta.modified.clone();
let headers = serde_json::to_string(&MetaHeaders { etag: etag.map(str::to_string), modified })?;
let body = match raw_body {
Some(text) => text.to_string(),
None => serde_json::to_string(meta)?,
};
Ok(format!("{headers}\n{body}"))
}
/// Read just the first line (headers JSON) of a mirror file. The
/// fetcher uses this to issue a conditional GET without paying the
/// full-body parse cost on a warm cache.
///
/// Returns `None` on any failure — missing file, unreadable header
/// line, parse error. The fetcher then proceeds without conditional
/// headers, identical to pnpm's
/// [`loadMetaHeaders`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/pickPackage.ts#L627-L644)
/// catch-and-return-null.
pub fn load_meta_headers(pkg_mirror: &Path) -> Option<MetaHeaders> {
let mut file = File::open(pkg_mirror).ok()?;
// Upstream uses a 1 KB buffer; the headers JSON is typically
// ~100 bytes. We match the upstream choice so a hand-edited
// mirror behaves the same on both stacks.
let mut buf = [0u8; 1024];
let bytes_read = file.read(&mut buf).ok()?;
if bytes_read == 0 {
return None;
}
let chunk = &buf[..bytes_read];
let newline = chunk.iter().position(|&b| b == b'\n')?;
let line = std::str::from_utf8(&chunk[..newline]).ok()?;
serde_json::from_str(line).ok()
}
/// Read the full mirror file and reconstruct a [`Package`] with its
/// etag back-filled from the headers line. Mirrors pnpm's
/// [`loadMeta`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/pickPackage.ts#L651-L663).
///
/// Returns `None` on missing file / malformed contents. Upstream
/// catches any error from `readFile` / `JSON.parse` and returns
/// `null`; we match that contract because the caller's response to
/// "couldn't read" is the same as "no cache".
pub fn load_meta(pkg_mirror: &Path) -> Option<Package> {
let data = fs::read_to_string(pkg_mirror).ok()?;
let newline = data.find('\n')?;
let headers: MetaHeaders = serde_json::from_str(&data[..newline]).ok()?;
let mut meta: Package = serde_json::from_str(&data[newline + 1..]).ok()?;
meta.etag = headers.etag;
Some(meta)
}
/// Atomic write: serialize to a sibling temp file, then `rename` it
/// over the target. Mirrors pnpm's
/// [`saveMeta`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/pickPackage.ts#L667-L676).
///
/// The rename is the only atomic step; an observer sees either the
/// old contents or the new ones, never a torn body line.
pub fn save_meta(pkg_mirror: &Path, json: &str) -> Result<(), SaveMetaError> {
let dir = pkg_mirror.parent().unwrap_or_else(|| Path::new("."));
fs::create_dir_all(dir)
.map_err(|error| SaveMetaError::CreateDir { dir: dir.to_path_buf(), error })?;
let temp = temp_sibling_path(pkg_mirror);
{
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&temp)
.map_err(|error| SaveMetaError::WriteTemp { temp: temp.clone(), error })?;
file.write_all(json.as_bytes())
.map_err(|error| SaveMetaError::WriteTemp { temp: temp.clone(), error })?;
}
fs::rename(&temp, pkg_mirror).map_err(|error| {
// Best-effort cleanup so a stale temp doesn't accumulate on
// a rename failure (e.g. cross-device move on an unusual mount).
let _ = fs::remove_file(&temp);
SaveMetaError::Rename { temp, target: pkg_mirror.to_path_buf(), error }
})?;
Ok(())
}
/// Per-process atomic counter used to disambiguate concurrent
/// `save_meta` calls writing to sibling temp paths under the same
/// mirror directory. Pid + counter is enough — pnpm's pathTemp uses
/// the same shape (`<pid>.<counter>` suffix) for the same reason.
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
fn temp_sibling_path(target: &Path) -> PathBuf {
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let mut name = match target.file_name().and_then(|n| n.to_str()) {
Some(name) => name.to_string(),
None => "tmp".to_string(),
};
name.push_str(&format!(".{pid}.{counter}.tmp"));
target.with_file_name(name)
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,193 @@
use std::path::PathBuf;
use pacquet_registry::Package;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use super::{
ABBREVIATED_META_DIR, FULL_META_DIR, MetaHeaders, encode_pkg_name, get_pkg_mirror_path,
get_registry_name, load_meta, load_meta_headers, prepare_json_for_disk, save_meta,
};
/// Lower-case names pass through unchanged. Matches upstream's
/// `pkgName !== pkgName.toLowerCase()` short-circuit.
#[test]
fn encode_pkg_name_passes_lowercase_through() {
assert_eq!(encode_pkg_name("lodash"), "lodash");
assert_eq!(encode_pkg_name("@scope/foo"), "@scope/foo");
}
/// Names containing any uppercase letter get a `_<sha256-hex>` suffix
/// so case-insensitive filesystems can't collide them with a lowercase
/// sibling. The prefix is the original name; the suffix is the sha256
/// hex of the original name.
#[test]
fn encode_pkg_name_hash_suffix_for_mixed_case() {
let got = encode_pkg_name("LRUCache");
assert!(got.starts_with("LRUCache_"), "got: {got}");
let suffix = got.trim_start_matches("LRUCache_");
assert_eq!(suffix.len(), 64, "sha256 hex is 64 chars");
assert!(suffix.chars().all(|ch| ch.is_ascii_hexdigit()));
}
/// `https://registry.npmjs.org/` → `registry.npmjs.org`. No port,
/// no escaping needed.
#[test]
fn get_registry_name_default_scheme() {
let got = get_registry_name("https://registry.npmjs.org/").expect("encode");
assert_eq!(got, "registry.npmjs.org");
}
/// Explicit non-default port encodes as `host+port`.
#[test]
fn get_registry_name_with_port() {
let got = get_registry_name("https://npm.example:8443/").expect("encode");
assert_eq!(got, "npm.example+8443");
}
/// Default scheme port is **not** included in the slug — the URL
/// parser strips it.
#[test]
fn get_registry_name_default_port_omitted() {
let got = get_registry_name("https://npm.example:443/").expect("encode");
assert_eq!(got, "npm.example");
}
/// Malformed registry URL surfaces as the dedicated [`super::EncodeRegistryError`]
/// rather than panicking — callers (notably the cached fetcher) downgrade
/// to a cache-less fetch instead of failing the install.
#[test]
fn get_registry_name_rejects_malformed_url() {
let err = get_registry_name("not a url").expect_err("malformed url must error");
assert!(matches!(err, super::EncodeRegistryError::ParseUrl { .. }), "got: {err:?}");
}
/// The mirror path is `<cache_dir>/<meta_dir>/<registry-slug>/<encoded-name>.jsonl`.
#[test]
fn get_pkg_mirror_path_composes_full_path() {
let dir = PathBuf::from("/cache");
let got = get_pkg_mirror_path(&dir, FULL_META_DIR, "https://registry.npmjs.org/", "lodash")
.expect("compose");
assert_eq!(got, PathBuf::from("/cache/v11/metadata-full/registry.npmjs.org/lodash.jsonl"));
}
/// Constants match upstream's `core/constants/src/index.ts` slugs.
/// Any drift would silently fork the cache layout from pnpm's.
#[test]
fn constants_match_upstream() {
assert_eq!(FULL_META_DIR, "v11/metadata-full");
assert_eq!(ABBREVIATED_META_DIR, "v11/metadata");
}
/// Build a minimal `Package` fixture for the round-trip tests.
fn fixture_package() -> Package {
let body = serde_json::json!({
"name": "acme",
"dist-tags": { "latest": "1.0.0" },
"modified": "2025-01-15T12:00:00.000Z",
"time": { "1.0.0": "2025-01-10T08:30:00.000Z" },
"versions": {
"1.0.0": {
"name": "acme",
"version": "1.0.0",
"dist": {
"integrity": "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"shasum": "0000000000000000000000000000000000000000",
"tarball": "https://registry/acme-1.0.0.tgz"
}
}
}
});
serde_json::from_value(body).expect("deserialize fixture Package")
}
/// `prepare_json_for_disk` produces a two-line NDJSON document. Line
/// one is `MetaHeaders`; line two is the raw body verbatim when
/// supplied (the fast path on a 200 response).
#[test]
fn prepare_json_for_disk_two_line_shape() {
let pkg = fixture_package();
let raw = r#"{"name":"acme"}"#;
let json = prepare_json_for_disk(&pkg, Some(r#""abc""#), Some(raw)).expect("serialize");
let mut lines = json.split('\n');
let header_line = lines.next().expect("header line present");
let body_line = lines.next().expect("body line present");
let headers: MetaHeaders = serde_json::from_str(header_line).expect("parse header");
assert_eq!(headers.etag.as_deref(), Some(r#""abc""#));
assert_eq!(headers.modified.as_deref(), Some("2025-01-15T12:00:00.000Z"));
assert_eq!(body_line, raw, "raw body line wins over re-serialization");
}
/// Save → load_meta_headers reads back only the first line (etag,
/// modified) without parsing the multi-megabyte body — fast path
/// for the conditional GET decision.
#[test]
fn load_meta_headers_round_trip() {
let dir = TempDir::new().expect("tmp dir");
let mirror = dir.path().join("nested").join("lodash.jsonl");
let pkg = fixture_package();
let json =
prepare_json_for_disk(&pkg, Some(r#"W/"abc""#), Some(r#"{"name":"acme"}"#)).expect("ser");
save_meta(&mirror, &json).expect("save");
let headers = load_meta_headers(&mirror).expect("read headers back");
assert_eq!(headers.etag.as_deref(), Some(r#"W/"abc""#));
assert_eq!(headers.modified.as_deref(), Some("2025-01-15T12:00:00.000Z"));
}
/// Save → load_meta reconstructs the full Package with the etag
/// back-filled. The cached fetcher uses this on a 304 response.
#[test]
fn load_meta_round_trip_backfills_etag() {
let dir = TempDir::new().expect("tmp dir");
let mirror = dir.path().join("acme.jsonl");
let pkg = fixture_package();
let json = prepare_json_for_disk(&pkg, Some(r#"W/"abc""#), None).expect("ser");
save_meta(&mirror, &json).expect("save");
let loaded = load_meta(&mirror).expect("read full back");
assert_eq!(loaded.name, "acme");
assert_eq!(loaded.etag.as_deref(), Some(r#"W/"abc""#));
assert_eq!(loaded.published_at("1.0.0"), Some("2025-01-10T08:30:00.000Z"));
}
/// Missing file → `None` from both readers. The fetcher's lookup
/// chain catches `None` as "cache cold" and proceeds with an
/// unconditional GET.
#[test]
fn load_helpers_return_none_on_missing_file() {
let dir = TempDir::new().expect("tmp dir");
let mirror = dir.path().join("does-not-exist.jsonl");
assert!(load_meta_headers(&mirror).is_none());
assert!(load_meta(&mirror).is_none());
}
/// Malformed mirror (no newline separator) → `None`. Mirrors
/// upstream's catch-and-return-null on JSON.parse errors.
#[test]
fn load_helpers_return_none_on_malformed_mirror() {
let dir = TempDir::new().expect("tmp dir");
let mirror = dir.path().join("bad.jsonl");
std::fs::write(&mirror, "no-newline-only-header").expect("write garbage");
assert!(load_meta_headers(&mirror).is_none());
assert!(load_meta(&mirror).is_none());
}
/// `save_meta` overwrites an existing mirror file atomically — the
/// observer sees either the old contents or the new ones, never a
/// torn body.
#[test]
fn save_meta_overwrites_existing_mirror() {
let dir = TempDir::new().expect("tmp dir");
let mirror = dir.path().join("acme.jsonl");
let pkg = fixture_package();
let first =
prepare_json_for_disk(&pkg, Some(r#"W/"old""#), Some(r#"{"name":"acme"}"#)).expect("ser");
save_meta(&mirror, &first).expect("first save");
let second =
prepare_json_for_disk(&pkg, Some(r#"W/"new""#), Some(r#"{"name":"acme"}"#)).expect("ser");
save_meta(&mirror, &second).expect("second save");
let headers = load_meta_headers(&mirror).expect("read headers");
assert_eq!(headers.etag.as_deref(), Some(r#"W/"new""#));
}

View File

@@ -0,0 +1,117 @@
//! Named-registry routing for the npm verifier.
//!
//! Lockfile entries carry a `tarball` URL recording where the
//! tarball was downloaded from. When that URL falls under a named
//! registry (`gh:` → `https://npm.pkg.github.com/`, custom user
//! mappings), the verifier must hit *that* registry's metadata
//! endpoint, not the scope-derived default — otherwise an entry
//! resolved via a named registry would 404 or, worse, hit a stale
//! mirror under the default registry.
//!
//! Ports the routing piece of upstream's
//! [`createNpmResolutionVerifier.ts`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/createNpmResolutionVerifier.ts#L118-L139)
//! plus the `BUILTIN_NAMED_REGISTRIES` constant from
//! [`parseBareSpecifier.ts`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/parseBareSpecifier.ts#L87-L89).
use std::collections::HashMap;
use reqwest::Url;
/// Built-in named-registry aliases the resolver recognizes
/// out of the box. Mirrors upstream's `BUILTIN_NAMED_REGISTRIES`.
pub const BUILTIN_NAMED_REGISTRIES: &[(&str, &str)] = &[("gh", "https://npm.pkg.github.com/")];
/// Build the sorted-by-length list of registry URL prefixes the
/// verifier matches a tarball URL against.
///
/// - Merges [`BUILTIN_NAMED_REGISTRIES`] with the user-supplied
/// `named_registries` (later wins on the same key — matches upstream's
/// spread semantics).
/// - Each prefix gets a trailing slash so a tarball URL under
/// `https://npm.pkg.github.com/@scope/pkg/-/pkg-1.0.0.tgz` matches
/// `https://npm.pkg.github.com/` but a sibling URL under
/// `https://npm.pkg.github.com-evil/...` does not.
/// - Output is sorted longest-first so two registries sharing a host
/// but differing by path (`https://npm/team-a/` vs
/// `https://npm/team-b/`) route to the deeper match.
pub fn build_named_registry_prefixes(named_registries: &HashMap<String, String>) -> Vec<String> {
let mut merged: HashMap<&str, String> = HashMap::new();
for (name, url) in BUILTIN_NAMED_REGISTRIES {
merged.insert(name, (*url).to_string());
}
for (name, url) in named_registries {
// `to_string()` on `&String` triggers a clippy lint elsewhere
// in the codebase; `clone` is the idiomatic equivalent.
merged.insert(name.as_str(), url.clone());
}
let mut prefixes: Vec<String> = merged
.into_values()
.filter_map(|url| Url::parse(&url).ok())
.map(|parsed| {
let mut pathname = parsed.path().to_string();
if !pathname.ends_with('/') {
pathname.push('/');
}
format!("{}{}", parsed.origin().ascii_serialization(), pathname)
})
.collect();
prefixes.sort_by_key(|prefix| std::cmp::Reverse(prefix.len()));
prefixes
}
/// Pick the registry URL the verifier should hit for a given
/// `(name, tarball)` pair.
///
/// Mirrors upstream's
/// [`pickRegistryForVersion`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/createNpmResolutionVerifier.ts#L591-L611):
///
/// 1. If the lockfile records a `tarball` URL **and** it starts with
/// one of the named-registry prefixes, return that prefix
/// (longest-match wins).
/// 2. Otherwise fall back to scope routing — `@scope/foo` consults
/// the `registries[@scope]` entry if present, else
/// `registries.default`. Ports upstream's
/// [`pickRegistryForPackage`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/config/pick-registry-for-package/src/index.ts#L3-L6).
pub fn pick_registry_for_version(
registries: &HashMap<String, String>,
named_registry_prefixes: &[String],
name: &str,
tarball_url: Option<&str>,
) -> String {
if let Some(url) = tarball_url
&& let Ok(parsed) = Url::parse(url)
{
// Normalize to the absolute URL string the prefix list is built from.
let normalized = parsed.as_str();
for prefix in named_registry_prefixes {
if normalized.starts_with(prefix) {
return prefix.clone();
}
}
}
pick_registry_for_package(registries, name)
}
/// Default-vs-scope routing for an npm package. Mirrors pnpm's
/// [`pickRegistryForPackage`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/config/pick-registry-for-package/src/index.ts#L3-L6).
/// `@scope/foo` consults `registries[@scope]`; everything else
/// (including unscoped) falls through to `registries["default"]`.
pub fn pick_registry_for_package(registries: &HashMap<String, String>, name: &str) -> String {
if let Some(scope) = scope_of(name)
&& let Some(url) = registries.get(scope)
{
return url.clone();
}
registries.get("default").cloned().unwrap_or_default()
}
fn scope_of(name: &str) -> Option<&str> {
if !name.starts_with('@') {
return None;
}
name.find('/').map(|sep| &name[..sep])
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,95 @@
use std::collections::HashMap;
use pretty_assertions::assert_eq;
use super::{build_named_registry_prefixes, pick_registry_for_version};
fn registries(entries: &[(&str, &str)]) -> HashMap<String, String> {
entries.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect()
}
/// The `gh:` builtin always lands in the prefix list, even when no
/// user-supplied named registries are configured.
#[test]
fn build_prefixes_includes_gh_builtin() {
let prefixes = build_named_registry_prefixes(&HashMap::new());
assert!(prefixes.iter().any(|prefix| prefix == "https://npm.pkg.github.com/"));
}
/// User-supplied named registries override the builtins on key
/// collision (later wins, matches upstream's spread semantics).
#[test]
fn build_prefixes_overrides_builtin_on_same_key() {
let mut named = HashMap::new();
named.insert("gh".to_string(), "https://internal/gh/".to_string());
let prefixes = build_named_registry_prefixes(&named);
assert!(prefixes.iter().any(|prefix| prefix == "https://internal/gh/"));
assert!(!prefixes.iter().any(|prefix| prefix == "https://npm.pkg.github.com/"));
}
/// Two registries sharing a host but different paths each get a
/// trailing-slash prefix; the longest match comes first so the
/// caller picks the deeper one.
#[test]
fn build_prefixes_sorts_longest_first() {
let mut named = HashMap::new();
named.insert("a".to_string(), "https://npm.example/team-a".to_string());
named.insert("b".to_string(), "https://npm.example/team-a/sub".to_string());
let prefixes = build_named_registry_prefixes(&named);
assert!(
prefixes[0].starts_with("https://npm.example/team-a/sub"),
"longest prefix first, got {prefixes:?}",
);
}
/// When the lockfile records a tarball URL falling under a named
/// registry, `pick_registry_for_version` returns the prefix even if
/// scope routing would have picked the default.
#[test]
fn tarball_under_named_registry_wins_over_scope_routing() {
let prefixes = build_named_registry_prefixes(&HashMap::new());
let regs = registries(&[("default", "https://registry.npmjs.org/")]);
let picked = pick_registry_for_version(
&regs,
&prefixes,
"@scope/foo",
Some("https://npm.pkg.github.com/@scope/foo/-/foo-1.0.0.tgz"),
);
assert_eq!(picked, "https://npm.pkg.github.com/");
}
/// Without a tarball URL, routing falls through to scope-vs-default.
/// A scoped name with a matching `registries[@scope]` entry wins; an
/// unscoped name hits `registries["default"]`.
#[test]
fn falls_back_to_scope_routing_without_tarball() {
let regs = registries(&[
("default", "https://registry.npmjs.org/"),
("@private", "https://internal/registry/"),
]);
let prefixes = build_named_registry_prefixes(&HashMap::new());
let scoped = pick_registry_for_version(&regs, &prefixes, "@private/foo", None);
assert_eq!(scoped, "https://internal/registry/");
let bare = pick_registry_for_version(&regs, &prefixes, "lodash", None);
assert_eq!(bare, "https://registry.npmjs.org/");
}
/// A tarball URL that's *almost* a prefix match — same host, but
/// without the trailing slash on the prefix — must not silently
/// route. The trailing-slash on every built prefix is what makes
/// `https://npm.pkg.github.com-evil/` reject correctly.
#[test]
fn tarball_under_unrelated_prefix_does_not_match() {
let prefixes = build_named_registry_prefixes(&HashMap::new());
let regs = registries(&[("default", "https://registry.npmjs.org/")]);
let picked = pick_registry_for_version(
&regs,
&prefixes,
"foo",
Some("https://npm.pkg.github.com-evil/foo-1.0.0.tgz"),
);
// Falls through to default since the prefix doesn't match.
assert_eq!(picked, "https://registry.npmjs.org/");
}

View File

@@ -0,0 +1,73 @@
//! Build the `<registry>/<encoded-pkg>` URL for a metadata fetch.
//!
//! Ports upstream's
//! [`toUri`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/fetch.ts).
//! Scoped names are routed as a single path segment by
//! percent-encoding the `/` (and other non-path-safe characters)
//! between the `@scope` prefix and the package's bare name —
//! otherwise `https://registry/@scope/pkg` would parse as two
//! segments and a registry that doesn't tolerate the un-encoded
//! form (or a CDN in front of the registry that re-canonicalizes
//! paths) would 404.
//!
//! Mirrors JS's `encodeURIComponent` for the characters npm package
//! names can carry. The grammar at
//! [the npm package-name spec](https://github.com/npm/validate-npm-package-name#naming-rules)
//! allows `a-z 0-9 _ . - ~` plus the leading `@scope/`; the leading
//! `@` is preserved (matching upstream's
//! `@${encodeURIComponent(pkgName.slice(1))}` shape), and every
//! other character that `encodeURIComponent` would touch is
//! percent-encoded.
/// Compose the metadata-fetch URL: `<registry-with-trailing-slash><encoded-name>`.
pub fn to_registry_url(registry: &str, pkg_name: &str) -> String {
let registry =
if registry.ends_with('/') { registry.to_string() } else { format!("{registry}/") };
let encoded = encode_pkg_name_path(pkg_name);
format!("{registry}{encoded}")
}
/// `encodeURIComponent` clone for the characters npm package names
/// can carry. For a scoped name the leading `@` is preserved and
/// the rest of the name is percent-encoded — matching upstream's
/// `@${encodeURIComponent(pkgName.slice(1))}`.
pub(crate) fn encode_pkg_name_path(pkg_name: &str) -> String {
let (prefix, rest) = if let Some(stripped) = pkg_name.strip_prefix('@') {
("@", stripped)
} else {
("", pkg_name)
};
let mut out = String::with_capacity(prefix.len() + rest.len());
out.push_str(prefix);
for byte in rest.bytes() {
if is_uri_component_unreserved(byte) {
out.push(byte as char);
} else {
out.push_str(&format!("%{byte:02X}"));
}
}
out
}
/// Matches JS's `encodeURIComponent` unreserved set:
/// `A-Z a-z 0-9 - _ . ! ~ * ' ( )`. Anything else gets percent-encoded.
fn is_uri_component_unreserved(byte: u8) -> bool {
matches!(
byte,
b'A'..=b'Z'
| b'a'..=b'z'
| b'0'..=b'9'
| b'-'
| b'_'
| b'.'
| b'!'
| b'~'
| b'*'
| b'\''
| b'('
| b')',
)
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,37 @@
use super::{encode_pkg_name_path, to_registry_url};
/// Unscoped names go through unchanged — every character npm allows
/// (`a-z 0-9 - _ . ~`) is in `encodeURIComponent`'s unreserved set.
#[test]
fn unscoped_name_passes_through() {
assert_eq!(encode_pkg_name_path("lodash"), "lodash");
assert_eq!(encode_pkg_name_path("acme-helper"), "acme-helper");
assert_eq!(encode_pkg_name_path("acme_helper"), "acme_helper");
assert_eq!(encode_pkg_name_path("acme.helper"), "acme.helper");
assert_eq!(encode_pkg_name_path("acme~legacy"), "acme~legacy");
}
/// Scoped name: the leading `@` is preserved, the `/` after the
/// scope is percent-encoded. Mirrors upstream's
/// `@${encodeURIComponent(pkgName.slice(1))}`.
#[test]
fn scoped_name_encodes_slash() {
assert_eq!(encode_pkg_name_path("@scope/pkg"), "@scope%2Fpkg");
assert_eq!(encode_pkg_name_path("@pnpm.e2e/hello-world"), "@pnpm.e2e%2Fhello-world");
}
/// `to_registry_url` joins the registry with a slash and appends
/// the encoded name. The registry is normalised to trailing slash
/// before joining so an unscoped vs trailing-slash registry config
/// produces the same URL.
#[test]
fn url_join_normalizes_trailing_slash() {
assert_eq!(
to_registry_url("https://registry.npmjs.org/", "@scope/pkg"),
"https://registry.npmjs.org/@scope%2Fpkg",
);
assert_eq!(
to_registry_url("https://registry.npmjs.org", "@scope/pkg"),
"https://registry.npmjs.org/@scope%2Fpkg",
);
}

View File

@@ -0,0 +1,264 @@
//! Trust-downgrade detection.
//!
//! Ports pnpm's
//! [`trustChecks.ts`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/trustChecks.ts).
//!
//! The check walks every published version of a package whose
//! publish time is strictly before the version under inspection.
//! For each it asks [`get_trust_evidence`] which "rank" of evidence
//! the version exposes:
//!
//! - `trustedPublisher` (rank 2) — `_npmUser.trustedPublisher` is
//! present.
//! - `provenance` (rank 1) — `dist.attestations.provenance` is
//! present (and no trusted-publisher record).
//! - `None` (rank 0 / no evidence).
//!
//! The strongest rank seen across the prior history is the
//! "baseline." If the current version's rank is lower than the
//! baseline, that's a trust *downgrade* — supply-chain incident
//! signal — and the verifier rejects the entry with
//! [`crate::TRUST_DOWNGRADE_VIOLATION_CODE`].
//!
//! Prereleases of the same major-minor-patch are excluded from the
//! history walk when the current version is *not* a prerelease:
//! upstream's `semver.prerelease(version, true)` decides this.
use chrono::{DateTime, Utc};
use derive_more::{Display, Error};
use miette::Diagnostic;
use node_semver::Version;
use pacquet_config::version_policy::{PackageVersionPolicy, PolicyMatch};
use pacquet_registry::{Package, PackageVersion};
/// Rank of supply-chain evidence on a single version.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum TrustEvidence {
/// `dist.attestations.provenance` is set.
Provenance,
/// `_npmUser.trustedPublisher` is set (overrides provenance —
/// it's a stronger signal that a known upstream pipeline
/// published the version).
TrustedPublisher,
}
/// Failure surfaced by [`fail_if_trust_downgraded`]. Each variant
/// maps to a `TRUST_*` diagnostic code mirroring upstream's
/// `PnpmError('TRUST_CHECK_FAIL', ...)` / `PnpmError('TRUST_DOWNGRADE', ...)`.
#[derive(Debug, Display, Error, Diagnostic)]
#[non_exhaustive]
pub enum TrustViolation {
/// Reserved for the metadata-shape failures upstream raises with
/// the `TRUST_CHECK_FAIL` code: missing `time` map, missing per-
/// version manifest, unparsable publish timestamp. Surfaced as
/// a "could not be checked" violation reason at the verifier
/// boundary.
#[display("trust check failed: {reason}")]
#[diagnostic(code(pacquet_resolving_npm_resolver::trust_check_failed))]
TrustCheckFailed {
#[error(not(source))]
reason: String,
},
/// Earlier versions had stronger trust evidence than the version
/// being verified — supply-chain incident signal. Mirrors
/// upstream's `TRUST_DOWNGRADE` code.
#[display("High-risk trust downgrade for \"{name}@{version}\" (possible package takeover)")]
#[diagnostic(
code(pacquet_resolving_npm_resolver::trust_downgrade),
help(
"Trust checks are based solely on publish date, not semver. A package cannot be installed if any earlier-published version had stronger trust evidence. Earlier versions had {past_pretty}, but this version has {current_pretty}. A trust downgrade may indicate a supply chain incident."
)
)]
TrustDowngrade {
#[error(not(source))]
name: String,
version: String,
past_pretty: &'static str,
current_pretty: &'static str,
},
}
/// Options bundle for [`fail_if_trust_downgraded`].
#[derive(Debug, Default, Clone)]
pub struct TrustCheckOptions<'a> {
/// Package-version policy that opts specific packages out of
/// the trust check entirely (`AnyVersion`) or for specific
/// versions (`ExactVersions`).
pub trust_policy_exclude: Option<&'a PackageVersionPolicy>,
/// Maximum age, in minutes, before which the check still
/// applies. A version older than this skips the check on the
/// theory that any downgrade would have surfaced by now.
/// `None` means "always check"; matches upstream's `undefined`
/// for the same field.
pub trust_policy_ignore_after_minutes: Option<u64>,
/// Override for "now" when the check evaluates
/// `trust_policy_ignore_after_minutes`. Defaults to wall-clock
/// `Utc::now`; tests pin it for determinism.
pub now: Option<DateTime<Utc>>,
}
/// Reject `version` of `meta` when its trust evidence is weaker
/// than the strongest evidence seen on any earlier-published
/// version. Port of upstream's
/// [`failIfTrustDowngraded`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/trustChecks.ts#L15-L80).
pub fn fail_if_trust_downgraded(
meta: &Package,
version: &str,
opts: &TrustCheckOptions<'_>,
) -> Result<(), TrustViolation> {
// Exclude policy short-circuit.
if let Some(exclude) = opts.trust_policy_exclude {
match exclude.matches(&meta.name) {
PolicyMatch::AnyVersion => return Ok(()),
PolicyMatch::ExactVersions(versions) => {
if versions.iter().any(|exact| exact == version) {
return Ok(());
}
}
PolicyMatch::No => {}
}
}
// Pull the version's publish time. Upstream's `assertMetaHasTime`
// throws if the whole `time` map is missing; we treat both
// "no time map" and "no entry for this version" as the same
// `TRUST_CHECK_FAIL` shape so the verifier surfaces a single
// "could not be checked" reason.
let published_at =
meta.published_at(version).ok_or_else(|| TrustViolation::TrustCheckFailed {
reason: format!(
"missing time for version {version} of {name} in metadata",
name = meta.name,
),
})?;
let version_date = DateTime::parse_from_rfc3339(published_at)
.map_err(|err| TrustViolation::TrustCheckFailed {
reason: format!("publish timestamp is not a valid date: {err}"),
})?
.with_timezone(&Utc);
// Ignore-after cutoff: a version old enough to be "settled"
// gets a pass.
if let Some(ignore_after_minutes) = opts.trust_policy_ignore_after_minutes {
let now = opts.now.unwrap_or_else(Utc::now);
let minutes_since_publish = (now - version_date).num_seconds().max(0) as u64 / 60;
if minutes_since_publish > ignore_after_minutes {
return Ok(());
}
}
let manifest = meta.versions.get(version).ok_or_else(|| TrustViolation::TrustCheckFailed {
reason: format!(
"missing version object for version {version} of {name} in metadata",
name = meta.name,
),
})?;
let exclude_prerelease = !is_prerelease(version);
let strongest_prior =
match detect_strongest_trust_evidence_before(meta, version_date, exclude_prerelease) {
Some(rank) => rank,
None => return Ok(()),
};
let current = get_trust_evidence(manifest);
let current_rank = current.map_or(0u8, trust_rank);
let prior_rank = trust_rank(strongest_prior);
if current_rank < prior_rank {
return Err(TrustViolation::TrustDowngrade {
name: meta.name.clone(),
version: version.to_string(),
past_pretty: pretty_print_trust_evidence(Some(strongest_prior)),
current_pretty: pretty_print_trust_evidence(current),
});
}
Ok(())
}
/// Map a [`TrustEvidence`] rank to upstream's numeric weight at
/// [`trustChecks.ts:10-13`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/trustChecks.ts#L10-L13).
/// Upstream uses `undefined` for "no evidence"; the Rust port uses
/// `Option<TrustEvidence>` so callers compare ranks via
/// `Option::map_or(0, trust_rank)`.
fn trust_rank(evidence: TrustEvidence) -> u8 {
match evidence {
TrustEvidence::TrustedPublisher => 2,
TrustEvidence::Provenance => 1,
}
}
fn pretty_print_trust_evidence(evidence: Option<TrustEvidence>) -> &'static str {
match evidence {
Some(TrustEvidence::TrustedPublisher) => "trusted publisher",
Some(TrustEvidence::Provenance) => "provenance attestation",
None => "no trust evidence",
}
}
/// Walk every version older than `before_date` and return the
/// strongest [`TrustEvidence`] seen. Prereleases are filtered out
/// when the current version is *not* itself a prerelease — matches
/// upstream's `semver.prerelease(version, true)` guard.
fn detect_strongest_trust_evidence_before(
meta: &Package,
before_date: DateTime<Utc>,
exclude_prerelease: bool,
) -> Option<TrustEvidence> {
let mut best: Option<TrustEvidence> = None;
for (version, manifest) in &meta.versions {
if exclude_prerelease && is_prerelease(version) {
continue;
}
// Skip individual versions that lack a publish timestamp
// rather than aborting the entire history walk: a single
// prior version with no `time` entry would otherwise mask
// every earlier version's evidence and allow a downgrade
// to slip through. Matches the upstream behavior of
// checking each timestamp in isolation.
let Some(ts) = meta.published_at(version) else {
continue;
};
let parsed = match DateTime::parse_from_rfc3339(ts) {
Ok(parsed) => parsed.with_timezone(&Utc),
Err(_) => continue,
};
if parsed >= before_date {
continue;
}
let Some(evidence) = get_trust_evidence(manifest) else {
continue;
};
if matches!(evidence, TrustEvidence::TrustedPublisher) {
return Some(TrustEvidence::TrustedPublisher);
}
// First provenance hit sticks; a later trusted-publisher
// hit would have returned above.
if best.is_none() {
best = Some(TrustEvidence::Provenance);
}
}
best
}
/// `_npmUser.trustedPublisher` outranks `dist.attestations.provenance`;
/// absence of both yields `None`. Mirrors pnpm's
/// [`getTrustEvidence`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/trustChecks.ts#L119-L127).
pub fn get_trust_evidence(version: &PackageVersion) -> Option<TrustEvidence> {
if version.npm_user.as_ref().and_then(|user| user.trusted_publisher.as_ref()).is_some() {
return Some(TrustEvidence::TrustedPublisher);
}
if version.dist.attestations.as_ref().and_then(|att| att.provenance.as_ref()).is_some() {
return Some(TrustEvidence::Provenance);
}
None
}
fn is_prerelease(version: &str) -> bool {
Version::parse(version).map(|parsed| !parsed.pre_release.is_empty()).unwrap_or(false)
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,320 @@
use chrono::{DateTime, Utc};
use pacquet_config::version_policy::create_package_version_policy;
use pacquet_registry::Package;
use super::{TrustCheckOptions, TrustViolation, fail_if_trust_downgraded};
#[derive(Clone, Copy)]
enum Evidence {
None,
Provenance,
TrustedPublisher,
}
/// Build a JSON object for a single version with the trust-evidence
/// shape the verifier reads (`_npmUser.trustedPublisher` or
/// `dist.attestations.provenance`).
fn version_json(name: &str, version: &str, evidence: Evidence) -> serde_json::Value {
let mut dist = serde_json::json!({
"integrity": "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"shasum": "0000000000000000000000000000000000000000",
"tarball": format!("https://registry/{name}-{version}.tgz")
});
if matches!(evidence, Evidence::Provenance) {
dist["attestations"] = serde_json::json!({
"provenance": { "predicateType": "https://slsa.dev/provenance/v1" }
});
}
let mut version_obj = serde_json::json!({
"name": name,
"version": version,
"dist": dist,
});
if matches!(evidence, Evidence::TrustedPublisher) {
version_obj["_npmUser"] = serde_json::json!({
"trustedPublisher": { "id": "github", "oidcConfigId": "release" }
});
}
version_obj
}
fn make_package(name: &str, versions: &[(&str, &str, Evidence)]) -> Package {
let versions_json: serde_json::Map<String, serde_json::Value> =
versions.iter().map(|(v, _, ev)| ((*v).to_string(), version_json(name, v, *ev))).collect();
let time_json: serde_json::Map<String, serde_json::Value> = versions
.iter()
.map(|(v, t, _)| ((*v).to_string(), serde_json::Value::String((*t).to_string())))
.collect();
let body = serde_json::json!({
"name": name,
"dist-tags": {},
"time": time_json,
"versions": versions_json,
});
serde_json::from_value(body).expect("deserialize fixture Package")
}
fn now_at(date: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(date).expect("parse RFC3339").with_timezone(&Utc)
}
/// First-ever version of a package: no earlier history, so no
/// baseline to downgrade from → always passes.
#[test]
fn first_version_passes_with_no_history() {
let meta = make_package("acme", &[("1.0.0", "2025-01-10T00:00:00.000Z", Evidence::None)]);
fail_if_trust_downgraded(&meta, "1.0.0", &TrustCheckOptions::default())
.expect("no prior history → no downgrade possible");
}
/// Earlier version had `trustedPublisher`, current version has
/// only `provenance` → DOWNGRADE.
#[test]
fn trusted_publisher_to_provenance_downgrade_fails() {
let meta = make_package(
"acme",
&[
("1.0.0", "2025-01-01T00:00:00.000Z", Evidence::TrustedPublisher),
("1.1.0", "2025-02-01T00:00:00.000Z", Evidence::Provenance),
],
);
let err = fail_if_trust_downgraded(&meta, "1.1.0", &TrustCheckOptions::default())
.expect_err("trusted-publisher → provenance is a downgrade");
assert!(matches!(err, TrustViolation::TrustDowngrade { .. }), "got {err:?}");
}
/// Earlier version had `provenance`, current version has no
/// evidence at all → DOWNGRADE.
#[test]
fn provenance_to_unsigned_downgrade_fails() {
let meta = make_package(
"acme",
&[
("1.0.0", "2025-01-01T00:00:00.000Z", Evidence::Provenance),
("1.1.0", "2025-02-01T00:00:00.000Z", Evidence::None),
],
);
let err = fail_if_trust_downgraded(&meta, "1.1.0", &TrustCheckOptions::default())
.expect_err("provenance → no evidence is a downgrade");
assert!(matches!(err, TrustViolation::TrustDowngrade { .. }), "got {err:?}");
}
/// Equal-rank evidence (provenance → provenance) is not a
/// downgrade.
#[test]
fn equal_rank_passes() {
let meta = make_package(
"acme",
&[
("1.0.0", "2025-01-01T00:00:00.000Z", Evidence::Provenance),
("1.1.0", "2025-02-01T00:00:00.000Z", Evidence::Provenance),
],
);
fail_if_trust_downgraded(&meta, "1.1.0", &TrustCheckOptions::default())
.expect("equal rank should pass");
}
/// Upgrade (provenance → trusted-publisher) is not a downgrade.
#[test]
fn rank_upgrade_passes() {
let meta = make_package(
"acme",
&[
("1.0.0", "2025-01-01T00:00:00.000Z", Evidence::Provenance),
("1.1.0", "2025-02-01T00:00:00.000Z", Evidence::TrustedPublisher),
],
);
fail_if_trust_downgraded(&meta, "1.1.0", &TrustCheckOptions::default())
.expect("rank upgrade should pass");
}
/// Only later-published versions had stronger evidence: that
/// can't downgrade an earlier version since the history walk
/// excludes anything published on/after the target's date.
#[test]
fn later_publish_does_not_downgrade_earlier_version() {
let meta = make_package(
"acme",
&[
("1.0.0", "2025-01-01T00:00:00.000Z", Evidence::None),
("1.1.0", "2025-02-01T00:00:00.000Z", Evidence::TrustedPublisher),
],
);
fail_if_trust_downgraded(&meta, "1.0.0", &TrustCheckOptions::default())
.expect("history walk excludes versions newer than the target");
}
/// Prerelease history is excluded when the current version is
/// stable. Mirrors upstream's `semver.prerelease(version, true)`
/// guard: a stable `1.1.0` doesn't get downgraded by a
/// `1.1.0-alpha.1` that happened to ship with provenance.
#[test]
fn stable_version_ignores_prerelease_history() {
let meta = make_package(
"acme",
&[
("1.0.0-alpha.1", "2025-01-01T00:00:00.000Z", Evidence::TrustedPublisher),
("1.0.0", "2025-02-01T00:00:00.000Z", Evidence::None),
],
);
fail_if_trust_downgraded(&meta, "1.0.0", &TrustCheckOptions::default())
.expect("prerelease history is excluded when target is stable");
}
/// Prerelease target inspects prerelease history (the same
/// upstream guard, inverted: the target itself is a prerelease so
/// the exclusion doesn't apply).
#[test]
fn prerelease_target_compares_against_prerelease_history() {
let meta = make_package(
"acme",
&[
("1.0.0-alpha.1", "2025-01-01T00:00:00.000Z", Evidence::TrustedPublisher),
("1.0.0-alpha.2", "2025-02-01T00:00:00.000Z", Evidence::None),
],
);
let err = fail_if_trust_downgraded(&meta, "1.0.0-alpha.2", &TrustCheckOptions::default())
.expect_err("prerelease target sees prerelease history");
assert!(matches!(err, TrustViolation::TrustDowngrade { .. }), "got {err:?}");
}
/// `trust_policy_ignore_after_minutes` cuts the check off once a
/// version is old enough. With `now` set 100 days after publish
/// and the ignore-cutoff at 7 days (10_080 minutes), the check
/// skips even though prior history would have flagged a downgrade.
#[test]
fn ignore_after_skips_check_for_settled_versions() {
let meta = make_package(
"acme",
&[
("1.0.0", "2025-01-01T00:00:00.000Z", Evidence::TrustedPublisher),
("1.1.0", "2025-01-15T00:00:00.000Z", Evidence::None),
],
);
let opts = TrustCheckOptions {
trust_policy_ignore_after_minutes: Some(7 * 24 * 60),
now: Some(now_at("2025-05-01T00:00:00.000Z")),
..Default::default()
};
fail_if_trust_downgraded(&meta, "1.1.0", &opts).expect("settled-enough version skips check");
}
/// Recent versions still get checked when `ignore_after` is set
/// but the target is younger than the cutoff. Same fixture as
/// above with `now` close to the publish time.
#[test]
fn ignore_after_still_checks_fresh_versions() {
let meta = make_package(
"acme",
&[
("1.0.0", "2025-01-01T00:00:00.000Z", Evidence::TrustedPublisher),
("1.1.0", "2025-01-15T00:00:00.000Z", Evidence::None),
],
);
let opts = TrustCheckOptions {
trust_policy_ignore_after_minutes: Some(7 * 24 * 60),
now: Some(now_at("2025-01-16T00:00:00.000Z")),
..Default::default()
};
let err = fail_if_trust_downgraded(&meta, "1.1.0", &opts)
.expect_err("fresh version still gets checked");
assert!(matches!(err, TrustViolation::TrustDowngrade { .. }), "got {err:?}");
}
/// `trust_policy_exclude` opting a whole package out of the check
/// short-circuits before the history walk.
#[test]
fn exclude_any_version_short_circuits_check() {
let meta = make_package(
"acme",
&[
("1.0.0", "2025-01-01T00:00:00.000Z", Evidence::TrustedPublisher),
("1.1.0", "2025-02-01T00:00:00.000Z", Evidence::None),
],
);
let exclude = create_package_version_policy(["acme"]).unwrap();
let opts = TrustCheckOptions { trust_policy_exclude: Some(&exclude), ..Default::default() };
fail_if_trust_downgraded(&meta, "1.1.0", &opts).expect("acme excluded → check short-circuits");
}
/// `trust_policy_exclude` opting one specific version out of the
/// check covers just that version.
#[test]
fn exclude_exact_version_short_circuits_check() {
let meta = make_package(
"acme",
&[
("1.0.0", "2025-01-01T00:00:00.000Z", Evidence::TrustedPublisher),
("1.1.0", "2025-02-01T00:00:00.000Z", Evidence::None),
],
);
let exclude = create_package_version_policy(["acme@1.1.0"]).unwrap();
let opts = TrustCheckOptions { trust_policy_exclude: Some(&exclude), ..Default::default() };
fail_if_trust_downgraded(&meta, "1.1.0", &opts)
.expect("acme@1.1.0 excluded → check short-circuits");
// A different version of the same package is still checked.
let err = fail_if_trust_downgraded(&meta, "1.0.0", &opts).err();
// 1.0.0 has trusted-publisher itself, so the check still passes
// even though it's not excluded — the exclude policy only
// matters when there'd otherwise be a downgrade. This test pins
// that the exclude is targeted, not blanket.
assert!(err.is_none(), "1.0.0 has its own trusted-publisher → passes");
}
/// Missing `time` entry for the target version surfaces as
/// `TrustCheckFailed`. Mirrors upstream's
/// `Missing time for version X of Y` PnpmError.
#[test]
fn missing_time_surfaces_trust_check_failed() {
let mut meta = make_package("acme", &[("1.0.0", "2025-01-10T00:00:00.000Z", Evidence::None)]);
// Drop the version's time entry.
if let Some(time) = meta.time.as_mut() {
time.clear();
}
let err = fail_if_trust_downgraded(&meta, "1.0.0", &TrustCheckOptions::default())
.expect_err("missing time should fail with TrustCheckFailed");
assert!(matches!(err, TrustViolation::TrustCheckFailed { .. }), "got {err:?}");
}
/// A timestamp string that doesn't parse as RFC3339 also surfaces
/// as `TrustCheckFailed`.
#[test]
fn unparsable_timestamp_surfaces_trust_check_failed() {
let mut meta = make_package("acme", &[("1.0.0", "2025-01-10T00:00:00.000Z", Evidence::None)]);
if let Some(time) = meta.time.as_mut() {
time.insert("1.0.0".to_string(), serde_json::Value::String("not-a-date".to_string()));
}
let err = fail_if_trust_downgraded(&meta, "1.0.0", &TrustCheckOptions::default())
.expect_err("unparsable timestamp should fail");
assert!(matches!(err, TrustViolation::TrustCheckFailed { .. }), "got {err:?}");
}
/// Regression: a prior version with no entry in the `time` map must
/// not abort the history walk. The scan needs to consult every
/// other prior version's evidence so a stronger-evidence ancestor
/// (here `1.0.0` with `TrustedPublisher`) still gates downgrades to
/// a weaker successor (`1.1.0` with no evidence). Without the
/// per-version `continue`, a single missing-time entry would mask
/// the entire ancestry and let the downgrade slip through.
#[test]
fn prior_version_missing_time_does_not_mask_trust_history() {
let mut meta = make_package(
"acme",
&[
("1.0.0", "2025-01-01T00:00:00.000Z", Evidence::TrustedPublisher),
("1.0.1", "2025-01-15T00:00:00.000Z", Evidence::Provenance),
("1.1.0", "2025-02-01T00:00:00.000Z", Evidence::None),
],
);
// Drop the middle version's `time` entry so it has a manifest
// but no publish timestamp — the exact shape that previously
// tripped the early-return.
if let Some(time) = meta.time.as_mut() {
time.remove("1.0.1");
}
let err = fail_if_trust_downgraded(&meta, "1.1.0", &TrustCheckOptions::default())
.expect_err("missing-time on a prior version must not mask the 1.0.0 baseline");
assert!(matches!(err, TrustViolation::TrustDowngrade { .. }), "got {err:?}");
}

View File

@@ -0,0 +1,11 @@
//! Verbatim port of pnpm's
//! [`violationCodes.ts`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/npm-resolver/src/violationCodes.ts).
//!
//! These constants are the verifier's piece of the public contract:
//! the install command filters violations by `code` to decide which
//! handler runs (auto-collect, strict-mode prompt, abort), and pnpm's
//! diagnostic catalog (<https://pnpm.io/errors>) routes the same
//! strings. Keep the values byte-identical to upstream.
pub const MINIMUM_RELEASE_AGE_VIOLATION_CODE: &str = "MINIMUM_RELEASE_AGE_VIOLATION";
pub const TRUST_DOWNGRADE_VIOLATION_CODE: &str = "TRUST_DOWNGRADE";

View File

@@ -0,0 +1,23 @@
[package]
name = "pacquet-resolving-resolver-base"
version = "0.0.1"
publish = false
authors.workspace = true
description.workspace = true
edition.workspace = true
homepage.workspace = true
keywords.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
pacquet-lockfile = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
ssri = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt"] }
[lints]
workspace = true

View File

@@ -0,0 +1,130 @@
//! Pacquet port of the verifier-side bits of pnpm's
//! [`@pnpm/resolving.resolver-base`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/resolver-base/src/index.ts).
//!
//! The trait + violation type live here (not in the lockfile-verification
//! runner) because every resolver-side verifier — today the npm one,
//! tomorrow custom ones — needs to depend on the trait without pulling in
//! the runner. Mirrors upstream's package boundary: `npm-resolver` depends
//! on `resolver-base`, the runner imports the trait from `resolver-base`,
//! and the runner crate is otherwise decoupled from any specific resolver.
//!
//! Scope is intentionally minimal: only the symbols pacquet's verifier
//! and runner actually consume today. The full upstream `resolver-base`
//! surface (resolve options, branded `PkgResolutionId`, `WorkspacePackages`,
//! etc.) is not in pacquet's scope until a real resolver lands.
use std::{future::Future, pin::Pin};
use pacquet_lockfile::{LockfileResolution, PkgName};
/// One verifier's decision about a single `(name, version, resolution)`
/// entry. Mirrors pnpm's
/// [`ResolutionVerification`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/resolver-base/src/index.ts#L91-L93)
/// discriminated union (`{ ok: true } | { ok: false, code, reason }`).
///
/// Verifiers short-circuit on resolutions outside their protocol by
/// returning [`ResolutionVerification::Ok`]; the runner fans out across
/// every active verifier per candidate and stops at the first
/// [`ResolutionVerification::Err`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolutionVerification {
Ok,
Err {
/// Verifier-defined error code (e.g.
/// `MINIMUM_RELEASE_AGE_VIOLATION`, `TRUST_DOWNGRADE`). The
/// install command filters violations by code to decide
/// downstream UX, so the value is part of the public contract
/// — verifier crates pin theirs as `&'static str` consts.
code: &'static str,
/// Human-readable explanation rendered in the install error
/// breakdown. Allowed to allocate.
reason: String,
},
}
/// A [`ResolutionVerifier`]'s rejection materialized for one
/// `(name, version, resolution)` entry. Mirrors pnpm's
/// [`ResolutionPolicyViolation`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/resolver-base/src/index.ts#L144-L150).
///
/// The runner aggregates violations across every active verifier on the
/// loaded lockfile, sorts them by `name@version` for stable output, and
/// caps the rendered breakdown.
///
/// `Eq` is not derived because [`LockfileResolution`] contains
/// `ssri::Integrity`, which is only `PartialEq`.
#[derive(Debug, Clone, PartialEq)]
pub struct ResolutionPolicyViolation {
pub name: PkgName,
pub version: String,
pub resolution: LockfileResolution,
pub code: &'static str,
pub reason: String,
}
/// `ctx` argument bundle for [`ResolutionVerifier::verify`]. Mirrors
/// upstream's inline `{ name, version }` object on the verify call.
#[derive(Debug, Clone, Copy)]
pub struct VerifyCtx<'a> {
pub name: &'a PkgName,
pub version: &'a str,
}
/// Boxed-future return type for [`ResolutionVerifier::verify`].
///
/// Async-fn-in-trait is stable since Rust 1.75, but `dyn Trait` over a
/// trait that returns `impl Future` is not yet ergonomic without
/// `#[async_trait]` or a manual boxed-future. The runner stores
/// verifiers as `&dyn ResolutionVerifier` so it can fan out across a
/// heterogeneous list (the npm verifier today, future custom
/// verifiers tomorrow); the boxed-future return is the minimal cost
/// for keeping that flexibility while staying off `async-trait`.
pub type VerifyFuture<'a> = Pin<Box<dyn Future<Output = ResolutionVerification> + Send + 'a>>;
/// Optional companion to a resolver factory.
///
/// `verify` inspects the `resolution` shape to decide whether the entry
/// is within its protocol; for entries outside its protocol it should
/// return [`ResolutionVerification::Ok`]. The install side fans out
/// across the verifier list rather than asking a combinator to dispatch.
///
/// `policy` and `can_trust_past_check` describe the verifier's cache
/// contract. Policies from every active verifier are merged into a
/// single shared bag stored alongside the lockfile hash; the
/// install-side verification cache reads them to decide whether a
/// previous run on the same lockfile is still trustworthy under
/// today's policy without re-issuing the registry round-trips that
/// `verify` would. Verifiers that check the same logical policy (e.g.
/// `minimumReleaseAge` across registries) name it the same and share
/// the cache slot.
///
/// Mirrors pnpm's
/// [`ResolutionVerifier`](https://github.com/pnpm/pnpm/blob/2a9bd897bf/resolving/resolver-base/src/index.ts#L112-L130).
pub trait ResolutionVerifier: Send + Sync {
fn verify<'a>(
&'a self,
resolution: &'a LockfileResolution,
ctx: VerifyCtx<'a>,
) -> VerifyFuture<'a>;
/// Snapshot of the policy fields this verifier enforces. Merged
/// with every other active verifier's `policy` into the cache
/// record. A field shared across verifiers (same key) should
/// carry the same value; if it doesn't, the last verifier in the
/// list wins.
fn policy(&self) -> &serde_json::Map<String, serde_json::Value>;
/// Returns `true` when the previously cached policy (the merged
/// snapshot from the last successful run) can be trusted to still
/// satisfy what this verifier currently demands. Reads whichever
/// fields the verifier owns; missing or non-conforming values
/// (e.g. an older record shape) should return `false`. A loosened
/// policy can trust a stricter cached run; a tightened policy
/// cannot.
fn can_trust_past_check(
&self,
cached_policy: &serde_json::Map<String, serde_json::Value>,
) -> bool;
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,102 @@
use pacquet_lockfile::{LockfileResolution, PkgName, RegistryResolution};
use ssri::Integrity;
use crate::{ResolutionPolicyViolation, ResolutionVerification, ResolutionVerifier, VerifyCtx};
fn fake_resolution() -> LockfileResolution {
LockfileResolution::Registry(RegistryResolution {
integrity: "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
.parse::<Integrity>()
.expect("parse fake integrity"),
})
}
/// [`ResolutionVerification::Err`] carries the verifier-supplied code
/// and reason verbatim; the runner pulls these out to compose the
/// install-level error breakdown. `code` is a `&'static str` so the
/// per-policy constants can flow through without allocation.
#[test]
fn resolution_verification_err_round_trip() {
let v = ResolutionVerification::Err {
code: "MINIMUM_RELEASE_AGE_VIOLATION",
reason: "was published yesterday".to_string(),
};
match v {
ResolutionVerification::Err { code, reason } => {
assert_eq!(code, "MINIMUM_RELEASE_AGE_VIOLATION");
assert_eq!(reason, "was published yesterday");
}
ResolutionVerification::Ok => panic!("expected Err"),
}
}
/// [`ResolutionPolicyViolation`] is the data shape the runner
/// aggregates and sorts by `name@version`. Constructing one with a
/// real [`PkgName`] proves the type composes with `pacquet_lockfile`.
#[test]
fn resolution_policy_violation_carries_pkg_name_and_resolution() {
let violation = ResolutionPolicyViolation {
name: "lodash".parse().expect("parse PkgName"),
version: "4.17.21".to_string(),
resolution: fake_resolution(),
code: "MINIMUM_RELEASE_AGE_VIOLATION",
reason: "was published yesterday".to_string(),
};
assert_eq!(violation.name.to_string(), "lodash");
assert_eq!(violation.version, "4.17.21");
assert_eq!(violation.code, "MINIMUM_RELEASE_AGE_VIOLATION");
}
/// Stand-in verifier that demonstrates the trait is implementable
/// with the manual boxed-future return type. Returns `Err` to prove
/// the err arm round-trips through the boxed future.
struct StubVerifier {
policy: serde_json::Map<String, serde_json::Value>,
}
impl ResolutionVerifier for StubVerifier {
fn verify<'a>(
&'a self,
_resolution: &'a LockfileResolution,
_ctx: VerifyCtx<'a>,
) -> crate::VerifyFuture<'a> {
Box::pin(async move {
ResolutionVerification::Err { code: "STUB", reason: "stub fails by design".to_string() }
})
}
fn policy(&self) -> &serde_json::Map<String, serde_json::Value> {
&self.policy
}
fn can_trust_past_check(
&self,
cached_policy: &serde_json::Map<String, serde_json::Value>,
) -> bool {
cached_policy.get("stub").and_then(|value| value.as_bool()).unwrap_or(false)
}
}
/// The trait is implementable and dispatch-able from a `&dyn` slot,
/// which is the shape the runner stores its verifier list in.
#[tokio::test(flavor = "current_thread")]
async fn resolution_verifier_dispatches_through_dyn() {
let mut policy = serde_json::Map::new();
policy.insert("stub".to_string(), serde_json::Value::Bool(true));
let verifier: Box<dyn ResolutionVerifier> = Box::new(StubVerifier { policy });
let name: PkgName = "lodash".parse().unwrap();
let resolution = fake_resolution();
let outcome = verifier.verify(&resolution, VerifyCtx { name: &name, version: "4.17.21" }).await;
assert_eq!(
outcome,
ResolutionVerification::Err { code: "STUB", reason: "stub fails by design".to_string() },
);
let mut cached = serde_json::Map::new();
cached.insert("stub".to_string(), serde_json::Value::Bool(true));
assert!(verifier.can_trust_past_check(&cached));
cached.insert("stub".to_string(), serde_json::Value::Bool(false));
assert!(!verifier.can_trust_past_check(&cached));
}

View File

@@ -617,3 +617,123 @@ Rust port notes:
- Frozen install should not resolve git specs, but it must materialize git-hosted package entries from the lockfile.
- Port store/fetcher handling before resolver tests if Stage 1 stays strictly lockfile-driven.
## Lockfile Verification Gate (`minimumReleaseAge`, `trustPolicy`)
The gate ported in pacquet/#11722 re-applies the resolver's policy
checks to every lockfile entry before resolution or fetch, so a
lockfile resolved elsewhere can't reach the install path under a
weaker policy. Spans three new crates
(`pacquet-resolving-resolver-base`, `pacquet-resolving-npm-resolver`,
`pacquet-lockfile-verification`) plus install-side wiring in
`pacquet-package-manager`. Reference: upstream `2a9bd897bf`.
### Trust check (`failIfTrustDowngraded`)
- [x] `TypeScript repo: resolving/npm-resolver/test/trustChecks.test.ts:8` `returns "trustedPublisher" when _npmUser.trustedPublisher exists``pacquet-resolving-npm-resolver`: implicit in `trust_checks::tests::trusted_publisher_to_provenance_downgrade_fails` (covers the rank assignment).
- [x] `TypeScript repo: resolving/npm-resolver/test/trustChecks.test.ts:28` `returns "trustedPublisher" even when attestations.provenance exists` — same coverage as above (the rank function prefers `trustedPublisher`).
- [x] `TypeScript repo: resolving/npm-resolver/test/trustChecks.test.ts:53` `returns true when provenance exists``trust_checks::tests::provenance_to_unsigned_downgrade_fails` exercises the same rank path.
- [x] `TypeScript repo: resolving/npm-resolver/test/trustChecks.test.ts:70` `returns undefined when provenance and attestations are undefined` — covered by `trust_checks::tests::first_version_passes_with_no_history`.
- [x] `TypeScript repo: resolving/npm-resolver/test/trustChecks.test.ts:100` `succeeds when no versions have attestation``trust_checks::tests::first_version_passes_with_no_history`.
- [x] `TypeScript repo: resolving/npm-resolver/test/trustChecks.test.ts:132` `succeeds for version published before first attested version``trust_checks::tests::later_publish_does_not_downgrade_earlier_version`.
- [x] `TypeScript repo: resolving/npm-resolver/test/trustChecks.test.ts:169` `throws an error when downgrading from provenance to none``trust_checks::tests::provenance_to_unsigned_downgrade_fails`.
- [x] `TypeScript repo: resolving/npm-resolver/test/trustChecks.test.ts:215` `does not throw an error when only prerelease versions had provenance``trust_checks::tests::stable_version_ignores_prerelease_history`.
- [x] `TypeScript repo: resolving/npm-resolver/test/trustChecks.test.ts:261` `throws an error when downgrading from trustedPublisher to provenance``trust_checks::tests::trusted_publisher_to_provenance_downgrade_fails`.
- [x] `TypeScript repo: resolving/npm-resolver/test/trustChecks.test.ts:315` `throws an error when downgrading from trustedPublisher to none` — covered by the same `trusted_publisher_to_provenance_downgrade_fails` plus `provenance_to_unsigned_downgrade_fails` rank logic.
- [x] `TypeScript repo: resolving/npm-resolver/test/trustChecks.test.ts:364` `succeeds when maintaining same trust level``trust_checks::tests::equal_rank_passes`.
- [x] `TypeScript repo: resolving/npm-resolver/test/trustChecks.test.ts:421` `throws an error when version time is missing``trust_checks::tests::missing_time_surfaces_trust_check_failed`.
- [x] `TypeScript repo: resolving/npm-resolver/test/trustChecks.test.ts:459` `allows downgrade when package@version is in exclude list``trust_checks::tests::exclude_exact_version_short_circuits_check`.
- [x] `TypeScript repo: resolving/npm-resolver/test/trustChecks.test.ts:501` `allows downgrade when package name is in exclude list (all versions)``trust_checks::tests::exclude_any_version_short_circuits_check`.
- [ ] `TypeScript repo: resolving/npm-resolver/test/trustChecks.test.ts:542` `does not fail with ERR_PNPM_MISSING_TIME when package@version is excluded and time field is missing` — exclude-then-missing-time interplay isn't pinned yet; an upstream-style test still needs to land in `trust_checks::tests`.
- [ ] `TypeScript repo: resolving/npm-resolver/test/trustChecks.test.ts:564` `does not fail with ERR_PNPM_MISSING_TIME when package name is excluded and time field is missing` — same as above (name-pattern variant).
### Attestation publish-time fetcher
- [x] `TypeScript repo: resolving/npm-resolver/test/fetchAttestationPublishedAt.test.ts:35` `returns an ISO timestamp built from tlogEntries[].integratedTime``fetch_attestation_published_at::tests::finds_publish_time_from_single_bundle`.
- [x] `TypeScript repo: resolving/npm-resolver/test/fetchAttestationPublishedAt.test.ts:75` `returns undefined when the registry has no attestations for the package (404)``fetch_attestation_published_at::tests::returns_none_on_404`.
- [x] `TypeScript repo: resolving/npm-resolver/test/fetchAttestationPublishedAt.test.ts:86` `returns undefined on 5xx — caller falls back to full metadata``fetch_attestation_published_at::tests::returns_none_on_5xx`.
- [x] `TypeScript repo: resolving/npm-resolver/test/fetchAttestationPublishedAt.test.ts:110` `returns undefined when the body is malformed JSON``fetch_attestation_published_at::tests::returns_none_on_malformed_body`.
- [x] `TypeScript repo: resolving/npm-resolver/test/fetchAttestationPublishedAt.test.ts:153` `picks the earliest integratedTime across multiple attestations``fetch_attestation_published_at::tests::earliest_wins_across_multiple_bundles`.
- [x] `TypeScript repo: resolving/npm-resolver/test/fetchAttestationPublishedAt.test.ts:169` `accepts integratedTime as a number too (defensive against schema drift)``fetch_attestation_published_at::tests::accepts_integrated_time_as_number`.
- [x] `TypeScript repo: resolving/npm-resolver/test/fetchAttestationPublishedAt.test.ts:198` `strips a trailing slash on the registry URL``fetch_attestation_published_at::tests::trims_trailing_slash_from_registry_root`.
### `createNpmResolutionVerifier`
- [x] `TypeScript repo: resolving/npm-resolver/test/createNpmResolutionVerifier.test.ts:48` `createNpmResolutionVerifier() returns undefined when no policy is active``create_npm_resolution_verifier::tests::returns_none_when_no_policy_active` (plus the `returns_none_when_min_age_is_zero` / `returns_none_when_trust_policy_off` siblings that pin the off-by-one cases).
- [x] `TypeScript repo: resolving/npm-resolver/test/createNpmResolutionVerifier.test.ts:52` `createNpmResolutionVerifier() flags a trustedPublisher → provenance downgrade``create_npm_resolution_verifier::tests::trust_downgrade_publisher_to_provenance_fails`.
- [x] `TypeScript repo: resolving/npm-resolver/test/createNpmResolutionVerifier.test.ts:99` `createNpmResolutionVerifier() passes a same-evidence-level version``create_npm_resolution_verifier::tests::trust_downgrade_pass_when_no_weaker_evidence`.
- [ ] `TypeScript repo: resolving/npm-resolver/test/createNpmResolutionVerifier.test.ts:141` `abbreviated shortcut requires the pinned version to be in metadata` — the abbreviated-modified shortcut is deferred (Phase 4 stubs that layer); rerun when Phase 5+ ports `fetchAbbreviatedMetadataCached`.
- [x] `TypeScript repo: resolving/npm-resolver/test/createNpmResolutionVerifier.test.ts:187` `ignoreMissingTimeField passes the entry when no source surfaces a timestamp``create_npm_resolution_verifier::tests::min_age_missing_time_passes_when_ignored` (plus the fail-closed sibling `min_age_missing_time_fails_closed_by_default`).
- [x] `TypeScript repo: resolving/npm-resolver/test/createNpmResolutionVerifier.test.ts:220` `canTrustPastCheck rejects when the trust-exclude list shrinks``create_npm_resolution_verifier::tests::can_trust_past_check_rejects_changed_exclude_list` (covers any shape change, not only shrinkage; matches the stricter upstream contract).
### Cached full-metadata fetcher
- [x] cold cache → 200 → mirror written — `fetch_full_metadata_cached::tests::cold_cache_writes_mirror_on_200`.
- [x] warm cache → 304 → mirror used — `fetch_full_metadata_cached::tests::warm_cache_serves_from_mirror_on_304`.
- [x] stale cache → 200 → mirror overwritten — `fetch_full_metadata_cached::tests::stale_cache_refreshes_mirror_on_200`.
- [x] no cache directory → straight fetch — `fetch_full_metadata_cached::tests::no_cache_dir_skips_mirror_io`.
- [x] read-only cache directory → call still succeeds — `fetch_full_metadata_cached::tests::read_only_cache_dir_does_not_fail_the_call`.
### `verifyLockfileResolutions` runner
- [x] `TypeScript repo: installing/deps-installer/test/install/verifyLockfileResolutions.ts:35` `no-op when the verifier list is empty``verify_lockfile_resolutions::tests::no_verifiers_is_a_noop`.
- [x] `TypeScript repo: installing/deps-installer/test/install/verifyLockfileResolutions.ts:42` `no-op when lockfile has no packages``verify_lockfile_resolutions::tests::no_packages_section_is_a_noop`.
- [x] `TypeScript repo: installing/deps-installer/test/install/verifyLockfileResolutions.ts:47` `passes when every entry is verified ok``verify_lockfile_resolutions::tests::all_ok_emits_started_then_done`.
- [x] `TypeScript repo: installing/deps-installer/test/install/verifyLockfileResolutions.ts:55` `throws with the verifier-supplied code and reason on a single failure``verify_lockfile_resolutions::tests::single_violation_picks_per_policy_variant`.
- [x] `TypeScript repo: installing/deps-installer/test/install/verifyLockfileResolutions.ts:71` `throws a generic code with per-entry codes in the breakdown when violations span policies``verify_lockfile_resolutions::tests::mixed_code_batch_escalates`.
- [x] `TypeScript repo: installing/deps-installer/test/install/verifyLockfileResolutions.ts:94` `lists violations in stable order across multiple failures` — implicit in `verify_lockfile_resolutions::tests::mixed_code_batch_escalates` (asserts alphabetical name ordering in the breakdown).
- [x] `TypeScript repo: installing/deps-installer/test/install/verifyLockfileResolutions.ts:109` `caps printed violations at 20 with an "…and N more" summary``errors::tests::over_cap_adds_and_n_more_summary`.
- [x] `TypeScript repo: installing/deps-installer/test/install/verifyLockfileResolutions.ts:127` `dedupes peer/patch-suffix variants and invokes the verifier once per (name, version)``verify_lockfile_resolutions::tests::one_packages_entry_yields_one_verification`.
- [x] `TypeScript repo: installing/deps-installer/test/install/verifyLockfileResolutions.ts:183` `keeps the per-policy code when every violation in the batch shares it``verify_lockfile_resolutions::tests::single_violation_picks_per_policy_variant`.
- [x] `TypeScript repo: installing/deps-installer/test/install/verifyLockfileResolutions.ts:202` `runs every active verifier per entry and stops at the first failure``verify_lockfile_resolutions::tests::per_candidate_fan_out_stops_at_first_failure`.
- [x] `TypeScript repo: installing/deps-installer/test/install/verifyLockfileResolutions.ts:232` `skips the verifier when the cache holds an unchanged lockfile + matching policy``verify_lockfile_resolutions::tests::second_run_with_cache_skips_fan_out`.
- [ ] `TypeScript repo: installing/deps-installer/test/install/verifyLockfileResolutions.ts:143` `does not collapse same (name, version) with different resolutions` — pacquet's collector keys by `(name, version, JSON(resolution))` (see `collect_candidates` in `verify_lockfile_resolutions.rs`), but a regression test pinning that contract is not yet written.
- [ ] `TypeScript repo: installing/deps-installer/test/install/verifyLockfileResolutions.ts:166` `the verifier sees the resolution shape verbatim` — same gap (the protocol-pass-through is exercised today only via the npm-verifier's own tarball-vs-registry tests).
- [ ] `TypeScript repo: installing/deps-installer/test/install/verifyLockfileResolutions.ts:264` `does not write a cache record when verification rejects` — currently relies on inspection of the cache file being absent; a dedicated test still needs to land in `cache::tests`.
### Cache (`tryLockfileVerificationCache`, `recordVerification`)
- [x] `TypeScript repo: installing/deps-installer/test/install/verifyLockfileResolutionsCache.ts:46` `miss when the cache file does not exist``cache::tests::cold_cache_misses_with_populated_stat`.
- [x] `TypeScript repo: installing/deps-installer/test/install/verifyLockfileResolutionsCache.ts:69` `stat-only hit when size, mtime, and inode all match``cache::tests::stat_shortcut_hits_same_path_same_stat`.
- [x] `TypeScript repo: installing/deps-installer/test/install/verifyLockfileResolutionsCache.ts:132` `miss when a verifier rejects the cached policy``cache::tests::policy_invalidation_misses_even_when_stat_matches`.
- [x] `TypeScript repo: installing/deps-installer/test/install/verifyLockfileResolutionsCache.ts:205` `hit at a new path when the content matches a cached hash (worktree case)``cache::tests::content_hash_lookup_finds_same_lockfile_at_different_path`.
- [x] `TypeScript repo: installing/deps-installer/test/install/verifyLockfileResolutionsCache.ts:256` `malformed lines are ignored, not propagated``cache::tests::malformed_lines_are_tolerated_on_read`.
- [x] `TypeScript repo: installing/deps-installer/test/install/verifyLockfileResolutionsCache.ts:273` `writes a JSONL record with a merged policy bag``cache::tests::record_verification_merges_policies`.
- [x] `TypeScript repo: installing/deps-installer/test/install/verifyLockfileResolutionsCache.ts:341` `appends without rewriting previous lines``cache::tests::append_only_log_records_each_call`.
- [ ] `TypeScript repo: installing/deps-installer/test/install/verifyLockfileResolutionsCache.ts:55` `miss when the lockfile path is not in the cache` — implicitly covered by `cold_cache_misses_with_populated_stat`, but an upstream-style explicit test is missing.
- [ ] `TypeScript repo: installing/deps-installer/test/install/verifyLockfileResolutionsCache.ts:80` `stat shortcut bails on size mismatch and falls through to hash lookup` — not yet ported.
- [ ] `TypeScript repo: installing/deps-installer/test/install/verifyLockfileResolutionsCache.ts:97` `hash-fallback hit when size matches but mtime/inode were reset` — not yet ported.
- [ ] `TypeScript repo: installing/deps-installer/test/install/verifyLockfileResolutionsCache.ts:116` `miss when content changed even if size happens to match` — not yet ported.
- [ ] `TypeScript repo: installing/deps-installer/test/install/verifyLockfileResolutionsCache.ts:144` `hit when a verifier accepts the cached policy` — implicit in the round-trip tests; explicit upstream-style test missing.
- [ ] `TypeScript repo: installing/deps-installer/test/install/verifyLockfileResolutionsCache.ts:176` `hit when every verifier trusts its share of the merged cached policy` — multi-verifier merge happy-path is not yet pinned.
- [ ] `TypeScript repo: installing/deps-installer/test/install/verifyLockfileResolutionsCache.ts:193` `miss when the lockfile no longer exists` — missing-lockfile branch returns `hit: false`, not yet pinned.
- [x] cache compaction past 1.5 MB — `cache::tests::compaction_dedupes_by_path_and_hash`.
### `recordLockfileVerified` wrapper
- [x] `TypeScript repo: installing/deps-installer/test/install/recordLockfileVerified.ts:62` `no-op when cacheDir is undefined``record_lockfile_verified` short-circuits on `cache_dir.is_none()`; pinned indirectly via the runner's `second_run_with_cache_skips_fan_out` (which exercises the recorder when caching is on).
- [x] `TypeScript repo: installing/deps-installer/test/install/recordLockfileVerified.ts:72` `no-op when resolutionVerifiers is empty` — same shape as the cache-dir guard.
- [ ] `TypeScript repo: installing/deps-installer/test/install/recordLockfileVerified.ts:103` `records the load-equivalent hash — matches what the next install computes off-disk` — would benefit from a dedicated round-trip test that loads a written lockfile and reads it back; pacquet's `hash_lockfile::tests::key_order_in_yaml_does_not_affect_hash` is close but not the same path.
- [ ] `TypeScript repo: installing/deps-installer/test/install/recordLockfileVerified.ts:141` `respects the caller-supplied lockfilePath` — git-branch-suffixed lockfile case is not yet pinned.
### `minimumReleaseAge` install-side behavior
- [x] `TypeScript repo: installing/deps-installer/test/install/minimumReleaseAge.ts:15` `prevents installation of versions that do not meet the required publish date cutoff` — covered end-to-end by `pacquet-package-manager::install::tests::frozen_lockfile_gate_rejects_under_huge_minimum_release_age` and the CLI integration test `cli::lockfile_verification::install_fails_under_huge_minimum_release_age`.
- [x] `TypeScript repo: installing/deps-installer/test/install/minimumReleaseAge.ts:23` `ignored for packages in the minimumReleaseAgeExclude array``create_npm_resolution_verifier::tests::verify_skips_age_check_when_package_excluded`.
- [x] `TypeScript repo: installing/deps-installer/test/install/minimumReleaseAge.ts:128` `throws error when semver range is used in minimumReleaseAgeExclude``pacquet-package-manager::install::tests::install_rejects_invalid_minimum_release_age_exclude_pattern`.
- [ ] `TypeScript repo: installing/deps-installer/test/install/minimumReleaseAge.ts:32` `ignored using a pattern` — wildcard exclude (`foo-*`) isn't pinned in pacquet's tests today; the exclude policy supports it.
- [ ] `TypeScript repo: installing/deps-installer/test/install/minimumReleaseAge.ts:41` `ignored for specific exact versions in minimumReleaseAgeExclude` — version-union excludes (`foo@1.0.0 || 1.1.0`) aren't pinned end-to-end yet.
- [ ] `TypeScript repo: installing/deps-installer/test/install/minimumReleaseAge.ts:68` `falls back to immature version when no mature version satisfies the range (non-strict mode)` — the fall-back-on-non-strict path lives in the resolver, which pacquet doesn't have yet; out of scope until the resolver lands.
- [ ] `TypeScript repo: installing/deps-installer/test/install/minimumReleaseAge.ts:86` `strict minimumReleaseAge surfaces every immature pick via handleResolutionPolicyViolations, then aborts` — same gating; resolver-dependent.
- [ ] `TypeScript repo: installing/deps-installer/test/install/minimumReleaseAge.ts:140` `enforced on an existing lockfile entry that does not meet the cutoff` — partially covered by the existing e2e tests (the gate runs from a lockfile); a closer mirror of upstream's fixture-with-timestamps shape isn't ported yet.
### Version-policy parser
- [x] `TypeScript repo: config/version-policy/test/index.ts:8` `createPackageVersionPolicy()``pacquet-config::version_policy::tests` exhaustive coverage of the parsing + matcher contract.
- [x] `TypeScript repo: config/version-policy/test/index.ts:57` `createPackageVersionPolicyOrThrow() rewraps parser errors with INVALID_<KEY>` — handled at the install boundary in `pacquet-package-manager::build_resolution_verifiers` (wraps `VersionPolicyError``BuildVerifiersError::InvalidMinimumReleaseAgeExclude` / `InvalidTrustPolicyExclude`).
Rust port notes:
- The abbreviated-modified shortcut and the on-disk `local_meta` layer are stubbed in Phase 4 / Phase 5 (no `fetchAbbreviatedMetadataCached` port yet). Upstream tests that depend on those layers stay unchecked until the abbreviated-cache fetcher lands.
- The cache cross-stack contract is content-divergent on hash format only — pacquet writes sha256-**hex** where pnpm writes sha256-**base64** (object-hash's default). Each stack reads its own records out of the shared JSONL; cross-stack hits aren't expected and aren't tested.
- The end-to-end CLI test uses a 100-year `minimumReleaseAge` to sidestep the mocked registry's real-world `time` field. A finer-grained fixture with controlled `time` values lives in the unit tests (`fetch_full_metadata_cached::tests`, `create_npm_resolution_verifier::tests`).