mirror of
https://github.com/pnpm/pnpm.git
synced 2026-07-19 12:12:34 -04:00
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:
63
Cargo.lock
generated
63
Cargo.lock
generated
@@ -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"
|
||||
|
||||
53
Cargo.toml
53
Cargo.toml
@@ -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" }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
95
pacquet/crates/cli/tests/lockfile_verification.rs
Normal file
95
pacquet/crates/cli/tests/lockfile_verification.rs
Normal 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));
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
254
pacquet/crates/config/src/version_policy.rs
Normal file
254
pacquet/crates/config/src/version_policy.rs
Normal 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;
|
||||
218
pacquet/crates/config/src/version_policy/tests.rs
Normal file
218
pacquet/crates/config/src/version_policy/tests.rs
Normal 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(),
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
37
pacquet/crates/lockfile-verification/Cargo.toml
Normal file
37
pacquet/crates/lockfile-verification/Cargo.toml
Normal 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
|
||||
437
pacquet/crates/lockfile-verification/src/cache.rs
Normal file
437
pacquet/crates/lockfile-verification/src/cache.rs
Normal 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;
|
||||
332
pacquet/crates/lockfile-verification/src/cache/tests.rs
vendored
Normal file
332
pacquet/crates/lockfile-verification/src/cache/tests.rs
vendored
Normal 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}");
|
||||
}
|
||||
159
pacquet/crates/lockfile-verification/src/errors.rs
Normal file
159
pacquet/crates/lockfile-verification/src/errors.rs
Normal 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;
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
187
pacquet/crates/lockfile-verification/src/errors/tests.rs
Normal file
187
pacquet/crates/lockfile-verification/src/errors/tests.rs
Normal 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());
|
||||
}
|
||||
85
pacquet/crates/lockfile-verification/src/hash_lockfile.rs
Normal file
85
pacquet/crates/lockfile-verification/src/hash_lockfile.rs
Normal 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;
|
||||
@@ -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));
|
||||
}
|
||||
40
pacquet/crates/lockfile-verification/src/lib.rs
Normal file
40
pacquet/crates/lockfile-verification/src/lib.rs
Normal 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,
|
||||
};
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
132
pacquet/crates/package-manager/src/build_resolution_verifiers.rs
Normal file
132
pacquet/crates/package-manager/src/build_resolution_verifiers.rs
Normal 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 }
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>:
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
40
pacquet/crates/resolving-npm-resolver/Cargo.toml
Normal file
40
pacquet/crates/resolving-npm-resolver/Cargo.toml
Normal 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
|
||||
@@ -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(®istry, ctx.name, ctx.version).await
|
||||
{
|
||||
return violation;
|
||||
}
|
||||
|
||||
if trust_applies
|
||||
&& let Some(violation) = self.run_trust_check(®istry, 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;
|
||||
@@ -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(®istry);
|
||||
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(®istry_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(®istry);
|
||||
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(®istry_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(®istry);
|
||||
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(®istry_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(®istry);
|
||||
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(®istry_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(®istry);
|
||||
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(®istry_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(®istry);
|
||||
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(®istry_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));
|
||||
}
|
||||
55
pacquet/crates/resolving-npm-resolver/src/errors.rs
Normal file
55
pacquet/crates/resolving-npm-resolver/src/errors.rs
Normal 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,
|
||||
},
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(®istry, &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(®istry, &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(®istry, &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(®istry, &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(®istry, &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(®istry, &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(®istry, &http_client, &auth_headers),
|
||||
)
|
||||
.await
|
||||
.expect("network ok");
|
||||
assert_eq!(result.as_deref(), Some("2024-01-01T00:00:00.000Z"));
|
||||
mock.assert_async().await;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(®istry), "Bearer top-secret".to_owned())],
|
||||
None,
|
||||
);
|
||||
let opts = FetchFullMetadataOptions {
|
||||
registry: ®istry,
|
||||
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: ®istry,
|
||||
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: ®istry,
|
||||
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: ®istry,
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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: ®istry,
|
||||
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, ®istry, "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: ®istry,
|
||||
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: ®istry,
|
||||
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, ®istry, "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: ®istry,
|
||||
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: ®istry,
|
||||
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();
|
||||
}
|
||||
41
pacquet/crates/resolving-npm-resolver/src/lib.rs
Normal file
41
pacquet/crates/resolving-npm-resolver/src/lib.rs
Normal 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};
|
||||
65
pacquet/crates/resolving-npm-resolver/src/lookup_context.rs
Normal file
65
pacquet/crates/resolving-npm-resolver/src/lookup_context.rs
Normal 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}")
|
||||
}
|
||||
282
pacquet/crates/resolving-npm-resolver/src/mirror.rs
Normal file
282
pacquet/crates/resolving-npm-resolver/src/mirror.rs
Normal 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;
|
||||
193
pacquet/crates/resolving-npm-resolver/src/mirror/tests.rs
Normal file
193
pacquet/crates/resolving-npm-resolver/src/mirror/tests.rs
Normal 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""#));
|
||||
}
|
||||
117
pacquet/crates/resolving-npm-resolver/src/named_registry.rs
Normal file
117
pacquet/crates/resolving-npm-resolver/src/named_registry.rs
Normal 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;
|
||||
@@ -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(
|
||||
®s,
|
||||
&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(®s, &prefixes, "@private/foo", None);
|
||||
assert_eq!(scoped, "https://internal/registry/");
|
||||
|
||||
let bare = pick_registry_for_version(®s, &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(
|
||||
®s,
|
||||
&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/");
|
||||
}
|
||||
73
pacquet/crates/resolving-npm-resolver/src/registry_url.rs
Normal file
73
pacquet/crates/resolving-npm-resolver/src/registry_url.rs
Normal 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;
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
264
pacquet/crates/resolving-npm-resolver/src/trust_checks.rs
Normal file
264
pacquet/crates/resolving-npm-resolver/src/trust_checks.rs
Normal 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;
|
||||
320
pacquet/crates/resolving-npm-resolver/src/trust_checks/tests.rs
Normal file
320
pacquet/crates/resolving-npm-resolver/src/trust_checks/tests.rs
Normal 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:?}");
|
||||
}
|
||||
11
pacquet/crates/resolving-npm-resolver/src/violation_codes.rs
Normal file
11
pacquet/crates/resolving-npm-resolver/src/violation_codes.rs
Normal 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";
|
||||
23
pacquet/crates/resolving-resolver-base/Cargo.toml
Normal file
23
pacquet/crates/resolving-resolver-base/Cargo.toml
Normal 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
|
||||
130
pacquet/crates/resolving-resolver-base/src/lib.rs
Normal file
130
pacquet/crates/resolving-resolver-base/src/lib.rs
Normal 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;
|
||||
102
pacquet/crates/resolving-resolver-base/src/tests.rs
Normal file
102
pacquet/crates/resolving-resolver-base/src/tests.rs
Normal 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));
|
||||
}
|
||||
@@ -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`).
|
||||
|
||||
Reference in New Issue
Block a user