diff --git a/site/content/docs/install/index.mdx b/site/content/docs/install/index.mdx index 9f1646c71..f99577405 100644 --- a/site/content/docs/install/index.mdx +++ b/site/content/docs/install/index.mdx @@ -649,7 +649,19 @@ Frozen reinstalls — `nub ci`, `--frozen-lockfile`, a teammate's clone — inhe ### Trust downgrades -Nub also weighs trust *evidence* across a package's release history — OIDC provenance, a trusted publisher, a staged-publish approval. A resolved version that carries weaker evidence than an earlier-published version of the same package stops the install with `ERR_NUB_TRUST_DOWNGRADE`, because a maintainer's pipeline that suddenly publishes without the attestation it used to carry is the shape of a token-theft supply-chain attack. +Nub also weighs trust *evidence* across a package's release history — OIDC provenance, a trusted publisher, a staged-publish approval. A resolved version that carries weaker evidence than an earlier-published version of the same package is refused, because a maintainer's pipeline that suddenly publishes without the attestation it used to carry is the shape of a token-theft supply-chain attack. + +Refusing a version is not the same as failing the install. Nub keeps looking through the versions the range admits and takes the best one that still carries its evidence, naming what it skipped and why: + +```console +# captured: fast-glob@^3.3.2 → @nodelib/fs.walk → fastq, whose 1.20.2 was hand-published +$ nub install +WARN skipped fastq@1.20.2 (trustPolicy=no-downgrade): earlier published version 1.20.0 had trusted publisher but this version has no trust evidence; resolved to fastq@1.20.1 instead code=WARN_NUB_TRUST_DOWNGRADE_SKIPPED +``` + +The substitution stays inside the declared range and never crosses the refused version — down to an older release for an ordinary pick, and up to a newer one when `resolution-mode=time-based` resolves a direct dependency to its range floor. When nothing the range admits clears the check, the install stops with `ERR_NUB_TRUST_DOWNGRADE`. + +A lockfile names one exact version rather than a range, so there is nothing to walk. Any install that reuses an undrifted lockfile — `nub ci`, `--frozen-lockfile`, or a plain `nub install` — still aborts on a pin the check refuses, because only a fresh resolve has a range to substitute within. The remedy is `nub install --no-frozen-lockfile`. The comparison is by publish date, so a legitimate maintenance release on an older major — shipped after a newer major adopted provenance — can trip it. Nub exempts any version older than 14 days, so an aged, un-yanked backport resolves while a freshly published downgrade is still checked against the full history. Widen the window, clear a single package, or turn the check off in `.npmrc`: diff --git a/vendor/aube/crates/aube-codes/src/warnings.rs b/vendor/aube/crates/aube-codes/src/warnings.rs index d8a17d1f3..1e29100b1 100644 --- a/vendor/aube/crates/aube-codes/src/warnings.rs +++ b/vendor/aube/crates/aube-codes/src/warnings.rs @@ -93,6 +93,7 @@ pub const WARN_AUBE_UNSUPPORTED_PLATFORM_INSTALL: &str = "WARN_AUBE_UNSUPPORTED_ #[rustfmt::skip] pub const WARN_AUBE_SKIPPED_OPTIONAL_NO_MATCHING_VERSION: &str = "WARN_AUBE_SKIPPED_OPTIONAL_NO_MATCHING_VERSION"; pub const WARN_AUBE_EXOTIC_SUBDEP_SKIPPED: &str = "WARN_AUBE_EXOTIC_SUBDEP_SKIPPED"; pub const WARN_AUBE_PEER_DEDUPE_COLLISION: &str = "WARN_AUBE_PEER_DEDUPE_COLLISION"; +#[rustfmt::skip] pub const WARN_AUBE_TRUST_DOWNGRADE_SKIPPED: &str = "WARN_AUBE_TRUST_DOWNGRADE_SKIPPED"; // ── lockfile ──────────────────────────────────────────────────────── pub const WARN_AUBE_LOCKFILE_MERGE_CONFLICT: &str = "WARN_AUBE_LOCKFILE_MERGE_CONFLICT"; @@ -542,6 +543,12 @@ pub const ALL: &[CodeMeta] = &[ description: "An optional or peer dep used an exotic specifier and was skipped under `blockExoticSubdeps=true`.", exit_code: None, }, + CodeMeta { + name: WARN_AUBE_TRUST_DOWNGRADE_SKIPPED, + category: category::RESOLVER, + description: "`trustPolicy=no-downgrade` refused the version the range would otherwise resolve to, so an older satisfying version that keeps its trust evidence was installed instead.", + exit_code: None, + }, CodeMeta { name: WARN_AUBE_PEER_DEDUPE_COLLISION, category: category::RESOLVER, diff --git a/vendor/aube/crates/aube-resolver/src/resolve/driver.rs b/vendor/aube/crates/aube-resolver/src/resolve/driver.rs index 06d762ba1..c614a514c 100644 --- a/vendor/aube/crates/aube-resolver/src/resolve/driver.rs +++ b/vendor/aube/crates/aube-resolver/src/resolve/driver.rs @@ -197,6 +197,11 @@ pub(crate) struct ResolveDriver<'a> { /// doesn't crash the wrong task. Checked after the fetch-wait loop /// to decide skip (optional) vs propagate (required). failed_fetches: FxHashMap, + /// `name@refused -> substitute` for every pick the trust gate refused and + /// the resolver then backtracked past, so the substitution notice is + /// printed once per outcome rather than once per parent that depends on + /// the package. + trust_repicks: FxHashSet, /// Catalog picks gathered as the BFS rewrites `catalog:` task /// ranges. Outer key: catalog name. Inner: package name → spec. catalog_picks: BTreeMap>, @@ -386,6 +391,7 @@ impl<'a> ResolveDriver<'a> { resolved_times: BTreeMap::new(), skipped_optional_dependencies: BTreeMap::new(), failed_fetches: FxHashMap::default(), + trust_repicks: FxHashSet::default(), catalog_picks: BTreeMap::new(), deferred_transitives: Vec::new(), deferred_auto_peers: Vec::new(), @@ -1113,7 +1119,7 @@ impl<'a> ResolveDriver<'a> { let packument = self.resolver.cache.get(®istry_name).ok_or_else(|| { Error::Registry(registry_name.clone(), "packument not in cache".to_string()) })?; - let picked_ref = prefer_non_vulnerable_pick( + let mut picked_ref = prefer_non_vulnerable_pick( task.registry_name(), packument, &task.range, @@ -1131,20 +1137,81 @@ impl<'a> ResolveDriver<'a> { // compare. The check needs the live packument's `time` // map and all version metadata, both of which are still // in scope here from L1191. - if self.resolver.dependency_policy.trust_policy == crate::TrustPolicy::NoDowngrade { - crate::trust::check_no_downgrade( + if self.resolver.dependency_policy.trust_policy == crate::TrustPolicy::NoDowngrade + && let Err(err) = crate::trust::check_no_downgrade( packument, &picked_ref.version, picked_ref, &self.resolver.dependency_policy.trust_policy_exclude, self.resolver.dependency_policy.trust_policy_ignore_after, ) - .map_err(|e| match e { - crate::trust::TrustCheckError::Downgrade(d) => Error::TrustDowngrade(Box::new(d)), + { + let downgrade = match err { + crate::trust::TrustCheckError::Downgrade(d) => d, + // A packument with no `time` entry for the picked version is a + // metadata anomaly, not a refused candidate: there is nothing + // to backtrack THROUGH, since the same missing-time shape is + // what every other candidate would be judged on. Stays fatal. crate::trust::TrustCheckError::MissingTime(d) => { - Error::TrustCheckMissingTime(Box::new(d)) + return Err(Error::TrustCheckMissingTime(Box::new(d))); } - })?; + }; + // Backtrack the way the age gate already does. `pick_version` + // treats a too-new release as "keep looking down the range", so a + // package that publishes one version manually between two attested + // ones deadlocks the two gates against each other: the age gate + // walks down to the manual publish and the trust gate refuses it, + // while a fully-signed release one version lower satisfies the + // range and is never considered. Refusing is still the outcome + // when NO satisfying version clears both gates, so neither gate is + // weakened — only the order of "refuse" and "keep looking" changes. + match crate::trust::repick_past_downgrade( + packument, + task.registry_name(), + &task.range, + &downgrade.picked_version, + pick_lowest, + cutoff_for_pkg, + exempt_cutoff, + strict, + &self.resolver.dependency_policy.trust_policy_exclude, + self.resolver.dependency_policy.trust_policy_ignore_after, + &self.resolver.vulnerable_ranges, + is_age_exempt, + ) { + Some(meta) => { + // Never silent: the user asked for a range and is getting + // something other than its head, for a supply-chain reason + // they may want to act on. Deduped per refusal AND + // substitute, because the same package resolves once per + // parent that depends on it and two disjunctive ranges can + // share a refused head while landing on different + // substitutes — keying on the refusal alone would suppress + // the second and leave the printed line naming a version + // one of the parents is not on. + if self.trust_repicks.insert(format!( + "{}@{} -> {}", + downgrade.name, downgrade.picked_version, meta.version + )) { + tracing::warn!( + code = aube_codes::warnings::WARN_AUBE_TRUST_DOWNGRADE_SKIPPED, + "skipped {}@{} (trustPolicy=no-downgrade): earlier published version \ + {} had {} but this version has {}; resolved to {}@{} instead", + downgrade.name, + downgrade.picked_version, + downgrade.prior_version, + downgrade.prior_evidence.label(), + downgrade + .current_evidence + .map_or("no trust evidence", |e| e.label()), + downgrade.name, + meta.version, + ); + } + picked_ref = meta; + } + None => return Err(Error::TrustDowngrade(Box::new(downgrade))), + } } // Clone the picked metadata into an owned value so we can @@ -1179,6 +1246,20 @@ impl<'a> ResolveDriver<'a> { && task.range == "latest" && let Some(latest) = packument.dist_tags.get("latest") && latest != &picked_ref.version + // The trust gate can move a pick below `latest` too, and this + // notice names `minimumReleaseAge` by wording. Fire it only when + // `latest` genuinely fails the age cutoff, so a trust re-pick does + // not get reported as an age-gate fallback. + && !crate::semver_util::version_clears_cutoff( + packument, + latest, + if is_age_exempt(latest, None) { + exempt_cutoff + } else { + cutoff_for_pkg + }, + strict, + ) { aube_util::record_age_gate_downgrade(task.registry_name(), &picked_ref.version, latest); } diff --git a/vendor/aube/crates/aube-resolver/src/tests.rs b/vendor/aube/crates/aube-resolver/src/tests.rs index 36abf0492..14706e08f 100644 --- a/vendor/aube/crates/aube-resolver/src/tests.rs +++ b/vendor/aube/crates/aube-resolver/src/tests.rs @@ -2807,6 +2807,217 @@ async fn trust_policy_no_downgrade_blocks_downgraded_install() { let _ = std::fs::remove_dir_all(base); } +/// The gate deadlock this backtracking exists to break, end to end through the +/// driver. `foo` mirrors `fastq`: two attested releases, then a hand-published +/// 2.0.2 that npm tagged `latest`. `^2.0.0` resolves to that head, the trust +/// gate refuses it, and before the fix the whole install aborted with a signed +/// 2.0.1 sitting inside the range. Now the resolver keeps walking down. +/// +/// The second half is the invariant: `>=2.0.2` admits nothing but the refused +/// version, so the refusal still stands. Backtracking changes which version is +/// installed, never whether an untrusted one can be. +#[tokio::test] +async fn trust_policy_no_downgrade_backtracks_to_a_still_trusted_version() { + use aube_registry::Attestations; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let mut packument = make_packument("foo", &["1.0.0", "2.0.0", "2.0.1", "2.0.2"], "2.0.2"); + for (ver, time) in [ + ("1.0.0", "2025-01-01T00:00:00.000Z"), + ("2.0.0", "2025-02-01T00:00:00.000Z"), + ("2.0.1", "2025-02-02T00:00:00.000Z"), + ("2.0.2", "2025-03-01T00:00:00.000Z"), + ] { + packument.time.insert(ver.to_string(), time.to_string()); + } + for ver in ["2.0.0", "2.0.1"] { + packument + .versions + .get_mut(ver) + .unwrap() + .dist + .as_mut() + .unwrap() + .attestations = Some(Attestations { + provenance: Some(serde_json::json!({ + "predicateType": "https://slsa.dev/provenance/v1" + })), + }); + } + let body = serde_json::to_vec(&packument).unwrap(); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let registry = format!("http://{}/", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + break; + }; + let body = body.clone(); + tokio::spawn(async move { + let mut buf = [0_u8; 2048]; + let _ = socket.read(&mut buf).await; + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", + body.len() + ); + socket.write_all(response.as_bytes()).await.unwrap(); + socket.write_all(&body).await.unwrap(); + }); + } + }); + + let base = std::env::temp_dir().join(format!( + "aube-resolver-trust-backtrack-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(base.join("packuments")).unwrap(); + std::fs::create_dir_all(base.join("packuments-full")).unwrap(); + + let resolve_range = |range: &str| { + let registry = registry.clone(); + let base = base.clone(); + let range = range.to_string(); + async move { + let policy = crate::DependencyPolicy { + trust_policy: crate::TrustPolicy::NoDowngrade, + ..crate::DependencyPolicy::default() + }; + let mut resolver = Resolver::new(Arc::new(aube_registry::client::RegistryClient::new( + ®istry, + ))) + .with_packument_cache(base.join("packuments")) + .with_packument_full_cache(base.join("packuments-full")) + .with_dependency_policy(policy); + let mut manifest = PackageJson::default(); + manifest.dependencies.insert("foo".to_string(), range); + resolver.resolve(&manifest, None).await + } + }; + + let graph = resolve_range("^2.0.0") + .await + .expect("the range still admits a signed 2.0.1"); + assert!( + graph_has_package(&graph, "foo", "2.0.1"), + "must fall back to the newest release that kept its provenance" + ); + assert!(!graph_has_package(&graph, "foo", "2.0.2")); + + match resolve_range(">=2.0.2") + .await + .expect_err("nothing in this range keeps its evidence") + { + Error::TrustDowngrade(d) => assert_eq!(d.picked_version, "2.0.2"), + other => panic!("expected TrustDowngrade, got {other:?}"), + } + + server.abort(); + let _ = std::fs::remove_dir_all(base); +} + +/// The `latest`-was-steered notice names `minimumReleaseAge` by wording, so a +/// pick the TRUST gate moved must not be reported under it. `bar@1.1.0` is +/// mature and tagged `latest`; only its missing attestation pushes the resolve +/// down to 1.0.0, and dlx — the only consumer of this notice — would otherwise +/// tell the user their tool is older because it was published too recently. +#[tokio::test] +async fn a_trust_repick_is_not_reported_as_an_age_gate_fallback() { + use aube_registry::Attestations; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + const NAME: &str = "trust-repick-notice"; + let mut packument = make_packument(NAME, &["1.0.0", "1.1.0"], "1.1.0"); + packument + .time + .insert("1.0.0".to_string(), "2025-01-01T00:00:00.000Z".to_string()); + packument + .time + .insert("1.1.0".to_string(), "2025-02-01T00:00:00.000Z".to_string()); + packument + .versions + .get_mut("1.0.0") + .unwrap() + .dist + .as_mut() + .unwrap() + .attestations = Some(Attestations { + provenance: Some(serde_json::json!({ + "predicateType": "https://slsa.dev/provenance/v1" + })), + }); + let body = serde_json::to_vec(&packument).unwrap(); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let registry = format!("http://{}/", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + break; + }; + let body = body.clone(); + tokio::spawn(async move { + let mut buf = [0_u8; 2048]; + let _ = socket.read(&mut buf).await; + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", + body.len() + ); + let _ = socket.write_all(response.as_bytes()).await; + let _ = socket.write_all(&body).await; + }); + } + }); + + let base = std::env::temp_dir().join(format!( + "aube-resolver-trust-notice-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(base.join("packuments")).unwrap(); + std::fs::create_dir_all(base.join("packuments-full")).unwrap(); + + let policy = crate::DependencyPolicy { + trust_policy: crate::TrustPolicy::NoDowngrade, + ..crate::DependencyPolicy::default() + }; + let mut resolver = Resolver::new(Arc::new(aube_registry::client::RegistryClient::new( + ®istry, + ))) + .with_packument_cache(base.join("packuments")) + .with_packument_full_cache(base.join("packuments-full")) + .with_dependency_policy(policy) + .with_minimum_release_age(Some(MinimumReleaseAge { + minutes: 60, + strict: true, + ..Default::default() + })); + let mut manifest = PackageJson::default(); + manifest + .dependencies + .insert(NAME.to_string(), "latest".to_string()); + + aube_util::arm_age_gate_downgrade_collection(); + let resolved = resolver.resolve(&manifest, None).await; + let downgrades = aube_util::take_age_gate_downgrades(); + server.abort(); + let _ = std::fs::remove_dir_all(base); + + let graph = resolved.expect("the signed 1.0.0 is still installable"); + assert!(graph_has_package(&graph, NAME, "1.0.0")); + assert!( + !downgrades.iter().any(|d| d.name == NAME), + "the age gate admitted `latest`; only trust moved the pick: {downgrades:?}" + ); +} + #[test] fn test_format_iso8601_known_epoch() { // 2024-01-01T00:00:00Z = 1704067200 diff --git a/vendor/aube/crates/aube-resolver/src/trust.rs b/vendor/aube/crates/aube-resolver/src/trust.rs index eb1542a88..168074b14 100644 --- a/vendor/aube/crates/aube-resolver/src/trust.rs +++ b/vendor/aube/crates/aube-resolver/src/trust.rs @@ -271,6 +271,119 @@ pub fn check_no_downgrade( Ok(()) } +/// Find the best version satisfying `range_str` that clears both the age +/// cutoff and [`check_no_downgrade`], searching away from a version the +/// trust gate just rejected. +/// +/// The age gate already backtracks: `pick_version` scans every satisfying +/// version and keeps the newest one clearing the cutoff, so a too-new release +/// costs the user an older pick rather than the whole install. The trust gate +/// used to abort instead, which deadlocks whenever a package publishes one +/// version manually between two attested ones — the age gate walks down to the +/// manual publish, the trust gate refuses it, and a fully-signed release +/// sitting one version lower is never considered. This makes the two gates +/// consistent. It is a deliberate divergence from pnpm's +/// `failIfTrustDowngraded`, which throws: pnpm implements its own age gate as a +/// packument FILTER (`filterPkgMetadataByPublishDate`) and its trust gate as a +/// post-pick throw, so the same asymmetry is baked into upstream. +/// +/// Bounded at `rejected_version` in the pick's own direction — strictly lower +/// for a highest-wins pick, strictly higher for `pick_lowest`. Unbounded, a +/// re-pick could climb ABOVE `dist-tags.latest` and hand back a release the +/// publisher has already moved the tag off, which is the same trap +/// `pick_version`'s `<=` fallback avoids for a gated `latest`. +/// +/// Two tiers, mirroring [`crate::resolve::vulnerable::prefer_non_vulnerable_pick`]: +/// a known-vulnerable version is a worse answer than a clean one, but still a +/// better answer than failing the install, so it ranks second rather than +/// being excluded. Trust and age are hard filters in both tiers. +/// +/// Cost is `O(versions²)` in the worst case — `check_no_downgrade` walks the +/// packument once per candidate — and is paid only on the failure path, after +/// the ordinary pick has already been refused. +#[allow(clippy::too_many_arguments)] +pub(crate) fn repick_past_downgrade<'a>( + packument: &'a Packument, + registry_name: &str, + range_str: &str, + rejected_version: &str, + pick_lowest: bool, + cutoff: Option<&str>, + exempt_cutoff: Option<&str>, + strict: bool, + exclude: &TrustExcludeRules, + ignore_after_minutes: Option, + vulnerable_ranges: &std::collections::BTreeMap>, + is_age_exempt: impl Fn(&str, Option<&node_semver::Version>) -> bool, +) -> Option<&'a VersionMetadata> { + // Mirror `pick_version`'s dist-tag handling. A bare tag is not a semver + // range, and a `latest` the gate refused widens to everything at or below + // the tag so the scan can reach the release under it — the same widening + // `pick_version` does for an age-gated `latest` (#681), which is the shape + // `dlx` and an unversioned `add` always take. Restricted to `latest` + // pointing at a STABLE release: every other tag resolves to exactly one + // version, which has no lower alternative by definition, and a channel + // pointer like `next` must not leak a stable release into a prerelease + // line. + let range = match node_semver::Range::parse(crate::semver_util::normalize_range(range_str)) { + Ok(range) => range, + Err(_) if range_str == "latest" => { + let latest = packument.dist_tags.get("latest")?; + if !node_semver::Version::parse(latest) + .ok()? + .pre_release + .is_empty() + { + return None; + } + node_semver::Range::parse(format!("<={latest}")).ok()? + } + Err(_) => return None, + }; + let rejected = node_semver::Version::parse(rejected_version).ok()?; + + let mut best: Option<(node_semver::Version, &'a VersionMetadata)> = None; + let mut best_vulnerable: Option<(node_semver::Version, &'a VersionMetadata)> = None; + for (ver_str, meta) in &packument.versions { + let Ok(version) = node_semver::Version::parse(ver_str) else { + continue; + }; + // Away from the rejected pick, never past it. + if pick_lowest { + if version <= rejected { + continue; + } + } else if version >= rejected { + continue; + } + if !version.satisfies(&range) { + continue; + } + let effective = if is_age_exempt(ver_str, Some(&version)) { + exempt_cutoff + } else { + cutoff + }; + if !crate::semver_util::version_clears_cutoff(packument, ver_str, effective, strict) { + continue; + } + if check_no_downgrade(packument, ver_str, meta, exclude, ignore_after_minutes).is_err() { + continue; + } + let tier = + if crate::resolve::vulnerable::is_vulnerable(registry_name, ver_str, vulnerable_ranges) + { + &mut best_vulnerable + } else { + &mut best + }; + if crate::semver_util::outranks(&version, meta, tier.as_ref(), pick_lowest) { + *tier = Some((version, meta)); + } + } + best.or(best_vulnerable).map(|(_, meta)| meta) +} + fn cutoff_iso8601(minutes_ago: u64) -> Option { let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs(); let cutoff_secs = now.saturating_sub(minutes_ago * 60); @@ -1434,4 +1547,192 @@ mod tests { let r = TrustExcludeRules::parse(["", "foo", ""]).unwrap(); assert!(r.matches("foo", &node_semver::Version::parse("1.0.0").unwrap())); } + + // ── backtracking past a refused pick ──────────────────────────── + + /// No exemptions and no ignore-window, so every candidate faces the full + /// chronological scan. The empty policy matters: `TrustExcludeRules::default` + /// seeds 40-odd real package names. + fn repick<'a>( + packument: &'a Packument, + range: &str, + rejected: &str, + cutoff: Option<&str>, + ) -> Option<&'a VersionMetadata> { + repick_past_downgrade( + packument, + &packument.name, + range, + rejected, + false, + cutoff, + None, + true, + &TrustExcludeRules::empty(), + None, + &std::collections::BTreeMap::new(), + |_, _| false, + ) + } + + /// The `fastq` shape: two attested releases, one hand-published on top of + /// them, and the range's head refused. The install must land on the newest + /// release that still carries its evidence rather than abort. + #[test] + fn repick_walks_down_to_the_newest_still_trusted_version() { + let p = packument( + "fastq", + vec![ + ( + "1.20.0", + "2025-12-23T07:49:07.472Z", + with_trusted_publisher(version("fastq", "1.20.0")), + ), + ( + "1.20.1", + "2025-12-23T07:58:58.958Z", + with_trusted_publisher(version("fastq", "1.20.1")), + ), + ( + "1.20.2", + "2026-08-28T00:52:38.498Z", + version("fastq", "1.20.2"), + ), + ], + ); + assert!( + check_no_downgrade( + &p, + "1.20.2", + p.versions.get("1.20.2").unwrap(), + &TrustExcludeRules::empty(), + None + ) + .is_err() + ); + let picked = repick(&p, "^1.20.0", "1.20.2", None).expect("1.20.1 satisfies and is signed"); + assert_eq!(picked.version, "1.20.1"); + } + + /// An unversioned request — `dlx`, a bare `add` — arrives as the literal + /// range `latest`, which is not semver. It widens to everything at or below + /// the tag, exactly as an age-gated `latest` does. A channel tag names one + /// version and stays un-widened. + #[test] + fn repick_widens_a_refused_latest_but_not_another_tag() { + let mut p = packument( + "foo", + vec![ + ( + "1.1.0", + "2025-02-01T00:00:00.000Z", + with_trusted_publisher(version("foo", "1.1.0")), + ), + ("1.2.0", "2025-03-01T00:00:00.000Z", version("foo", "1.2.0")), + ], + ); + p.dist_tags + .insert("latest".to_string(), "1.2.0".to_string()); + p.dist_tags.insert("next".to_string(), "1.2.0".to_string()); + let picked = repick(&p, "latest", "1.2.0", None).expect("1.1.0 is under the tag"); + assert_eq!(picked.version, "1.1.0"); + assert!(repick(&p, "next", "1.2.0", None).is_none()); + } + + /// The gate is not weakened: when the range admits nothing that clears the + /// trust check, the caller still gets its refusal. `>=1.1.0` cannot reach + /// the attested 1.0.0 below it. + #[test] + fn repick_gives_up_when_no_satisfying_version_keeps_its_evidence() { + let p = packument( + "foo", + vec![ + ( + "1.0.0", + "2025-01-01T00:00:00.000Z", + with_trusted_publisher(version("foo", "1.0.0")), + ), + ("1.1.0", "2025-02-01T00:00:00.000Z", version("foo", "1.1.0")), + ("1.2.0", "2025-03-01T00:00:00.000Z", version("foo", "1.2.0")), + ], + ); + assert!(repick(&p, ">=1.1.0", "1.2.0", None).is_none()); + } + + /// Bounded at the refused version. 1.3.0 satisfies the range and is signed, + /// but it sits ABOVE the pick — reaching it would hand back a release the + /// publisher has already moved `dist-tags.latest` off. + #[test] + fn repick_never_climbs_above_the_refused_version() { + let mut p = packument( + "foo", + vec![ + ( + "1.0.0", + "2025-01-01T00:00:00.000Z", + with_trusted_publisher(version("foo", "1.0.0")), + ), + ( + "1.1.0", + "2025-02-01T00:00:00.000Z", + with_trusted_publisher(version("foo", "1.1.0")), + ), + ("1.2.0", "2025-03-01T00:00:00.000Z", version("foo", "1.2.0")), + ( + "1.3.0", + "2025-04-01T00:00:00.000Z", + with_trusted_publisher(version("foo", "1.3.0")), + ), + ], + ); + p.dist_tags + .insert("latest".to_string(), "1.2.0".to_string()); + let picked = repick(&p, "^1.0.0", "1.2.0", None).expect("1.1.0 is reachable"); + assert_eq!(picked.version, "1.1.0"); + + // `resolution-mode=time-based` takes the FLOOR of the range, so its + // backtrack runs the other way and 1.3.0 is what comes into reach. The + // bound is "away from the refusal", not "downward". + let picked = repick_past_downgrade( + &p, + &p.name, + "^1.0.0", + "1.2.0", + true, + None, + None, + true, + &TrustExcludeRules::empty(), + None, + &std::collections::BTreeMap::new(), + |_, _| false, + ) + .expect("1.3.0 is reachable upward"); + assert_eq!(picked.version, "1.3.0"); + } + + /// The age gate still binds inside the backtrack: a signed release that is + /// too new is no more installable here than it was at the original pick. + #[test] + fn repick_still_honors_the_age_cutoff() { + let p = packument( + "foo", + vec![ + ( + "1.0.0", + "2025-01-01T00:00:00.000Z", + with_trusted_publisher(version("foo", "1.0.0")), + ), + ( + "1.1.0", + "2026-08-28T00:00:00.000Z", + with_trusted_publisher(version("foo", "1.1.0")), + ), + ("1.2.0", "2026-08-29T00:00:00.000Z", version("foo", "1.2.0")), + ], + ); + let picked = repick(&p, "^1.0.0", "1.2.0", Some("2026-01-01T00:00:00.000Z")) + .expect("1.0.0 clears both gates"); + assert_eq!(picked.version, "1.0.0"); + } }