diff --git a/Cargo.lock b/Cargo.lock index fc81b0477c..4a51839c08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index d1abc471fc..3f5aaa6533 100644 --- a/Cargo.toml +++ b/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" } diff --git a/pacquet/crates/cli/src/cli_args/add.rs b/pacquet/crates/cli/src/cli_args/add.rs index 4aa3fee438..e5674b3bff 100644 --- a/pacquet/crates/cli/src/cli_args/add.rs +++ b/pacquet/crates/cli/src/cli_args/add.rs @@ -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, diff --git a/pacquet/crates/cli/src/cli_args/install.rs b/pacquet/crates/cli/src/cli_args/install.rs index 957d2bb1d6..9b5ae73507 100644 --- a/pacquet/crates/cli/src/cli_args/install.rs +++ b/pacquet/crates/cli/src/cli_args/install.rs @@ -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 + // `/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, diff --git a/pacquet/crates/cli/src/state.rs b/pacquet/crates/cli/src/state.rs index 6c7b57d49c..3062b7d6df 100644 --- a/pacquet/crates/cli/src/state.rs +++ b/pacquet/crates/cli/src/state.rs @@ -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, /// 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(), }) diff --git a/pacquet/crates/cli/tests/lockfile_verification.rs b/pacquet/crates/cli/tests/lockfile_verification.rs new file mode 100644 index 0000000000..ea3ceaa122 --- /dev/null +++ b/pacquet/crates/cli/tests/lockfile_verification.rs @@ -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)); +} diff --git a/pacquet/crates/config/Cargo.toml b/pacquet/crates/config/Cargo.toml index 858fdb09fc..434ac9e8fa 100644 --- a/pacquet/crates/config/Cargo.toml +++ b/pacquet/crates/config/Cargo.toml @@ -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 } diff --git a/pacquet/crates/config/src/defaults.rs b/pacquet/crates/config/src/defaults.rs index 59fd035d31..1144520b95 100644 --- a/pacquet/crates/config/src/defaults.rs +++ b/pacquet/crates/config/src/defaults.rs @@ -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(home_dir: HomeDir) -> PathBuf +where + Sys: EnvVar, + HomeDir: FnOnce() -> Option, +{ + 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") diff --git a/pacquet/crates/config/src/defaults/tests.rs b/pacquet/crates/config/src/defaults/tests.rs index 6cc83e02e1..cb77504a2c 100644 --- a/pacquet/crates/config/src/defaults/tests.rs +++ b/pacquet/crates/config/src/defaults/tests.rs @@ -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 { + (name == "XDG_CACHE_HOME").then(|| "/tmp/xdg-cache-home".to_owned()) + } + } + let cache_dir = default_cache_dir::(|| { + 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::(|| 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). diff --git a/pacquet/crates/config/src/lib.rs b/pacquet/crates/config/src/lib.rs index 1c3c5134f2..35424ac992 100644 --- a/pacquet/crates/config/src/lib.rs +++ b/pacquet/crates/config/src/lib.rs @@ -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>, + /// pnpm's packument cache directory. Used by the lockfile + /// verification gate to memoize past results in + /// `/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::(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, + + /// 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>, + + /// 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, + + /// 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>, + + /// 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, + /// 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). diff --git a/pacquet/crates/config/src/version_policy.rs b/pacquet/crates/config/src/version_policy.rs new file mode 100644 index 0000000000..3330d71e65 --- /dev/null +++ b/pacquet/crates/config/src/version_policy.rs @@ -0,0 +1,254 @@ +//! Parse `[@[||...]]` 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 . +#[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( + specs: Iter, +) -> Result, VersionPolicyError> +where + Iter: IntoIterator, + Spec: AsRef, +{ + let mut out: HashSet = 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), +} + +/// Matcher-based version policy built from a list of +/// `[@||...]` 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, +} + +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::>()) + .finish() + } +} + +struct VersionPolicyRule { + name_matcher: Matcher, + exact_versions: Vec, +} + +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 `[@||...]` 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( + patterns: Iter, +) -> Result +where + Iter: IntoIterator, + Spec: AsRef, +{ + let mut rules: Vec = 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 `[@]` 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, +} + +fn parse_version_policy_rule(pattern: &str) -> Result, 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> { + let mut out: Vec = 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; diff --git a/pacquet/crates/config/src/version_policy/tests.rs b/pacquet/crates/config/src/version_policy/tests.rs new file mode 100644 index 0000000000..a4e4b36d75 --- /dev/null +++ b/pacquet/crates/config/src/version_policy/tests.rs @@ -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 { + let mut out: Vec = + 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 +// . + +/// 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(), + ]), + ); +} diff --git a/pacquet/crates/config/src/workspace_yaml.rs b/pacquet/crates/config/src/workspace_yaml.rs index 946934162c..fa8d20f737 100644 --- a/pacquet/crates/config/src/workspace_yaml.rs +++ b/pacquet/crates/config/src/workspace_yaml.rs @@ -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>, + + /// `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, + + /// `minimumReleaseAge` from `pnpm-workspace.yaml`. Milliseconds; + /// see [`Config::minimum_release_age`]. + pub minimum_release_age: Option, + + /// `minimumReleaseAgeExclude` from `pnpm-workspace.yaml`. + pub minimum_release_age_exclude: Option>, + + /// `minimumReleaseAgeIgnoreMissingTime` from `pnpm-workspace.yaml`. + pub minimum_release_age_ignore_missing_time: Option, + + /// `minimumReleaseAgeStrict` from `pnpm-workspace.yaml`. + pub minimum_release_age_strict: Option, + + /// `trustPolicy` from `pnpm-workspace.yaml`. See [`TrustPolicy`]. + pub trust_policy: Option, + + /// `trustPolicyExclude` from `pnpm-workspace.yaml`. + pub trust_policy_exclude: Option>, + + /// `trustPolicyIgnoreAfter` from `pnpm-workspace.yaml`. Minutes. + pub trust_policy_ignore_after: Option, } /// 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); + } } } diff --git a/pacquet/crates/config/src/workspace_yaml/tests.rs b/pacquet/crates/config/src/workspace_yaml/tests.rs index 8574cc0ab8..88db71c0e0 100644 --- a/pacquet/crates/config/src/workspace_yaml/tests.rs +++ b/pacquet/crates/config/src/workspace_yaml/tests.rs @@ -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 +/// . +#[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"); +} diff --git a/pacquet/crates/lockfile-verification/Cargo.toml b/pacquet/crates/lockfile-verification/Cargo.toml new file mode 100644 index 0000000000..76115e7489 --- /dev/null +++ b/pacquet/crates/lockfile-verification/Cargo.toml @@ -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 diff --git a/pacquet/crates/lockfile-verification/src/cache.rs b/pacquet/crates/lockfile-verification/src/cache.rs new file mode 100644 index 0000000000..7a8345c33a --- /dev/null +++ b/pacquet/crates/lockfile-verification/src/cache.rs @@ -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 `/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, +} + +/// 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, + pub hash: Option, +} + +/// 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], + 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], + 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, + /// Latest record per absolute path — same-machine stat fast path. + by_path: HashMap, +} + +/// 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 { + 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 { + 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], +) -> bool { + verifiers.iter().all(|verifier| verifier.can_trust_past_check(&record.policy)) +} + +fn merge_policies(verifiers: &[Arc]) -> serde_json::Map { + // 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 = std::collections::HashSet::new(); + let mut reversed: Vec = 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; diff --git a/pacquet/crates/lockfile-verification/src/cache/tests.rs b/pacquet/crates/lockfile-verification/src/cache/tests.rs new file mode 100644 index 0000000000..e8dc5e9769 --- /dev/null +++ b/pacquet/crates/lockfile-verification/src/cache/tests.rs @@ -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, + trusts_past: bool, +} + +impl Stub { + fn new(trusts_past: bool) -> Arc { + Arc::new(Self { policy: serde_json::Map::new(), trusts_past }) + } + + fn with_policy(trusts_past: bool, policy: serde_json::Map) -> Arc { + 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 { + &self.policy + } + + fn can_trust_past_check(&self, _cached: &serde_json::Map) -> 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> = + vec![Stub::new(true) as Arc]; + + 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> = + vec![Stub::new(true) as Arc]; + + // 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> = + vec![Stub::new(true) as Arc]; + 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> = + vec![Stub::new(false) as Arc]; + 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> = + 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> = + vec![Stub::new(true) as Arc]; + 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> = + vec![Stub::new(true) as Arc]; + 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> = + vec![Stub::new(true) as Arc]; + 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}"); +} diff --git a/pacquet/crates/lockfile-verification/src/errors.rs b/pacquet/crates/lockfile-verification/src/errors.rs new file mode 100644 index 0000000000..5d1f1086c8 --- /dev/null +++ b/pacquet/crates/lockfile-verification/src/errors.rs @@ -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) -> 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; diff --git a/pacquet/crates/lockfile-verification/src/errors/snapshots/pacquet_lockfile_verification__errors__tests__single_entry_single_code.snap b/pacquet/crates/lockfile-verification/src/errors/snapshots/pacquet_lockfile_verification__errors__tests__single_entry_single_code.snap new file mode 100644 index 0000000000..e0c6474bcf --- /dev/null +++ b/pacquet/crates/lockfile-verification/src/errors/snapshots/pacquet_lockfile_verification__errors__tests__single_entry_single_code.snap @@ -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) diff --git a/pacquet/crates/lockfile-verification/src/errors/snapshots/pacquet_lockfile_verification__errors__tests__three_entries_mixed_codes.snap b/pacquet/crates/lockfile-verification/src/errors/snapshots/pacquet_lockfile_verification__errors__tests__three_entries_mixed_codes.snap new file mode 100644 index 0000000000..48b2dc7552 --- /dev/null +++ b/pacquet/crates/lockfile-verification/src/errors/snapshots/pacquet_lockfile_verification__errors__tests__three_entries_mixed_codes.snap @@ -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) diff --git a/pacquet/crates/lockfile-verification/src/errors/snapshots/pacquet_lockfile_verification__errors__tests__three_entries_single_code.snap b/pacquet/crates/lockfile-verification/src/errors/snapshots/pacquet_lockfile_verification__errors__tests__three_entries_single_code.snap new file mode 100644 index 0000000000..f45a270379 --- /dev/null +++ b/pacquet/crates/lockfile-verification/src/errors/snapshots/pacquet_lockfile_verification__errors__tests__three_entries_single_code.snap @@ -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) diff --git a/pacquet/crates/lockfile-verification/src/errors/tests.rs b/pacquet/crates/lockfile-verification/src/errors/tests.rs new file mode 100644 index 0000000000..7729fc6244 --- /dev/null +++ b/pacquet/crates/lockfile-verification/src/errors/tests.rs @@ -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 `@ ` 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()); +} diff --git a/pacquet/crates/lockfile-verification/src/hash_lockfile.rs b/pacquet/crates/lockfile-verification/src/hash_lockfile.rs new file mode 100644 index 0000000000..73b6b2def3 --- /dev/null +++ b/pacquet/crates/lockfile-verification/src/hash_lockfile.rs @@ -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 = 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 { + self.0.update(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +#[cfg(test)] +mod tests; diff --git a/pacquet/crates/lockfile-verification/src/hash_lockfile/tests.rs b/pacquet/crates/lockfile-verification/src/hash_lockfile/tests.rs new file mode 100644 index 0000000000..5f78902972 --- /dev/null +++ b/pacquet/crates/lockfile-verification/src/hash_lockfile/tests.rs @@ -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)); +} diff --git a/pacquet/crates/lockfile-verification/src/lib.rs b/pacquet/crates/lockfile-verification/src/lib.rs new file mode 100644 index 0000000000..7d2fe775e9 --- /dev/null +++ b/pacquet/crates/lockfile-verification/src/lib.rs @@ -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, +}; diff --git a/pacquet/crates/lockfile-verification/src/record_lockfile_verified.rs b/pacquet/crates/lockfile-verification/src/record_lockfile_verified.rs new file mode 100644 index 0000000000..23dd91c743 --- /dev/null +++ b/pacquet/crates/lockfile-verification/src/record_lockfile_verified.rs @@ -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], +) { + 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(), + ); +} diff --git a/pacquet/crates/lockfile-verification/src/verify_lockfile_resolutions.rs b/pacquet/crates/lockfile-verification/src/verify_lockfile_resolutions.rs new file mode 100644 index 0000000000..6d44269be7 --- /dev/null +++ b/pacquet/crates/lockfile-verification/src/verify_lockfile_resolutions.rs @@ -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, + /// 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 + /// `/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( + lockfile: &Lockfile, + verifiers: &[Arc], + 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 = 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::( + 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::::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], + concurrency: Option, +) -> Vec { + 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 { + let Some(packages) = lockfile.packages.as_ref() else { + return Vec::new(); + }; + let mut deduped: BTreeMap = 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, + verifiers: &[Arc], + concurrency: Option, +) -> Vec { + 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> = + 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], +) -> Option { + 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) -> VerifyError { + violations.sort_by(|left, right| { + format!("{}@{}", left.name, left.version).cmp(&format!("{}@{}", right.name, right.version)) + }); + let rendered: Vec = 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(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 { + pending: Option, + /// `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, +} + +impl TerminalEmitGuard { + fn failed(entries: u64, started_at: Instant, lockfile_path: Option) -> 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 Drop for TerminalEmitGuard { + 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: })`. + 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::(LogLevel::Debug, message); + } + } +} + +#[cfg(test)] +mod tests; diff --git a/pacquet/crates/lockfile-verification/src/verify_lockfile_resolutions/tests.rs b/pacquet/crates/lockfile-verification/src/verify_lockfile_resolutions/tests.rs new file mode 100644 index 0000000000..aeb53190f7 --- /dev/null +++ b/pacquet/crates/lockfile-verification/src/verify_lockfile_resolutions/tests.rs @@ -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, +} + +impl AlwaysFail { + fn new(code: &'static str, reason: &'static str) -> Arc { + 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 { + &self.policy + } + + fn can_trust_past_check(&self, _cached: &serde_json::Map) -> 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, +} + +impl FailFor { + fn new(code: &'static str, reason: &'static str, names: Vec<&'static str>) -> Arc { + 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 { + &self.policy + } + + fn can_trust_past_check(&self, _cached: &serde_json::Map) -> 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> = 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::( + &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::( + &lockfile, + &[verifier as Arc], + &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> = 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::( + &lockfile, + &[verifier as Arc], + &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::( + &lockfile, + &[verifier as Arc], + &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::( + &lockfile, + &[min_age as Arc, trust as Arc], + &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, second as Arc], + 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], + 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> = 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::( + &lockfile, + &[verifier as Arc], + &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, + } + 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 { + &self.policy + } + + fn can_trust_past_check( + &self, + _cached: &serde_json::Map, + ) -> bool { + true + } + } + + let lockfile = parse(SINGLE_PKG_LOCKFILE); + let verifier: Arc = + Arc::new(Counting { policy: serde_json::Map::new() }); + verify_lockfile_resolutions::( + &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, + } + 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 { + &self.policy + } + fn can_trust_past_check( + &self, + _cached: &serde_json::Map, + ) -> 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 = + 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::( + &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::( + &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::( + &lockfile, + &[], + &VerifyLockfileResolutionsOptions::default(), + ) + .await; + assert!(result.is_ok()); +} diff --git a/pacquet/crates/package-manager/Cargo.toml b/pacquet/crates/package-manager/Cargo.toml index 5662d99788..ce3740b07c 100644 --- a/pacquet/crates/package-manager/Cargo.toml +++ b/pacquet/crates/package-manager/Cargo.toml @@ -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 } diff --git a/pacquet/crates/package-manager/src/add.rs b/pacquet/crates/package-manager/src/add.rs index ab9826a2e3..faa8f405d5 100644 --- a/pacquet/crates/package-manager/src/add.rs +++ b/pacquet/crates/package-manager/src/add.rs @@ -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, 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, diff --git a/pacquet/crates/package-manager/src/build_resolution_verifiers.rs b/pacquet/crates/package-manager/src/build_resolution_verifiers.rs new file mode 100644 index 0000000000..487745dd05 --- /dev/null +++ b/pacquet/crates/package-manager/src/build_resolution_verifiers.rs @@ -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>` 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, +) -> Result>, BuildVerifiersError> { + let mut verifiers: Vec> = 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, 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 } + } +} diff --git a/pacquet/crates/package-manager/src/build_snapshot/tests.rs b/pacquet/crates/package-manager/src/build_snapshot/tests.rs index 264bcdcb2c..a5f7b85f8d 100644 --- a/pacquet/crates/package-manager/src/build_snapshot/tests.rs +++ b/pacquet/crates/package-manager/src/build_snapshot/tests.rs @@ -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, } } diff --git a/pacquet/crates/package-manager/src/install.rs b/pacquet/crates/package-manager/src/install.rs index 42bf5fc38b..de4606b38a 100644 --- a/pacquet/crates/package-manager/src/install.rs +++ b/pacquet/crates/package-manager/src/install.rs @@ -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, 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 `/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 . + 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 + // . + // `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::( + 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 : diff --git a/pacquet/crates/package-manager/src/install/tests.rs b/pacquet/crates/package-manager/src/install/tests.rs index 1f2d636738..8acafdfb02 100644 --- a/pacquet/crates/package-manager/src/install/tests.rs +++ b/pacquet/crates/package-manager/src/install/tests.rs @@ -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::() + .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::() + .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)); +} diff --git a/pacquet/crates/package-manager/src/install_package_from_registry/tests.rs b/pacquet/crates/package-manager/src/install_package_from_registry/tests.rs index a4827f7e0b..3aa9095970 100644 --- a/pacquet/crates/package-manager/src/install_package_from_registry/tests.rs +++ b/pacquet/crates/package-manager/src/install_package_from_registry/tests.rs @@ -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(), diff --git a/pacquet/crates/package-manager/src/lib.rs b/pacquet/crates/package-manager/src/lib.rs index 1bff870f97..eed5b36b19 100644 --- a/pacquet/crates/package-manager/src/lib.rs +++ b/pacquet/crates/package-manager/src/lib.rs @@ -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::*; diff --git a/pacquet/crates/package-manager/src/version_policy.rs b/pacquet/crates/package-manager/src/version_policy.rs index 790accd0bb..026cd79b19 100644 --- a/pacquet/crates/package-manager/src/version_policy.rs +++ b/pacquet/crates/package-manager/src/version_policy.rs @@ -1,159 +1,12 @@ -//! Parse and expand `[@[||...]]` 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( - specs: Iter, -) -> Result, VersionPolicyError> -where - Iter: IntoIterator, - Spec: AsRef, -{ - let mut out: HashSet = 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 `[@]` 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, -} - -fn parse_version_policy_rule(pattern: &str) -> Result, 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> { - let mut out: Vec = 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}; diff --git a/pacquet/crates/package-manager/src/version_policy/tests.rs b/pacquet/crates/package-manager/src/version_policy/tests.rs deleted file mode 100644 index e3c7227b3e..0000000000 --- a/pacquet/crates/package-manager/src/version_policy/tests.rs +++ /dev/null @@ -1,98 +0,0 @@ -use crate::version_policy::{VersionPolicyError, expand_package_version_specs}; -use pretty_assertions::assert_eq; - -fn expand(specs: &[&str]) -> Vec { - let mut out: Vec = - 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()]); -} diff --git a/pacquet/crates/registry/src/lib.rs b/pacquet/crates/registry/src/lib.rs index da3db9a474..41787a24f5 100644 --- a/pacquet/crates/registry/src/lib.rs +++ b/pacquet/crates/registry/src/lib.rs @@ -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; diff --git a/pacquet/crates/registry/src/package.rs b/pacquet/crates/registry/src/package.rs index 3b145f5401..812310ea77 100644 --- a/pacquet/crates/registry/src/package.rs +++ b/pacquet/crates/registry/src/package.rs @@ -16,10 +16,59 @@ pub struct Package { dist_tags: HashMap, pub versions: HashMap, + /// 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>, + + /// 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, + + /// 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, + #[serde(skip_serializing, skip_deserializing)] pub mutex: Arc>, } +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 diff --git a/pacquet/crates/registry/src/package/tests.rs b/pacquet/crates/registry/src/package/tests.rs index 889244fe4e..cd3e230d71 100644 --- a/pacquet/crates/registry/src/package/tests.rs +++ b/pacquet/crates/registry/src/package/tests.rs @@ -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::>(); @@ -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 +/// . +#[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"); +} diff --git a/pacquet/crates/registry/src/package_distribution.rs b/pacquet/crates/registry/src/package_distribution.rs index 4c178a20fc..843ac29889 100644 --- a/pacquet/crates/registry/src/package_distribution.rs +++ b/pacquet/crates/registry/src/package_distribution.rs @@ -9,6 +9,46 @@ pub struct PackageDistribution { pub tarball: String, pub file_count: Option, pub unpacked_size: Option, + + /// 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/@` 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, +} + +/// 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, +} + +/// 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, } impl PartialEq for PackageDistribution { diff --git a/pacquet/crates/registry/src/package_version.rs b/pacquet/crates/registry/src/package_version.rs index 110297f378..bfa000a0a1 100644 --- a/pacquet/crates/registry/src/package_version.rs +++ b/pacquet/crates/registry/src/package_version.rs @@ -15,6 +15,50 @@ pub struct PackageVersion { pub dependencies: Option>, pub dev_dependencies: Option>, pub peer_dependencies: Option>, + + /// 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` 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trusted_publisher: Option, +} + +/// 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 { diff --git a/pacquet/crates/reporter/src/lib.rs b/pacquet/crates/reporter/src/lib.rs index 8586fcc2c0..105ab06100 100644 --- a/pacquet/crates/reporter/src/lib.rs +++ b/pacquet/crates/reporter/src/lib.rs @@ -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: . + /// Emit site: . + #[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, + }, + Done { + entries: u64, + #[serde(rename = "elapsedMs")] + elapsed_ms: u64, + #[serde(rename = "lockfilePath", skip_serializing_if = "Option::is_none")] + lockfile_path: Option, + }, + Failed { + entries: u64, + #[serde(rename = "elapsedMs")] + elapsed_ms: u64, + #[serde(rename = "lockfilePath", skip_serializing_if = "Option::is_none")] + lockfile_path: Option, + }, +} + /// Severity level on the [bunyan]-shaped envelope. /// /// pnpm's logger uses the [bole] library, which writes one of these strings diff --git a/pacquet/crates/reporter/src/tests.rs b/pacquet/crates/reporter/src/tests.rs index c15d4b5144..6a9234263b 100644 --- a/pacquet/crates/reporter/src/tests.rs +++ b/pacquet/crates/reporter/src/tests.rs @@ -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 +/// . +#[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 +/// . +#[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. diff --git a/pacquet/crates/resolving-npm-resolver/Cargo.toml b/pacquet/crates/resolving-npm-resolver/Cargo.toml new file mode 100644 index 0000000000..dc6717b459 --- /dev/null +++ b/pacquet/crates/resolving-npm-resolver/Cargo.toml @@ -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 diff --git a/pacquet/crates/resolving-npm-resolver/src/create_npm_resolution_verifier.rs b/pacquet/crates/resolving-npm-resolver/src/create_npm_resolution_verifier.rs new file mode 100644 index 0000000000..8f513b7f23 --- /dev/null +++ b/pacquet/crates/resolving-npm-resolver/src/create_npm_resolution_verifier.rs @@ -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, + /// Wildcard / exact-version patterns whose packages skip the age + /// check. `None` (or empty) means "no exclusions". + pub minimum_release_age_exclude: Option, + /// 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, + /// `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, + pub trust_policy_exclude: Option, + pub trust_policy_exclude_patterns: Vec, + /// Maximum age (in minutes) before which the trust check still + /// applies. `None` ("always check") mirrors upstream's + /// `undefined`. + pub trust_policy_ignore_after: Option, + /// `default` + per-scope registry map. Keyed by `"default"` or + /// `"@scope"`; mirrors pnpm's `Registries` shape. + pub registries: HashMap, + /// User-defined named-registry aliases (e.g. `gh:` → + /// `https://npm.pkg.github.com/`). Merged with + /// [`crate::BUILTIN_NAMED_REGISTRIES`]. + pub named_registries: HashMap, + pub http_client: Arc, + pub auth_headers: Arc, + /// Root of pnpm's on-disk metadata mirror. When set, the verifier + /// reads conditional headers from + /// `/v11/metadata-full//.jsonl` and + /// writes 200 responses back; when `None`, every fetch is + /// unconditional. Mirrors upstream's `cacheDir` option. + pub cache_dir: Option, + /// 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>, +} + +/// 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, + cutoff: Option>, + minimum_release_age_exclude: Option, + ignore_missing_time_field: bool, + trust_policy: Option, + trust_policy_exclude: Option, + trust_policy_ignore_after: Option, + /// 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, + sorted_trust_excludes: Vec, + registries: HashMap, + named_registry_prefixes: Vec, + http_client: Arc, + auth_headers: Arc, + cache_dir: Option, + now: Option>, + policy_snapshot: serde_json::Map, + 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 { + 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 { + &self.policy_snapshot + } + + fn can_trust_past_check(&self, cached_policy: &serde_json::Map) -> 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::>() + }) + .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::>() + }) + .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 { + 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 { + 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, 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, 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, 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>, 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::() + .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, 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 { + 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> { + 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 { + let mut deduped: Vec = values.to_vec(); + deduped.sort(); + deduped.dedup(); + deduped +} + +fn build_policy_snapshot( + minimum_release_age: u64, + sorted_min_age_excludes: &[String], + trust_policy: Option, + sorted_trust_excludes: &[String], + trust_policy_ignore_after: Option, +) -> serde_json::Map { + 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; diff --git a/pacquet/crates/resolving-npm-resolver/src/create_npm_resolution_verifier/tests.rs b/pacquet/crates/resolving-npm-resolver/src/create_npm_resolution_verifier/tests.rs new file mode 100644 index 0000000000..ea83566aae --- /dev/null +++ b/pacquet/crates/resolving-npm-resolver/src/create_npm_resolution_verifier/tests.rs @@ -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 { + DateTime::parse_from_rfc3339(date).expect("parse rfc3339").with_timezone(&Utc) +} + +fn fake_integrity() -> Integrity { + FAKE_INTEGRITY.parse::().expect("parse fake integrity") +} + +fn registry_resolution() -> LockfileResolution { + LockfileResolution::Registry(RegistryResolution { integrity: fake_integrity() }) +} + +fn registries_with_default(default: &str) -> HashMap { + 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::().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::().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::().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::().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::().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::().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::().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!["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)); +} diff --git a/pacquet/crates/resolving-npm-resolver/src/errors.rs b/pacquet/crates/resolving-npm-resolver/src/errors.rs new file mode 100644 index 0000000000..aeb7a45269 --- /dev/null +++ b/pacquet/crates/resolving-npm-resolver/src/errors.rs @@ -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, + }, +} diff --git a/pacquet/crates/resolving-npm-resolver/src/fetch_attestation_published_at.rs b/pacquet/crates/resolving-npm-resolver/src/fetch_attestation_published_at.rs new file mode 100644 index 0000000000..53f7c15ce3 --- /dev/null +++ b/pacquet/crates/resolving-npm-resolver/src/fetch_attestation_published_at.rs @@ -0,0 +1,138 @@ +//! Per-version publish timestamp from npm's attestation endpoint — +//! `/-/npm/v1/attestations/@`. +//! +//! 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 `@`. 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, FetchMetadataError> { + // Strip a trailing `/` from the registry root before assembling + // the endpoint URL — `/-/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 { + let attestations = body.get("attestations")?.as_array()?; + let mut earliest: Option = 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 { + let bundle = attestation.get("bundle")?; + let verification_material = bundle.get("verificationMaterial")?; + let tlog_entries = verification_material.get("tlogEntries")?.as_array()?; + let mut earliest: Option = 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 { + if let Some(text) = value.as_str() { + return text.parse::().ok().filter(|&seconds| seconds > 0); + } + if let Some(seconds) = value.as_i64() { + return Some(seconds).filter(|&s| s > 0); + } + None +} + +#[cfg(test)] +mod tests; diff --git a/pacquet/crates/resolving-npm-resolver/src/fetch_attestation_published_at/tests.rs b/pacquet/crates/resolving-npm-resolver/src/fetch_attestation_published_at/tests.rs new file mode 100644 index 0000000000..2584e37bde --- /dev/null +++ b/pacquet/crates/resolving-npm-resolver/src/fetch_attestation_published_at/tests.rs @@ -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; +} diff --git a/pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata.rs b/pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata.rs new file mode 100644 index 0000000000..995c3ae4a6 --- /dev/null +++ b/pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata.rs @@ -0,0 +1,75 @@ +//! No-cache full-metadata fetcher. +//! +//! Issues a GET against `/` 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 { + // 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::()` 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; diff --git a/pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata/tests.rs b/pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata/tests.rs new file mode 100644 index 0000000000..346696fa36 --- /dev/null +++ b/pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata/tests.rs @@ -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; +} diff --git a/pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata_cached.rs b/pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata_cached.rs new file mode 100644 index 0000000000..36d96b0c7b --- /dev/null +++ b/pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata_cached.rs @@ -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 `/v11/metadata-full//.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 + /// `/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 { + // 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: `. + // 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; diff --git a/pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata_cached/tests.rs b/pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata_cached/tests.rs new file mode 100644 index 0000000000..d937e447e7 --- /dev/null +++ b/pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata_cached/tests.rs @@ -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(); +} diff --git a/pacquet/crates/resolving-npm-resolver/src/lib.rs b/pacquet/crates/resolving-npm-resolver/src/lib.rs new file mode 100644 index 0000000000..fc3208e6e8 --- /dev/null +++ b/pacquet/crates/resolving-npm-resolver/src/lib.rs @@ -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}; diff --git a/pacquet/crates/resolving-npm-resolver/src/lookup_context.rs b/pacquet/crates/resolving-npm-resolver/src/lookup_context.rs new file mode 100644 index 0000000000..b254940bd6 --- /dev/null +++ b/pacquet/crates/resolving-npm-resolver/src/lookup_context.rs @@ -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; + +/// 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>>, + pub full_meta: Mutex>>>, + pub full_meta_for_trust: Mutex, 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}") +} diff --git a/pacquet/crates/resolving-npm-resolver/src/mirror.rs b/pacquet/crates/resolving-npm-resolver/src/mirror.rs new file mode 100644 index 0000000000..6340ce0f2b --- /dev/null +++ b/pacquet/crates/resolving-npm-resolver/src/mirror.rs @@ -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`] — `///.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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub modified: Option, +} + +/// 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 { + 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 { + 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 { + 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 { + 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 { + 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 (`.` 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; diff --git a/pacquet/crates/resolving-npm-resolver/src/mirror/tests.rs b/pacquet/crates/resolving-npm-resolver/src/mirror/tests.rs new file mode 100644 index 0000000000..a0a619a5bd --- /dev/null +++ b/pacquet/crates/resolving-npm-resolver/src/mirror/tests.rs @@ -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 `_` 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 `///.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""#)); +} diff --git a/pacquet/crates/resolving-npm-resolver/src/named_registry.rs b/pacquet/crates/resolving-npm-resolver/src/named_registry.rs new file mode 100644 index 0000000000..405850c7aa --- /dev/null +++ b/pacquet/crates/resolving-npm-resolver/src/named_registry.rs @@ -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) -> Vec { + 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 = 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, + 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, 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; diff --git a/pacquet/crates/resolving-npm-resolver/src/named_registry/tests.rs b/pacquet/crates/resolving-npm-resolver/src/named_registry/tests.rs new file mode 100644 index 0000000000..8e83b8782f --- /dev/null +++ b/pacquet/crates/resolving-npm-resolver/src/named_registry/tests.rs @@ -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 { + 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/"); +} diff --git a/pacquet/crates/resolving-npm-resolver/src/registry_url.rs b/pacquet/crates/resolving-npm-resolver/src/registry_url.rs new file mode 100644 index 0000000000..ac392e1a13 --- /dev/null +++ b/pacquet/crates/resolving-npm-resolver/src/registry_url.rs @@ -0,0 +1,73 @@ +//! Build the `/` 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: ``. +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; diff --git a/pacquet/crates/resolving-npm-resolver/src/registry_url/tests.rs b/pacquet/crates/resolving-npm-resolver/src/registry_url/tests.rs new file mode 100644 index 0000000000..3fe62915a4 --- /dev/null +++ b/pacquet/crates/resolving-npm-resolver/src/registry_url/tests.rs @@ -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", + ); +} diff --git a/pacquet/crates/resolving-npm-resolver/src/trust_checks.rs b/pacquet/crates/resolving-npm-resolver/src/trust_checks.rs new file mode 100644 index 0000000000..bac0a8ecc1 --- /dev/null +++ b/pacquet/crates/resolving-npm-resolver/src/trust_checks.rs @@ -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, + + /// 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>, +} + +/// 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` 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) -> &'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, + exclude_prerelease: bool, +) -> Option { + let mut best: Option = 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 { + 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; diff --git a/pacquet/crates/resolving-npm-resolver/src/trust_checks/tests.rs b/pacquet/crates/resolving-npm-resolver/src/trust_checks/tests.rs new file mode 100644 index 0000000000..ed5036a8a8 --- /dev/null +++ b/pacquet/crates/resolving-npm-resolver/src/trust_checks/tests.rs @@ -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 = + versions.iter().map(|(v, _, ev)| ((*v).to_string(), version_json(name, v, *ev))).collect(); + let time_json: serde_json::Map = 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 { + 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:?}"); +} diff --git a/pacquet/crates/resolving-npm-resolver/src/violation_codes.rs b/pacquet/crates/resolving-npm-resolver/src/violation_codes.rs new file mode 100644 index 0000000000..11ef432bfc --- /dev/null +++ b/pacquet/crates/resolving-npm-resolver/src/violation_codes.rs @@ -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 () 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"; diff --git a/pacquet/crates/resolving-resolver-base/Cargo.toml b/pacquet/crates/resolving-resolver-base/Cargo.toml new file mode 100644 index 0000000000..e352ba1daf --- /dev/null +++ b/pacquet/crates/resolving-resolver-base/Cargo.toml @@ -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 diff --git a/pacquet/crates/resolving-resolver-base/src/lib.rs b/pacquet/crates/resolving-resolver-base/src/lib.rs new file mode 100644 index 0000000000..cb378afeb5 --- /dev/null +++ b/pacquet/crates/resolving-resolver-base/src/lib.rs @@ -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 + 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; + + /// 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, + ) -> bool; +} + +#[cfg(test)] +mod tests; diff --git a/pacquet/crates/resolving-resolver-base/src/tests.rs b/pacquet/crates/resolving-resolver-base/src/tests.rs new file mode 100644 index 0000000000..aca4d9c3c9 --- /dev/null +++ b/pacquet/crates/resolving-resolver-base/src/tests.rs @@ -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::() + .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, +} + +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 { + &self.policy + } + + fn can_trust_past_check( + &self, + cached_policy: &serde_json::Map, + ) -> 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 = 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)); +} diff --git a/pacquet/plans/TEST_PORTING.md b/pacquet/plans/TEST_PORTING.md index 7cf36897ca..c51f19bdfb 100644 --- a/pacquet/plans/TEST_PORTING.md +++ b/pacquet/plans/TEST_PORTING.md @@ -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_` — 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`).