From e91c43853918fcc541fc76918920500f04b4db9a Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Sun, 6 Sep 2026 21:32:33 -0400 Subject: [PATCH 01/34] Widen lockfile intake to carry dependency edges and registry origin Package now carries its dependency edges (resolved to concrete versions by cargo_lock) and an is_registry flag. Intake stops filtering non-registry packages so workspace members and other lockfile entries are available as dependents for the requirement graph the suggest-fix flow will build; the registry filter moves to the point of use in main's check loop. Adds semver, glob, and cargo_toml as dependencies for the requirement-matching and manifest-reading work to follow. --- Cargo.lock | 86 ++++++++++++++++++++++++++++++++++++--- Cargo.toml | 3 ++ src/lockfile.rs | 105 ++++++++++++++++++++++++++++++++++++++++++------ src/main.rs | 7 +++- src/policy.rs | 2 + 5 files changed, 183 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e45e787..32e37ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -111,7 +111,7 @@ checksum = "c06acb4f71407ba205a07cb453211e0e6a67b21904e47f6ba1f9589e38f2e454" dependencies = [ "semver", "serde", - "toml", + "toml 0.8.23", "url", ] @@ -121,14 +121,28 @@ version = "0.1.8" dependencies = [ "anyhow", "cargo-lock", + "cargo_toml", "chrono", "clap", + "glob", + "semver", "serde", "serde_json", "tempfile", "ureq", ] +[[package]] +name = "cargo_toml" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f4b26e751e711a5302649417f2da046dce6391b2ea30a4820f37462314f0b9" +dependencies = [ + "semver", + "serde", + "toml 1.1.5+spec-1.1.0", +] + [[package]] name = "cc" version = "1.2.62" @@ -353,6 +367,12 @@ dependencies = [ "wasip3", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "hashbrown" version = "0.15.5" @@ -821,6 +841,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "shlex" version = "1.3.0" @@ -940,11 +969,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", "toml_edit", ] +[[package]] +name = "toml" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + [[package]] name = "toml_datetime" version = "0.6.11" @@ -954,6 +998,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -962,10 +1015,19 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.15", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", ] [[package]] @@ -974,6 +1036,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1322,6 +1390,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 2fdecc8..3da37ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,9 @@ ureq = { version = "3", features = ["rustls", "json"] } serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" +semver = "1.0.28" +glob = "0.3.4" +cargo_toml = "1.0.1" [dev-dependencies] tempfile = "3" diff --git a/src/lockfile.rs b/src/lockfile.rs index a7ecf19..1038d47 100644 --- a/src/lockfile.rs +++ b/src/lockfile.rs @@ -1,19 +1,35 @@ use anyhow::{Context, Result}; use std::path::Path; -/// A dependency from a lockfile, checked against the crates.io registry. +/// A name/version pair identifying a package, used both for lockfile entries +/// and for the dependency edges between them. +pub struct PackageRef { + pub name: String, + pub version: String, +} + +/// An entry from `Cargo.lock`. Includes path and git packages (not just +/// crates.io ones) so that workspace members can appear as dependents in the +/// requirement graph; `is_registry` tells callers which entries are eligible +/// for the age check itself. pub struct Package { pub name: String, pub version: String, + pub is_registry: bool, + pub dependencies: Vec, } -/// Loads the crates.io registry packages from a lockfile. +/// Loads every package recorded in a lockfile, registry and non-registry +/// alike. /// /// `path` is the lockfile path as given by the caller (relative or /// absolute), resolved against `working_dir` if relative. Rejects anything /// that is not a regular file within `working_dir` (`..` traversal and -/// symlink escapes included), then keeps only packages sourced from the -/// default registry, since path and git dependencies aren't on crates.io. +/// symlink escapes included). +/// +/// `cargo_lock` resolves each dependency edge to a concrete version itself +/// (lockfiles may omit a dependency's version when only one instance of it +/// exists), so every `PackageRef` here already carries one. pub fn load(path: &Path, working_dir: &Path) -> Result> { let resolved = if path.is_absolute() { path.to_path_buf() @@ -55,13 +71,18 @@ pub fn load(path: &Path, working_dir: &Path) -> Result> { let packages = lockfile .packages .into_iter() - .filter(|p| { - // Only check packages from crates.io registry - p.source.as_ref().is_some_and(|s| s.is_default_registry()) - }) .map(|p| Package { name: p.name.as_str().to_string(), version: p.version.to_string(), + is_registry: p.source.as_ref().is_some_and(|s| s.is_default_registry()), + dependencies: p + .dependencies + .iter() + .map(|d| PackageRef { + name: d.name.as_str().to_string(), + version: d.version.to_string(), + }) + .collect(), }) .collect(); @@ -87,6 +108,28 @@ checksum = "0000000000000000000000000000000000000000000000000000000000000000" ) } + fn registry_entry_with_deps(name: &str, version: &str, deps: &[&str]) -> String { + let deps_line = if deps.is_empty() { + String::new() + } else { + let list = deps + .iter() + .map(|d| format!("\"{d}\"")) + .collect::>() + .join(",\n "); + format!("dependencies = [\n {list},\n]\n") + }; + format!( + r#" +[[package]] +name = "{name}" +version = "{version}" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0000000000000000000000000000000000000000000000000000000000000000" +{deps_line}"# + ) + } + fn git_entry(name: &str, version: &str) -> String { format!( r#" @@ -115,7 +158,7 @@ version = "{version}" } #[test] - fn only_crates_io_registry_packages_are_kept() { + fn non_registry_packages_are_kept_with_the_flag_set() { let dir = tempdir().unwrap(); let contents = format!( "{}{}{}", @@ -127,9 +170,47 @@ version = "{version}" let packages = load(Path::new("Cargo.lock"), dir.path()).unwrap(); - assert_eq!(packages.len(), 1); - assert_eq!(packages[0].name, "serde"); - assert_eq!(packages[0].version, "1.0.0"); + assert_eq!(packages.len(), 3); + let serde = packages.iter().find(|p| p.name == "serde").unwrap(); + assert!(serde.is_registry); + let rand = packages.iter().find(|p| p.name == "rand").unwrap(); + assert!(!rand.is_registry); + let local = packages.iter().find(|p| p.name == "local-crate").unwrap(); + assert!(!local.is_registry); + } + + #[test] + fn dependency_with_explicit_version_resolves() { + let dir = tempdir().unwrap(); + let contents = format!( + "{}{}", + registry_entry_with_deps("a", "1.0.0", &["b 2.0.0"]), + registry_entry("b", "2.0.0"), + ); + write_lockfile(dir.path(), &contents); + + let packages = load(Path::new("Cargo.lock"), dir.path()).unwrap(); + let a = packages.iter().find(|p| p.name == "a").unwrap(); + assert_eq!(a.dependencies.len(), 1); + assert_eq!(a.dependencies[0].name, "b"); + assert_eq!(a.dependencies[0].version, "2.0.0"); + } + + #[test] + fn dependency_with_omitted_version_resolves_by_name() { + let dir = tempdir().unwrap(); + let contents = format!( + "{}{}", + registry_entry_with_deps("a", "1.0.0", &["b"]), + registry_entry("b", "2.0.0"), + ); + write_lockfile(dir.path(), &contents); + + let packages = load(Path::new("Cargo.lock"), dir.path()).unwrap(); + let a = packages.iter().find(|p| p.name == "a").unwrap(); + assert_eq!(a.dependencies.len(), 1); + assert_eq!(a.dependencies[0].name, "b"); + assert_eq!(a.dependencies[0].version, "2.0.0"); } #[test] diff --git a/src/main.rs b/src/main.rs index 4b50f29..8b8750d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -129,8 +129,11 @@ fn run(cli: Cli) -> Result { let mut violations = Vec::new(); let now = chrono::Utc::now(); - let total = packages.len(); - for (i, pkg) in packages.iter().enumerate() { + let registry_packages: Vec<&lockfile::Package> = + packages.iter().filter(|p| p.is_registry).collect(); + + let total = registry_packages.len(); + for (i, pkg) in registry_packages.iter().enumerate() { if freshness_policy.is_exempt(&pkg.name) { continue; } diff --git a/src/policy.rs b/src/policy.rs index 84fc6a7..7497bc7 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -105,6 +105,8 @@ mod tests { Package { name: "serde".to_string(), version: "1.0.0".to_string(), + is_registry: true, + dependencies: vec![], } } From b5b004181aa1510843829b1b2106caad104f2ead Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Sun, 6 Sep 2026 21:34:49 -0400 Subject: [PATCH 02/34] Add sparse crates.io index fetch, parsing, and caching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetch_index_record retrieves the newline-delimited JSON index record for a crate from index.crates.io, one line per published version with that version's own dependency requirements — the source the suggest flow will use to check whether a candidate downgrade satisfies a transitive dependent. It reuses the existing retry and 404-as-empty handling via a new fetch_body helper shared with fetch_json, but skips the inter-request pacing delay since the sparse index isn't subject to the crates.io API's rate limit. Index records are cached under a third map in the response cache, defaulted on deserialisation so cache files from earlier releases still load. --- src/api.rs | 188 +++++++++++++++++++++++++++++++++++++++++++++++++-- src/cache.rs | 54 ++++++++++++++- 2 files changed, 235 insertions(+), 7 deletions(-) diff --git a/src/api.rs b/src/api.rs index b9ddd8b..b5f140e 100644 --- a/src/api.rs +++ b/src/api.rs @@ -30,6 +30,56 @@ pub struct CrateVersionInfo { pub yanked: bool, } +/// One version's record from the crates.io sparse index: its own version +/// string, whether it's yanked, and the requirements it places on its own +/// dependencies (used to check whether a candidate downgrade would still +/// satisfy a dependent). +#[derive(Deserialize, Serialize, Clone)] +pub struct IndexRecord { + pub vers: String, + #[serde(default)] + pub yanked: bool, + #[serde(default)] + pub deps: Vec, +} + +#[derive(Deserialize, Serialize, Clone)] +pub struct IndexDep { + pub name: String, + pub req: String, + #[serde(default)] + pub kind: Option, + #[serde(default)] + pub target: Option, + #[serde(default)] + pub optional: Option, + /// The original crate name, present when `name` is a rename alias. + #[serde(default)] + pub package: Option, +} + +/// Computes the sparse-index path fragment for a crate name, per the rules +/// at : +/// 1-char names live under `1/`, 2-char under `2/`, 3-char under +/// `3//`, and everything else is split into two two-character +/// prefix directories. Matching is done on the lowercased name. +pub fn sparse_index_path(name: &str) -> String { + let lower = name.to_lowercase(); + match lower.len() { + 1 => format!("1/{lower}"), + 2 => format!("2/{lower}"), + 3 => { + let c0 = &lower[0..1]; + format!("3/{c0}/{lower}") + } + _ => { + let c01 = &lower[0..2]; + let c23 = &lower[2..4]; + format!("{c01}/{c23}/{lower}") + } + } +} + /// Classifies API fetch errors for retry decision-making. #[derive(Debug)] pub enum FetchError { @@ -197,6 +247,21 @@ impl CratesIoClient { url: &str, subject: &str, ) -> Result, FetchError> { + let Some(body) = self.fetch_body(url, subject)? else { + return Ok(None); + }; + + serde_json::from_slice(&body) + .map(Some) + .map_err(|e| FetchError::Permanent(format!("Failed to parse response {subject}: {e}"))) + } + + /// Issues a GET and classifies HTTP errors, without interpreting the + /// body. Used by `fetch_json` and by the index fetch, whose body is + /// newline-delimited JSON rather than a single document. + /// + /// Returns `Ok(None)` on HTTP 404, same as `fetch_json`. + fn fetch_body(&self, url: &str, subject: &str) -> Result>, FetchError> { let response = self .transport .get(url) @@ -211,11 +276,7 @@ impl CratesIoClient { status if (400..500).contains(&status) => Err(FetchError::Permanent(format!( "Client error {status} {subject}" ))), - _ => serde_json::from_slice(&response.body) - .map(Some) - .map_err(|e| { - FetchError::Permanent(format!("Failed to parse response {subject}: {e}")) - }), + _ => Ok(Some(response.body)), } } @@ -301,6 +362,48 @@ impl CratesIoClient { self.pace(); result } + + fn fetch_index_record_uncached(&self, name: &str) -> Result, FetchError> { + let url = format!("https://index.crates.io/{}", sparse_index_path(name)); + let subject = format!("fetching index record for {name}"); + let Some(body) = self.fetch_body(&url, &subject)? else { + return Ok(vec![]); + }; + let text = String::from_utf8_lossy(&body); + + text.lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| { + serde_json::from_str(line).map_err(|e| { + FetchError::Permanent(format!("Failed to parse index record {subject}: {e}")) + }) + }) + .collect() + } + + /// Fetches every published version's index record for `name` from the + /// crates.io sparse index — one line of JSON per version, each listing + /// that version's own dependency requirements. An unknown crate yields + /// an empty vector rather than an error. + /// + /// Unlike the other two fetches, this one is not subject to the + /// crates.io API's inter-request pacing: the sparse index is a static + /// endpoint outside that rate limit. + pub fn fetch_index_record(&mut self, name: &str) -> Result, FetchError> { + let max_age = ChronoDuration::hours(self.cache_max_age_hours as i64); + + if let Some(records) = self.cache.get_index_records(name, max_age) { + return Ok(records); + } + + self.with_retry(|client| { + let result = client.fetch_index_record_uncached(name)?; + if !result.is_empty() { + client.cache.set_index_records(name, result.clone()); + } + Ok(result) + }) + } } /// Test-only fake `Transport` and URL helpers, shared by this module's own @@ -367,11 +470,18 @@ pub(crate) mod test_support { pub(crate) fn versions_url(name: &str) -> String { format!("https://crates.io/api/v1/crates/{name}") } + + pub(crate) fn index_url(name: &str) -> String { + format!( + "https://index.crates.io/{}", + super::sparse_index_path(name) + ) + } } #[cfg(test)] mod tests { - use super::test_support::{FakeTransport, ScriptedResponse, versions_url}; + use super::test_support::{FakeTransport, ScriptedResponse, index_url, versions_url}; use super::*; use std::num::NonZeroU32; use std::time::Instant; @@ -586,4 +696,70 @@ mod tests { assert_eq!(result.len(), 1); assert_eq!(result[0].num, "1.0.0"); } + + #[test] + fn sparse_index_path_prefix_rules() { + assert_eq!(sparse_index_path("a"), "1/a"); + assert_eq!(sparse_index_path("io"), "2/io"); + assert_eq!(sparse_index_path("syn"), "3/s/syn"); + assert_eq!(sparse_index_path("serde"), "se/rd/serde"); + assert_eq!(sparse_index_path("Serde"), "se/rd/serde"); + } + + #[test] + fn fetch_index_record_parses_multiple_lines_with_defaults() { + let url = index_url("serde"); + let transport = FakeTransport::new(); + transport.push( + &url, + ScriptedResponse::Http( + 200, + concat!( + r#"{"vers":"1.0.0","yanked":false,"deps":[{"name":"quote","req":"^1.0"}]}"#, + "\n", + r#"{"vers":"1.0.1","yanked":true}"#, + "\n", + ) + .to_string(), + ), + ); + + let mut client = fast_client(transport); + let records = client.fetch_index_record("serde").unwrap(); + + assert_eq!(records.len(), 2); + assert_eq!(records[0].vers, "1.0.0"); + assert!(!records[0].yanked); + assert_eq!(records[0].deps.len(), 1); + assert_eq!(records[0].deps[0].name, "quote"); + assert_eq!(records[0].deps[0].req, "^1.0"); + assert_eq!(records[0].deps[0].kind, None); + assert_eq!(records[0].deps[0].package, None); + + assert_eq!(records[1].vers, "1.0.1"); + assert!(records[1].yanked); + assert!(records[1].deps.is_empty()); + } + + #[test] + fn fetch_index_record_missing_crate_yields_empty_result() { + let url = index_url("does-not-exist"); + let transport = FakeTransport::new(); + transport.push(&url, ScriptedResponse::Http(404, String::new())); + + let mut client = fast_client(transport); + let records = client.fetch_index_record("does-not-exist").unwrap(); + assert!(records.is_empty()); + } + + #[test] + fn fetch_index_record_cache_hit_issues_no_request() { + let transport = FakeTransport::new(); + let mut client = fast_client(transport); + client.cache.set_index_records("serde", vec![]); + + let records = client.fetch_index_record("serde").unwrap(); + assert!(records.is_empty()); + assert_eq!(client.transport.call_count(), 0); + } } diff --git a/src/cache.rs b/src/cache.rs index 0f82414..542a5dc 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::{Path, PathBuf}; -use crate::api::CrateVersionInfo; +use crate::api::{CrateVersionInfo, IndexRecord}; const CACHE_VERSION: u32 = 1; @@ -13,6 +13,8 @@ struct CacheData { version: u32, publish_dates: HashMap>, all_versions: HashMap, + #[serde(default)] + index_records: HashMap, } #[derive(Serialize, Deserialize)] @@ -21,6 +23,12 @@ struct AllVersionsEntry { versions: Vec, } +#[derive(Serialize, Deserialize)] +struct IndexRecordsEntry { + fetched_at: DateTime, + records: Vec, +} + pub struct ResponseCache { path: Option, data: CacheData, @@ -107,6 +115,26 @@ impl ResponseCache { self.dirty = true; } + pub fn get_index_records(&self, name: &str, max_age: Duration) -> Option> { + let entry = self.data.index_records.get(name)?; + let age = Utc::now() - entry.fetched_at; + + if age > max_age { + return None; + } + + Some(entry.records.clone()) + } + + pub fn set_index_records(&mut self, name: &str, records: Vec) { + let entry = IndexRecordsEntry { + fetched_at: Utc::now(), + records, + }; + self.data.index_records.insert(name.to_string(), entry); + self.dirty = true; + } + pub fn save(&self) -> Result<()> { if !self.dirty { return Ok(()); @@ -257,6 +285,30 @@ mod tests { cache.save().unwrap(); } + #[test] + fn cache_file_without_index_records_still_loads() { + let dir = tempdir().unwrap(); + let path = dir.path().join("cache.json"); + + // A cache file as written by a release before index_records existed. + std::fs::write( + &path, + r#"{"version":1,"publish_dates":{"serde/1.0.0":"2020-01-01T00:00:00Z"},"all_versions":{}}"#, + ) + .unwrap(); + + let cache = ResponseCache::load(Some(&path)); + assert_eq!( + cache.get_publish_date("serde", "1.0.0"), + Some( + DateTime::parse_from_rfc3339("2020-01-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc) + ) + ); + assert!(cache.get_index_records("serde", Duration::hours(1)).is_none()); + } + #[test] fn load_wrong_version_starts_fresh() { let dir = tempdir().unwrap(); From c2e2139bc6ea314f81ae86d4683f0dfb783c1aa3 Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Sun, 6 Sep 2026 21:59:55 -0400 Subject: [PATCH 03/34] Add manifest module reading the user's own version requirements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit load_direct_requirements answers "what do the user's manifests require of a given registry crate": it reads the root Cargo.toml beside the lockfile, expands workspace members from glob patterns with exclusions applied, follows path dependencies one level so members not listed under workspace.members are still read, and walks normal, dev, build, and target-specific dependency tables. Renames resolve to the real crate name and workspace-inherited requirements are resolved by cargo_toml itself. A missing or unparseable manifest, or an entry whose requirement can't be determined, produces a warning and is treated as unconstrained rather than aborting the run — the bare-lockfile workflow must keep working. --- src/main.rs | 1 + src/manifest.rs | 405 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 406 insertions(+) create mode 100644 src/manifest.rs diff --git a/src/main.rs b/src/main.rs index 8b8750d..f097e66 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ use std::process::ExitCode; mod api; mod cache; mod lockfile; +mod manifest; mod policy; mod report; mod suggest; diff --git a/src/manifest.rs b/src/manifest.rs new file mode 100644 index 0000000..102ba55 --- /dev/null +++ b/src/manifest.rs @@ -0,0 +1,405 @@ +use cargo_toml::{Dependency, DepsSet, Manifest}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +/// One version requirement the user's own manifests place on a registry +/// crate, together with the manifest that placed it (for warnings and +/// diagnostics). +pub struct DirectRequirement { + pub manifest: PathBuf, + pub crate_name: String, + pub req: semver::VersionReq, +} + +/// Reads every version requirement the user's own manifests place on +/// registry crates. +/// +/// `lockfile_dir` is the directory containing `Cargo.lock`, where the root +/// `Cargo.toml` is expected to live. Workspace members are expanded from +/// `[workspace.members]` glob patterns with `[workspace.exclude]` applied, +/// and path dependencies are followed one level further so that a member +/// not listed under `members` is still read. `dependencies`, +/// `dev-dependencies`, `build-dependencies`, and each `[target.*]` table are +/// all walked; path and git dependencies are skipped since they carry no +/// registry version. +/// +/// Neither a missing manifest nor one that fails to parse aborts the run: +/// each produces a warning in the second return value and is simply +/// excluded from the (possibly empty) first. +pub fn load_direct_requirements(lockfile_dir: &Path) -> (Vec, Vec) { + let mut warnings = Vec::new(); + let root_path = lockfile_dir.join("Cargo.toml"); + + if !root_path.is_file() { + warnings.push(format!( + "No Cargo.toml found beside the lockfile at {}; direct dependency requirements were not checked", + lockfile_dir.display() + )); + return (vec![], warnings); + } + + let mut seen = HashSet::new(); + seen.insert(canonical_or(&root_path)); + + let mut manifests = Vec::new(); + match load_manifest(&root_path) { + Ok(root) => { + if let Some(ws) = &root.workspace { + for member_dir in expand_members(lockfile_dir, ws) { + if !seen.insert(canonical_or(&member_dir)) { + continue; + } + let member_path = member_dir.join("Cargo.toml"); + match load_manifest(&member_path) { + Ok(m) => manifests.push((member_path, m)), + Err(e) => warnings.push(e), + } + } + } + manifests.push((root_path, root)); + } + Err(e) => warnings.push(e), + } + + // Follow path dependencies one level further, so members not listed + // under `workspace.members` are still read. + let mut followed = Vec::new(); + for (path, manifest) in &manifests { + let base_dir = path.parent().unwrap_or(lockfile_dir); + for dep_dir in path_dependency_dirs(base_dir, manifest) { + if !seen.insert(canonical_or(&dep_dir)) { + continue; + } + let dep_path = dep_dir.join("Cargo.toml"); + match load_manifest(&dep_path) { + Ok(m) => followed.push((dep_path, m)), + Err(e) => warnings.push(e), + } + } + } + manifests.extend(followed); + + let mut requirements = Vec::new(); + for (path, manifest) in &manifests { + collect_requirements(path, manifest, &mut requirements, &mut warnings); + } + + (requirements, warnings) +} + +fn canonical_or(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + +fn load_manifest(path: &Path) -> Result { + Manifest::from_path(path) + .map_err(|e| format!("Failed to parse manifest {}: {e}", path.display())) +} + +/// Expands `workspace.members` glob patterns against `root_dir`, dropping +/// anything matching `workspace.exclude`. +fn expand_members(root_dir: &Path, workspace: &cargo_toml::Workspace) -> Vec { + let mut dirs = Vec::new(); + + for pattern in &workspace.members { + let full_pattern = root_dir.join(pattern); + let Some(pattern_str) = full_pattern.to_str() else { + continue; + }; + let Ok(paths) = glob::glob(pattern_str) else { + continue; + }; + for entry in paths.flatten() { + if is_excluded(root_dir, &entry, &workspace.exclude) { + continue; + } + dirs.push(entry); + } + } + + dirs +} + +fn is_excluded(root_dir: &Path, member_dir: &Path, exclude: &[String]) -> bool { + let Ok(relative) = member_dir.strip_prefix(root_dir) else { + return false; + }; + exclude.iter().any(|pattern| relative.starts_with(pattern)) +} + +/// Directories of every path dependency declared in `manifest`'s normal, +/// dev, build, or target-specific dependency tables. +fn path_dependency_dirs(base_dir: &Path, manifest: &Manifest) -> Vec { + let mut dirs = Vec::new(); + for deps in all_dep_sets(manifest) { + for dep in deps.values() { + if let Some(detail) = dep.detail() + && let Some(rel_path) = &detail.path + { + dirs.push(base_dir.join(rel_path)); + } + } + } + dirs +} + +fn all_dep_sets(manifest: &Manifest) -> Vec<&DepsSet> { + let mut sets = vec![ + &manifest.dependencies, + &manifest.dev_dependencies, + &manifest.build_dependencies, + ]; + for target in manifest.target.values() { + sets.push(&target.dependencies); + sets.push(&target.dev_dependencies); + sets.push(&target.build_dependencies); + } + sets +} + +fn collect_requirements( + manifest_path: &Path, + manifest: &Manifest, + out: &mut Vec, + warnings: &mut Vec, +) { + for deps in all_dep_sets(manifest) { + for (key, dep) in deps { + collect_one(manifest_path, key, dep, out, warnings); + } + } +} + +fn collect_one( + manifest_path: &Path, + key: &str, + dep: &Dependency, + out: &mut Vec, + warnings: &mut Vec, +) { + // Path and git dependencies aren't registry-versioned. + if let Some(detail) = dep.detail() + && (detail.path.is_some() || detail.git.is_some()) + { + return; + } + + let crate_name = dep.package().unwrap_or(key).to_string(); + + match dep.try_req() { + Ok(req) => out.push(DirectRequirement { + manifest: manifest_path.to_path_buf(), + crate_name, + req: req.clone(), + }), + Err(e) => warnings.push(format!( + "Could not determine requirement for {crate_name} in {}: {e}", + manifest_path.display() + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn write(dir: &Path, rel: &str, contents: &str) -> PathBuf { + let path = dir.join(rel); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(&path, contents).unwrap(); + path + } + + fn find<'a>(reqs: &'a [DirectRequirement], name: &str) -> &'a DirectRequirement { + reqs.iter() + .find(|r| r.crate_name == name) + .unwrap_or_else(|| panic!("no requirement collected for {name}")) + } + + #[test] + fn plain_string_requirement() { + let dir = tempdir().unwrap(); + write( + dir.path(), + "Cargo.toml", + r#" +[package] +name = "root" +version = "0.1.0" + +[dependencies] +serde = "1.0" +"#, + ); + + let (reqs, warnings) = load_direct_requirements(dir.path()); + assert!(warnings.is_empty()); + assert_eq!(find(&reqs, "serde").req, semver::VersionReq::parse("1.0").unwrap()); + } + + #[test] + fn detailed_dependency_with_rename() { + let dir = tempdir().unwrap(); + write( + dir.path(), + "Cargo.toml", + r#" +[package] +name = "root" +version = "0.1.0" + +[dependencies] +my_serde = { package = "serde", version = "1.0" } +"#, + ); + + let (reqs, warnings) = load_direct_requirements(dir.path()); + assert!(warnings.is_empty()); + assert_eq!(reqs.len(), 1); + assert_eq!(reqs[0].crate_name, "serde"); + } + + #[test] + fn workspace_inherited_requirement() { + let dir = tempdir().unwrap(); + write( + dir.path(), + "Cargo.toml", + r#" +[workspace] +members = ["member"] + +[workspace.dependencies] +serde = "1.0" +"#, + ); + write( + dir.path(), + "member/Cargo.toml", + r#" +[package] +name = "member" +version = "0.1.0" + +[dependencies] +serde = { workspace = true } +"#, + ); + + let (reqs, warnings) = load_direct_requirements(dir.path()); + assert!(warnings.is_empty()); + assert_eq!(find(&reqs, "serde").req, semver::VersionReq::parse("1.0").unwrap()); + } + + #[test] + fn target_specific_table() { + let dir = tempdir().unwrap(); + write( + dir.path(), + "Cargo.toml", + r#" +[package] +name = "root" +version = "0.1.0" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" +"#, + ); + + let (reqs, warnings) = load_direct_requirements(dir.path()); + assert!(warnings.is_empty()); + assert_eq!(find(&reqs, "libc").req, semver::VersionReq::parse("0.2").unwrap()); + } + + #[test] + fn workspace_members_glob_with_exclude() { + let dir = tempdir().unwrap(); + write( + dir.path(), + "Cargo.toml", + r#" +[workspace] +members = ["crates/*"] +exclude = ["crates/skip-me"] +"#, + ); + write( + dir.path(), + "crates/a/Cargo.toml", + r#" +[package] +name = "a" +version = "0.1.0" + +[dependencies] +serde = "1.0" +"#, + ); + write( + dir.path(), + "crates/skip-me/Cargo.toml", + r#" +[package] +name = "skip-me" +version = "0.1.0" + +[dependencies] +rand = "0.8" +"#, + ); + + let (reqs, warnings) = load_direct_requirements(dir.path()); + assert!(warnings.is_empty()); + assert!(reqs.iter().any(|r| r.crate_name == "serde")); + assert!(!reqs.iter().any(|r| r.crate_name == "rand")); + } + + #[test] + fn path_dependency_followed_one_level() { + let dir = tempdir().unwrap(); + write( + dir.path(), + "Cargo.toml", + r#" +[package] +name = "root" +version = "0.1.0" + +[dependencies] +helper = { path = "helper" } +"#, + ); + write( + dir.path(), + "helper/Cargo.toml", + r#" +[package] +name = "helper" +version = "0.1.0" + +[dependencies] +serde = "1.0" +"#, + ); + + let (reqs, warnings) = load_direct_requirements(dir.path()); + assert!(warnings.is_empty()); + assert!(reqs.iter().any(|r| r.crate_name == "serde")); + // The path dependency itself is skipped: it's not registry-versioned. + assert!(!reqs.iter().any(|r| r.crate_name == "helper")); + } + + #[test] + fn missing_manifest_degrades_to_a_warning() { + let dir = tempdir().unwrap(); + + let (reqs, warnings) = load_direct_requirements(dir.path()); + assert!(reqs.is_empty()); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("No Cargo.toml")); + } +} From c5deff4ac72e0dbae629e76543336ebe1780d9cc Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Sun, 6 Sep 2026 22:06:37 -0400 Subject: [PATCH 04/34] Make --suggest-fix downgrades resolvable against Cargo.lock and manifests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the newest-by-age-only suggestion with a pipeline: gather every version requirement currently placed on a "too new" package, from lockfile-recorded dependents (via the crates.io sparse index for registry dependents, honouring renames and excluding dev-kind edges) and from the user's own manifests; then walk candidates newest to oldest, within the same caret-compatible zone as the locked version, for the first one every requirement accepts. Candidate filtering deliberately doesn't build a literal `^` requirement to bound the search — that only ever matches versions at or above locked, which would rule out every downgrade. Bounding uses same_compatible_zone instead: cargo's own symmetric notion of "same leading nonzero component" (major, or minor/patch when major is 0). Each violation now produces one Outcome — Suggest, Blocked (naming the blocking package/manifest and its requirement, and whether the blocker itself has a pending suggestion), or NoCompliantVersion — so reporting can't silently drop a case. A dependent whose requirement couldn't be read degrades the suggestion to an "unverified" annotation rather than losing it. The report gains a "no compatible version" section and drops the old blanket disclaimer and cargo-tree-i pointer, both made obsolete by the check the tool now performs; a closing note states what is and isn't verified. Adds --include-prerelease (requires --suggest-fix, per clap's existing requires="min_age_days" pattern for --suggest-fix itself), with help text noting that semver rarely lets a prerelease satisfy a normal requirement, so an empty result under the flag is expected. --- src/main.rs | 39 ++- src/report.rs | 90 +++++- src/suggest.rs | 772 ++++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 752 insertions(+), 149 deletions(-) diff --git a/src/main.rs b/src/main.rs index f097e66..da875fa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -47,6 +47,14 @@ struct Cli { #[arg(long, requires = "min_age_days")] suggest_fix: bool, + /// Consider prerelease versions as suggestion candidates (requires --suggest-fix). Under + /// semver, a requirement matches a prerelease only when it names the identical + /// major.minor.patch with a prerelease part of its own, so this flag usually changes nothing + /// unless a dependent already tracks that exact prerelease line or the locked version is + /// itself a prerelease — an empty result with the flag set is expected, not a bug. + #[arg(long, requires = "suggest_fix")] + include_prerelease: bool, + /// Path to the response cache file (enables caching) #[arg(long, env = "CARGO_OXIDATE_CACHE_PATH")] cache_path: Option, @@ -154,11 +162,32 @@ fn run(cli: Cli) -> Result { report::print_report(&violations); // Generate suggestions if requested - if let Some(min_age) = suggest_min_age - && let Some(suggestions) = - suggest::generate_suggestions(&mut client, &violations, min_age, now) - { - report::print_suggestions(&suggestions); + if let Some(min_age) = suggest_min_age { + let cargo_lock_path = if cli.cargo_lock.is_absolute() { + cli.cargo_lock.clone() + } else { + working_dir.join(&cli.cargo_lock) + }; + let lockfile_dir = cargo_lock_path.parent().unwrap_or(&working_dir); + + let (direct_requirements, manifest_warnings) = + manifest::load_direct_requirements(lockfile_dir); + for warning in &manifest_warnings { + eprintln!(" Warning: {warning}"); + } + + if let Some(outcomes) = suggest::generate_suggestions( + &mut client, + &violations, + &packages, + &direct_requirements, + lockfile_dir, + min_age, + cli.include_prerelease, + now, + ) { + report::print_suggestions(&outcomes); + } } client.finish(); diff --git a/src/report.rs b/src/report.rs index 1cf8ea4..e03c59f 100644 --- a/src/report.rs +++ b/src/report.rs @@ -1,4 +1,4 @@ -use crate::suggest; +use crate::suggest::Outcome; use chrono::{DateTime, Utc}; /// A package's publish date and its resulting age, shared by both @@ -93,27 +93,91 @@ pub fn print_report(violations: &[Violation]) { } } -pub fn print_suggestions(suggestions: &[suggest::Suggestion]) { - if suggestions.is_empty() { +pub fn print_suggestions(outcomes: &[Outcome]) { + if outcomes + .iter() + .all(|o| !matches!(o, Outcome::Suggest { .. })) + { println!("\n⚠️ No compliant versions found for any \"too new\" violations."); println!(" Consider adding these packages to --exempt if they are trusted.\n"); - return; + } else { + println!( + "\n💡 Suggested fixes for \"too new\" violations (apply top to bottom, then re-run):\n" + ); + + for outcome in outcomes { + if let Outcome::Suggest { + package, + locked_version, + suggested_version, + suggested_age_days, + unverified_dependents, + } = outcome + { + let annotation = if unverified_dependents.is_empty() { + String::new() + } else { + format!( + " (requirement of {} unverified)", + unverified_dependents.join(", ") + ) + }; + println!( + " cargo update -p {package}@{locked_version} --precise {suggested_version} # {suggested_age_days} days old{annotation}" + ); + } + } } - println!("\n💡 Suggested fixes for \"too new\" violations:\n"); + let blocked: Vec<&Outcome> = outcomes + .iter() + .filter(|o| !matches!(o, Outcome::Suggest { .. })) + .collect(); - for s in suggestions { - println!( - " cargo update -p {} --precise {} # {} days old", - s.package, s.suggested_version, s.suggested_age_days - ); + if !blocked.is_empty() { + println!("\n⛔ No compatible compliant version:\n"); + for outcome in blocked { + match outcome { + Outcome::Blocked { + package, + locked_version, + newest_compliant, + blocker, + } => { + let source = match &blocker.version { + Some(v) => format!("{} {v}", blocker.name), + None => blocker.name.clone(), + }; + let also_suggested = if blocker.also_suggested { + format!( + " ({} also has a suggested downgrade above; apply it first and re-run)", + blocker.name + ) + } else { + String::new() + }; + println!( + " {package} {locked_version}: newest compliant is {newest_compliant}, but {source} requires {}{also_suggested}", + blocker.req + ); + } + Outcome::NoCompliantVersion { + package, + locked_version, + } => { + println!( + " {package} {locked_version}: no version at least the minimum age old within its compatible range" + ); + } + Outcome::Suggest { .. } => unreachable!(), + } + } } println!( r#" - Note: These suggestions pick the newest version that satisfies --min-age-days. - They may not be compatible with your Cargo.toml version requirements. - For transitive dependencies, run `cargo tree -i ` to find the parent. + Suggestions satisfy every version requirement in Cargo.lock and your manifests. + Source compatibility is not verified: build after applying. "# ); } diff --git a/src/suggest.rs b/src/suggest.rs index 855abd1..f67e613 100644 --- a/src/suggest.rs +++ b/src/suggest.rs @@ -1,44 +1,240 @@ use crate::api::{CrateVersionInfo, CratesIoClient, Transport}; +use crate::lockfile::Package; +use crate::manifest::DirectRequirement; use crate::report::{Violation, ViolationKind}; use chrono::{DateTime, Utc}; +use semver::{Version, VersionReq}; +use std::collections::HashSet; +use std::path::Path; + +/// A version requirement currently placed on a package, and who placed it — +/// either a dependent recorded in the lockfile (`blocker_version: Some`) or +/// one of the user's own manifests (`blocker_version: None`). +pub struct Constraint { + pub blocker_name: String, + pub blocker_version: Option, + pub req: VersionReq, +} + +/// The package and requirement standing in the way of a downgrade. +pub struct Blocker { + pub name: String, + pub version: Option, + pub req: String, + /// Set when `name` is itself a package this run suggests downgrading — + /// applying that suggestion first may unblock this one. + pub also_suggested: bool, +} -pub struct Suggestion { - pub package: String, - pub suggested_version: String, - pub suggested_age_days: i64, +/// The single outcome of checking one "too new" violation: a working +/// suggestion, a package nothing could unblock, or one with no candidate +/// old enough in range at all. +pub enum Outcome { + Suggest { + package: String, + locked_version: String, + suggested_version: String, + suggested_age_days: i64, + unverified_dependents: Vec, + }, + Blocked { + package: String, + locked_version: String, + newest_compliant: String, + blocker: Blocker, + }, + NoCompliantVersion { + package: String, + locked_version: String, + }, } -/// Finds the newest non-yanked version at least `min_age_days` old as of -/// `now`, together with its age in days. -fn find_compliant_version( +/// Whether `a` and `b` fall in the same caret-compatible zone: the same +/// leading nonzero component (major, or minor when major is 0, or patch +/// when both are 0) — cargo's own notion of "compatible" versions. Unlike +/// parsing `^{a}` as a requirement and matching `b` against it, this is +/// symmetric, which is what bounding a *downgrade* search needs: a caret +/// requirement built from the locked version only ever accepts versions at +/// or above it. +fn same_compatible_zone(a: &Version, b: &Version) -> bool { + if a.major != 0 || b.major != 0 { + a.major == b.major + } else if a.minor != 0 || b.minor != 0 { + a.minor == b.minor + } else { + a.patch == b.patch + } +} + +/// Filters `versions` to non-yanked, at least `min_age_days` old as of +/// `now`, within the caret-compatible zone of `locked`, sorted newest first +/// by publish date. Prereleases are excluded unless `allow_prerelease` is +/// set or `locked` is itself a prerelease. +fn filter_candidates( versions: &[CrateVersionInfo], + locked: &Version, min_age_days: u64, now: DateTime, -) -> Option<(String, i64)> { + allow_prerelease: bool, +) -> Vec<(Version, i64)> { let min_age_threshold = now - chrono::Duration::days(min_age_days as i64); + let allow_pre = allow_prerelease || !locked.pre.is_empty(); - versions + let mut candidates: Vec<(Version, DateTime, i64)> = versions .iter() .filter(|v| !v.yanked && v.created_at <= min_age_threshold) - .max_by_key(|v| v.created_at) - .map(|v| { + .filter_map(|v| { + let parsed = Version::parse(&v.num).ok()?; + if !allow_pre && !parsed.pre.is_empty() { + return None; + } + if !same_compatible_zone(locked, &parsed) { + return None; + } let age_days = (now - v.created_at).num_days(); - (v.num.clone(), age_days) + Some((parsed, v.created_at, age_days)) }) + .collect(); + + candidates.sort_by_key(|(_, created_at, _)| std::cmp::Reverse(*created_at)); + candidates.into_iter().map(|(v, _, age)| (v, age)).collect() +} + +enum WalkResult { + Suggest(Version, i64), + Blocked { + newest_compliant: Version, + blocker: Constraint, + }, + NoCompliantVersion, } -/// Generates suggested compliant versions for every "too new" violation. -/// Returns `None` when there are no "too new" violations, so the caller -/// prints nothing; returns `Some` (possibly empty) once the flow has run. +/// Walks `candidates` (already filtered and sorted newest first) looking for +/// the first one every constraint accepts. When none does, reports the +/// newest candidate and the first constraint it fails. +fn walk(candidates: Vec<(Version, i64)>, constraints: Vec) -> WalkResult { + let Some((newest, _)) = candidates.first() else { + return WalkResult::NoCompliantVersion; + }; + let newest = newest.clone(); + + for (version, age_days) in &candidates { + if constraints.iter().all(|c| c.req.matches(version)) { + return WalkResult::Suggest(version.clone(), *age_days); + } + } + + let blocker = constraints + .into_iter() + .find(|c| !c.req.matches(&newest)) + .expect("newest candidate was rejected, so some constraint must reject it"); + WalkResult::Blocked { + newest_compliant: newest, + blocker, + } +} + +/// Every registry-crate dependent (from the lockfile) whose recorded +/// requirement on `name` could not be read, kept as a display name rather +/// than aborting the constraint gathering. +struct GatheredConstraints { + constraints: Vec, + unverified_dependents: Vec, +} + +/// Gathers every version requirement currently placed on `name` at +/// `locked_version`: from lockfile-recorded dependents (via the crates.io +/// sparse index for registry dependents) and from the user's own manifests +/// (via `direct_requirements`, included whenever a non-registry dependent +/// records the edge). +fn gather_constraints( + client: &mut CratesIoClient, + all_packages: &[Package], + direct_requirements: &[DirectRequirement], + working_dir: &Path, + name: &str, + locked_version: &str, +) -> GatheredConstraints { + let mut constraints = Vec::new(); + let mut unverified_dependents = Vec::new(); + let mut manifest_constraints_included = false; + + let dependents = all_packages.iter().filter(|p| { + p.dependencies + .iter() + .any(|d| d.name == name && d.version == locked_version) + }); + + for dependent in dependents { + if dependent.is_registry { + match client.fetch_index_record(&dependent.name) { + Ok(records) => match records.iter().find(|r| r.vers == dependent.version) { + Some(record) => { + for dep in &record.deps { + if dep.kind.as_deref() == Some("dev") { + continue; + } + let real_name = dep.package.as_deref().unwrap_or(&dep.name); + if real_name != name { + continue; + } + match VersionReq::parse(&dep.req) { + Ok(req) => constraints.push(Constraint { + blocker_name: dependent.name.clone(), + blocker_version: Some(dependent.version.clone()), + req, + }), + Err(_) => unverified_dependents.push(dependent.name.clone()), + } + } + } + None => unverified_dependents.push(dependent.name.clone()), + }, + Err(_) => unverified_dependents.push(dependent.name.clone()), + } + } else if !manifest_constraints_included { + manifest_constraints_included = true; + for req in direct_requirements.iter().filter(|r| r.crate_name == name) { + constraints.push(Constraint { + blocker_name: manifest_label(&req.manifest, working_dir), + blocker_version: None, + req: req.req.clone(), + }); + } + } + } + + GatheredConstraints { + constraints, + unverified_dependents, + } +} + +fn manifest_label(path: &Path, working_dir: &Path) -> String { + path.strip_prefix(working_dir) + .unwrap_or(path) + .display() + .to_string() +} + +/// Generates one outcome for every "too new" violation. Returns `None` when +/// there are no "too new" violations, so the caller prints nothing; returns +/// `Some` (possibly empty) once the flow has run. /// -/// A package whose fetch fails, or which has no compliant version, is -/// simply absent from the result rather than aborting the whole operation. +/// A package whose own version list fails to fetch is simply absent from +/// the result, matching the tool's established tolerance for per-package +/// fetch failures. +#[allow(clippy::too_many_arguments)] pub fn generate_suggestions( client: &mut CratesIoClient, violations: &[Violation], + all_packages: &[Package], + direct_requirements: &[DirectRequirement], + working_dir: &Path, min_age_days: u64, + allow_prerelease: bool, now: DateTime, -) -> Option> { +) -> Option> { let too_new: Vec<&Violation> = violations .iter() .filter(|v| matches!(v.kind, ViolationKind::TooNew(_))) @@ -48,33 +244,70 @@ pub fn generate_suggestions( return None; } - let mut suggestions = Vec::new(); + let too_new_names: HashSet<&str> = too_new.iter().map(|v| v.package.as_str()).collect(); + + let mut outcomes = Vec::new(); eprintln!("\nFetching version suggestions..."); for (i, violation) in too_new.iter().enumerate() { eprintln!(" [{}/{}] {}", i + 1, too_new.len(), violation.package); - match client.fetch_all_versions(&violation.package) { - Ok(versions) => { - if let Some((suggested_version, age_days)) = - find_compliant_version(&versions, min_age_days, now) - { - suggestions.push(Suggestion { - package: violation.package.clone(), - suggested_version, - suggested_age_days: age_days, - }); - } - } + let Ok(locked) = Version::parse(&violation.version) else { + continue; + }; + + let versions = match client.fetch_all_versions(&violation.package) { + Ok(versions) => versions, Err(e) => { eprintln!( "\n Warning: failed to fetch versions for {}: {e}", violation.package ); + continue; } - } + }; + + let gathered = gather_constraints( + client, + all_packages, + direct_requirements, + working_dir, + &violation.package, + &violation.version, + ); + let candidates = filter_candidates(&versions, &locked, min_age_days, now, allow_prerelease); + + let outcome = match walk(candidates, gathered.constraints) { + WalkResult::Suggest(version, age_days) => Outcome::Suggest { + package: violation.package.clone(), + locked_version: violation.version.clone(), + suggested_version: version.to_string(), + suggested_age_days: age_days, + unverified_dependents: gathered.unverified_dependents, + }, + WalkResult::Blocked { + newest_compliant, + blocker, + } => Outcome::Blocked { + package: violation.package.clone(), + locked_version: violation.version.clone(), + newest_compliant: newest_compliant.to_string(), + blocker: Blocker { + also_suggested: blocker.blocker_version.is_some() + && too_new_names.contains(blocker.blocker_name.as_str()), + name: blocker.blocker_name, + version: blocker.blocker_version, + req: blocker.req.to_string(), + }, + }, + WalkResult::NoCompliantVersion => Outcome::NoCompliantVersion { + package: violation.package.clone(), + locked_version: violation.version.clone(), + }, + }; + outcomes.push(outcome); } - Some(suggestions) + Some(outcomes) } #[cfg(test)] @@ -86,6 +319,10 @@ mod tests { Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap() } + fn v(s: &str) -> Version { + Version::parse(s).unwrap() + } + fn make_version(version: &str, days_ago: i64, yanked: bool) -> CrateVersionInfo { let created_at = now() - chrono::Duration::days(days_ago); CrateVersionInfo { @@ -95,101 +332,143 @@ mod tests { } } - #[test] - fn test_find_compliant_version_basic() { - let versions = vec![ - make_version("1.0.0", 100, false), - make_version("1.1.0", 50, false), - make_version("1.2.0", 20, false), - make_version("1.3.0", 5, false), - ]; - - let result = find_compliant_version(&versions, 30, now()); - assert!(result.is_some()); - let (version, age_days) = result.unwrap(); - assert_eq!(version, "1.1.0"); // Newest version older than 30 days - assert_eq!(age_days, 50); + fn constraint(req: &str) -> Constraint { + Constraint { + blocker_name: "dep".to_string(), + blocker_version: Some("1.0.0".to_string()), + req: VersionReq::parse(req).unwrap(), + } } - #[test] - fn test_find_compliant_version_filters_yanked() { - let versions = vec![ - make_version("1.0.0", 100, false), - make_version("1.1.0", 50, true), // yanked - make_version("1.2.0", 20, false), - ]; - - let result = find_compliant_version(&versions, 30, now()); - assert!(result.is_some()); - let (version, _) = result.unwrap(); - assert_eq!(version, "1.0.0"); // Skips yanked 1.1.0 - } + mod filter_candidates_tests { + use super::*; - #[test] - fn test_find_compliant_version_no_compliant() { - let versions = vec![ - make_version("1.0.0", 10, false), - make_version("1.1.0", 5, false), - make_version("1.2.0", 2, false), - ]; + #[test] + fn excludes_too_new_yanked_and_out_of_range() { + let versions = vec![ + make_version("1.0.0", 100, false), + make_version("1.1.0", 50, true), // yanked + make_version("1.2.0", 40, false), // compliant, same major as locked + make_version("2.0.0", 200, false), // different major: out of range + make_version("1.3.0", 5, false), // too new (min age 30) + ]; + + let result = filter_candidates(&versions, &v("1.0.0"), 30, now(), false); + let nums: Vec = result.iter().map(|(ver, _)| ver.to_string()).collect(); + // Newest-first by publish date among the two survivors. + assert_eq!(nums, vec!["1.2.0".to_string(), "1.0.0".to_string()]); + } - let result = find_compliant_version(&versions, 30, now()); - assert!(result.is_none()); - } + #[test] + fn sorted_newest_first_by_publish_date() { + let versions = vec![ + make_version("1.0.0", 100, false), + make_version("1.1.0", 200, false), + make_version("1.2.0", 50, false), + ]; + + let result = filter_candidates(&versions, &v("1.0.0"), 30, now(), false); + let nums: Vec = result.iter().map(|(ver, _)| ver.to_string()).collect(); + assert_eq!(nums, vec!["1.2.0", "1.0.0", "1.1.0"]); + } - #[test] - fn test_find_compliant_version_all_yanked() { - let versions = vec![ - make_version("1.0.0", 100, true), - make_version("1.1.0", 50, true), - ]; + #[test] + fn prerelease_excluded_by_default() { + let versions = vec![make_version("1.1.0-beta.1", 100, false)]; + let result = filter_candidates(&versions, &v("1.0.0"), 30, now(), false); + assert!(result.is_empty()); + } - let result = find_compliant_version(&versions, 30, now()); - assert!(result.is_none()); - } + #[test] + fn prerelease_included_with_flag_when_range_matches() { + // Same compatible zone (1.0.0), prerelease allowed by the flag. + let versions = vec![make_version("1.0.0-beta.2", 100, false)]; + let result = filter_candidates(&versions, &v("1.0.0-beta.1"), 30, now(), true); + assert_eq!(result.len(), 1); + assert_eq!(result[0].0.to_string(), "1.0.0-beta.2"); + } - #[test] - fn test_find_compliant_version_empty() { - let versions: Vec = vec![]; - let result = find_compliant_version(&versions, 30, now()); - assert!(result.is_none()); + #[test] + fn prerelease_allowed_when_locked_is_itself_a_prerelease() { + let versions = vec![make_version("1.0.0-beta.2", 100, false)]; + let result = filter_candidates(&versions, &v("1.0.0-beta.1"), 30, now(), false); + assert_eq!(result.len(), 1); + } } - #[test] - fn test_find_compliant_version_exact_threshold() { - let versions = vec![ - make_version("1.0.0", 30, false), - make_version("1.1.0", 29, false), - ]; - - let result = find_compliant_version(&versions, 30, now()); - assert!(result.is_some()); - let (version, age_days) = result.unwrap(); - assert_eq!(version, "1.0.0"); // Exactly 30 days should be compliant - assert_eq!(age_days, 30); - } + mod walk_tests { + use super::*; + + #[test] + fn newest_accepted_when_every_constraint_matches() { + let candidates = vec![(v("1.3.0"), 5), (v("1.2.0"), 20)]; + let constraints = vec![constraint("^1.2")]; + + match walk(candidates, constraints) { + WalkResult::Suggest(version, age) => { + assert_eq!(version.to_string(), "1.3.0"); + assert_eq!(age, 5); + } + _ => panic!("expected Suggest"), + } + } + + #[test] + fn walk_continues_to_older_version_when_newest_is_rejected() { + let candidates = vec![(v("1.3.0"), 5), (v("1.2.0"), 20), (v("1.1.0"), 40)]; + // `~1.1` narrows to the 1.1.x line, so only the oldest candidate + // satisfies it — the walk must skip past the two newer ones. + let constraints = vec![constraint("~1.1")]; + match walk(candidates, constraints) { + WalkResult::Suggest(version, _) => assert_eq!(version.to_string(), "1.1.0"), + _ => panic!("expected Suggest"), + } + } - #[test] - fn test_find_compliant_version_picks_newest_compliant() { - let versions = vec![ - make_version("1.0.0", 100, false), - make_version("1.1.0", 90, false), - make_version("1.2.0", 80, false), - make_version("1.3.0", 70, false), - make_version("1.4.0", 10, false), // Too new - ]; - - let result = find_compliant_version(&versions, 50, now()); - assert!(result.is_some()); - let (version, age_days) = result.unwrap(); - assert_eq!(version, "1.3.0"); // Newest among compliant versions - assert_eq!(age_days, 70); + #[test] + fn blocked_when_no_candidate_satisfies_every_constraint() { + let candidates = vec![(v("1.3.0"), 5), (v("1.2.0"), 20)]; + let constraints = vec![constraint("^2.0")]; + + match walk(candidates, constraints) { + WalkResult::Blocked { + newest_compliant, + blocker, + } => { + assert_eq!(newest_compliant.to_string(), "1.3.0"); + assert_eq!(blocker.blocker_name, "dep"); + assert_eq!(blocker.req.to_string(), "^2.0"); + } + _ => panic!("expected Blocked"), + } + } + + #[test] + fn no_compliant_version_when_candidates_empty() { + match walk(vec![], vec![constraint("^1.0")]) { + WalkResult::NoCompliantVersion => {} + _ => panic!("expected NoCompliantVersion"), + } + } + + #[test] + fn no_constraints_picks_newest_by_age() { + let candidates = vec![(v("1.3.0"), 5), (v("1.2.0"), 20)]; + match walk(candidates, vec![]) { + WalkResult::Suggest(version, age) => { + assert_eq!(version.to_string(), "1.3.0"); + assert_eq!(age, 5); + } + _ => panic!("expected Suggest"), + } + } } mod generate_suggestions_tests { use super::*; use crate::api::RetryPolicy; - use crate::api::test_support::{FakeTransport, ScriptedResponse, versions_url}; + use crate::api::test_support::{FakeTransport, ScriptedResponse, index_url, versions_url}; + use crate::lockfile::PackageRef; use crate::report::Aged; use std::num::NonZeroU32; use std::time::Duration; @@ -197,6 +476,8 @@ mod tests { trait FakeTransportExt { fn ok(&self, name: &str, versions_json: &str); fn error(&self, name: &str); + fn index_ok(&self, name: &str, records_ndjson: &str); + fn index_error(&self, name: &str); } impl FakeTransportExt for FakeTransport { @@ -210,6 +491,17 @@ mod tests { fn error(&self, name: &str) { self.push(&versions_url(name), ScriptedResponse::Error); } + + fn index_ok(&self, name: &str, records_ndjson: &str) { + self.push( + &index_url(name), + ScriptedResponse::Http(200, records_ndjson.to_string()), + ); + } + + fn index_error(&self, name: &str) { + self.push(&index_url(name), ScriptedResponse::Error); + } } fn versions_body(entries: &[(&str, i64, bool)], now: DateTime) -> String { @@ -226,8 +518,6 @@ mod tests { format!(r#"{{"versions":[{}]}}"#, versions.join(",")) } - /// Builds a client with retry/pacing delays zeroed out, so the test - /// suite doesn't sleep. fn fast_client(transport: FakeTransport) -> CratesIoClient { CratesIoClient::with_transport( transport, @@ -241,10 +531,10 @@ mod tests { ) } - fn too_new(package: &str) -> Violation { + fn too_new(package: &str, locked_version: &str) -> Violation { Violation { package: package.to_string(), - version: "1.0.0".to_string(), + version: locked_version.to_string(), kind: ViolationKind::TooNew(Aged { published: now(), age_days: 1, @@ -263,6 +553,21 @@ mod tests { } } + fn pkg(name: &str, version: &str, deps: &[(&str, &str)]) -> Package { + Package { + name: name.to_string(), + version: version.to_string(), + is_registry: true, + dependencies: deps + .iter() + .map(|(n, v)| PackageRef { + name: n.to_string(), + version: v.to_string(), + }) + .collect(), + } + } + #[test] fn only_too_new_violations_are_fetched() { let transport = FakeTransport::default(); @@ -271,11 +576,21 @@ mod tests { // transport would panic. let mut client = fast_client(transport); - let violations = vec![too_new("serde"), too_old("syn")]; - let suggestions = generate_suggestions(&mut client, &violations, 30, now()).unwrap(); + let violations = vec![too_new("serde", "1.0.0"), too_old("syn")]; + let packages = vec![pkg("serde", "1.0.0", &[])]; + let outcomes = generate_suggestions( + &mut client, + &violations, + &packages, + &[], + Path::new("/work"), + 30, + false, + now(), + ) + .unwrap(); - assert_eq!(suggestions.len(), 1); - assert_eq!(suggestions[0].package, "serde"); + assert_eq!(outcomes.len(), 1); } #[test] @@ -285,23 +600,45 @@ mod tests { transport.ok("syn", &versions_body(&[("1.0.0", 50, false)], now())); let mut client = fast_client(transport); - let violations = vec![too_new("serde"), too_new("syn")]; - let suggestions = generate_suggestions(&mut client, &violations, 30, now()).unwrap(); + let violations = vec![too_new("serde", "1.0.0"), too_new("syn", "1.0.0")]; + let packages = vec![pkg("serde", "1.0.0", &[]), pkg("syn", "1.0.0", &[])]; + let outcomes = generate_suggestions( + &mut client, + &violations, + &packages, + &[], + Path::new("/work"), + 30, + false, + now(), + ) + .unwrap(); - assert_eq!(suggestions.len(), 1); - assert_eq!(suggestions[0].package, "syn"); + assert_eq!(outcomes.len(), 1); + assert!(matches!(&outcomes[0], Outcome::Suggest { package, .. } if package == "syn")); } #[test] - fn no_compliant_version_produces_no_suggestion() { + fn no_compliant_version_reports_the_no_candidate_outcome() { let transport = FakeTransport::default(); transport.ok("serde", &versions_body(&[("1.0.0", 5, false)], now())); let mut client = fast_client(transport); - let violations = vec![too_new("serde")]; - let suggestions = generate_suggestions(&mut client, &violations, 30, now()).unwrap(); + let violations = vec![too_new("serde", "1.0.0")]; + let packages = vec![pkg("serde", "1.0.0", &[])]; + let outcomes = generate_suggestions( + &mut client, + &violations, + &packages, + &[], + Path::new("/work"), + 30, + false, + now(), + ) + .unwrap(); - assert!(suggestions.is_empty()); + assert!(matches!(outcomes[0], Outcome::NoCompliantVersion { .. })); } #[test] @@ -310,9 +647,182 @@ mod tests { let mut client = fast_client(transport); let violations = vec![too_old("syn")]; - let suggestions = generate_suggestions(&mut client, &violations, 30, now()); + let outcomes = generate_suggestions( + &mut client, + &violations, + &[], + &[], + Path::new("/work"), + 30, + false, + now(), + ); + + assert!(outcomes.is_none()); + } + + #[test] + fn transitive_dependent_requirement_blocks_the_newest_candidate() { + // "app" depends on serde 1.5.0; serde's index says app requires ^1.5. + let transport = FakeTransport::default(); + transport.ok( + "serde", + &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), + ); + transport.index_ok( + "app", + r#"{"vers":"1.0.0","deps":[{"name":"serde","req":"^1.5"}]}"#, + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("serde", "1.5.0")]; + let packages = vec![ + pkg("serde", "1.5.0", &[]), + pkg("app", "1.0.0", &[("serde", "1.5.0")]), + ]; + let outcomes = generate_suggestions( + &mut client, + &violations, + &packages, + &[], + Path::new("/work"), + 30, + false, + now(), + ) + .unwrap(); + + match &outcomes[0] { + Outcome::Blocked { + newest_compliant, + blocker, + .. + } => { + assert_eq!(newest_compliant, "1.4.0"); + assert_eq!(blocker.name, "app"); + assert_eq!(blocker.req, "^1.5"); + } + _ => panic!("expected Blocked"), + } + } + + #[test] + fn dev_kind_edge_from_a_transitive_dependent_is_ignored() { + let transport = FakeTransport::default(); + transport.ok("serde", &versions_body(&[("1.4.0", 50, false)], now())); + transport.index_ok( + "app", + r#"{"vers":"1.0.0","deps":[{"name":"serde","req":"^1.5","kind":"dev"}]}"#, + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("serde", "1.4.0")]; + let packages = vec![ + pkg("serde", "1.4.0", &[]), + pkg("app", "1.0.0", &[("serde", "1.4.0")]), + ]; + let outcomes = generate_suggestions( + &mut client, + &violations, + &packages, + &[], + Path::new("/work"), + 30, + false, + now(), + ) + .unwrap(); + + assert!(matches!(outcomes[0], Outcome::Suggest { .. })); + } + + #[test] + fn renamed_dependency_is_matched_by_its_real_name() { + let transport = FakeTransport::default(); + transport.ok("serde", &versions_body(&[("1.4.0", 50, false)], now())); + transport.index_ok( + "app", + r#"{"vers":"1.0.0","deps":[{"name":"my_serde","package":"serde","req":"^1.5"}]}"#, + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("serde", "1.5.0")]; + let packages = vec![ + pkg("serde", "1.5.0", &[]), + pkg("app", "1.0.0", &[("serde", "1.5.0")]), + ]; + let outcomes = generate_suggestions( + &mut client, + &violations, + &packages, + &[], + Path::new("/work"), + 30, + false, + now(), + ) + .unwrap(); + + assert!(matches!(outcomes[0], Outcome::Blocked { .. })); + } + + #[test] + fn failed_index_fetch_yields_suggestion_with_unverified_annotation() { + let transport = FakeTransport::default(); + transport.ok("serde", &versions_body(&[("1.4.0", 50, false)], now())); + transport.index_error("app"); + let mut client = fast_client(transport); + + let violations = vec![too_new("serde", "1.4.0")]; + let packages = vec![ + pkg("serde", "1.4.0", &[]), + pkg("app", "1.0.0", &[("serde", "1.4.0")]), + ]; + let outcomes = generate_suggestions( + &mut client, + &violations, + &packages, + &[], + Path::new("/work"), + 30, + false, + now(), + ) + .unwrap(); + + match &outcomes[0] { + Outcome::Suggest { + unverified_dependents, + .. + } => assert_eq!(unverified_dependents, &["app".to_string()]), + _ => panic!("expected Suggest"), + } + } + + #[test] + fn a_package_locked_at_two_versions_produces_two_outcomes() { + let transport = FakeTransport::default(); + transport.ok( + "serde", + &versions_body(&[("1.0.0", 50, false), ("2.0.0", 50, false)], now()), + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("serde", "1.0.0"), too_new("serde", "2.0.0")]; + let packages = vec![pkg("serde", "1.0.0", &[]), pkg("serde", "2.0.0", &[])]; + let outcomes = generate_suggestions( + &mut client, + &violations, + &packages, + &[], + Path::new("/work"), + 30, + false, + now(), + ) + .unwrap(); - assert!(suggestions.is_none()); + assert_eq!(outcomes.len(), 2); } } } From aaaef89088eda31e4383e9742a0ceac26bb26897 Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Sun, 6 Sep 2026 22:09:07 -0400 Subject: [PATCH 05/34] Add fixture-based end-to-end test and document --suggest-fix Drives lockfile intake, manifest reading, and generate_suggestions together over a committed fixture (a one-member workspace and a hand-written Cargo.lock), asserting all four outcome kinds in one pass: a suggestion, a package blocked by a manifest requirement, one blocked by a transitive dependent's index requirement, and one with nothing old enough in its compatible range. README documents --suggest-fix's actual guarantee and adds --include-prerelease. --- README.md | 22 +++ src/api.rs | 5 +- src/cache.rs | 6 +- src/main.rs | 23 +++ src/manifest.rs | 15 +- src/suggest.rs | 174 ++++++++++++++++++ tests/fixtures/suggest_fix_e2e/Cargo.lock | 42 +++++ tests/fixtures/suggest_fix_e2e/Cargo.toml | 2 + tests/fixtures/suggest_fix_e2e/app/Cargo.toml | 8 + 9 files changed, 289 insertions(+), 8 deletions(-) create mode 100644 tests/fixtures/suggest_fix_e2e/Cargo.lock create mode 100644 tests/fixtures/suggest_fix_e2e/Cargo.toml create mode 100644 tests/fixtures/suggest_fix_e2e/app/Cargo.toml diff --git a/README.md b/README.md index 2b8d486..7a6823b 100644 --- a/README.md +++ b/README.md @@ -28,11 +28,33 @@ cargo-oxidate Cargo.lock --min-age-days 14 --max-age-days 730 | `--exclude-missing` | Don't flag packages with unknown publish dates | | `--timeout N` | HTTP timeout in seconds (default: 10) | | `--suggest-fix` | For "too new" violations, suggest `cargo update` commands to downgrade | +| `--include-prerelease` | Consider prerelease versions as suggestion candidates (requires `--suggest-fix`) | | `--cache-path PATH` | Enable response caching at PATH (or set `CARGO_OXIDATE_CACHE_PATH`) | | `--cache-max-age-hours N` | Max age for cached version listings (default: 24) | At least one of `--min-age-days` or `--max-age-days` must be specified. +## `--suggest-fix` + +For every "too new" violation, `--suggest-fix` walks candidate versions newest to oldest, +within the same compatible range as the version currently locked, and suggests the first one +that satisfies every version requirement currently placed on that package — from other packages +in `Cargo.lock` (checked against the crates.io index) and from your own `Cargo.toml` manifests, +workspace members included. Every printed `cargo update` command is one cargo will accept. + +Source-level compatibility — whether the project still compiles — is not checked, since that +would require a build. Build after applying a suggestion. + +A package with no candidate that satisfies every requirement is reported separately, naming the +package and requirement standing in the way, so you know what would have to change. Since each +suggestion is computed independently against the current lockfile, apply them top to bottom and +re-run. + +`--include-prerelease` allows a prerelease version to be suggested. Under semver, a requirement +only matches a prerelease when it names the identical `major.minor.patch` with a prerelease part +of its own, so this rarely changes the outcome for a package with real dependents — expect it to +matter only for a package with no lockfile dependents, or one already tracking a prerelease line. + ## Exit Codes - `0` — No violations found diff --git a/src/api.rs b/src/api.rs index b5f140e..a715194 100644 --- a/src/api.rs +++ b/src/api.rs @@ -472,10 +472,7 @@ pub(crate) mod test_support { } pub(crate) fn index_url(name: &str) -> String { - format!( - "https://index.crates.io/{}", - super::sparse_index_path(name) - ) + format!("https://index.crates.io/{}", super::sparse_index_path(name)) } } diff --git a/src/cache.rs b/src/cache.rs index 542a5dc..e5f9ac5 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -306,7 +306,11 @@ mod tests { .with_timezone(&Utc) ) ); - assert!(cache.get_index_records("serde", Duration::hours(1)).is_none()); + assert!( + cache + .get_index_records("serde", Duration::hours(1)) + .is_none() + ); } #[test] diff --git a/src/main.rs b/src/main.rs index da875fa..43da030 100644 --- a/src/main.rs +++ b/src/main.rs @@ -211,4 +211,27 @@ mod tests { let result = Cli::try_parse_from(["cargo-oxidate", "--suggest-fix", "--min-age-days", "7"]); assert!(result.is_ok()); } + + #[test] + fn include_prerelease_without_suggest_fix_fails_to_parse() { + let result = Cli::try_parse_from([ + "cargo-oxidate", + "--include-prerelease", + "--min-age-days", + "7", + ]); + assert!(result.is_err()); + } + + #[test] + fn include_prerelease_with_suggest_fix_parses() { + let result = Cli::try_parse_from([ + "cargo-oxidate", + "--suggest-fix", + "--min-age-days", + "7", + "--include-prerelease", + ]); + assert!(result.is_ok()); + } } diff --git a/src/manifest.rs b/src/manifest.rs index 102ba55..c0cabcc 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -237,7 +237,10 @@ serde = "1.0" let (reqs, warnings) = load_direct_requirements(dir.path()); assert!(warnings.is_empty()); - assert_eq!(find(&reqs, "serde").req, semver::VersionReq::parse("1.0").unwrap()); + assert_eq!( + find(&reqs, "serde").req, + semver::VersionReq::parse("1.0").unwrap() + ); } #[test] @@ -291,7 +294,10 @@ serde = { workspace = true } let (reqs, warnings) = load_direct_requirements(dir.path()); assert!(warnings.is_empty()); - assert_eq!(find(&reqs, "serde").req, semver::VersionReq::parse("1.0").unwrap()); + assert_eq!( + find(&reqs, "serde").req, + semver::VersionReq::parse("1.0").unwrap() + ); } #[test] @@ -312,7 +318,10 @@ libc = "0.2" let (reqs, warnings) = load_direct_requirements(dir.path()); assert!(warnings.is_empty()); - assert_eq!(find(&reqs, "libc").req, semver::VersionReq::parse("0.2").unwrap()); + assert_eq!( + find(&reqs, "libc").req, + semver::VersionReq::parse("0.2").unwrap() + ); } #[test] diff --git a/src/suggest.rs b/src/suggest.rs index f67e613..8889362 100644 --- a/src/suggest.rs +++ b/src/suggest.rs @@ -825,4 +825,178 @@ mod tests { assert_eq!(outcomes.len(), 2); } } + + /// Drives the whole pipeline — lockfile intake, manifest reading, and + /// `generate_suggestions` — over the committed fixture at + /// `tests/fixtures/suggest_fix_e2e/`, a two-member workspace-ish layout + /// (a real workspace with one member) plus a hand-written lockfile. + /// Covers all four outcome kinds at once: a suggestion, a package + /// blocked by a manifest requirement, one blocked by a transitive + /// dependent, and one with nothing old enough in range. + mod end_to_end_tests { + use super::*; + use crate::api::RetryPolicy; + use crate::api::test_support::{FakeTransport, ScriptedResponse, index_url, versions_url}; + use crate::manifest::load_direct_requirements; + use crate::report::Aged; + use std::num::NonZeroU32; + use std::path::PathBuf; + use std::time::Duration; + + fn fixture_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/suggest_fix_e2e") + } + + fn versions_body(entries: &[(&str, i64, bool)], now: DateTime) -> String { + let versions: Vec = entries + .iter() + .map(|(num, days_ago, yanked)| { + let created_at = now - chrono::Duration::days(*days_ago); + format!( + r#"{{"num":"{num}","created_at":"{}","yanked":{yanked}}}"#, + created_at.to_rfc3339() + ) + }) + .collect(); + format!(r#"{{"versions":[{}]}}"#, versions.join(",")) + } + + fn too_new(package: &str, locked_version: &str) -> Violation { + Violation { + package: package.to_string(), + version: locked_version.to_string(), + kind: ViolationKind::TooNew(Aged { + published: now() - chrono::Duration::days(5), + age_days: 5, + }), + } + } + + #[test] + fn covers_a_suggestion_two_blocked_kinds_and_no_compliant_version() { + let dir = fixture_dir(); + let packages = crate::lockfile::load(Path::new("Cargo.lock"), &dir).unwrap(); + let (direct_requirements, warnings) = load_direct_requirements(&dir); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + let transport = FakeTransport::default(); + transport.push( + &versions_url("alpha"), + ScriptedResponse::Http( + 200, + versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), + ), + ); + transport.push( + &versions_url("beta"), + ScriptedResponse::Http( + 200, + versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), + ), + ); + transport.push( + &versions_url("gamma"), + ScriptedResponse::Http( + 200, + versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), + ), + ); + transport.push( + &versions_url("delta"), + ScriptedResponse::Http(200, versions_body(&[("1.5.0", 5, false)], now())), + ); + transport.push( + &index_url("consumer"), + ScriptedResponse::Http( + 200, + r#"{"vers":"2.0.0","deps":[{"name":"gamma","req":"^1.5"}]}"#.to_string(), + ), + ); + + let mut client = CratesIoClient::with_transport( + transport, + None, + 24, + RetryPolicy { + retry_count: NonZeroU32::new(1).unwrap(), + retry_delay: Duration::from_millis(0), + pacing_delay: Duration::from_millis(0), + }, + ); + + let violations = vec![ + too_new("alpha", "1.5.0"), + too_new("beta", "1.5.0"), + too_new("gamma", "1.5.0"), + too_new("delta", "1.5.0"), + ]; + + let outcomes = generate_suggestions( + &mut client, + &violations, + &packages, + &direct_requirements, + &dir, + 30, + false, + now(), + ) + .unwrap(); + + assert_eq!(outcomes.len(), 4); + + match &outcomes[0] { + Outcome::Suggest { + package, + suggested_version, + suggested_age_days, + unverified_dependents, + .. + } => { + assert_eq!(package, "alpha"); + assert_eq!(suggested_version, "1.4.0"); + assert_eq!(*suggested_age_days, 50); + assert!(unverified_dependents.is_empty()); + } + _ => panic!("expected alpha to be Suggest"), + } + + match &outcomes[1] { + Outcome::Blocked { + package, + newest_compliant, + blocker, + .. + } => { + assert_eq!(package, "beta"); + assert_eq!(newest_compliant, "1.4.0"); + assert_eq!(blocker.name, "app/Cargo.toml"); + assert_eq!(blocker.version, None); + assert_eq!(blocker.req, "^1.5"); + } + _ => panic!("expected beta to be Blocked"), + } + + match &outcomes[2] { + Outcome::Blocked { + package, + newest_compliant, + blocker, + .. + } => { + assert_eq!(package, "gamma"); + assert_eq!(newest_compliant, "1.4.0"); + assert_eq!(blocker.name, "consumer"); + assert_eq!(blocker.version, Some("2.0.0".to_string())); + assert_eq!(blocker.req, "^1.5"); + } + _ => panic!("expected gamma to be Blocked"), + } + + assert!(matches!( + &outcomes[3], + Outcome::NoCompliantVersion { package, .. } if package == "delta" + )); + } + } } diff --git a/tests/fixtures/suggest_fix_e2e/Cargo.lock b/tests/fixtures/suggest_fix_e2e/Cargo.lock new file mode 100644 index 0000000..5bc11b8 --- /dev/null +++ b/tests/fixtures/suggest_fix_e2e/Cargo.lock @@ -0,0 +1,42 @@ +version = 4 + +[[package]] +name = "app" +version = "0.1.0" +dependencies = [ + "alpha", + "beta", +] + +[[package]] +name = "alpha" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0000000000000000000000000000000000000000000000000000000000000000" + +[[package]] +name = "beta" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0000000000000000000000000000000000000000000000000000000000000000" + +[[package]] +name = "gamma" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0000000000000000000000000000000000000000000000000000000000000000" + +[[package]] +name = "delta" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0000000000000000000000000000000000000000000000000000000000000000" + +[[package]] +name = "consumer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0000000000000000000000000000000000000000000000000000000000000000" +dependencies = [ + "gamma", +] diff --git a/tests/fixtures/suggest_fix_e2e/Cargo.toml b/tests/fixtures/suggest_fix_e2e/Cargo.toml new file mode 100644 index 0000000..cfb525e --- /dev/null +++ b/tests/fixtures/suggest_fix_e2e/Cargo.toml @@ -0,0 +1,2 @@ +[workspace] +members = ["app"] diff --git a/tests/fixtures/suggest_fix_e2e/app/Cargo.toml b/tests/fixtures/suggest_fix_e2e/app/Cargo.toml new file mode 100644 index 0000000..bfee6e9 --- /dev/null +++ b/tests/fixtures/suggest_fix_e2e/app/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "app" +version = "0.1.0" +edition = "2021" + +[dependencies] +alpha = "1.0" +beta = "^1.5" From 34046237e0605ad2646ea43f623e3847665b6eb9 Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Sun, 6 Sep 2026 22:21:24 -0400 Subject: [PATCH 06/34] Apply code review fixes: also_suggested, manifest scoping, report output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - also_suggested was set whenever the blocker's name appeared in the too-new set, regardless of whether the blocker's own walk actually resolved to a suggestion. It's now computed in a second pass once every outcome is known, against the set of packages that actually got Outcome::Suggest. - Manifest-derived constraints were matched by crate name alone, so a workspace member's requirement on a crate leaked into another member's unrelated locked version of a same-named crate. Direct requirements now carry the manifest's own declaring_package, and gather_constraints scopes each dependent to its own manifest. - print_suggestions's "no compliant versions found" branch fired whenever there were zero Suggest outcomes, even when Blocked outcomes existed with real compliant versions — printing that contradictory message right above the accurate "no compatible compliant version" section. Restructured around has_suggestion; the closing "suggestions satisfy..." footer only prints when there is at least one suggestion to make a claim about. - gather_constraints rescanned every lockfile package per violation; a dependents index is now built once per run. - main.rs's ad hoc lockfile-path resolution duplicated logic already in lockfile::load; both now share lockfile::resolve_path. - Restored a doc comment on fast_client that an earlier edit dropped. Two new regression tests cover the also_suggested and manifest-scoping fixes directly; both failed before the fix and pass after. --- src/lockfile.rs | 19 +++-- src/main.rs | 6 +- src/manifest.rs | 17 ++++- src/report.rs | 22 +++--- src/suggest.rs | 183 ++++++++++++++++++++++++++++++++++++++++++++---- 5 files changed, 211 insertions(+), 36 deletions(-) diff --git a/src/lockfile.rs b/src/lockfile.rs index 1038d47..83d5660 100644 --- a/src/lockfile.rs +++ b/src/lockfile.rs @@ -1,5 +1,16 @@ use anyhow::{Context, Result}; -use std::path::Path; +use std::path::{Path, PathBuf}; + +/// Resolves a possibly-relative lockfile path against `working_dir`, without +/// touching the filesystem. Shared by `load` (which then canonicalizes and +/// validates it) and by callers that just need the lockfile's directory. +pub fn resolve_path(path: &Path, working_dir: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + working_dir.join(path) + } +} /// A name/version pair identifying a package, used both for lockfile entries /// and for the dependency edges between them. @@ -31,11 +42,7 @@ pub struct Package { /// (lockfiles may omit a dependency's version when only one instance of it /// exists), so every `PackageRef` here already carries one. pub fn load(path: &Path, working_dir: &Path) -> Result> { - let resolved = if path.is_absolute() { - path.to_path_buf() - } else { - working_dir.join(path) - }; + let resolved = resolve_path(path, working_dir); // Canonicalize to resolve symlinks and ".." components // (file must exist for canonicalize to succeed) diff --git a/src/main.rs b/src/main.rs index 43da030..0990349 100644 --- a/src/main.rs +++ b/src/main.rs @@ -163,11 +163,7 @@ fn run(cli: Cli) -> Result { // Generate suggestions if requested if let Some(min_age) = suggest_min_age { - let cargo_lock_path = if cli.cargo_lock.is_absolute() { - cli.cargo_lock.clone() - } else { - working_dir.join(&cli.cargo_lock) - }; + let cargo_lock_path = lockfile::resolve_path(&cli.cargo_lock, &working_dir); let lockfile_dir = cargo_lock_path.parent().unwrap_or(&working_dir); let (direct_requirements, manifest_warnings) = diff --git a/src/manifest.rs b/src/manifest.rs index c0cabcc..71b2566 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -4,9 +4,13 @@ use std::path::{Path, PathBuf}; /// One version requirement the user's own manifests place on a registry /// crate, together with the manifest that placed it (for warnings and -/// diagnostics). +/// diagnostics) and the name of the package that manifest declares — +/// callers use this to scope a requirement to the lockfile dependent that +/// actually placed it, rather than to every manifest in the workspace that +/// happens to mention the same crate name. pub struct DirectRequirement { pub manifest: PathBuf, + pub declaring_package: String, pub crate_name: String, pub req: semver::VersionReq, } @@ -163,15 +167,23 @@ fn collect_requirements( out: &mut Vec, warnings: &mut Vec, ) { + // A manifest with no [package] table (a pure workspace root) declares no + // crate identity, so it can never be a lockfile dependent — nothing it + // lists (ordinarily nothing) could be scoped to it correctly. + let Some(declaring_package) = manifest.package.as_ref().map(|p| p.name().to_string()) else { + return; + }; + for deps in all_dep_sets(manifest) { for (key, dep) in deps { - collect_one(manifest_path, key, dep, out, warnings); + collect_one(manifest_path, &declaring_package, key, dep, out, warnings); } } } fn collect_one( manifest_path: &Path, + declaring_package: &str, key: &str, dep: &Dependency, out: &mut Vec, @@ -189,6 +201,7 @@ fn collect_one( match dep.try_req() { Ok(req) => out.push(DirectRequirement { manifest: manifest_path.to_path_buf(), + declaring_package: declaring_package.to_string(), crate_name, req: req.clone(), }), diff --git a/src/report.rs b/src/report.rs index e03c59f..71a3da2 100644 --- a/src/report.rs +++ b/src/report.rs @@ -94,13 +94,17 @@ pub fn print_report(violations: &[Violation]) { } pub fn print_suggestions(outcomes: &[Outcome]) { - if outcomes - .iter() - .all(|o| !matches!(o, Outcome::Suggest { .. })) - { + if outcomes.is_empty() { println!("\n⚠️ No compliant versions found for any \"too new\" violations."); println!(" Consider adding these packages to --exempt if they are trusted.\n"); - } else { + return; + } + + let has_suggestion = outcomes + .iter() + .any(|o| matches!(o, Outcome::Suggest { .. })); + + if has_suggestion { println!( "\n💡 Suggested fixes for \"too new\" violations (apply top to bottom, then re-run):\n" ); @@ -174,10 +178,12 @@ pub fn print_suggestions(outcomes: &[Outcome]) { } } - println!( - r#" + if has_suggestion { + println!( + r#" Suggestions satisfy every version requirement in Cargo.lock and your manifests. Source compatibility is not verified: build after applying. "# - ); + ); + } } diff --git a/src/suggest.rs b/src/suggest.rs index 8889362..dcdf32f 100644 --- a/src/suggest.rs +++ b/src/suggest.rs @@ -4,7 +4,7 @@ use crate::manifest::DirectRequirement; use crate::report::{Violation, ViolationKind}; use chrono::{DateTime, Utc}; use semver::{Version, VersionReq}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::path::Path; /// A version requirement currently placed on a package, and who placed it — @@ -142,6 +142,24 @@ struct GatheredConstraints { unverified_dependents: Vec, } +/// Maps `(name, version)` to every lockfile package that depends on it, +/// built once per run so `gather_constraints` doesn't rescan every package +/// for every "too new" violation. +type DependentsIndex<'a> = HashMap<(&'a str, &'a str), Vec<&'a Package>>; + +fn build_dependents_index(all_packages: &[Package]) -> DependentsIndex<'_> { + let mut index: DependentsIndex = HashMap::new(); + for pkg in all_packages { + for dep in &pkg.dependencies { + index + .entry((dep.name.as_str(), dep.version.as_str())) + .or_default() + .push(pkg); + } + } + index +} + /// Gathers every version requirement currently placed on `name` at /// `locked_version`: from lockfile-recorded dependents (via the crates.io /// sparse index for registry dependents) and from the user's own manifests @@ -149,7 +167,7 @@ struct GatheredConstraints { /// records the edge). fn gather_constraints( client: &mut CratesIoClient, - all_packages: &[Package], + dependents_index: &DependentsIndex, direct_requirements: &[DirectRequirement], working_dir: &Path, name: &str, @@ -157,13 +175,12 @@ fn gather_constraints( ) -> GatheredConstraints { let mut constraints = Vec::new(); let mut unverified_dependents = Vec::new(); - let mut manifest_constraints_included = false; - let dependents = all_packages.iter().filter(|p| { - p.dependencies - .iter() - .any(|d| d.name == name && d.version == locked_version) - }); + let dependents = dependents_index + .get(&(name, locked_version)) + .into_iter() + .flatten() + .copied(); for dependent in dependents { if dependent.is_registry { @@ -192,9 +209,15 @@ fn gather_constraints( }, Err(_) => unverified_dependents.push(dependent.name.clone()), } - } else if !manifest_constraints_included { - manifest_constraints_included = true; - for req in direct_requirements.iter().filter(|r| r.crate_name == name) { + } else { + // Scoped to this dependent's own manifest, not every manifest in + // the workspace that happens to mention the same crate name — + // two members can lock the same crate name at different major + // versions, each with its own unrelated requirement. + for req in direct_requirements + .iter() + .filter(|r| r.crate_name == name && r.declaring_package == dependent.name) + { constraints.push(Constraint { blocker_name: manifest_label(&req.manifest, working_dir), blocker_version: None, @@ -244,7 +267,7 @@ pub fn generate_suggestions( return None; } - let too_new_names: HashSet<&str> = too_new.iter().map(|v| v.package.as_str()).collect(); + let dependents_index = build_dependents_index(all_packages); let mut outcomes = Vec::new(); eprintln!("\nFetching version suggestions..."); @@ -268,7 +291,7 @@ pub fn generate_suggestions( let gathered = gather_constraints( client, - all_packages, + &dependents_index, direct_requirements, working_dir, &violation.package, @@ -292,8 +315,10 @@ pub fn generate_suggestions( locked_version: violation.version.clone(), newest_compliant: newest_compliant.to_string(), blocker: Blocker { - also_suggested: blocker.blocker_version.is_some() - && too_new_names.contains(blocker.blocker_name.as_str()), + // Whether `blocker_name` actually resolved to a + // suggestion is only known once every violation has been + // walked, so this starts false and is patched below. + also_suggested: false, name: blocker.blocker_name, version: blocker.blocker_version, req: blocker.req.to_string(), @@ -307,6 +332,21 @@ pub fn generate_suggestions( outcomes.push(outcome); } + let suggested_names: HashSet = outcomes + .iter() + .filter_map(|o| match o { + Outcome::Suggest { package, .. } => Some(package.clone()), + _ => None, + }) + .collect(); + for outcome in &mut outcomes { + if let Outcome::Blocked { blocker, .. } = outcome + && blocker.version.is_some() + { + blocker.also_suggested = suggested_names.contains(blocker.name.as_str()); + } + } + Some(outcomes) } @@ -471,6 +511,7 @@ mod tests { use crate::lockfile::PackageRef; use crate::report::Aged; use std::num::NonZeroU32; + use std::path::PathBuf; use std::time::Duration; trait FakeTransportExt { @@ -518,6 +559,8 @@ mod tests { format!(r#"{{"versions":[{}]}}"#, versions.join(",")) } + /// Builds a client with retry/pacing delays zeroed out, so the test + /// suite doesn't sleep. fn fast_client(transport: FakeTransport) -> CratesIoClient { CratesIoClient::with_transport( transport, @@ -568,6 +611,13 @@ mod tests { } } + fn non_registry_pkg(name: &str, version: &str, deps: &[(&str, &str)]) -> Package { + Package { + is_registry: false, + ..pkg(name, version, deps) + } + } + #[test] fn only_too_new_violations_are_fetched() { let transport = FakeTransport::default(); @@ -824,6 +874,109 @@ mod tests { assert_eq!(outcomes.len(), 2); } + + #[test] + fn also_suggested_is_false_when_the_blocker_itself_has_no_suggestion() { + // "y" blocks "target", and "y" is itself in the too-new set — + // but y's own walk resolves to NoCompliantVersion, not Suggest, + // so the blocked message must not claim a fix for y exists. + let transport = FakeTransport::default(); + transport.ok( + "target", + &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), + ); + transport.ok("y", &versions_body(&[("1.5.0", 5, false)], now())); + transport.index_ok( + "y", + r#"{"vers":"1.5.0","deps":[{"name":"target","req":"^1.5"}]}"#, + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("target", "1.5.0"), too_new("y", "1.5.0")]; + let packages = vec![ + pkg("target", "1.5.0", &[]), + pkg("y", "1.5.0", &[("target", "1.5.0")]), + ]; + let outcomes = generate_suggestions( + &mut client, + &violations, + &packages, + &[], + Path::new("/work"), + 30, + false, + now(), + ) + .unwrap(); + + match &outcomes[0] { + Outcome::Blocked { blocker, .. } => { + assert_eq!(blocker.name, "y"); + assert!( + !blocker.also_suggested, + "y has no Suggest outcome of its own" + ); + } + _ => panic!("expected target to be Blocked"), + } + assert!(matches!( + &outcomes[1], + Outcome::NoCompliantVersion { package, .. } if package == "y" + )); + } + + #[test] + fn manifest_constraint_is_scoped_to_the_declaring_dependent() { + // member_a locks clap@2.5.0 and requires ^2; member_b locks a + // different clap version and requires ^3. member_b's unrelated + // requirement must not leak into member_a's constraint set. + let transport = FakeTransport::default(); + transport.ok( + "clap", + &versions_body(&[("2.5.0", 5, false), ("2.0.0", 50, false)], now()), + ); + let mut client = fast_client(transport); + + let direct_requirements = vec![ + crate::manifest::DirectRequirement { + manifest: PathBuf::from("/work/member_a/Cargo.toml"), + declaring_package: "member_a".to_string(), + crate_name: "clap".to_string(), + req: VersionReq::parse("^2").unwrap(), + }, + crate::manifest::DirectRequirement { + manifest: PathBuf::from("/work/member_b/Cargo.toml"), + declaring_package: "member_b".to_string(), + crate_name: "clap".to_string(), + req: VersionReq::parse("^3").unwrap(), + }, + ]; + let packages = vec![ + pkg("clap", "2.5.0", &[]), + non_registry_pkg("member_a", "0.1.0", &[("clap", "2.5.0")]), + non_registry_pkg("member_b", "0.1.0", &[("clap", "3.1.0")]), + ]; + + let violations = vec![too_new("clap", "2.5.0")]; + let outcomes = generate_suggestions( + &mut client, + &violations, + &packages, + &direct_requirements, + Path::new("/work"), + 30, + false, + now(), + ) + .unwrap(); + + match &outcomes[0] { + Outcome::Suggest { + suggested_version, .. + } => assert_eq!(suggested_version, "2.0.0"), + _ => panic!("expected clap to be Suggest: member_b's ^3 must not apply"), + } + } } /// Drives the whole pipeline — lockfile intake, manifest reading, and From b348e92f987fd1074b0248cbf789750137df59d4 Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Mon, 7 Sep 2026 20:16:03 -0400 Subject: [PATCH 07/34] Verify dependent requirements and match blockers by locked version gather_constraints now counts a dependent as verified only when one of its recorded requirements actually matches the locked version. An alias resolving to a different major, or an edge whose requirement no longer covers what is locked, is reported as unverified instead of silently treated as confirmation. The registry branch moves out into registry_dependent_constraints. also_suggested now keys on both name and locked version. A suggestion for foo 1.5.0 previously annotated a dependency blocked by foo 2.5.0, telling the reader to apply an unrelated downgrade. The message names the blocker's version and no longer claims applying it will work, since nothing checks whether the older version relaxes its requirement. Trim the --include-prerelease help to one line. clap prints the whole doc comment in -h, where a paragraph on semver prerelease matching sat next to one-line entries for every other flag, and the README already explains it. --- src/main.rs | 6 +- src/report.rs | 3 +- src/suggest.rs | 287 +++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 257 insertions(+), 39 deletions(-) diff --git a/src/main.rs b/src/main.rs index 0990349..9fdce80 100644 --- a/src/main.rs +++ b/src/main.rs @@ -47,11 +47,7 @@ struct Cli { #[arg(long, requires = "min_age_days")] suggest_fix: bool, - /// Consider prerelease versions as suggestion candidates (requires --suggest-fix). Under - /// semver, a requirement matches a prerelease only when it names the identical - /// major.minor.patch with a prerelease part of its own, so this flag usually changes nothing - /// unless a dependent already tracks that exact prerelease line or the locked version is - /// itself a prerelease — an empty result with the flag set is expected, not a bug. + /// Consider prerelease versions as suggestion candidates (requires --suggest-fix) #[arg(long, requires = "suggest_fix")] include_prerelease: bool, diff --git a/src/report.rs b/src/report.rs index 71a3da2..599e7ff 100644 --- a/src/report.rs +++ b/src/report.rs @@ -154,8 +154,7 @@ pub fn print_suggestions(outcomes: &[Outcome]) { }; let also_suggested = if blocker.also_suggested { format!( - " ({} also has a suggested downgrade above; apply it first and re-run)", - blocker.name + " ({source} also has a suggested downgrade above; applying it may unblock this, so re-run to check)" ) } else { String::new() diff --git a/src/suggest.rs b/src/suggest.rs index dcdf32f..ab904ad 100644 --- a/src/suggest.rs +++ b/src/suggest.rs @@ -21,8 +21,10 @@ pub struct Blocker { pub name: String, pub version: Option, pub req: String, - /// Set when `name` is itself a package this run suggests downgrading — - /// applying that suggestion first may unblock this one. + /// Set when this blocker's own `name` and `version` is itself a package + /// this run suggests downgrading. That suggestion may unblock this one, + /// though nothing here checks whether the older version relaxes its + /// requirement. pub also_suggested: bool, } @@ -183,33 +185,19 @@ fn gather_constraints( .copied(); for dependent in dependents { + // A dependent counts as verified only if every requirement it + // records on `name` parsed and at least one matches the locked + // version. A fetch failure, a missing index record, or an + // unparseable requirement is annotated instead of silently dropped. + let matched; + let unreadable; if dependent.is_registry { - match client.fetch_index_record(&dependent.name) { - Ok(records) => match records.iter().find(|r| r.vers == dependent.version) { - Some(record) => { - for dep in &record.deps { - if dep.kind.as_deref() == Some("dev") { - continue; - } - let real_name = dep.package.as_deref().unwrap_or(&dep.name); - if real_name != name { - continue; - } - match VersionReq::parse(&dep.req) { - Ok(req) => constraints.push(Constraint { - blocker_name: dependent.name.clone(), - blocker_version: Some(dependent.version.clone()), - req, - }), - Err(_) => unverified_dependents.push(dependent.name.clone()), - } - } - } - None => unverified_dependents.push(dependent.name.clone()), - }, - Err(_) => unverified_dependents.push(dependent.name.clone()), - } + let result = registry_dependent_constraints(client, dependent, name, locked_version); + matched = result.matched; + unreadable = result.unreadable; + constraints.extend(result.constraints); } else { + let mut manifest_matched = false; // Scoped to this dependent's own manifest, not every manifest in // the workspace that happens to mention the same crate name — // two members can lock the same crate name at different major @@ -217,13 +205,22 @@ fn gather_constraints( for req in direct_requirements .iter() .filter(|r| r.crate_name == name && r.declaring_package == dependent.name) + .filter(|r| { + Version::parse(locked_version).is_ok_and(|version| r.req.matches(&version)) + }) { + manifest_matched = true; constraints.push(Constraint { blocker_name: manifest_label(&req.manifest, working_dir), blocker_version: None, req: req.req.clone(), }); } + matched = manifest_matched; + unreadable = false; + } + if !matched || unreadable { + unverified_dependents.push(dependent.name.clone()); } } @@ -233,6 +230,69 @@ fn gather_constraints( } } +/// Requirements a registry dependent's crates.io index record places on +/// `name` at `locked_version`. `unreadable` is set on a fetch failure, a +/// missing index record, or an unparseable requirement. +struct RegistryConstraints { + constraints: Vec, + matched: bool, + unreadable: bool, +} + +fn registry_dependent_constraints( + client: &mut CratesIoClient, + dependent: &Package, + name: &str, + locked_version: &str, +) -> RegistryConstraints { + let mut constraints = Vec::new(); + let mut matched = false; + + let Ok(records) = client.fetch_index_record(&dependent.name) else { + return RegistryConstraints { + constraints, + matched, + unreadable: true, + }; + }; + let Some(record) = records.iter().find(|r| r.vers == dependent.version) else { + return RegistryConstraints { + constraints, + matched, + unreadable: true, + }; + }; + + let mut unreadable = false; + for dep in &record.deps { + if dep.kind.as_deref() == Some("dev") { + continue; + } + let real_name = dep.package.as_deref().unwrap_or(&dep.name); + if real_name != name { + continue; + } + match VersionReq::parse(&dep.req) { + Ok(req) if Version::parse(locked_version).is_ok_and(|v| req.matches(&v)) => { + matched = true; + constraints.push(Constraint { + blocker_name: dependent.name.clone(), + blocker_version: Some(dependent.version.clone()), + req, + }); + } + Ok(_) => {} + Err(_) => unreadable = true, + } + } + + RegistryConstraints { + constraints, + matched, + unreadable, + } +} + fn manifest_label(path: &Path, working_dir: &Path) -> String { path.strip_prefix(working_dir) .unwrap_or(path) @@ -315,7 +375,7 @@ pub fn generate_suggestions( locked_version: violation.version.clone(), newest_compliant: newest_compliant.to_string(), blocker: Blocker { - // Whether `blocker_name` actually resolved to a + // Whether this blocker's own locked version resolved to a // suggestion is only known once every violation has been // walked, so this starts false and is patched below. also_suggested: false, @@ -332,18 +392,25 @@ pub fn generate_suggestions( outcomes.push(outcome); } - let suggested_names: HashSet = outcomes + // Keyed by locked version as well as name. A suggestion for one locked + // version of a package says nothing about another version of it, which + // may have no compliant version at all. + let suggested: HashSet<(String, String)> = outcomes .iter() .filter_map(|o| match o { - Outcome::Suggest { package, .. } => Some(package.clone()), + Outcome::Suggest { + package, + locked_version, + .. + } => Some((package.clone(), locked_version.clone())), _ => None, }) .collect(); for outcome in &mut outcomes { if let Outcome::Blocked { blocker, .. } = outcome - && blocker.version.is_some() + && let Some(version) = &blocker.version { - blocker.also_suggested = suggested_names.contains(blocker.name.as_str()); + blocker.also_suggested = suggested.contains(&(blocker.name.clone(), version.clone())); } } @@ -618,6 +685,94 @@ mod tests { } } + #[test] + fn aliased_registry_requirements_follow_the_locked_version() { + let transport = FakeTransport::default(); + transport.index_ok("app", r#"{"vers":"1.0.0","deps":[{"name":"foo_old","package":"foo","req":"^1"},{"name":"foo_new","package":"foo","req":"^2"}]}"#); + let mut client = fast_client(transport); + let packages = vec![pkg("app", "1.0.0", &[("foo", "1.5.0"), ("foo", "2.5.0")])]; + let index = build_dependents_index(&packages); + for (locked, candidate) in [("1.5.0", "1.4.0"), ("2.5.0", "2.4.0")] { + let gathered = + gather_constraints(&mut client, &index, &[], Path::new("/work"), "foo", locked); + assert_eq!(gathered.constraints.len(), 1); + assert!(gathered.unverified_dependents.is_empty()); + assert!(matches!( + walk( + vec![(Version::parse(candidate).unwrap(), 50)], + gathered.constraints + ), + WalkResult::Suggest(_, _) + )); + } + } + + #[test] + fn unmatched_registry_requirements_are_unverified() { + let transport = FakeTransport::default(); + transport.index_ok( + "app", + r#"{"vers":"1.0.0","deps":[{"name":"foo","req":"^1.5"}]}"#, + ); + let mut client = fast_client(transport); + let packages = vec![pkg("app", "1.0.0", &[("foo", "1.4.0")])]; + let index = build_dependents_index(&packages); + let gathered = + gather_constraints(&mut client, &index, &[], Path::new("/work"), "foo", "1.4.0"); + assert!(gathered.constraints.is_empty()); + assert_eq!(gathered.unverified_dependents, ["app"]); + } + + #[test] + fn missing_non_registry_requirements_are_unverified() { + let mut client = fast_client(FakeTransport::default()); + let packages = vec![non_registry_pkg("git-app", "1.0.0", &[("foo", "1.5.0")])]; + let index = build_dependents_index(&packages); + let gathered = + gather_constraints(&mut client, &index, &[], Path::new("/work"), "foo", "1.5.0"); + assert!(gathered.constraints.is_empty()); + assert_eq!(gathered.unverified_dependents, ["git-app"]); + } + + #[test] + fn aliased_manifest_requirements_follow_the_locked_version() { + let mut client = fast_client(FakeTransport::default()); + let packages = vec![non_registry_pkg( + "app", + "1.0.0", + &[("foo", "1.5.0"), ("foo", "2.5.0")], + )]; + let requirements: Vec<_> = ["^1", "^2"] + .into_iter() + .map(|req| DirectRequirement { + manifest: "/work/Cargo.toml".into(), + declaring_package: "app".to_string(), + crate_name: "foo".to_string(), + req: VersionReq::parse(req).unwrap(), + }) + .collect(); + let index = build_dependents_index(&packages); + for (locked, candidate) in [("1.5.0", "1.4.0"), ("2.5.0", "2.4.0")] { + let gathered = gather_constraints( + &mut client, + &index, + &requirements, + Path::new("/work"), + "foo", + locked, + ); + assert_eq!(gathered.constraints.len(), 1); + assert!(gathered.unverified_dependents.is_empty()); + assert!(matches!( + walk( + vec![(Version::parse(candidate).unwrap(), 50)], + gathered.constraints + ), + WalkResult::Suggest(_, _) + )); + } + } + #[test] fn only_too_new_violations_are_fetched() { let transport = FakeTransport::default(); @@ -925,6 +1080,74 @@ mod tests { )); } + #[test] + fn also_suggested_is_false_when_only_another_version_of_the_blocker_is_suggested() { + // "foo" is locked at both 1.5.0 and 2.5.0. Only 1.5.0 resolves to + // a Suggest; the 2.5.0 that blocks "target" has no compliant + // version, so the blocked message must not point at the unrelated + // 1.5.0 downgrade. + let transport = FakeTransport::default(); + transport.ok( + "target", + &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), + ); + transport.ok( + "foo", + &versions_body( + &[ + ("1.5.0", 5, false), + ("1.4.0", 50, false), + ("2.5.0", 5, false), + ], + now(), + ), + ); + transport.index_ok( + "foo", + r#"{"vers":"2.5.0","deps":[{"name":"target","req":"^1.5"}]}"#, + ); + let mut client = fast_client(transport); + + let violations = vec![ + too_new("target", "1.5.0"), + too_new("foo", "1.5.0"), + too_new("foo", "2.5.0"), + ]; + let packages = vec![ + pkg("target", "1.5.0", &[]), + pkg("foo", "1.5.0", &[]), + pkg("foo", "2.5.0", &[("target", "1.5.0")]), + ]; + let outcomes = generate_suggestions( + &mut client, + &violations, + &packages, + &[], + Path::new("/work"), + 30, + false, + now(), + ) + .unwrap(); + + assert!( + matches!(&outcomes[1], Outcome::Suggest { package, locked_version, .. } + if package == "foo" && locked_version == "1.5.0"), + "foo 1.5.0 should be suggested, otherwise the test proves nothing" + ); + match &outcomes[0] { + Outcome::Blocked { blocker, .. } => { + assert_eq!(blocker.name, "foo"); + assert_eq!(blocker.version.as_deref(), Some("2.5.0")); + assert!( + !blocker.also_suggested, + "the suggestion is for foo 1.5.0, which does not unblock foo 2.5.0" + ); + } + _ => panic!("expected target to be Blocked"), + } + } + #[test] fn manifest_constraint_is_scoped_to_the_declaring_dependent() { // member_a locks clap@2.5.0 and requires ^2; member_b locks a From 1b6ceb0fd1b98dfb9c3ab8f6f3d010c0ee20004e Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Tue, 8 Sep 2026 00:48:13 -0400 Subject: [PATCH 08/34] Preserve dependency identity across sources during suggestion generation Widen lockfile intake to carry each package's and dependency edge's source (crates.io, alternate registry, git, or none for a local path), and key the dependents index by (name, version, source) instead of just (name, version). Previously, a dependent's requirement could leak across packages that merely shared a name and version but came from different origins, wrongly blocking a valid downgrade. Dependency edges that omit a source in the lockfile are resolved against the package list: an edge without a source always targets a path package sharing the name and version when one exists (cargo only omits the source when the true target has none), and only falls back to treating every same-name/same-version candidate as a possible match when no such path package exists. This stops a path consumer's requirement from blocking a same-name/same-version crates.io downgrade. Also fix a related false-ambiguity bug surfaced during review: the index lists one dependency entry per target table, so a requirement repeated identically across, say, cfg(unix) and cfg(windows) produced multiple matching entries and was incorrectly treated as ambiguous (demoted to "unverified" instead of enforced as a blocker). Distinct requirement text is still ambiguous; identical repeats are not. --- src/lockfile.rs | 47 ++++- src/policy.rs | 1 + src/suggest.rs | 457 +++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 478 insertions(+), 27 deletions(-) diff --git a/src/lockfile.rs b/src/lockfile.rs index 83d5660..2c2992d 100644 --- a/src/lockfile.rs +++ b/src/lockfile.rs @@ -13,10 +13,15 @@ pub fn resolve_path(path: &Path, working_dir: &Path) -> PathBuf { } /// A name/version pair identifying a package, used both for lockfile entries -/// and for the dependency edges between them. +/// and for the dependency edges between them. `source` is the package's (or +/// dependency edge's) origin — crates.io, an alternate registry, git, or a +/// local path — encoded as `cargo_lock::SourceId`'s canonical string, so +/// same-name/same-version packages from different origins aren't confused +/// for one another. pub struct PackageRef { pub name: String, pub version: String, + pub source: Option, } /// An entry from `Cargo.lock`. Includes path and git packages (not just @@ -27,6 +32,7 @@ pub struct Package { pub name: String, pub version: String, pub is_registry: bool, + pub source: Option, pub dependencies: Vec, } @@ -82,12 +88,14 @@ pub fn load(path: &Path, working_dir: &Path) -> Result> { name: p.name.as_str().to_string(), version: p.version.to_string(), is_registry: p.source.as_ref().is_some_and(|s| s.is_default_registry()), + source: p.source.as_ref().map(|s| s.to_string()), dependencies: p .dependencies .iter() .map(|d| PackageRef { name: d.name.as_str().to_string(), version: d.version.to_string(), + source: d.source.as_ref().map(|s| s.to_string()), }) .collect(), }) @@ -220,6 +228,43 @@ version = "{version}" assert_eq!(a.dependencies[0].version, "2.0.0"); } + #[test] + fn registry_and_git_packages_carry_distinct_sources() { + let dir = tempdir().unwrap(); + let contents = format!( + "{}{}", + registry_entry("serde", "1.0.0"), + git_entry("serde-fork", "1.0.0"), + ); + write_lockfile(dir.path(), &contents); + + let packages = load(Path::new("Cargo.lock"), dir.path()).unwrap(); + + let registry = packages.iter().find(|p| p.name == "serde").unwrap(); + let git = packages.iter().find(|p| p.name == "serde-fork").unwrap(); + assert!(registry.source.is_some()); + assert!(git.source.is_some()); + assert_ne!(registry.source, git.source); + } + + #[test] + fn dependency_source_omitted_in_the_lockfile_still_resolves() { + // The dependency line ("b" with no version, no source) is the + // ordinary, unambiguous case: only one "b" package exists. + let dir = tempdir().unwrap(); + let contents = format!( + "{}{}", + registry_entry_with_deps("a", "1.0.0", &["b"]), + registry_entry("b", "2.0.0"), + ); + write_lockfile(dir.path(), &contents); + + let packages = load(Path::new("Cargo.lock"), dir.path()).unwrap(); + let a = packages.iter().find(|p| p.name == "a").unwrap(); + let b = packages.iter().find(|p| p.name == "b").unwrap(); + assert_eq!(a.dependencies[0].source, b.source); + } + #[test] fn path_outside_working_directory_is_rejected() { let outside = tempdir().unwrap(); diff --git a/src/policy.rs b/src/policy.rs index 7497bc7..fd98a06 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -106,6 +106,7 @@ mod tests { name: "serde".to_string(), version: "1.0.0".to_string(), is_registry: true, + source: None, dependencies: vec![], } } diff --git a/src/suggest.rs b/src/suggest.rs index ab904ad..1bba2ba 100644 --- a/src/suggest.rs +++ b/src/suggest.rs @@ -1,5 +1,5 @@ use crate::api::{CrateVersionInfo, CratesIoClient, Transport}; -use crate::lockfile::Package; +use crate::lockfile::{Package, PackageRef}; use crate::manifest::DirectRequirement; use crate::report::{Violation, ViolationKind}; use chrono::{DateTime, Utc}; @@ -144,29 +144,70 @@ struct GatheredConstraints { unverified_dependents: Vec, } -/// Maps `(name, version)` to every lockfile package that depends on it, -/// built once per run so `gather_constraints` doesn't rescan every package -/// for every "too new" violation. -type DependentsIndex<'a> = HashMap<(&'a str, &'a str), Vec<&'a Package>>; +/// Maps `(name, version, source)` to every lockfile package that depends on +/// it, built once per run so `gather_constraints` doesn't rescan every +/// package for every "too new" violation. `source` distinguishes same-name, +/// same-version packages from different origins (crates.io, an alternate +/// registry, git) so a dependent of one doesn't leak into another's +/// constraint set. +type DependentsIndex<'a> = HashMap<(&'a str, &'a str, Option<&'a str>), Vec<&'a Package>>; fn build_dependents_index(all_packages: &[Package]) -> DependentsIndex<'_> { let mut index: DependentsIndex = HashMap::new(); for pkg in all_packages { for dep in &pkg.dependencies { - index - .entry((dep.name.as_str(), dep.version.as_str())) - .or_default() - .push(pkg); + for source in resolve_dependency_sources(dep, all_packages) { + index + .entry((dep.name.as_str(), dep.version.as_str(), source)) + .or_default() + .push(pkg); + } } } index } +/// The source(s) a dependency edge could refer to. An edge that already +/// carries a source names it exactly. An edge without one refers either to +/// a path package sharing the name and version — cargo only omits the +/// source when the resolved target genuinely has none, so a path package is +/// the definite target even when a same-name/same-version registry package +/// also exists — or, absent any such path package, to whichever single +/// package the edge names, resolved here against the lockfile's own package +/// list. If more than one still shares the name and version (and the edge +/// still has no source to disambiguate with), the edge is kept under every +/// one of them rather than guessed at, so an edge that's genuinely ambiguous +/// still counts as a dependent everywhere it might apply. +fn resolve_dependency_sources<'a>( + dep: &'a PackageRef, + all_packages: &'a [Package], +) -> Vec> { + if let Some(source) = dep.source.as_deref() { + return vec![Some(source)]; + } + let has_path_match = all_packages + .iter() + .any(|p| p.name == dep.name && p.version == dep.version && p.source.is_none()); + if has_path_match { + return vec![None]; + } + let matches: Vec> = all_packages + .iter() + .filter(|p| p.name == dep.name && p.version == dep.version) + .map(|p| p.source.as_deref()) + .collect(); + if matches.is_empty() { + vec![None] + } else { + matches + } +} + /// Gathers every version requirement currently placed on `name` at -/// `locked_version`: from lockfile-recorded dependents (via the crates.io -/// sparse index for registry dependents) and from the user's own manifests -/// (via `direct_requirements`, included whenever a non-registry dependent -/// records the edge). +/// `locked_version` from `source`: from lockfile-recorded dependents (via +/// the crates.io sparse index for registry dependents) and from the user's +/// own manifests (via `direct_requirements`, included whenever a +/// non-registry dependent records the edge). fn gather_constraints( client: &mut CratesIoClient, dependents_index: &DependentsIndex, @@ -174,12 +215,13 @@ fn gather_constraints( working_dir: &Path, name: &str, locked_version: &str, + source: Option<&str>, ) -> GatheredConstraints { let mut constraints = Vec::new(); let mut unverified_dependents = Vec::new(); let dependents = dependents_index - .get(&(name, locked_version)) + .get(&(name, locked_version, source)) .into_iter() .flatten() .copied(); @@ -264,6 +306,7 @@ fn registry_dependent_constraints( }; let mut unreadable = false; + let mut matching_reqs = Vec::new(); for dep in &record.deps { if dep.kind.as_deref() == Some("dev") { continue; @@ -274,18 +317,42 @@ fn registry_dependent_constraints( } match VersionReq::parse(&dep.req) { Ok(req) if Version::parse(locked_version).is_ok_and(|v| req.matches(&v)) => { - matched = true; - constraints.push(Constraint { - blocker_name: dependent.name.clone(), - blocker_version: Some(dependent.version.clone()), - req, - }); + matching_reqs.push(req); } Ok(_) => {} Err(_) => unreadable = true, } } + // Duplicate declarations of the *same* requirement aren't ambiguous — + // the index lists one entry per target, so a requirement repeated + // across, say, `cfg(unix)` and `cfg(windows)` tables is still a single + // requirement, not competing candidates. Only distinct requirements + // signal genuine ambiguity. (Not necessarily adjacent, so a plain + // `dedup()` wouldn't catch every repeat.) + let mut distinct_reqs: Vec = Vec::new(); + for req in matching_reqs { + if !distinct_reqs.contains(&req) { + distinct_reqs.push(req); + } + } + let matching_reqs = distinct_reqs; + + // A single matching declaration is the unique explanation for this + // lockfile edge and is enforced as a definite blocker, optional or not. + // Several distinct matching declarations (e.g. a normal requirement and + // a disabled optional/renamed one both matching the locked version) mean + // which one is actually activated can't be established, so none of them + // are enforced — the dependent is reported unverified instead. + if let [req] = matching_reqs.as_slice() { + matched = true; + constraints.push(Constraint { + blocker_name: dependent.name.clone(), + blocker_version: Some(dependent.version.clone()), + req: req.clone(), + }); + } + RegistryConstraints { constraints, matched, @@ -349,6 +416,18 @@ pub fn generate_suggestions( } }; + // Violations are only ever raised for registry packages (see the + // `is_registry` filter that builds `violations`), so the crates.io + // entry matching this name and version is the one this violation + // refers to — not any git or alternate-registry package that + // happens to share the same name and version. + let target_source = all_packages + .iter() + .find(|p| { + p.name == violation.package && p.version == violation.version && p.is_registry + }) + .and_then(|p| p.source.as_deref()); + let gathered = gather_constraints( client, &dependents_index, @@ -356,6 +435,7 @@ pub fn generate_suggestions( working_dir, &violation.package, &violation.version, + target_source, ); let candidates = filter_candidates(&versions, &locked, min_age_days, now, allow_prerelease); @@ -668,11 +748,13 @@ mod tests { name: name.to_string(), version: version.to_string(), is_registry: true, + source: Some("registry+https://github.com/rust-lang/crates.io-index".to_string()), dependencies: deps .iter() .map(|(n, v)| PackageRef { name: n.to_string(), version: v.to_string(), + source: None, }) .collect(), } @@ -681,6 +763,7 @@ mod tests { fn non_registry_pkg(name: &str, version: &str, deps: &[(&str, &str)]) -> Package { Package { is_registry: false, + source: None, ..pkg(name, version, deps) } } @@ -693,8 +776,15 @@ mod tests { let packages = vec![pkg("app", "1.0.0", &[("foo", "1.5.0"), ("foo", "2.5.0")])]; let index = build_dependents_index(&packages); for (locked, candidate) in [("1.5.0", "1.4.0"), ("2.5.0", "2.4.0")] { - let gathered = - gather_constraints(&mut client, &index, &[], Path::new("/work"), "foo", locked); + let gathered = gather_constraints( + &mut client, + &index, + &[], + Path::new("/work"), + "foo", + locked, + None, + ); assert_eq!(gathered.constraints.len(), 1); assert!(gathered.unverified_dependents.is_empty()); assert!(matches!( @@ -717,8 +807,15 @@ mod tests { let mut client = fast_client(transport); let packages = vec![pkg("app", "1.0.0", &[("foo", "1.4.0")])]; let index = build_dependents_index(&packages); - let gathered = - gather_constraints(&mut client, &index, &[], Path::new("/work"), "foo", "1.4.0"); + let gathered = gather_constraints( + &mut client, + &index, + &[], + Path::new("/work"), + "foo", + "1.4.0", + None, + ); assert!(gathered.constraints.is_empty()); assert_eq!(gathered.unverified_dependents, ["app"]); } @@ -728,8 +825,15 @@ mod tests { let mut client = fast_client(FakeTransport::default()); let packages = vec![non_registry_pkg("git-app", "1.0.0", &[("foo", "1.5.0")])]; let index = build_dependents_index(&packages); - let gathered = - gather_constraints(&mut client, &index, &[], Path::new("/work"), "foo", "1.5.0"); + let gathered = gather_constraints( + &mut client, + &index, + &[], + Path::new("/work"), + "foo", + "1.5.0", + None, + ); assert!(gathered.constraints.is_empty()); assert_eq!(gathered.unverified_dependents, ["git-app"]); } @@ -760,6 +864,7 @@ mod tests { Path::new("/work"), "foo", locked, + None, ); assert_eq!(gathered.constraints.len(), 1); assert!(gathered.unverified_dependents.is_empty()); @@ -911,6 +1016,158 @@ mod tests { } } + #[test] + fn same_name_version_collision_across_sources_does_not_leak_dependents() { + // Two packages both named "serde" locked at 1.5.0: one from + // crates.io, one from git. "consumer" depends on the git one + // specifically. The crates.io serde must not inherit consumer's + // requirement just because the name and version happen to match. + let transport = FakeTransport::default(); + transport.ok( + "serde", + &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), + ); + // If "consumer" were (wrongly) treated as a dependent of the + // crates.io serde, this scripted index response would be + // fetched and its ^1.5 requirement would block the downgrade. + transport.index_ok( + "consumer", + r#"{"vers":"1.0.0","deps":[{"name":"serde","req":"^1.5"}]}"#, + ); + let mut client = fast_client(transport); + + let registry_source = "registry+https://github.com/rust-lang/crates.io-index"; + let git_source = + "git+https://github.com/example/serde#0000000000000000000000000000000000000000"; + + let packages = vec![ + Package { + name: "serde".to_string(), + version: "1.5.0".to_string(), + is_registry: true, + source: Some(registry_source.to_string()), + dependencies: vec![], + }, + Package { + name: "serde".to_string(), + version: "1.5.0".to_string(), + is_registry: false, + source: Some(git_source.to_string()), + dependencies: vec![], + }, + Package { + name: "consumer".to_string(), + version: "1.0.0".to_string(), + is_registry: true, + source: Some(registry_source.to_string()), + dependencies: vec![PackageRef { + name: "serde".to_string(), + version: "1.5.0".to_string(), + source: Some(git_source.to_string()), + }], + }, + ]; + + let violations = vec![too_new("serde", "1.5.0")]; + let outcomes = generate_suggestions( + &mut client, + &violations, + &packages, + &[], + Path::new("/work"), + 30, + false, + now(), + ) + .unwrap(); + + assert!( + matches!(&outcomes[0], Outcome::Suggest { suggested_version, .. } if suggested_version == "1.4.0"), + "the crates.io serde must not be blocked by consumer's requirement on the git serde: {:?}", + match &outcomes[0] { + Outcome::Blocked { blocker, .. } => format!("Blocked by {}", blocker.name), + _ => "other".to_string(), + } + ); + } + + #[test] + fn path_package_sharing_a_name_and_version_does_not_leak_dependents_to_the_registry_package() + { + // A path package "local-crate" 0.1.0 and a crates.io package of + // the same name and version both exist. "consumer" depends on + // the path one via an edge that omits the source, as cargo does + // for any edge whose true target has none. Since a path + // package's own source is always omitted too, the crates.io + // package must not inherit consumer's requirement just because + // the name and version happen to collide. + let transport = FakeTransport::default(); + transport.ok( + "local-crate", + &versions_body(&[("1.1.0", 5, false), ("1.0.0", 50, false)], now()), + ); + // If "consumer" were (wrongly) treated as a dependent of the + // crates.io local-crate, this scripted index response would be + // fetched and its ^1.1 requirement would block the downgrade. + transport.index_ok( + "consumer", + r#"{"vers":"1.0.0","deps":[{"name":"local-crate","req":"^1.1"}]}"#, + ); + let mut client = fast_client(transport); + + let registry_source = "registry+https://github.com/rust-lang/crates.io-index"; + + let packages = vec![ + Package { + name: "local-crate".to_string(), + version: "1.1.0".to_string(), + is_registry: true, + source: Some(registry_source.to_string()), + dependencies: vec![], + }, + Package { + name: "local-crate".to_string(), + version: "1.1.0".to_string(), + is_registry: false, + source: None, + dependencies: vec![], + }, + Package { + name: "consumer".to_string(), + version: "1.0.0".to_string(), + is_registry: true, + source: Some(registry_source.to_string()), + dependencies: vec![PackageRef { + name: "local-crate".to_string(), + version: "1.1.0".to_string(), + source: None, + }], + }, + ]; + + let violations = vec![too_new("local-crate", "1.1.0")]; + let outcomes = generate_suggestions( + &mut client, + &violations, + &packages, + &[], + Path::new("/work"), + 30, + false, + now(), + ) + .unwrap(); + + assert!( + matches!(&outcomes[0], Outcome::Suggest { suggested_version, .. } if suggested_version == "1.0.0"), + "the crates.io local-crate must not be blocked by consumer's requirement on the path local-crate: {:?}", + match &outcomes[0] { + Outcome::Blocked { blocker, .. } => format!("Blocked by {}", blocker.name), + _ => "other".to_string(), + } + ); + } + #[test] fn dev_kind_edge_from_a_transitive_dependent_is_ignored() { let transport = FakeTransport::default(); @@ -971,6 +1228,154 @@ mod tests { assert!(matches!(outcomes[0], Outcome::Blocked { .. })); } + #[test] + fn ambiguous_optional_declaration_does_not_block_the_downgrade() { + // "app" declares both a normal `serde = "^1"` and a disabled, + // renamed optional `serde_new = { package = "serde", version = + // "^1.5", optional = true }`. Both match locked serde@1.5.0, but + // since the optional one may not even be activated, neither can + // be enforced as a definite blocker. + let transport = FakeTransport::default(); + transport.ok( + "serde", + &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), + ); + transport.index_ok( + "app", + r#"{"vers":"1.0.0","deps":[{"name":"serde","req":"^1"},{"name":"serde_new","package":"serde","req":"^1.5","optional":true}]}"#, + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("serde", "1.5.0")]; + let packages = vec![ + pkg("serde", "1.5.0", &[]), + pkg("app", "1.0.0", &[("serde", "1.5.0")]), + ]; + let outcomes = generate_suggestions( + &mut client, + &violations, + &packages, + &[], + Path::new("/work"), + 30, + false, + now(), + ) + .unwrap(); + + match &outcomes[0] { + Outcome::Suggest { + suggested_version, + unverified_dependents, + .. + } => { + assert_eq!(suggested_version, "1.4.0"); + assert_eq!(unverified_dependents, &["app".to_string()]); + } + _ => panic!("expected serde to be Suggest, with app marked unverified"), + } + } + + #[test] + fn unique_optional_declaration_still_blocks() { + // Only the optional, renamed declaration matches — no ambiguity, + // so it's the unique explanation for the lockfile edge and must + // still be enforced. + let transport = FakeTransport::default(); + transport.ok( + "serde", + &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), + ); + transport.index_ok( + "app", + r#"{"vers":"1.0.0","deps":[{"name":"serde_new","package":"serde","req":"^1.5","optional":true}]}"#, + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("serde", "1.5.0")]; + let packages = vec![ + pkg("serde", "1.5.0", &[]), + pkg("app", "1.0.0", &[("serde", "1.5.0")]), + ]; + let outcomes = generate_suggestions( + &mut client, + &violations, + &packages, + &[], + Path::new("/work"), + 30, + false, + now(), + ) + .unwrap(); + + match &outcomes[0] { + Outcome::Blocked { + newest_compliant, + blocker, + .. + } => { + assert_eq!(newest_compliant, "1.4.0"); + assert_eq!(blocker.name, "app"); + assert_eq!(blocker.req, "^1.5"); + } + _ => panic!("expected serde to be Blocked by app's unique optional declaration"), + } + } + + #[test] + fn identical_requirement_repeated_across_targets_still_blocks() { + // "app" declares the same `serde = "^1.5"` requirement under two + // target-specific tables (e.g. cfg(unix) and cfg(windows)), which + // the index lists as two separate `deps` entries with identical + // `req` strings. This is not the same situation as two distinct + // declarations that might not both be active — the requirement + // is identical either way, so it must still be enforced as a + // definite blocker rather than merely "unverified". + let transport = FakeTransport::default(); + transport.ok( + "serde", + &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), + ); + transport.index_ok( + "app", + r#"{"vers":"1.0.0","deps":[{"name":"serde","req":"^1.5","target":"cfg(unix)"},{"name":"serde","req":"^1.5","target":"cfg(windows)"}]}"#, + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("serde", "1.5.0")]; + let packages = vec![ + pkg("serde", "1.5.0", &[]), + pkg("app", "1.0.0", &[("serde", "1.5.0")]), + ]; + let outcomes = generate_suggestions( + &mut client, + &violations, + &packages, + &[], + Path::new("/work"), + 30, + false, + now(), + ) + .unwrap(); + + match &outcomes[0] { + Outcome::Blocked { + newest_compliant, + blocker, + .. + } => { + assert_eq!(newest_compliant, "1.4.0"); + assert_eq!(blocker.name, "app"); + assert_eq!(blocker.req, "^1.5"); + } + _ => panic!( + "expected serde to be Blocked by app's requirement, not merely unverified" + ), + } + } + #[test] fn failed_index_fetch_yields_suggestion_with_unverified_annotation() { let transport = FakeTransport::default(); From 9b3e432f460da0fdd1c55b83a885e367ede753d6 Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Tue, 8 Sep 2026 01:18:53 -0400 Subject: [PATCH 09/34] Preserve mandatory requirements amid ambiguous declarations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit registry_dependent_constraints now classifies each matching declaration as mandatory (unconditional, non-optional — always active) or uncertain (target-gated or optional). Every mandatory requirement is enforced as a definite blocker regardless of how many other declarations also match, instead of the previous all-or-nothing rule that let a single ambiguous extra declaration erase a known blocker. A leftover uncertain declaration next to an enforced mandatory one still marks the dependent unverified, since its own applicability is still unresolved. With no mandatory declaration, a lone uncertain one remains the unique explanation for the lockfile edge and is still enforced, as before. Add regression tests for two mandatory declarations of different kinds both blocking a downgrade, and for a mandatory requirement still blocking despite a co-occurring uncertain declaration. --- src/suggest.rs | 197 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 166 insertions(+), 31 deletions(-) diff --git a/src/suggest.rs b/src/suggest.rs index 1bba2ba..6f3174a 100644 --- a/src/suggest.rs +++ b/src/suggest.rs @@ -233,10 +233,12 @@ fn gather_constraints( // unparseable requirement is annotated instead of silently dropped. let matched; let unreadable; + let mut has_unverified_leftover = false; if dependent.is_registry { let result = registry_dependent_constraints(client, dependent, name, locked_version); matched = result.matched; unreadable = result.unreadable; + has_unverified_leftover = result.has_unverified_leftover; constraints.extend(result.constraints); } else { let mut manifest_matched = false; @@ -261,7 +263,7 @@ fn gather_constraints( matched = manifest_matched; unreadable = false; } - if !matched || unreadable { + if !matched || unreadable || has_unverified_leftover { unverified_dependents.push(dependent.name.clone()); } } @@ -274,11 +276,27 @@ fn gather_constraints( /// Requirements a registry dependent's crates.io index record places on /// `name` at `locked_version`. `unreadable` is set on a fetch failure, a -/// missing index record, or an unparseable requirement. +/// missing index record, or an unparseable requirement. `has_unverified_leftover` +/// is set when a matching declaration exists whose applicability couldn't be +/// settled even though other, mandatory declarations were enforced. struct RegistryConstraints { constraints: Vec, matched: bool, unreadable: bool, + has_unverified_leftover: bool, +} + +impl RegistryConstraints { + /// The index record for the dependent (or the matching version within + /// it) couldn't be read at all, so nothing can be enforced. + fn unreadable() -> Self { + RegistryConstraints { + constraints: Vec::new(), + matched: false, + unreadable: true, + has_unverified_leftover: false, + } + } } fn registry_dependent_constraints( @@ -287,26 +305,19 @@ fn registry_dependent_constraints( name: &str, locked_version: &str, ) -> RegistryConstraints { - let mut constraints = Vec::new(); let mut matched = false; let Ok(records) = client.fetch_index_record(&dependent.name) else { - return RegistryConstraints { - constraints, - matched, - unreadable: true, - }; + return RegistryConstraints::unreadable(); }; let Some(record) = records.iter().find(|r| r.vers == dependent.version) else { - return RegistryConstraints { - constraints, - matched, - unreadable: true, - }; + return RegistryConstraints::unreadable(); }; let mut unreadable = false; - let mut matching_reqs = Vec::new(); + // Every matching declaration, alongside whether it alone guarantees the + // requirement is active: unconditional (no `target`) and non-optional. + let mut matching_reqs: Vec<(VersionReq, bool)> = Vec::new(); for dep in &record.deps { if dep.kind.as_deref() == Some("dev") { continue; @@ -317,7 +328,8 @@ fn registry_dependent_constraints( } match VersionReq::parse(&dep.req) { Ok(req) if Version::parse(locked_version).is_ok_and(|v| req.matches(&v)) => { - matching_reqs.push(req); + let mandatory = dep.target.is_none() && dep.optional != Some(true); + matching_reqs.push((req, mandatory)); } Ok(_) => {} Err(_) => unreadable = true, @@ -327,24 +339,44 @@ fn registry_dependent_constraints( // Duplicate declarations of the *same* requirement aren't ambiguous — // the index lists one entry per target, so a requirement repeated // across, say, `cfg(unix)` and `cfg(windows)` tables is still a single - // requirement, not competing candidates. Only distinct requirements - // signal genuine ambiguity. (Not necessarily adjacent, so a plain - // `dedup()` wouldn't catch every repeat.) - let mut distinct_reqs: Vec = Vec::new(); - for req in matching_reqs { - if !distinct_reqs.contains(&req) { - distinct_reqs.push(req); + // requirement, not competing candidates. (Not necessarily adjacent, so a + // plain `dedup()` wouldn't catch every repeat.) A requirement is + // mandatory if any declaration producing it is unconditional and + // non-optional — such a declaration guarantees the requirement is + // active no matter what else matches. + let mut distinct: Vec<(VersionReq, bool)> = Vec::new(); + for (req, mandatory) in matching_reqs { + match distinct.iter_mut().find(|(r, _)| *r == req) { + Some((_, m)) => *m = *m || mandatory, + None => distinct.push((req, mandatory)), } } - let matching_reqs = distinct_reqs; - - // A single matching declaration is the unique explanation for this - // lockfile edge and is enforced as a definite blocker, optional or not. - // Several distinct matching declarations (e.g. a normal requirement and - // a disabled optional/renamed one both matching the locked version) mean - // which one is actually activated can't be established, so none of them - // are enforced — the dependent is reported unverified instead. - if let [req] = matching_reqs.as_slice() { + let (mandatory_reqs, uncertain_reqs): (Vec<_>, Vec<_>) = + distinct.into_iter().partition(|(_, mandatory)| *mandatory); + + let mut constraints = Vec::new(); + let mut has_unverified_leftover = false; + + if !mandatory_reqs.is_empty() { + // Every known-mandatory requirement is always active, so all of + // them are enforced regardless of how many other, uncertain + // declarations also match. + matched = true; + for (req, _) in mandatory_reqs { + constraints.push(Constraint { + blocker_name: dependent.name.clone(), + blocker_version: Some(dependent.version.clone()), + req, + }); + } + // A leftover uncertain declaration can't be resolved either way, so + // the dependent is still worth flagging even though the mandatory + // requirements above are enforced as definite blockers. + has_unverified_leftover = !uncertain_reqs.is_empty(); + } else if let [(req, _)] = uncertain_reqs.as_slice() { + // With no mandatory declaration to settle it, a single uncertain + // declaration is the unique explanation for this lockfile edge and + // is enforced as a definite blocker. matched = true; constraints.push(Constraint { blocker_name: dependent.name.clone(), @@ -352,11 +384,15 @@ fn registry_dependent_constraints( req: req.clone(), }); } + // Otherwise: no matching declaration, or several distinct uncertain + // ones with no mandatory declaration to settle it — neither can be + // enforced, so the dependent is reported unverified instead. RegistryConstraints { constraints, matched, unreadable, + has_unverified_leftover, } } @@ -1323,6 +1359,105 @@ mod tests { } } + #[test] + fn mandatory_requirements_from_different_kinds_both_block() { + // "app" declares an unconditional, nonoptional normal + // requirement of `^1.5` and an unconditional, nonoptional build + // requirement of `^1` on serde. Both are always active, so both + // are enforced — the more restrictive one (`^1.5`) rejects the + // downgrade to 1.4.0. + let transport = FakeTransport::default(); + transport.ok( + "serde", + &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), + ); + transport.index_ok( + "app", + r#"{"vers":"1.0.0","deps":[{"name":"serde","req":"^1.5"},{"name":"serde","req":"^1","kind":"build"}]}"#, + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("serde", "1.5.0")]; + let packages = vec![ + pkg("serde", "1.5.0", &[]), + pkg("app", "1.0.0", &[("serde", "1.5.0")]), + ]; + let outcomes = generate_suggestions( + &mut client, + &violations, + &packages, + &[], + Path::new("/work"), + 30, + false, + now(), + ) + .unwrap(); + + match &outcomes[0] { + Outcome::Blocked { + newest_compliant, + blocker, + .. + } => { + assert_eq!(newest_compliant, "1.4.0"); + assert_eq!(blocker.name, "app"); + assert_eq!(blocker.req, "^1.5"); + } + _ => panic!("expected serde to be Blocked by app's mandatory requirement"), + } + } + + #[test] + fn mandatory_requirement_still_blocks_alongside_uncertain_declaration() { + // "app" declares an unconditional, nonoptional normal + // requirement of `^1.5`, and a separate disabled, renamed + // optional declaration matching a looser `^1`. The mandatory + // requirement is enforced regardless of the uncertain one, even + // though the uncertain one alone would have permitted the + // downgrade. + let transport = FakeTransport::default(); + transport.ok( + "serde", + &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), + ); + transport.index_ok( + "app", + r#"{"vers":"1.0.0","deps":[{"name":"serde","req":"^1.5"},{"name":"serde_new","package":"serde","req":"^1","optional":true}]}"#, + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("serde", "1.5.0")]; + let packages = vec![ + pkg("serde", "1.5.0", &[]), + pkg("app", "1.0.0", &[("serde", "1.5.0")]), + ]; + let outcomes = generate_suggestions( + &mut client, + &violations, + &packages, + &[], + Path::new("/work"), + 30, + false, + now(), + ) + .unwrap(); + + match &outcomes[0] { + Outcome::Blocked { + newest_compliant, + blocker, + .. + } => { + assert_eq!(newest_compliant, "1.4.0"); + assert_eq!(blocker.name, "app"); + assert_eq!(blocker.req, "^1.5"); + } + _ => panic!("expected serde to be Blocked by app's mandatory requirement"), + } + } + #[test] fn identical_requirement_repeated_across_targets_still_blocks() { // "app" declares the same `serde = "^1.5"` requirement under two From 07f7eb7524761051291203857c721724f046b321 Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Tue, 8 Sep 2026 01:34:01 -0400 Subject: [PATCH 10/34] Update README --- README.md | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 7a6823b..5233331 100644 --- a/README.md +++ b/README.md @@ -36,24 +36,12 @@ At least one of `--min-age-days` or `--max-age-days` must be specified. ## `--suggest-fix` -For every "too new" violation, `--suggest-fix` walks candidate versions newest to oldest, -within the same compatible range as the version currently locked, and suggests the first one -that satisfies every version requirement currently placed on that package — from other packages -in `Cargo.lock` (checked against the crates.io index) and from your own `Cargo.toml` manifests, -workspace members included. Every printed `cargo update` command is one cargo will accept. - -Source-level compatibility — whether the project still compiles — is not checked, since that -would require a build. Build after applying a suggestion. - -A package with no candidate that satisfies every requirement is reported separately, naming the -package and requirement standing in the way, so you know what would have to change. Since each -suggestion is computed independently against the current lockfile, apply them top to bottom and -re-run. - -`--include-prerelease` allows a prerelease version to be suggested. Under semver, a requirement -only matches a prerelease when it names the identical `major.minor.patch` with a prerelease part -of its own, so this rarely changes the outcome for a package with real dependents — expect it to -matter only for a package with no lockfile dependents, or one already tracking a prerelease line. +`--suggest-fix` finds the newest older version that satisfies every dependency requirement in +your lockfile and workspace manifests. It prints a `cargo update` command Cargo accepts. + +The tool does not build your project. Run your tests after applying a suggestion. If no version +fits, it reports the requirement that prevents a downgrade. Apply suggestions in order, then run +the command again. ## Exit Codes From 7215199af877c798683a060dba50a11d17292634 Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Tue, 8 Sep 2026 10:00:17 -0400 Subject: [PATCH 11/34] fix(suggest): restrict candidates to strict downgrades filter_candidates only bounded candidates to the caret-compatible zone of the locked version, sorted by publish date. A version published earlier than the locked one but higher in semantic precedence (or equal, including build-metadata-only differences, or a stable release above a locked prerelease) could slip through and get suggested as a "downgrade". Reject any candidate whose semver precedence is >= locked before sorting. Version's Ord already ignores build metadata and orders prereleases below the release they precede, so this covers all of those cases in one check. Signed-off-by: Duc Thanh Nguyen --- src/suggest.rs | 100 ++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 79 insertions(+), 21 deletions(-) diff --git a/src/suggest.rs b/src/suggest.rs index 6f3174a..dcb24a4 100644 --- a/src/suggest.rs +++ b/src/suggest.rs @@ -69,9 +69,10 @@ fn same_compatible_zone(a: &Version, b: &Version) -> bool { } /// Filters `versions` to non-yanked, at least `min_age_days` old as of -/// `now`, within the caret-compatible zone of `locked`, sorted newest first -/// by publish date. Prereleases are excluded unless `allow_prerelease` is -/// set or `locked` is itself a prerelease. +/// `now`, strictly older in semantic precedence than `locked`, within the +/// caret-compatible zone of `locked`, sorted newest first by publish date. +/// Prereleases are excluded unless `allow_prerelease` is set or `locked` is +/// itself a prerelease. fn filter_candidates( versions: &[CrateVersionInfo], locked: &Version, @@ -93,6 +94,13 @@ fn filter_candidates( if !same_compatible_zone(locked, &parsed) { return None; } + // `Version`'s `Ord` compares precedence per the semver spec: + // build metadata never affects it, so this also rejects a + // version differing from `locked` only in build metadata, and + // prereleases order below the release they precede. + if parsed >= *locked { + return None; + } let age_days = (now - v.created_at).num_days(); Some((parsed, v.created_at, age_days)) }) @@ -572,11 +580,11 @@ mod tests { make_version("1.0.0", 100, false), make_version("1.1.0", 50, true), // yanked make_version("1.2.0", 40, false), // compliant, same major as locked - make_version("2.0.0", 200, false), // different major: out of range + make_version("0.9.0", 200, false), // different major: out of range make_version("1.3.0", 5, false), // too new (min age 30) ]; - let result = filter_candidates(&versions, &v("1.0.0"), 30, now(), false); + let result = filter_candidates(&versions, &v("1.5.0"), 30, now(), false); let nums: Vec = result.iter().map(|(ver, _)| ver.to_string()).collect(); // Newest-first by publish date among the two survivors. assert_eq!(nums, vec!["1.2.0".to_string(), "1.0.0".to_string()]); @@ -590,7 +598,7 @@ mod tests { make_version("1.2.0", 50, false), ]; - let result = filter_candidates(&versions, &v("1.0.0"), 30, now(), false); + let result = filter_candidates(&versions, &v("1.5.0"), 30, now(), false); let nums: Vec = result.iter().map(|(ver, _)| ver.to_string()).collect(); assert_eq!(nums, vec!["1.2.0", "1.0.0", "1.1.0"]); } @@ -605,17 +613,58 @@ mod tests { #[test] fn prerelease_included_with_flag_when_range_matches() { // Same compatible zone (1.0.0), prerelease allowed by the flag. - let versions = vec![make_version("1.0.0-beta.2", 100, false)]; - let result = filter_candidates(&versions, &v("1.0.0-beta.1"), 30, now(), true); + let versions = vec![make_version("1.0.0-beta.1", 100, false)]; + let result = filter_candidates(&versions, &v("1.0.0-beta.2"), 30, now(), true); assert_eq!(result.len(), 1); - assert_eq!(result[0].0.to_string(), "1.0.0-beta.2"); + assert_eq!(result[0].0.to_string(), "1.0.0-beta.1"); } #[test] fn prerelease_allowed_when_locked_is_itself_a_prerelease() { + let versions = vec![make_version("1.0.0-beta.1", 100, false)]; + let result = filter_candidates(&versions, &v("1.0.0-beta.2"), 30, now(), false); + assert_eq!(result.len(), 1); + } + + #[test] + fn excludes_a_higher_version_published_earlier_than_locked() { + // "1.4.0" was published before "1.3.0" but is a higher semantic + // version, so it must never be offered as a downgrade even + // though it's older on the publish timeline. + let versions = vec![ + make_version("1.4.0", 100, false), + make_version("1.3.0", 50, false), + ]; + let result = filter_candidates(&versions, &v("1.3.0"), 30, now(), false); + let nums: Vec = result.iter().map(|(ver, _)| ver.to_string()).collect(); + assert!(nums.is_empty(), "expected no candidates, got {nums:?}"); + } + + #[test] + fn excludes_a_version_equal_in_precedence_including_build_metadata_only_differences() { + let versions = vec![ + make_version("1.3.0", 100, false), + make_version("1.3.0+build.1", 100, false), + ]; + let result = filter_candidates(&versions, &v("1.3.0"), 30, now(), false); + assert!(result.is_empty()); + } + + #[test] + fn excludes_stable_release_above_a_locked_prerelease() { + // A stable release outranks any prerelease of the same + // major.minor.patch, so it must not be offered as a "downgrade" + // from a locked prerelease. + let versions = vec![make_version("1.0.0", 100, false)]; + let result = filter_candidates(&versions, &v("1.0.0-beta.1"), 30, now(), false); + assert!(result.is_empty()); + } + + #[test] + fn excludes_a_later_prerelease_above_a_locked_prerelease() { let versions = vec![make_version("1.0.0-beta.2", 100, false)]; let result = filter_candidates(&versions, &v("1.0.0-beta.1"), 30, now(), false); - assert_eq!(result.len(), 1); + assert!(result.is_empty()); } } @@ -943,11 +992,14 @@ mod tests { fn a_failed_fetch_does_not_abort_the_others() { let transport = FakeTransport::default(); transport.error("serde"); - transport.ok("syn", &versions_body(&[("1.0.0", 50, false)], now())); + transport.ok( + "syn", + &versions_body(&[("1.0.0", 50, false), ("1.1.0", 5, false)], now()), + ); let mut client = fast_client(transport); - let violations = vec![too_new("serde", "1.0.0"), too_new("syn", "1.0.0")]; - let packages = vec![pkg("serde", "1.0.0", &[]), pkg("syn", "1.0.0", &[])]; + let violations = vec![too_new("serde", "1.0.0"), too_new("syn", "1.1.0")]; + let packages = vec![pkg("serde", "1.0.0", &[]), pkg("syn", "1.1.0", &[])]; let outcomes = generate_suggestions( &mut client, &violations, @@ -1207,17 +1259,20 @@ mod tests { #[test] fn dev_kind_edge_from_a_transitive_dependent_is_ignored() { let transport = FakeTransport::default(); - transport.ok("serde", &versions_body(&[("1.4.0", 50, false)], now())); + transport.ok( + "serde", + &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), + ); transport.index_ok( "app", r#"{"vers":"1.0.0","deps":[{"name":"serde","req":"^1.5","kind":"dev"}]}"#, ); let mut client = fast_client(transport); - let violations = vec![too_new("serde", "1.4.0")]; + let violations = vec![too_new("serde", "1.5.0")]; let packages = vec![ - pkg("serde", "1.4.0", &[]), - pkg("app", "1.0.0", &[("serde", "1.4.0")]), + pkg("serde", "1.5.0", &[]), + pkg("app", "1.0.0", &[("serde", "1.5.0")]), ]; let outcomes = generate_suggestions( &mut client, @@ -1514,14 +1569,17 @@ mod tests { #[test] fn failed_index_fetch_yields_suggestion_with_unverified_annotation() { let transport = FakeTransport::default(); - transport.ok("serde", &versions_body(&[("1.4.0", 50, false)], now())); + transport.ok( + "serde", + &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), + ); transport.index_error("app"); let mut client = fast_client(transport); - let violations = vec![too_new("serde", "1.4.0")]; + let violations = vec![too_new("serde", "1.5.0")]; let packages = vec![ - pkg("serde", "1.4.0", &[]), - pkg("app", "1.0.0", &[("serde", "1.4.0")]), + pkg("serde", "1.5.0", &[]), + pkg("app", "1.0.0", &[("serde", "1.5.0")]), ]; let outcomes = generate_suggestions( &mut client, From 99feecda840ea4327a7519aa028cebd8934f67d2 Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Tue, 8 Sep 2026 10:11:58 -0400 Subject: [PATCH 12/34] fix(suggest): qualify update spec with source on name/version collision When a registry package shares a name and locked version with a path or git package, cargo rejects the abbreviated `name@version` pkgid spec as ambiguous. Suggestions now compute a source-qualified spec (`{source}#{name}@{version}`) whenever such a collision exists, and the printed cargo update command uses it. Verified against a hermetic local-registry fixture: the abbreviated spec is confirmed ambiguous to a real cargo invocation, and the qualified spec resolves without error. --- Cargo.lock | 119 +++++++++++++++++++- Cargo.toml | 3 + src/report.rs | 5 +- src/suggest.rs | 296 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 417 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 32e37ed..2ca65ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,6 +91,15 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -124,10 +133,13 @@ dependencies = [ "cargo_toml", "chrono", "clap", + "flate2", "glob", "semver", "serde", "serde_json", + "sha2", + "tar", "tempfile", "ureq", ] @@ -217,6 +229,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "cookie" version = "0.18.1" @@ -252,6 +270,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -261,6 +288,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "deranged" version = "0.5.8" @@ -270,6 +306,17 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -312,6 +359,16 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -320,12 +377,13 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -410,6 +468,15 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -621,9 +688,9 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "miniz_oxide" -version = "0.8.9" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" dependencies = [ "adler2", "simd-adler32", @@ -850,6 +917,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -908,6 +986,17 @@ dependencies = [ "syn", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -1042,6 +1131,12 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1496,6 +1591,16 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.2" @@ -1579,6 +1684,12 @@ dependencies = [ "syn", ] +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 3da37ae..fe7bcad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,9 @@ glob = "0.3.4" cargo_toml = "1.0.1" [dev-dependencies] +flate2 = "1.1.10" +sha2 = "0.11.0" +tar = "0.4.46" tempfile = "3" # Optimize for small binary size - reduces CI cache/download time diff --git a/src/report.rs b/src/report.rs index 599e7ff..28e3d6a 100644 --- a/src/report.rs +++ b/src/report.rs @@ -111,11 +111,12 @@ pub fn print_suggestions(outcomes: &[Outcome]) { for outcome in outcomes { if let Outcome::Suggest { - package, + package_spec, locked_version, suggested_version, suggested_age_days, unverified_dependents, + .. } = outcome { let annotation = if unverified_dependents.is_empty() { @@ -127,7 +128,7 @@ pub fn print_suggestions(outcomes: &[Outcome]) { ) }; println!( - " cargo update -p {package}@{locked_version} --precise {suggested_version} # {suggested_age_days} days old{annotation}" + " cargo update -p {package_spec}@{locked_version} --precise {suggested_version} # {suggested_age_days} days old{annotation}" ); } } diff --git a/src/suggest.rs b/src/suggest.rs index dcb24a4..31c2c1a 100644 --- a/src/suggest.rs +++ b/src/suggest.rs @@ -34,6 +34,11 @@ pub struct Blocker { pub enum Outcome { Suggest { package: String, + /// The pkgid to print in the update command: the bare package name, + /// or `{source}#{package}` when another package in the lockfile + /// shares this name and locked version, so the abbreviated spec + /// would be ambiguous to Cargo. + package_spec: String, locked_version: String, suggested_version: String, suggested_age_days: i64, @@ -411,6 +416,30 @@ fn manifest_label(path: &Path, working_dir: &Path) -> String { .to_string() } +/// The pkgid to print in an update command for `name` at `version`: +/// abbreviated to the bare name, unless another package in `all_packages` +/// shares the name and version — a path or git package, say — in which case +/// Cargo would reject the abbreviated spec as ambiguous. Qualifying with +/// `target_source` (the source of the package the suggestion is actually +/// for) disambiguates it, per Cargo's package ID specification grammar: +/// `[+]#@`. +fn build_package_spec( + name: &str, + version: &str, + target_source: Option<&str>, + all_packages: &[Package], +) -> String { + let is_ambiguous = all_packages + .iter() + .filter(|p| p.name == name && p.version == version) + .count() + > 1; + match (is_ambiguous, target_source) { + (true, Some(source)) => format!("{source}#{name}"), + _ => name.to_string(), + } +} + /// Generates one outcome for every "too new" violation. Returns `None` when /// there are no "too new" violations, so the caller prints nothing; returns /// `Some` (possibly empty) once the flow has run. @@ -472,6 +501,13 @@ pub fn generate_suggestions( }) .and_then(|p| p.source.as_deref()); + let package_spec = build_package_spec( + &violation.package, + &violation.version, + target_source, + all_packages, + ); + let gathered = gather_constraints( client, &dependents_index, @@ -486,6 +522,7 @@ pub fn generate_suggestions( let outcome = match walk(candidates, gathered.constraints) { WalkResult::Suggest(version, age_days) => Outcome::Suggest { package: violation.package.clone(), + package_spec: package_spec.clone(), locked_version: violation.version.clone(), suggested_version: version.to_string(), suggested_age_days: age_days, @@ -1179,6 +1216,90 @@ mod tests { ); } + #[test] + fn source_collision_yields_a_source_qualified_package_spec() { + // Same fixture as above: a crates.io "serde" and a git "serde" + // both locked at 1.5.0. Cargo would reject the abbreviated + // `serde@1.5.0` spec as ambiguous, so the suggestion for the + // registry package must qualify it with the registry source. + let transport = FakeTransport::default(); + transport.ok( + "serde", + &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), + ); + let mut client = fast_client(transport); + + let registry_source = "registry+https://github.com/rust-lang/crates.io-index"; + let git_source = + "git+https://github.com/example/serde#0000000000000000000000000000000000000000"; + + let packages = vec![ + Package { + name: "serde".to_string(), + version: "1.5.0".to_string(), + is_registry: true, + source: Some(registry_source.to_string()), + dependencies: vec![], + }, + Package { + name: "serde".to_string(), + version: "1.5.0".to_string(), + is_registry: false, + source: Some(git_source.to_string()), + dependencies: vec![], + }, + ]; + + let violations = vec![too_new("serde", "1.5.0")]; + let outcomes = generate_suggestions( + &mut client, + &violations, + &packages, + &[], + Path::new("/work"), + 30, + false, + now(), + ) + .unwrap(); + + match &outcomes[0] { + Outcome::Suggest { package_spec, .. } => { + assert_eq!(package_spec, &format!("{registry_source}#serde")); + } + _ => panic!("expected serde to be Suggest"), + } + } + + #[test] + fn no_collision_keeps_the_abbreviated_package_spec() { + let transport = FakeTransport::default(); + transport.ok( + "serde", + &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("serde", "1.5.0")]; + let packages = vec![pkg("serde", "1.5.0", &[])]; + let outcomes = generate_suggestions( + &mut client, + &violations, + &packages, + &[], + Path::new("/work"), + 30, + false, + now(), + ) + .unwrap(); + + match &outcomes[0] { + Outcome::Suggest { package_spec, .. } => assert_eq!(package_spec, "serde"), + _ => panic!("expected serde to be Suggest"), + } + } + #[test] fn path_package_sharing_a_name_and_version_does_not_leak_dependents_to_the_registry_package() { @@ -1973,4 +2094,179 @@ mod tests { )); } } + + /// Builds a real Cargo project with a source collision — a crates.io + /// package and a path package sharing a name and locked version — and + /// runs a real `cargo` against the spec `build_package_spec` produces, + /// to verify it's the source-qualified pkgid Cargo itself expects, + /// rather than merely a string this crate assumes is valid. + mod source_collision_cargo_tests { + use super::*; + use sha2::{Digest, Sha256}; + use std::process::Command; + + const CRATE_NAME: &str = "semver"; + const CRATE_VERSION: &str = "1.0.28"; + + /// Writes a minimal crate (`Cargo.toml` + `src/lib.rs`) at `dir`. + fn write_crate_source(dir: &Path, name: &str, version: &str) { + std::fs::create_dir_all(dir.join("src")).unwrap(); + std::fs::write( + dir.join("Cargo.toml"), + format!( + "[package]\nname = \"{name}\"\nversion = \"{version}\"\nedition = \"2021\"\n" + ), + ) + .unwrap(); + std::fs::write(dir.join("src/lib.rs"), "").unwrap(); + } + + /// Packs `crate_dir` (already containing a `{name}-{version}` + /// top-level directory) into a `.crate` tarball, Cargo's own + /// publish format. + fn pack_crate_tarball(crate_dir: &Path, name: &str, version: &str) -> Vec { + let mut bytes = Vec::new(); + { + let encoder = + flate2::write::GzEncoder::new(&mut bytes, flate2::Compression::default()); + let mut builder = tar::Builder::new(encoder); + builder + .append_dir_all(format!("{name}-{version}"), crate_dir) + .unwrap(); + builder.finish().unwrap(); + } + bytes + } + + /// Assembles a local-registry source (see Cargo's source-replacement + /// docs) at `registry_dir`, containing one crate. Local-registry + /// index entries are sharded by name length: a 4+ character name + /// shards under its first two, then next two, characters. + fn write_local_registry(registry_dir: &Path, name: &str, version: &str) { + let build_dir = registry_dir + .join(".build") + .join(format!("{name}-{version}")); + write_crate_source(&build_dir, name, version); + let tarball = pack_crate_tarball(&build_dir, name, version); + + std::fs::write( + registry_dir.join(format!("{name}-{version}.crate")), + &tarball, + ) + .unwrap(); + + let cksum = Sha256::digest(&tarball) + .iter() + .map(|b| format!("{b:02x}")) + .collect::(); + let shard = registry_dir + .join("index") + .join(&name[0..2]) + .join(&name[2..4]); + std::fs::create_dir_all(&shard).unwrap(); + std::fs::write( + shard.join(name), + format!( + r#"{{"name":"{name}","vers":"{version}","deps":[],"cksum":"{cksum}","features":{{}},"yanked":false}}"# + ), + ) + .unwrap(); + } + + fn run_cargo(args: &[&str], cwd: &Path) -> std::process::Output { + Command::new("cargo") + .args(args) + .current_dir(cwd) + .output() + .expect("failed to run cargo") + } + + #[test] + fn qualified_spec_resolves_where_the_abbreviated_spec_is_ambiguous() { + let root = tempfile::tempdir().unwrap(); + let registry_dir = root.path().join("registry"); + let workspace_dir = root.path().join("workspace"); + + write_local_registry(®istry_dir, CRATE_NAME, CRATE_VERSION); + write_crate_source( + &workspace_dir.join("vendor-semver"), + CRATE_NAME, + CRATE_VERSION, + ); + + std::fs::create_dir_all(workspace_dir.join(".cargo")).unwrap(); + std::fs::write( + workspace_dir.join(".cargo/config.toml"), + format!( + "[source.local-vendor]\nlocal-registry = \"{}\"\n\n[source.crates-io]\nreplace-with = \"local-vendor\"\n", + registry_dir.display() + ), + ) + .unwrap(); + std::fs::create_dir_all(workspace_dir.join("src")).unwrap(); + std::fs::write(workspace_dir.join("src/main.rs"), "fn main() {}\n").unwrap(); + std::fs::write( + workspace_dir.join("Cargo.toml"), + format!( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n{CRATE_NAME} = \"{CRATE_VERSION}\"\n{CRATE_NAME}-path = {{ package = \"{CRATE_NAME}\", path = \"vendor-semver\" }}\n" + ), + ) + .unwrap(); + + let lock = run_cargo(&["generate-lockfile", "--offline"], &workspace_dir); + assert!( + lock.status.success(), + "generate-lockfile failed: {}", + String::from_utf8_lossy(&lock.stderr) + ); + + let packages = crate::lockfile::load(Path::new("Cargo.lock"), &workspace_dir).unwrap(); + let target_source = packages + .iter() + .find(|p| p.name == CRATE_NAME && p.is_registry) + .and_then(|p| p.source.as_deref()); + let spec = build_package_spec(CRATE_NAME, CRATE_VERSION, target_source, &packages); + assert!( + spec.contains('#'), + "expected a source-qualified spec for a name/version collision, got {spec}" + ); + + // The abbreviated spec really is ambiguous in this fixture — + // otherwise the qualified spec above proves nothing. + let abbreviated = run_cargo( + &[ + "update", + "--offline", + "-p", + &format!("{CRATE_NAME}@{CRATE_VERSION}"), + "--precise", + CRATE_VERSION, + ], + &workspace_dir, + ); + assert!( + !abbreviated.status.success() + && String::from_utf8_lossy(&abbreviated.stderr).contains("ambiguous"), + "expected the abbreviated spec to be ambiguous in this fixture: {}", + String::from_utf8_lossy(&abbreviated.stderr) + ); + + let qualified = run_cargo( + &[ + "update", + "--offline", + "-p", + &format!("{spec}@{CRATE_VERSION}"), + "--precise", + CRATE_VERSION, + ], + &workspace_dir, + ); + assert!( + qualified.status.success(), + "expected the source-qualified spec to resolve without an ambiguous-specification error: {}", + String::from_utf8_lossy(&qualified.stderr) + ); + } + } } From ed832b9b6ce29a893bf2b532e78e7d5d409abd4f Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Tue, 8 Sep 2026 10:22:24 -0400 Subject: [PATCH 13/34] refactor(suggest): share one name/version index for spec disambiguation Precompute a single (name, version) -> packages index once per generate_suggestions call, and use it for both the target-source lookup and the ambiguity check that feeds build_package_spec, instead of scanning all_packages twice per violation. Mirrors the existing build_dependents_index pattern. Also drops an unneeded package_spec.clone() now that the value isn't read again after being moved into the outcome. --- src/suggest.rs | 64 ++++++++++++++++++++++++++++++-------------------- 1 file changed, 39 insertions(+), 25 deletions(-) diff --git a/src/suggest.rs b/src/suggest.rs index 31c2c1a..da3bab1 100644 --- a/src/suggest.rs +++ b/src/suggest.rs @@ -180,6 +180,23 @@ fn build_dependents_index(all_packages: &[Package]) -> DependentsIndex<'_> { index } +/// Maps `(name, version)` to every lockfile package sharing them, built once +/// per run so the target-source lookup and the ambiguity check in +/// `generate_suggestions` don't each rescan every package for every "too +/// new" violation. +type NameVersionIndex<'a> = HashMap<(&'a str, &'a str), Vec<&'a Package>>; + +fn build_name_version_index(all_packages: &[Package]) -> NameVersionIndex<'_> { + let mut index: NameVersionIndex = HashMap::new(); + for pkg in all_packages { + index + .entry((pkg.name.as_str(), pkg.version.as_str())) + .or_default() + .push(pkg); + } + index +} + /// The source(s) a dependency edge could refer to. An edge that already /// carries a source names it exactly. An edge without one refers either to /// a path package sharing the name and version — cargo only omits the @@ -417,23 +434,12 @@ fn manifest_label(path: &Path, working_dir: &Path) -> String { } /// The pkgid to print in an update command for `name` at `version`: -/// abbreviated to the bare name, unless another package in `all_packages` -/// shares the name and version — a path or git package, say — in which case -/// Cargo would reject the abbreviated spec as ambiguous. Qualifying with -/// `target_source` (the source of the package the suggestion is actually -/// for) disambiguates it, per Cargo's package ID specification grammar: -/// `[+]#@`. -fn build_package_spec( - name: &str, - version: &str, - target_source: Option<&str>, - all_packages: &[Package], -) -> String { - let is_ambiguous = all_packages - .iter() - .filter(|p| p.name == name && p.version == version) - .count() - > 1; +/// abbreviated to the bare name, unless another package shares the name and +/// version — a path or git package, say — in which case Cargo would reject +/// the abbreviated spec as ambiguous. Qualifying with `target_source` (the +/// source of the package the suggestion is actually for) disambiguates it, +/// per Cargo's package ID specification grammar: `[+]#@`. +fn build_package_spec(name: &str, target_source: Option<&str>, is_ambiguous: bool) -> String { match (is_ambiguous, target_source) { (true, Some(source)) => format!("{source}#{name}"), _ => name.to_string(), @@ -468,6 +474,7 @@ pub fn generate_suggestions( } let dependents_index = build_dependents_index(all_packages); + let name_version_index = build_name_version_index(all_packages); let mut outcomes = Vec::new(); eprintln!("\nFetching version suggestions..."); @@ -489,23 +496,25 @@ pub fn generate_suggestions( } }; + let same_name_version = name_version_index + .get(&(violation.package.as_str(), violation.version.as_str())) + .map(Vec::as_slice) + .unwrap_or_default(); + // Violations are only ever raised for registry packages (see the // `is_registry` filter that builds `violations`), so the crates.io // entry matching this name and version is the one this violation // refers to — not any git or alternate-registry package that // happens to share the same name and version. - let target_source = all_packages + let target_source = same_name_version .iter() - .find(|p| { - p.name == violation.package && p.version == violation.version && p.is_registry - }) + .find(|p| p.is_registry) .and_then(|p| p.source.as_deref()); let package_spec = build_package_spec( &violation.package, - &violation.version, target_source, - all_packages, + same_name_version.len() > 1, ); let gathered = gather_constraints( @@ -522,7 +531,7 @@ pub fn generate_suggestions( let outcome = match walk(candidates, gathered.constraints) { WalkResult::Suggest(version, age_days) => Outcome::Suggest { package: violation.package.clone(), - package_spec: package_spec.clone(), + package_spec, locked_version: violation.version.clone(), suggested_version: version.to_string(), suggested_age_days: age_days, @@ -2225,7 +2234,12 @@ mod tests { .iter() .find(|p| p.name == CRATE_NAME && p.is_registry) .and_then(|p| p.source.as_deref()); - let spec = build_package_spec(CRATE_NAME, CRATE_VERSION, target_source, &packages); + let is_ambiguous = packages + .iter() + .filter(|p| p.name == CRATE_NAME && p.version == CRATE_VERSION) + .count() + > 1; + let spec = build_package_spec(CRATE_NAME, target_source, is_ambiguous); assert!( spec.contains('#'), "expected a source-qualified spec for a name/version collision, got {spec}" From d823fda0f8d68f414ed27a5089d48ca5a6918bd6 Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Tue, 8 Sep 2026 10:29:36 -0400 Subject: [PATCH 14/34] docs(suggest): qualify suggestion guarantees as best effort README and CLI previously claimed suggestions satisfy every requirement and that Cargo accepts the resulting command unconditionally, even though unverified requirements are a supported path. Both now describe best-effort verification and call out that unverified requirements may still be rejected by Cargo. --- README.md | 15 +++++++++------ src/report.rs | 6 ++++-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 5233331..fdac6d3 100644 --- a/README.md +++ b/README.md @@ -36,12 +36,15 @@ At least one of `--min-age-days` or `--max-age-days` must be specified. ## `--suggest-fix` -`--suggest-fix` finds the newest older version that satisfies every dependency requirement in -your lockfile and workspace manifests. It prints a `cargo update` command Cargo accepts. - -The tool does not build your project. Run your tests after applying a suggestion. If no version -fits, it reports the requirement that prevents a downgrade. Apply suggestions in order, then run -the command again. +`--suggest-fix` finds the newest older version that satisfies, on a best-effort basis, every +dependency requirement it can verify from your lockfile and workspace manifests, and prints a +`cargo update` command for it. Some requirements can't be verified (for example, an optional +dependency behind a feature flag not enabled in your manifests) — such suggestions are annotated, +and Cargo may still reject them. + +The tool does not build your project and does not guarantee Cargo will accept every suggested +command. Run your tests after applying a suggestion. If no version fits, it reports the +requirement that prevents a downgrade. Apply suggestions in order, then run the command again. ## Exit Codes diff --git a/src/report.rs b/src/report.rs index 28e3d6a..ba7067f 100644 --- a/src/report.rs +++ b/src/report.rs @@ -181,8 +181,10 @@ pub fn print_suggestions(outcomes: &[Outcome]) { if has_suggestion { println!( r#" - Suggestions satisfy every version requirement in Cargo.lock and your manifests. - Source compatibility is not verified: build after applying. + Suggestions satisfy, on a best-effort basis, the version requirements verified from + Cargo.lock and your manifests. Requirements marked "unverified" above were not checked + and Cargo may still reject that suggestion. Source compatibility is not verified: build + or test after applying. "# ); } From 7169de26cd5afca7d78fc00f5b0e1d0d1d31441e Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Tue, 8 Sep 2026 10:45:09 -0400 Subject: [PATCH 15/34] fix(suggest): compare semver precedence, not full Ord, for downgrade guard Version's Ord breaks precedence ties on build metadata, so a locked version like 1.3.0+build.2 let both 1.3.0 and 1.3.0+build.1 pass the >= guard despite having equal semantic precedence. Use cmp_precedence, which ignores build metadata per the semver spec, and require Less. --- src/suggest.rs | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/suggest.rs b/src/suggest.rs index da3bab1..fabb33b 100644 --- a/src/suggest.rs +++ b/src/suggest.rs @@ -99,11 +99,13 @@ fn filter_candidates( if !same_compatible_zone(locked, &parsed) { return None; } - // `Version`'s `Ord` compares precedence per the semver spec: - // build metadata never affects it, so this also rejects a - // version differing from `locked` only in build metadata, and - // prereleases order below the release they precede. - if parsed >= *locked { + // `Version`'s `Ord` breaks precedence ties on build metadata, + // so a version differing from `locked` only in build metadata + // would otherwise slip past a plain `>=` comparison despite + // having equal semantic precedence. `cmp_precedence` follows + // the semver spec instead: it ignores build metadata, and + // orders prereleases below the release they precede. + if parsed.cmp_precedence(locked) != std::cmp::Ordering::Less { return None; } let age_days = (now - v.created_at).num_days(); @@ -696,6 +698,19 @@ mod tests { assert!(result.is_empty()); } + #[test] + fn excludes_versions_equal_in_precedence_to_a_locked_version_with_build_metadata() { + // Locked itself carries build metadata this time: candidates + // differing only in build metadata (or lacking it) still have + // equal semantic precedence and must not be offered. + let versions = vec![ + make_version("1.3.0", 100, false), + make_version("1.3.0+build.1", 100, false), + ]; + let result = filter_candidates(&versions, &v("1.3.0+build.2"), 30, now(), false); + assert!(result.is_empty()); + } + #[test] fn excludes_stable_release_above_a_locked_prerelease() { // A stable release outranks any prerelease of the same From 8b2d185db73b1880634743686bbee7706676cba0 Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Tue, 8 Sep 2026 13:44:41 -0400 Subject: [PATCH 16/34] fix(suggest): scope manifest constraints to their declared registry A manifest requirement declared against an explicit alternate registry (via `registry` or `registry-index`) was previously indistinguishable from an ordinary crates.io requirement once loaded: both compared only on crate name, declaring package, and locked version. When one manifest declared crates.io foo and a renamed alternate-registry foo at the same locked version, the alternate-registry requirement could wrongly block (or wrongly count as verifying) a crates.io suggestion it was never written for. DirectRequirement now carries a RequirementSource recording whether the declaration is an ordinary crates.io dependency or names an alternate registry, resolved from the dependency detail after workspace inheritance. Only CratesIo requirements are enforced (or count as verification) in the crates.io suggestion flow; a Registry(_) declaration is excluded outright rather than compared against a lockfile source string, since a registry alias and a lockfile source URL are different representations with no mapping available here. --- src/manifest.rs | 136 ++++++++++++++++++- src/suggest.rs | 338 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 468 insertions(+), 6 deletions(-) diff --git a/src/manifest.rs b/src/manifest.rs index 71b2566..29923e7 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -2,17 +2,38 @@ use cargo_toml::{Dependency, DepsSet, Manifest}; use std::collections::HashSet; use std::path::{Path, PathBuf}; +/// Which registry a manifest dependency declaration names. A manifest's +/// `registry` (a Cargo config alias) or `registry-index` (a raw index URL) +/// field identifies an alternate registry by a different representation +/// than the source URL recorded against a lockfile package or dependency +/// edge, and this crate has no access to Cargo's registry configuration to +/// resolve the alias to a source. Recording that identity — without +/// claiming it resolves to any particular lockfile source — is enough to +/// keep it out of the crates.io suggestion flow, which only ever concerns +/// itself with `CratesIo` requirements. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RequirementSource { + /// No `registry` or `registry-index` on the declaration: an ordinary + /// crates.io dependency. + CratesIo, + /// An explicitly named alternate registry (alias or raw index URL). The + /// identity is kept for diagnostics; it is never matched against a + /// lockfile source string. + Registry(String), +} + /// One version requirement the user's own manifests place on a registry /// crate, together with the manifest that placed it (for warnings and -/// diagnostics) and the name of the package that manifest declares — -/// callers use this to scope a requirement to the lockfile dependent that -/// actually placed it, rather than to every manifest in the workspace that -/// happens to mention the same crate name. +/// diagnostics), the name of the package that manifest declares — callers +/// use this to scope a requirement to the lockfile dependent that actually +/// placed it, rather than to every manifest in the workspace that happens to +/// mention the same crate name — and which registry it was declared against. pub struct DirectRequirement { pub manifest: PathBuf, pub declaring_package: String, pub crate_name: String, pub req: semver::VersionReq, + pub source: RequirementSource, } /// Reads every version requirement the user's own manifests place on @@ -197,6 +218,7 @@ fn collect_one( } let crate_name = dep.package().unwrap_or(key).to_string(); + let source = requirement_source(dep); match dep.try_req() { Ok(req) => out.push(DirectRequirement { @@ -204,6 +226,7 @@ fn collect_one( declaring_package: declaring_package.to_string(), crate_name, req: req.clone(), + source, }), Err(e) => warnings.push(format!( "Could not determine requirement for {crate_name} in {}: {e}", @@ -212,6 +235,20 @@ fn collect_one( } } +/// The registry a dependency declaration names. `dep` has already passed +/// through workspace inheritance by the time it reaches here (`from_path` +/// resolves it), so a `{ workspace = true }` dependency backed by a +/// workspace-level `registry` is read the same way as one declared directly. +fn requirement_source(dep: &Dependency) -> RequirementSource { + match dep + .detail() + .and_then(|d| d.registry.clone().or_else(|| d.registry_index.clone())) + { + Some(registry) => RequirementSource::Registry(registry), + None => RequirementSource::CratesIo, + } +} + #[cfg(test)] mod tests { use super::*; @@ -415,6 +452,97 @@ serde = "1.0" assert!(!reqs.iter().any(|r| r.crate_name == "helper")); } + #[test] + fn dependency_table_records_registry_identity() { + let dir = tempdir().unwrap(); + write( + dir.path(), + "Cargo.toml", + r#" +[package] +name = "root" +version = "0.1.0" + +[dependencies] +serde = "1.0" +priv_serde = { package = "serde", version = "1.0", registry = "priv" } + +[dev-dependencies] +rand = { version = "0.8", registry = "priv" } + +[build-dependencies] +libc = { version = "0.2", registry = "priv" } + +[target.'cfg(unix)'.dependencies] +nix = { version = "0.2", registry = "priv" } +"#, + ); + + let (reqs, warnings) = load_direct_requirements(dir.path()); + assert!(warnings.is_empty()); + + let crates_io_serde = reqs + .iter() + .find(|r| r.crate_name == "serde" && r.source == RequirementSource::CratesIo) + .expect("plain serde dependency should resolve to crates.io"); + assert_eq!( + crates_io_serde.req, + semver::VersionReq::parse("1.0").unwrap() + ); + + let priv_serde = reqs + .iter() + .find(|r| r.crate_name == "serde" && r.source != RequirementSource::CratesIo) + .expect("renamed serde dependency should record its alternate registry"); + assert_eq!( + priv_serde.source, + RequirementSource::Registry("priv".to_string()) + ); + + for name in ["rand", "libc", "nix"] { + assert_eq!( + find(&reqs, name).source, + RequirementSource::Registry("priv".to_string()), + "{name} should record its alternate registry" + ); + } + } + + #[test] + fn workspace_inherited_registry_metadata_is_preserved() { + let dir = tempdir().unwrap(); + write( + dir.path(), + "Cargo.toml", + r#" +[workspace] +members = ["member"] + +[workspace.dependencies] +serde = { version = "1.0", registry = "priv" } +"#, + ); + write( + dir.path(), + "member/Cargo.toml", + r#" +[package] +name = "member" +version = "0.1.0" + +[dependencies] +serde = { workspace = true } +"#, + ); + + let (reqs, warnings) = load_direct_requirements(dir.path()); + assert!(warnings.is_empty()); + assert_eq!( + find(&reqs, "serde").source, + RequirementSource::Registry("priv".to_string()) + ); + } + #[test] fn missing_manifest_degrades_to_a_warning() { let dir = tempdir().unwrap(); diff --git a/src/suggest.rs b/src/suggest.rs index fabb33b..89171ed 100644 --- a/src/suggest.rs +++ b/src/suggest.rs @@ -1,6 +1,6 @@ use crate::api::{CrateVersionInfo, CratesIoClient, Transport}; use crate::lockfile::{Package, PackageRef}; -use crate::manifest::DirectRequirement; +use crate::manifest::{DirectRequirement, RequirementSource}; use crate::report::{Violation, ViolationKind}; use chrono::{DateTime, Utc}; use semver::{Version, VersionReq}; @@ -277,10 +277,17 @@ fn gather_constraints( // Scoped to this dependent's own manifest, not every manifest in // the workspace that happens to mention the same crate name — // two members can lock the same crate name at different major - // versions, each with its own unrelated requirement. + // versions, each with its own unrelated requirement. Also scoped + // to crates.io declarations: this suggestion flow only ever + // targets a crates.io package (see the `is_registry` filter + // above `target_source` in `generate_suggestions`), so a + // declaration naming an explicit alternate registry can never be + // the one that placed this edge and must not be enforced here — + // nor must it count toward this dependent being verified. for req in direct_requirements .iter() .filter(|r| r.crate_name == name && r.declaring_package == dependent.name) + .filter(|r| r.source == RequirementSource::CratesIo) .filter(|r| { Version::parse(locked_version).is_ok_and(|version| r.req.matches(&version)) }) @@ -999,6 +1006,7 @@ mod tests { declaring_package: "app".to_string(), crate_name: "foo".to_string(), req: VersionReq::parse(req).unwrap(), + source: RequirementSource::CratesIo, }) .collect(); let index = build_dependents_index(&packages); @@ -1909,12 +1917,14 @@ mod tests { declaring_package: "member_a".to_string(), crate_name: "clap".to_string(), req: VersionReq::parse("^2").unwrap(), + source: RequirementSource::CratesIo, }, crate::manifest::DirectRequirement { manifest: PathBuf::from("/work/member_b/Cargo.toml"), declaring_package: "member_b".to_string(), crate_name: "clap".to_string(), req: VersionReq::parse("^3").unwrap(), + source: RequirementSource::CratesIo, }, ]; let packages = vec![ @@ -2298,4 +2308,328 @@ mod tests { ); } } + + /// Covers a manifest crate name declared against two different + /// registries at once: an ordinary crates.io requirement and one + /// explicitly pinned to a renamed private registry. The renamed + /// declaration must never be treated as if it constrained the crates.io + /// package this flow actually suggests a downgrade for, nor may it + /// silently mark the declaring dependent as unverified when the + /// crates.io declaration alone already verifies it. + mod manifest_registry_identity_tests { + use super::*; + use crate::api::RetryPolicy; + use crate::api::test_support::{FakeTransport, ScriptedResponse, versions_url}; + use crate::manifest::load_direct_requirements; + use crate::report::Aged; + use std::num::NonZeroU32; + use std::time::Duration; + use tempfile::tempdir; + + const CRATES_IO_SOURCE: &str = "registry+https://github.com/rust-lang/crates.io-index"; + const PRIVATE_SOURCE: &str = "registry+https://example.com/priv-index"; + + fn versions_body(entries: &[(&str, i64, bool)]) -> String { + let versions: Vec = entries + .iter() + .map(|(num, days_ago, yanked)| { + let created_at = now() - chrono::Duration::days(*days_ago); + format!( + r#"{{"num":"{num}","created_at":"{}","yanked":{yanked}}}"#, + created_at.to_rfc3339() + ) + }) + .collect(); + format!(r#"{{"versions":[{}]}}"#, versions.join(",")) + } + + fn fast_client(transport: FakeTransport) -> CratesIoClient { + CratesIoClient::with_transport( + transport, + None, + 24, + RetryPolicy { + retry_count: NonZeroU32::new(1).unwrap(), + retry_delay: Duration::from_millis(0), + pacing_delay: Duration::from_millis(0), + }, + ) + } + + fn too_new(package: &str, locked_version: &str) -> Violation { + Violation { + package: package.to_string(), + version: locked_version.to_string(), + kind: ViolationKind::TooNew(Aged { + published: now(), + age_days: 1, + }), + } + } + + fn write_manifest(dir: &Path, contents: &str) { + std::fs::write(dir.join("Cargo.toml"), contents).unwrap(); + } + + /// A "foo" package locked at 1.9.0 on each of `CRATES_IO_SOURCE` and + /// `PRIVATE_SOURCE`, both depended on by workspace member "app" via + /// source-qualified lockfile dependency edges — the shape a real + /// lockfile resolves to when a same-name/same-version package exists + /// on more than one registry. + fn packages_with_dual_source_foo() -> Vec { + vec![ + Package { + name: "foo".to_string(), + version: "1.9.0".to_string(), + is_registry: true, + source: Some(CRATES_IO_SOURCE.to_string()), + dependencies: vec![], + }, + Package { + name: "foo".to_string(), + version: "1.9.0".to_string(), + is_registry: false, + source: Some(PRIVATE_SOURCE.to_string()), + dependencies: vec![], + }, + Package { + name: "app".to_string(), + version: "0.1.0".to_string(), + is_registry: false, + source: None, + dependencies: vec![ + PackageRef { + name: "foo".to_string(), + version: "1.9.0".to_string(), + source: Some(CRATES_IO_SOURCE.to_string()), + }, + PackageRef { + name: "foo".to_string(), + version: "1.9.0".to_string(), + source: Some(PRIVATE_SOURCE.to_string()), + }, + ], + }, + ] + } + + #[test] + fn crates_io_declaration_is_enforced_while_the_renamed_registry_declaration_is_excluded() { + // "app" depends on crates.io foo ^1.0 and a renamed + // private-registry foo pinned to =1.9.0; both resolve to foo + // 1.9.0 in the lockfile. Only the crates.io declaration should + // count toward foo's suggestion: it doesn't block 1.8.0, so foo + // should suggest it, and the =1.9.0 renamed declaration must not + // spuriously block a package it was never written for. + let dir = tempdir().unwrap(); + write_manifest( + dir.path(), + r#" +[package] +name = "app" +version = "0.1.0" + +[dependencies] +foo = "^1.0" +foo_priv = { package = "foo", version = "=1.9.0", registry = "priv" } +"#, + ); + let (direct_requirements, warnings) = load_direct_requirements(dir.path()); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + let transport = FakeTransport::default(); + transport.push( + &versions_url("foo"), + ScriptedResponse::Http( + 200, + versions_body(&[("1.9.0", 5, false), ("1.8.0", 50, false)]), + ), + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("foo", "1.9.0")]; + let packages = packages_with_dual_source_foo(); + let outcomes = generate_suggestions( + &mut client, + &violations, + &packages, + &direct_requirements, + dir.path(), + 30, + false, + now(), + ) + .unwrap(); + + match &outcomes[0] { + Outcome::Suggest { + package_spec, + suggested_version, + unverified_dependents, + .. + } => { + assert_eq!(suggested_version, "1.8.0"); + assert_eq!(package_spec, &format!("{CRATES_IO_SOURCE}#foo")); + assert!( + unverified_dependents.is_empty(), + "app is verified by its crates.io ^1.0 declaration: {unverified_dependents:?}" + ); + } + Outcome::Blocked { blocker, .. } => panic!( + "expected foo to be Suggest, but was Blocked by {} \ + (the renamed-registry declaration must have leaked in)", + blocker.name + ), + _ => panic!("expected foo to be Suggest"), + } + } + + #[test] + fn reverse_roles_still_block_via_the_crates_io_declaration() { + // Same fixture, roles swapped: crates.io foo is now pinned to + // =1.9.0 and the renamed private-registry foo carries the + // lenient ^1.0. The crates.io pin must still block the + // downgrade, reported as the blocker. + let dir = tempdir().unwrap(); + write_manifest( + dir.path(), + r#" +[package] +name = "app" +version = "0.1.0" + +[dependencies] +foo = "=1.9.0" +foo_priv = { package = "foo", version = "^1.0", registry = "priv" } +"#, + ); + let (direct_requirements, warnings) = load_direct_requirements(dir.path()); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + let transport = FakeTransport::default(); + transport.push( + &versions_url("foo"), + ScriptedResponse::Http( + 200, + versions_body(&[("1.9.0", 5, false), ("1.8.0", 50, false)]), + ), + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("foo", "1.9.0")]; + let packages = packages_with_dual_source_foo(); + let outcomes = generate_suggestions( + &mut client, + &violations, + &packages, + &direct_requirements, + dir.path(), + 30, + false, + now(), + ) + .unwrap(); + + match &outcomes[0] { + Outcome::Blocked { + newest_compliant, + blocker, + .. + } => { + assert_eq!(newest_compliant, "1.8.0"); + assert_eq!(blocker.name, "Cargo.toml"); + assert_eq!(blocker.version, None); + assert_eq!(blocker.req, "=1.9.0"); + } + _ => panic!("expected foo to be Blocked by the crates.io declaration"), + } + } + + #[test] + fn a_second_crates_io_declaration_for_the_same_identity_still_blocks() { + // Both declarations are ordinary crates.io dependencies — no + // registry collision at all — one lenient, one restrictive. + // Guards against the crates.io source filter collapsing + // enforcement down to a single matching declaration. + let dir = tempdir().unwrap(); + write_manifest( + dir.path(), + r#" +[package] +name = "app" +version = "0.1.0" + +[dependencies] +foo = "^1.0" +foo_pinned = { package = "foo", version = "=1.9.0" } +"#, + ); + let (direct_requirements, warnings) = load_direct_requirements(dir.path()); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + assert_eq!(direct_requirements.len(), 2); + assert!( + direct_requirements + .iter() + .all(|r| r.source == RequirementSource::CratesIo) + ); + + let transport = FakeTransport::default(); + transport.push( + &versions_url("foo"), + ScriptedResponse::Http( + 200, + versions_body(&[("1.9.0", 5, false), ("1.8.0", 50, false)]), + ), + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("foo", "1.9.0")]; + let packages = vec![ + Package { + name: "foo".to_string(), + version: "1.9.0".to_string(), + is_registry: true, + source: Some(CRATES_IO_SOURCE.to_string()), + dependencies: vec![], + }, + Package { + name: "app".to_string(), + version: "0.1.0".to_string(), + is_registry: false, + source: None, + dependencies: vec![PackageRef { + name: "foo".to_string(), + version: "1.9.0".to_string(), + source: Some(CRATES_IO_SOURCE.to_string()), + }], + }, + ]; + let outcomes = generate_suggestions( + &mut client, + &violations, + &packages, + &direct_requirements, + dir.path(), + 30, + false, + now(), + ) + .unwrap(); + + match &outcomes[0] { + Outcome::Blocked { + newest_compliant, + blocker, + .. + } => { + assert_eq!(newest_compliant, "1.8.0"); + assert_eq!(blocker.req, "=1.9.0"); + } + _ => panic!( + "expected foo to be Blocked by the restrictive =1.9.0 declaration \ + alongside the lenient ^1.0 one" + ), + } + } + } } From 4e472d1d7439ed479abd3173827e82d55489a67b Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Tue, 8 Sep 2026 14:14:25 -0400 Subject: [PATCH 17/34] fix(suggest): recognize explicit crates-io registry identity Cargo reserves the name `crates-io` for the default registry, and a `registry-index` naming crates.io's own git or sparse index URL directly is the same registry under its literal address. The prior fix treated any `registry`/`registry-index` field as an alternate registry unconditionally, so a manifest requirement written as `registry = "crates-io"` was wrongly excluded from the crates.io suggestion flow instead of being enforced as an ordinary crates.io constraint. requirement_source now recognizes these identities and reports RequirementSource::CratesIo for them, alongside a bare (unqualified) dependency. --- src/manifest.rs | 54 ++++++++++++++++++++++++++++++++++++++-- src/suggest.rs | 66 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 2 deletions(-) diff --git a/src/manifest.rs b/src/manifest.rs index 29923e7..341796d 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -10,10 +10,13 @@ use std::path::{Path, PathBuf}; /// resolve the alias to a source. Recording that identity — without /// claiming it resolves to any particular lockfile source — is enough to /// keep it out of the crates.io suggestion flow, which only ever concerns -/// itself with `CratesIo` requirements. +/// itself with `CratesIo` requirements. Cargo reserves `crates-io` as the +/// name of the default registry and also accepts its index URL directly, so +/// both are recognized as `CratesIo` rather than an alternate registry. #[derive(Debug, Clone, PartialEq, Eq)] pub enum RequirementSource { - /// No `registry` or `registry-index` on the declaration: an ordinary + /// No `registry`/`registry-index` on the declaration, or one explicitly + /// naming crates.io itself (`crates-io`, or its index URL): an ordinary /// crates.io dependency. CratesIo, /// An explicitly named alternate registry (alias or raw index URL). The @@ -22,6 +25,19 @@ pub enum RequirementSource { Registry(String), } +/// The identities Cargo treats as naming crates.io itself: the reserved +/// `crates-io` registry alias, and crates.io's own git and sparse index +/// URLs (either of which `registry-index` may name directly). +const CRATES_IO_REGISTRY_NAME: &str = "crates-io"; +const CRATES_IO_GIT_INDEX: &str = "https://github.com/rust-lang/crates.io-index"; +const CRATES_IO_SPARSE_INDEX: &str = "sparse+https://index.crates.io/"; + +fn is_crates_io_identity(registry: &str) -> bool { + registry == CRATES_IO_REGISTRY_NAME + || registry == CRATES_IO_GIT_INDEX + || registry == CRATES_IO_SPARSE_INDEX +} + /// One version requirement the user's own manifests place on a registry /// crate, together with the manifest that placed it (for warnings and /// diagnostics), the name of the package that manifest declares — callers @@ -244,6 +260,7 @@ fn requirement_source(dep: &Dependency) -> RequirementSource { .detail() .and_then(|d| d.registry.clone().or_else(|| d.registry_index.clone())) { + Some(registry) if is_crates_io_identity(®istry) => RequirementSource::CratesIo, Some(registry) => RequirementSource::Registry(registry), None => RequirementSource::CratesIo, } @@ -543,6 +560,39 @@ serde = { workspace = true } ); } + #[test] + fn explicit_crates_io_registry_identity_is_recognized() { + // `registry = "crates-io"` is Cargo's reserved name for the default + // registry, and a `registry-index` naming crates.io's own index URL + // directly is the same registry under its literal address. Neither + // is an alternate registry, so both must be `CratesIo`. + let dir = tempdir().unwrap(); + write( + dir.path(), + "Cargo.toml", + r#" +[package] +name = "root" +version = "0.1.0" + +[dependencies] +by_name = { package = "serde", version = "1.0", registry = "crates-io" } +by_git_index = { package = "rand", version = "0.8", registry-index = "https://github.com/rust-lang/crates.io-index" } +by_sparse_index = { package = "libc", version = "0.2", registry-index = "sparse+https://index.crates.io/" } +"#, + ); + + let (reqs, warnings) = load_direct_requirements(dir.path()); + assert!(warnings.is_empty()); + for name in ["serde", "rand", "libc"] { + assert_eq!( + find(&reqs, name).source, + RequirementSource::CratesIo, + "{name} should resolve to crates.io" + ); + } + } + #[test] fn missing_manifest_degrades_to_a_warning() { let dir = tempdir().unwrap(); diff --git a/src/suggest.rs b/src/suggest.rs index 89171ed..1037f64 100644 --- a/src/suggest.rs +++ b/src/suggest.rs @@ -2545,6 +2545,72 @@ foo_priv = { package = "foo", version = "^1.0", registry = "priv" } } } + #[test] + fn explicit_crates_io_registry_name_still_blocks() { + // Same fixture as `reverse_roles_still_block_via_the_crates_io_declaration`, + // but the crates.io declaration names its registry explicitly + // via the reserved `crates-io` alias instead of omitting + // `registry` altogether. It must still be recognized as + // crates.io and enforced, not excluded as an unrecognized + // alternate registry. + let dir = tempdir().unwrap(); + write_manifest( + dir.path(), + r#" +[package] +name = "app" +version = "0.1.0" + +[dependencies] +foo = { version = "=1.9.0", registry = "crates-io" } +foo_priv = { package = "foo", version = "^1.0", registry = "priv" } +"#, + ); + let (direct_requirements, warnings) = load_direct_requirements(dir.path()); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + let transport = FakeTransport::default(); + transport.push( + &versions_url("foo"), + ScriptedResponse::Http( + 200, + versions_body(&[("1.9.0", 5, false), ("1.8.0", 50, false)]), + ), + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("foo", "1.9.0")]; + let packages = packages_with_dual_source_foo(); + let outcomes = generate_suggestions( + &mut client, + &violations, + &packages, + &direct_requirements, + dir.path(), + 30, + false, + now(), + ) + .unwrap(); + + match &outcomes[0] { + Outcome::Blocked { + newest_compliant, + blocker, + .. + } => { + assert_eq!(newest_compliant, "1.8.0"); + assert_eq!(blocker.name, "Cargo.toml"); + assert_eq!(blocker.version, None); + assert_eq!(blocker.req, "=1.9.0"); + } + _ => panic!( + "expected foo to be Blocked by the explicit crates-io declaration, \ + not excluded as an unrecognized alternate registry" + ), + } + } + #[test] fn a_second_crates_io_declaration_for_the_same_identity_still_blocks() { // Both declarations are ordinary crates.io dependencies — no From e2d5b623b2fbddf066ac5ab4f27dd9f1969d3db1 Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Tue, 8 Sep 2026 14:55:09 -0400 Subject: [PATCH 18/34] fix(suggest): match local manifest requirements to the locked dependent's identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local manifest requirements were scoped by declaring-package name alone, so a git or alternate-registry dependent could inherit an unrelated local package's requirement just because they shared a name (and, with no version check, even without sharing a version). Restrict manifest matching to dependents whose lockfile source is absent (a git or alternate-registry dependent's requirements genuinely can't be read), and match on the declaring package's version as well as its name, carrying that version — including workspace-inherited versions — through requirement collection. --- src/manifest.rs | 119 +++++++++- src/suggest.rs | 569 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 677 insertions(+), 11 deletions(-) diff --git a/src/manifest.rs b/src/manifest.rs index 341796d..e9525c4 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -40,13 +40,21 @@ fn is_crates_io_identity(registry: &str) -> bool { /// One version requirement the user's own manifests place on a registry /// crate, together with the manifest that placed it (for warnings and -/// diagnostics), the name of the package that manifest declares — callers -/// use this to scope a requirement to the lockfile dependent that actually -/// placed it, rather than to every manifest in the workspace that happens to -/// mention the same crate name — and which registry it was declared against. +/// diagnostics), the name and version of the package that manifest declares +/// — callers use this identity, not just the name, to scope a requirement to +/// the lockfile dependent that actually placed it, rather than to every +/// manifest in the workspace that happens to mention the same crate name, or +/// to an unrelated package that happens to share the declaring package's +/// name — and which registry it was declared against. +/// +/// `declaring_version` is `None` when the declaring package's version +/// couldn't be determined — most commonly a `version.workspace = true` whose +/// value the workspace root doesn't actually supply — so callers must treat +/// it as unresolvable identity rather than a wildcard. pub struct DirectRequirement { pub manifest: PathBuf, pub declaring_package: String, + pub declaring_version: Option, pub crate_name: String, pub req: semver::VersionReq, pub source: RequirementSource, @@ -207,13 +215,28 @@ fn collect_requirements( // A manifest with no [package] table (a pure workspace root) declares no // crate identity, so it can never be a lockfile dependent — nothing it // lists (ordinarily nothing) could be scoped to it correctly. - let Some(declaring_package) = manifest.package.as_ref().map(|p| p.name().to_string()) else { + let Some(package) = manifest.package.as_ref() else { return; }; + let declaring_package = package.name().to_string(); + // `version.get()` fails only when the version is still + // `workspace = true` and workspace inheritance never actually resolved + // it (e.g. the workspace root has no `[workspace.package]` value for + // it). `Manifest::from_path` has already applied workspace inheritance + // by this point, so a resolvable version is already resolved here. + let declaring_version = package.version.get().ok().map(|v| v.to_string()); for deps in all_dep_sets(manifest) { for (key, dep) in deps { - collect_one(manifest_path, &declaring_package, key, dep, out, warnings); + collect_one( + manifest_path, + &declaring_package, + declaring_version.as_deref(), + key, + dep, + out, + warnings, + ); } } } @@ -221,6 +244,7 @@ fn collect_requirements( fn collect_one( manifest_path: &Path, declaring_package: &str, + declaring_version: Option<&str>, key: &str, dep: &Dependency, out: &mut Vec, @@ -240,6 +264,7 @@ fn collect_one( Ok(req) => out.push(DirectRequirement { manifest: manifest_path.to_path_buf(), declaring_package: declaring_package.to_string(), + declaring_version: declaring_version.map(str::to_string), crate_name, req: req.clone(), source, @@ -308,6 +333,88 @@ serde = "1.0" find(&reqs, "serde").req, semver::VersionReq::parse("1.0").unwrap() ); + assert_eq!(find(&reqs, "serde").declaring_package, "root"); + assert_eq!( + find(&reqs, "serde").declaring_version.as_deref(), + Some("0.1.0") + ); + } + + #[test] + fn workspace_inherited_declaring_version_is_resolved() { + // "member"'s own `version` is inherited from the workspace root, not + // written directly — the requirement's declaring identity must + // still resolve to the workspace-supplied version, not go missing. + let dir = tempdir().unwrap(); + write( + dir.path(), + "Cargo.toml", + r#" +[workspace] +members = ["member"] + +[workspace.package] +version = "2.3.4" +"#, + ); + write( + dir.path(), + "member/Cargo.toml", + r#" +[package] +name = "member" +version.workspace = true + +[dependencies] +serde = "1.0" +"#, + ); + + let (reqs, warnings) = load_direct_requirements(dir.path()); + assert!(warnings.is_empty()); + assert_eq!( + find(&reqs, "serde").declaring_version.as_deref(), + Some("2.3.4") + ); + } + + #[test] + fn unresolvable_declaring_version_is_reported_as_unavailable() { + // "member" inherits its version from the workspace, but the + // workspace root supplies no `[workspace.package]` value for it — + // the version genuinely can't be determined, and must come back as + // `None` rather than some guessed-at value. + let dir = tempdir().unwrap(); + write( + dir.path(), + "Cargo.toml", + r#" +[workspace] +members = ["member"] +"#, + ); + write( + dir.path(), + "member/Cargo.toml", + r#" +[package] +name = "member" +version.workspace = true + +[dependencies] +serde = "1.0" +"#, + ); + + let (reqs, warnings) = load_direct_requirements(dir.path()); + assert!( + !warnings.is_empty(), + "expected a warning about the unresolved workspace field" + ); + assert!( + reqs.is_empty(), + "the member's manifest failed to load, so it collects no requirements" + ); } #[test] diff --git a/src/suggest.rs b/src/suggest.rs index 1037f64..87ccdc1 100644 --- a/src/suggest.rs +++ b/src/suggest.rs @@ -272,21 +272,29 @@ fn gather_constraints( unreadable = result.unreadable; has_unverified_leftover = result.has_unverified_leftover; constraints.extend(result.constraints); - } else { + } else if dependent.source.is_none() { + // No lockfile source means a local (path/workspace) package — + // `is_registry` only tells us this isn't crates.io, not that it's + // local, so a git or alternate-registry dependent (which does + // carry a source) must not fall into this branch. Only a local + // dependent's own manifest can be read directly. let mut manifest_matched = false; // Scoped to this dependent's own manifest, not every manifest in // the workspace that happens to mention the same crate name — // two members can lock the same crate name at different major - // versions, each with its own unrelated requirement. Also scoped - // to crates.io declarations: this suggestion flow only ever - // targets a crates.io package (see the `is_registry` filter - // above `target_source` in `generate_suggestions`), so a + // versions, each with its own unrelated requirement. Matched by + // the declaring package's name *and* version, since an unrelated + // local package can share the dependent's name without being it. + // Also scoped to crates.io declarations: this suggestion flow + // only ever targets a crates.io package (see the `is_registry` + // filter above `target_source` in `generate_suggestions`), so a // declaration naming an explicit alternate registry can never be // the one that placed this edge and must not be enforced here — // nor must it count toward this dependent being verified. for req in direct_requirements .iter() .filter(|r| r.crate_name == name && r.declaring_package == dependent.name) + .filter(|r| r.declaring_version.as_deref() == Some(dependent.version.as_str())) .filter(|r| r.source == RequirementSource::CratesIo) .filter(|r| { Version::parse(locked_version).is_ok_and(|version| r.req.matches(&version)) @@ -301,6 +309,13 @@ fn gather_constraints( } matched = manifest_matched; unreadable = false; + } else { + // A git or alternate-registry dependent: nothing here can read + // its actual requirement, local manifest or otherwise, so it + // stays unverified regardless of what any local package's own + // manifest happens to say. + matched = false; + unreadable = false; } if !matched || unreadable || has_unverified_leftover { unverified_dependents.push(dependent.name.clone()); @@ -921,6 +936,42 @@ mod tests { } } + /// A dependent whose lockfile source is present but isn't crates.io + /// — a git or alternate-registry package. Unlike `non_registry_pkg` + /// (a local/path package, `source: None`), nothing here can read its + /// requirements: not the crates.io index (it's not on crates.io), + /// and not a local manifest (it has no manifest this crate can find). + fn external_pkg(name: &str, version: &str, source: &str, deps: &[(&str, &str)]) -> Package { + Package { + is_registry: false, + source: Some(source.to_string()), + ..pkg(name, version, deps) + } + } + + const GIT_SOURCE: &str = + "git+https://github.com/example/app#0000000000000000000000000000000000000000"; + const ALT_REGISTRY_SOURCE: &str = "registry+https://example.com/priv-index"; + + /// A `DirectRequirement` naming `declaring_package`/`declaring_version` + /// as the identity of the manifest that placed it — the shape + /// `load_direct_requirements` produces for a real local manifest. + fn local_requirement( + declaring_package: &str, + declaring_version: &str, + crate_name: &str, + req: &str, + ) -> DirectRequirement { + DirectRequirement { + manifest: "/work/Cargo.toml".into(), + declaring_package: declaring_package.to_string(), + declaring_version: Some(declaring_version.to_string()), + crate_name: crate_name.to_string(), + req: VersionReq::parse(req).unwrap(), + source: RequirementSource::CratesIo, + } + } + #[test] fn aliased_registry_requirements_follow_the_locked_version() { let transport = FakeTransport::default(); @@ -1004,6 +1055,7 @@ mod tests { .map(|req| DirectRequirement { manifest: "/work/Cargo.toml".into(), declaring_package: "app".to_string(), + declaring_version: Some("1.0.0".to_string()), crate_name: "foo".to_string(), req: VersionReq::parse(req).unwrap(), source: RequirementSource::CratesIo, @@ -1032,6 +1084,179 @@ mod tests { } } + #[test] + fn matching_local_identity_with_a_permissive_requirement_allows_the_downgrade() { + // Positive control: a genuine local dependent, matched by both + // name and version, whose manifest requirement is loose enough + // to permit the downgrade — the ordinary case this whole path + // exists for. + let mut client = fast_client(FakeTransport::default()); + let packages = vec![non_registry_pkg("app", "1.0.0", &[("foo", "1.5.0")])]; + let requirements = vec![local_requirement("app", "1.0.0", "foo", "^1")]; + let index = build_dependents_index(&packages); + + let gathered = gather_constraints( + &mut client, + &index, + &requirements, + Path::new("/work"), + "foo", + "1.5.0", + None, + ); + assert_eq!(gathered.constraints.len(), 1); + assert!(gathered.unverified_dependents.is_empty()); + assert!(matches!( + walk( + vec![(Version::parse("1.4.0").unwrap(), 50)], + gathered.constraints + ), + WalkResult::Suggest(_, _) + )); + } + + #[test] + fn matching_local_identity_with_a_restrictive_requirement_blocks_the_downgrade() { + // Positive control, the other direction: the same identity + // match, but the requirement is restrictive enough to reject + // the downgrade candidate. + let mut client = fast_client(FakeTransport::default()); + let packages = vec![non_registry_pkg("app", "1.0.0", &[("foo", "1.5.0")])]; + let requirements = vec![local_requirement("app", "1.0.0", "foo", "^1.5")]; + let index = build_dependents_index(&packages); + + let gathered = gather_constraints( + &mut client, + &index, + &requirements, + Path::new("/work"), + "foo", + "1.5.0", + None, + ); + assert_eq!(gathered.constraints.len(), 1); + assert!(gathered.unverified_dependents.is_empty()); + assert!(matches!( + walk( + vec![(Version::parse("1.4.0").unwrap(), 50)], + gathered.constraints + ), + WalkResult::Blocked { .. } + )); + } + + #[test] + fn git_dependent_sharing_a_local_packages_name_and_version_stays_unverified() { + // A git "app" and a local "app" happen to share a name and + // version. The local one's manifest permits the downgrade, but + // that manifest was never the git dependent's own — it must not + // be credited with verifying it. + let mut client = fast_client(FakeTransport::default()); + let packages = vec![ + external_pkg("app", "1.0.0", GIT_SOURCE, &[("foo", "1.5.0")]), + non_registry_pkg("app", "1.0.0", &[]), + ]; + let requirements = vec![local_requirement("app", "1.0.0", "foo", "^1")]; + let index = build_dependents_index(&packages); + + let gathered = gather_constraints( + &mut client, + &index, + &requirements, + Path::new("/work"), + "foo", + "1.5.0", + None, + ); + assert!(gathered.constraints.is_empty()); + assert_eq!(gathered.unverified_dependents, ["app"]); + } + + #[test] + fn alternate_registry_dependent_sharing_a_local_packages_name_and_version_stays_unverified() + { + // Same shape as the git case, but the external dependent is on + // an alternate registry instead. + let mut client = fast_client(FakeTransport::default()); + let packages = vec![ + external_pkg("app", "1.0.0", ALT_REGISTRY_SOURCE, &[("foo", "1.5.0")]), + non_registry_pkg("app", "1.0.0", &[]), + ]; + let requirements = vec![local_requirement("app", "1.0.0", "foo", "^1")]; + let index = build_dependents_index(&packages); + + let gathered = gather_constraints( + &mut client, + &index, + &requirements, + Path::new("/work"), + "foo", + "1.5.0", + None, + ); + assert!(gathered.constraints.is_empty()); + assert_eq!(gathered.unverified_dependents, ["app"]); + } + + #[test] + fn restrictive_unrelated_local_requirement_does_not_block_the_external_dependents_target() { + // The local "app" shares the git dependent's name and version, + // but has no dependency edge of its own onto "foo" at all — its + // manifest requirement is unrelated noise. Even though it's + // restrictive, it must not block foo's downgrade for the git + // dependent, whose own requirement can't be read at all. + let mut client = fast_client(FakeTransport::default()); + let packages = vec![ + external_pkg("app", "1.0.0", GIT_SOURCE, &[("foo", "1.5.0")]), + non_registry_pkg("app", "1.0.0", &[]), + ]; + let requirements = vec![local_requirement("app", "1.0.0", "foo", "^2")]; + let index = build_dependents_index(&packages); + + let gathered = gather_constraints( + &mut client, + &index, + &requirements, + Path::new("/work"), + "foo", + "1.5.0", + None, + ); + assert!(gathered.constraints.is_empty()); + assert_eq!(gathered.unverified_dependents, ["app"]); + assert!(matches!( + walk( + vec![(Version::parse("1.4.0").unwrap(), 50)], + gathered.constraints + ), + WalkResult::Suggest(_, _) + )); + } + + #[test] + fn local_declaring_version_mismatch_neither_verifies_nor_constrains() { + // A local "app" 2.0.0 declares a restrictive requirement on + // "foo", but the actual lockfile dependent is a *different* + // "app" — 1.0.0 — with an identical name. Declaring package + // name alone must not be enough to apply this requirement. + let mut client = fast_client(FakeTransport::default()); + let packages = vec![non_registry_pkg("app", "1.0.0", &[("foo", "1.5.0")])]; + let requirements = vec![local_requirement("app", "2.0.0", "foo", "^1.5")]; + let index = build_dependents_index(&packages); + + let gathered = gather_constraints( + &mut client, + &index, + &requirements, + Path::new("/work"), + "foo", + "1.5.0", + None, + ); + assert!(gathered.constraints.is_empty()); + assert_eq!(gathered.unverified_dependents, ["app"]); + } + #[test] fn only_too_new_violations_are_fetched() { let transport = FakeTransport::default(); @@ -1915,6 +2140,7 @@ mod tests { crate::manifest::DirectRequirement { manifest: PathBuf::from("/work/member_a/Cargo.toml"), declaring_package: "member_a".to_string(), + declaring_version: Some("0.1.0".to_string()), crate_name: "clap".to_string(), req: VersionReq::parse("^2").unwrap(), source: RequirementSource::CratesIo, @@ -1922,6 +2148,7 @@ mod tests { crate::manifest::DirectRequirement { manifest: PathBuf::from("/work/member_b/Cargo.toml"), declaring_package: "member_b".to_string(), + declaring_version: Some("0.1.0".to_string()), crate_name: "clap".to_string(), req: VersionReq::parse("^3").unwrap(), source: RequirementSource::CratesIo, @@ -2698,4 +2925,336 @@ foo_pinned = { package = "foo", version = "=1.9.0" } } } } + + /// Covers matching a real local dependent's manifest requirement against + /// its own locked identity (name and version), including workspace + /// version inheritance, rather than name alone. + mod manifest_dependent_identity_tests { + use super::*; + use crate::api::RetryPolicy; + use crate::api::test_support::{FakeTransport, ScriptedResponse, versions_url}; + use crate::manifest::load_direct_requirements; + use crate::report::Aged; + use std::num::NonZeroU32; + use std::time::Duration; + use tempfile::tempdir; + + const CRATES_IO_SOURCE: &str = "registry+https://github.com/rust-lang/crates.io-index"; + const GIT_SOURCE: &str = + "git+https://github.com/example/vendor#0000000000000000000000000000000000000000"; + + fn versions_body(entries: &[(&str, i64, bool)]) -> String { + let versions: Vec = entries + .iter() + .map(|(num, days_ago, yanked)| { + let created_at = now() - chrono::Duration::days(*days_ago); + format!( + r#"{{"num":"{num}","created_at":"{}","yanked":{yanked}}}"#, + created_at.to_rfc3339() + ) + }) + .collect(); + format!(r#"{{"versions":[{}]}}"#, versions.join(",")) + } + + fn fast_client(transport: FakeTransport) -> CratesIoClient { + CratesIoClient::with_transport( + transport, + None, + 24, + RetryPolicy { + retry_count: NonZeroU32::new(1).unwrap(), + retry_delay: Duration::from_millis(0), + pacing_delay: Duration::from_millis(0), + }, + ) + } + + fn too_new(package: &str, locked_version: &str) -> Violation { + Violation { + package: package.to_string(), + version: locked_version.to_string(), + kind: ViolationKind::TooNew(Aged { + published: now() - chrono::Duration::days(5), + age_days: 5, + }), + } + } + + fn write_manifest(dir: &Path, rel: &str, contents: &str) { + let path = dir.join(rel); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(&path, contents).unwrap(); + } + + #[test] + fn workspace_inherited_declaring_version_matches_the_locked_local_dependent() { + // "app"'s own version is inherited from the workspace root + // rather than written directly. Its requirement on foo must + // still be recognized as its own — matched by the resolved + // version, not skipped for lack of one. + let dir = tempdir().unwrap(); + write_manifest( + dir.path(), + "Cargo.toml", + r#" +[workspace] +members = ["app"] + +[workspace.package] +version = "0.1.0" +"#, + ); + write_manifest( + dir.path(), + "app/Cargo.toml", + r#" +[package] +name = "app" +version.workspace = true + +[dependencies] +foo = "^1.0" +"#, + ); + let (direct_requirements, warnings) = load_direct_requirements(dir.path()); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + let transport = FakeTransport::default(); + transport.push( + &versions_url("foo"), + ScriptedResponse::Http( + 200, + versions_body(&[("1.9.0", 5, false), ("1.8.0", 50, false)]), + ), + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("foo", "1.9.0")]; + let packages = vec![ + Package { + name: "foo".to_string(), + version: "1.9.0".to_string(), + is_registry: true, + source: Some(CRATES_IO_SOURCE.to_string()), + dependencies: vec![], + }, + Package { + name: "app".to_string(), + version: "0.1.0".to_string(), + is_registry: false, + source: None, + dependencies: vec![PackageRef { + name: "foo".to_string(), + version: "1.9.0".to_string(), + source: Some(CRATES_IO_SOURCE.to_string()), + }], + }, + ]; + let outcomes = generate_suggestions( + &mut client, + &violations, + &packages, + &direct_requirements, + dir.path(), + 30, + false, + now(), + ) + .unwrap(); + + match &outcomes[0] { + Outcome::Suggest { + suggested_version, + unverified_dependents, + .. + } => { + assert_eq!(suggested_version, "1.8.0"); + assert!( + unverified_dependents.is_empty(), + "app's workspace-inherited version should have matched: {unverified_dependents:?}" + ); + } + _ => panic!("expected foo to be Suggest"), + } + } + + #[test] + fn unresolvable_declaring_version_does_not_falsely_verify_the_dependent() { + // "app" inherits its version from the workspace, but the + // workspace root supplies none — the member's manifest fails to + // load entirely, so no requirement is ever collected for it. + // "app" must come back unverified, not silently treated as + // matching by name alone. + let dir = tempdir().unwrap(); + write_manifest( + dir.path(), + "Cargo.toml", + r#" +[workspace] +members = ["app"] +"#, + ); + write_manifest( + dir.path(), + "app/Cargo.toml", + r#" +[package] +name = "app" +version.workspace = true + +[dependencies] +foo = "^1.0" +"#, + ); + let (direct_requirements, warnings) = load_direct_requirements(dir.path()); + assert!( + !warnings.is_empty(), + "expected a warning about app's unresolved workspace version" + ); + + let transport = FakeTransport::default(); + transport.push( + &versions_url("foo"), + ScriptedResponse::Http( + 200, + versions_body(&[("1.9.0", 5, false), ("1.8.0", 50, false)]), + ), + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("foo", "1.9.0")]; + let packages = vec![ + Package { + name: "foo".to_string(), + version: "1.9.0".to_string(), + is_registry: true, + source: Some(CRATES_IO_SOURCE.to_string()), + dependencies: vec![], + }, + Package { + name: "app".to_string(), + version: "0.1.0".to_string(), + is_registry: false, + source: None, + dependencies: vec![PackageRef { + name: "foo".to_string(), + version: "1.9.0".to_string(), + source: Some(CRATES_IO_SOURCE.to_string()), + }], + }, + ]; + let outcomes = generate_suggestions( + &mut client, + &violations, + &packages, + &direct_requirements, + dir.path(), + 30, + false, + now(), + ) + .unwrap(); + + match &outcomes[0] { + Outcome::Suggest { + unverified_dependents, + .. + } => assert_eq!(unverified_dependents, &["app".to_string()]), + _ => panic!("expected foo to be Suggest, with app marked unverified"), + } + } + + #[test] + fn real_local_constraint_is_enforced_while_an_external_dependent_stays_unverified() { + // "app" is a real local dependent whose manifest permits the + // downgrade; "vendor" is a git dependent that also locks foo at + // the same version, but nothing here can read its requirement. + // The suggestion must reflect app's real (permissive) + // constraint while still flagging vendor as unverified. + let dir = tempdir().unwrap(); + write_manifest( + dir.path(), + "Cargo.toml", + r#" +[package] +name = "app" +version = "0.1.0" + +[dependencies] +foo = "^1.0" +"#, + ); + let (direct_requirements, warnings) = load_direct_requirements(dir.path()); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + let transport = FakeTransport::default(); + transport.push( + &versions_url("foo"), + ScriptedResponse::Http( + 200, + versions_body(&[("1.9.0", 5, false), ("1.8.0", 50, false)]), + ), + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("foo", "1.9.0")]; + let packages = vec![ + Package { + name: "foo".to_string(), + version: "1.9.0".to_string(), + is_registry: true, + source: Some(CRATES_IO_SOURCE.to_string()), + dependencies: vec![], + }, + Package { + name: "app".to_string(), + version: "0.1.0".to_string(), + is_registry: false, + source: None, + dependencies: vec![PackageRef { + name: "foo".to_string(), + version: "1.9.0".to_string(), + source: Some(CRATES_IO_SOURCE.to_string()), + }], + }, + Package { + name: "vendor".to_string(), + version: "1.0.0".to_string(), + is_registry: false, + source: Some(GIT_SOURCE.to_string()), + dependencies: vec![PackageRef { + name: "foo".to_string(), + version: "1.9.0".to_string(), + source: Some(CRATES_IO_SOURCE.to_string()), + }], + }, + ]; + let outcomes = generate_suggestions( + &mut client, + &violations, + &packages, + &direct_requirements, + dir.path(), + 30, + false, + now(), + ) + .unwrap(); + + match &outcomes[0] { + Outcome::Suggest { + suggested_version, + unverified_dependents, + .. + } => { + assert_eq!(suggested_version, "1.8.0"); + assert_eq!(unverified_dependents, &["vendor".to_string()]); + } + _ => panic!("expected foo to be Suggest, with vendor marked unverified"), + } + } + } } From 69bc88bd2f48ae3e1da47f44c14bac6abdee8ef5 Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Tue, 8 Sep 2026 15:42:23 -0400 Subject: [PATCH 19/34] test(suggest): make dependent-identity tests fail without the source guard --- src/suggest.rs | 195 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 187 insertions(+), 8 deletions(-) diff --git a/src/suggest.rs b/src/suggest.rs index 87ccdc1..0baf30e 100644 --- a/src/suggest.rs +++ b/src/suggest.rs @@ -1202,15 +1202,16 @@ mod tests { fn restrictive_unrelated_local_requirement_does_not_block_the_external_dependents_target() { // The local "app" shares the git dependent's name and version, // but has no dependency edge of its own onto "foo" at all — its - // manifest requirement is unrelated noise. Even though it's - // restrictive, it must not block foo's downgrade for the git + // manifest requirement is unrelated noise. The requirement + // matches the locked version but would block candidate 1.4.0; + // even so, it must not block foo's downgrade for the git // dependent, whose own requirement can't be read at all. let mut client = fast_client(FakeTransport::default()); let packages = vec![ external_pkg("app", "1.0.0", GIT_SOURCE, &[("foo", "1.5.0")]), non_registry_pkg("app", "1.0.0", &[]), ]; - let requirements = vec![local_requirement("app", "1.0.0", "foo", "^2")]; + let requirements = vec![local_requirement("app", "1.0.0", "foo", "^1.5")]; let index = build_dependents_index(&packages); let gathered = gather_constraints( @@ -2942,6 +2943,7 @@ foo_pinned = { package = "foo", version = "=1.9.0" } const CRATES_IO_SOURCE: &str = "registry+https://github.com/rust-lang/crates.io-index"; const GIT_SOURCE: &str = "git+https://github.com/example/vendor#0000000000000000000000000000000000000000"; + const ALT_REGISTRY_SOURCE: &str = "registry+https://example.com/priv-index"; fn versions_body(entries: &[(&str, i64, bool)]) -> String { let versions: Vec = entries @@ -3172,8 +3174,9 @@ foo = "^1.0" // "app" is a real local dependent whose manifest permits the // downgrade; "vendor" is a git dependent that also locks foo at // the same version, but nothing here can read its requirement. - // The suggestion must reflect app's real (permissive) - // constraint while still flagging vendor as unverified. + // 1.8.0 is the newer, otherwise-preferred candidate, but app's + // manifest rejects it, so enforcement must fall back to 1.8.5 + // while still flagging vendor as unverified. let dir = tempdir().unwrap(); write_manifest( dir.path(), @@ -3184,7 +3187,7 @@ name = "app" version = "0.1.0" [dependencies] -foo = "^1.0" +foo = "^1.8.5" "#, ); let (direct_requirements, warnings) = load_direct_requirements(dir.path()); @@ -3195,7 +3198,11 @@ foo = "^1.0" &versions_url("foo"), ScriptedResponse::Http( 200, - versions_body(&[("1.9.0", 5, false), ("1.8.0", 50, false)]), + versions_body(&[ + ("1.9.0", 5, false), + ("1.8.0", 40, false), + ("1.8.5", 50, false), + ]), ), ); let mut client = fast_client(transport); @@ -3250,11 +3257,183 @@ foo = "^1.0" unverified_dependents, .. } => { - assert_eq!(suggested_version, "1.8.0"); + assert_eq!(suggested_version, "1.8.5"); assert_eq!(unverified_dependents, &["vendor".to_string()]); } _ => panic!("expected foo to be Suggest, with vendor marked unverified"), } } + + #[test] + fn git_dependent_sharing_a_local_packages_identity_is_not_verified_by_its_manifest() { + // A git "app" and the local "app" share name and version; the + // local manifest permits the downgrade but was never the git + // dependent's own, so it must not verify it. + let dir = tempdir().unwrap(); + write_manifest( + dir.path(), + "Cargo.toml", + r#" +[package] +name = "app" +version = "1.0.0" + +[dependencies] +foo = "^1.0" +"#, + ); + let (direct_requirements, warnings) = load_direct_requirements(dir.path()); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + let transport = FakeTransport::default(); + transport.push( + &versions_url("foo"), + ScriptedResponse::Http( + 200, + versions_body(&[("1.9.0", 5, false), ("1.8.0", 50, false)]), + ), + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("foo", "1.9.0")]; + let packages = vec![ + Package { + name: "foo".to_string(), + version: "1.9.0".to_string(), + is_registry: true, + source: Some(CRATES_IO_SOURCE.to_string()), + dependencies: vec![], + }, + Package { + name: "app".to_string(), + version: "1.0.0".to_string(), + is_registry: false, + source: Some(GIT_SOURCE.to_string()), + dependencies: vec![PackageRef { + name: "foo".to_string(), + version: "1.9.0".to_string(), + source: Some(CRATES_IO_SOURCE.to_string()), + }], + }, + Package { + name: "app".to_string(), + version: "1.0.0".to_string(), + is_registry: false, + source: None, + dependencies: vec![], + }, + ]; + let outcomes = generate_suggestions( + &mut client, + &violations, + &packages, + &direct_requirements, + dir.path(), + 30, + false, + now(), + ) + .unwrap(); + + match &outcomes[0] { + Outcome::Suggest { + suggested_version, + unverified_dependents, + .. + } => { + assert_eq!(suggested_version, "1.8.0"); + assert_eq!(unverified_dependents, &["app".to_string()]); + } + _ => panic!("expected foo to be Suggest, with the git app marked unverified"), + } + } + + #[test] + fn alternate_registry_dependent_sharing_a_local_packages_identity_is_not_verified_by_its_manifest() + { + // An alternate-registry "app" and the local "app" share name and + // version; the local manifest permits the downgrade but was + // never the alternate-registry dependent's own, so it must not + // verify it. + let dir = tempdir().unwrap(); + write_manifest( + dir.path(), + "Cargo.toml", + r#" +[package] +name = "app" +version = "1.0.0" + +[dependencies] +foo = "^1.0" +"#, + ); + let (direct_requirements, warnings) = load_direct_requirements(dir.path()); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + let transport = FakeTransport::default(); + transport.push( + &versions_url("foo"), + ScriptedResponse::Http( + 200, + versions_body(&[("1.9.0", 5, false), ("1.8.0", 50, false)]), + ), + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("foo", "1.9.0")]; + let packages = vec![ + Package { + name: "foo".to_string(), + version: "1.9.0".to_string(), + is_registry: true, + source: Some(CRATES_IO_SOURCE.to_string()), + dependencies: vec![], + }, + Package { + name: "app".to_string(), + version: "1.0.0".to_string(), + is_registry: false, + source: Some(ALT_REGISTRY_SOURCE.to_string()), + dependencies: vec![PackageRef { + name: "foo".to_string(), + version: "1.9.0".to_string(), + source: Some(CRATES_IO_SOURCE.to_string()), + }], + }, + Package { + name: "app".to_string(), + version: "1.0.0".to_string(), + is_registry: false, + source: None, + dependencies: vec![], + }, + ]; + let outcomes = generate_suggestions( + &mut client, + &violations, + &packages, + &direct_requirements, + dir.path(), + 30, + false, + now(), + ) + .unwrap(); + + match &outcomes[0] { + Outcome::Suggest { + suggested_version, + unverified_dependents, + .. + } => { + assert_eq!(suggested_version, "1.8.0"); + assert_eq!(unverified_dependents, &["app".to_string()]); + } + _ => panic!( + "expected foo to be Suggest, with the alternate-registry app marked unverified" + ), + } + } } } From b7db6cf0d572ecb8c9af2de6b8cdb6095b700fe1 Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Tue, 8 Sep 2026 16:49:48 -0400 Subject: [PATCH 20/34] Fix --- src/manifest.rs | 216 ++++++++++++++++++++++++++++++++++++++++++++---- src/suggest.rs | 106 ++++++++++++++++++++++++ 2 files changed, 304 insertions(+), 18 deletions(-) diff --git a/src/manifest.rs b/src/manifest.rs index e9525c4..6beba3f 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -70,7 +70,11 @@ pub struct DirectRequirement { /// not listed under `members` is still read. `dependencies`, /// `dev-dependencies`, `build-dependencies`, and each `[target.*]` table are /// all walked; path and git dependencies are skipped since they carry no -/// registry version. +/// registry version. A path dependency followed this way is a workspace +/// member — and so has its `dev-dependencies` collected — exactly when the +/// root manifest has a `[workspace]` table and the dependency's directory +/// lies inside the workspace root, unless it matches `workspace.exclude`; +/// matching Cargo's own behavior. /// /// Neither a missing manifest nor one that fails to parse aborts the run: /// each produces a warning in the second return value and is simply @@ -90,38 +94,52 @@ pub fn load_direct_requirements(lockfile_dir: &Path) -> (Vec, let mut seen = HashSet::new(); seen.insert(canonical_or(&root_path)); - let mut manifests = Vec::new(); + // `bool` marks whether the manifest is a workspace member (root or a + // `workspace.members` entry) as opposed to a followed path dependency. + let mut manifests: Vec<(PathBuf, Manifest, bool)> = Vec::new(); + let mut has_workspace = false; + let mut workspace_exclude: Vec = Vec::new(); match load_manifest(&root_path) { Ok(root) => { if let Some(ws) = &root.workspace { + has_workspace = true; + workspace_exclude = ws.exclude.clone(); for member_dir in expand_members(lockfile_dir, ws) { if !seen.insert(canonical_or(&member_dir)) { continue; } let member_path = member_dir.join("Cargo.toml"); match load_manifest(&member_path) { - Ok(m) => manifests.push((member_path, m)), + Ok(m) => manifests.push((member_path, m, true)), Err(e) => warnings.push(e), } } } - manifests.push((root_path, root)); + manifests.push((root_path, root, true)); } Err(e) => warnings.push(e), } // Follow path dependencies one level further, so members not listed - // under `workspace.members` are still read. + // under `workspace.members` are still read. A followed dependency is + // itself a workspace member exactly when the root has a `[workspace]` + // table and its directory lies inside the workspace root, unless it + // matches `workspace.exclude` — matching Cargo's own behavior. + let canonical_root_dir = canonical_or(lockfile_dir); let mut followed = Vec::new(); - for (path, manifest) in &manifests { + for (path, manifest, _) in &manifests { let base_dir = path.parent().unwrap_or(lockfile_dir); for dep_dir in path_dependency_dirs(base_dir, manifest) { if !seen.insert(canonical_or(&dep_dir)) { continue; } let dep_path = dep_dir.join("Cargo.toml"); + let canonical_dep_dir = canonical_or(&dep_dir); + let is_member = has_workspace + && canonical_dep_dir.starts_with(&canonical_root_dir) + && !is_excluded(&canonical_root_dir, &canonical_dep_dir, &workspace_exclude); match load_manifest(&dep_path) { - Ok(m) => followed.push((dep_path, m)), + Ok(m) => followed.push((dep_path, m, is_member)), Err(e) => warnings.push(e), } } @@ -129,8 +147,8 @@ pub fn load_direct_requirements(lockfile_dir: &Path) -> (Vec, manifests.extend(followed); let mut requirements = Vec::new(); - for (path, manifest) in &manifests { - collect_requirements(path, manifest, &mut requirements, &mut warnings); + for (path, manifest, is_member) in &manifests { + collect_requirements(path, manifest, *is_member, &mut requirements, &mut warnings); } (requirements, warnings) @@ -180,7 +198,7 @@ fn is_excluded(root_dir: &Path, member_dir: &Path, exclude: &[String]) -> bool { /// dev, build, or target-specific dependency tables. fn path_dependency_dirs(base_dir: &Path, manifest: &Manifest) -> Vec { let mut dirs = Vec::new(); - for deps in all_dep_sets(manifest) { + for deps in all_dep_sets(manifest, true) { for dep in deps.values() { if let Some(detail) = dep.detail() && let Some(rel_path) = &detail.path @@ -192,16 +210,17 @@ fn path_dependency_dirs(base_dir: &Path, manifest: &Manifest) -> Vec { dirs } -fn all_dep_sets(manifest: &Manifest) -> Vec<&DepsSet> { - let mut sets = vec![ - &manifest.dependencies, - &manifest.dev_dependencies, - &manifest.build_dependencies, - ]; +fn all_dep_sets(manifest: &Manifest, include_dev: bool) -> Vec<&DepsSet> { + let mut sets = vec![&manifest.dependencies, &manifest.build_dependencies]; + if include_dev { + sets.push(&manifest.dev_dependencies); + } for target in manifest.target.values() { sets.push(&target.dependencies); - sets.push(&target.dev_dependencies); sets.push(&target.build_dependencies); + if include_dev { + sets.push(&target.dev_dependencies); + } } sets } @@ -209,6 +228,7 @@ fn all_dep_sets(manifest: &Manifest) -> Vec<&DepsSet> { fn collect_requirements( manifest_path: &Path, manifest: &Manifest, + include_dev: bool, out: &mut Vec, warnings: &mut Vec, ) { @@ -226,7 +246,7 @@ fn collect_requirements( // by this point, so a resolvable version is already resolved here. let declaring_version = package.version.get().ok().map(|v| v.to_string()); - for deps in all_dep_sets(manifest) { + for deps in all_dep_sets(manifest, include_dev) { for (key, dep) in deps { collect_one( manifest_path, @@ -576,6 +596,166 @@ serde = "1.0" assert!(!reqs.iter().any(|r| r.crate_name == "helper")); } + #[test] + fn followed_path_dependency_dev_dependencies_are_skipped() { + let dir = tempdir().unwrap(); + write( + dir.path(), + "Cargo.toml", + r#" +[package] +name = "root" +version = "0.1.0" + +[dependencies] +helper = { path = "helper" } + +[dev-dependencies] +baz = "3" +"#, + ); + write( + dir.path(), + "helper/Cargo.toml", + r#" +[package] +name = "helper" +version = "0.1.0" + +[dependencies] +foo = "1" + +[dev-dependencies] +bar = "2" +"#, + ); + + let (reqs, warnings) = load_direct_requirements(dir.path()); + assert!(warnings.is_empty()); + // helper is not a workspace member, so Cargo ignores its + // dev-dependencies. + assert!(reqs.iter().any(|r| r.crate_name == "foo")); + assert!(!reqs.iter().any(|r| r.crate_name == "bar")); + // The root is a workspace member, so its dev-dependencies are + // still collected. + assert!(reqs.iter().any(|r| r.crate_name == "baz")); + } + + #[test] + fn in_tree_path_dependency_of_workspace_is_a_member() { + let dir = tempdir().unwrap(); + write( + dir.path(), + "Cargo.toml", + r#" +[workspace] +members = [] + +[package] +name = "root" +version = "0.1.0" + +[dependencies] +helper = { path = "helper" } +"#, + ); + write( + dir.path(), + "helper/Cargo.toml", + r#" +[package] +name = "helper" +version = "0.1.0" + +[dev-dependencies] +foo = "=1.9.0" +"#, + ); + + let (reqs, warnings) = load_direct_requirements(dir.path()); + assert!(warnings.is_empty()); + let foo = find(&reqs, "foo"); + assert_eq!(foo.req, semver::VersionReq::parse("=1.9.0").unwrap()); + assert_eq!(foo.declaring_package, "helper"); + } + + #[test] + fn excluded_in_tree_path_dependency_is_not_a_member() { + let dir = tempdir().unwrap(); + write( + dir.path(), + "Cargo.toml", + r#" +[workspace] +members = [] +exclude = ["helper"] + +[package] +name = "root" +version = "0.1.0" + +[dependencies] +helper = { path = "helper" } +"#, + ); + write( + dir.path(), + "helper/Cargo.toml", + r#" +[package] +name = "helper" +version = "0.1.0" + +[dependencies] +bar = "1" + +[dev-dependencies] +foo = "=1.9.0" +"#, + ); + + let (reqs, warnings) = load_direct_requirements(dir.path()); + assert!(warnings.is_empty()); + assert!(!reqs.iter().any(|r| r.crate_name == "foo")); + assert!(reqs.iter().any(|r| r.crate_name == "bar")); + } + + #[test] + fn out_of_tree_path_dependency_is_not_a_member() { + let dir = tempdir().unwrap(); + write( + dir.path(), + "ws/Cargo.toml", + r#" +[workspace] +members = [] + +[package] +name = "root" +version = "0.1.0" + +[dependencies] +helper = { path = "../helper" } +"#, + ); + write( + dir.path(), + "helper/Cargo.toml", + r#" +[package] +name = "helper" +version = "0.1.0" + +[dev-dependencies] +foo = "=1.9.0" +"#, + ); + + let (reqs, warnings) = load_direct_requirements(&dir.path().join("ws")); + assert!(warnings.is_empty()); + assert!(!reqs.iter().any(|r| r.crate_name == "foo")); + } + #[test] fn dependency_table_records_registry_identity() { let dir = tempdir().unwrap(); diff --git a/src/suggest.rs b/src/suggest.rs index 0baf30e..40737e8 100644 --- a/src/suggest.rs +++ b/src/suggest.rs @@ -2925,6 +2925,112 @@ foo_pinned = { package = "foo", version = "=1.9.0" } ), } } + + /// "helper" is a path dependency of root "app", not a workspace + /// member. Cargo ignores the dev-dependencies of a non-member path + /// dependency, so helper's `[dev-dependencies] foo = "=1.9.0"` must + /// not block a downgrade that only helper's own `[dependencies] foo + /// = "1"` would allow. + #[test] + fn dev_dependency_of_a_non_member_path_dependency_does_not_block() { + let dir = tempdir().unwrap(); + write_manifest( + dir.path(), + r#" +[package] +name = "app" +version = "0.1.0" + +[dependencies] +helper = { path = "helper" } +"#, + ); + std::fs::create_dir_all(dir.path().join("helper")).unwrap(); + write_manifest( + &dir.path().join("helper"), + r#" +[package] +name = "helper" +version = "0.1.0" + +[dependencies] +foo = "1" + +[dev-dependencies] +foo = "=1.9.0" +"#, + ); + let (direct_requirements, warnings) = load_direct_requirements(dir.path()); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + let transport = FakeTransport::default(); + transport.push( + &versions_url("foo"), + ScriptedResponse::Http( + 200, + versions_body(&[("1.9.0", 5, false), ("1.8.0", 50, false)]), + ), + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("foo", "1.9.0")]; + let packages = vec![ + Package { + name: "foo".to_string(), + version: "1.9.0".to_string(), + is_registry: true, + source: Some(CRATES_IO_SOURCE.to_string()), + dependencies: vec![], + }, + Package { + name: "helper".to_string(), + version: "0.1.0".to_string(), + is_registry: false, + source: None, + dependencies: vec![PackageRef { + name: "foo".to_string(), + version: "1.9.0".to_string(), + source: Some(CRATES_IO_SOURCE.to_string()), + }], + }, + Package { + name: "app".to_string(), + version: "0.1.0".to_string(), + is_registry: false, + source: None, + dependencies: vec![PackageRef { + name: "helper".to_string(), + version: "0.1.0".to_string(), + source: None, + }], + }, + ]; + let outcomes = generate_suggestions( + &mut client, + &violations, + &packages, + &direct_requirements, + dir.path(), + 30, + false, + now(), + ) + .unwrap(); + + match &outcomes[0] { + Outcome::Suggest { + suggested_version, .. + } => { + assert_eq!(suggested_version, "1.8.0"); + } + Outcome::Blocked { blocker, .. } => panic!( + "expected foo to be Suggest, but was Blocked by {} \ + (helper's dev-dependency must be ignored: it isn't a workspace member)", + blocker.name + ), + _ => panic!("expected foo to be Suggest"), + } + } } /// Covers matching a real local dependent's manifest requirement against From 36aa201421c571fa35dedf1a005acd6f3cee8242 Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Tue, 8 Sep 2026 23:26:18 -0400 Subject: [PATCH 21/34] Refator --- src/manifest.rs | 146 ++++++------------------ src/suggest.rs | 293 ++++++++++++++++++------------------------------ 2 files changed, 147 insertions(+), 292 deletions(-) diff --git a/src/manifest.rs b/src/manifest.rs index 6beba3f..1964196 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -2,32 +2,16 @@ use cargo_toml::{Dependency, DepsSet, Manifest}; use std::collections::HashSet; use std::path::{Path, PathBuf}; -/// Which registry a manifest dependency declaration names. A manifest's -/// `registry` (a Cargo config alias) or `registry-index` (a raw index URL) -/// field identifies an alternate registry by a different representation -/// than the source URL recorded against a lockfile package or dependency -/// edge, and this crate has no access to Cargo's registry configuration to -/// resolve the alias to a source. Recording that identity — without -/// claiming it resolves to any particular lockfile source — is enough to -/// keep it out of the crates.io suggestion flow, which only ever concerns -/// itself with `CratesIo` requirements. Cargo reserves `crates-io` as the -/// name of the default registry and also accepts its index URL directly, so -/// both are recognized as `CratesIo` rather than an alternate registry. +/// A manifest registry identity; aliases are not lockfile source URLs. #[derive(Debug, Clone, PartialEq, Eq)] pub enum RequirementSource { - /// No `registry`/`registry-index` on the declaration, or one explicitly - /// naming crates.io itself (`crates-io`, or its index URL): an ordinary - /// crates.io dependency. + /// The default registry or its accepted name/index identities. CratesIo, - /// An explicitly named alternate registry (alias or raw index URL). The - /// identity is kept for diagnostics; it is never matched against a - /// lockfile source string. + /// An alternate registry alias or raw index, retained for diagnostics. Registry(String), } -/// The identities Cargo treats as naming crates.io itself: the reserved -/// `crates-io` registry alias, and crates.io's own git and sparse index -/// URLs (either of which `registry-index` may name directly). +/// Registry values Cargo treats as crates.io. const CRATES_IO_REGISTRY_NAME: &str = "crates-io"; const CRATES_IO_GIT_INDEX: &str = "https://github.com/rust-lang/crates.io-index"; const CRATES_IO_SPARSE_INDEX: &str = "sparse+https://index.crates.io/"; @@ -38,19 +22,8 @@ fn is_crates_io_identity(registry: &str) -> bool { || registry == CRATES_IO_SPARSE_INDEX } -/// One version requirement the user's own manifests place on a registry -/// crate, together with the manifest that placed it (for warnings and -/// diagnostics), the name and version of the package that manifest declares -/// — callers use this identity, not just the name, to scope a requirement to -/// the lockfile dependent that actually placed it, rather than to every -/// manifest in the workspace that happens to mention the same crate name, or -/// to an unrelated package that happens to share the declaring package's -/// name — and which registry it was declared against. -/// -/// `declaring_version` is `None` when the declaring package's version -/// couldn't be determined — most commonly a `version.workspace = true` whose -/// value the workspace root doesn't actually supply — so callers must treat -/// it as unresolvable identity rather than a wildcard. +/// A registry requirement scoped to its declaring package and registry. +/// A missing `declaring_version` is unresolved, never a wildcard. pub struct DirectRequirement { pub manifest: PathBuf, pub declaring_package: String, @@ -60,25 +33,10 @@ pub struct DirectRequirement { pub source: RequirementSource, } -/// Reads every version requirement the user's own manifests place on -/// registry crates. -/// -/// `lockfile_dir` is the directory containing `Cargo.lock`, where the root -/// `Cargo.toml` is expected to live. Workspace members are expanded from -/// `[workspace.members]` glob patterns with `[workspace.exclude]` applied, -/// and path dependencies are followed one level further so that a member -/// not listed under `members` is still read. `dependencies`, -/// `dev-dependencies`, `build-dependencies`, and each `[target.*]` table are -/// all walked; path and git dependencies are skipped since they carry no -/// registry version. A path dependency followed this way is a workspace -/// member — and so has its `dev-dependencies` collected — exactly when the -/// root manifest has a `[workspace]` table and the dependency's directory -/// lies inside the workspace root, unless it matches `workspace.exclude`; -/// matching Cargo's own behavior. -/// -/// Neither a missing manifest nor one that fails to parse aborts the run: -/// each produces a warning in the second return value and is simply -/// excluded from the (possibly empty) first. +/// Collects registry requirements from the root, members, and one-level path +/// dependencies. Members and in-tree, non-excluded path dependencies include +/// dev dependencies. Missing or invalid manifests produce warnings and are +/// excluded. pub fn load_direct_requirements(lockfile_dir: &Path) -> (Vec, Vec) { let mut warnings = Vec::new(); let root_path = lockfile_dir.join("Cargo.toml"); @@ -120,11 +78,7 @@ pub fn load_direct_requirements(lockfile_dir: &Path) -> (Vec, Err(e) => warnings.push(e), } - // Follow path dependencies one level further, so members not listed - // under `workspace.members` are still read. A followed dependency is - // itself a workspace member exactly when the root has a `[workspace]` - // table and its directory lies inside the workspace root, unless it - // matches `workspace.exclude` — matching Cargo's own behavior. + // Follow one level; only in-tree, non-excluded dependencies are members. let canonical_root_dir = canonical_or(lockfile_dir); let mut followed = Vec::new(); for (path, manifest, _) in &manifests { @@ -232,67 +186,41 @@ fn collect_requirements( out: &mut Vec, warnings: &mut Vec, ) { - // A manifest with no [package] table (a pure workspace root) declares no - // crate identity, so it can never be a lockfile dependent — nothing it - // lists (ordinarily nothing) could be scoped to it correctly. + // A workspace root without [package] has no crate identity to scope. let Some(package) = manifest.package.as_ref() else { return; }; let declaring_package = package.name().to_string(); - // `version.get()` fails only when the version is still - // `workspace = true` and workspace inheritance never actually resolved - // it (e.g. the workspace root has no `[workspace.package]` value for - // it). `Manifest::from_path` has already applied workspace inheritance - // by this point, so a resolvable version is already resolved here. + // Only unresolved `workspace = true` versions fail here; from_path + // already applies resolvable workspace inheritance. let declaring_version = package.version.get().ok().map(|v| v.to_string()); for deps in all_dep_sets(manifest, include_dev) { for (key, dep) in deps { - collect_one( - manifest_path, - &declaring_package, - declaring_version.as_deref(), - key, - dep, - out, - warnings, - ); - } - } -} - -fn collect_one( - manifest_path: &Path, - declaring_package: &str, - declaring_version: Option<&str>, - key: &str, - dep: &Dependency, - out: &mut Vec, - warnings: &mut Vec, -) { - // Path and git dependencies aren't registry-versioned. - if let Some(detail) = dep.detail() - && (detail.path.is_some() || detail.git.is_some()) - { - return; - } + // Path and git dependencies aren't registry-versioned. + if let Some(detail) = dep.detail() + && (detail.path.is_some() || detail.git.is_some()) + { + continue; + } - let crate_name = dep.package().unwrap_or(key).to_string(); - let source = requirement_source(dep); - - match dep.try_req() { - Ok(req) => out.push(DirectRequirement { - manifest: manifest_path.to_path_buf(), - declaring_package: declaring_package.to_string(), - declaring_version: declaring_version.map(str::to_string), - crate_name, - req: req.clone(), - source, - }), - Err(e) => warnings.push(format!( - "Could not determine requirement for {crate_name} in {}: {e}", - manifest_path.display() - )), + let crate_name = dep.package().unwrap_or(key).to_string(); + let source = requirement_source(dep); + match dep.try_req() { + Ok(req) => out.push(DirectRequirement { + manifest: manifest_path.to_path_buf(), + declaring_package: declaring_package.clone(), + declaring_version: declaring_version.clone(), + crate_name, + req: req.clone(), + source, + }), + Err(e) => warnings.push(format!( + "Could not determine requirement for {crate_name} in {}: {e}", + manifest_path.display() + )), + } + } } } diff --git a/src/suggest.rs b/src/suggest.rs index 40737e8..e40a8e7 100644 --- a/src/suggest.rs +++ b/src/suggest.rs @@ -7,37 +7,28 @@ use semver::{Version, VersionReq}; use std::collections::{HashMap, HashSet}; use std::path::Path; -/// A version requirement currently placed on a package, and who placed it — -/// either a dependent recorded in the lockfile (`blocker_version: Some`) or -/// one of the user's own manifests (`blocker_version: None`). +/// A requirement placed by a lockfile dependent or the user's manifest. pub struct Constraint { pub blocker_name: String, pub blocker_version: Option, pub req: VersionReq, } -/// The package and requirement standing in the way of a downgrade. +/// The package and requirement blocking a downgrade. pub struct Blocker { pub name: String, pub version: Option, pub req: String, - /// Set when this blocker's own `name` and `version` is itself a package - /// this run suggests downgrading. That suggestion may unblock this one, - /// though nothing here checks whether the older version relaxes its - /// requirement. + /// Whether this run also suggests downgrading this locked package. pub also_suggested: bool, } -/// The single outcome of checking one "too new" violation: a working -/// suggestion, a package nothing could unblock, or one with no candidate -/// old enough in range at all. +/// The result of checking one "too new" violation. pub enum Outcome { Suggest { package: String, - /// The pkgid to print in the update command: the bare package name, - /// or `{source}#{package}` when another package in the lockfile - /// shares this name and locked version, so the abbreviated spec - /// would be ambiguous to Cargo. + /// Bare name unless a same-name, same-version package makes Cargo's + /// abbreviated pkgid ambiguous, then `{source}#{package}`. package_spec: String, locked_version: String, suggested_version: String, @@ -56,13 +47,8 @@ pub enum Outcome { }, } -/// Whether `a` and `b` fall in the same caret-compatible zone: the same -/// leading nonzero component (major, or minor when major is 0, or patch -/// when both are 0) — cargo's own notion of "compatible" versions. Unlike -/// parsing `^{a}` as a requirement and matching `b` against it, this is -/// symmetric, which is what bounding a *downgrade* search needs: a caret -/// requirement built from the locked version only ever accepts versions at -/// or above it. +/// Whether `a` and `b` share Cargo's symmetric caret-compatible zone: major, +/// minor when major is zero, or patch when both are zero. fn same_compatible_zone(a: &Version, b: &Version) -> bool { if a.major != 0 || b.major != 0 { a.major == b.major @@ -73,11 +59,8 @@ fn same_compatible_zone(a: &Version, b: &Version) -> bool { } } -/// Filters `versions` to non-yanked, at least `min_age_days` old as of -/// `now`, strictly older in semantic precedence than `locked`, within the -/// caret-compatible zone of `locked`, sorted newest first by publish date. -/// Prereleases are excluded unless `allow_prerelease` is set or `locked` is -/// itself a prerelease. +/// Keeps old-enough, non-yanked compatible versions older than `locked`, +/// newest first. Excludes prereleases unless allowed or `locked` is one. fn filter_candidates( versions: &[CrateVersionInfo], locked: &Version, @@ -128,8 +111,8 @@ enum WalkResult { /// Walks `candidates` (already filtered and sorted newest first) looking for /// the first one every constraint accepts. When none does, reports the -/// newest candidate and the first constraint it fails. -fn walk(candidates: Vec<(Version, i64)>, constraints: Vec) -> WalkResult { +/// newest candidate and the constraint responsible for the block. +fn walk(candidates: Vec<(Version, i64)>, mut constraints: Vec) -> WalkResult { let Some((newest, _)) = candidates.first() else { return WalkResult::NoCompliantVersion; }; @@ -141,30 +124,31 @@ fn walk(candidates: Vec<(Version, i64)>, constraints: Vec) -> WalkRe } } - let blocker = constraints - .into_iter() - .find(|c| !c.req.matches(&newest)) + // Prefer a constraint that rejects every candidate: that one alone makes + // the downgrade impossible. Falling back to the first constraint the + // newest candidate fails only misattributes when no single constraint + // blocks everything — there the block is a genuine combination, and this + // still explains why the newest candidate was rejected. + let blocker_idx = constraints + .iter() + .position(|c| candidates.iter().all(|(v, _)| !c.req.matches(v))) + .or_else(|| constraints.iter().position(|c| !c.req.matches(&newest))) .expect("newest candidate was rejected, so some constraint must reject it"); + let blocker = constraints.swap_remove(blocker_idx); WalkResult::Blocked { newest_compliant: newest, blocker, } } -/// Every registry-crate dependent (from the lockfile) whose recorded -/// requirement on `name` could not be read, kept as a display name rather -/// than aborting the constraint gathering. +/// Constraints plus dependent labels whose requirements remain unverified. struct GatheredConstraints { constraints: Vec, unverified_dependents: Vec, } -/// Maps `(name, version, source)` to every lockfile package that depends on -/// it, built once per run so `gather_constraints` doesn't rescan every -/// package for every "too new" violation. `source` distinguishes same-name, -/// same-version packages from different origins (crates.io, an alternate -/// registry, git) so a dependent of one doesn't leak into another's -/// constraint set. +/// Dependents keyed by full package identity, including source, so equal name +/// and version from different origins never share constraints. type DependentsIndex<'a> = HashMap<(&'a str, &'a str, Option<&'a str>), Vec<&'a Package>>; fn build_dependents_index(all_packages: &[Package]) -> DependentsIndex<'_> { @@ -182,10 +166,7 @@ fn build_dependents_index(all_packages: &[Package]) -> DependentsIndex<'_> { index } -/// Maps `(name, version)` to every lockfile package sharing them, built once -/// per run so the target-source lookup and the ambiguity check in -/// `generate_suggestions` don't each rescan every package for every "too -/// new" violation. +/// Packages keyed by name and version for source lookup and pkgid ambiguity. type NameVersionIndex<'a> = HashMap<(&'a str, &'a str), Vec<&'a Package>>; fn build_name_version_index(all_packages: &[Package]) -> NameVersionIndex<'_> { @@ -199,17 +180,9 @@ fn build_name_version_index(all_packages: &[Package]) -> NameVersionIndex<'_> { index } -/// The source(s) a dependency edge could refer to. An edge that already -/// carries a source names it exactly. An edge without one refers either to -/// a path package sharing the name and version — cargo only omits the -/// source when the resolved target genuinely has none, so a path package is -/// the definite target even when a same-name/same-version registry package -/// also exists — or, absent any such path package, to whichever single -/// package the edge names, resolved here against the lockfile's own package -/// list. If more than one still shares the name and version (and the edge -/// still has no source to disambiguate with), the edge is kept under every -/// one of them rather than guessed at, so an edge that's genuinely ambiguous -/// still counts as a dependent everywhere it might apply. +/// Resolves an edge's source. An explicit source wins; an unsourced edge +/// definitely targets a matching path package. Otherwise retain every +/// same-name, same-version source, so an ambiguous edge applies everywhere. fn resolve_dependency_sources<'a>( dep: &'a PackageRef, all_packages: &'a [Package], @@ -235,11 +208,7 @@ fn resolve_dependency_sources<'a>( } } -/// Gathers every version requirement currently placed on `name` at -/// `locked_version` from `source`: from lockfile-recorded dependents (via -/// the crates.io sparse index for registry dependents) and from the user's -/// own manifests (via `direct_requirements`, included whenever a -/// non-registry dependent records the edge). +/// Gathers requirements from registry dependents and local manifests. fn gather_constraints( client: &mut CratesIoClient, dependents_index: &DependentsIndex, @@ -259,38 +228,17 @@ fn gather_constraints( .copied(); for dependent in dependents { - // A dependent counts as verified only if every requirement it - // records on `name` parsed and at least one matches the locked - // version. A fetch failure, a missing index record, or an - // unparseable requirement is annotated instead of silently dropped. - let matched; - let unreadable; - let mut has_unverified_leftover = false; - if dependent.is_registry { + let unverified = if dependent.is_registry { let result = registry_dependent_constraints(client, dependent, name, locked_version); - matched = result.matched; - unreadable = result.unreadable; - has_unverified_leftover = result.has_unverified_leftover; + let unverified = result.unverified; constraints.extend(result.constraints); + unverified } else if dependent.source.is_none() { - // No lockfile source means a local (path/workspace) package — - // `is_registry` only tells us this isn't crates.io, not that it's - // local, so a git or alternate-registry dependent (which does - // carry a source) must not fall into this branch. Only a local - // dependent's own manifest can be read directly. - let mut manifest_matched = false; - // Scoped to this dependent's own manifest, not every manifest in - // the workspace that happens to mention the same crate name — - // two members can lock the same crate name at different major - // versions, each with its own unrelated requirement. Matched by - // the declaring package's name *and* version, since an unrelated - // local package can share the dependent's name without being it. - // Also scoped to crates.io declarations: this suggestion flow - // only ever targets a crates.io package (see the `is_registry` - // filter above `target_source` in `generate_suggestions`), so a - // declaration naming an explicit alternate registry can never be - // the one that placed this edge and must not be enforced here — - // nor must it count toward this dependent being verified. + // Only an unsourced non-registry dependent is local; other + // sources cannot be verified through a workspace manifest. + let constraint_count = constraints.len(); + // Match the declaring package's name and version, and only its + // crates.io declarations, to avoid unrelated local requirements. for req in direct_requirements .iter() .filter(|r| r.crate_name == name && r.declaring_package == dependent.name) @@ -300,24 +248,18 @@ fn gather_constraints( Version::parse(locked_version).is_ok_and(|version| r.req.matches(&version)) }) { - manifest_matched = true; constraints.push(Constraint { blocker_name: manifest_label(&req.manifest, working_dir), blocker_version: None, req: req.req.clone(), }); } - matched = manifest_matched; - unreadable = false; + constraints.len() == constraint_count } else { - // A git or alternate-registry dependent: nothing here can read - // its actual requirement, local manifest or otherwise, so it - // stays unverified regardless of what any local package's own - // manifest happens to say. - matched = false; - unreadable = false; - } - if !matched || unreadable || has_unverified_leftover { + // Git and alternate-registry requirements cannot be read here. + true + }; + if unverified { unverified_dependents.push(dependent.name.clone()); } } @@ -329,15 +271,10 @@ fn gather_constraints( } /// Requirements a registry dependent's crates.io index record places on -/// `name` at `locked_version`. `unreadable` is set on a fetch failure, a -/// missing index record, or an unparseable requirement. `has_unverified_leftover` -/// is set when a matching declaration exists whose applicability couldn't be -/// settled even though other, mandatory declarations were enforced. +/// `name` at `locked_version`, plus whether an uncertain declaration remains. struct RegistryConstraints { constraints: Vec, - matched: bool, - unreadable: bool, - has_unverified_leftover: bool, + unverified: bool, } impl RegistryConstraints { @@ -346,9 +283,7 @@ impl RegistryConstraints { fn unreadable() -> Self { RegistryConstraints { constraints: Vec::new(), - matched: false, - unreadable: true, - has_unverified_leftover: false, + unverified: true, } } } @@ -359,8 +294,6 @@ fn registry_dependent_constraints( name: &str, locked_version: &str, ) -> RegistryConstraints { - let mut matched = false; - let Ok(records) = client.fetch_index_record(&dependent.name) else { return RegistryConstraints::unreadable(); }; @@ -368,10 +301,10 @@ fn registry_dependent_constraints( return RegistryConstraints::unreadable(); }; - let mut unreadable = false; // Every matching declaration, alongside whether it alone guarantees the // requirement is active: unconditional (no `target`) and non-optional. - let mut matching_reqs: Vec<(VersionReq, bool)> = Vec::new(); + let mut requirements: Vec<(VersionReq, bool)> = Vec::new(); + let mut unverified = false; for dep in &record.deps { if dep.kind.as_deref() == Some("dev") { continue; @@ -383,70 +316,45 @@ fn registry_dependent_constraints( match VersionReq::parse(&dep.req) { Ok(req) if Version::parse(locked_version).is_ok_and(|v| req.matches(&v)) => { let mandatory = dep.target.is_none() && dep.optional != Some(true); - matching_reqs.push((req, mandatory)); + match requirements + .iter_mut() + .find(|(existing, _)| *existing == req) + { + Some((_, existing_mandatory)) => *existing_mandatory |= mandatory, + None => requirements.push((req, mandatory)), + } } Ok(_) => {} - Err(_) => unreadable = true, - } - } - - // Duplicate declarations of the *same* requirement aren't ambiguous — - // the index lists one entry per target, so a requirement repeated - // across, say, `cfg(unix)` and `cfg(windows)` tables is still a single - // requirement, not competing candidates. (Not necessarily adjacent, so a - // plain `dedup()` wouldn't catch every repeat.) A requirement is - // mandatory if any declaration producing it is unconditional and - // non-optional — such a declaration guarantees the requirement is - // active no matter what else matches. - let mut distinct: Vec<(VersionReq, bool)> = Vec::new(); - for (req, mandatory) in matching_reqs { - match distinct.iter_mut().find(|(r, _)| *r == req) { - Some((_, m)) => *m = *m || mandatory, - None => distinct.push((req, mandatory)), + Err(_) => unverified = true, } } - let (mandatory_reqs, uncertain_reqs): (Vec<_>, Vec<_>) = - distinct.into_iter().partition(|(_, mandatory)| *mandatory); let mut constraints = Vec::new(); - let mut has_unverified_leftover = false; - - if !mandatory_reqs.is_empty() { - // Every known-mandatory requirement is always active, so all of - // them are enforced regardless of how many other, uncertain - // declarations also match. - matched = true; - for (req, _) in mandatory_reqs { - constraints.push(Constraint { - blocker_name: dependent.name.clone(), - blocker_version: Some(dependent.version.clone()), - req, - }); - } - // A leftover uncertain declaration can't be resolved either way, so - // the dependent is still worth flagging even though the mandatory - // requirements above are enforced as definite blockers. - has_unverified_leftover = !uncertain_reqs.is_empty(); - } else if let [(req, _)] = uncertain_reqs.as_slice() { - // With no mandatory declaration to settle it, a single uncertain - // declaration is the unique explanation for this lockfile edge and - // is enforced as a definite blocker. - matched = true; + if requirements.iter().any(|(_, mandatory)| *mandatory) { + // A mandatory declaration makes every matching mandatory requirement + // definite; matching uncertain declarations remain annotations. + for (req, mandatory) in requirements { + if mandatory { + constraints.push(Constraint { + blocker_name: dependent.name.clone(), + blocker_version: Some(dependent.version.clone()), + req, + }); + } else { + unverified = true; + } + } + } else if let [(req, _)] = requirements.as_slice() { + // One uncertain declaration is the unique explanation for the edge. constraints.push(Constraint { blocker_name: dependent.name.clone(), blocker_version: Some(dependent.version.clone()), req: req.clone(), }); } - // Otherwise: no matching declaration, or several distinct uncertain - // ones with no mandatory declaration to settle it — neither can be - // enforced, so the dependent is reported unverified instead. - RegistryConstraints { + unverified: unverified || constraints.is_empty(), constraints, - matched, - unreadable, - has_unverified_leftover, } } @@ -457,12 +365,7 @@ fn manifest_label(path: &Path, working_dir: &Path) -> String { .to_string() } -/// The pkgid to print in an update command for `name` at `version`: -/// abbreviated to the bare name, unless another package shares the name and -/// version — a path or git package, say — in which case Cargo would reject -/// the abbreviated spec as ambiguous. Qualifying with `target_source` (the -/// source of the package the suggestion is actually for) disambiguates it, -/// per Cargo's package ID specification grammar: `[+]#@`. +/// Uses a source-qualified pkgid when a shared name and version is ambiguous. fn build_package_spec(name: &str, target_source: Option<&str>, is_ambiguous: bool) -> String { match (is_ambiguous, target_source) { (true, Some(source)) => format!("{source}#{name}"), @@ -470,13 +373,8 @@ fn build_package_spec(name: &str, target_source: Option<&str>, is_ambiguous: boo } } -/// Generates one outcome for every "too new" violation. Returns `None` when -/// there are no "too new" violations, so the caller prints nothing; returns -/// `Some` (possibly empty) once the flow has run. -/// -/// A package whose own version list fails to fetch is simply absent from -/// the result, matching the tool's established tolerance for per-package -/// fetch failures. +/// Generates outcomes for "too new" violations, or `None` when there are none. +/// Ignores packages whose version list cannot be fetched. #[allow(clippy::too_many_arguments)] pub fn generate_suggestions( client: &mut CratesIoClient, @@ -525,11 +423,8 @@ pub fn generate_suggestions( .map(Vec::as_slice) .unwrap_or_default(); - // Violations are only ever raised for registry packages (see the - // `is_registry` filter that builds `violations`), so the crates.io - // entry matching this name and version is the one this violation - // refers to — not any git or alternate-registry package that - // happens to share the same name and version. + // This registry package is the violation target, even if another + // source shares its name and version. let target_source = same_name_version .iter() .find(|p| p.is_registry) @@ -798,6 +693,38 @@ mod tests { } } + #[test] + // Pins that the blocker is the constraint rejecting every candidate. + // `<=1.3` comes first and rejects the newest candidate, but 1.2.0 + // satisfies it — only `>=1.4.5` makes every downgrade impossible. + fn blocker_is_the_constraint_that_rejects_every_candidate() { + let candidates = vec![(v("1.4.0"), 5), (v("1.2.0"), 20)]; + let constraints = vec![constraint("<=1.3"), constraint(">=1.4.5")]; + + match walk(candidates, constraints) { + WalkResult::Blocked { blocker, .. } => { + assert_eq!(blocker.req.to_string(), ">=1.4.5"); + } + _ => panic!("expected Blocked"), + } + } + + #[test] + // Pins the fallback: when no single constraint rejects every + // candidate, the block is a genuine combination, so we fall back to + // the first constraint that rejects the newest candidate. + fn blocker_falls_back_when_no_single_constraint_blocks_all_candidates() { + let candidates = vec![(v("1.4.0"), 5), (v("1.2.0"), 20)]; + let constraints = vec![constraint("<=1.3"), constraint(">=1.4")]; + + match walk(candidates, constraints) { + WalkResult::Blocked { blocker, .. } => { + assert_eq!(blocker.req.to_string(), "<=1.3"); + } + _ => panic!("expected Blocked"), + } + } + #[test] fn no_compliant_version_when_candidates_empty() { match walk(vec![], vec![constraint("^1.0")]) { From 3c042d5562854bcece17dbd7d3004f687ec99cfa Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Wed, 16 Sep 2026 09:30:03 -0400 Subject: [PATCH 22/34] Tests --- src/suggest.rs | 270 +++++++++++++++++++++++++++++-- tests/suggest_fix_cli.rs | 333 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 589 insertions(+), 14 deletions(-) create mode 100644 tests/suggest_fix_cli.rs diff --git a/src/suggest.rs b/src/suggest.rs index e40a8e7..add28aa 100644 --- a/src/suggest.rs +++ b/src/suggest.rs @@ -208,10 +208,48 @@ fn resolve_dependency_sources<'a>( } } +/// Multiple versions are ambiguous only when their matching requirements differ. +fn is_ambiguous(locked_versions: usize, distinct_requirements: usize) -> bool { + locked_versions > 1 && distinct_requirements > 1 +} + +/// Count versions within the eligible source, ignoring duplicate edges. +fn eligible_locked_versions<'a>( + dependent: &'a Package, + all_packages: &'a [Package], + name: &str, + source: Option<&str>, +) -> usize { + dependent + .dependencies + .iter() + .filter(|d| d.name == name) + .filter(|d| resolve_dependency_sources(d, all_packages).contains(&source)) + .map(|d| d.version.as_str()) + .collect::>() + .len() +} + +/// An unresolved source cannot supply verified requirements. +fn edge_source_is_ambiguous( + dependent: &Package, + all_packages: &[Package], + name: &str, + locked_version: &str, +) -> bool { + dependent + .dependencies + .iter() + .filter(|d| d.name == name && d.version == locked_version) + .any(|d| resolve_dependency_sources(d, all_packages).len() > 1) +} + /// Gathers requirements from registry dependents and local manifests. +#[allow(clippy::too_many_arguments)] fn gather_constraints( client: &mut CratesIoClient, dependents_index: &DependentsIndex, + all_packages: &[Package], direct_requirements: &[DirectRequirement], working_dir: &Path, name: &str, @@ -228,18 +266,27 @@ fn gather_constraints( .copied(); for dependent in dependents { - let unverified = if dependent.is_registry { - let result = registry_dependent_constraints(client, dependent, name, locked_version); + let unverified = if edge_source_is_ambiguous(dependent, all_packages, name, locked_version) + { + true + } else if dependent.is_registry { + let result = registry_dependent_constraints( + client, + dependent, + all_packages, + name, + locked_version, + source, + ); let unverified = result.unverified; constraints.extend(result.constraints); unverified } else if dependent.source.is_none() { // Only an unsourced non-registry dependent is local; other // sources cannot be verified through a workspace manifest. - let constraint_count = constraints.len(); // Match the declaring package's name and version, and only its // crates.io declarations, to avoid unrelated local requirements. - for req in direct_requirements + let matching: Vec<&DirectRequirement> = direct_requirements .iter() .filter(|r| r.crate_name == name && r.declaring_package == dependent.name) .filter(|r| r.declaring_version.as_deref() == Some(dependent.version.as_str())) @@ -247,14 +294,29 @@ fn gather_constraints( .filter(|r| { Version::parse(locked_version).is_ok_and(|version| r.req.matches(&version)) }) - { - constraints.push(Constraint { - blocker_name: manifest_label(&req.manifest, working_dir), - blocker_version: None, - req: req.req.clone(), - }); + .collect(); + // Aliases with equal parsed requirements count once. + let mut distinct: Vec<&VersionReq> = Vec::new(); + for req in matching.iter().map(|r| &r.req) { + if !distinct.contains(&req) { + distinct.push(req); + } + } + + let locked_versions = eligible_locked_versions(dependent, all_packages, name, source); + + if matching.is_empty() || is_ambiguous(locked_versions, distinct.len()) { + true + } else { + for req in &matching { + constraints.push(Constraint { + blocker_name: manifest_label(&req.manifest, working_dir), + blocker_version: None, + req: req.req.clone(), + }); + } + false } - constraints.len() == constraint_count } else { // Git and alternate-registry requirements cannot be read here. true @@ -291,8 +353,10 @@ impl RegistryConstraints { fn registry_dependent_constraints( client: &mut CratesIoClient, dependent: &Package, + all_packages: &[Package], name: &str, locked_version: &str, + source: Option<&str>, ) -> RegistryConstraints { let Ok(records) = client.fetch_index_record(&dependent.name) else { return RegistryConstraints::unreadable(); @@ -301,8 +365,7 @@ fn registry_dependent_constraints( return RegistryConstraints::unreadable(); }; - // Every matching declaration, alongside whether it alone guarantees the - // requirement is active: unconditional (no `target`) and non-optional. + // Deduplicate requirements, retaining whether any declaration is mandatory. let mut requirements: Vec<(VersionReq, bool)> = Vec::new(); let mut unverified = false; for dep in &record.deps { @@ -329,8 +392,12 @@ fn registry_dependent_constraints( } } + let locked_versions = eligible_locked_versions(dependent, all_packages, name, source); + let mut constraints = Vec::new(); - if requirements.iter().any(|(_, mandatory)| *mandatory) { + if is_ambiguous(locked_versions, requirements.len()) { + unverified = true; + } else if requirements.iter().any(|(_, mandatory)| *mandatory) { // A mandatory declaration makes every matching mandatory requirement // definite; matching uncertain declarations remain annotations. for (req, mandatory) in requirements { @@ -439,6 +506,7 @@ pub fn generate_suggestions( let gathered = gather_constraints( client, &dependents_index, + all_packages, direct_requirements, working_dir, &violation.package, @@ -910,6 +978,7 @@ mod tests { let gathered = gather_constraints( &mut client, &index, + &packages, &[], Path::new("/work"), "foo", @@ -941,6 +1010,7 @@ mod tests { let gathered = gather_constraints( &mut client, &index, + &packages, &[], Path::new("/work"), "foo", @@ -959,6 +1029,7 @@ mod tests { let gathered = gather_constraints( &mut client, &index, + &packages, &[], Path::new("/work"), "foo", @@ -993,6 +1064,7 @@ mod tests { let gathered = gather_constraints( &mut client, &index, + &packages, &requirements, Path::new("/work"), "foo", @@ -1025,6 +1097,7 @@ mod tests { let gathered = gather_constraints( &mut client, &index, + &packages, &requirements, Path::new("/work"), "foo", @@ -1055,6 +1128,7 @@ mod tests { let gathered = gather_constraints( &mut client, &index, + &packages, &requirements, Path::new("/work"), "foo", @@ -1089,6 +1163,7 @@ mod tests { let gathered = gather_constraints( &mut client, &index, + &packages, &requirements, Path::new("/work"), "foo", @@ -1115,6 +1190,7 @@ mod tests { let gathered = gather_constraints( &mut client, &index, + &packages, &requirements, Path::new("/work"), "foo", @@ -1144,6 +1220,7 @@ mod tests { let gathered = gather_constraints( &mut client, &index, + &packages, &requirements, Path::new("/work"), "foo", @@ -1175,6 +1252,7 @@ mod tests { let gathered = gather_constraints( &mut client, &index, + &packages, &requirements, Path::new("/work"), "foo", @@ -1872,6 +1950,170 @@ mod tests { } } + #[test] + fn requirement_attribution_acceptance_cases() { + // name, second version, requirements, unverified, blocks 1.7 + let cases: &[(&str, bool, &[&str], bool, bool)] = &[ + ("overlap", true, &["^1", ">=1.8,<3"], true, false), + ("disjoint", true, &["^1", "^2"], false, false), + ( + "identical aliases", + true, + &[">=1.8,<3", ">=1.8, <3"], + false, + true, + ), + ("single version", false, &["^1", ">=1.8,<2"], false, true), + ("optional", true, &["^1", ">=1.8,<3"], true, false), + ("target", true, &["^1", ">=1.8,<3"], true, false), + ("other blocker", true, &["^1", ">=1.8,<3"], true, true), + ("other parent", false, &["^1", ">=1.8,<3"], false, true), + ("other source", true, &["^1", ">=1.8,<3"], false, true), + ("path sibling", false, &["^1", ">=1.8,<3"], false, true), + ("duplicate edge", false, &["^1", ">=1.8,<3"], false, true), + ("unknown source", false, &["^1"], true, false), + ("unreadable", false, &[], true, false), + ]; + for registry in [true, false] { + for &(case, second_version, reqs, unverified, blocked) in cases { + if !registry && matches!(case, "optional" | "target") { + continue; + } + let transport = FakeTransport::default(); + let mut requirements = Vec::new(); + let mut app = if registry { + pkg("app", "1.0.0", &[("foo", "1.9.0")]) + } else { + non_registry_pkg("app", "1.0.0", &[("foo", "1.9.0")]) + }; + let source = pkg("foo", "1.9.0", &[]).source.unwrap(); + app.dependencies[0].source = Some(source.clone()); + let mut packages = vec![pkg("foo", "1.9.0", &[])]; + if second_version { + let mut sibling = pkg("foo", "2.0.0", &[]); + if case == "other source" { + sibling = external_pkg("foo", "2.0.0", ALT_REGISTRY_SOURCE, &[]); + } + app.dependencies.push(PackageRef { + name: "foo".into(), + version: sibling.version.clone(), + source: sibling.source.clone(), + }); + packages.push(sibling); + } + match case { + "path sibling" => { + packages.push(non_registry_pkg("foo", "1.9.0", &[])); + app.dependencies.push(PackageRef { + name: "foo".into(), + version: "1.9.0".into(), + source: None, + }); + } + "duplicate edge" => app.dependencies.push(PackageRef { + name: "foo".into(), + version: "1.9.0".into(), + source: Some(source.clone()), + }), + "unknown source" => { + app.dependencies[0].source = None; + packages.push(external_pkg("foo", "1.9.0", ALT_REGISTRY_SOURCE, &[])); + } + "other parent" => { + packages.push(pkg("foo", "2.0.0", &[])); + packages.push(pkg("other", "1.0.0", &[("foo", "2.0.0")])); + } + "other blocker" => { + if registry { + packages.push(pkg("other", "1.0.0", &[("foo", "1.9.0")])); + transport.index_ok( + "other", + r#"{"vers":"1.0.0","deps":[{"name":"foo","req":">=1.8"}]}"#, + ); + } else { + packages.push(non_registry_pkg( + "other", + "1.0.0", + &[("foo", "1.9.0")], + )); + requirements + .push(local_requirement("other", "1.0.0", "foo", ">=1.8")); + } + } + _ => {} + } + packages.push(app); + if registry && case != "unreadable" { + let deps: Vec<_> = reqs.iter().enumerate().map(|(i, req)| { + serde_json::json!({ + "name": format!("alias_{i}"), "package": "foo", "req": req, + "optional": case == "optional" && i == 0, + "target": if case == "target" && i == 0 { Some("cfg(unix)") } else { None }, + }) + }).collect(); + transport.index_ok( + "app", + &serde_json::json!({ + "vers": "1.0.0", "deps": deps, + }) + .to_string(), + ); + } else if registry { + transport.index_error("app"); + } else { + requirements.extend( + reqs.iter() + .map(|req| local_requirement("app", "1.0.0", "foo", req)), + ); + } + let mut client = fast_client(transport); + let index = build_dependents_index(&packages); + let gathered = gather_constraints( + &mut client, + &index, + &packages, + &requirements, + Path::new("/work"), + "foo", + "1.9.0", + Some(&source), + ); + let expected_unverified = if unverified { vec!["app"] } else { vec![] }; + assert_eq!( + gathered.unverified_dependents, expected_unverified, + "{case}, registry={registry}" + ); + if case == "disjoint" { + assert!( + gathered + .constraints + .iter() + .any(|c| !c.req.matches(&v("0.9.0"))) + ); + } + let result = walk(vec![(v("1.7.0"), 80)], gathered.constraints); + assert_eq!( + matches!(result, WalkResult::Blocked { .. }), + blocked, + "{case}, registry={registry}" + ); + if case == "other blocker" { + let WalkResult::Blocked { blocker, .. } = result else { + unreachable!() + }; + assert_eq!( + blocker.blocker_name, + if registry { "other" } else { "Cargo.toml" } + ); + } else if !blocked { + assert!( + matches!(result, WalkResult::Suggest(version, 80) if version == v("1.7.0")) + ); + } + } + } + } + #[test] fn failed_index_fetch_yields_suggestion_with_unverified_annotation() { let transport = FakeTransport::default(); diff --git a/tests/suggest_fix_cli.rs b/tests/suggest_fix_cli.rs new file mode 100644 index 0000000..a5ff77b --- /dev/null +++ b/tests/suggest_fix_cli.rs @@ -0,0 +1,333 @@ +use chrono::{Duration, Utc}; +use flate2::write::GzEncoder; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::Path; +use std::process::{Command, Output}; +use tar::Builder; +use tempfile::{TempDir, tempdir}; + +const BIN: &str = env!("CARGO_BIN_EXE_cargo-oxidate"); +type CacheEntry<'a> = (&'a str, &'a str, i64, Vec<(&'a str, i64)>); + +fn run_oxidate(cwd: &Path, args: &[&str]) -> Output { + Command::new(BIN) + .args(args) + .current_dir(cwd) + .output() + .expect("cargo-oxidate should run") +} + +fn write_cache(dir: &Path, entries: &[CacheEntry<'_>]) -> std::path::PathBuf { + let now = Utc::now(); + let mut publish_dates = serde_json::Map::new(); + let mut all_versions = serde_json::Map::new(); + let mut index_records = serde_json::Map::new(); + + for (name, locked, locked_age, versions) in entries { + publish_dates.insert( + format!("{name}/{locked}"), + serde_json::Value::String((now - Duration::days(*locked_age)).to_rfc3339()), + ); + all_versions.insert( + (*name).to_string(), + serde_json::json!({ + "fetched_at": now, + "versions": versions.iter().map(|(version, age)| serde_json::json!({ + "num": version, + "created_at": now - Duration::days(*age), + "yanked": false, + })).collect::>(), + }), + ); + index_records.insert( + (*name).to_string(), + serde_json::json!({ + "fetched_at": now, + "records": [{ "vers": locked, "yanked": false, "deps": [] }], + }), + ); + } + + let path = dir.join("responses.json"); + fs::write( + &path, + serde_json::to_vec(&serde_json::json!({ + "version": 1, + "publish_dates": publish_dates, + "all_versions": all_versions, + "index_records": index_records, + })) + .unwrap(), + ) + .unwrap(); + path +} + +fn copied_fixture() -> TempDir { + let temp = tempdir().unwrap(); + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/suggest_fix_e2e"); + fs::copy( + fixture.join("app/Cargo.toml"), + temp.path().join("Cargo.toml"), + ) + .unwrap(); + fs::copy(fixture.join("Cargo.lock"), temp.path().join("Cargo.lock")).unwrap(); + temp +} + +#[test] +fn suggest_fix_cli_reports_mixed_outcomes_and_preserves_project_files() { + let project = copied_fixture(); + let cache = write_cache( + project.path(), + &[ + ("alpha", "1.5.0", 2, vec![("1.5.0", 2), ("1.4.0", 80)]), + ("beta", "1.5.0", 2, vec![("1.5.0", 2), ("1.4.0", 80)]), + ("gamma", "1.5.0", 2, vec![("1.5.0", 2), ("1.4.0", 80)]), + ("delta", "1.5.0", 2, vec![("1.5.0", 2)]), + ("consumer", "2.0.0", 100, vec![("2.0.0", 100)]), + ], + ); + let mut cache_json: serde_json::Value = + serde_json::from_slice(&fs::read(&cache).unwrap()).unwrap(); + cache_json["index_records"]["consumer"]["records"] = serde_json::json!([{ + "vers": "2.0.0", + "yanked": false, + "deps": [ + { "name": "gamma", "req": "^1.5", "kind": null, "target": null, "optional": false, "package": null } + ] + }]); + fs::write(&cache, serde_json::to_vec(&cache_json).unwrap()).unwrap(); + + let manifest_before = fs::read(project.path().join("Cargo.toml")).unwrap(); + let lock_before = fs::read(project.path().join("Cargo.lock")).unwrap(); + let output = run_oxidate( + project.path(), + &[ + "--min-age-days", + "30", + "--suggest-fix", + "--cache-path", + cache.to_str().unwrap(), + ], + ); + assert_eq!(output.status.code(), Some(1)); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!( + stdout.contains("cargo update -p alpha@1.5.0 --precise 1.4.0"), + "stdout was:\n{stdout}" + ); + assert!( + stdout.contains("beta 1.5.0") && stdout.contains("Cargo.toml") && stdout.contains("^1.5"), + "stdout was:\n{stdout}" + ); + assert!( + stdout.contains("gamma 1.5.0") + && stdout.contains("consumer 2.0.0") + && stdout.contains("^1.5") + ); + assert!(stdout.contains("delta 1.5.0") && stdout.contains("no version")); + assert!(stdout.contains("best-effort")); + assert_eq!( + fs::read(project.path().join("Cargo.toml")).unwrap(), + manifest_before + ); + assert_eq!( + fs::read(project.path().join("Cargo.lock")).unwrap(), + lock_before + ); +} + +#[test] +fn suggest_fix_cli_retains_best_effort_qualification() { + let project = tempdir().unwrap(); + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/suggest_fix_e2e"); + fs::copy( + fixture.join("app/Cargo.toml"), + project.path().join("Cargo.toml"), + ) + .unwrap(); + let lock = fs::read_to_string(fixture.join("Cargo.lock")).unwrap(); + let epsilon = "\n[[package]]\nname = \"epsilon\"\nversion = \"1.5.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"0000000000000000000000000000000000000000000000000000000000000000\"\n"; + fs::write( + project.path().join("Cargo.lock"), + lock.replace( + " \"gamma\",\n]", + " \"gamma\",\n \"epsilon 1.5.0 (registry+https://github.com/rust-lang/crates.io-index)\",\n]", + ) + epsilon, + ) + .unwrap(); + let cache = write_cache( + project.path(), + &[ + ("alpha", "1.5.0", 2, vec![("1.5.0", 2), ("1.4.0", 80)]), + ("beta", "1.5.0", 2, vec![("1.5.0", 2), ("1.4.0", 80)]), + ("gamma", "1.5.0", 2, vec![("1.5.0", 2), ("1.4.0", 80)]), + ("delta", "1.5.0", 2, vec![("1.5.0", 2)]), + ("epsilon", "1.5.0", 2, vec![("1.5.0", 2), ("1.4.0", 80)]), + ("consumer", "2.0.0", 100, vec![("2.0.0", 100)]), + ], + ); + let mut cache_json: serde_json::Value = + serde_json::from_slice(&fs::read(&cache).unwrap()).unwrap(); + cache_json["index_records"]["consumer"]["records"] = serde_json::json!([{ + "vers": "1.0.0", "yanked": false, "deps": [] + }]); + fs::write(&cache, serde_json::to_vec(&cache_json).unwrap()).unwrap(); + let output = run_oxidate( + project.path(), + &[ + "--min-age-days", + "30", + "--suggest-fix", + "--cache-path", + cache.to_str().unwrap(), + ], + ); + assert_eq!(output.status.code(), Some(1)); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("best-effort"), "stdout was:\n{stdout}"); +} + +#[test] +fn ordinary_and_invalid_cli_runs_keep_their_exit_contract() { + let project = tempdir().unwrap(); + fs::write( + project.path().join("Cargo.toml"), + "[package]\nname = \"clean\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ) + .unwrap(); + fs::write( + project.path().join("Cargo.lock"), + "version = 4\n\n[[package]]\nname = \"clean\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let clean = run_oxidate(project.path(), &["--min-age-days", "30"]); + assert!(clean.status.success()); + assert!( + !String::from_utf8(clean.stdout) + .unwrap() + .contains("Suggested fixes") + ); + + let invalid = run_oxidate(project.path(), &["--suggest-fix"]); + assert_eq!(invalid.status.code(), Some(2)); +} + +fn write_crate_source(dir: &Path, name: &str, version: &str) { + fs::create_dir_all(dir.join("src")).unwrap(); + fs::write( + dir.join("Cargo.toml"), + format!("[package]\nname = \"{name}\"\nversion = \"{version}\"\nedition = \"2021\"\n"), + ) + .unwrap(); + fs::write(dir.join("src/lib.rs"), "").unwrap(); +} + +fn write_local_registry(registry: &Path, name: &str, version: &str) { + let source = registry.join(".build").join(format!("{name}-{version}")); + write_crate_source(&source, name, version); + let mut bytes = Vec::new(); + let encoder = GzEncoder::new(&mut bytes, flate2::Compression::default()); + let mut tar = Builder::new(encoder); + tar.append_dir_all(format!("{name}-{version}"), &source) + .unwrap(); + tar.into_inner().unwrap().finish().unwrap(); + fs::write(registry.join(format!("{name}-{version}.crate")), &bytes).unwrap(); + let checksum = Sha256::digest(&bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let shard = registry.join("index").join(&name[..2]).join(&name[2..4]); + fs::create_dir_all(&shard).unwrap(); + let entry = serde_json::json!({"name": name, "vers": version, "deps": [], "cksum": checksum, "features": {}, "yanked": false}); + use std::io::Write; + fs::OpenOptions::new() + .create(true) + .append(true) + .open(shard.join(name)) + .unwrap() + .write_all(format!("{entry}\n").as_bytes()) + .unwrap(); +} + +#[test] +fn printed_source_qualified_command_updates_only_the_registry_package() { + let root = tempdir().unwrap(); + let registry = root.path().join("registry"); + let project = root.path().join("project"); + write_local_registry(®istry, "semver", "1.0.28"); + write_local_registry(®istry, "semver", "1.0.27"); + write_crate_source(&project.join("vendor-semver"), "semver", "1.0.28"); + fs::create_dir_all(project.join(".cargo")).unwrap(); + fs::create_dir_all(project.join("src")).unwrap(); + fs::write(project.join("src/main.rs"), "fn main() {}\n").unwrap(); + fs::write(project.join(".cargo/config.toml"), format!("[source.local-vendor]\nlocal-registry = \"{}\"\n\n[source.crates-io]\nreplace-with = \"local-vendor\"\n", registry.display())).unwrap(); + fs::write(project.join("Cargo.toml"), "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nsemver = \"1\"\nsemver-path = { package = \"semver\", path = \"vendor-semver\" }\n").unwrap(); + let generated = Command::new("cargo") + .args(["generate-lockfile", "--offline"]) + .current_dir(&project) + .output() + .unwrap(); + assert!( + generated.status.success(), + "{}", + String::from_utf8_lossy(&generated.stderr) + ); + + let cache = write_cache( + &project, + &[("semver", "1.0.28", 2, vec![("1.0.28", 2), ("1.0.27", 80)])], + ); + let output = run_oxidate( + &project, + &[ + "--min-age-days", + "30", + "--suggest-fix", + "--cache-path", + cache.to_str().unwrap(), + ], + ); + assert_eq!(output.status.code(), Some(1)); + let stdout = String::from_utf8(output.stdout).unwrap(); + let line = stdout + .lines() + .find(|line| line.trim_start().starts_with("cargo update ")) + .unwrap_or_else(|| panic!("a rendered cargo update command; stdout was:\n{stdout}")); + let command = line.split(" #").next().unwrap().trim(); + let args: Vec<_> = command.split_whitespace().skip(1).collect(); + assert!( + args.iter().any(|arg| arg.contains('#')), + "expected source-qualified package spec: {command}" + ); + let applied = Command::new("cargo") + .args(args) + .arg("--offline") + .current_dir(&project) + .output() + .unwrap(); + assert!( + applied.status.success(), + "{}", + String::from_utf8_lossy(&applied.stderr) + ); + + let lock = cargo_lock::Lockfile::load(project.join("Cargo.lock")).unwrap(); + assert!( + lock.packages + .iter() + .any(|package| package.name.as_str() == "semver" + && package.version.to_string() == "1.0.27" + && package.source.is_some()) + ); + assert!( + lock.packages + .iter() + .any(|package| package.name.as_str() == "semver" + && package.version.to_string() == "1.0.28" + && package.source.is_none()) + ); +} From fd7fc14ea36492d0db40243272187d64ce383027 Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Wed, 16 Sep 2026 13:47:18 -0400 Subject: [PATCH 23/34] refactor(suggest): simplify requirement attribution and fix reporting gaps Deduplicate unverified parent labels in encounter order, document that --include-prerelease does not change SemVer matching, and assert the unverified label and guidance in the CLI test. Use dependency sources already resolved by cargo-lock instead of re-inferring them; drop the unreachable edge-ambiguity check and add loader tests proving the invariant. Consolidate local-manifest and registry-index requirement attribution behind one private policy. Collapse repeated test setup behind two small helpers. --- README.md | 2 +- src/lockfile.rs | 90 +++++ src/main.rs | 4 + src/suggest.rs | 752 +++++++++++++++------------------------ tests/suggest_fix_cli.rs | 79 ++++ 5 files changed, 463 insertions(+), 464 deletions(-) diff --git a/README.md b/README.md index fdac6d3..50e8a65 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ cargo-oxidate Cargo.lock --min-age-days 14 --max-age-days 730 | `--exclude-missing` | Don't flag packages with unknown publish dates | | `--timeout N` | HTTP timeout in seconds (default: 10) | | `--suggest-fix` | For "too new" violations, suggest `cargo update` commands to downgrade | -| `--include-prerelease` | Consider prerelease versions as suggestion candidates (requires `--suggest-fix`) | +| `--include-prerelease` | Consider prerelease versions as suggestion candidates (requires `--suggest-fix`); ordinary SemVer requirements (e.g. `^1.2`) still generally don't match prereleases, so most will still be rejected | | `--cache-path PATH` | Enable response caching at PATH (or set `CARGO_OXIDATE_CACHE_PATH`) | | `--cache-max-age-hours N` | Max age for cached version listings (default: 24) | diff --git a/src/lockfile.rs b/src/lockfile.rs index 2c2992d..fecd8fb 100644 --- a/src/lockfile.rs +++ b/src/lockfile.rs @@ -166,6 +166,17 @@ version = "{version}" ) } + fn alt_registry_entry(name: &str, version: &str) -> String { + format!( + r#" +[[package]] +name = "{name}" +version = "{version}" +source = "registry+https://example.com/index" +"# + ) + } + fn write_lockfile(dir: &Path, contents: &str) -> std::path::PathBuf { let path = dir.join("Cargo.lock"); std::fs::write(&path, format!("{LOCKFILE_HEADER}{contents}")).unwrap(); @@ -265,6 +276,85 @@ version = "{version}" assert_eq!(a.dependencies[0].source, b.source); } + #[test] + fn dependency_edges_carry_the_selected_packages_resolved_source() { + // Proves the invariant `resolve_dependency_sources` used to + // re-derive: `cargo_lock` already resolves each dependency edge to + // its selected package's source while parsing. A root package + // depends, without source qualification, on a crates.io package, a + // git package, an alternate-registry package, and a path package, + // plus one dangling versioned edge to a package absent from the + // lockfile. + let dir = tempdir().unwrap(); + let contents = format!( + "{}{}{}{}{}", + registry_entry_with_deps( + "root", + "1.0.0", + &[ + "crates-dep 1.0.0", + "git-dep 1.0.0", + "alt-dep 1.0.0", + "path-dep 1.0.0", + "missing-dep 9.9.9", + ], + ), + registry_entry("crates-dep", "1.0.0"), + git_entry("git-dep", "1.0.0"), + alt_registry_entry("alt-dep", "1.0.0"), + path_entry("path-dep", "1.0.0"), + ); + write_lockfile(dir.path(), &contents); + + let packages = load(Path::new("Cargo.lock"), dir.path()).unwrap(); + let root = packages.iter().find(|p| p.name == "root").unwrap(); + let target = |name: &str| packages.iter().find(|p| p.name == name).unwrap(); + let edge = |name: &str| root.dependencies.iter().find(|d| d.name == name).unwrap(); + + assert_eq!(edge("crates-dep").source, target("crates-dep").source); + // A git edge's resolved source drops the commit hash that the + // target package's own source keeps (`normalize_git_source_for_dependency`), + // so it's still `Some`, but not identical to the target's source. + assert!(edge("git-dep").source.is_some()); + assert_eq!(edge("alt-dep").source, target("alt-dep").source); + assert_eq!(edge("path-dep").source, target("path-dep").source); + assert_eq!(edge("path-dep").source, None); + assert_eq!(edge("missing-dep").source, None); + } + + #[test] + fn source_qualified_edge_keeps_its_declared_source_over_a_same_identity_path_package() { + // A path package and a crates.io package share a name and version. + // The dependent's edge to it is source-qualified, so it must keep + // that declared source rather than resolving to the path package. + let dir = tempdir().unwrap(); + let contents = format!( + "{}{}{}", + registry_entry_with_deps( + "root", + "1.0.0", + &["shared 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)"], + ), + registry_entry("shared", "1.0.0"), + path_entry("shared", "1.0.0"), + ); + write_lockfile(dir.path(), &contents); + + let packages = load(Path::new("Cargo.lock"), dir.path()).unwrap(); + let root = packages.iter().find(|p| p.name == "root").unwrap(); + let edge = root + .dependencies + .iter() + .find(|d| d.name == "shared") + .unwrap(); + let registry_shared = packages + .iter() + .find(|p| p.name == "shared" && p.source.is_some()) + .unwrap(); + + assert_eq!(edge.source, registry_shared.source); + } + #[test] fn path_outside_working_directory_is_rejected() { let outside = tempdir().unwrap(); diff --git a/src/main.rs b/src/main.rs index 9fdce80..70fcbcf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,6 +48,10 @@ struct Cli { suggest_fix: bool, /// Consider prerelease versions as suggestion candidates (requires --suggest-fix) + /// + /// This only admits prerelease versions as candidates; it does not change + /// requirement matching. Ordinary SemVer requirements (e.g. `^1.2`) still + /// generally do not match prereleases, so most will still be rejected. #[arg(long, requires = "suggest_fix")] include_prerelease: bool, diff --git a/src/suggest.rs b/src/suggest.rs index add28aa..cebb1d5 100644 --- a/src/suggest.rs +++ b/src/suggest.rs @@ -1,5 +1,5 @@ use crate::api::{CrateVersionInfo, CratesIoClient, Transport}; -use crate::lockfile::{Package, PackageRef}; +use crate::lockfile::Package; use crate::manifest::{DirectRequirement, RequirementSource}; use crate::report::{Violation, ViolationKind}; use chrono::{DateTime, Utc}; @@ -155,12 +155,14 @@ fn build_dependents_index(all_packages: &[Package]) -> DependentsIndex<'_> { let mut index: DependentsIndex = HashMap::new(); for pkg in all_packages { for dep in &pkg.dependencies { - for source in resolve_dependency_sources(dep, all_packages) { - index - .entry((dep.name.as_str(), dep.version.as_str(), source)) - .or_default() - .push(pkg); - } + index + .entry(( + dep.name.as_str(), + dep.version.as_str(), + dep.source.as_deref(), + )) + .or_default() + .push(pkg); } } index @@ -180,68 +182,91 @@ fn build_name_version_index(all_packages: &[Package]) -> NameVersionIndex<'_> { index } -/// Resolves an edge's source. An explicit source wins; an unsourced edge -/// definitely targets a matching path package. Otherwise retain every -/// same-name, same-version source, so an ambiguous edge applies everywhere. -fn resolve_dependency_sources<'a>( - dep: &'a PackageRef, - all_packages: &'a [Package], -) -> Vec> { - if let Some(source) = dep.source.as_deref() { - return vec![Some(source)]; - } - let has_path_match = all_packages - .iter() - .any(|p| p.name == dep.name && p.version == dep.version && p.source.is_none()); - if has_path_match { - return vec![None]; - } - let matches: Vec> = all_packages - .iter() - .filter(|p| p.name == dep.name && p.version == dep.version) - .map(|p| p.source.as_deref()) - .collect(); - if matches.is_empty() { - vec![None] - } else { - matches - } -} - /// Multiple versions are ambiguous only when their matching requirements differ. fn is_ambiguous(locked_versions: usize, distinct_requirements: usize) -> bool { locked_versions > 1 && distinct_requirements > 1 } /// Count versions within the eligible source, ignoring duplicate edges. -fn eligible_locked_versions<'a>( - dependent: &'a Package, - all_packages: &'a [Package], - name: &str, - source: Option<&str>, -) -> usize { +fn eligible_locked_versions(dependent: &Package, name: &str, source: Option<&str>) -> usize { dependent .dependencies .iter() - .filter(|d| d.name == name) - .filter(|d| resolve_dependency_sources(d, all_packages).contains(&source)) + .filter(|d| d.name == name && d.source.as_deref() == source) .map(|d| d.version.as_str()) .collect::>() .len() } -/// An unresolved source cannot supply verified requirements. -fn edge_source_is_ambiguous( - dependent: &Package, - all_packages: &[Package], - name: &str, - locked_version: &str, -) -> bool { - dependent - .dependencies - .iter() - .filter(|d| d.name == name && d.version == locked_version) - .any(|d| resolve_dependency_sources(d, all_packages).len() > 1) +/// A single declaration's parsed requirement plus the evidence the shared +/// attribution policy needs: whether it is definitely active (mandatory) or +/// merely possible (optional/target-specific/aliased). Local declarations are +/// always mandatory, since their representation drops that distinction. +struct NormalizedDeclaration { + req: VersionReq, + mandatory: bool, +} + +/// The shared attribution policy's decision: which parsed requirements are +/// verified enforceable, plus whether the parent must still be marked +/// unverified. Both can hold at once (a mandatory constraint alongside an +/// uncertain declaration). +struct AttributionResult { + enforced: Vec, + unverified: bool, +} + +/// Applies the requirement-attribution policy shared by local manifests and +/// registry index records: dedupe by parsed requirement (aliases with an +/// equal requirement count once), decide multi-version ambiguity, and decide +/// which requirements are verified enforceable. +fn attribute_requirements( + declarations: &[NormalizedDeclaration], + locked_versions: usize, +) -> AttributionResult { + let mut deduped: Vec = Vec::new(); + for decl in declarations { + match deduped.iter_mut().find(|existing| existing.req == decl.req) { + Some(existing) => existing.mandatory |= decl.mandatory, + None => deduped.push(NormalizedDeclaration { + req: decl.req.clone(), + mandatory: decl.mandatory, + }), + } + } + + if declarations.is_empty() || is_ambiguous(locked_versions, deduped.len()) { + return AttributionResult { + enforced: Vec::new(), + unverified: true, + }; + } + + let mut enforced = Vec::new(); + let mut unverified = false; + if deduped.iter().any(|d| d.mandatory) { + // A mandatory declaration makes every matching mandatory requirement + // definite; matching uncertain declarations remain annotations. + for d in deduped { + if d.mandatory { + enforced.push(d.req); + } else { + unverified = true; + } + } + } else if let [d] = deduped.as_slice() { + // One uncertain declaration is the unique explanation for the edge. + enforced.push(d.req.clone()); + } + + if enforced.is_empty() { + unverified = true; + } + + AttributionResult { + enforced, + unverified, + } } /// Gathers requirements from registry dependents and local manifests. @@ -249,7 +274,6 @@ fn edge_source_is_ambiguous( fn gather_constraints( client: &mut CratesIoClient, dependents_index: &DependentsIndex, - all_packages: &[Package], direct_requirements: &[DirectRequirement], working_dir: &Path, name: &str, @@ -266,18 +290,9 @@ fn gather_constraints( .copied(); for dependent in dependents { - let unverified = if edge_source_is_ambiguous(dependent, all_packages, name, locked_version) - { - true - } else if dependent.is_registry { - let result = registry_dependent_constraints( - client, - dependent, - all_packages, - name, - locked_version, - source, - ); + let unverified = if dependent.is_registry { + let result = + registry_dependent_constraints(client, dependent, name, locked_version, source); let unverified = result.unverified; constraints.extend(result.constraints); unverified @@ -295,19 +310,24 @@ fn gather_constraints( Version::parse(locked_version).is_ok_and(|version| r.req.matches(&version)) }) .collect(); - // Aliases with equal parsed requirements count once. - let mut distinct: Vec<&VersionReq> = Vec::new(); - for req in matching.iter().map(|r| &r.req) { - if !distinct.contains(&req) { - distinct.push(req); - } - } - let locked_versions = eligible_locked_versions(dependent, all_packages, name, source); + let declarations: Vec = matching + .iter() + .map(|r| NormalizedDeclaration { + req: r.req.clone(), + mandatory: true, + }) + .collect(); + let locked_versions = eligible_locked_versions(dependent, name, source); + let result = attribute_requirements(&declarations, locked_versions); - if matching.is_empty() || is_ambiguous(locked_versions, distinct.len()) { + if result.unverified { true } else { + // Every local declaration is mandatory, so on this + // non-ambiguous path `result.enforced` always contains every + // deduped requirement from `matching`; no filter is needed to + // decide which of `matching` to keep. for req in &matching { constraints.push(Constraint { blocker_name: manifest_label(&req.manifest, working_dir), @@ -321,7 +341,7 @@ fn gather_constraints( // Git and alternate-registry requirements cannot be read here. true }; - if unverified { + if unverified && !unverified_dependents.contains(&dependent.name) { unverified_dependents.push(dependent.name.clone()); } } @@ -353,7 +373,6 @@ impl RegistryConstraints { fn registry_dependent_constraints( client: &mut CratesIoClient, dependent: &Package, - all_packages: &[Package], name: &str, locked_version: &str, source: Option<&str>, @@ -365,8 +384,7 @@ fn registry_dependent_constraints( return RegistryConstraints::unreadable(); }; - // Deduplicate requirements, retaining whether any declaration is mandatory. - let mut requirements: Vec<(VersionReq, bool)> = Vec::new(); + let mut declarations = Vec::new(); let mut unverified = false; for dep in &record.deps { if dep.kind.as_deref() == Some("dev") { @@ -379,48 +397,28 @@ fn registry_dependent_constraints( match VersionReq::parse(&dep.req) { Ok(req) if Version::parse(locked_version).is_ok_and(|v| req.matches(&v)) => { let mandatory = dep.target.is_none() && dep.optional != Some(true); - match requirements - .iter_mut() - .find(|(existing, _)| *existing == req) - { - Some((_, existing_mandatory)) => *existing_mandatory |= mandatory, - None => requirements.push((req, mandatory)), - } + declarations.push(NormalizedDeclaration { req, mandatory }); } Ok(_) => {} Err(_) => unverified = true, } } - let locked_versions = eligible_locked_versions(dependent, all_packages, name, source); + let locked_versions = eligible_locked_versions(dependent, name, source); + let result = attribute_requirements(&declarations, locked_versions); - let mut constraints = Vec::new(); - if is_ambiguous(locked_versions, requirements.len()) { - unverified = true; - } else if requirements.iter().any(|(_, mandatory)| *mandatory) { - // A mandatory declaration makes every matching mandatory requirement - // definite; matching uncertain declarations remain annotations. - for (req, mandatory) in requirements { - if mandatory { - constraints.push(Constraint { - blocker_name: dependent.name.clone(), - blocker_version: Some(dependent.version.clone()), - req, - }); - } else { - unverified = true; - } - } - } else if let [(req, _)] = requirements.as_slice() { - // One uncertain declaration is the unique explanation for the edge. - constraints.push(Constraint { + let constraints = result + .enforced + .into_iter() + .map(|req| Constraint { blocker_name: dependent.name.clone(), blocker_version: Some(dependent.version.clone()), - req: req.clone(), - }); - } + req, + }) + .collect(); + RegistryConstraints { - unverified: unverified || constraints.is_empty(), + unverified: unverified || result.unverified, constraints, } } @@ -506,7 +504,6 @@ pub fn generate_suggestions( let gathered = gather_constraints( client, &dependents_index, - all_packages, direct_requirements, working_dir, &violation.package, @@ -577,12 +574,36 @@ pub fn generate_suggestions( #[cfg(test)] mod tests { use super::*; + use crate::lockfile::PackageRef; use chrono::TimeZone; fn now() -> DateTime { Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap() } + /// Calls production `generate_suggestions` with the fixed 30-day + /// minimum age, no prerelease admission, and `now()` that almost every + /// call site in this module shares, unwrapping the `Some` result. + fn suggestions( + client: &mut CratesIoClient, + violations: &[Violation], + packages: &[Package], + direct_requirements: &[DirectRequirement], + working_dir: &Path, + ) -> Vec { + generate_suggestions( + client, + violations, + packages, + direct_requirements, + working_dir, + 30, + false, + now(), + ) + .unwrap() + } + fn v(s: &str) -> Version { Version::parse(s).unwrap() } @@ -906,18 +927,23 @@ mod tests { } } + const CRATES_IO_SOURCE: &str = "registry+https://github.com/rust-lang/crates.io-index"; + + /// A registry package. Its dependency edges default to the same + /// crates.io source as the loader resolves an unsourced edge to, + /// when — as here — the only matching name is a registry package. fn pkg(name: &str, version: &str, deps: &[(&str, &str)]) -> Package { Package { name: name.to_string(), version: version.to_string(), is_registry: true, - source: Some("registry+https://github.com/rust-lang/crates.io-index".to_string()), + source: Some(CRATES_IO_SOURCE.to_string()), dependencies: deps .iter() .map(|(n, v)| PackageRef { name: n.to_string(), version: v.to_string(), - source: None, + source: Some(CRATES_IO_SOURCE.to_string()), }) .collect(), } @@ -967,6 +993,31 @@ mod tests { } } + /// Builds a client and dependents index from `transport`/`packages` + /// and calls production `gather_constraints` with a fixed + /// `/work` working dir — the shared shape of most of this module's + /// `gather_constraints` call sites. + fn gather( + transport: FakeTransport, + packages: &[Package], + requirements: &[DirectRequirement], + name: &str, + locked_version: &str, + source: Option<&str>, + ) -> GatheredConstraints { + let mut client = fast_client(transport); + let index = build_dependents_index(packages); + gather_constraints( + &mut client, + &index, + requirements, + Path::new("/work"), + name, + locked_version, + source, + ) + } + #[test] fn aliased_registry_requirements_follow_the_locked_version() { let transport = FakeTransport::default(); @@ -978,12 +1029,11 @@ mod tests { let gathered = gather_constraints( &mut client, &index, - &packages, &[], Path::new("/work"), "foo", locked, - None, + Some(CRATES_IO_SOURCE), ); assert_eq!(gathered.constraints.len(), 1); assert!(gathered.unverified_dependents.is_empty()); @@ -1004,18 +1054,14 @@ mod tests { "app", r#"{"vers":"1.0.0","deps":[{"name":"foo","req":"^1.5"}]}"#, ); - let mut client = fast_client(transport); let packages = vec![pkg("app", "1.0.0", &[("foo", "1.4.0")])]; - let index = build_dependents_index(&packages); - let gathered = gather_constraints( - &mut client, - &index, + let gathered = gather( + transport, &packages, &[], - Path::new("/work"), "foo", "1.4.0", - None, + Some(CRATES_IO_SOURCE), ); assert!(gathered.constraints.is_empty()); assert_eq!(gathered.unverified_dependents, ["app"]); @@ -1023,23 +1069,53 @@ mod tests { #[test] fn missing_non_registry_requirements_are_unverified() { - let mut client = fast_client(FakeTransport::default()); let packages = vec![non_registry_pkg("git-app", "1.0.0", &[("foo", "1.5.0")])]; - let index = build_dependents_index(&packages); - let gathered = gather_constraints( - &mut client, - &index, + let gathered = gather( + FakeTransport::default(), &packages, &[], - Path::new("/work"), "foo", "1.5.0", - None, + Some(CRATES_IO_SOURCE), ); assert!(gathered.constraints.is_empty()); assert_eq!(gathered.unverified_dependents, ["git-app"]); } + #[test] + fn same_named_git_dependents_report_one_unverified_label() { + let packages = vec![ + external_pkg("parent", "1.0.0", GIT_SOURCE, &[("foo", "1.9.0")]), + external_pkg("parent", "2.0.0", GIT_SOURCE, &[("foo", "1.9.0")]), + ]; + let gathered = gather( + FakeTransport::default(), + &packages, + &[], + "foo", + "1.9.0", + Some(CRATES_IO_SOURCE), + ); + assert_eq!(gathered.unverified_dependents, ["parent"]); + } + + #[test] + fn distinct_git_dependents_are_each_reported() { + let packages = vec![ + external_pkg("parent-a", "1.0.0", GIT_SOURCE, &[("foo", "1.9.0")]), + external_pkg("parent-b", "1.0.0", GIT_SOURCE, &[("foo", "1.9.0")]), + ]; + let gathered = gather( + FakeTransport::default(), + &packages, + &[], + "foo", + "1.9.0", + Some(CRATES_IO_SOURCE), + ); + assert_eq!(gathered.unverified_dependents, ["parent-a", "parent-b"]); + } + #[test] fn aliased_manifest_requirements_follow_the_locked_version() { let mut client = fast_client(FakeTransport::default()); @@ -1064,12 +1140,11 @@ mod tests { let gathered = gather_constraints( &mut client, &index, - &packages, &requirements, Path::new("/work"), "foo", locked, - None, + Some(CRATES_IO_SOURCE), ); assert_eq!(gathered.constraints.len(), 1); assert!(gathered.unverified_dependents.is_empty()); @@ -1089,20 +1164,16 @@ mod tests { // name and version, whose manifest requirement is loose enough // to permit the downgrade — the ordinary case this whole path // exists for. - let mut client = fast_client(FakeTransport::default()); let packages = vec![non_registry_pkg("app", "1.0.0", &[("foo", "1.5.0")])]; let requirements = vec![local_requirement("app", "1.0.0", "foo", "^1")]; - let index = build_dependents_index(&packages); - let gathered = gather_constraints( - &mut client, - &index, + let gathered = gather( + FakeTransport::default(), &packages, &requirements, - Path::new("/work"), "foo", "1.5.0", - None, + Some(CRATES_IO_SOURCE), ); assert_eq!(gathered.constraints.len(), 1); assert!(gathered.unverified_dependents.is_empty()); @@ -1120,20 +1191,16 @@ mod tests { // Positive control, the other direction: the same identity // match, but the requirement is restrictive enough to reject // the downgrade candidate. - let mut client = fast_client(FakeTransport::default()); let packages = vec![non_registry_pkg("app", "1.0.0", &[("foo", "1.5.0")])]; let requirements = vec![local_requirement("app", "1.0.0", "foo", "^1.5")]; - let index = build_dependents_index(&packages); - let gathered = gather_constraints( - &mut client, - &index, + let gathered = gather( + FakeTransport::default(), &packages, &requirements, - Path::new("/work"), "foo", "1.5.0", - None, + Some(CRATES_IO_SOURCE), ); assert_eq!(gathered.constraints.len(), 1); assert!(gathered.unverified_dependents.is_empty()); @@ -1152,23 +1219,19 @@ mod tests { // version. The local one's manifest permits the downgrade, but // that manifest was never the git dependent's own — it must not // be credited with verifying it. - let mut client = fast_client(FakeTransport::default()); let packages = vec![ external_pkg("app", "1.0.0", GIT_SOURCE, &[("foo", "1.5.0")]), non_registry_pkg("app", "1.0.0", &[]), ]; let requirements = vec![local_requirement("app", "1.0.0", "foo", "^1")]; - let index = build_dependents_index(&packages); - let gathered = gather_constraints( - &mut client, - &index, + let gathered = gather( + FakeTransport::default(), &packages, &requirements, - Path::new("/work"), "foo", "1.5.0", - None, + Some(CRATES_IO_SOURCE), ); assert!(gathered.constraints.is_empty()); assert_eq!(gathered.unverified_dependents, ["app"]); @@ -1179,23 +1242,19 @@ mod tests { { // Same shape as the git case, but the external dependent is on // an alternate registry instead. - let mut client = fast_client(FakeTransport::default()); let packages = vec![ external_pkg("app", "1.0.0", ALT_REGISTRY_SOURCE, &[("foo", "1.5.0")]), non_registry_pkg("app", "1.0.0", &[]), ]; let requirements = vec![local_requirement("app", "1.0.0", "foo", "^1")]; - let index = build_dependents_index(&packages); - let gathered = gather_constraints( - &mut client, - &index, + let gathered = gather( + FakeTransport::default(), &packages, &requirements, - Path::new("/work"), "foo", "1.5.0", - None, + Some(CRATES_IO_SOURCE), ); assert!(gathered.constraints.is_empty()); assert_eq!(gathered.unverified_dependents, ["app"]); @@ -1209,23 +1268,19 @@ mod tests { // matches the locked version but would block candidate 1.4.0; // even so, it must not block foo's downgrade for the git // dependent, whose own requirement can't be read at all. - let mut client = fast_client(FakeTransport::default()); let packages = vec![ external_pkg("app", "1.0.0", GIT_SOURCE, &[("foo", "1.5.0")]), non_registry_pkg("app", "1.0.0", &[]), ]; let requirements = vec![local_requirement("app", "1.0.0", "foo", "^1.5")]; - let index = build_dependents_index(&packages); - let gathered = gather_constraints( - &mut client, - &index, + let gathered = gather( + FakeTransport::default(), &packages, &requirements, - Path::new("/work"), "foo", "1.5.0", - None, + Some(CRATES_IO_SOURCE), ); assert!(gathered.constraints.is_empty()); assert_eq!(gathered.unverified_dependents, ["app"]); @@ -1244,20 +1299,16 @@ mod tests { // "foo", but the actual lockfile dependent is a *different* // "app" — 1.0.0 — with an identical name. Declaring package // name alone must not be enough to apply this requirement. - let mut client = fast_client(FakeTransport::default()); let packages = vec![non_registry_pkg("app", "1.0.0", &[("foo", "1.5.0")])]; let requirements = vec![local_requirement("app", "2.0.0", "foo", "^1.5")]; - let index = build_dependents_index(&packages); - let gathered = gather_constraints( - &mut client, - &index, + let gathered = gather( + FakeTransport::default(), &packages, &requirements, - Path::new("/work"), "foo", "1.5.0", - None, + Some(CRATES_IO_SOURCE), ); assert!(gathered.constraints.is_empty()); assert_eq!(gathered.unverified_dependents, ["app"]); @@ -1273,17 +1324,8 @@ mod tests { let violations = vec![too_new("serde", "1.0.0"), too_old("syn")]; let packages = vec![pkg("serde", "1.0.0", &[])]; - let outcomes = generate_suggestions( - &mut client, - &violations, - &packages, - &[], - Path::new("/work"), - 30, - false, - now(), - ) - .unwrap(); + let outcomes = + suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); assert_eq!(outcomes.len(), 1); } @@ -1300,17 +1342,8 @@ mod tests { let violations = vec![too_new("serde", "1.0.0"), too_new("syn", "1.1.0")]; let packages = vec![pkg("serde", "1.0.0", &[]), pkg("syn", "1.1.0", &[])]; - let outcomes = generate_suggestions( - &mut client, - &violations, - &packages, - &[], - Path::new("/work"), - 30, - false, - now(), - ) - .unwrap(); + let outcomes = + suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); assert_eq!(outcomes.len(), 1); assert!(matches!(&outcomes[0], Outcome::Suggest { package, .. } if package == "syn")); @@ -1324,17 +1357,8 @@ mod tests { let violations = vec![too_new("serde", "1.0.0")]; let packages = vec![pkg("serde", "1.0.0", &[])]; - let outcomes = generate_suggestions( - &mut client, - &violations, - &packages, - &[], - Path::new("/work"), - 30, - false, - now(), - ) - .unwrap(); + let outcomes = + suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); assert!(matches!(outcomes[0], Outcome::NoCompliantVersion { .. })); } @@ -1378,17 +1402,8 @@ mod tests { pkg("serde", "1.5.0", &[]), pkg("app", "1.0.0", &[("serde", "1.5.0")]), ]; - let outcomes = generate_suggestions( - &mut client, - &violations, - &packages, - &[], - Path::new("/work"), - 30, - false, - now(), - ) - .unwrap(); + let outcomes = + suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); match &outcomes[0] { Outcome::Blocked { @@ -1457,17 +1472,8 @@ mod tests { ]; let violations = vec![too_new("serde", "1.5.0")]; - let outcomes = generate_suggestions( - &mut client, - &violations, - &packages, - &[], - Path::new("/work"), - 30, - false, - now(), - ) - .unwrap(); + let outcomes = + suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); assert!( matches!(&outcomes[0], Outcome::Suggest { suggested_version, .. } if suggested_version == "1.4.0"), @@ -1514,17 +1520,8 @@ mod tests { ]; let violations = vec![too_new("serde", "1.5.0")]; - let outcomes = generate_suggestions( - &mut client, - &violations, - &packages, - &[], - Path::new("/work"), - 30, - false, - now(), - ) - .unwrap(); + let outcomes = + suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); match &outcomes[0] { Outcome::Suggest { package_spec, .. } => { @@ -1545,17 +1542,8 @@ mod tests { let violations = vec![too_new("serde", "1.5.0")]; let packages = vec![pkg("serde", "1.5.0", &[])]; - let outcomes = generate_suggestions( - &mut client, - &violations, - &packages, - &[], - Path::new("/work"), - 30, - false, - now(), - ) - .unwrap(); + let outcomes = + suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); match &outcomes[0] { Outcome::Suggest { package_spec, .. } => assert_eq!(package_spec, "serde"), @@ -1618,17 +1606,8 @@ mod tests { ]; let violations = vec![too_new("local-crate", "1.1.0")]; - let outcomes = generate_suggestions( - &mut client, - &violations, - &packages, - &[], - Path::new("/work"), - 30, - false, - now(), - ) - .unwrap(); + let outcomes = + suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); assert!( matches!(&outcomes[0], Outcome::Suggest { suggested_version, .. } if suggested_version == "1.0.0"), @@ -1658,17 +1637,8 @@ mod tests { pkg("serde", "1.5.0", &[]), pkg("app", "1.0.0", &[("serde", "1.5.0")]), ]; - let outcomes = generate_suggestions( - &mut client, - &violations, - &packages, - &[], - Path::new("/work"), - 30, - false, - now(), - ) - .unwrap(); + let outcomes = + suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); assert!(matches!(outcomes[0], Outcome::Suggest { .. })); } @@ -1688,17 +1658,8 @@ mod tests { pkg("serde", "1.5.0", &[]), pkg("app", "1.0.0", &[("serde", "1.5.0")]), ]; - let outcomes = generate_suggestions( - &mut client, - &violations, - &packages, - &[], - Path::new("/work"), - 30, - false, - now(), - ) - .unwrap(); + let outcomes = + suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); assert!(matches!(outcomes[0], Outcome::Blocked { .. })); } @@ -1726,17 +1687,8 @@ mod tests { pkg("serde", "1.5.0", &[]), pkg("app", "1.0.0", &[("serde", "1.5.0")]), ]; - let outcomes = generate_suggestions( - &mut client, - &violations, - &packages, - &[], - Path::new("/work"), - 30, - false, - now(), - ) - .unwrap(); + let outcomes = + suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); match &outcomes[0] { Outcome::Suggest { @@ -1772,17 +1724,8 @@ mod tests { pkg("serde", "1.5.0", &[]), pkg("app", "1.0.0", &[("serde", "1.5.0")]), ]; - let outcomes = generate_suggestions( - &mut client, - &violations, - &packages, - &[], - Path::new("/work"), - 30, - false, - now(), - ) - .unwrap(); + let outcomes = + suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); match &outcomes[0] { Outcome::Blocked { @@ -1821,17 +1764,8 @@ mod tests { pkg("serde", "1.5.0", &[]), pkg("app", "1.0.0", &[("serde", "1.5.0")]), ]; - let outcomes = generate_suggestions( - &mut client, - &violations, - &packages, - &[], - Path::new("/work"), - 30, - false, - now(), - ) - .unwrap(); + let outcomes = + suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); match &outcomes[0] { Outcome::Blocked { @@ -1871,17 +1805,8 @@ mod tests { pkg("serde", "1.5.0", &[]), pkg("app", "1.0.0", &[("serde", "1.5.0")]), ]; - let outcomes = generate_suggestions( - &mut client, - &violations, - &packages, - &[], - Path::new("/work"), - 30, - false, - now(), - ) - .unwrap(); + let outcomes = + suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); match &outcomes[0] { Outcome::Blocked { @@ -1922,17 +1847,8 @@ mod tests { pkg("serde", "1.5.0", &[]), pkg("app", "1.0.0", &[("serde", "1.5.0")]), ]; - let outcomes = generate_suggestions( - &mut client, - &violations, - &packages, - &[], - Path::new("/work"), - 30, - false, - now(), - ) - .unwrap(); + let outcomes = + suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); match &outcomes[0] { Outcome::Blocked { @@ -1971,7 +1887,6 @@ mod tests { ("other source", true, &["^1", ">=1.8,<3"], false, true), ("path sibling", false, &["^1", ">=1.8,<3"], false, true), ("duplicate edge", false, &["^1", ">=1.8,<3"], false, true), - ("unknown source", false, &["^1"], true, false), ("unreadable", false, &[], true, false), ]; for registry in [true, false] { @@ -2015,10 +1930,6 @@ mod tests { version: "1.9.0".into(), source: Some(source.clone()), }), - "unknown source" => { - app.dependencies[0].source = None; - packages.push(external_pkg("foo", "1.9.0", ALT_REGISTRY_SOURCE, &[])); - } "other parent" => { packages.push(pkg("foo", "2.0.0", &[])); packages.push(pkg("other", "1.0.0", &[("foo", "2.0.0")])); @@ -2071,7 +1982,6 @@ mod tests { let gathered = gather_constraints( &mut client, &index, - &packages, &requirements, Path::new("/work"), "foo", @@ -2129,17 +2039,8 @@ mod tests { pkg("serde", "1.5.0", &[]), pkg("app", "1.0.0", &[("serde", "1.5.0")]), ]; - let outcomes = generate_suggestions( - &mut client, - &violations, - &packages, - &[], - Path::new("/work"), - 30, - false, - now(), - ) - .unwrap(); + let outcomes = + suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); match &outcomes[0] { Outcome::Suggest { @@ -2161,17 +2062,8 @@ mod tests { let violations = vec![too_new("serde", "1.0.0"), too_new("serde", "2.0.0")]; let packages = vec![pkg("serde", "1.0.0", &[]), pkg("serde", "2.0.0", &[])]; - let outcomes = generate_suggestions( - &mut client, - &violations, - &packages, - &[], - Path::new("/work"), - 30, - false, - now(), - ) - .unwrap(); + let outcomes = + suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); assert_eq!(outcomes.len(), 2); } @@ -2198,17 +2090,8 @@ mod tests { pkg("target", "1.5.0", &[]), pkg("y", "1.5.0", &[("target", "1.5.0")]), ]; - let outcomes = generate_suggestions( - &mut client, - &violations, - &packages, - &[], - Path::new("/work"), - 30, - false, - now(), - ) - .unwrap(); + let outcomes = + suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); match &outcomes[0] { Outcome::Blocked { blocker, .. } => { @@ -2264,17 +2147,8 @@ mod tests { pkg("foo", "1.5.0", &[]), pkg("foo", "2.5.0", &[("target", "1.5.0")]), ]; - let outcomes = generate_suggestions( - &mut client, - &violations, - &packages, - &[], - Path::new("/work"), - 30, - false, - now(), - ) - .unwrap(); + let outcomes = + suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); assert!( matches!(&outcomes[1], Outcome::Suggest { package, locked_version, .. } @@ -2331,17 +2205,13 @@ mod tests { ]; let violations = vec![too_new("clap", "2.5.0")]; - let outcomes = generate_suggestions( + let outcomes = suggestions( &mut client, &violations, &packages, &direct_requirements, Path::new("/work"), - 30, - false, - now(), - ) - .unwrap(); + ); match &outcomes[0] { Outcome::Suggest { @@ -2457,17 +2327,13 @@ mod tests { too_new("delta", "1.5.0"), ]; - let outcomes = generate_suggestions( + let outcomes = suggestions( &mut client, &violations, &packages, &direct_requirements, &dir, - 30, - false, - now(), - ) - .unwrap(); + ); assert_eq!(outcomes.len(), 4); @@ -2846,17 +2712,13 @@ foo_priv = { package = "foo", version = "=1.9.0", registry = "priv" } let violations = vec![too_new("foo", "1.9.0")]; let packages = packages_with_dual_source_foo(); - let outcomes = generate_suggestions( + let outcomes = suggestions( &mut client, &violations, &packages, &direct_requirements, dir.path(), - 30, - false, - now(), - ) - .unwrap(); + ); match &outcomes[0] { Outcome::Suggest { @@ -2915,17 +2777,13 @@ foo_priv = { package = "foo", version = "^1.0", registry = "priv" } let violations = vec![too_new("foo", "1.9.0")]; let packages = packages_with_dual_source_foo(); - let outcomes = generate_suggestions( + let outcomes = suggestions( &mut client, &violations, &packages, &direct_requirements, dir.path(), - 30, - false, - now(), - ) - .unwrap(); + ); match &outcomes[0] { Outcome::Blocked { @@ -2978,17 +2836,13 @@ foo_priv = { package = "foo", version = "^1.0", registry = "priv" } let violations = vec![too_new("foo", "1.9.0")]; let packages = packages_with_dual_source_foo(); - let outcomes = generate_suggestions( + let outcomes = suggestions( &mut client, &violations, &packages, &direct_requirements, dir.path(), - 30, - false, - now(), - ) - .unwrap(); + ); match &outcomes[0] { Outcome::Blocked { @@ -3067,17 +2921,13 @@ foo_pinned = { package = "foo", version = "=1.9.0" } }], }, ]; - let outcomes = generate_suggestions( + let outcomes = suggestions( &mut client, &violations, &packages, &direct_requirements, dir.path(), - 30, - false, - now(), - ) - .unwrap(); + ); match &outcomes[0] { Outcome::Blocked { @@ -3174,17 +3024,13 @@ foo = "=1.9.0" }], }, ]; - let outcomes = generate_suggestions( + let outcomes = suggestions( &mut client, &violations, &packages, &direct_requirements, dir.path(), - 30, - false, - now(), - ) - .unwrap(); + ); match &outcomes[0] { Outcome::Suggest { @@ -3330,17 +3176,13 @@ foo = "^1.0" }], }, ]; - let outcomes = generate_suggestions( + let outcomes = suggestions( &mut client, &violations, &packages, &direct_requirements, dir.path(), - 30, - false, - now(), - ) - .unwrap(); + ); match &outcomes[0] { Outcome::Suggest { @@ -3423,17 +3265,13 @@ foo = "^1.0" }], }, ]; - let outcomes = generate_suggestions( + let outcomes = suggestions( &mut client, &violations, &packages, &direct_requirements, dir.path(), - 30, - false, - now(), - ) - .unwrap(); + ); match &outcomes[0] { Outcome::Suggest { @@ -3514,17 +3352,13 @@ foo = "^1.8.5" }], }, ]; - let outcomes = generate_suggestions( + let outcomes = suggestions( &mut client, &violations, &packages, &direct_requirements, dir.path(), - 30, - false, - now(), - ) - .unwrap(); + ); match &outcomes[0] { Outcome::Suggest { @@ -3598,17 +3432,13 @@ foo = "^1.0" dependencies: vec![], }, ]; - let outcomes = generate_suggestions( + let outcomes = suggestions( &mut client, &violations, &packages, &direct_requirements, dir.path(), - 30, - false, - now(), - ) - .unwrap(); + ); match &outcomes[0] { Outcome::Suggest { @@ -3684,17 +3514,13 @@ foo = "^1.0" dependencies: vec![], }, ]; - let outcomes = generate_suggestions( + let outcomes = suggestions( &mut client, &violations, &packages, &direct_requirements, dir.path(), - 30, - false, - now(), - ) - .unwrap(); + ); match &outcomes[0] { Outcome::Suggest { diff --git a/tests/suggest_fix_cli.rs b/tests/suggest_fix_cli.rs index a5ff77b..e6a9834 100644 --- a/tests/suggest_fix_cli.rs +++ b/tests/suggest_fix_cli.rs @@ -187,7 +187,86 @@ fn suggest_fix_cli_retains_best_effort_qualification() { ); assert_eq!(output.status.code(), Some(1)); let stdout = String::from_utf8(output.stdout).unwrap(); + assert!( + stdout.contains("requirement of consumer unverified"), + "stdout was:\n{stdout}" + ); assert!(stdout.contains("best-effort"), "stdout was:\n{stdout}"); + assert!( + stdout.contains("apply top to bottom, then re-run"), + "stdout was:\n{stdout}" + ); + assert!( + stdout.contains("or test after applying"), + "stdout was:\n{stdout}" + ); +} + +#[test] +fn suggest_fix_cli_reports_same_named_git_parent_once() { + let project = tempdir().unwrap(); + fs::write( + project.path().join("Cargo.toml"), + "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ) + .unwrap(); + fs::write( + project.path().join("Cargo.lock"), + r#"version = 4 + +[[package]] +name = "app" +version = "0.1.0" + +[[package]] +name = "parent" +version = "1.0.0" +source = "git+https://github.com/example/parent?tag=v1#1111111111111111111111111111111111111111" +dependencies = [ + "foo", +] + +[[package]] +name = "parent" +version = "2.0.0" +source = "git+https://github.com/example/parent?tag=v2#2222222222222222222222222222222222222222" +dependencies = [ + "foo", +] + +[[package]] +name = "foo" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0000000000000000000000000000000000000000000000000000000000000000" +"#, + ) + .unwrap(); + let cache = write_cache( + project.path(), + &[("foo", "1.9.0", 2, vec![("1.9.0", 2), ("1.7.0", 80)])], + ); + let output = run_oxidate( + project.path(), + &[ + "--min-age-days", + "30", + "--suggest-fix", + "--cache-path", + cache.to_str().unwrap(), + ], + ); + assert_eq!(output.status.code(), Some(1)); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!( + stdout.contains("cargo update -p foo@1.9.0 --precise 1.7.0"), + "stdout was:\n{stdout}" + ); + assert!( + stdout.contains("(requirement of parent unverified)"), + "stdout was:\n{stdout}" + ); + assert!(!stdout.contains("parent, parent"), "stdout was:\n{stdout}"); } #[test] From d10762e3075101d2d6d4b37882dc941ef055a04f Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Wed, 16 Sep 2026 23:42:44 -0400 Subject: [PATCH 24/34] refactor tests --- src/suggest.rs | 3007 +---------------- src/suggest/tests.rs | 59 + src/suggest/tests/end_to_end_tests.rs | 167 + src/suggest/tests/filter_candidates_tests.rs | 107 + .../tests/generate_suggestions_tests.rs | 1356 ++++++++ .../manifest_dependent_identity_tests.rs | 487 +++ .../tests/manifest_registry_identity_tests.rs | 473 +++ .../tests/source_collision_cargo_tests.rs | 174 + src/suggest/tests/walk_tests.rs | 97 + 9 files changed, 2935 insertions(+), 2992 deletions(-) create mode 100644 src/suggest/tests.rs create mode 100644 src/suggest/tests/end_to_end_tests.rs create mode 100644 src/suggest/tests/filter_candidates_tests.rs create mode 100644 src/suggest/tests/generate_suggestions_tests.rs create mode 100644 src/suggest/tests/manifest_dependent_identity_tests.rs create mode 100644 src/suggest/tests/manifest_registry_identity_tests.rs create mode 100644 src/suggest/tests/source_collision_cargo_tests.rs create mode 100644 src/suggest/tests/walk_tests.rs diff --git a/src/suggest.rs b/src/suggest.rs index cebb1d5..7bee579 100644 --- a/src/suggest.rs +++ b/src/suggest.rs @@ -151,11 +151,19 @@ struct GatheredConstraints { /// and version from different origins never share constraints. type DependentsIndex<'a> = HashMap<(&'a str, &'a str, Option<&'a str>), Vec<&'a Package>>; -fn build_dependents_index(all_packages: &[Package]) -> DependentsIndex<'_> { - let mut index: DependentsIndex = HashMap::new(); +/// Packages keyed by name and version for source lookup and pkgid ambiguity. +type NameVersionIndex<'a> = HashMap<(&'a str, &'a str), Vec<&'a Package>>; + +fn build_indexes(all_packages: &[Package]) -> (DependentsIndex<'_>, NameVersionIndex<'_>) { + let mut dependents = DependentsIndex::new(); + let mut names_and_versions = NameVersionIndex::new(); for pkg in all_packages { + names_and_versions + .entry((pkg.name.as_str(), pkg.version.as_str())) + .or_default() + .push(pkg); for dep in &pkg.dependencies { - index + dependents .entry(( dep.name.as_str(), dep.version.as_str(), @@ -165,26 +173,7 @@ fn build_dependents_index(all_packages: &[Package]) -> DependentsIndex<'_> { .push(pkg); } } - index -} - -/// Packages keyed by name and version for source lookup and pkgid ambiguity. -type NameVersionIndex<'a> = HashMap<(&'a str, &'a str), Vec<&'a Package>>; - -fn build_name_version_index(all_packages: &[Package]) -> NameVersionIndex<'_> { - let mut index: NameVersionIndex = HashMap::new(); - for pkg in all_packages { - index - .entry((pkg.name.as_str(), pkg.version.as_str())) - .or_default() - .push(pkg); - } - index -} - -/// Multiple versions are ambiguous only when their matching requirements differ. -fn is_ambiguous(locked_versions: usize, distinct_requirements: usize) -> bool { - locked_versions > 1 && distinct_requirements > 1 + (dependents, names_and_versions) } /// Count versions within the eligible source, ignoring duplicate edges. @@ -235,7 +224,7 @@ fn attribute_requirements( } } - if declarations.is_empty() || is_ambiguous(locked_versions, deduped.len()) { + if declarations.is_empty() || locked_versions > 1 && deduped.len() > 1 { return AttributionResult { enforced: Vec::new(), unverified: true, @@ -460,8 +449,7 @@ pub fn generate_suggestions( return None; } - let dependents_index = build_dependents_index(all_packages); - let name_version_index = build_name_version_index(all_packages); + let (dependents_index, name_version_index) = build_indexes(all_packages); let mut outcomes = Vec::new(); eprintln!("\nFetching version suggestions..."); @@ -572,2969 +560,4 @@ pub fn generate_suggestions( } #[cfg(test)] -mod tests { - use super::*; - use crate::lockfile::PackageRef; - use chrono::TimeZone; - - fn now() -> DateTime { - Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap() - } - - /// Calls production `generate_suggestions` with the fixed 30-day - /// minimum age, no prerelease admission, and `now()` that almost every - /// call site in this module shares, unwrapping the `Some` result. - fn suggestions( - client: &mut CratesIoClient, - violations: &[Violation], - packages: &[Package], - direct_requirements: &[DirectRequirement], - working_dir: &Path, - ) -> Vec { - generate_suggestions( - client, - violations, - packages, - direct_requirements, - working_dir, - 30, - false, - now(), - ) - .unwrap() - } - - fn v(s: &str) -> Version { - Version::parse(s).unwrap() - } - - fn make_version(version: &str, days_ago: i64, yanked: bool) -> CrateVersionInfo { - let created_at = now() - chrono::Duration::days(days_ago); - CrateVersionInfo { - num: version.to_string(), - created_at, - yanked, - } - } - - fn constraint(req: &str) -> Constraint { - Constraint { - blocker_name: "dep".to_string(), - blocker_version: Some("1.0.0".to_string()), - req: VersionReq::parse(req).unwrap(), - } - } - - mod filter_candidates_tests { - use super::*; - - #[test] - fn excludes_too_new_yanked_and_out_of_range() { - let versions = vec![ - make_version("1.0.0", 100, false), - make_version("1.1.0", 50, true), // yanked - make_version("1.2.0", 40, false), // compliant, same major as locked - make_version("0.9.0", 200, false), // different major: out of range - make_version("1.3.0", 5, false), // too new (min age 30) - ]; - - let result = filter_candidates(&versions, &v("1.5.0"), 30, now(), false); - let nums: Vec = result.iter().map(|(ver, _)| ver.to_string()).collect(); - // Newest-first by publish date among the two survivors. - assert_eq!(nums, vec!["1.2.0".to_string(), "1.0.0".to_string()]); - } - - #[test] - fn sorted_newest_first_by_publish_date() { - let versions = vec![ - make_version("1.0.0", 100, false), - make_version("1.1.0", 200, false), - make_version("1.2.0", 50, false), - ]; - - let result = filter_candidates(&versions, &v("1.5.0"), 30, now(), false); - let nums: Vec = result.iter().map(|(ver, _)| ver.to_string()).collect(); - assert_eq!(nums, vec!["1.2.0", "1.0.0", "1.1.0"]); - } - - #[test] - fn prerelease_excluded_by_default() { - let versions = vec![make_version("1.1.0-beta.1", 100, false)]; - let result = filter_candidates(&versions, &v("1.0.0"), 30, now(), false); - assert!(result.is_empty()); - } - - #[test] - fn prerelease_included_with_flag_when_range_matches() { - // Same compatible zone (1.0.0), prerelease allowed by the flag. - let versions = vec![make_version("1.0.0-beta.1", 100, false)]; - let result = filter_candidates(&versions, &v("1.0.0-beta.2"), 30, now(), true); - assert_eq!(result.len(), 1); - assert_eq!(result[0].0.to_string(), "1.0.0-beta.1"); - } - - #[test] - fn prerelease_allowed_when_locked_is_itself_a_prerelease() { - let versions = vec![make_version("1.0.0-beta.1", 100, false)]; - let result = filter_candidates(&versions, &v("1.0.0-beta.2"), 30, now(), false); - assert_eq!(result.len(), 1); - } - - #[test] - fn excludes_a_higher_version_published_earlier_than_locked() { - // "1.4.0" was published before "1.3.0" but is a higher semantic - // version, so it must never be offered as a downgrade even - // though it's older on the publish timeline. - let versions = vec![ - make_version("1.4.0", 100, false), - make_version("1.3.0", 50, false), - ]; - let result = filter_candidates(&versions, &v("1.3.0"), 30, now(), false); - let nums: Vec = result.iter().map(|(ver, _)| ver.to_string()).collect(); - assert!(nums.is_empty(), "expected no candidates, got {nums:?}"); - } - - #[test] - fn excludes_a_version_equal_in_precedence_including_build_metadata_only_differences() { - let versions = vec![ - make_version("1.3.0", 100, false), - make_version("1.3.0+build.1", 100, false), - ]; - let result = filter_candidates(&versions, &v("1.3.0"), 30, now(), false); - assert!(result.is_empty()); - } - - #[test] - fn excludes_versions_equal_in_precedence_to_a_locked_version_with_build_metadata() { - // Locked itself carries build metadata this time: candidates - // differing only in build metadata (or lacking it) still have - // equal semantic precedence and must not be offered. - let versions = vec![ - make_version("1.3.0", 100, false), - make_version("1.3.0+build.1", 100, false), - ]; - let result = filter_candidates(&versions, &v("1.3.0+build.2"), 30, now(), false); - assert!(result.is_empty()); - } - - #[test] - fn excludes_stable_release_above_a_locked_prerelease() { - // A stable release outranks any prerelease of the same - // major.minor.patch, so it must not be offered as a "downgrade" - // from a locked prerelease. - let versions = vec![make_version("1.0.0", 100, false)]; - let result = filter_candidates(&versions, &v("1.0.0-beta.1"), 30, now(), false); - assert!(result.is_empty()); - } - - #[test] - fn excludes_a_later_prerelease_above_a_locked_prerelease() { - let versions = vec![make_version("1.0.0-beta.2", 100, false)]; - let result = filter_candidates(&versions, &v("1.0.0-beta.1"), 30, now(), false); - assert!(result.is_empty()); - } - } - - mod walk_tests { - use super::*; - - #[test] - fn newest_accepted_when_every_constraint_matches() { - let candidates = vec![(v("1.3.0"), 5), (v("1.2.0"), 20)]; - let constraints = vec![constraint("^1.2")]; - - match walk(candidates, constraints) { - WalkResult::Suggest(version, age) => { - assert_eq!(version.to_string(), "1.3.0"); - assert_eq!(age, 5); - } - _ => panic!("expected Suggest"), - } - } - - #[test] - fn walk_continues_to_older_version_when_newest_is_rejected() { - let candidates = vec![(v("1.3.0"), 5), (v("1.2.0"), 20), (v("1.1.0"), 40)]; - // `~1.1` narrows to the 1.1.x line, so only the oldest candidate - // satisfies it — the walk must skip past the two newer ones. - let constraints = vec![constraint("~1.1")]; - match walk(candidates, constraints) { - WalkResult::Suggest(version, _) => assert_eq!(version.to_string(), "1.1.0"), - _ => panic!("expected Suggest"), - } - } - - #[test] - fn blocked_when_no_candidate_satisfies_every_constraint() { - let candidates = vec![(v("1.3.0"), 5), (v("1.2.0"), 20)]; - let constraints = vec![constraint("^2.0")]; - - match walk(candidates, constraints) { - WalkResult::Blocked { - newest_compliant, - blocker, - } => { - assert_eq!(newest_compliant.to_string(), "1.3.0"); - assert_eq!(blocker.blocker_name, "dep"); - assert_eq!(blocker.req.to_string(), "^2.0"); - } - _ => panic!("expected Blocked"), - } - } - - #[test] - // Pins that the blocker is the constraint rejecting every candidate. - // `<=1.3` comes first and rejects the newest candidate, but 1.2.0 - // satisfies it — only `>=1.4.5` makes every downgrade impossible. - fn blocker_is_the_constraint_that_rejects_every_candidate() { - let candidates = vec![(v("1.4.0"), 5), (v("1.2.0"), 20)]; - let constraints = vec![constraint("<=1.3"), constraint(">=1.4.5")]; - - match walk(candidates, constraints) { - WalkResult::Blocked { blocker, .. } => { - assert_eq!(blocker.req.to_string(), ">=1.4.5"); - } - _ => panic!("expected Blocked"), - } - } - - #[test] - // Pins the fallback: when no single constraint rejects every - // candidate, the block is a genuine combination, so we fall back to - // the first constraint that rejects the newest candidate. - fn blocker_falls_back_when_no_single_constraint_blocks_all_candidates() { - let candidates = vec![(v("1.4.0"), 5), (v("1.2.0"), 20)]; - let constraints = vec![constraint("<=1.3"), constraint(">=1.4")]; - - match walk(candidates, constraints) { - WalkResult::Blocked { blocker, .. } => { - assert_eq!(blocker.req.to_string(), "<=1.3"); - } - _ => panic!("expected Blocked"), - } - } - - #[test] - fn no_compliant_version_when_candidates_empty() { - match walk(vec![], vec![constraint("^1.0")]) { - WalkResult::NoCompliantVersion => {} - _ => panic!("expected NoCompliantVersion"), - } - } - - #[test] - fn no_constraints_picks_newest_by_age() { - let candidates = vec![(v("1.3.0"), 5), (v("1.2.0"), 20)]; - match walk(candidates, vec![]) { - WalkResult::Suggest(version, age) => { - assert_eq!(version.to_string(), "1.3.0"); - assert_eq!(age, 5); - } - _ => panic!("expected Suggest"), - } - } - } - - mod generate_suggestions_tests { - use super::*; - use crate::api::RetryPolicy; - use crate::api::test_support::{FakeTransport, ScriptedResponse, index_url, versions_url}; - use crate::lockfile::PackageRef; - use crate::report::Aged; - use std::num::NonZeroU32; - use std::path::PathBuf; - use std::time::Duration; - - trait FakeTransportExt { - fn ok(&self, name: &str, versions_json: &str); - fn error(&self, name: &str); - fn index_ok(&self, name: &str, records_ndjson: &str); - fn index_error(&self, name: &str); - } - - impl FakeTransportExt for FakeTransport { - fn ok(&self, name: &str, versions_json: &str) { - self.push( - &versions_url(name), - ScriptedResponse::Http(200, versions_json.to_string()), - ); - } - - fn error(&self, name: &str) { - self.push(&versions_url(name), ScriptedResponse::Error); - } - - fn index_ok(&self, name: &str, records_ndjson: &str) { - self.push( - &index_url(name), - ScriptedResponse::Http(200, records_ndjson.to_string()), - ); - } - - fn index_error(&self, name: &str) { - self.push(&index_url(name), ScriptedResponse::Error); - } - } - - fn versions_body(entries: &[(&str, i64, bool)], now: DateTime) -> String { - let versions: Vec = entries - .iter() - .map(|(num, days_ago, yanked)| { - let created_at = now - chrono::Duration::days(*days_ago); - format!( - r#"{{"num":"{num}","created_at":"{}","yanked":{yanked}}}"#, - created_at.to_rfc3339() - ) - }) - .collect(); - format!(r#"{{"versions":[{}]}}"#, versions.join(",")) - } - - /// Builds a client with retry/pacing delays zeroed out, so the test - /// suite doesn't sleep. - fn fast_client(transport: FakeTransport) -> CratesIoClient { - CratesIoClient::with_transport( - transport, - None, - 24, - RetryPolicy { - retry_count: NonZeroU32::new(1).unwrap(), - retry_delay: Duration::from_millis(0), - pacing_delay: Duration::from_millis(0), - }, - ) - } - - fn too_new(package: &str, locked_version: &str) -> Violation { - Violation { - package: package.to_string(), - version: locked_version.to_string(), - kind: ViolationKind::TooNew(Aged { - published: now(), - age_days: 1, - }), - } - } - - fn too_old(package: &str) -> Violation { - Violation { - package: package.to_string(), - version: "1.0.0".to_string(), - kind: ViolationKind::TooOld(Aged { - published: now(), - age_days: 1000, - }), - } - } - - const CRATES_IO_SOURCE: &str = "registry+https://github.com/rust-lang/crates.io-index"; - - /// A registry package. Its dependency edges default to the same - /// crates.io source as the loader resolves an unsourced edge to, - /// when — as here — the only matching name is a registry package. - fn pkg(name: &str, version: &str, deps: &[(&str, &str)]) -> Package { - Package { - name: name.to_string(), - version: version.to_string(), - is_registry: true, - source: Some(CRATES_IO_SOURCE.to_string()), - dependencies: deps - .iter() - .map(|(n, v)| PackageRef { - name: n.to_string(), - version: v.to_string(), - source: Some(CRATES_IO_SOURCE.to_string()), - }) - .collect(), - } - } - - fn non_registry_pkg(name: &str, version: &str, deps: &[(&str, &str)]) -> Package { - Package { - is_registry: false, - source: None, - ..pkg(name, version, deps) - } - } - - /// A dependent whose lockfile source is present but isn't crates.io - /// — a git or alternate-registry package. Unlike `non_registry_pkg` - /// (a local/path package, `source: None`), nothing here can read its - /// requirements: not the crates.io index (it's not on crates.io), - /// and not a local manifest (it has no manifest this crate can find). - fn external_pkg(name: &str, version: &str, source: &str, deps: &[(&str, &str)]) -> Package { - Package { - is_registry: false, - source: Some(source.to_string()), - ..pkg(name, version, deps) - } - } - - const GIT_SOURCE: &str = - "git+https://github.com/example/app#0000000000000000000000000000000000000000"; - const ALT_REGISTRY_SOURCE: &str = "registry+https://example.com/priv-index"; - - /// A `DirectRequirement` naming `declaring_package`/`declaring_version` - /// as the identity of the manifest that placed it — the shape - /// `load_direct_requirements` produces for a real local manifest. - fn local_requirement( - declaring_package: &str, - declaring_version: &str, - crate_name: &str, - req: &str, - ) -> DirectRequirement { - DirectRequirement { - manifest: "/work/Cargo.toml".into(), - declaring_package: declaring_package.to_string(), - declaring_version: Some(declaring_version.to_string()), - crate_name: crate_name.to_string(), - req: VersionReq::parse(req).unwrap(), - source: RequirementSource::CratesIo, - } - } - - /// Builds a client and dependents index from `transport`/`packages` - /// and calls production `gather_constraints` with a fixed - /// `/work` working dir — the shared shape of most of this module's - /// `gather_constraints` call sites. - fn gather( - transport: FakeTransport, - packages: &[Package], - requirements: &[DirectRequirement], - name: &str, - locked_version: &str, - source: Option<&str>, - ) -> GatheredConstraints { - let mut client = fast_client(transport); - let index = build_dependents_index(packages); - gather_constraints( - &mut client, - &index, - requirements, - Path::new("/work"), - name, - locked_version, - source, - ) - } - - #[test] - fn aliased_registry_requirements_follow_the_locked_version() { - let transport = FakeTransport::default(); - transport.index_ok("app", r#"{"vers":"1.0.0","deps":[{"name":"foo_old","package":"foo","req":"^1"},{"name":"foo_new","package":"foo","req":"^2"}]}"#); - let mut client = fast_client(transport); - let packages = vec![pkg("app", "1.0.0", &[("foo", "1.5.0"), ("foo", "2.5.0")])]; - let index = build_dependents_index(&packages); - for (locked, candidate) in [("1.5.0", "1.4.0"), ("2.5.0", "2.4.0")] { - let gathered = gather_constraints( - &mut client, - &index, - &[], - Path::new("/work"), - "foo", - locked, - Some(CRATES_IO_SOURCE), - ); - assert_eq!(gathered.constraints.len(), 1); - assert!(gathered.unverified_dependents.is_empty()); - assert!(matches!( - walk( - vec![(Version::parse(candidate).unwrap(), 50)], - gathered.constraints - ), - WalkResult::Suggest(_, _) - )); - } - } - - #[test] - fn unmatched_registry_requirements_are_unverified() { - let transport = FakeTransport::default(); - transport.index_ok( - "app", - r#"{"vers":"1.0.0","deps":[{"name":"foo","req":"^1.5"}]}"#, - ); - let packages = vec![pkg("app", "1.0.0", &[("foo", "1.4.0")])]; - let gathered = gather( - transport, - &packages, - &[], - "foo", - "1.4.0", - Some(CRATES_IO_SOURCE), - ); - assert!(gathered.constraints.is_empty()); - assert_eq!(gathered.unverified_dependents, ["app"]); - } - - #[test] - fn missing_non_registry_requirements_are_unverified() { - let packages = vec![non_registry_pkg("git-app", "1.0.0", &[("foo", "1.5.0")])]; - let gathered = gather( - FakeTransport::default(), - &packages, - &[], - "foo", - "1.5.0", - Some(CRATES_IO_SOURCE), - ); - assert!(gathered.constraints.is_empty()); - assert_eq!(gathered.unverified_dependents, ["git-app"]); - } - - #[test] - fn same_named_git_dependents_report_one_unverified_label() { - let packages = vec![ - external_pkg("parent", "1.0.0", GIT_SOURCE, &[("foo", "1.9.0")]), - external_pkg("parent", "2.0.0", GIT_SOURCE, &[("foo", "1.9.0")]), - ]; - let gathered = gather( - FakeTransport::default(), - &packages, - &[], - "foo", - "1.9.0", - Some(CRATES_IO_SOURCE), - ); - assert_eq!(gathered.unverified_dependents, ["parent"]); - } - - #[test] - fn distinct_git_dependents_are_each_reported() { - let packages = vec![ - external_pkg("parent-a", "1.0.0", GIT_SOURCE, &[("foo", "1.9.0")]), - external_pkg("parent-b", "1.0.0", GIT_SOURCE, &[("foo", "1.9.0")]), - ]; - let gathered = gather( - FakeTransport::default(), - &packages, - &[], - "foo", - "1.9.0", - Some(CRATES_IO_SOURCE), - ); - assert_eq!(gathered.unverified_dependents, ["parent-a", "parent-b"]); - } - - #[test] - fn aliased_manifest_requirements_follow_the_locked_version() { - let mut client = fast_client(FakeTransport::default()); - let packages = vec![non_registry_pkg( - "app", - "1.0.0", - &[("foo", "1.5.0"), ("foo", "2.5.0")], - )]; - let requirements: Vec<_> = ["^1", "^2"] - .into_iter() - .map(|req| DirectRequirement { - manifest: "/work/Cargo.toml".into(), - declaring_package: "app".to_string(), - declaring_version: Some("1.0.0".to_string()), - crate_name: "foo".to_string(), - req: VersionReq::parse(req).unwrap(), - source: RequirementSource::CratesIo, - }) - .collect(); - let index = build_dependents_index(&packages); - for (locked, candidate) in [("1.5.0", "1.4.0"), ("2.5.0", "2.4.0")] { - let gathered = gather_constraints( - &mut client, - &index, - &requirements, - Path::new("/work"), - "foo", - locked, - Some(CRATES_IO_SOURCE), - ); - assert_eq!(gathered.constraints.len(), 1); - assert!(gathered.unverified_dependents.is_empty()); - assert!(matches!( - walk( - vec![(Version::parse(candidate).unwrap(), 50)], - gathered.constraints - ), - WalkResult::Suggest(_, _) - )); - } - } - - #[test] - fn matching_local_identity_with_a_permissive_requirement_allows_the_downgrade() { - // Positive control: a genuine local dependent, matched by both - // name and version, whose manifest requirement is loose enough - // to permit the downgrade — the ordinary case this whole path - // exists for. - let packages = vec![non_registry_pkg("app", "1.0.0", &[("foo", "1.5.0")])]; - let requirements = vec![local_requirement("app", "1.0.0", "foo", "^1")]; - - let gathered = gather( - FakeTransport::default(), - &packages, - &requirements, - "foo", - "1.5.0", - Some(CRATES_IO_SOURCE), - ); - assert_eq!(gathered.constraints.len(), 1); - assert!(gathered.unverified_dependents.is_empty()); - assert!(matches!( - walk( - vec![(Version::parse("1.4.0").unwrap(), 50)], - gathered.constraints - ), - WalkResult::Suggest(_, _) - )); - } - - #[test] - fn matching_local_identity_with_a_restrictive_requirement_blocks_the_downgrade() { - // Positive control, the other direction: the same identity - // match, but the requirement is restrictive enough to reject - // the downgrade candidate. - let packages = vec![non_registry_pkg("app", "1.0.0", &[("foo", "1.5.0")])]; - let requirements = vec![local_requirement("app", "1.0.0", "foo", "^1.5")]; - - let gathered = gather( - FakeTransport::default(), - &packages, - &requirements, - "foo", - "1.5.0", - Some(CRATES_IO_SOURCE), - ); - assert_eq!(gathered.constraints.len(), 1); - assert!(gathered.unverified_dependents.is_empty()); - assert!(matches!( - walk( - vec![(Version::parse("1.4.0").unwrap(), 50)], - gathered.constraints - ), - WalkResult::Blocked { .. } - )); - } - - #[test] - fn git_dependent_sharing_a_local_packages_name_and_version_stays_unverified() { - // A git "app" and a local "app" happen to share a name and - // version. The local one's manifest permits the downgrade, but - // that manifest was never the git dependent's own — it must not - // be credited with verifying it. - let packages = vec![ - external_pkg("app", "1.0.0", GIT_SOURCE, &[("foo", "1.5.0")]), - non_registry_pkg("app", "1.0.0", &[]), - ]; - let requirements = vec![local_requirement("app", "1.0.0", "foo", "^1")]; - - let gathered = gather( - FakeTransport::default(), - &packages, - &requirements, - "foo", - "1.5.0", - Some(CRATES_IO_SOURCE), - ); - assert!(gathered.constraints.is_empty()); - assert_eq!(gathered.unverified_dependents, ["app"]); - } - - #[test] - fn alternate_registry_dependent_sharing_a_local_packages_name_and_version_stays_unverified() - { - // Same shape as the git case, but the external dependent is on - // an alternate registry instead. - let packages = vec![ - external_pkg("app", "1.0.0", ALT_REGISTRY_SOURCE, &[("foo", "1.5.0")]), - non_registry_pkg("app", "1.0.0", &[]), - ]; - let requirements = vec![local_requirement("app", "1.0.0", "foo", "^1")]; - - let gathered = gather( - FakeTransport::default(), - &packages, - &requirements, - "foo", - "1.5.0", - Some(CRATES_IO_SOURCE), - ); - assert!(gathered.constraints.is_empty()); - assert_eq!(gathered.unverified_dependents, ["app"]); - } - - #[test] - fn restrictive_unrelated_local_requirement_does_not_block_the_external_dependents_target() { - // The local "app" shares the git dependent's name and version, - // but has no dependency edge of its own onto "foo" at all — its - // manifest requirement is unrelated noise. The requirement - // matches the locked version but would block candidate 1.4.0; - // even so, it must not block foo's downgrade for the git - // dependent, whose own requirement can't be read at all. - let packages = vec![ - external_pkg("app", "1.0.0", GIT_SOURCE, &[("foo", "1.5.0")]), - non_registry_pkg("app", "1.0.0", &[]), - ]; - let requirements = vec![local_requirement("app", "1.0.0", "foo", "^1.5")]; - - let gathered = gather( - FakeTransport::default(), - &packages, - &requirements, - "foo", - "1.5.0", - Some(CRATES_IO_SOURCE), - ); - assert!(gathered.constraints.is_empty()); - assert_eq!(gathered.unverified_dependents, ["app"]); - assert!(matches!( - walk( - vec![(Version::parse("1.4.0").unwrap(), 50)], - gathered.constraints - ), - WalkResult::Suggest(_, _) - )); - } - - #[test] - fn local_declaring_version_mismatch_neither_verifies_nor_constrains() { - // A local "app" 2.0.0 declares a restrictive requirement on - // "foo", but the actual lockfile dependent is a *different* - // "app" — 1.0.0 — with an identical name. Declaring package - // name alone must not be enough to apply this requirement. - let packages = vec![non_registry_pkg("app", "1.0.0", &[("foo", "1.5.0")])]; - let requirements = vec![local_requirement("app", "2.0.0", "foo", "^1.5")]; - - let gathered = gather( - FakeTransport::default(), - &packages, - &requirements, - "foo", - "1.5.0", - Some(CRATES_IO_SOURCE), - ); - assert!(gathered.constraints.is_empty()); - assert_eq!(gathered.unverified_dependents, ["app"]); - } - - #[test] - fn only_too_new_violations_are_fetched() { - let transport = FakeTransport::default(); - transport.ok("serde", &versions_body(&[("1.0.0", 50, false)], now())); - // "syn" has no scripted response: if it were fetched, the - // transport would panic. - let mut client = fast_client(transport); - - let violations = vec![too_new("serde", "1.0.0"), too_old("syn")]; - let packages = vec![pkg("serde", "1.0.0", &[])]; - let outcomes = - suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); - - assert_eq!(outcomes.len(), 1); - } - - #[test] - fn a_failed_fetch_does_not_abort_the_others() { - let transport = FakeTransport::default(); - transport.error("serde"); - transport.ok( - "syn", - &versions_body(&[("1.0.0", 50, false), ("1.1.0", 5, false)], now()), - ); - let mut client = fast_client(transport); - - let violations = vec![too_new("serde", "1.0.0"), too_new("syn", "1.1.0")]; - let packages = vec![pkg("serde", "1.0.0", &[]), pkg("syn", "1.1.0", &[])]; - let outcomes = - suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); - - assert_eq!(outcomes.len(), 1); - assert!(matches!(&outcomes[0], Outcome::Suggest { package, .. } if package == "syn")); - } - - #[test] - fn no_compliant_version_reports_the_no_candidate_outcome() { - let transport = FakeTransport::default(); - transport.ok("serde", &versions_body(&[("1.0.0", 5, false)], now())); - let mut client = fast_client(transport); - - let violations = vec![too_new("serde", "1.0.0")]; - let packages = vec![pkg("serde", "1.0.0", &[])]; - let outcomes = - suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); - - assert!(matches!(outcomes[0], Outcome::NoCompliantVersion { .. })); - } - - #[test] - fn no_too_new_violations_yields_none() { - let transport = FakeTransport::default(); - let mut client = fast_client(transport); - - let violations = vec![too_old("syn")]; - let outcomes = generate_suggestions( - &mut client, - &violations, - &[], - &[], - Path::new("/work"), - 30, - false, - now(), - ); - - assert!(outcomes.is_none()); - } - - #[test] - fn transitive_dependent_requirement_blocks_the_newest_candidate() { - // "app" depends on serde 1.5.0; serde's index says app requires ^1.5. - let transport = FakeTransport::default(); - transport.ok( - "serde", - &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), - ); - transport.index_ok( - "app", - r#"{"vers":"1.0.0","deps":[{"name":"serde","req":"^1.5"}]}"#, - ); - let mut client = fast_client(transport); - - let violations = vec![too_new("serde", "1.5.0")]; - let packages = vec![ - pkg("serde", "1.5.0", &[]), - pkg("app", "1.0.0", &[("serde", "1.5.0")]), - ]; - let outcomes = - suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); - - match &outcomes[0] { - Outcome::Blocked { - newest_compliant, - blocker, - .. - } => { - assert_eq!(newest_compliant, "1.4.0"); - assert_eq!(blocker.name, "app"); - assert_eq!(blocker.req, "^1.5"); - } - _ => panic!("expected Blocked"), - } - } - - #[test] - fn same_name_version_collision_across_sources_does_not_leak_dependents() { - // Two packages both named "serde" locked at 1.5.0: one from - // crates.io, one from git. "consumer" depends on the git one - // specifically. The crates.io serde must not inherit consumer's - // requirement just because the name and version happen to match. - let transport = FakeTransport::default(); - transport.ok( - "serde", - &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), - ); - // If "consumer" were (wrongly) treated as a dependent of the - // crates.io serde, this scripted index response would be - // fetched and its ^1.5 requirement would block the downgrade. - transport.index_ok( - "consumer", - r#"{"vers":"1.0.0","deps":[{"name":"serde","req":"^1.5"}]}"#, - ); - let mut client = fast_client(transport); - - let registry_source = "registry+https://github.com/rust-lang/crates.io-index"; - let git_source = - "git+https://github.com/example/serde#0000000000000000000000000000000000000000"; - - let packages = vec![ - Package { - name: "serde".to_string(), - version: "1.5.0".to_string(), - is_registry: true, - source: Some(registry_source.to_string()), - dependencies: vec![], - }, - Package { - name: "serde".to_string(), - version: "1.5.0".to_string(), - is_registry: false, - source: Some(git_source.to_string()), - dependencies: vec![], - }, - Package { - name: "consumer".to_string(), - version: "1.0.0".to_string(), - is_registry: true, - source: Some(registry_source.to_string()), - dependencies: vec![PackageRef { - name: "serde".to_string(), - version: "1.5.0".to_string(), - source: Some(git_source.to_string()), - }], - }, - ]; - - let violations = vec![too_new("serde", "1.5.0")]; - let outcomes = - suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); - - assert!( - matches!(&outcomes[0], Outcome::Suggest { suggested_version, .. } if suggested_version == "1.4.0"), - "the crates.io serde must not be blocked by consumer's requirement on the git serde: {:?}", - match &outcomes[0] { - Outcome::Blocked { blocker, .. } => format!("Blocked by {}", blocker.name), - _ => "other".to_string(), - } - ); - } - - #[test] - fn source_collision_yields_a_source_qualified_package_spec() { - // Same fixture as above: a crates.io "serde" and a git "serde" - // both locked at 1.5.0. Cargo would reject the abbreviated - // `serde@1.5.0` spec as ambiguous, so the suggestion for the - // registry package must qualify it with the registry source. - let transport = FakeTransport::default(); - transport.ok( - "serde", - &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), - ); - let mut client = fast_client(transport); - - let registry_source = "registry+https://github.com/rust-lang/crates.io-index"; - let git_source = - "git+https://github.com/example/serde#0000000000000000000000000000000000000000"; - - let packages = vec![ - Package { - name: "serde".to_string(), - version: "1.5.0".to_string(), - is_registry: true, - source: Some(registry_source.to_string()), - dependencies: vec![], - }, - Package { - name: "serde".to_string(), - version: "1.5.0".to_string(), - is_registry: false, - source: Some(git_source.to_string()), - dependencies: vec![], - }, - ]; - - let violations = vec![too_new("serde", "1.5.0")]; - let outcomes = - suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); - - match &outcomes[0] { - Outcome::Suggest { package_spec, .. } => { - assert_eq!(package_spec, &format!("{registry_source}#serde")); - } - _ => panic!("expected serde to be Suggest"), - } - } - - #[test] - fn no_collision_keeps_the_abbreviated_package_spec() { - let transport = FakeTransport::default(); - transport.ok( - "serde", - &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), - ); - let mut client = fast_client(transport); - - let violations = vec![too_new("serde", "1.5.0")]; - let packages = vec![pkg("serde", "1.5.0", &[])]; - let outcomes = - suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); - - match &outcomes[0] { - Outcome::Suggest { package_spec, .. } => assert_eq!(package_spec, "serde"), - _ => panic!("expected serde to be Suggest"), - } - } - - #[test] - fn path_package_sharing_a_name_and_version_does_not_leak_dependents_to_the_registry_package() - { - // A path package "local-crate" 0.1.0 and a crates.io package of - // the same name and version both exist. "consumer" depends on - // the path one via an edge that omits the source, as cargo does - // for any edge whose true target has none. Since a path - // package's own source is always omitted too, the crates.io - // package must not inherit consumer's requirement just because - // the name and version happen to collide. - let transport = FakeTransport::default(); - transport.ok( - "local-crate", - &versions_body(&[("1.1.0", 5, false), ("1.0.0", 50, false)], now()), - ); - // If "consumer" were (wrongly) treated as a dependent of the - // crates.io local-crate, this scripted index response would be - // fetched and its ^1.1 requirement would block the downgrade. - transport.index_ok( - "consumer", - r#"{"vers":"1.0.0","deps":[{"name":"local-crate","req":"^1.1"}]}"#, - ); - let mut client = fast_client(transport); - - let registry_source = "registry+https://github.com/rust-lang/crates.io-index"; - - let packages = vec![ - Package { - name: "local-crate".to_string(), - version: "1.1.0".to_string(), - is_registry: true, - source: Some(registry_source.to_string()), - dependencies: vec![], - }, - Package { - name: "local-crate".to_string(), - version: "1.1.0".to_string(), - is_registry: false, - source: None, - dependencies: vec![], - }, - Package { - name: "consumer".to_string(), - version: "1.0.0".to_string(), - is_registry: true, - source: Some(registry_source.to_string()), - dependencies: vec![PackageRef { - name: "local-crate".to_string(), - version: "1.1.0".to_string(), - source: None, - }], - }, - ]; - - let violations = vec![too_new("local-crate", "1.1.0")]; - let outcomes = - suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); - - assert!( - matches!(&outcomes[0], Outcome::Suggest { suggested_version, .. } if suggested_version == "1.0.0"), - "the crates.io local-crate must not be blocked by consumer's requirement on the path local-crate: {:?}", - match &outcomes[0] { - Outcome::Blocked { blocker, .. } => format!("Blocked by {}", blocker.name), - _ => "other".to_string(), - } - ); - } - - #[test] - fn dev_kind_edge_from_a_transitive_dependent_is_ignored() { - let transport = FakeTransport::default(); - transport.ok( - "serde", - &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), - ); - transport.index_ok( - "app", - r#"{"vers":"1.0.0","deps":[{"name":"serde","req":"^1.5","kind":"dev"}]}"#, - ); - let mut client = fast_client(transport); - - let violations = vec![too_new("serde", "1.5.0")]; - let packages = vec![ - pkg("serde", "1.5.0", &[]), - pkg("app", "1.0.0", &[("serde", "1.5.0")]), - ]; - let outcomes = - suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); - - assert!(matches!(outcomes[0], Outcome::Suggest { .. })); - } - - #[test] - fn renamed_dependency_is_matched_by_its_real_name() { - let transport = FakeTransport::default(); - transport.ok("serde", &versions_body(&[("1.4.0", 50, false)], now())); - transport.index_ok( - "app", - r#"{"vers":"1.0.0","deps":[{"name":"my_serde","package":"serde","req":"^1.5"}]}"#, - ); - let mut client = fast_client(transport); - - let violations = vec![too_new("serde", "1.5.0")]; - let packages = vec![ - pkg("serde", "1.5.0", &[]), - pkg("app", "1.0.0", &[("serde", "1.5.0")]), - ]; - let outcomes = - suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); - - assert!(matches!(outcomes[0], Outcome::Blocked { .. })); - } - - #[test] - fn ambiguous_optional_declaration_does_not_block_the_downgrade() { - // "app" declares both a normal `serde = "^1"` and a disabled, - // renamed optional `serde_new = { package = "serde", version = - // "^1.5", optional = true }`. Both match locked serde@1.5.0, but - // since the optional one may not even be activated, neither can - // be enforced as a definite blocker. - let transport = FakeTransport::default(); - transport.ok( - "serde", - &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), - ); - transport.index_ok( - "app", - r#"{"vers":"1.0.0","deps":[{"name":"serde","req":"^1"},{"name":"serde_new","package":"serde","req":"^1.5","optional":true}]}"#, - ); - let mut client = fast_client(transport); - - let violations = vec![too_new("serde", "1.5.0")]; - let packages = vec![ - pkg("serde", "1.5.0", &[]), - pkg("app", "1.0.0", &[("serde", "1.5.0")]), - ]; - let outcomes = - suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); - - match &outcomes[0] { - Outcome::Suggest { - suggested_version, - unverified_dependents, - .. - } => { - assert_eq!(suggested_version, "1.4.0"); - assert_eq!(unverified_dependents, &["app".to_string()]); - } - _ => panic!("expected serde to be Suggest, with app marked unverified"), - } - } - - #[test] - fn unique_optional_declaration_still_blocks() { - // Only the optional, renamed declaration matches — no ambiguity, - // so it's the unique explanation for the lockfile edge and must - // still be enforced. - let transport = FakeTransport::default(); - transport.ok( - "serde", - &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), - ); - transport.index_ok( - "app", - r#"{"vers":"1.0.0","deps":[{"name":"serde_new","package":"serde","req":"^1.5","optional":true}]}"#, - ); - let mut client = fast_client(transport); - - let violations = vec![too_new("serde", "1.5.0")]; - let packages = vec![ - pkg("serde", "1.5.0", &[]), - pkg("app", "1.0.0", &[("serde", "1.5.0")]), - ]; - let outcomes = - suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); - - match &outcomes[0] { - Outcome::Blocked { - newest_compliant, - blocker, - .. - } => { - assert_eq!(newest_compliant, "1.4.0"); - assert_eq!(blocker.name, "app"); - assert_eq!(blocker.req, "^1.5"); - } - _ => panic!("expected serde to be Blocked by app's unique optional declaration"), - } - } - - #[test] - fn mandatory_requirements_from_different_kinds_both_block() { - // "app" declares an unconditional, nonoptional normal - // requirement of `^1.5` and an unconditional, nonoptional build - // requirement of `^1` on serde. Both are always active, so both - // are enforced — the more restrictive one (`^1.5`) rejects the - // downgrade to 1.4.0. - let transport = FakeTransport::default(); - transport.ok( - "serde", - &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), - ); - transport.index_ok( - "app", - r#"{"vers":"1.0.0","deps":[{"name":"serde","req":"^1.5"},{"name":"serde","req":"^1","kind":"build"}]}"#, - ); - let mut client = fast_client(transport); - - let violations = vec![too_new("serde", "1.5.0")]; - let packages = vec![ - pkg("serde", "1.5.0", &[]), - pkg("app", "1.0.0", &[("serde", "1.5.0")]), - ]; - let outcomes = - suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); - - match &outcomes[0] { - Outcome::Blocked { - newest_compliant, - blocker, - .. - } => { - assert_eq!(newest_compliant, "1.4.0"); - assert_eq!(blocker.name, "app"); - assert_eq!(blocker.req, "^1.5"); - } - _ => panic!("expected serde to be Blocked by app's mandatory requirement"), - } - } - - #[test] - fn mandatory_requirement_still_blocks_alongside_uncertain_declaration() { - // "app" declares an unconditional, nonoptional normal - // requirement of `^1.5`, and a separate disabled, renamed - // optional declaration matching a looser `^1`. The mandatory - // requirement is enforced regardless of the uncertain one, even - // though the uncertain one alone would have permitted the - // downgrade. - let transport = FakeTransport::default(); - transport.ok( - "serde", - &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), - ); - transport.index_ok( - "app", - r#"{"vers":"1.0.0","deps":[{"name":"serde","req":"^1.5"},{"name":"serde_new","package":"serde","req":"^1","optional":true}]}"#, - ); - let mut client = fast_client(transport); - - let violations = vec![too_new("serde", "1.5.0")]; - let packages = vec![ - pkg("serde", "1.5.0", &[]), - pkg("app", "1.0.0", &[("serde", "1.5.0")]), - ]; - let outcomes = - suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); - - match &outcomes[0] { - Outcome::Blocked { - newest_compliant, - blocker, - .. - } => { - assert_eq!(newest_compliant, "1.4.0"); - assert_eq!(blocker.name, "app"); - assert_eq!(blocker.req, "^1.5"); - } - _ => panic!("expected serde to be Blocked by app's mandatory requirement"), - } - } - - #[test] - fn identical_requirement_repeated_across_targets_still_blocks() { - // "app" declares the same `serde = "^1.5"` requirement under two - // target-specific tables (e.g. cfg(unix) and cfg(windows)), which - // the index lists as two separate `deps` entries with identical - // `req` strings. This is not the same situation as two distinct - // declarations that might not both be active — the requirement - // is identical either way, so it must still be enforced as a - // definite blocker rather than merely "unverified". - let transport = FakeTransport::default(); - transport.ok( - "serde", - &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), - ); - transport.index_ok( - "app", - r#"{"vers":"1.0.0","deps":[{"name":"serde","req":"^1.5","target":"cfg(unix)"},{"name":"serde","req":"^1.5","target":"cfg(windows)"}]}"#, - ); - let mut client = fast_client(transport); - - let violations = vec![too_new("serde", "1.5.0")]; - let packages = vec![ - pkg("serde", "1.5.0", &[]), - pkg("app", "1.0.0", &[("serde", "1.5.0")]), - ]; - let outcomes = - suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); - - match &outcomes[0] { - Outcome::Blocked { - newest_compliant, - blocker, - .. - } => { - assert_eq!(newest_compliant, "1.4.0"); - assert_eq!(blocker.name, "app"); - assert_eq!(blocker.req, "^1.5"); - } - _ => panic!( - "expected serde to be Blocked by app's requirement, not merely unverified" - ), - } - } - - #[test] - fn requirement_attribution_acceptance_cases() { - // name, second version, requirements, unverified, blocks 1.7 - let cases: &[(&str, bool, &[&str], bool, bool)] = &[ - ("overlap", true, &["^1", ">=1.8,<3"], true, false), - ("disjoint", true, &["^1", "^2"], false, false), - ( - "identical aliases", - true, - &[">=1.8,<3", ">=1.8, <3"], - false, - true, - ), - ("single version", false, &["^1", ">=1.8,<2"], false, true), - ("optional", true, &["^1", ">=1.8,<3"], true, false), - ("target", true, &["^1", ">=1.8,<3"], true, false), - ("other blocker", true, &["^1", ">=1.8,<3"], true, true), - ("other parent", false, &["^1", ">=1.8,<3"], false, true), - ("other source", true, &["^1", ">=1.8,<3"], false, true), - ("path sibling", false, &["^1", ">=1.8,<3"], false, true), - ("duplicate edge", false, &["^1", ">=1.8,<3"], false, true), - ("unreadable", false, &[], true, false), - ]; - for registry in [true, false] { - for &(case, second_version, reqs, unverified, blocked) in cases { - if !registry && matches!(case, "optional" | "target") { - continue; - } - let transport = FakeTransport::default(); - let mut requirements = Vec::new(); - let mut app = if registry { - pkg("app", "1.0.0", &[("foo", "1.9.0")]) - } else { - non_registry_pkg("app", "1.0.0", &[("foo", "1.9.0")]) - }; - let source = pkg("foo", "1.9.0", &[]).source.unwrap(); - app.dependencies[0].source = Some(source.clone()); - let mut packages = vec![pkg("foo", "1.9.0", &[])]; - if second_version { - let mut sibling = pkg("foo", "2.0.0", &[]); - if case == "other source" { - sibling = external_pkg("foo", "2.0.0", ALT_REGISTRY_SOURCE, &[]); - } - app.dependencies.push(PackageRef { - name: "foo".into(), - version: sibling.version.clone(), - source: sibling.source.clone(), - }); - packages.push(sibling); - } - match case { - "path sibling" => { - packages.push(non_registry_pkg("foo", "1.9.0", &[])); - app.dependencies.push(PackageRef { - name: "foo".into(), - version: "1.9.0".into(), - source: None, - }); - } - "duplicate edge" => app.dependencies.push(PackageRef { - name: "foo".into(), - version: "1.9.0".into(), - source: Some(source.clone()), - }), - "other parent" => { - packages.push(pkg("foo", "2.0.0", &[])); - packages.push(pkg("other", "1.0.0", &[("foo", "2.0.0")])); - } - "other blocker" => { - if registry { - packages.push(pkg("other", "1.0.0", &[("foo", "1.9.0")])); - transport.index_ok( - "other", - r#"{"vers":"1.0.0","deps":[{"name":"foo","req":">=1.8"}]}"#, - ); - } else { - packages.push(non_registry_pkg( - "other", - "1.0.0", - &[("foo", "1.9.0")], - )); - requirements - .push(local_requirement("other", "1.0.0", "foo", ">=1.8")); - } - } - _ => {} - } - packages.push(app); - if registry && case != "unreadable" { - let deps: Vec<_> = reqs.iter().enumerate().map(|(i, req)| { - serde_json::json!({ - "name": format!("alias_{i}"), "package": "foo", "req": req, - "optional": case == "optional" && i == 0, - "target": if case == "target" && i == 0 { Some("cfg(unix)") } else { None }, - }) - }).collect(); - transport.index_ok( - "app", - &serde_json::json!({ - "vers": "1.0.0", "deps": deps, - }) - .to_string(), - ); - } else if registry { - transport.index_error("app"); - } else { - requirements.extend( - reqs.iter() - .map(|req| local_requirement("app", "1.0.0", "foo", req)), - ); - } - let mut client = fast_client(transport); - let index = build_dependents_index(&packages); - let gathered = gather_constraints( - &mut client, - &index, - &requirements, - Path::new("/work"), - "foo", - "1.9.0", - Some(&source), - ); - let expected_unverified = if unverified { vec!["app"] } else { vec![] }; - assert_eq!( - gathered.unverified_dependents, expected_unverified, - "{case}, registry={registry}" - ); - if case == "disjoint" { - assert!( - gathered - .constraints - .iter() - .any(|c| !c.req.matches(&v("0.9.0"))) - ); - } - let result = walk(vec![(v("1.7.0"), 80)], gathered.constraints); - assert_eq!( - matches!(result, WalkResult::Blocked { .. }), - blocked, - "{case}, registry={registry}" - ); - if case == "other blocker" { - let WalkResult::Blocked { blocker, .. } = result else { - unreachable!() - }; - assert_eq!( - blocker.blocker_name, - if registry { "other" } else { "Cargo.toml" } - ); - } else if !blocked { - assert!( - matches!(result, WalkResult::Suggest(version, 80) if version == v("1.7.0")) - ); - } - } - } - } - - #[test] - fn failed_index_fetch_yields_suggestion_with_unverified_annotation() { - let transport = FakeTransport::default(); - transport.ok( - "serde", - &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), - ); - transport.index_error("app"); - let mut client = fast_client(transport); - - let violations = vec![too_new("serde", "1.5.0")]; - let packages = vec![ - pkg("serde", "1.5.0", &[]), - pkg("app", "1.0.0", &[("serde", "1.5.0")]), - ]; - let outcomes = - suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); - - match &outcomes[0] { - Outcome::Suggest { - unverified_dependents, - .. - } => assert_eq!(unverified_dependents, &["app".to_string()]), - _ => panic!("expected Suggest"), - } - } - - #[test] - fn a_package_locked_at_two_versions_produces_two_outcomes() { - let transport = FakeTransport::default(); - transport.ok( - "serde", - &versions_body(&[("1.0.0", 50, false), ("2.0.0", 50, false)], now()), - ); - let mut client = fast_client(transport); - - let violations = vec![too_new("serde", "1.0.0"), too_new("serde", "2.0.0")]; - let packages = vec![pkg("serde", "1.0.0", &[]), pkg("serde", "2.0.0", &[])]; - let outcomes = - suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); - - assert_eq!(outcomes.len(), 2); - } - - #[test] - fn also_suggested_is_false_when_the_blocker_itself_has_no_suggestion() { - // "y" blocks "target", and "y" is itself in the too-new set — - // but y's own walk resolves to NoCompliantVersion, not Suggest, - // so the blocked message must not claim a fix for y exists. - let transport = FakeTransport::default(); - transport.ok( - "target", - &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), - ); - transport.ok("y", &versions_body(&[("1.5.0", 5, false)], now())); - transport.index_ok( - "y", - r#"{"vers":"1.5.0","deps":[{"name":"target","req":"^1.5"}]}"#, - ); - let mut client = fast_client(transport); - - let violations = vec![too_new("target", "1.5.0"), too_new("y", "1.5.0")]; - let packages = vec![ - pkg("target", "1.5.0", &[]), - pkg("y", "1.5.0", &[("target", "1.5.0")]), - ]; - let outcomes = - suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); - - match &outcomes[0] { - Outcome::Blocked { blocker, .. } => { - assert_eq!(blocker.name, "y"); - assert!( - !blocker.also_suggested, - "y has no Suggest outcome of its own" - ); - } - _ => panic!("expected target to be Blocked"), - } - assert!(matches!( - &outcomes[1], - Outcome::NoCompliantVersion { package, .. } if package == "y" - )); - } - - #[test] - fn also_suggested_is_false_when_only_another_version_of_the_blocker_is_suggested() { - // "foo" is locked at both 1.5.0 and 2.5.0. Only 1.5.0 resolves to - // a Suggest; the 2.5.0 that blocks "target" has no compliant - // version, so the blocked message must not point at the unrelated - // 1.5.0 downgrade. - let transport = FakeTransport::default(); - transport.ok( - "target", - &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), - ); - transport.ok( - "foo", - &versions_body( - &[ - ("1.5.0", 5, false), - ("1.4.0", 50, false), - ("2.5.0", 5, false), - ], - now(), - ), - ); - transport.index_ok( - "foo", - r#"{"vers":"2.5.0","deps":[{"name":"target","req":"^1.5"}]}"#, - ); - let mut client = fast_client(transport); - - let violations = vec![ - too_new("target", "1.5.0"), - too_new("foo", "1.5.0"), - too_new("foo", "2.5.0"), - ]; - let packages = vec![ - pkg("target", "1.5.0", &[]), - pkg("foo", "1.5.0", &[]), - pkg("foo", "2.5.0", &[("target", "1.5.0")]), - ]; - let outcomes = - suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); - - assert!( - matches!(&outcomes[1], Outcome::Suggest { package, locked_version, .. } - if package == "foo" && locked_version == "1.5.0"), - "foo 1.5.0 should be suggested, otherwise the test proves nothing" - ); - match &outcomes[0] { - Outcome::Blocked { blocker, .. } => { - assert_eq!(blocker.name, "foo"); - assert_eq!(blocker.version.as_deref(), Some("2.5.0")); - assert!( - !blocker.also_suggested, - "the suggestion is for foo 1.5.0, which does not unblock foo 2.5.0" - ); - } - _ => panic!("expected target to be Blocked"), - } - } - - #[test] - fn manifest_constraint_is_scoped_to_the_declaring_dependent() { - // member_a locks clap@2.5.0 and requires ^2; member_b locks a - // different clap version and requires ^3. member_b's unrelated - // requirement must not leak into member_a's constraint set. - let transport = FakeTransport::default(); - transport.ok( - "clap", - &versions_body(&[("2.5.0", 5, false), ("2.0.0", 50, false)], now()), - ); - let mut client = fast_client(transport); - - let direct_requirements = vec![ - crate::manifest::DirectRequirement { - manifest: PathBuf::from("/work/member_a/Cargo.toml"), - declaring_package: "member_a".to_string(), - declaring_version: Some("0.1.0".to_string()), - crate_name: "clap".to_string(), - req: VersionReq::parse("^2").unwrap(), - source: RequirementSource::CratesIo, - }, - crate::manifest::DirectRequirement { - manifest: PathBuf::from("/work/member_b/Cargo.toml"), - declaring_package: "member_b".to_string(), - declaring_version: Some("0.1.0".to_string()), - crate_name: "clap".to_string(), - req: VersionReq::parse("^3").unwrap(), - source: RequirementSource::CratesIo, - }, - ]; - let packages = vec![ - pkg("clap", "2.5.0", &[]), - non_registry_pkg("member_a", "0.1.0", &[("clap", "2.5.0")]), - non_registry_pkg("member_b", "0.1.0", &[("clap", "3.1.0")]), - ]; - - let violations = vec![too_new("clap", "2.5.0")]; - let outcomes = suggestions( - &mut client, - &violations, - &packages, - &direct_requirements, - Path::new("/work"), - ); - - match &outcomes[0] { - Outcome::Suggest { - suggested_version, .. - } => assert_eq!(suggested_version, "2.0.0"), - _ => panic!("expected clap to be Suggest: member_b's ^3 must not apply"), - } - } - } - - /// Drives the whole pipeline — lockfile intake, manifest reading, and - /// `generate_suggestions` — over the committed fixture at - /// `tests/fixtures/suggest_fix_e2e/`, a two-member workspace-ish layout - /// (a real workspace with one member) plus a hand-written lockfile. - /// Covers all four outcome kinds at once: a suggestion, a package - /// blocked by a manifest requirement, one blocked by a transitive - /// dependent, and one with nothing old enough in range. - mod end_to_end_tests { - use super::*; - use crate::api::RetryPolicy; - use crate::api::test_support::{FakeTransport, ScriptedResponse, index_url, versions_url}; - use crate::manifest::load_direct_requirements; - use crate::report::Aged; - use std::num::NonZeroU32; - use std::path::PathBuf; - use std::time::Duration; - - fn fixture_dir() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/suggest_fix_e2e") - } - - fn versions_body(entries: &[(&str, i64, bool)], now: DateTime) -> String { - let versions: Vec = entries - .iter() - .map(|(num, days_ago, yanked)| { - let created_at = now - chrono::Duration::days(*days_ago); - format!( - r#"{{"num":"{num}","created_at":"{}","yanked":{yanked}}}"#, - created_at.to_rfc3339() - ) - }) - .collect(); - format!(r#"{{"versions":[{}]}}"#, versions.join(",")) - } - - fn too_new(package: &str, locked_version: &str) -> Violation { - Violation { - package: package.to_string(), - version: locked_version.to_string(), - kind: ViolationKind::TooNew(Aged { - published: now() - chrono::Duration::days(5), - age_days: 5, - }), - } - } - - #[test] - fn covers_a_suggestion_two_blocked_kinds_and_no_compliant_version() { - let dir = fixture_dir(); - let packages = crate::lockfile::load(Path::new("Cargo.lock"), &dir).unwrap(); - let (direct_requirements, warnings) = load_direct_requirements(&dir); - assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); - - let transport = FakeTransport::default(); - transport.push( - &versions_url("alpha"), - ScriptedResponse::Http( - 200, - versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), - ), - ); - transport.push( - &versions_url("beta"), - ScriptedResponse::Http( - 200, - versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), - ), - ); - transport.push( - &versions_url("gamma"), - ScriptedResponse::Http( - 200, - versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), - ), - ); - transport.push( - &versions_url("delta"), - ScriptedResponse::Http(200, versions_body(&[("1.5.0", 5, false)], now())), - ); - transport.push( - &index_url("consumer"), - ScriptedResponse::Http( - 200, - r#"{"vers":"2.0.0","deps":[{"name":"gamma","req":"^1.5"}]}"#.to_string(), - ), - ); - - let mut client = CratesIoClient::with_transport( - transport, - None, - 24, - RetryPolicy { - retry_count: NonZeroU32::new(1).unwrap(), - retry_delay: Duration::from_millis(0), - pacing_delay: Duration::from_millis(0), - }, - ); - - let violations = vec![ - too_new("alpha", "1.5.0"), - too_new("beta", "1.5.0"), - too_new("gamma", "1.5.0"), - too_new("delta", "1.5.0"), - ]; - - let outcomes = suggestions( - &mut client, - &violations, - &packages, - &direct_requirements, - &dir, - ); - - assert_eq!(outcomes.len(), 4); - - match &outcomes[0] { - Outcome::Suggest { - package, - suggested_version, - suggested_age_days, - unverified_dependents, - .. - } => { - assert_eq!(package, "alpha"); - assert_eq!(suggested_version, "1.4.0"); - assert_eq!(*suggested_age_days, 50); - assert!(unverified_dependents.is_empty()); - } - _ => panic!("expected alpha to be Suggest"), - } - - match &outcomes[1] { - Outcome::Blocked { - package, - newest_compliant, - blocker, - .. - } => { - assert_eq!(package, "beta"); - assert_eq!(newest_compliant, "1.4.0"); - assert_eq!(blocker.name, "app/Cargo.toml"); - assert_eq!(blocker.version, None); - assert_eq!(blocker.req, "^1.5"); - } - _ => panic!("expected beta to be Blocked"), - } - - match &outcomes[2] { - Outcome::Blocked { - package, - newest_compliant, - blocker, - .. - } => { - assert_eq!(package, "gamma"); - assert_eq!(newest_compliant, "1.4.0"); - assert_eq!(blocker.name, "consumer"); - assert_eq!(blocker.version, Some("2.0.0".to_string())); - assert_eq!(blocker.req, "^1.5"); - } - _ => panic!("expected gamma to be Blocked"), - } - - assert!(matches!( - &outcomes[3], - Outcome::NoCompliantVersion { package, .. } if package == "delta" - )); - } - } - - /// Builds a real Cargo project with a source collision — a crates.io - /// package and a path package sharing a name and locked version — and - /// runs a real `cargo` against the spec `build_package_spec` produces, - /// to verify it's the source-qualified pkgid Cargo itself expects, - /// rather than merely a string this crate assumes is valid. - mod source_collision_cargo_tests { - use super::*; - use sha2::{Digest, Sha256}; - use std::process::Command; - - const CRATE_NAME: &str = "semver"; - const CRATE_VERSION: &str = "1.0.28"; - - /// Writes a minimal crate (`Cargo.toml` + `src/lib.rs`) at `dir`. - fn write_crate_source(dir: &Path, name: &str, version: &str) { - std::fs::create_dir_all(dir.join("src")).unwrap(); - std::fs::write( - dir.join("Cargo.toml"), - format!( - "[package]\nname = \"{name}\"\nversion = \"{version}\"\nedition = \"2021\"\n" - ), - ) - .unwrap(); - std::fs::write(dir.join("src/lib.rs"), "").unwrap(); - } - - /// Packs `crate_dir` (already containing a `{name}-{version}` - /// top-level directory) into a `.crate` tarball, Cargo's own - /// publish format. - fn pack_crate_tarball(crate_dir: &Path, name: &str, version: &str) -> Vec { - let mut bytes = Vec::new(); - { - let encoder = - flate2::write::GzEncoder::new(&mut bytes, flate2::Compression::default()); - let mut builder = tar::Builder::new(encoder); - builder - .append_dir_all(format!("{name}-{version}"), crate_dir) - .unwrap(); - builder.finish().unwrap(); - } - bytes - } - - /// Assembles a local-registry source (see Cargo's source-replacement - /// docs) at `registry_dir`, containing one crate. Local-registry - /// index entries are sharded by name length: a 4+ character name - /// shards under its first two, then next two, characters. - fn write_local_registry(registry_dir: &Path, name: &str, version: &str) { - let build_dir = registry_dir - .join(".build") - .join(format!("{name}-{version}")); - write_crate_source(&build_dir, name, version); - let tarball = pack_crate_tarball(&build_dir, name, version); - - std::fs::write( - registry_dir.join(format!("{name}-{version}.crate")), - &tarball, - ) - .unwrap(); - - let cksum = Sha256::digest(&tarball) - .iter() - .map(|b| format!("{b:02x}")) - .collect::(); - let shard = registry_dir - .join("index") - .join(&name[0..2]) - .join(&name[2..4]); - std::fs::create_dir_all(&shard).unwrap(); - std::fs::write( - shard.join(name), - format!( - r#"{{"name":"{name}","vers":"{version}","deps":[],"cksum":"{cksum}","features":{{}},"yanked":false}}"# - ), - ) - .unwrap(); - } - - fn run_cargo(args: &[&str], cwd: &Path) -> std::process::Output { - Command::new("cargo") - .args(args) - .current_dir(cwd) - .output() - .expect("failed to run cargo") - } - - #[test] - fn qualified_spec_resolves_where_the_abbreviated_spec_is_ambiguous() { - let root = tempfile::tempdir().unwrap(); - let registry_dir = root.path().join("registry"); - let workspace_dir = root.path().join("workspace"); - - write_local_registry(®istry_dir, CRATE_NAME, CRATE_VERSION); - write_crate_source( - &workspace_dir.join("vendor-semver"), - CRATE_NAME, - CRATE_VERSION, - ); - - std::fs::create_dir_all(workspace_dir.join(".cargo")).unwrap(); - std::fs::write( - workspace_dir.join(".cargo/config.toml"), - format!( - "[source.local-vendor]\nlocal-registry = \"{}\"\n\n[source.crates-io]\nreplace-with = \"local-vendor\"\n", - registry_dir.display() - ), - ) - .unwrap(); - std::fs::create_dir_all(workspace_dir.join("src")).unwrap(); - std::fs::write(workspace_dir.join("src/main.rs"), "fn main() {}\n").unwrap(); - std::fs::write( - workspace_dir.join("Cargo.toml"), - format!( - "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n{CRATE_NAME} = \"{CRATE_VERSION}\"\n{CRATE_NAME}-path = {{ package = \"{CRATE_NAME}\", path = \"vendor-semver\" }}\n" - ), - ) - .unwrap(); - - let lock = run_cargo(&["generate-lockfile", "--offline"], &workspace_dir); - assert!( - lock.status.success(), - "generate-lockfile failed: {}", - String::from_utf8_lossy(&lock.stderr) - ); - - let packages = crate::lockfile::load(Path::new("Cargo.lock"), &workspace_dir).unwrap(); - let target_source = packages - .iter() - .find(|p| p.name == CRATE_NAME && p.is_registry) - .and_then(|p| p.source.as_deref()); - let is_ambiguous = packages - .iter() - .filter(|p| p.name == CRATE_NAME && p.version == CRATE_VERSION) - .count() - > 1; - let spec = build_package_spec(CRATE_NAME, target_source, is_ambiguous); - assert!( - spec.contains('#'), - "expected a source-qualified spec for a name/version collision, got {spec}" - ); - - // The abbreviated spec really is ambiguous in this fixture — - // otherwise the qualified spec above proves nothing. - let abbreviated = run_cargo( - &[ - "update", - "--offline", - "-p", - &format!("{CRATE_NAME}@{CRATE_VERSION}"), - "--precise", - CRATE_VERSION, - ], - &workspace_dir, - ); - assert!( - !abbreviated.status.success() - && String::from_utf8_lossy(&abbreviated.stderr).contains("ambiguous"), - "expected the abbreviated spec to be ambiguous in this fixture: {}", - String::from_utf8_lossy(&abbreviated.stderr) - ); - - let qualified = run_cargo( - &[ - "update", - "--offline", - "-p", - &format!("{spec}@{CRATE_VERSION}"), - "--precise", - CRATE_VERSION, - ], - &workspace_dir, - ); - assert!( - qualified.status.success(), - "expected the source-qualified spec to resolve without an ambiguous-specification error: {}", - String::from_utf8_lossy(&qualified.stderr) - ); - } - } - - /// Covers a manifest crate name declared against two different - /// registries at once: an ordinary crates.io requirement and one - /// explicitly pinned to a renamed private registry. The renamed - /// declaration must never be treated as if it constrained the crates.io - /// package this flow actually suggests a downgrade for, nor may it - /// silently mark the declaring dependent as unverified when the - /// crates.io declaration alone already verifies it. - mod manifest_registry_identity_tests { - use super::*; - use crate::api::RetryPolicy; - use crate::api::test_support::{FakeTransport, ScriptedResponse, versions_url}; - use crate::manifest::load_direct_requirements; - use crate::report::Aged; - use std::num::NonZeroU32; - use std::time::Duration; - use tempfile::tempdir; - - const CRATES_IO_SOURCE: &str = "registry+https://github.com/rust-lang/crates.io-index"; - const PRIVATE_SOURCE: &str = "registry+https://example.com/priv-index"; - - fn versions_body(entries: &[(&str, i64, bool)]) -> String { - let versions: Vec = entries - .iter() - .map(|(num, days_ago, yanked)| { - let created_at = now() - chrono::Duration::days(*days_ago); - format!( - r#"{{"num":"{num}","created_at":"{}","yanked":{yanked}}}"#, - created_at.to_rfc3339() - ) - }) - .collect(); - format!(r#"{{"versions":[{}]}}"#, versions.join(",")) - } - - fn fast_client(transport: FakeTransport) -> CratesIoClient { - CratesIoClient::with_transport( - transport, - None, - 24, - RetryPolicy { - retry_count: NonZeroU32::new(1).unwrap(), - retry_delay: Duration::from_millis(0), - pacing_delay: Duration::from_millis(0), - }, - ) - } - - fn too_new(package: &str, locked_version: &str) -> Violation { - Violation { - package: package.to_string(), - version: locked_version.to_string(), - kind: ViolationKind::TooNew(Aged { - published: now(), - age_days: 1, - }), - } - } - - fn write_manifest(dir: &Path, contents: &str) { - std::fs::write(dir.join("Cargo.toml"), contents).unwrap(); - } - - /// A "foo" package locked at 1.9.0 on each of `CRATES_IO_SOURCE` and - /// `PRIVATE_SOURCE`, both depended on by workspace member "app" via - /// source-qualified lockfile dependency edges — the shape a real - /// lockfile resolves to when a same-name/same-version package exists - /// on more than one registry. - fn packages_with_dual_source_foo() -> Vec { - vec![ - Package { - name: "foo".to_string(), - version: "1.9.0".to_string(), - is_registry: true, - source: Some(CRATES_IO_SOURCE.to_string()), - dependencies: vec![], - }, - Package { - name: "foo".to_string(), - version: "1.9.0".to_string(), - is_registry: false, - source: Some(PRIVATE_SOURCE.to_string()), - dependencies: vec![], - }, - Package { - name: "app".to_string(), - version: "0.1.0".to_string(), - is_registry: false, - source: None, - dependencies: vec![ - PackageRef { - name: "foo".to_string(), - version: "1.9.0".to_string(), - source: Some(CRATES_IO_SOURCE.to_string()), - }, - PackageRef { - name: "foo".to_string(), - version: "1.9.0".to_string(), - source: Some(PRIVATE_SOURCE.to_string()), - }, - ], - }, - ] - } - - #[test] - fn crates_io_declaration_is_enforced_while_the_renamed_registry_declaration_is_excluded() { - // "app" depends on crates.io foo ^1.0 and a renamed - // private-registry foo pinned to =1.9.0; both resolve to foo - // 1.9.0 in the lockfile. Only the crates.io declaration should - // count toward foo's suggestion: it doesn't block 1.8.0, so foo - // should suggest it, and the =1.9.0 renamed declaration must not - // spuriously block a package it was never written for. - let dir = tempdir().unwrap(); - write_manifest( - dir.path(), - r#" -[package] -name = "app" -version = "0.1.0" - -[dependencies] -foo = "^1.0" -foo_priv = { package = "foo", version = "=1.9.0", registry = "priv" } -"#, - ); - let (direct_requirements, warnings) = load_direct_requirements(dir.path()); - assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); - - let transport = FakeTransport::default(); - transport.push( - &versions_url("foo"), - ScriptedResponse::Http( - 200, - versions_body(&[("1.9.0", 5, false), ("1.8.0", 50, false)]), - ), - ); - let mut client = fast_client(transport); - - let violations = vec![too_new("foo", "1.9.0")]; - let packages = packages_with_dual_source_foo(); - let outcomes = suggestions( - &mut client, - &violations, - &packages, - &direct_requirements, - dir.path(), - ); - - match &outcomes[0] { - Outcome::Suggest { - package_spec, - suggested_version, - unverified_dependents, - .. - } => { - assert_eq!(suggested_version, "1.8.0"); - assert_eq!(package_spec, &format!("{CRATES_IO_SOURCE}#foo")); - assert!( - unverified_dependents.is_empty(), - "app is verified by its crates.io ^1.0 declaration: {unverified_dependents:?}" - ); - } - Outcome::Blocked { blocker, .. } => panic!( - "expected foo to be Suggest, but was Blocked by {} \ - (the renamed-registry declaration must have leaked in)", - blocker.name - ), - _ => panic!("expected foo to be Suggest"), - } - } - - #[test] - fn reverse_roles_still_block_via_the_crates_io_declaration() { - // Same fixture, roles swapped: crates.io foo is now pinned to - // =1.9.0 and the renamed private-registry foo carries the - // lenient ^1.0. The crates.io pin must still block the - // downgrade, reported as the blocker. - let dir = tempdir().unwrap(); - write_manifest( - dir.path(), - r#" -[package] -name = "app" -version = "0.1.0" - -[dependencies] -foo = "=1.9.0" -foo_priv = { package = "foo", version = "^1.0", registry = "priv" } -"#, - ); - let (direct_requirements, warnings) = load_direct_requirements(dir.path()); - assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); - - let transport = FakeTransport::default(); - transport.push( - &versions_url("foo"), - ScriptedResponse::Http( - 200, - versions_body(&[("1.9.0", 5, false), ("1.8.0", 50, false)]), - ), - ); - let mut client = fast_client(transport); - - let violations = vec![too_new("foo", "1.9.0")]; - let packages = packages_with_dual_source_foo(); - let outcomes = suggestions( - &mut client, - &violations, - &packages, - &direct_requirements, - dir.path(), - ); - - match &outcomes[0] { - Outcome::Blocked { - newest_compliant, - blocker, - .. - } => { - assert_eq!(newest_compliant, "1.8.0"); - assert_eq!(blocker.name, "Cargo.toml"); - assert_eq!(blocker.version, None); - assert_eq!(blocker.req, "=1.9.0"); - } - _ => panic!("expected foo to be Blocked by the crates.io declaration"), - } - } - - #[test] - fn explicit_crates_io_registry_name_still_blocks() { - // Same fixture as `reverse_roles_still_block_via_the_crates_io_declaration`, - // but the crates.io declaration names its registry explicitly - // via the reserved `crates-io` alias instead of omitting - // `registry` altogether. It must still be recognized as - // crates.io and enforced, not excluded as an unrecognized - // alternate registry. - let dir = tempdir().unwrap(); - write_manifest( - dir.path(), - r#" -[package] -name = "app" -version = "0.1.0" - -[dependencies] -foo = { version = "=1.9.0", registry = "crates-io" } -foo_priv = { package = "foo", version = "^1.0", registry = "priv" } -"#, - ); - let (direct_requirements, warnings) = load_direct_requirements(dir.path()); - assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); - - let transport = FakeTransport::default(); - transport.push( - &versions_url("foo"), - ScriptedResponse::Http( - 200, - versions_body(&[("1.9.0", 5, false), ("1.8.0", 50, false)]), - ), - ); - let mut client = fast_client(transport); - - let violations = vec![too_new("foo", "1.9.0")]; - let packages = packages_with_dual_source_foo(); - let outcomes = suggestions( - &mut client, - &violations, - &packages, - &direct_requirements, - dir.path(), - ); - - match &outcomes[0] { - Outcome::Blocked { - newest_compliant, - blocker, - .. - } => { - assert_eq!(newest_compliant, "1.8.0"); - assert_eq!(blocker.name, "Cargo.toml"); - assert_eq!(blocker.version, None); - assert_eq!(blocker.req, "=1.9.0"); - } - _ => panic!( - "expected foo to be Blocked by the explicit crates-io declaration, \ - not excluded as an unrecognized alternate registry" - ), - } - } - - #[test] - fn a_second_crates_io_declaration_for_the_same_identity_still_blocks() { - // Both declarations are ordinary crates.io dependencies — no - // registry collision at all — one lenient, one restrictive. - // Guards against the crates.io source filter collapsing - // enforcement down to a single matching declaration. - let dir = tempdir().unwrap(); - write_manifest( - dir.path(), - r#" -[package] -name = "app" -version = "0.1.0" - -[dependencies] -foo = "^1.0" -foo_pinned = { package = "foo", version = "=1.9.0" } -"#, - ); - let (direct_requirements, warnings) = load_direct_requirements(dir.path()); - assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); - assert_eq!(direct_requirements.len(), 2); - assert!( - direct_requirements - .iter() - .all(|r| r.source == RequirementSource::CratesIo) - ); - - let transport = FakeTransport::default(); - transport.push( - &versions_url("foo"), - ScriptedResponse::Http( - 200, - versions_body(&[("1.9.0", 5, false), ("1.8.0", 50, false)]), - ), - ); - let mut client = fast_client(transport); - - let violations = vec![too_new("foo", "1.9.0")]; - let packages = vec![ - Package { - name: "foo".to_string(), - version: "1.9.0".to_string(), - is_registry: true, - source: Some(CRATES_IO_SOURCE.to_string()), - dependencies: vec![], - }, - Package { - name: "app".to_string(), - version: "0.1.0".to_string(), - is_registry: false, - source: None, - dependencies: vec![PackageRef { - name: "foo".to_string(), - version: "1.9.0".to_string(), - source: Some(CRATES_IO_SOURCE.to_string()), - }], - }, - ]; - let outcomes = suggestions( - &mut client, - &violations, - &packages, - &direct_requirements, - dir.path(), - ); - - match &outcomes[0] { - Outcome::Blocked { - newest_compliant, - blocker, - .. - } => { - assert_eq!(newest_compliant, "1.8.0"); - assert_eq!(blocker.req, "=1.9.0"); - } - _ => panic!( - "expected foo to be Blocked by the restrictive =1.9.0 declaration \ - alongside the lenient ^1.0 one" - ), - } - } - - /// "helper" is a path dependency of root "app", not a workspace - /// member. Cargo ignores the dev-dependencies of a non-member path - /// dependency, so helper's `[dev-dependencies] foo = "=1.9.0"` must - /// not block a downgrade that only helper's own `[dependencies] foo - /// = "1"` would allow. - #[test] - fn dev_dependency_of_a_non_member_path_dependency_does_not_block() { - let dir = tempdir().unwrap(); - write_manifest( - dir.path(), - r#" -[package] -name = "app" -version = "0.1.0" - -[dependencies] -helper = { path = "helper" } -"#, - ); - std::fs::create_dir_all(dir.path().join("helper")).unwrap(); - write_manifest( - &dir.path().join("helper"), - r#" -[package] -name = "helper" -version = "0.1.0" - -[dependencies] -foo = "1" - -[dev-dependencies] -foo = "=1.9.0" -"#, - ); - let (direct_requirements, warnings) = load_direct_requirements(dir.path()); - assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); - - let transport = FakeTransport::default(); - transport.push( - &versions_url("foo"), - ScriptedResponse::Http( - 200, - versions_body(&[("1.9.0", 5, false), ("1.8.0", 50, false)]), - ), - ); - let mut client = fast_client(transport); - - let violations = vec![too_new("foo", "1.9.0")]; - let packages = vec![ - Package { - name: "foo".to_string(), - version: "1.9.0".to_string(), - is_registry: true, - source: Some(CRATES_IO_SOURCE.to_string()), - dependencies: vec![], - }, - Package { - name: "helper".to_string(), - version: "0.1.0".to_string(), - is_registry: false, - source: None, - dependencies: vec![PackageRef { - name: "foo".to_string(), - version: "1.9.0".to_string(), - source: Some(CRATES_IO_SOURCE.to_string()), - }], - }, - Package { - name: "app".to_string(), - version: "0.1.0".to_string(), - is_registry: false, - source: None, - dependencies: vec![PackageRef { - name: "helper".to_string(), - version: "0.1.0".to_string(), - source: None, - }], - }, - ]; - let outcomes = suggestions( - &mut client, - &violations, - &packages, - &direct_requirements, - dir.path(), - ); - - match &outcomes[0] { - Outcome::Suggest { - suggested_version, .. - } => { - assert_eq!(suggested_version, "1.8.0"); - } - Outcome::Blocked { blocker, .. } => panic!( - "expected foo to be Suggest, but was Blocked by {} \ - (helper's dev-dependency must be ignored: it isn't a workspace member)", - blocker.name - ), - _ => panic!("expected foo to be Suggest"), - } - } - } - - /// Covers matching a real local dependent's manifest requirement against - /// its own locked identity (name and version), including workspace - /// version inheritance, rather than name alone. - mod manifest_dependent_identity_tests { - use super::*; - use crate::api::RetryPolicy; - use crate::api::test_support::{FakeTransport, ScriptedResponse, versions_url}; - use crate::manifest::load_direct_requirements; - use crate::report::Aged; - use std::num::NonZeroU32; - use std::time::Duration; - use tempfile::tempdir; - - const CRATES_IO_SOURCE: &str = "registry+https://github.com/rust-lang/crates.io-index"; - const GIT_SOURCE: &str = - "git+https://github.com/example/vendor#0000000000000000000000000000000000000000"; - const ALT_REGISTRY_SOURCE: &str = "registry+https://example.com/priv-index"; - - fn versions_body(entries: &[(&str, i64, bool)]) -> String { - let versions: Vec = entries - .iter() - .map(|(num, days_ago, yanked)| { - let created_at = now() - chrono::Duration::days(*days_ago); - format!( - r#"{{"num":"{num}","created_at":"{}","yanked":{yanked}}}"#, - created_at.to_rfc3339() - ) - }) - .collect(); - format!(r#"{{"versions":[{}]}}"#, versions.join(",")) - } - - fn fast_client(transport: FakeTransport) -> CratesIoClient { - CratesIoClient::with_transport( - transport, - None, - 24, - RetryPolicy { - retry_count: NonZeroU32::new(1).unwrap(), - retry_delay: Duration::from_millis(0), - pacing_delay: Duration::from_millis(0), - }, - ) - } - - fn too_new(package: &str, locked_version: &str) -> Violation { - Violation { - package: package.to_string(), - version: locked_version.to_string(), - kind: ViolationKind::TooNew(Aged { - published: now() - chrono::Duration::days(5), - age_days: 5, - }), - } - } - - fn write_manifest(dir: &Path, rel: &str, contents: &str) { - let path = dir.join(rel); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).unwrap(); - } - std::fs::write(&path, contents).unwrap(); - } - - #[test] - fn workspace_inherited_declaring_version_matches_the_locked_local_dependent() { - // "app"'s own version is inherited from the workspace root - // rather than written directly. Its requirement on foo must - // still be recognized as its own — matched by the resolved - // version, not skipped for lack of one. - let dir = tempdir().unwrap(); - write_manifest( - dir.path(), - "Cargo.toml", - r#" -[workspace] -members = ["app"] - -[workspace.package] -version = "0.1.0" -"#, - ); - write_manifest( - dir.path(), - "app/Cargo.toml", - r#" -[package] -name = "app" -version.workspace = true - -[dependencies] -foo = "^1.0" -"#, - ); - let (direct_requirements, warnings) = load_direct_requirements(dir.path()); - assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); - - let transport = FakeTransport::default(); - transport.push( - &versions_url("foo"), - ScriptedResponse::Http( - 200, - versions_body(&[("1.9.0", 5, false), ("1.8.0", 50, false)]), - ), - ); - let mut client = fast_client(transport); - - let violations = vec![too_new("foo", "1.9.0")]; - let packages = vec![ - Package { - name: "foo".to_string(), - version: "1.9.0".to_string(), - is_registry: true, - source: Some(CRATES_IO_SOURCE.to_string()), - dependencies: vec![], - }, - Package { - name: "app".to_string(), - version: "0.1.0".to_string(), - is_registry: false, - source: None, - dependencies: vec![PackageRef { - name: "foo".to_string(), - version: "1.9.0".to_string(), - source: Some(CRATES_IO_SOURCE.to_string()), - }], - }, - ]; - let outcomes = suggestions( - &mut client, - &violations, - &packages, - &direct_requirements, - dir.path(), - ); - - match &outcomes[0] { - Outcome::Suggest { - suggested_version, - unverified_dependents, - .. - } => { - assert_eq!(suggested_version, "1.8.0"); - assert!( - unverified_dependents.is_empty(), - "app's workspace-inherited version should have matched: {unverified_dependents:?}" - ); - } - _ => panic!("expected foo to be Suggest"), - } - } - - #[test] - fn unresolvable_declaring_version_does_not_falsely_verify_the_dependent() { - // "app" inherits its version from the workspace, but the - // workspace root supplies none — the member's manifest fails to - // load entirely, so no requirement is ever collected for it. - // "app" must come back unverified, not silently treated as - // matching by name alone. - let dir = tempdir().unwrap(); - write_manifest( - dir.path(), - "Cargo.toml", - r#" -[workspace] -members = ["app"] -"#, - ); - write_manifest( - dir.path(), - "app/Cargo.toml", - r#" -[package] -name = "app" -version.workspace = true - -[dependencies] -foo = "^1.0" -"#, - ); - let (direct_requirements, warnings) = load_direct_requirements(dir.path()); - assert!( - !warnings.is_empty(), - "expected a warning about app's unresolved workspace version" - ); - - let transport = FakeTransport::default(); - transport.push( - &versions_url("foo"), - ScriptedResponse::Http( - 200, - versions_body(&[("1.9.0", 5, false), ("1.8.0", 50, false)]), - ), - ); - let mut client = fast_client(transport); - - let violations = vec![too_new("foo", "1.9.0")]; - let packages = vec![ - Package { - name: "foo".to_string(), - version: "1.9.0".to_string(), - is_registry: true, - source: Some(CRATES_IO_SOURCE.to_string()), - dependencies: vec![], - }, - Package { - name: "app".to_string(), - version: "0.1.0".to_string(), - is_registry: false, - source: None, - dependencies: vec![PackageRef { - name: "foo".to_string(), - version: "1.9.0".to_string(), - source: Some(CRATES_IO_SOURCE.to_string()), - }], - }, - ]; - let outcomes = suggestions( - &mut client, - &violations, - &packages, - &direct_requirements, - dir.path(), - ); - - match &outcomes[0] { - Outcome::Suggest { - unverified_dependents, - .. - } => assert_eq!(unverified_dependents, &["app".to_string()]), - _ => panic!("expected foo to be Suggest, with app marked unverified"), - } - } - - #[test] - fn real_local_constraint_is_enforced_while_an_external_dependent_stays_unverified() { - // "app" is a real local dependent whose manifest permits the - // downgrade; "vendor" is a git dependent that also locks foo at - // the same version, but nothing here can read its requirement. - // 1.8.0 is the newer, otherwise-preferred candidate, but app's - // manifest rejects it, so enforcement must fall back to 1.8.5 - // while still flagging vendor as unverified. - let dir = tempdir().unwrap(); - write_manifest( - dir.path(), - "Cargo.toml", - r#" -[package] -name = "app" -version = "0.1.0" - -[dependencies] -foo = "^1.8.5" -"#, - ); - let (direct_requirements, warnings) = load_direct_requirements(dir.path()); - assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); - - let transport = FakeTransport::default(); - transport.push( - &versions_url("foo"), - ScriptedResponse::Http( - 200, - versions_body(&[ - ("1.9.0", 5, false), - ("1.8.0", 40, false), - ("1.8.5", 50, false), - ]), - ), - ); - let mut client = fast_client(transport); - - let violations = vec![too_new("foo", "1.9.0")]; - let packages = vec![ - Package { - name: "foo".to_string(), - version: "1.9.0".to_string(), - is_registry: true, - source: Some(CRATES_IO_SOURCE.to_string()), - dependencies: vec![], - }, - Package { - name: "app".to_string(), - version: "0.1.0".to_string(), - is_registry: false, - source: None, - dependencies: vec![PackageRef { - name: "foo".to_string(), - version: "1.9.0".to_string(), - source: Some(CRATES_IO_SOURCE.to_string()), - }], - }, - Package { - name: "vendor".to_string(), - version: "1.0.0".to_string(), - is_registry: false, - source: Some(GIT_SOURCE.to_string()), - dependencies: vec![PackageRef { - name: "foo".to_string(), - version: "1.9.0".to_string(), - source: Some(CRATES_IO_SOURCE.to_string()), - }], - }, - ]; - let outcomes = suggestions( - &mut client, - &violations, - &packages, - &direct_requirements, - dir.path(), - ); - - match &outcomes[0] { - Outcome::Suggest { - suggested_version, - unverified_dependents, - .. - } => { - assert_eq!(suggested_version, "1.8.5"); - assert_eq!(unverified_dependents, &["vendor".to_string()]); - } - _ => panic!("expected foo to be Suggest, with vendor marked unverified"), - } - } - - #[test] - fn git_dependent_sharing_a_local_packages_identity_is_not_verified_by_its_manifest() { - // A git "app" and the local "app" share name and version; the - // local manifest permits the downgrade but was never the git - // dependent's own, so it must not verify it. - let dir = tempdir().unwrap(); - write_manifest( - dir.path(), - "Cargo.toml", - r#" -[package] -name = "app" -version = "1.0.0" - -[dependencies] -foo = "^1.0" -"#, - ); - let (direct_requirements, warnings) = load_direct_requirements(dir.path()); - assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); - - let transport = FakeTransport::default(); - transport.push( - &versions_url("foo"), - ScriptedResponse::Http( - 200, - versions_body(&[("1.9.0", 5, false), ("1.8.0", 50, false)]), - ), - ); - let mut client = fast_client(transport); - - let violations = vec![too_new("foo", "1.9.0")]; - let packages = vec![ - Package { - name: "foo".to_string(), - version: "1.9.0".to_string(), - is_registry: true, - source: Some(CRATES_IO_SOURCE.to_string()), - dependencies: vec![], - }, - Package { - name: "app".to_string(), - version: "1.0.0".to_string(), - is_registry: false, - source: Some(GIT_SOURCE.to_string()), - dependencies: vec![PackageRef { - name: "foo".to_string(), - version: "1.9.0".to_string(), - source: Some(CRATES_IO_SOURCE.to_string()), - }], - }, - Package { - name: "app".to_string(), - version: "1.0.0".to_string(), - is_registry: false, - source: None, - dependencies: vec![], - }, - ]; - let outcomes = suggestions( - &mut client, - &violations, - &packages, - &direct_requirements, - dir.path(), - ); - - match &outcomes[0] { - Outcome::Suggest { - suggested_version, - unverified_dependents, - .. - } => { - assert_eq!(suggested_version, "1.8.0"); - assert_eq!(unverified_dependents, &["app".to_string()]); - } - _ => panic!("expected foo to be Suggest, with the git app marked unverified"), - } - } - - #[test] - fn alternate_registry_dependent_sharing_a_local_packages_identity_is_not_verified_by_its_manifest() - { - // An alternate-registry "app" and the local "app" share name and - // version; the local manifest permits the downgrade but was - // never the alternate-registry dependent's own, so it must not - // verify it. - let dir = tempdir().unwrap(); - write_manifest( - dir.path(), - "Cargo.toml", - r#" -[package] -name = "app" -version = "1.0.0" - -[dependencies] -foo = "^1.0" -"#, - ); - let (direct_requirements, warnings) = load_direct_requirements(dir.path()); - assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); - - let transport = FakeTransport::default(); - transport.push( - &versions_url("foo"), - ScriptedResponse::Http( - 200, - versions_body(&[("1.9.0", 5, false), ("1.8.0", 50, false)]), - ), - ); - let mut client = fast_client(transport); - - let violations = vec![too_new("foo", "1.9.0")]; - let packages = vec![ - Package { - name: "foo".to_string(), - version: "1.9.0".to_string(), - is_registry: true, - source: Some(CRATES_IO_SOURCE.to_string()), - dependencies: vec![], - }, - Package { - name: "app".to_string(), - version: "1.0.0".to_string(), - is_registry: false, - source: Some(ALT_REGISTRY_SOURCE.to_string()), - dependencies: vec![PackageRef { - name: "foo".to_string(), - version: "1.9.0".to_string(), - source: Some(CRATES_IO_SOURCE.to_string()), - }], - }, - Package { - name: "app".to_string(), - version: "1.0.0".to_string(), - is_registry: false, - source: None, - dependencies: vec![], - }, - ]; - let outcomes = suggestions( - &mut client, - &violations, - &packages, - &direct_requirements, - dir.path(), - ); - - match &outcomes[0] { - Outcome::Suggest { - suggested_version, - unverified_dependents, - .. - } => { - assert_eq!(suggested_version, "1.8.0"); - assert_eq!(unverified_dependents, &["app".to_string()]); - } - _ => panic!( - "expected foo to be Suggest, with the alternate-registry app marked unverified" - ), - } - } - } -} +mod tests; diff --git a/src/suggest/tests.rs b/src/suggest/tests.rs new file mode 100644 index 0000000..61963bb --- /dev/null +++ b/src/suggest/tests.rs @@ -0,0 +1,59 @@ +use super::*; +use crate::lockfile::PackageRef; +use chrono::TimeZone; + +fn now() -> DateTime { + Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap() +} + +/// Calls production `generate_suggestions` with the fixed 30-day +/// minimum age, no prerelease admission, and `now()` that almost every +/// call site in this module shares, unwrapping the `Some` result. +fn suggestions( + client: &mut CratesIoClient, + violations: &[Violation], + packages: &[Package], + direct_requirements: &[DirectRequirement], + working_dir: &Path, +) -> Vec { + generate_suggestions( + client, + violations, + packages, + direct_requirements, + working_dir, + 30, + false, + now(), + ) + .unwrap() +} + +fn v(s: &str) -> Version { + Version::parse(s).unwrap() +} + +fn make_version(version: &str, days_ago: i64, yanked: bool) -> CrateVersionInfo { + let created_at = now() - chrono::Duration::days(days_ago); + CrateVersionInfo { + num: version.to_string(), + created_at, + yanked, + } +} + +fn constraint(req: &str) -> Constraint { + Constraint { + blocker_name: "dep".to_string(), + blocker_version: Some("1.0.0".to_string()), + req: VersionReq::parse(req).unwrap(), + } +} + +mod end_to_end_tests; +mod filter_candidates_tests; +mod generate_suggestions_tests; +mod manifest_dependent_identity_tests; +mod manifest_registry_identity_tests; +mod source_collision_cargo_tests; +mod walk_tests; diff --git a/src/suggest/tests/end_to_end_tests.rs b/src/suggest/tests/end_to_end_tests.rs new file mode 100644 index 0000000..61bcc12 --- /dev/null +++ b/src/suggest/tests/end_to_end_tests.rs @@ -0,0 +1,167 @@ +/// Drives the whole pipeline — lockfile intake, manifest reading, and +/// `generate_suggestions` — over the committed fixture at +/// `tests/fixtures/suggest_fix_e2e/`, a two-member workspace-ish layout +/// (a real workspace with one member) plus a hand-written lockfile. +/// Covers all four outcome kinds at once: a suggestion, a package +/// blocked by a manifest requirement, one blocked by a transitive +/// dependent, and one with nothing old enough in range. +use super::*; +use crate::api::RetryPolicy; +use crate::api::test_support::{FakeTransport, ScriptedResponse, index_url, versions_url}; +use crate::manifest::load_direct_requirements; +use crate::report::Aged; +use std::num::NonZeroU32; +use std::path::PathBuf; +use std::time::Duration; + +fn fixture_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/suggest_fix_e2e") +} + +fn versions_body(entries: &[(&str, i64, bool)], now: DateTime) -> String { + let versions: Vec = entries + .iter() + .map(|(num, days_ago, yanked)| { + let created_at = now - chrono::Duration::days(*days_ago); + format!( + r#"{{"num":"{num}","created_at":"{}","yanked":{yanked}}}"#, + created_at.to_rfc3339() + ) + }) + .collect(); + format!(r#"{{"versions":[{}]}}"#, versions.join(",")) +} + +fn too_new(package: &str, locked_version: &str) -> Violation { + Violation { + package: package.to_string(), + version: locked_version.to_string(), + kind: ViolationKind::TooNew(Aged { + published: now() - chrono::Duration::days(5), + age_days: 5, + }), + } +} + +#[test] +fn covers_a_suggestion_two_blocked_kinds_and_no_compliant_version() { + let dir = fixture_dir(); + let packages = crate::lockfile::load(Path::new("Cargo.lock"), &dir).unwrap(); + let (direct_requirements, warnings) = load_direct_requirements(&dir); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + let transport = FakeTransport::default(); + transport.push( + &versions_url("alpha"), + ScriptedResponse::Http( + 200, + versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), + ), + ); + transport.push( + &versions_url("beta"), + ScriptedResponse::Http( + 200, + versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), + ), + ); + transport.push( + &versions_url("gamma"), + ScriptedResponse::Http( + 200, + versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), + ), + ); + transport.push( + &versions_url("delta"), + ScriptedResponse::Http(200, versions_body(&[("1.5.0", 5, false)], now())), + ); + transport.push( + &index_url("consumer"), + ScriptedResponse::Http( + 200, + r#"{"vers":"2.0.0","deps":[{"name":"gamma","req":"^1.5"}]}"#.to_string(), + ), + ); + + let mut client = CratesIoClient::with_transport( + transport, + None, + 24, + RetryPolicy { + retry_count: NonZeroU32::new(1).unwrap(), + retry_delay: Duration::from_millis(0), + pacing_delay: Duration::from_millis(0), + }, + ); + + let violations = vec![ + too_new("alpha", "1.5.0"), + too_new("beta", "1.5.0"), + too_new("gamma", "1.5.0"), + too_new("delta", "1.5.0"), + ]; + + let outcomes = suggestions( + &mut client, + &violations, + &packages, + &direct_requirements, + &dir, + ); + + assert_eq!(outcomes.len(), 4); + + match &outcomes[0] { + Outcome::Suggest { + package, + suggested_version, + suggested_age_days, + unverified_dependents, + .. + } => { + assert_eq!(package, "alpha"); + assert_eq!(suggested_version, "1.4.0"); + assert_eq!(*suggested_age_days, 50); + assert!(unverified_dependents.is_empty()); + } + _ => panic!("expected alpha to be Suggest"), + } + + match &outcomes[1] { + Outcome::Blocked { + package, + newest_compliant, + blocker, + .. + } => { + assert_eq!(package, "beta"); + assert_eq!(newest_compliant, "1.4.0"); + assert_eq!(blocker.name, "app/Cargo.toml"); + assert_eq!(blocker.version, None); + assert_eq!(blocker.req, "^1.5"); + } + _ => panic!("expected beta to be Blocked"), + } + + match &outcomes[2] { + Outcome::Blocked { + package, + newest_compliant, + blocker, + .. + } => { + assert_eq!(package, "gamma"); + assert_eq!(newest_compliant, "1.4.0"); + assert_eq!(blocker.name, "consumer"); + assert_eq!(blocker.version, Some("2.0.0".to_string())); + assert_eq!(blocker.req, "^1.5"); + } + _ => panic!("expected gamma to be Blocked"), + } + + assert!(matches!( + &outcomes[3], + Outcome::NoCompliantVersion { package, .. } if package == "delta" + )); +} diff --git a/src/suggest/tests/filter_candidates_tests.rs b/src/suggest/tests/filter_candidates_tests.rs new file mode 100644 index 0000000..895f6ef --- /dev/null +++ b/src/suggest/tests/filter_candidates_tests.rs @@ -0,0 +1,107 @@ +use super::*; + +#[test] +fn excludes_too_new_yanked_and_out_of_range() { + let versions = vec![ + make_version("1.0.0", 100, false), + make_version("1.1.0", 50, true), // yanked + make_version("1.2.0", 40, false), // compliant, same major as locked + make_version("0.9.0", 200, false), // different major: out of range + make_version("1.3.0", 5, false), // too new (min age 30) + ]; + + let result = filter_candidates(&versions, &v("1.5.0"), 30, now(), false); + let nums: Vec = result.iter().map(|(ver, _)| ver.to_string()).collect(); + // Newest-first by publish date among the two survivors. + assert_eq!(nums, vec!["1.2.0".to_string(), "1.0.0".to_string()]); +} + +#[test] +fn sorted_newest_first_by_publish_date() { + let versions = vec![ + make_version("1.0.0", 100, false), + make_version("1.1.0", 200, false), + make_version("1.2.0", 50, false), + ]; + + let result = filter_candidates(&versions, &v("1.5.0"), 30, now(), false); + let nums: Vec = result.iter().map(|(ver, _)| ver.to_string()).collect(); + assert_eq!(nums, vec!["1.2.0", "1.0.0", "1.1.0"]); +} + +#[test] +fn prerelease_excluded_by_default() { + let versions = vec![make_version("1.1.0-beta.1", 100, false)]; + let result = filter_candidates(&versions, &v("1.0.0"), 30, now(), false); + assert!(result.is_empty()); +} + +#[test] +fn prerelease_included_with_flag_when_range_matches() { + // Same compatible zone (1.0.0), prerelease allowed by the flag. + let versions = vec![make_version("1.0.0-beta.1", 100, false)]; + let result = filter_candidates(&versions, &v("1.0.0-beta.2"), 30, now(), true); + assert_eq!(result.len(), 1); + assert_eq!(result[0].0.to_string(), "1.0.0-beta.1"); +} + +#[test] +fn prerelease_allowed_when_locked_is_itself_a_prerelease() { + let versions = vec![make_version("1.0.0-beta.1", 100, false)]; + let result = filter_candidates(&versions, &v("1.0.0-beta.2"), 30, now(), false); + assert_eq!(result.len(), 1); +} + +#[test] +fn excludes_a_higher_version_published_earlier_than_locked() { + // "1.4.0" was published before "1.3.0" but is a higher semantic + // version, so it must never be offered as a downgrade even + // though it's older on the publish timeline. + let versions = vec![ + make_version("1.4.0", 100, false), + make_version("1.3.0", 50, false), + ]; + let result = filter_candidates(&versions, &v("1.3.0"), 30, now(), false); + let nums: Vec = result.iter().map(|(ver, _)| ver.to_string()).collect(); + assert!(nums.is_empty(), "expected no candidates, got {nums:?}"); +} + +#[test] +fn excludes_a_version_equal_in_precedence_including_build_metadata_only_differences() { + let versions = vec![ + make_version("1.3.0", 100, false), + make_version("1.3.0+build.1", 100, false), + ]; + let result = filter_candidates(&versions, &v("1.3.0"), 30, now(), false); + assert!(result.is_empty()); +} + +#[test] +fn excludes_versions_equal_in_precedence_to_a_locked_version_with_build_metadata() { + // Locked itself carries build metadata this time: candidates + // differing only in build metadata (or lacking it) still have + // equal semantic precedence and must not be offered. + let versions = vec![ + make_version("1.3.0", 100, false), + make_version("1.3.0+build.1", 100, false), + ]; + let result = filter_candidates(&versions, &v("1.3.0+build.2"), 30, now(), false); + assert!(result.is_empty()); +} + +#[test] +fn excludes_stable_release_above_a_locked_prerelease() { + // A stable release outranks any prerelease of the same + // major.minor.patch, so it must not be offered as a "downgrade" + // from a locked prerelease. + let versions = vec![make_version("1.0.0", 100, false)]; + let result = filter_candidates(&versions, &v("1.0.0-beta.1"), 30, now(), false); + assert!(result.is_empty()); +} + +#[test] +fn excludes_a_later_prerelease_above_a_locked_prerelease() { + let versions = vec![make_version("1.0.0-beta.2", 100, false)]; + let result = filter_candidates(&versions, &v("1.0.0-beta.1"), 30, now(), false); + assert!(result.is_empty()); +} diff --git a/src/suggest/tests/generate_suggestions_tests.rs b/src/suggest/tests/generate_suggestions_tests.rs new file mode 100644 index 0000000..4ace0bd --- /dev/null +++ b/src/suggest/tests/generate_suggestions_tests.rs @@ -0,0 +1,1356 @@ +use super::*; +use crate::api::RetryPolicy; +use crate::api::test_support::{FakeTransport, ScriptedResponse, index_url, versions_url}; +use crate::lockfile::PackageRef; +use crate::report::Aged; +use std::num::NonZeroU32; +use std::path::PathBuf; +use std::time::Duration; + +trait FakeTransportExt { + fn ok(&self, name: &str, versions_json: &str); + fn error(&self, name: &str); + fn index_ok(&self, name: &str, records_ndjson: &str); + fn index_error(&self, name: &str); +} + +impl FakeTransportExt for FakeTransport { + fn ok(&self, name: &str, versions_json: &str) { + self.push( + &versions_url(name), + ScriptedResponse::Http(200, versions_json.to_string()), + ); + } + + fn error(&self, name: &str) { + self.push(&versions_url(name), ScriptedResponse::Error); + } + + fn index_ok(&self, name: &str, records_ndjson: &str) { + self.push( + &index_url(name), + ScriptedResponse::Http(200, records_ndjson.to_string()), + ); + } + + fn index_error(&self, name: &str) { + self.push(&index_url(name), ScriptedResponse::Error); + } +} + +fn versions_body(entries: &[(&str, i64, bool)], now: DateTime) -> String { + let versions: Vec = entries + .iter() + .map(|(num, days_ago, yanked)| { + let created_at = now - chrono::Duration::days(*days_ago); + format!( + r#"{{"num":"{num}","created_at":"{}","yanked":{yanked}}}"#, + created_at.to_rfc3339() + ) + }) + .collect(); + format!(r#"{{"versions":[{}]}}"#, versions.join(",")) +} + +/// Builds a client with retry/pacing delays zeroed out, so the test +/// suite doesn't sleep. +fn fast_client(transport: FakeTransport) -> CratesIoClient { + CratesIoClient::with_transport( + transport, + None, + 24, + RetryPolicy { + retry_count: NonZeroU32::new(1).unwrap(), + retry_delay: Duration::from_millis(0), + pacing_delay: Duration::from_millis(0), + }, + ) +} + +fn too_new(package: &str, locked_version: &str) -> Violation { + Violation { + package: package.to_string(), + version: locked_version.to_string(), + kind: ViolationKind::TooNew(Aged { + published: now(), + age_days: 1, + }), + } +} + +fn too_old(package: &str) -> Violation { + Violation { + package: package.to_string(), + version: "1.0.0".to_string(), + kind: ViolationKind::TooOld(Aged { + published: now(), + age_days: 1000, + }), + } +} + +const CRATES_IO_SOURCE: &str = "registry+https://github.com/rust-lang/crates.io-index"; + +/// A registry package. Its dependency edges default to the same +/// crates.io source as the loader resolves an unsourced edge to, +/// when — as here — the only matching name is a registry package. +fn pkg(name: &str, version: &str, deps: &[(&str, &str)]) -> Package { + Package { + name: name.to_string(), + version: version.to_string(), + is_registry: true, + source: Some(CRATES_IO_SOURCE.to_string()), + dependencies: deps + .iter() + .map(|(n, v)| PackageRef { + name: n.to_string(), + version: v.to_string(), + source: Some(CRATES_IO_SOURCE.to_string()), + }) + .collect(), + } +} + +fn non_registry_pkg(name: &str, version: &str, deps: &[(&str, &str)]) -> Package { + Package { + is_registry: false, + source: None, + ..pkg(name, version, deps) + } +} + +/// A dependent whose lockfile source is present but isn't crates.io +/// — a git or alternate-registry package. Unlike `non_registry_pkg` +/// (a local/path package, `source: None`), nothing here can read its +/// requirements: not the crates.io index (it's not on crates.io), +/// and not a local manifest (it has no manifest this crate can find). +fn external_pkg(name: &str, version: &str, source: &str, deps: &[(&str, &str)]) -> Package { + Package { + is_registry: false, + source: Some(source.to_string()), + ..pkg(name, version, deps) + } +} + +const GIT_SOURCE: &str = + "git+https://github.com/example/app#0000000000000000000000000000000000000000"; +const ALT_REGISTRY_SOURCE: &str = "registry+https://example.com/priv-index"; + +/// A `DirectRequirement` naming `declaring_package`/`declaring_version` +/// as the identity of the manifest that placed it — the shape +/// `load_direct_requirements` produces for a real local manifest. +fn local_requirement( + declaring_package: &str, + declaring_version: &str, + crate_name: &str, + req: &str, +) -> DirectRequirement { + DirectRequirement { + manifest: "/work/Cargo.toml".into(), + declaring_package: declaring_package.to_string(), + declaring_version: Some(declaring_version.to_string()), + crate_name: crate_name.to_string(), + req: VersionReq::parse(req).unwrap(), + source: RequirementSource::CratesIo, + } +} + +/// Builds a client and dependents index from `transport`/`packages` +/// and calls production `gather_constraints` with a fixed +/// `/work` working dir — the shared shape of most of this module's +/// `gather_constraints` call sites. +fn gather( + transport: FakeTransport, + packages: &[Package], + requirements: &[DirectRequirement], + name: &str, + locked_version: &str, + source: Option<&str>, +) -> GatheredConstraints { + let mut client = fast_client(transport); + let index = build_indexes(packages).0; + gather_constraints( + &mut client, + &index, + requirements, + Path::new("/work"), + name, + locked_version, + source, + ) +} + +#[test] +fn aliased_registry_requirements_follow_the_locked_version() { + let transport = FakeTransport::default(); + transport.index_ok("app", r#"{"vers":"1.0.0","deps":[{"name":"foo_old","package":"foo","req":"^1"},{"name":"foo_new","package":"foo","req":"^2"}]}"#); + let mut client = fast_client(transport); + let packages = vec![pkg("app", "1.0.0", &[("foo", "1.5.0"), ("foo", "2.5.0")])]; + let index = build_indexes(&packages).0; + for (locked, candidate) in [("1.5.0", "1.4.0"), ("2.5.0", "2.4.0")] { + let gathered = gather_constraints( + &mut client, + &index, + &[], + Path::new("/work"), + "foo", + locked, + Some(CRATES_IO_SOURCE), + ); + assert_eq!(gathered.constraints.len(), 1); + assert!(gathered.unverified_dependents.is_empty()); + assert!(matches!( + walk( + vec![(Version::parse(candidate).unwrap(), 50)], + gathered.constraints + ), + WalkResult::Suggest(_, _) + )); + } +} + +#[test] +fn unmatched_registry_requirements_are_unverified() { + let transport = FakeTransport::default(); + transport.index_ok( + "app", + r#"{"vers":"1.0.0","deps":[{"name":"foo","req":"^1.5"}]}"#, + ); + let packages = vec![pkg("app", "1.0.0", &[("foo", "1.4.0")])]; + let gathered = gather( + transport, + &packages, + &[], + "foo", + "1.4.0", + Some(CRATES_IO_SOURCE), + ); + assert!(gathered.constraints.is_empty()); + assert_eq!(gathered.unverified_dependents, ["app"]); +} + +#[test] +fn missing_non_registry_requirements_are_unverified() { + let packages = vec![non_registry_pkg("git-app", "1.0.0", &[("foo", "1.5.0")])]; + let gathered = gather( + FakeTransport::default(), + &packages, + &[], + "foo", + "1.5.0", + Some(CRATES_IO_SOURCE), + ); + assert!(gathered.constraints.is_empty()); + assert_eq!(gathered.unverified_dependents, ["git-app"]); +} + +#[test] +fn same_named_git_dependents_report_one_unverified_label() { + let packages = vec![ + external_pkg("parent", "1.0.0", GIT_SOURCE, &[("foo", "1.9.0")]), + external_pkg("parent", "2.0.0", GIT_SOURCE, &[("foo", "1.9.0")]), + ]; + let gathered = gather( + FakeTransport::default(), + &packages, + &[], + "foo", + "1.9.0", + Some(CRATES_IO_SOURCE), + ); + assert_eq!(gathered.unverified_dependents, ["parent"]); +} + +#[test] +fn distinct_git_dependents_are_each_reported() { + let packages = vec![ + external_pkg("parent-a", "1.0.0", GIT_SOURCE, &[("foo", "1.9.0")]), + external_pkg("parent-b", "1.0.0", GIT_SOURCE, &[("foo", "1.9.0")]), + ]; + let gathered = gather( + FakeTransport::default(), + &packages, + &[], + "foo", + "1.9.0", + Some(CRATES_IO_SOURCE), + ); + assert_eq!(gathered.unverified_dependents, ["parent-a", "parent-b"]); +} + +#[test] +fn aliased_manifest_requirements_follow_the_locked_version() { + let mut client = fast_client(FakeTransport::default()); + let packages = vec![non_registry_pkg( + "app", + "1.0.0", + &[("foo", "1.5.0"), ("foo", "2.5.0")], + )]; + let requirements: Vec<_> = ["^1", "^2"] + .into_iter() + .map(|req| DirectRequirement { + manifest: "/work/Cargo.toml".into(), + declaring_package: "app".to_string(), + declaring_version: Some("1.0.0".to_string()), + crate_name: "foo".to_string(), + req: VersionReq::parse(req).unwrap(), + source: RequirementSource::CratesIo, + }) + .collect(); + let index = build_indexes(&packages).0; + for (locked, candidate) in [("1.5.0", "1.4.0"), ("2.5.0", "2.4.0")] { + let gathered = gather_constraints( + &mut client, + &index, + &requirements, + Path::new("/work"), + "foo", + locked, + Some(CRATES_IO_SOURCE), + ); + assert_eq!(gathered.constraints.len(), 1); + assert!(gathered.unverified_dependents.is_empty()); + assert!(matches!( + walk( + vec![(Version::parse(candidate).unwrap(), 50)], + gathered.constraints + ), + WalkResult::Suggest(_, _) + )); + } +} + +#[test] +fn matching_local_identity_with_a_permissive_requirement_allows_the_downgrade() { + // Positive control: a genuine local dependent, matched by both + // name and version, whose manifest requirement is loose enough + // to permit the downgrade — the ordinary case this whole path + // exists for. + let packages = vec![non_registry_pkg("app", "1.0.0", &[("foo", "1.5.0")])]; + let requirements = vec![local_requirement("app", "1.0.0", "foo", "^1")]; + + let gathered = gather( + FakeTransport::default(), + &packages, + &requirements, + "foo", + "1.5.0", + Some(CRATES_IO_SOURCE), + ); + assert_eq!(gathered.constraints.len(), 1); + assert!(gathered.unverified_dependents.is_empty()); + assert!(matches!( + walk( + vec![(Version::parse("1.4.0").unwrap(), 50)], + gathered.constraints + ), + WalkResult::Suggest(_, _) + )); +} + +#[test] +fn matching_local_identity_with_a_restrictive_requirement_blocks_the_downgrade() { + // Positive control, the other direction: the same identity + // match, but the requirement is restrictive enough to reject + // the downgrade candidate. + let packages = vec![non_registry_pkg("app", "1.0.0", &[("foo", "1.5.0")])]; + let requirements = vec![local_requirement("app", "1.0.0", "foo", "^1.5")]; + + let gathered = gather( + FakeTransport::default(), + &packages, + &requirements, + "foo", + "1.5.0", + Some(CRATES_IO_SOURCE), + ); + assert_eq!(gathered.constraints.len(), 1); + assert!(gathered.unverified_dependents.is_empty()); + assert!(matches!( + walk( + vec![(Version::parse("1.4.0").unwrap(), 50)], + gathered.constraints + ), + WalkResult::Blocked { .. } + )); +} + +#[test] +fn git_dependent_sharing_a_local_packages_name_and_version_stays_unverified() { + // A git "app" and a local "app" happen to share a name and + // version. The local one's manifest permits the downgrade, but + // that manifest was never the git dependent's own — it must not + // be credited with verifying it. + let packages = vec![ + external_pkg("app", "1.0.0", GIT_SOURCE, &[("foo", "1.5.0")]), + non_registry_pkg("app", "1.0.0", &[]), + ]; + let requirements = vec![local_requirement("app", "1.0.0", "foo", "^1")]; + + let gathered = gather( + FakeTransport::default(), + &packages, + &requirements, + "foo", + "1.5.0", + Some(CRATES_IO_SOURCE), + ); + assert!(gathered.constraints.is_empty()); + assert_eq!(gathered.unverified_dependents, ["app"]); +} + +#[test] +fn alternate_registry_dependent_sharing_a_local_packages_name_and_version_stays_unverified() { + // Same shape as the git case, but the external dependent is on + // an alternate registry instead. + let packages = vec![ + external_pkg("app", "1.0.0", ALT_REGISTRY_SOURCE, &[("foo", "1.5.0")]), + non_registry_pkg("app", "1.0.0", &[]), + ]; + let requirements = vec![local_requirement("app", "1.0.0", "foo", "^1")]; + + let gathered = gather( + FakeTransport::default(), + &packages, + &requirements, + "foo", + "1.5.0", + Some(CRATES_IO_SOURCE), + ); + assert!(gathered.constraints.is_empty()); + assert_eq!(gathered.unverified_dependents, ["app"]); +} + +#[test] +fn restrictive_unrelated_local_requirement_does_not_block_the_external_dependents_target() { + // The local "app" shares the git dependent's name and version, + // but has no dependency edge of its own onto "foo" at all — its + // manifest requirement is unrelated noise. The requirement + // matches the locked version but would block candidate 1.4.0; + // even so, it must not block foo's downgrade for the git + // dependent, whose own requirement can't be read at all. + let packages = vec![ + external_pkg("app", "1.0.0", GIT_SOURCE, &[("foo", "1.5.0")]), + non_registry_pkg("app", "1.0.0", &[]), + ]; + let requirements = vec![local_requirement("app", "1.0.0", "foo", "^1.5")]; + + let gathered = gather( + FakeTransport::default(), + &packages, + &requirements, + "foo", + "1.5.0", + Some(CRATES_IO_SOURCE), + ); + assert!(gathered.constraints.is_empty()); + assert_eq!(gathered.unverified_dependents, ["app"]); + assert!(matches!( + walk( + vec![(Version::parse("1.4.0").unwrap(), 50)], + gathered.constraints + ), + WalkResult::Suggest(_, _) + )); +} + +#[test] +fn local_declaring_version_mismatch_neither_verifies_nor_constrains() { + // A local "app" 2.0.0 declares a restrictive requirement on + // "foo", but the actual lockfile dependent is a *different* + // "app" — 1.0.0 — with an identical name. Declaring package + // name alone must not be enough to apply this requirement. + let packages = vec![non_registry_pkg("app", "1.0.0", &[("foo", "1.5.0")])]; + let requirements = vec![local_requirement("app", "2.0.0", "foo", "^1.5")]; + + let gathered = gather( + FakeTransport::default(), + &packages, + &requirements, + "foo", + "1.5.0", + Some(CRATES_IO_SOURCE), + ); + assert!(gathered.constraints.is_empty()); + assert_eq!(gathered.unverified_dependents, ["app"]); +} + +#[test] +fn only_too_new_violations_are_fetched() { + let transport = FakeTransport::default(); + transport.ok("serde", &versions_body(&[("1.0.0", 50, false)], now())); + // "syn" has no scripted response: if it were fetched, the + // transport would panic. + let mut client = fast_client(transport); + + let violations = vec![too_new("serde", "1.0.0"), too_old("syn")]; + let packages = vec![pkg("serde", "1.0.0", &[])]; + let outcomes = suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); + + assert_eq!(outcomes.len(), 1); +} + +#[test] +fn a_failed_fetch_does_not_abort_the_others() { + let transport = FakeTransport::default(); + transport.error("serde"); + transport.ok( + "syn", + &versions_body(&[("1.0.0", 50, false), ("1.1.0", 5, false)], now()), + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("serde", "1.0.0"), too_new("syn", "1.1.0")]; + let packages = vec![pkg("serde", "1.0.0", &[]), pkg("syn", "1.1.0", &[])]; + let outcomes = suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); + + assert_eq!(outcomes.len(), 1); + assert!(matches!(&outcomes[0], Outcome::Suggest { package, .. } if package == "syn")); +} + +#[test] +fn no_compliant_version_reports_the_no_candidate_outcome() { + let transport = FakeTransport::default(); + transport.ok("serde", &versions_body(&[("1.0.0", 5, false)], now())); + let mut client = fast_client(transport); + + let violations = vec![too_new("serde", "1.0.0")]; + let packages = vec![pkg("serde", "1.0.0", &[])]; + let outcomes = suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); + + assert!(matches!(outcomes[0], Outcome::NoCompliantVersion { .. })); +} + +#[test] +fn no_too_new_violations_yields_none() { + let transport = FakeTransport::default(); + let mut client = fast_client(transport); + + let violations = vec![too_old("syn")]; + let outcomes = generate_suggestions( + &mut client, + &violations, + &[], + &[], + Path::new("/work"), + 30, + false, + now(), + ); + + assert!(outcomes.is_none()); +} + +#[test] +fn transitive_dependent_requirement_blocks_the_newest_candidate() { + // "app" depends on serde 1.5.0; serde's index says app requires ^1.5. + let transport = FakeTransport::default(); + transport.ok( + "serde", + &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), + ); + transport.index_ok( + "app", + r#"{"vers":"1.0.0","deps":[{"name":"serde","req":"^1.5"}]}"#, + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("serde", "1.5.0")]; + let packages = vec![ + pkg("serde", "1.5.0", &[]), + pkg("app", "1.0.0", &[("serde", "1.5.0")]), + ]; + let outcomes = suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); + + match &outcomes[0] { + Outcome::Blocked { + newest_compliant, + blocker, + .. + } => { + assert_eq!(newest_compliant, "1.4.0"); + assert_eq!(blocker.name, "app"); + assert_eq!(blocker.req, "^1.5"); + } + _ => panic!("expected Blocked"), + } +} + +#[test] +fn same_name_version_collision_across_sources_does_not_leak_dependents() { + // Two packages both named "serde" locked at 1.5.0: one from + // crates.io, one from git. "consumer" depends on the git one + // specifically. The crates.io serde must not inherit consumer's + // requirement just because the name and version happen to match. + let transport = FakeTransport::default(); + transport.ok( + "serde", + &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), + ); + // If "consumer" were (wrongly) treated as a dependent of the + // crates.io serde, this scripted index response would be + // fetched and its ^1.5 requirement would block the downgrade. + transport.index_ok( + "consumer", + r#"{"vers":"1.0.0","deps":[{"name":"serde","req":"^1.5"}]}"#, + ); + let mut client = fast_client(transport); + + let registry_source = "registry+https://github.com/rust-lang/crates.io-index"; + let git_source = + "git+https://github.com/example/serde#0000000000000000000000000000000000000000"; + + let packages = vec![ + Package { + name: "serde".to_string(), + version: "1.5.0".to_string(), + is_registry: true, + source: Some(registry_source.to_string()), + dependencies: vec![], + }, + Package { + name: "serde".to_string(), + version: "1.5.0".to_string(), + is_registry: false, + source: Some(git_source.to_string()), + dependencies: vec![], + }, + Package { + name: "consumer".to_string(), + version: "1.0.0".to_string(), + is_registry: true, + source: Some(registry_source.to_string()), + dependencies: vec![PackageRef { + name: "serde".to_string(), + version: "1.5.0".to_string(), + source: Some(git_source.to_string()), + }], + }, + ]; + + let violations = vec![too_new("serde", "1.5.0")]; + let outcomes = suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); + + assert!( + matches!(&outcomes[0], Outcome::Suggest { suggested_version, .. } if suggested_version == "1.4.0"), + "the crates.io serde must not be blocked by consumer's requirement on the git serde: {:?}", + match &outcomes[0] { + Outcome::Blocked { blocker, .. } => format!("Blocked by {}", blocker.name), + _ => "other".to_string(), + } + ); +} + +#[test] +fn source_collision_yields_a_source_qualified_package_spec() { + // Same fixture as above: a crates.io "serde" and a git "serde" + // both locked at 1.5.0. Cargo would reject the abbreviated + // `serde@1.5.0` spec as ambiguous, so the suggestion for the + // registry package must qualify it with the registry source. + let transport = FakeTransport::default(); + transport.ok( + "serde", + &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), + ); + let mut client = fast_client(transport); + + let registry_source = "registry+https://github.com/rust-lang/crates.io-index"; + let git_source = + "git+https://github.com/example/serde#0000000000000000000000000000000000000000"; + + let packages = vec![ + Package { + name: "serde".to_string(), + version: "1.5.0".to_string(), + is_registry: true, + source: Some(registry_source.to_string()), + dependencies: vec![], + }, + Package { + name: "serde".to_string(), + version: "1.5.0".to_string(), + is_registry: false, + source: Some(git_source.to_string()), + dependencies: vec![], + }, + ]; + + let violations = vec![too_new("serde", "1.5.0")]; + let outcomes = suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); + + match &outcomes[0] { + Outcome::Suggest { package_spec, .. } => { + assert_eq!(package_spec, &format!("{registry_source}#serde")); + } + _ => panic!("expected serde to be Suggest"), + } +} + +#[test] +fn no_collision_keeps_the_abbreviated_package_spec() { + let transport = FakeTransport::default(); + transport.ok( + "serde", + &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("serde", "1.5.0")]; + let packages = vec![pkg("serde", "1.5.0", &[])]; + let outcomes = suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); + + match &outcomes[0] { + Outcome::Suggest { package_spec, .. } => assert_eq!(package_spec, "serde"), + _ => panic!("expected serde to be Suggest"), + } +} + +#[test] +fn path_package_sharing_a_name_and_version_does_not_leak_dependents_to_the_registry_package() { + // A path package "local-crate" 0.1.0 and a crates.io package of + // the same name and version both exist. "consumer" depends on + // the path one via an edge that omits the source, as cargo does + // for any edge whose true target has none. Since a path + // package's own source is always omitted too, the crates.io + // package must not inherit consumer's requirement just because + // the name and version happen to collide. + let transport = FakeTransport::default(); + transport.ok( + "local-crate", + &versions_body(&[("1.1.0", 5, false), ("1.0.0", 50, false)], now()), + ); + // If "consumer" were (wrongly) treated as a dependent of the + // crates.io local-crate, this scripted index response would be + // fetched and its ^1.1 requirement would block the downgrade. + transport.index_ok( + "consumer", + r#"{"vers":"1.0.0","deps":[{"name":"local-crate","req":"^1.1"}]}"#, + ); + let mut client = fast_client(transport); + + let registry_source = "registry+https://github.com/rust-lang/crates.io-index"; + + let packages = vec![ + Package { + name: "local-crate".to_string(), + version: "1.1.0".to_string(), + is_registry: true, + source: Some(registry_source.to_string()), + dependencies: vec![], + }, + Package { + name: "local-crate".to_string(), + version: "1.1.0".to_string(), + is_registry: false, + source: None, + dependencies: vec![], + }, + Package { + name: "consumer".to_string(), + version: "1.0.0".to_string(), + is_registry: true, + source: Some(registry_source.to_string()), + dependencies: vec![PackageRef { + name: "local-crate".to_string(), + version: "1.1.0".to_string(), + source: None, + }], + }, + ]; + + let violations = vec![too_new("local-crate", "1.1.0")]; + let outcomes = suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); + + assert!( + matches!(&outcomes[0], Outcome::Suggest { suggested_version, .. } if suggested_version == "1.0.0"), + "the crates.io local-crate must not be blocked by consumer's requirement on the path local-crate: {:?}", + match &outcomes[0] { + Outcome::Blocked { blocker, .. } => format!("Blocked by {}", blocker.name), + _ => "other".to_string(), + } + ); +} + +#[test] +fn dev_kind_edge_from_a_transitive_dependent_is_ignored() { + let transport = FakeTransport::default(); + transport.ok( + "serde", + &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), + ); + transport.index_ok( + "app", + r#"{"vers":"1.0.0","deps":[{"name":"serde","req":"^1.5","kind":"dev"}]}"#, + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("serde", "1.5.0")]; + let packages = vec![ + pkg("serde", "1.5.0", &[]), + pkg("app", "1.0.0", &[("serde", "1.5.0")]), + ]; + let outcomes = suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); + + assert!(matches!(outcomes[0], Outcome::Suggest { .. })); +} + +#[test] +fn renamed_dependency_is_matched_by_its_real_name() { + let transport = FakeTransport::default(); + transport.ok("serde", &versions_body(&[("1.4.0", 50, false)], now())); + transport.index_ok( + "app", + r#"{"vers":"1.0.0","deps":[{"name":"my_serde","package":"serde","req":"^1.5"}]}"#, + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("serde", "1.5.0")]; + let packages = vec![ + pkg("serde", "1.5.0", &[]), + pkg("app", "1.0.0", &[("serde", "1.5.0")]), + ]; + let outcomes = suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); + + assert!(matches!(outcomes[0], Outcome::Blocked { .. })); +} + +#[test] +fn ambiguous_optional_declaration_does_not_block_the_downgrade() { + // "app" declares both a normal `serde = "^1"` and a disabled, + // renamed optional `serde_new = { package = "serde", version = + // "^1.5", optional = true }`. Both match locked serde@1.5.0, but + // since the optional one may not even be activated, neither can + // be enforced as a definite blocker. + let transport = FakeTransport::default(); + transport.ok( + "serde", + &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), + ); + transport.index_ok( + "app", + r#"{"vers":"1.0.0","deps":[{"name":"serde","req":"^1"},{"name":"serde_new","package":"serde","req":"^1.5","optional":true}]}"#, + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("serde", "1.5.0")]; + let packages = vec![ + pkg("serde", "1.5.0", &[]), + pkg("app", "1.0.0", &[("serde", "1.5.0")]), + ]; + let outcomes = suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); + + match &outcomes[0] { + Outcome::Suggest { + suggested_version, + unverified_dependents, + .. + } => { + assert_eq!(suggested_version, "1.4.0"); + assert_eq!(unverified_dependents, &["app".to_string()]); + } + _ => panic!("expected serde to be Suggest, with app marked unverified"), + } +} + +#[test] +fn unique_optional_declaration_still_blocks() { + // Only the optional, renamed declaration matches — no ambiguity, + // so it's the unique explanation for the lockfile edge and must + // still be enforced. + let transport = FakeTransport::default(); + transport.ok( + "serde", + &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), + ); + transport.index_ok( + "app", + r#"{"vers":"1.0.0","deps":[{"name":"serde_new","package":"serde","req":"^1.5","optional":true}]}"#, + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("serde", "1.5.0")]; + let packages = vec![ + pkg("serde", "1.5.0", &[]), + pkg("app", "1.0.0", &[("serde", "1.5.0")]), + ]; + let outcomes = suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); + + match &outcomes[0] { + Outcome::Blocked { + newest_compliant, + blocker, + .. + } => { + assert_eq!(newest_compliant, "1.4.0"); + assert_eq!(blocker.name, "app"); + assert_eq!(blocker.req, "^1.5"); + } + _ => panic!("expected serde to be Blocked by app's unique optional declaration"), + } +} + +#[test] +fn mandatory_requirements_from_different_kinds_both_block() { + // "app" declares an unconditional, nonoptional normal + // requirement of `^1.5` and an unconditional, nonoptional build + // requirement of `^1` on serde. Both are always active, so both + // are enforced — the more restrictive one (`^1.5`) rejects the + // downgrade to 1.4.0. + let transport = FakeTransport::default(); + transport.ok( + "serde", + &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), + ); + transport.index_ok( + "app", + r#"{"vers":"1.0.0","deps":[{"name":"serde","req":"^1.5"},{"name":"serde","req":"^1","kind":"build"}]}"#, + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("serde", "1.5.0")]; + let packages = vec![ + pkg("serde", "1.5.0", &[]), + pkg("app", "1.0.0", &[("serde", "1.5.0")]), + ]; + let outcomes = suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); + + match &outcomes[0] { + Outcome::Blocked { + newest_compliant, + blocker, + .. + } => { + assert_eq!(newest_compliant, "1.4.0"); + assert_eq!(blocker.name, "app"); + assert_eq!(blocker.req, "^1.5"); + } + _ => panic!("expected serde to be Blocked by app's mandatory requirement"), + } +} + +#[test] +fn mandatory_requirement_still_blocks_alongside_uncertain_declaration() { + // "app" declares an unconditional, nonoptional normal + // requirement of `^1.5`, and a separate disabled, renamed + // optional declaration matching a looser `^1`. The mandatory + // requirement is enforced regardless of the uncertain one, even + // though the uncertain one alone would have permitted the + // downgrade. + let transport = FakeTransport::default(); + transport.ok( + "serde", + &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), + ); + transport.index_ok( + "app", + r#"{"vers":"1.0.0","deps":[{"name":"serde","req":"^1.5"},{"name":"serde_new","package":"serde","req":"^1","optional":true}]}"#, + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("serde", "1.5.0")]; + let packages = vec![ + pkg("serde", "1.5.0", &[]), + pkg("app", "1.0.0", &[("serde", "1.5.0")]), + ]; + let outcomes = suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); + + match &outcomes[0] { + Outcome::Blocked { + newest_compliant, + blocker, + .. + } => { + assert_eq!(newest_compliant, "1.4.0"); + assert_eq!(blocker.name, "app"); + assert_eq!(blocker.req, "^1.5"); + } + _ => panic!("expected serde to be Blocked by app's mandatory requirement"), + } +} + +#[test] +fn identical_requirement_repeated_across_targets_still_blocks() { + // "app" declares the same `serde = "^1.5"` requirement under two + // target-specific tables (e.g. cfg(unix) and cfg(windows)), which + // the index lists as two separate `deps` entries with identical + // `req` strings. This is not the same situation as two distinct + // declarations that might not both be active — the requirement + // is identical either way, so it must still be enforced as a + // definite blocker rather than merely "unverified". + let transport = FakeTransport::default(); + transport.ok( + "serde", + &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), + ); + transport.index_ok( + "app", + r#"{"vers":"1.0.0","deps":[{"name":"serde","req":"^1.5","target":"cfg(unix)"},{"name":"serde","req":"^1.5","target":"cfg(windows)"}]}"#, + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("serde", "1.5.0")]; + let packages = vec![ + pkg("serde", "1.5.0", &[]), + pkg("app", "1.0.0", &[("serde", "1.5.0")]), + ]; + let outcomes = suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); + + match &outcomes[0] { + Outcome::Blocked { + newest_compliant, + blocker, + .. + } => { + assert_eq!(newest_compliant, "1.4.0"); + assert_eq!(blocker.name, "app"); + assert_eq!(blocker.req, "^1.5"); + } + _ => panic!("expected serde to be Blocked by app's requirement, not merely unverified"), + } +} + +#[test] +fn requirement_attribution_acceptance_cases() { + // name, second version, requirements, unverified, blocks 1.7 + let cases: &[(&str, bool, &[&str], bool, bool)] = &[ + ("overlap", true, &["^1", ">=1.8,<3"], true, false), + ("disjoint", true, &["^1", "^2"], false, false), + ( + "identical aliases", + true, + &[">=1.8,<3", ">=1.8, <3"], + false, + true, + ), + ("single version", false, &["^1", ">=1.8,<2"], false, true), + ("optional", true, &["^1", ">=1.8,<3"], true, false), + ("target", true, &["^1", ">=1.8,<3"], true, false), + ("other blocker", true, &["^1", ">=1.8,<3"], true, true), + ("other parent", false, &["^1", ">=1.8,<3"], false, true), + ("other source", true, &["^1", ">=1.8,<3"], false, true), + ("path sibling", false, &["^1", ">=1.8,<3"], false, true), + ("duplicate edge", false, &["^1", ">=1.8,<3"], false, true), + ("unreadable", false, &[], true, false), + ]; + for registry in [true, false] { + for &(case, second_version, reqs, unverified, blocked) in cases { + if !registry && matches!(case, "optional" | "target") { + continue; + } + let transport = FakeTransport::default(); + let mut requirements = Vec::new(); + let mut app = if registry { + pkg("app", "1.0.0", &[("foo", "1.9.0")]) + } else { + non_registry_pkg("app", "1.0.0", &[("foo", "1.9.0")]) + }; + let source = pkg("foo", "1.9.0", &[]).source.unwrap(); + app.dependencies[0].source = Some(source.clone()); + let mut packages = vec![pkg("foo", "1.9.0", &[])]; + if second_version { + let mut sibling = pkg("foo", "2.0.0", &[]); + if case == "other source" { + sibling = external_pkg("foo", "2.0.0", ALT_REGISTRY_SOURCE, &[]); + } + app.dependencies.push(PackageRef { + name: "foo".into(), + version: sibling.version.clone(), + source: sibling.source.clone(), + }); + packages.push(sibling); + } + match case { + "path sibling" => { + packages.push(non_registry_pkg("foo", "1.9.0", &[])); + app.dependencies.push(PackageRef { + name: "foo".into(), + version: "1.9.0".into(), + source: None, + }); + } + "duplicate edge" => app.dependencies.push(PackageRef { + name: "foo".into(), + version: "1.9.0".into(), + source: Some(source.clone()), + }), + "other parent" => { + packages.push(pkg("foo", "2.0.0", &[])); + packages.push(pkg("other", "1.0.0", &[("foo", "2.0.0")])); + } + "other blocker" => { + if registry { + packages.push(pkg("other", "1.0.0", &[("foo", "1.9.0")])); + transport.index_ok( + "other", + r#"{"vers":"1.0.0","deps":[{"name":"foo","req":">=1.8"}]}"#, + ); + } else { + packages.push(non_registry_pkg("other", "1.0.0", &[("foo", "1.9.0")])); + requirements.push(local_requirement("other", "1.0.0", "foo", ">=1.8")); + } + } + _ => {} + } + packages.push(app); + if registry && case != "unreadable" { + let deps: Vec<_> = reqs.iter().enumerate().map(|(i, req)| { + serde_json::json!({ + "name": format!("alias_{i}"), "package": "foo", "req": req, + "optional": case == "optional" && i == 0, + "target": if case == "target" && i == 0 { Some("cfg(unix)") } else { None }, + }) + }).collect(); + transport.index_ok( + "app", + &serde_json::json!({ + "vers": "1.0.0", "deps": deps, + }) + .to_string(), + ); + } else if registry { + transport.index_error("app"); + } else { + requirements.extend( + reqs.iter() + .map(|req| local_requirement("app", "1.0.0", "foo", req)), + ); + } + let mut client = fast_client(transport); + let index = build_indexes(&packages).0; + let gathered = gather_constraints( + &mut client, + &index, + &requirements, + Path::new("/work"), + "foo", + "1.9.0", + Some(&source), + ); + let expected_unverified = if unverified { vec!["app"] } else { vec![] }; + assert_eq!( + gathered.unverified_dependents, expected_unverified, + "{case}, registry={registry}" + ); + if case == "disjoint" { + assert!( + gathered + .constraints + .iter() + .any(|c| !c.req.matches(&v("0.9.0"))) + ); + } + let result = walk(vec![(v("1.7.0"), 80)], gathered.constraints); + assert_eq!( + matches!(result, WalkResult::Blocked { .. }), + blocked, + "{case}, registry={registry}" + ); + if case == "other blocker" { + let WalkResult::Blocked { blocker, .. } = result else { + unreachable!() + }; + assert_eq!( + blocker.blocker_name, + if registry { "other" } else { "Cargo.toml" } + ); + } else if !blocked { + assert!( + matches!(result, WalkResult::Suggest(version, 80) if version == v("1.7.0")) + ); + } + } + } +} + +#[test] +fn failed_index_fetch_yields_suggestion_with_unverified_annotation() { + let transport = FakeTransport::default(); + transport.ok( + "serde", + &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), + ); + transport.index_error("app"); + let mut client = fast_client(transport); + + let violations = vec![too_new("serde", "1.5.0")]; + let packages = vec![ + pkg("serde", "1.5.0", &[]), + pkg("app", "1.0.0", &[("serde", "1.5.0")]), + ]; + let outcomes = suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); + + match &outcomes[0] { + Outcome::Suggest { + unverified_dependents, + .. + } => assert_eq!(unverified_dependents, &["app".to_string()]), + _ => panic!("expected Suggest"), + } +} + +#[test] +fn a_package_locked_at_two_versions_produces_two_outcomes() { + let transport = FakeTransport::default(); + transport.ok( + "serde", + &versions_body(&[("1.0.0", 50, false), ("2.0.0", 50, false)], now()), + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("serde", "1.0.0"), too_new("serde", "2.0.0")]; + let packages = vec![pkg("serde", "1.0.0", &[]), pkg("serde", "2.0.0", &[])]; + let outcomes = suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); + + assert_eq!(outcomes.len(), 2); +} + +#[test] +fn also_suggested_is_false_when_the_blocker_itself_has_no_suggestion() { + // "y" blocks "target", and "y" is itself in the too-new set — + // but y's own walk resolves to NoCompliantVersion, not Suggest, + // so the blocked message must not claim a fix for y exists. + let transport = FakeTransport::default(); + transport.ok( + "target", + &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), + ); + transport.ok("y", &versions_body(&[("1.5.0", 5, false)], now())); + transport.index_ok( + "y", + r#"{"vers":"1.5.0","deps":[{"name":"target","req":"^1.5"}]}"#, + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("target", "1.5.0"), too_new("y", "1.5.0")]; + let packages = vec![ + pkg("target", "1.5.0", &[]), + pkg("y", "1.5.0", &[("target", "1.5.0")]), + ]; + let outcomes = suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); + + match &outcomes[0] { + Outcome::Blocked { blocker, .. } => { + assert_eq!(blocker.name, "y"); + assert!( + !blocker.also_suggested, + "y has no Suggest outcome of its own" + ); + } + _ => panic!("expected target to be Blocked"), + } + assert!(matches!( + &outcomes[1], + Outcome::NoCompliantVersion { package, .. } if package == "y" + )); +} + +#[test] +fn also_suggested_is_false_when_only_another_version_of_the_blocker_is_suggested() { + // "foo" is locked at both 1.5.0 and 2.5.0. Only 1.5.0 resolves to + // a Suggest; the 2.5.0 that blocks "target" has no compliant + // version, so the blocked message must not point at the unrelated + // 1.5.0 downgrade. + let transport = FakeTransport::default(); + transport.ok( + "target", + &versions_body(&[("1.5.0", 5, false), ("1.4.0", 50, false)], now()), + ); + transport.ok( + "foo", + &versions_body( + &[ + ("1.5.0", 5, false), + ("1.4.0", 50, false), + ("2.5.0", 5, false), + ], + now(), + ), + ); + transport.index_ok( + "foo", + r#"{"vers":"2.5.0","deps":[{"name":"target","req":"^1.5"}]}"#, + ); + let mut client = fast_client(transport); + + let violations = vec![ + too_new("target", "1.5.0"), + too_new("foo", "1.5.0"), + too_new("foo", "2.5.0"), + ]; + let packages = vec![ + pkg("target", "1.5.0", &[]), + pkg("foo", "1.5.0", &[]), + pkg("foo", "2.5.0", &[("target", "1.5.0")]), + ]; + let outcomes = suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); + + assert!( + matches!(&outcomes[1], Outcome::Suggest { package, locked_version, .. } + if package == "foo" && locked_version == "1.5.0"), + "foo 1.5.0 should be suggested, otherwise the test proves nothing" + ); + match &outcomes[0] { + Outcome::Blocked { blocker, .. } => { + assert_eq!(blocker.name, "foo"); + assert_eq!(blocker.version.as_deref(), Some("2.5.0")); + assert!( + !blocker.also_suggested, + "the suggestion is for foo 1.5.0, which does not unblock foo 2.5.0" + ); + } + _ => panic!("expected target to be Blocked"), + } +} + +#[test] +fn manifest_constraint_is_scoped_to_the_declaring_dependent() { + // member_a locks clap@2.5.0 and requires ^2; member_b locks a + // different clap version and requires ^3. member_b's unrelated + // requirement must not leak into member_a's constraint set. + let transport = FakeTransport::default(); + transport.ok( + "clap", + &versions_body(&[("2.5.0", 5, false), ("2.0.0", 50, false)], now()), + ); + let mut client = fast_client(transport); + + let direct_requirements = vec![ + crate::manifest::DirectRequirement { + manifest: PathBuf::from("/work/member_a/Cargo.toml"), + declaring_package: "member_a".to_string(), + declaring_version: Some("0.1.0".to_string()), + crate_name: "clap".to_string(), + req: VersionReq::parse("^2").unwrap(), + source: RequirementSource::CratesIo, + }, + crate::manifest::DirectRequirement { + manifest: PathBuf::from("/work/member_b/Cargo.toml"), + declaring_package: "member_b".to_string(), + declaring_version: Some("0.1.0".to_string()), + crate_name: "clap".to_string(), + req: VersionReq::parse("^3").unwrap(), + source: RequirementSource::CratesIo, + }, + ]; + let packages = vec![ + pkg("clap", "2.5.0", &[]), + non_registry_pkg("member_a", "0.1.0", &[("clap", "2.5.0")]), + non_registry_pkg("member_b", "0.1.0", &[("clap", "3.1.0")]), + ]; + + let violations = vec![too_new("clap", "2.5.0")]; + let outcomes = suggestions( + &mut client, + &violations, + &packages, + &direct_requirements, + Path::new("/work"), + ); + + match &outcomes[0] { + Outcome::Suggest { + suggested_version, .. + } => assert_eq!(suggested_version, "2.0.0"), + _ => panic!("expected clap to be Suggest: member_b's ^3 must not apply"), + } +} diff --git a/src/suggest/tests/manifest_dependent_identity_tests.rs b/src/suggest/tests/manifest_dependent_identity_tests.rs new file mode 100644 index 0000000..059c36b --- /dev/null +++ b/src/suggest/tests/manifest_dependent_identity_tests.rs @@ -0,0 +1,487 @@ +/// Covers matching a real local dependent's manifest requirement against +/// its own locked identity (name and version), including workspace +/// version inheritance, rather than name alone. +use super::*; +use crate::api::RetryPolicy; +use crate::api::test_support::{FakeTransport, ScriptedResponse, versions_url}; +use crate::manifest::load_direct_requirements; +use crate::report::Aged; +use std::num::NonZeroU32; +use std::time::Duration; +use tempfile::tempdir; + +const CRATES_IO_SOURCE: &str = "registry+https://github.com/rust-lang/crates.io-index"; +const GIT_SOURCE: &str = + "git+https://github.com/example/vendor#0000000000000000000000000000000000000000"; +const ALT_REGISTRY_SOURCE: &str = "registry+https://example.com/priv-index"; + +fn versions_body(entries: &[(&str, i64, bool)]) -> String { + let versions: Vec = entries + .iter() + .map(|(num, days_ago, yanked)| { + let created_at = now() - chrono::Duration::days(*days_ago); + format!( + r#"{{"num":"{num}","created_at":"{}","yanked":{yanked}}}"#, + created_at.to_rfc3339() + ) + }) + .collect(); + format!(r#"{{"versions":[{}]}}"#, versions.join(",")) +} + +fn fast_client(transport: FakeTransport) -> CratesIoClient { + CratesIoClient::with_transport( + transport, + None, + 24, + RetryPolicy { + retry_count: NonZeroU32::new(1).unwrap(), + retry_delay: Duration::from_millis(0), + pacing_delay: Duration::from_millis(0), + }, + ) +} + +fn too_new(package: &str, locked_version: &str) -> Violation { + Violation { + package: package.to_string(), + version: locked_version.to_string(), + kind: ViolationKind::TooNew(Aged { + published: now() - chrono::Duration::days(5), + age_days: 5, + }), + } +} + +fn write_manifest(dir: &Path, rel: &str, contents: &str) { + let path = dir.join(rel); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(&path, contents).unwrap(); +} + +#[test] +fn workspace_inherited_declaring_version_matches_the_locked_local_dependent() { + // "app"'s own version is inherited from the workspace root + // rather than written directly. Its requirement on foo must + // still be recognized as its own — matched by the resolved + // version, not skipped for lack of one. + let dir = tempdir().unwrap(); + write_manifest( + dir.path(), + "Cargo.toml", + r#" +[workspace] +members = ["app"] + +[workspace.package] +version = "0.1.0" +"#, + ); + write_manifest( + dir.path(), + "app/Cargo.toml", + r#" +[package] +name = "app" +version.workspace = true + +[dependencies] +foo = "^1.0" +"#, + ); + let (direct_requirements, warnings) = load_direct_requirements(dir.path()); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + let transport = FakeTransport::default(); + transport.push( + &versions_url("foo"), + ScriptedResponse::Http( + 200, + versions_body(&[("1.9.0", 5, false), ("1.8.0", 50, false)]), + ), + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("foo", "1.9.0")]; + let packages = vec![ + Package { + name: "foo".to_string(), + version: "1.9.0".to_string(), + is_registry: true, + source: Some(CRATES_IO_SOURCE.to_string()), + dependencies: vec![], + }, + Package { + name: "app".to_string(), + version: "0.1.0".to_string(), + is_registry: false, + source: None, + dependencies: vec![PackageRef { + name: "foo".to_string(), + version: "1.9.0".to_string(), + source: Some(CRATES_IO_SOURCE.to_string()), + }], + }, + ]; + let outcomes = suggestions( + &mut client, + &violations, + &packages, + &direct_requirements, + dir.path(), + ); + + match &outcomes[0] { + Outcome::Suggest { + suggested_version, + unverified_dependents, + .. + } => { + assert_eq!(suggested_version, "1.8.0"); + assert!( + unverified_dependents.is_empty(), + "app's workspace-inherited version should have matched: {unverified_dependents:?}" + ); + } + _ => panic!("expected foo to be Suggest"), + } +} + +#[test] +fn unresolvable_declaring_version_does_not_falsely_verify_the_dependent() { + // "app" inherits its version from the workspace, but the + // workspace root supplies none — the member's manifest fails to + // load entirely, so no requirement is ever collected for it. + // "app" must come back unverified, not silently treated as + // matching by name alone. + let dir = tempdir().unwrap(); + write_manifest( + dir.path(), + "Cargo.toml", + r#" +[workspace] +members = ["app"] +"#, + ); + write_manifest( + dir.path(), + "app/Cargo.toml", + r#" +[package] +name = "app" +version.workspace = true + +[dependencies] +foo = "^1.0" +"#, + ); + let (direct_requirements, warnings) = load_direct_requirements(dir.path()); + assert!( + !warnings.is_empty(), + "expected a warning about app's unresolved workspace version" + ); + + let transport = FakeTransport::default(); + transport.push( + &versions_url("foo"), + ScriptedResponse::Http( + 200, + versions_body(&[("1.9.0", 5, false), ("1.8.0", 50, false)]), + ), + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("foo", "1.9.0")]; + let packages = vec![ + Package { + name: "foo".to_string(), + version: "1.9.0".to_string(), + is_registry: true, + source: Some(CRATES_IO_SOURCE.to_string()), + dependencies: vec![], + }, + Package { + name: "app".to_string(), + version: "0.1.0".to_string(), + is_registry: false, + source: None, + dependencies: vec![PackageRef { + name: "foo".to_string(), + version: "1.9.0".to_string(), + source: Some(CRATES_IO_SOURCE.to_string()), + }], + }, + ]; + let outcomes = suggestions( + &mut client, + &violations, + &packages, + &direct_requirements, + dir.path(), + ); + + match &outcomes[0] { + Outcome::Suggest { + unverified_dependents, + .. + } => assert_eq!(unverified_dependents, &["app".to_string()]), + _ => panic!("expected foo to be Suggest, with app marked unverified"), + } +} + +#[test] +fn real_local_constraint_is_enforced_while_an_external_dependent_stays_unverified() { + // "app" is a real local dependent whose manifest permits the + // downgrade; "vendor" is a git dependent that also locks foo at + // the same version, but nothing here can read its requirement. + // 1.8.0 is the newer, otherwise-preferred candidate, but app's + // manifest rejects it, so enforcement must fall back to 1.8.5 + // while still flagging vendor as unverified. + let dir = tempdir().unwrap(); + write_manifest( + dir.path(), + "Cargo.toml", + r#" +[package] +name = "app" +version = "0.1.0" + +[dependencies] +foo = "^1.8.5" +"#, + ); + let (direct_requirements, warnings) = load_direct_requirements(dir.path()); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + let transport = FakeTransport::default(); + transport.push( + &versions_url("foo"), + ScriptedResponse::Http( + 200, + versions_body(&[ + ("1.9.0", 5, false), + ("1.8.0", 40, false), + ("1.8.5", 50, false), + ]), + ), + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("foo", "1.9.0")]; + let packages = vec![ + Package { + name: "foo".to_string(), + version: "1.9.0".to_string(), + is_registry: true, + source: Some(CRATES_IO_SOURCE.to_string()), + dependencies: vec![], + }, + Package { + name: "app".to_string(), + version: "0.1.0".to_string(), + is_registry: false, + source: None, + dependencies: vec![PackageRef { + name: "foo".to_string(), + version: "1.9.0".to_string(), + source: Some(CRATES_IO_SOURCE.to_string()), + }], + }, + Package { + name: "vendor".to_string(), + version: "1.0.0".to_string(), + is_registry: false, + source: Some(GIT_SOURCE.to_string()), + dependencies: vec![PackageRef { + name: "foo".to_string(), + version: "1.9.0".to_string(), + source: Some(CRATES_IO_SOURCE.to_string()), + }], + }, + ]; + let outcomes = suggestions( + &mut client, + &violations, + &packages, + &direct_requirements, + dir.path(), + ); + + match &outcomes[0] { + Outcome::Suggest { + suggested_version, + unverified_dependents, + .. + } => { + assert_eq!(suggested_version, "1.8.5"); + assert_eq!(unverified_dependents, &["vendor".to_string()]); + } + _ => panic!("expected foo to be Suggest, with vendor marked unverified"), + } +} + +#[test] +fn git_dependent_sharing_a_local_packages_identity_is_not_verified_by_its_manifest() { + // A git "app" and the local "app" share name and version; the + // local manifest permits the downgrade but was never the git + // dependent's own, so it must not verify it. + let dir = tempdir().unwrap(); + write_manifest( + dir.path(), + "Cargo.toml", + r#" +[package] +name = "app" +version = "1.0.0" + +[dependencies] +foo = "^1.0" +"#, + ); + let (direct_requirements, warnings) = load_direct_requirements(dir.path()); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + let transport = FakeTransport::default(); + transport.push( + &versions_url("foo"), + ScriptedResponse::Http( + 200, + versions_body(&[("1.9.0", 5, false), ("1.8.0", 50, false)]), + ), + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("foo", "1.9.0")]; + let packages = vec![ + Package { + name: "foo".to_string(), + version: "1.9.0".to_string(), + is_registry: true, + source: Some(CRATES_IO_SOURCE.to_string()), + dependencies: vec![], + }, + Package { + name: "app".to_string(), + version: "1.0.0".to_string(), + is_registry: false, + source: Some(GIT_SOURCE.to_string()), + dependencies: vec![PackageRef { + name: "foo".to_string(), + version: "1.9.0".to_string(), + source: Some(CRATES_IO_SOURCE.to_string()), + }], + }, + Package { + name: "app".to_string(), + version: "1.0.0".to_string(), + is_registry: false, + source: None, + dependencies: vec![], + }, + ]; + let outcomes = suggestions( + &mut client, + &violations, + &packages, + &direct_requirements, + dir.path(), + ); + + match &outcomes[0] { + Outcome::Suggest { + suggested_version, + unverified_dependents, + .. + } => { + assert_eq!(suggested_version, "1.8.0"); + assert_eq!(unverified_dependents, &["app".to_string()]); + } + _ => panic!("expected foo to be Suggest, with the git app marked unverified"), + } +} + +#[test] +fn alternate_registry_dependent_sharing_a_local_packages_identity_is_not_verified_by_its_manifest() +{ + // An alternate-registry "app" and the local "app" share name and + // version; the local manifest permits the downgrade but was + // never the alternate-registry dependent's own, so it must not + // verify it. + let dir = tempdir().unwrap(); + write_manifest( + dir.path(), + "Cargo.toml", + r#" +[package] +name = "app" +version = "1.0.0" + +[dependencies] +foo = "^1.0" +"#, + ); + let (direct_requirements, warnings) = load_direct_requirements(dir.path()); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + let transport = FakeTransport::default(); + transport.push( + &versions_url("foo"), + ScriptedResponse::Http( + 200, + versions_body(&[("1.9.0", 5, false), ("1.8.0", 50, false)]), + ), + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("foo", "1.9.0")]; + let packages = vec![ + Package { + name: "foo".to_string(), + version: "1.9.0".to_string(), + is_registry: true, + source: Some(CRATES_IO_SOURCE.to_string()), + dependencies: vec![], + }, + Package { + name: "app".to_string(), + version: "1.0.0".to_string(), + is_registry: false, + source: Some(ALT_REGISTRY_SOURCE.to_string()), + dependencies: vec![PackageRef { + name: "foo".to_string(), + version: "1.9.0".to_string(), + source: Some(CRATES_IO_SOURCE.to_string()), + }], + }, + Package { + name: "app".to_string(), + version: "1.0.0".to_string(), + is_registry: false, + source: None, + dependencies: vec![], + }, + ]; + let outcomes = suggestions( + &mut client, + &violations, + &packages, + &direct_requirements, + dir.path(), + ); + + match &outcomes[0] { + Outcome::Suggest { + suggested_version, + unverified_dependents, + .. + } => { + assert_eq!(suggested_version, "1.8.0"); + assert_eq!(unverified_dependents, &["app".to_string()]); + } + _ => { + panic!("expected foo to be Suggest, with the alternate-registry app marked unverified") + } + } +} diff --git a/src/suggest/tests/manifest_registry_identity_tests.rs b/src/suggest/tests/manifest_registry_identity_tests.rs new file mode 100644 index 0000000..f636bd4 --- /dev/null +++ b/src/suggest/tests/manifest_registry_identity_tests.rs @@ -0,0 +1,473 @@ +/// Covers a manifest crate name declared against two different +/// registries at once: an ordinary crates.io requirement and one +/// explicitly pinned to a renamed private registry. The renamed +/// declaration must never be treated as if it constrained the crates.io +/// package this flow actually suggests a downgrade for, nor may it +/// silently mark the declaring dependent as unverified when the +/// crates.io declaration alone already verifies it. +use super::*; +use crate::api::RetryPolicy; +use crate::api::test_support::{FakeTransport, ScriptedResponse, versions_url}; +use crate::manifest::load_direct_requirements; +use crate::report::Aged; +use std::num::NonZeroU32; +use std::time::Duration; +use tempfile::tempdir; + +const CRATES_IO_SOURCE: &str = "registry+https://github.com/rust-lang/crates.io-index"; +const PRIVATE_SOURCE: &str = "registry+https://example.com/priv-index"; + +fn versions_body(entries: &[(&str, i64, bool)]) -> String { + let versions: Vec = entries + .iter() + .map(|(num, days_ago, yanked)| { + let created_at = now() - chrono::Duration::days(*days_ago); + format!( + r#"{{"num":"{num}","created_at":"{}","yanked":{yanked}}}"#, + created_at.to_rfc3339() + ) + }) + .collect(); + format!(r#"{{"versions":[{}]}}"#, versions.join(",")) +} + +fn fast_client(transport: FakeTransport) -> CratesIoClient { + CratesIoClient::with_transport( + transport, + None, + 24, + RetryPolicy { + retry_count: NonZeroU32::new(1).unwrap(), + retry_delay: Duration::from_millis(0), + pacing_delay: Duration::from_millis(0), + }, + ) +} + +fn too_new(package: &str, locked_version: &str) -> Violation { + Violation { + package: package.to_string(), + version: locked_version.to_string(), + kind: ViolationKind::TooNew(Aged { + published: now(), + age_days: 1, + }), + } +} + +fn write_manifest(dir: &Path, contents: &str) { + std::fs::write(dir.join("Cargo.toml"), contents).unwrap(); +} + +/// A "foo" package locked at 1.9.0 on each of `CRATES_IO_SOURCE` and +/// `PRIVATE_SOURCE`, both depended on by workspace member "app" via +/// source-qualified lockfile dependency edges — the shape a real +/// lockfile resolves to when a same-name/same-version package exists +/// on more than one registry. +fn packages_with_dual_source_foo() -> Vec { + vec![ + Package { + name: "foo".to_string(), + version: "1.9.0".to_string(), + is_registry: true, + source: Some(CRATES_IO_SOURCE.to_string()), + dependencies: vec![], + }, + Package { + name: "foo".to_string(), + version: "1.9.0".to_string(), + is_registry: false, + source: Some(PRIVATE_SOURCE.to_string()), + dependencies: vec![], + }, + Package { + name: "app".to_string(), + version: "0.1.0".to_string(), + is_registry: false, + source: None, + dependencies: vec![ + PackageRef { + name: "foo".to_string(), + version: "1.9.0".to_string(), + source: Some(CRATES_IO_SOURCE.to_string()), + }, + PackageRef { + name: "foo".to_string(), + version: "1.9.0".to_string(), + source: Some(PRIVATE_SOURCE.to_string()), + }, + ], + }, + ] +} + +#[test] +fn crates_io_declaration_is_enforced_while_the_renamed_registry_declaration_is_excluded() { + // "app" depends on crates.io foo ^1.0 and a renamed + // private-registry foo pinned to =1.9.0; both resolve to foo + // 1.9.0 in the lockfile. Only the crates.io declaration should + // count toward foo's suggestion: it doesn't block 1.8.0, so foo + // should suggest it, and the =1.9.0 renamed declaration must not + // spuriously block a package it was never written for. + let dir = tempdir().unwrap(); + write_manifest( + dir.path(), + r#" +[package] +name = "app" +version = "0.1.0" + +[dependencies] +foo = "^1.0" +foo_priv = { package = "foo", version = "=1.9.0", registry = "priv" } +"#, + ); + let (direct_requirements, warnings) = load_direct_requirements(dir.path()); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + let transport = FakeTransport::default(); + transport.push( + &versions_url("foo"), + ScriptedResponse::Http( + 200, + versions_body(&[("1.9.0", 5, false), ("1.8.0", 50, false)]), + ), + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("foo", "1.9.0")]; + let packages = packages_with_dual_source_foo(); + let outcomes = suggestions( + &mut client, + &violations, + &packages, + &direct_requirements, + dir.path(), + ); + + match &outcomes[0] { + Outcome::Suggest { + package_spec, + suggested_version, + unverified_dependents, + .. + } => { + assert_eq!(suggested_version, "1.8.0"); + assert_eq!(package_spec, &format!("{CRATES_IO_SOURCE}#foo")); + assert!( + unverified_dependents.is_empty(), + "app is verified by its crates.io ^1.0 declaration: {unverified_dependents:?}" + ); + } + Outcome::Blocked { blocker, .. } => panic!( + "expected foo to be Suggest, but was Blocked by {} \ + (the renamed-registry declaration must have leaked in)", + blocker.name + ), + _ => panic!("expected foo to be Suggest"), + } +} + +#[test] +fn reverse_roles_still_block_via_the_crates_io_declaration() { + // Same fixture, roles swapped: crates.io foo is now pinned to + // =1.9.0 and the renamed private-registry foo carries the + // lenient ^1.0. The crates.io pin must still block the + // downgrade, reported as the blocker. + let dir = tempdir().unwrap(); + write_manifest( + dir.path(), + r#" +[package] +name = "app" +version = "0.1.0" + +[dependencies] +foo = "=1.9.0" +foo_priv = { package = "foo", version = "^1.0", registry = "priv" } +"#, + ); + let (direct_requirements, warnings) = load_direct_requirements(dir.path()); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + let transport = FakeTransport::default(); + transport.push( + &versions_url("foo"), + ScriptedResponse::Http( + 200, + versions_body(&[("1.9.0", 5, false), ("1.8.0", 50, false)]), + ), + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("foo", "1.9.0")]; + let packages = packages_with_dual_source_foo(); + let outcomes = suggestions( + &mut client, + &violations, + &packages, + &direct_requirements, + dir.path(), + ); + + match &outcomes[0] { + Outcome::Blocked { + newest_compliant, + blocker, + .. + } => { + assert_eq!(newest_compliant, "1.8.0"); + assert_eq!(blocker.name, "Cargo.toml"); + assert_eq!(blocker.version, None); + assert_eq!(blocker.req, "=1.9.0"); + } + _ => panic!("expected foo to be Blocked by the crates.io declaration"), + } +} + +#[test] +fn explicit_crates_io_registry_name_still_blocks() { + // Same fixture as `reverse_roles_still_block_via_the_crates_io_declaration`, + // but the crates.io declaration names its registry explicitly + // via the reserved `crates-io` alias instead of omitting + // `registry` altogether. It must still be recognized as + // crates.io and enforced, not excluded as an unrecognized + // alternate registry. + let dir = tempdir().unwrap(); + write_manifest( + dir.path(), + r#" +[package] +name = "app" +version = "0.1.0" + +[dependencies] +foo = { version = "=1.9.0", registry = "crates-io" } +foo_priv = { package = "foo", version = "^1.0", registry = "priv" } +"#, + ); + let (direct_requirements, warnings) = load_direct_requirements(dir.path()); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + let transport = FakeTransport::default(); + transport.push( + &versions_url("foo"), + ScriptedResponse::Http( + 200, + versions_body(&[("1.9.0", 5, false), ("1.8.0", 50, false)]), + ), + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("foo", "1.9.0")]; + let packages = packages_with_dual_source_foo(); + let outcomes = suggestions( + &mut client, + &violations, + &packages, + &direct_requirements, + dir.path(), + ); + + match &outcomes[0] { + Outcome::Blocked { + newest_compliant, + blocker, + .. + } => { + assert_eq!(newest_compliant, "1.8.0"); + assert_eq!(blocker.name, "Cargo.toml"); + assert_eq!(blocker.version, None); + assert_eq!(blocker.req, "=1.9.0"); + } + _ => panic!( + "expected foo to be Blocked by the explicit crates-io declaration, \ + not excluded as an unrecognized alternate registry" + ), + } +} + +#[test] +fn a_second_crates_io_declaration_for_the_same_identity_still_blocks() { + // Both declarations are ordinary crates.io dependencies — no + // registry collision at all — one lenient, one restrictive. + // Guards against the crates.io source filter collapsing + // enforcement down to a single matching declaration. + let dir = tempdir().unwrap(); + write_manifest( + dir.path(), + r#" +[package] +name = "app" +version = "0.1.0" + +[dependencies] +foo = "^1.0" +foo_pinned = { package = "foo", version = "=1.9.0" } +"#, + ); + let (direct_requirements, warnings) = load_direct_requirements(dir.path()); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + assert_eq!(direct_requirements.len(), 2); + assert!( + direct_requirements + .iter() + .all(|r| r.source == RequirementSource::CratesIo) + ); + + let transport = FakeTransport::default(); + transport.push( + &versions_url("foo"), + ScriptedResponse::Http( + 200, + versions_body(&[("1.9.0", 5, false), ("1.8.0", 50, false)]), + ), + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("foo", "1.9.0")]; + let packages = vec![ + Package { + name: "foo".to_string(), + version: "1.9.0".to_string(), + is_registry: true, + source: Some(CRATES_IO_SOURCE.to_string()), + dependencies: vec![], + }, + Package { + name: "app".to_string(), + version: "0.1.0".to_string(), + is_registry: false, + source: None, + dependencies: vec![PackageRef { + name: "foo".to_string(), + version: "1.9.0".to_string(), + source: Some(CRATES_IO_SOURCE.to_string()), + }], + }, + ]; + let outcomes = suggestions( + &mut client, + &violations, + &packages, + &direct_requirements, + dir.path(), + ); + + match &outcomes[0] { + Outcome::Blocked { + newest_compliant, + blocker, + .. + } => { + assert_eq!(newest_compliant, "1.8.0"); + assert_eq!(blocker.req, "=1.9.0"); + } + _ => panic!( + "expected foo to be Blocked by the restrictive =1.9.0 declaration \ + alongside the lenient ^1.0 one" + ), + } +} + +/// "helper" is a path dependency of root "app", not a workspace +/// member. Cargo ignores the dev-dependencies of a non-member path +/// dependency, so helper's `[dev-dependencies] foo = "=1.9.0"` must +/// not block a downgrade that only helper's own `[dependencies] foo +/// = "1"` would allow. +#[test] +fn dev_dependency_of_a_non_member_path_dependency_does_not_block() { + let dir = tempdir().unwrap(); + write_manifest( + dir.path(), + r#" +[package] +name = "app" +version = "0.1.0" + +[dependencies] +helper = { path = "helper" } +"#, + ); + std::fs::create_dir_all(dir.path().join("helper")).unwrap(); + write_manifest( + &dir.path().join("helper"), + r#" +[package] +name = "helper" +version = "0.1.0" + +[dependencies] +foo = "1" + +[dev-dependencies] +foo = "=1.9.0" +"#, + ); + let (direct_requirements, warnings) = load_direct_requirements(dir.path()); + assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); + + let transport = FakeTransport::default(); + transport.push( + &versions_url("foo"), + ScriptedResponse::Http( + 200, + versions_body(&[("1.9.0", 5, false), ("1.8.0", 50, false)]), + ), + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("foo", "1.9.0")]; + let packages = vec![ + Package { + name: "foo".to_string(), + version: "1.9.0".to_string(), + is_registry: true, + source: Some(CRATES_IO_SOURCE.to_string()), + dependencies: vec![], + }, + Package { + name: "helper".to_string(), + version: "0.1.0".to_string(), + is_registry: false, + source: None, + dependencies: vec![PackageRef { + name: "foo".to_string(), + version: "1.9.0".to_string(), + source: Some(CRATES_IO_SOURCE.to_string()), + }], + }, + Package { + name: "app".to_string(), + version: "0.1.0".to_string(), + is_registry: false, + source: None, + dependencies: vec![PackageRef { + name: "helper".to_string(), + version: "0.1.0".to_string(), + source: None, + }], + }, + ]; + let outcomes = suggestions( + &mut client, + &violations, + &packages, + &direct_requirements, + dir.path(), + ); + + match &outcomes[0] { + Outcome::Suggest { + suggested_version, .. + } => { + assert_eq!(suggested_version, "1.8.0"); + } + Outcome::Blocked { blocker, .. } => panic!( + "expected foo to be Suggest, but was Blocked by {} \ + (helper's dev-dependency must be ignored: it isn't a workspace member)", + blocker.name + ), + _ => panic!("expected foo to be Suggest"), + } +} diff --git a/src/suggest/tests/source_collision_cargo_tests.rs b/src/suggest/tests/source_collision_cargo_tests.rs new file mode 100644 index 0000000..3d5a3b8 --- /dev/null +++ b/src/suggest/tests/source_collision_cargo_tests.rs @@ -0,0 +1,174 @@ +/// Builds a real Cargo project with a source collision — a crates.io +/// package and a path package sharing a name and locked version — and +/// runs a real `cargo` against the spec `build_package_spec` produces, +/// to verify it's the source-qualified pkgid Cargo itself expects, +/// rather than merely a string this crate assumes is valid. +use super::*; +use sha2::{Digest, Sha256}; +use std::process::Command; + +const CRATE_NAME: &str = "semver"; +const CRATE_VERSION: &str = "1.0.28"; + +/// Writes a minimal crate (`Cargo.toml` + `src/lib.rs`) at `dir`. +fn write_crate_source(dir: &Path, name: &str, version: &str) { + std::fs::create_dir_all(dir.join("src")).unwrap(); + std::fs::write( + dir.join("Cargo.toml"), + format!("[package]\nname = \"{name}\"\nversion = \"{version}\"\nedition = \"2021\"\n"), + ) + .unwrap(); + std::fs::write(dir.join("src/lib.rs"), "").unwrap(); +} + +/// Packs `crate_dir` (already containing a `{name}-{version}` +/// top-level directory) into a `.crate` tarball, Cargo's own +/// publish format. +fn pack_crate_tarball(crate_dir: &Path, name: &str, version: &str) -> Vec { + let mut bytes = Vec::new(); + { + let encoder = flate2::write::GzEncoder::new(&mut bytes, flate2::Compression::default()); + let mut builder = tar::Builder::new(encoder); + builder + .append_dir_all(format!("{name}-{version}"), crate_dir) + .unwrap(); + builder.finish().unwrap(); + } + bytes +} + +/// Assembles a local-registry source (see Cargo's source-replacement +/// docs) at `registry_dir`, containing one crate. Local-registry +/// index entries are sharded by name length: a 4+ character name +/// shards under its first two, then next two, characters. +fn write_local_registry(registry_dir: &Path, name: &str, version: &str) { + let build_dir = registry_dir + .join(".build") + .join(format!("{name}-{version}")); + write_crate_source(&build_dir, name, version); + let tarball = pack_crate_tarball(&build_dir, name, version); + + std::fs::write( + registry_dir.join(format!("{name}-{version}.crate")), + &tarball, + ) + .unwrap(); + + let cksum = Sha256::digest(&tarball) + .iter() + .map(|b| format!("{b:02x}")) + .collect::(); + let shard = registry_dir + .join("index") + .join(&name[0..2]) + .join(&name[2..4]); + std::fs::create_dir_all(&shard).unwrap(); + std::fs::write( + shard.join(name), + format!( + r#"{{"name":"{name}","vers":"{version}","deps":[],"cksum":"{cksum}","features":{{}},"yanked":false}}"# + ), + ) + .unwrap(); +} + +fn run_cargo(args: &[&str], cwd: &Path) -> std::process::Output { + Command::new("cargo") + .args(args) + .current_dir(cwd) + .output() + .expect("failed to run cargo") +} + +#[test] +fn qualified_spec_resolves_where_the_abbreviated_spec_is_ambiguous() { + let root = tempfile::tempdir().unwrap(); + let registry_dir = root.path().join("registry"); + let workspace_dir = root.path().join("workspace"); + + write_local_registry(®istry_dir, CRATE_NAME, CRATE_VERSION); + write_crate_source( + &workspace_dir.join("vendor-semver"), + CRATE_NAME, + CRATE_VERSION, + ); + + std::fs::create_dir_all(workspace_dir.join(".cargo")).unwrap(); + std::fs::write( + workspace_dir.join(".cargo/config.toml"), + format!( + "[source.local-vendor]\nlocal-registry = \"{}\"\n\n[source.crates-io]\nreplace-with = \"local-vendor\"\n", + registry_dir.display() + ), + ) + .unwrap(); + std::fs::create_dir_all(workspace_dir.join("src")).unwrap(); + std::fs::write(workspace_dir.join("src/main.rs"), "fn main() {}\n").unwrap(); + std::fs::write( + workspace_dir.join("Cargo.toml"), + format!( + "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n{CRATE_NAME} = \"{CRATE_VERSION}\"\n{CRATE_NAME}-path = {{ package = \"{CRATE_NAME}\", path = \"vendor-semver\" }}\n" + ), + ) + .unwrap(); + + let lock = run_cargo(&["generate-lockfile", "--offline"], &workspace_dir); + assert!( + lock.status.success(), + "generate-lockfile failed: {}", + String::from_utf8_lossy(&lock.stderr) + ); + + let packages = crate::lockfile::load(Path::new("Cargo.lock"), &workspace_dir).unwrap(); + let target_source = packages + .iter() + .find(|p| p.name == CRATE_NAME && p.is_registry) + .and_then(|p| p.source.as_deref()); + let is_ambiguous = packages + .iter() + .filter(|p| p.name == CRATE_NAME && p.version == CRATE_VERSION) + .count() + > 1; + let spec = build_package_spec(CRATE_NAME, target_source, is_ambiguous); + assert!( + spec.contains('#'), + "expected a source-qualified spec for a name/version collision, got {spec}" + ); + + // The abbreviated spec really is ambiguous in this fixture — + // otherwise the qualified spec above proves nothing. + let abbreviated = run_cargo( + &[ + "update", + "--offline", + "-p", + &format!("{CRATE_NAME}@{CRATE_VERSION}"), + "--precise", + CRATE_VERSION, + ], + &workspace_dir, + ); + assert!( + !abbreviated.status.success() + && String::from_utf8_lossy(&abbreviated.stderr).contains("ambiguous"), + "expected the abbreviated spec to be ambiguous in this fixture: {}", + String::from_utf8_lossy(&abbreviated.stderr) + ); + + let qualified = run_cargo( + &[ + "update", + "--offline", + "-p", + &format!("{spec}@{CRATE_VERSION}"), + "--precise", + CRATE_VERSION, + ], + &workspace_dir, + ); + assert!( + qualified.status.success(), + "expected the source-qualified spec to resolve without an ambiguous-specification error: {}", + String::from_utf8_lossy(&qualified.stderr) + ); +} diff --git a/src/suggest/tests/walk_tests.rs b/src/suggest/tests/walk_tests.rs new file mode 100644 index 0000000..4ec33d0 --- /dev/null +++ b/src/suggest/tests/walk_tests.rs @@ -0,0 +1,97 @@ +use super::*; + +#[test] +fn newest_accepted_when_every_constraint_matches() { + let candidates = vec![(v("1.3.0"), 5), (v("1.2.0"), 20)]; + let constraints = vec![constraint("^1.2")]; + + match walk(candidates, constraints) { + WalkResult::Suggest(version, age) => { + assert_eq!(version.to_string(), "1.3.0"); + assert_eq!(age, 5); + } + _ => panic!("expected Suggest"), + } +} + +#[test] +fn walk_continues_to_older_version_when_newest_is_rejected() { + let candidates = vec![(v("1.3.0"), 5), (v("1.2.0"), 20), (v("1.1.0"), 40)]; + // `~1.1` narrows to the 1.1.x line, so only the oldest candidate + // satisfies it — the walk must skip past the two newer ones. + let constraints = vec![constraint("~1.1")]; + match walk(candidates, constraints) { + WalkResult::Suggest(version, _) => assert_eq!(version.to_string(), "1.1.0"), + _ => panic!("expected Suggest"), + } +} + +#[test] +fn blocked_when_no_candidate_satisfies_every_constraint() { + let candidates = vec![(v("1.3.0"), 5), (v("1.2.0"), 20)]; + let constraints = vec![constraint("^2.0")]; + + match walk(candidates, constraints) { + WalkResult::Blocked { + newest_compliant, + blocker, + } => { + assert_eq!(newest_compliant.to_string(), "1.3.0"); + assert_eq!(blocker.blocker_name, "dep"); + assert_eq!(blocker.req.to_string(), "^2.0"); + } + _ => panic!("expected Blocked"), + } +} + +#[test] +// Pins that the blocker is the constraint rejecting every candidate. +// `<=1.3` comes first and rejects the newest candidate, but 1.2.0 +// satisfies it — only `>=1.4.5` makes every downgrade impossible. +fn blocker_is_the_constraint_that_rejects_every_candidate() { + let candidates = vec![(v("1.4.0"), 5), (v("1.2.0"), 20)]; + let constraints = vec![constraint("<=1.3"), constraint(">=1.4.5")]; + + match walk(candidates, constraints) { + WalkResult::Blocked { blocker, .. } => { + assert_eq!(blocker.req.to_string(), ">=1.4.5"); + } + _ => panic!("expected Blocked"), + } +} + +#[test] +// Pins the fallback: when no single constraint rejects every +// candidate, the block is a genuine combination, so we fall back to +// the first constraint that rejects the newest candidate. +fn blocker_falls_back_when_no_single_constraint_blocks_all_candidates() { + let candidates = vec![(v("1.4.0"), 5), (v("1.2.0"), 20)]; + let constraints = vec![constraint("<=1.3"), constraint(">=1.4")]; + + match walk(candidates, constraints) { + WalkResult::Blocked { blocker, .. } => { + assert_eq!(blocker.req.to_string(), "<=1.3"); + } + _ => panic!("expected Blocked"), + } +} + +#[test] +fn no_compliant_version_when_candidates_empty() { + match walk(vec![], vec![constraint("^1.0")]) { + WalkResult::NoCompliantVersion => {} + _ => panic!("expected NoCompliantVersion"), + } +} + +#[test] +fn no_constraints_picks_newest_by_age() { + let candidates = vec![(v("1.3.0"), 5), (v("1.2.0"), 20)]; + match walk(candidates, vec![]) { + WalkResult::Suggest(version, age) => { + assert_eq!(version.to_string(), "1.3.0"); + assert_eq!(age, 5); + } + _ => panic!("expected Suggest"), + } +} From a71ee1f273edaf5aacc950b9bd50fa7bd31e4774 Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Thu, 17 Sep 2026 00:30:29 -0400 Subject: [PATCH 25/34] Address comments --- README.md | 14 ++++- src/lockfile.rs | 39 ++++++++----- src/main.rs | 6 +- src/suggest/tests/end_to_end_tests.rs | 4 +- .../tests/source_collision_cargo_tests.rs | 4 +- tests/suggest_fix_cli.rs | 57 +++++++++++++++++++ 6 files changed, 103 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 50e8a65..eb27d8a 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,17 @@ At least one of `--min-age-days` or `--max-age-days` must be specified. `--suggest-fix` finds the newest older version that satisfies, on a best-effort basis, every dependency requirement it can verify from your lockfile and workspace manifests, and prints a -`cargo update` command for it. Some requirements can't be verified (for example, an optional -dependency behind a feature flag not enabled in your manifests) — such suggestions are annotated, -and Cargo may still reject them. +`cargo update` command for it. Candidates are limited to the locked version's existing compatible +zone (same major, or same minor when major is `0`, or same patch when both are `0`) — it does not +consider every eligible version across a major boundary. + +Requirement verification differs by source. For registry (crates.io) dependents, a requirement +behind an optional dependency or target that can't be confirmed active is annotated as unverified +rather than enforced, and Cargo may still reject that suggestion. For local/workspace manifests, +activation isn't tracked at all, so every declared requirement — including optional and +target-specific ones — is treated as mandatory; an inactive local optional or target-specific +requirement can therefore still block an otherwise valid downgrade, without any "unverified" +annotation. The tool does not build your project and does not guarantee Cargo will accept every suggested command. Run your tests after applying a suggestion. If no version fits, it reports the diff --git a/src/lockfile.rs b/src/lockfile.rs index fecd8fb..c979106 100644 --- a/src/lockfile.rs +++ b/src/lockfile.rs @@ -2,9 +2,9 @@ use anyhow::{Context, Result}; use std::path::{Path, PathBuf}; /// Resolves a possibly-relative lockfile path against `working_dir`, without -/// touching the filesystem. Shared by `load` (which then canonicalizes and -/// validates it) and by callers that just need the lockfile's directory. -pub fn resolve_path(path: &Path, working_dir: &Path) -> PathBuf { +/// touching the filesystem. Used by `load` before it canonicalizes and +/// validates the result. +fn resolve_path(path: &Path, working_dir: &Path) -> PathBuf { if path.is_absolute() { path.to_path_buf() } else { @@ -36,8 +36,18 @@ pub struct Package { pub dependencies: Vec, } +/// The validated canonical lockfile path together with its parsed packages. +/// Callers that need the lockfile's directory (e.g. to locate the manifest +/// beside it) should derive it from `path` rather than re-resolving the +/// caller-supplied path themselves, since `path` has already had symlinks +/// and `..` components resolved. +pub struct LoadedLockfile { + pub path: PathBuf, + pub packages: Vec, +} + /// Loads every package recorded in a lockfile, registry and non-registry -/// alike. +/// alike, together with the validated canonical lockfile path. /// /// `path` is the lockfile path as given by the caller (relative or /// absolute), resolved against `working_dir` if relative. Rejects anything @@ -47,7 +57,7 @@ pub struct Package { /// `cargo_lock` resolves each dependency edge to a concrete version itself /// (lockfiles may omit a dependency's version when only one instance of it /// exists), so every `PackageRef` here already carries one. -pub fn load(path: &Path, working_dir: &Path) -> Result> { +pub fn load(path: &Path, working_dir: &Path) -> Result { let resolved = resolve_path(path, working_dir); // Canonicalize to resolve symlinks and ".." components @@ -101,7 +111,10 @@ pub fn load(path: &Path, working_dir: &Path) -> Result> { }) .collect(); - Ok(packages) + Ok(LoadedLockfile { + path: canonical, + packages, + }) } #[cfg(test)] @@ -194,7 +207,7 @@ source = "registry+https://example.com/index" ); write_lockfile(dir.path(), &contents); - let packages = load(Path::new("Cargo.lock"), dir.path()).unwrap(); + let packages = load(Path::new("Cargo.lock"), dir.path()).unwrap().packages; assert_eq!(packages.len(), 3); let serde = packages.iter().find(|p| p.name == "serde").unwrap(); @@ -215,7 +228,7 @@ source = "registry+https://example.com/index" ); write_lockfile(dir.path(), &contents); - let packages = load(Path::new("Cargo.lock"), dir.path()).unwrap(); + let packages = load(Path::new("Cargo.lock"), dir.path()).unwrap().packages; let a = packages.iter().find(|p| p.name == "a").unwrap(); assert_eq!(a.dependencies.len(), 1); assert_eq!(a.dependencies[0].name, "b"); @@ -232,7 +245,7 @@ source = "registry+https://example.com/index" ); write_lockfile(dir.path(), &contents); - let packages = load(Path::new("Cargo.lock"), dir.path()).unwrap(); + let packages = load(Path::new("Cargo.lock"), dir.path()).unwrap().packages; let a = packages.iter().find(|p| p.name == "a").unwrap(); assert_eq!(a.dependencies.len(), 1); assert_eq!(a.dependencies[0].name, "b"); @@ -249,7 +262,7 @@ source = "registry+https://example.com/index" ); write_lockfile(dir.path(), &contents); - let packages = load(Path::new("Cargo.lock"), dir.path()).unwrap(); + let packages = load(Path::new("Cargo.lock"), dir.path()).unwrap().packages; let registry = packages.iter().find(|p| p.name == "serde").unwrap(); let git = packages.iter().find(|p| p.name == "serde-fork").unwrap(); @@ -270,7 +283,7 @@ source = "registry+https://example.com/index" ); write_lockfile(dir.path(), &contents); - let packages = load(Path::new("Cargo.lock"), dir.path()).unwrap(); + let packages = load(Path::new("Cargo.lock"), dir.path()).unwrap().packages; let a = packages.iter().find(|p| p.name == "a").unwrap(); let b = packages.iter().find(|p| p.name == "b").unwrap(); assert_eq!(a.dependencies[0].source, b.source); @@ -306,7 +319,7 @@ source = "registry+https://example.com/index" ); write_lockfile(dir.path(), &contents); - let packages = load(Path::new("Cargo.lock"), dir.path()).unwrap(); + let packages = load(Path::new("Cargo.lock"), dir.path()).unwrap().packages; let root = packages.iter().find(|p| p.name == "root").unwrap(); let target = |name: &str| packages.iter().find(|p| p.name == name).unwrap(); let edge = |name: &str| root.dependencies.iter().find(|d| d.name == name).unwrap(); @@ -340,7 +353,7 @@ source = "registry+https://example.com/index" ); write_lockfile(dir.path(), &contents); - let packages = load(Path::new("Cargo.lock"), dir.path()).unwrap(); + let packages = load(Path::new("Cargo.lock"), dir.path()).unwrap().packages; let root = packages.iter().find(|p| p.name == "root").unwrap(); let edge = root .dependencies diff --git a/src/main.rs b/src/main.rs index 70fcbcf..6999a41 100644 --- a/src/main.rs +++ b/src/main.rs @@ -125,7 +125,8 @@ fn run(cli: Cli) -> Result { let working_dir = std::env::current_dir().context("Failed to get current directory")?; // Parse lockfile - let packages = lockfile::load(&cli.cargo_lock, &working_dir)?; + let loaded_lockfile = lockfile::load(&cli.cargo_lock, &working_dir)?; + let packages = loaded_lockfile.packages; // Build API client let mut client = api::CratesIoClient::new( @@ -163,8 +164,7 @@ fn run(cli: Cli) -> Result { // Generate suggestions if requested if let Some(min_age) = suggest_min_age { - let cargo_lock_path = lockfile::resolve_path(&cli.cargo_lock, &working_dir); - let lockfile_dir = cargo_lock_path.parent().unwrap_or(&working_dir); + let lockfile_dir = loaded_lockfile.path.parent().unwrap_or(&working_dir); let (direct_requirements, manifest_warnings) = manifest::load_direct_requirements(lockfile_dir); diff --git a/src/suggest/tests/end_to_end_tests.rs b/src/suggest/tests/end_to_end_tests.rs index 61bcc12..1111cf9 100644 --- a/src/suggest/tests/end_to_end_tests.rs +++ b/src/suggest/tests/end_to_end_tests.rs @@ -46,7 +46,9 @@ fn too_new(package: &str, locked_version: &str) -> Violation { #[test] fn covers_a_suggestion_two_blocked_kinds_and_no_compliant_version() { let dir = fixture_dir(); - let packages = crate::lockfile::load(Path::new("Cargo.lock"), &dir).unwrap(); + let packages = crate::lockfile::load(Path::new("Cargo.lock"), &dir) + .unwrap() + .packages; let (direct_requirements, warnings) = load_direct_requirements(&dir); assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}"); diff --git a/src/suggest/tests/source_collision_cargo_tests.rs b/src/suggest/tests/source_collision_cargo_tests.rs index 3d5a3b8..3b1c216 100644 --- a/src/suggest/tests/source_collision_cargo_tests.rs +++ b/src/suggest/tests/source_collision_cargo_tests.rs @@ -119,7 +119,9 @@ fn qualified_spec_resolves_where_the_abbreviated_spec_is_ambiguous() { String::from_utf8_lossy(&lock.stderr) ); - let packages = crate::lockfile::load(Path::new("Cargo.lock"), &workspace_dir).unwrap(); + let packages = crate::lockfile::load(Path::new("Cargo.lock"), &workspace_dir) + .unwrap() + .packages; let target_source = packages .iter() .find(|p| p.name == CRATE_NAME && p.is_registry) diff --git a/tests/suggest_fix_cli.rs b/tests/suggest_fix_cli.rs index e6a9834..0ccbb80 100644 --- a/tests/suggest_fix_cli.rs +++ b/tests/suggest_fix_cli.rs @@ -269,6 +269,63 @@ checksum = "0000000000000000000000000000000000000000000000000000000000000000" assert!(!stdout.contains("parent, parent"), "stdout was:\n{stdout}"); } +#[cfg(unix)] +#[test] +fn suggest_fix_uses_manifest_beside_the_symlinks_real_lockfile() { + // `decoy/Cargo.lock` is a symlink to `real/Cargo.lock`. Manifest discovery + // must follow the canonical path, or the decoy's permissive manifest + // (which doesn't declare `alpha`) would let the downgrade through. + let project = tempdir().unwrap(); + let real_dir = project.path().join("real"); + let decoy_dir = project.path().join("decoy"); + fs::create_dir_all(&real_dir).unwrap(); + fs::create_dir_all(&decoy_dir).unwrap(); + + fs::write( + real_dir.join("Cargo.toml"), + "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nalpha = \"^1.5\"\n", + ) + .unwrap(); + fs::write( + real_dir.join("Cargo.lock"), + "version = 4\n\n[[package]]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\n \"alpha\",\n]\n\n[[package]]\nname = \"alpha\"\nversion = \"1.5.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"0000000000000000000000000000000000000000000000000000000000000000\"\n", + ) + .unwrap(); + + fs::write( + decoy_dir.join("Cargo.toml"), + "[package]\nname = \"decoy\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ) + .unwrap(); + std::os::unix::fs::symlink(real_dir.join("Cargo.lock"), decoy_dir.join("Cargo.lock")).unwrap(); + + let cache = write_cache( + project.path(), + &[("alpha", "1.5.0", 2, vec![("1.5.0", 2), ("1.4.0", 80)])], + ); + let output = run_oxidate( + project.path(), + &[ + "decoy/Cargo.lock", + "--min-age-days", + "30", + "--suggest-fix", + "--cache-path", + cache.to_str().unwrap(), + ], + ); + assert_eq!(output.status.code(), Some(1)); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!( + !stdout.contains("cargo update -p alpha@1.5.0 --precise 1.4.0"), + "forbidden downgrade command was suggested; stdout was:\n{stdout}" + ); + assert!( + stdout.contains("alpha 1.5.0") && stdout.contains("Cargo.toml") && stdout.contains("^1.5"), + "expected the real manifest's restriction on alpha to be reported; stdout was:\n{stdout}" + ); +} + #[test] fn ordinary_and_invalid_cli_runs_keep_their_exit_contract() { let project = tempdir().unwrap(); From 986930d73e9e0a580d5cd0fbdfd98648be792c04 Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Thu, 17 Sep 2026 00:41:53 -0400 Subject: [PATCH 26/34] Address comments --- src/manifest.rs | 46 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/src/manifest.rs b/src/manifest.rs index 1964196..95f8a6a 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -50,7 +50,7 @@ pub fn load_direct_requirements(lockfile_dir: &Path) -> (Vec, } let mut seen = HashSet::new(); - seen.insert(canonical_or(&root_path)); + seen.insert(canonical_or(lockfile_dir)); // `bool` marks whether the manifest is a workspace member (root or a // `workspace.members` entry) as opposed to a followed path dependency. @@ -684,6 +684,50 @@ foo = "=1.9.0" assert!(!reqs.iter().any(|r| r.crate_name == "foo")); } + #[test] + fn member_path_dependency_on_root_does_not_duplicate_root_requirements() { + // "member" declares a path dependency back to the workspace root + // (e.g. `app = { path = ".." }`). The root is already collected + // as a workspace member, so following that path dependency must not + // collect it a second time. + let dir = tempdir().unwrap(); + write( + dir.path(), + "Cargo.toml", + r#" +[workspace] +members = ["member"] + +[package] +name = "root" +version = "0.1.0" + +[dependencies] +serde = "1.0" +"#, + ); + write( + dir.path(), + "member/Cargo.toml", + r#" +[package] +name = "member" +version = "0.1.0" + +[dependencies] +app = { path = ".." } +"#, + ); + + let (reqs, warnings) = load_direct_requirements(dir.path()); + assert!(warnings.is_empty()); + assert_eq!( + reqs.iter().filter(|r| r.crate_name == "serde").count(), + 1, + "the root manifest's requirements must be collected exactly once" + ); + } + #[test] fn dependency_table_records_registry_identity() { let dir = tempdir().unwrap(); From 92f6ea1c7667b3f9b75dfff1d76f72082bd7f776 Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Thu, 17 Sep 2026 01:03:29 -0400 Subject: [PATCH 27/34] fix(suggest): address review findings on constraint handling and reporting - Treat target-specific index dependencies as mandatory, since Cargo resolves every target table regardless of host platform. - Ignore alternate-registry index declarations when collecting crates.io constraints. - Warn instead of silently skipping violations whose locked version fails to parse. - Only load manifest requirements when there is a too-new violation, avoiding a spurious missing-Cargo.toml warning on clean runs. - Reword the empty-suggestions message to say packages could not be checked rather than claiming no compliant versions exist. --- src/api.rs | 3 + src/main.rs | 7 +- src/report.rs | 4 +- src/suggest.rs | 15 ++- .../tests/generate_suggestions_tests.rs | 92 +++++++++++++++++++ tests/suggest_fix_cli.rs | 19 ++++ 6 files changed, 134 insertions(+), 6 deletions(-) diff --git a/src/api.rs b/src/api.rs index a715194..7a2f397 100644 --- a/src/api.rs +++ b/src/api.rs @@ -56,6 +56,9 @@ pub struct IndexDep { /// The original crate name, present when `name` is a rename alias. #[serde(default)] pub package: Option, + /// The alternate registry this dependency is resolved from, if any. + #[serde(default)] + pub registry: Option, } /// Computes the sparse-index path fragment for a crate name, per the rules diff --git a/src/main.rs b/src/main.rs index 6999a41..74b7530 100644 --- a/src/main.rs +++ b/src/main.rs @@ -163,7 +163,12 @@ fn run(cli: Cli) -> Result { report::print_report(&violations); // Generate suggestions if requested - if let Some(min_age) = suggest_min_age { + let has_too_new = violations + .iter() + .any(|v| matches!(v.kind, report::ViolationKind::TooNew(_))); + if let Some(min_age) = suggest_min_age + && has_too_new + { let lockfile_dir = loaded_lockfile.path.parent().unwrap_or(&working_dir); let (direct_requirements, manifest_warnings) = diff --git a/src/report.rs b/src/report.rs index ba7067f..857fa81 100644 --- a/src/report.rs +++ b/src/report.rs @@ -95,8 +95,8 @@ pub fn print_report(violations: &[Violation]) { pub fn print_suggestions(outcomes: &[Outcome]) { if outcomes.is_empty() { - println!("\n⚠️ No compliant versions found for any \"too new\" violations."); - println!(" Consider adding these packages to --exempt if they are trusted.\n"); + println!("\n⚠️ Could not check \"too new\" violations for compliant versions."); + println!(" The registry may have been unreachable, or their versions unparsable.\n"); return; } diff --git a/src/suggest.rs b/src/suggest.rs index 7bee579..c4d287f 100644 --- a/src/suggest.rs +++ b/src/suggest.rs @@ -189,8 +189,10 @@ fn eligible_locked_versions(dependent: &Package, name: &str, source: Option<&str /// A single declaration's parsed requirement plus the evidence the shared /// attribution policy needs: whether it is definitely active (mandatory) or -/// merely possible (optional/target-specific/aliased). Local declarations are -/// always mandatory, since their representation drops that distinction. +/// merely possible (optional/aliased). Target-specific declarations are +/// always mandatory too: Cargo's resolver evaluates every target table +/// regardless of the host platform. Local declarations are always mandatory, +/// since their representation drops that distinction. struct NormalizedDeclaration { req: VersionReq, mandatory: bool, @@ -379,13 +381,16 @@ fn registry_dependent_constraints( if dep.kind.as_deref() == Some("dev") { continue; } + if dep.registry.is_some() { + continue; + } let real_name = dep.package.as_deref().unwrap_or(&dep.name); if real_name != name { continue; } match VersionReq::parse(&dep.req) { Ok(req) if Version::parse(locked_version).is_ok_and(|v| req.matches(&v)) => { - let mandatory = dep.target.is_none() && dep.optional != Some(true); + let mandatory = dep.optional != Some(true); declarations.push(NormalizedDeclaration { req, mandatory }); } Ok(_) => {} @@ -457,6 +462,10 @@ pub fn generate_suggestions( eprintln!(" [{}/{}] {}", i + 1, too_new.len(), violation.package); let Ok(locked) = Version::parse(&violation.version) else { + eprintln!( + "\n Warning: failed to parse locked version for {}: {}", + violation.package, violation.version + ); continue; }; diff --git a/src/suggest/tests/generate_suggestions_tests.rs b/src/suggest/tests/generate_suggestions_tests.rs index 4ace0bd..7952566 100644 --- a/src/suggest/tests/generate_suggestions_tests.rs +++ b/src/suggest/tests/generate_suggestions_tests.rs @@ -490,6 +490,26 @@ fn only_too_new_violations_are_fetched() { assert_eq!(outcomes.len(), 1); } +#[test] +fn an_unparsable_locked_version_does_not_abort_the_others() { + let transport = FakeTransport::default(); + // "serde" has no scripted response: if its versions were fetched, + // the transport would panic — its unparsable locked version must + // be skipped before that. + transport.ok( + "syn", + &versions_body(&[("1.0.0", 50, false), ("1.1.0", 5, false)], now()), + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("serde", "not-a-version"), too_new("syn", "1.1.0")]; + let packages = vec![pkg("serde", "not-a-version", &[]), pkg("syn", "1.1.0", &[])]; + let outcomes = suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); + + assert_eq!(outcomes.len(), 1); + assert!(matches!(&outcomes[0], Outcome::Suggest { package, .. } if package == "syn")); +} + #[test] fn a_failed_fetch_does_not_abort_the_others() { let transport = FakeTransport::default(); @@ -814,6 +834,39 @@ fn renamed_dependency_is_matched_by_its_real_name() { assert!(matches!(outcomes[0], Outcome::Blocked { .. })); } +#[test] +fn alternate_registry_index_declaration_is_not_enforced() { + // "app" declares a normal `foo = "^1"` from crates.io and a + // same-crate alias `foo_alt = { package = "foo", version = "^1.3", + // registry = "alt" }` from an alternate registry. The alternate + // registry declaration cannot be resolved against the locked + // crates.io package, so only `^1` may be enforced. + let transport = FakeTransport::default(); + transport.ok( + "foo", + &versions_body(&[("1.5.0", 5, false), ("1.2.0", 50, false)], now()), + ); + transport.index_ok( + "app", + r#"{"vers":"1.0.0","deps":[{"name":"foo","req":"^1"},{"name":"foo_alt","package":"foo","req":"^1.3","registry":"alt"}]}"#, + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("foo", "1.5.0")]; + let packages = vec![ + pkg("foo", "1.5.0", &[]), + pkg("app", "1.0.0", &[("foo", "1.5.0")]), + ]; + let outcomes = suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); + + match &outcomes[0] { + Outcome::Suggest { + suggested_version, .. + } => assert_eq!(suggested_version, "1.2.0"), + _ => panic!("expected foo to be Suggest, with the alternate registry declaration ignored"), + } +} + #[test] fn ambiguous_optional_declaration_does_not_block_the_downgrade() { // "app" declares both a normal `serde = "^1"` and a disabled, @@ -968,6 +1021,45 @@ fn mandatory_requirement_still_blocks_alongside_uncertain_declaration() { } } +#[test] +fn target_specific_requirement_is_always_enforced() { + // "app" declares an unconditional `serde = "^1"` and a stricter + // `serde = "^1.4"` under a target-specific table. Cargo's resolver + // evaluates every target table regardless of the host platform, so + // the target-specific requirement is just as mandatory as the + // unconditional one and must be enforced alongside it. + let transport = FakeTransport::default(); + transport.ok( + "serde", + &versions_body(&[("1.5.0", 5, false), ("1.3.0", 50, false)], now()), + ); + transport.index_ok( + "app", + r#"{"vers":"1.0.0","deps":[{"name":"serde","req":"^1"},{"name":"serde","req":"^1.4","target":"cfg(unix)"}]}"#, + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("serde", "1.5.0")]; + let packages = vec![ + pkg("serde", "1.5.0", &[]), + pkg("app", "1.0.0", &[("serde", "1.5.0")]), + ]; + let outcomes = suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); + + match &outcomes[0] { + Outcome::Blocked { + newest_compliant, + blocker, + .. + } => { + assert_eq!(newest_compliant, "1.3.0"); + assert_eq!(blocker.name, "app"); + assert_eq!(blocker.req, "^1.4"); + } + _ => panic!("expected serde to be Blocked by app's target-specific requirement"), + } +} + #[test] fn identical_requirement_repeated_across_targets_still_blocks() { // "app" declares the same `serde = "^1.5"` requirement under two diff --git a/tests/suggest_fix_cli.rs b/tests/suggest_fix_cli.rs index 0ccbb80..e85b7a1 100644 --- a/tests/suggest_fix_cli.rs +++ b/tests/suggest_fix_cli.rs @@ -352,6 +352,25 @@ fn ordinary_and_invalid_cli_runs_keep_their_exit_contract() { assert_eq!(invalid.status.code(), Some(2)); } +#[test] +fn suggest_fix_with_no_too_new_violations_does_not_warn_about_a_missing_manifest() { + // No Cargo.toml beside the lockfile, and no registry packages to + // check, so there is nothing to suggest a fix for. `--suggest-fix` + // must not still load (and warn about) direct requirements when + // there are no "too new" violations to act on. + let project = tempdir().unwrap(); + fs::write( + project.path().join("Cargo.lock"), + "version = 4\n\n[[package]]\nname = \"clean\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let output = run_oxidate(project.path(), &["--suggest-fix", "--min-age-days", "30"]); + assert!(output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(!stderr.contains("No Cargo.toml"), "stderr was:\n{stderr}"); +} + fn write_crate_source(dir: &Path, name: &str, version: &str) { fs::create_dir_all(dir.join("src")).unwrap(); fs::write( From db72084acc1f849e7feaf6c89d63c1f5d7c4c82d Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Thu, 17 Sep 2026 01:06:22 -0400 Subject: [PATCH 28/34] fix(suggest): order candidates by semver precedence instead of publish date A later-published backport (e.g. 1.3.9) could outrank a higher compliant version (e.g. 1.4.0) and be suggested or reported as "newest compliant". Sort by semver precedence descending, using publish date only to break ties. --- src/suggest.rs | 9 ++- src/suggest/tests/filter_candidates_tests.rs | 35 +++++++++- .../tests/generate_suggestions_tests.rs | 66 +++++++++++++++++++ 3 files changed, 105 insertions(+), 5 deletions(-) diff --git a/src/suggest.rs b/src/suggest.rs index c4d287f..f2b9ab9 100644 --- a/src/suggest.rs +++ b/src/suggest.rs @@ -60,7 +60,8 @@ fn same_compatible_zone(a: &Version, b: &Version) -> bool { } /// Keeps old-enough, non-yanked compatible versions older than `locked`, -/// newest first. Excludes prereleases unless allowed or `locked` is one. +/// ordered by semver precedence descending (publish date breaks ties). +/// Excludes prereleases unless allowed or `locked` is one. fn filter_candidates( versions: &[CrateVersionInfo], locked: &Version, @@ -96,7 +97,11 @@ fn filter_candidates( }) .collect(); - candidates.sort_by_key(|(_, created_at, _)| std::cmp::Reverse(*created_at)); + candidates.sort_by(|(a_ver, a_created, _), (b_ver, b_created, _)| { + b_ver + .cmp_precedence(a_ver) + .then_with(|| b_created.cmp(a_created)) + }); candidates.into_iter().map(|(v, _, age)| (v, age)).collect() } diff --git a/src/suggest/tests/filter_candidates_tests.rs b/src/suggest/tests/filter_candidates_tests.rs index 895f6ef..022c597 100644 --- a/src/suggest/tests/filter_candidates_tests.rs +++ b/src/suggest/tests/filter_candidates_tests.rs @@ -12,12 +12,12 @@ fn excludes_too_new_yanked_and_out_of_range() { let result = filter_candidates(&versions, &v("1.5.0"), 30, now(), false); let nums: Vec = result.iter().map(|(ver, _)| ver.to_string()).collect(); - // Newest-first by publish date among the two survivors. + // Newest-first by semver precedence among the two survivors. assert_eq!(nums, vec!["1.2.0".to_string(), "1.0.0".to_string()]); } #[test] -fn sorted_newest_first_by_publish_date() { +fn sorted_by_semver_precedence_descending() { let versions = vec![ make_version("1.0.0", 100, false), make_version("1.1.0", 200, false), @@ -26,7 +26,36 @@ fn sorted_newest_first_by_publish_date() { let result = filter_candidates(&versions, &v("1.5.0"), 30, now(), false); let nums: Vec = result.iter().map(|(ver, _)| ver.to_string()).collect(); - assert_eq!(nums, vec!["1.2.0", "1.0.0", "1.1.0"]); + assert_eq!(nums, vec!["1.2.0", "1.1.0", "1.0.0"]); +} + +#[test] +fn higher_semver_precedence_wins_over_more_recent_publish_date() { + // 1.4.0 outranks 1.3.9 by semver even though 1.3.9 was published + // more recently: ordering by precedence must win over publish date. + let versions = vec![ + make_version("1.4.0", 100, false), + make_version("1.3.9", 40, false), + ]; + + let result = filter_candidates(&versions, &v("1.5.0"), 30, now(), false); + let nums: Vec = result.iter().map(|(ver, _)| ver.to_string()).collect(); + assert_eq!(nums, vec!["1.4.0", "1.3.9"]); +} + +#[test] +fn publish_date_breaks_ties_in_equal_semver_precedence() { + // Build metadata doesn't affect precedence, so these two versions tie + // under `cmp_precedence`; publish date must decide the order, with + // the more recently published one (build.2, 50 days ago) first. + let versions = vec![ + make_version("1.3.0+build.1", 100, false), + make_version("1.3.0+build.2", 50, false), + ]; + + let result = filter_candidates(&versions, &v("1.5.0"), 30, now(), false); + let nums: Vec = result.iter().map(|(ver, _)| ver.to_string()).collect(); + assert_eq!(nums, vec!["1.3.0+build.2", "1.3.0+build.1"]); } #[test] diff --git a/src/suggest/tests/generate_suggestions_tests.rs b/src/suggest/tests/generate_suggestions_tests.rs index 7952566..0158470 100644 --- a/src/suggest/tests/generate_suggestions_tests.rs +++ b/src/suggest/tests/generate_suggestions_tests.rs @@ -596,6 +596,72 @@ fn transitive_dependent_requirement_blocks_the_newest_candidate() { } } +#[test] +fn suggests_the_semver_highest_compliant_version_over_a_later_backport() { + // 1.4.0 outranks 1.3.9 by semver even though 1.3.9 was published + // more recently (40 days ago vs. 100), so it must be the suggestion. + let transport = FakeTransport::default(); + transport.ok( + "serde", + &versions_body(&[("1.4.0", 100, false), ("1.3.9", 40, false)], now()), + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("serde", "1.5.0")]; + let packages = vec![pkg("serde", "1.5.0", &[])]; + let outcomes = suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); + + match &outcomes[0] { + Outcome::Suggest { + suggested_version, + suggested_age_days, + .. + } => { + assert_eq!(suggested_version, "1.4.0"); + assert_eq!(*suggested_age_days, 100); + } + _ => panic!("expected Suggest"), + } +} + +#[test] +fn blocked_names_the_semver_highest_compliant_version_as_newest_compliant() { + // 1.4.0 and 1.3.9 both satisfy age, but the manifest's `^1.4.1` + // requirement rejects both. "newest_compliant" in the Blocked + // outcome must be the semver-highest one, 1.4.0, not the more + // recently published 1.3.9. + let transport = FakeTransport::default(); + transport.ok( + "serde", + &versions_body(&[("1.4.0", 100, false), ("1.3.9", 40, false)], now()), + ); + transport.index_ok( + "app", + r#"{"vers":"1.0.0","deps":[{"name":"serde","req":"^1.4.1"}]}"#, + ); + let mut client = fast_client(transport); + + let violations = vec![too_new("serde", "1.5.0")]; + let packages = vec![ + pkg("serde", "1.5.0", &[]), + pkg("app", "1.0.0", &[("serde", "1.5.0")]), + ]; + let outcomes = suggestions(&mut client, &violations, &packages, &[], Path::new("/work")); + + match &outcomes[0] { + Outcome::Blocked { + newest_compliant, + blocker, + .. + } => { + assert_eq!(newest_compliant, "1.4.0"); + assert_eq!(blocker.name, "app"); + assert_eq!(blocker.req, "^1.4.1"); + } + _ => panic!("expected Blocked"), + } +} + #[test] fn same_name_version_collision_across_sources_does_not_leak_dependents() { // Two packages both named "serde" locked at 1.5.0: one from From 2a17b2ba52c6d7ff92adfd52647343f3577c3226 Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Thu, 17 Sep 2026 01:19:42 -0400 Subject: [PATCH 29/34] fix(suggest): refetch stale index records missing the locked dependent version A cached index record list that predates the locked dependent version caused the lookup to miss, marking the dependent unverified and dropping its requirement. Treat such a cache entry as a miss and refetch. Also align the README with target-specific registry requirements now being enforced rather than annotated as unverified. --- README.md | 7 +-- src/api.rs | 50 +++++++++++++++-- src/suggest.rs | 2 +- .../tests/generate_suggestions_tests.rs | 53 +++++++++++++++++++ 4 files changed, 103 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index eb27d8a..31adb5e 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,10 @@ dependency requirement it can verify from your lockfile and workspace manifests, zone (same major, or same minor when major is `0`, or same patch when both are `0`) — it does not consider every eligible version across a major boundary. -Requirement verification differs by source. For registry (crates.io) dependents, a requirement -behind an optional dependency or target that can't be confirmed active is annotated as unverified -rather than enforced, and Cargo may still reject that suggestion. For local/workspace manifests, +Requirement verification differs by source. For registry (crates.io) dependents, only a +requirement behind an optional dependency that can't be confirmed active is annotated as +unverified rather than enforced; target-specific requirements are always enforced, and Cargo may +still reject that suggestion. For local/workspace manifests, activation isn't tracked at all, so every declared requirement — including optional and target-specific ones — is treated as mandatory; an inactive local optional or target-specific requirement can therefore still block an otherwise valid downgrade, without any "unverified" diff --git a/src/api.rs b/src/api.rs index 7a2f397..bfc9c9c 100644 --- a/src/api.rs +++ b/src/api.rs @@ -389,13 +389,26 @@ impl CratesIoClient { /// that version's own dependency requirements. An unknown crate yields /// an empty vector rather than an error. /// + /// A non-empty cached record list that doesn't include + /// `needed_version` (e.g. a dependent published after the cache entry + /// was written) is treated as a cache miss: the index is refetched so + /// that version isn't silently missed. An empty cached list (an + /// unknown crate) is still treated as a hit, since refetching it + /// can't produce the missing version either. + /// /// Unlike the other two fetches, this one is not subject to the /// crates.io API's inter-request pacing: the sparse index is a static /// endpoint outside that rate limit. - pub fn fetch_index_record(&mut self, name: &str) -> Result, FetchError> { + pub fn fetch_index_record( + &mut self, + name: &str, + needed_version: &str, + ) -> Result, FetchError> { let max_age = ChronoDuration::hours(self.cache_max_age_hours as i64); - if let Some(records) = self.cache.get_index_records(name, max_age) { + if let Some(records) = self.cache.get_index_records(name, max_age) + && (records.is_empty() || records.iter().any(|r| r.vers == needed_version)) + { return Ok(records); } @@ -725,7 +738,7 @@ mod tests { ); let mut client = fast_client(transport); - let records = client.fetch_index_record("serde").unwrap(); + let records = client.fetch_index_record("serde", "1.0.0").unwrap(); assert_eq!(records.len(), 2); assert_eq!(records[0].vers, "1.0.0"); @@ -748,7 +761,9 @@ mod tests { transport.push(&url, ScriptedResponse::Http(404, String::new())); let mut client = fast_client(transport); - let records = client.fetch_index_record("does-not-exist").unwrap(); + let records = client + .fetch_index_record("does-not-exist", "1.0.0") + .unwrap(); assert!(records.is_empty()); } @@ -758,8 +773,33 @@ mod tests { let mut client = fast_client(transport); client.cache.set_index_records("serde", vec![]); - let records = client.fetch_index_record("serde").unwrap(); + let records = client.fetch_index_record("serde", "1.0.0").unwrap(); assert!(records.is_empty()); assert_eq!(client.transport.call_count(), 0); } + + #[test] + fn fetch_index_record_refetches_when_cached_records_miss_needed_version() { + let url = index_url("serde"); + let transport = FakeTransport::new(); + transport.push( + &url, + ScriptedResponse::Http(200, r#"{"vers":"1.0.1","yanked":false}"#.to_string()), + ); + + let mut client = fast_client(transport); + client.cache.set_index_records( + "serde", + vec![IndexRecord { + vers: "1.0.0".to_string(), + yanked: false, + deps: vec![], + }], + ); + + let records = client.fetch_index_record("serde", "1.0.1").unwrap(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].vers, "1.0.1"); + assert_eq!(client.transport.call_count(), 1); + } } diff --git a/src/suggest.rs b/src/suggest.rs index f2b9ab9..943f3fd 100644 --- a/src/suggest.rs +++ b/src/suggest.rs @@ -373,7 +373,7 @@ fn registry_dependent_constraints( locked_version: &str, source: Option<&str>, ) -> RegistryConstraints { - let Ok(records) = client.fetch_index_record(&dependent.name) else { + let Ok(records) = client.fetch_index_record(&dependent.name, &dependent.version) else { return RegistryConstraints::unreadable(); }; let Some(record) = records.iter().find(|r| r.vers == dependent.version) else { diff --git a/src/suggest/tests/generate_suggestions_tests.rs b/src/suggest/tests/generate_suggestions_tests.rs index 0158470..a53aac1 100644 --- a/src/suggest/tests/generate_suggestions_tests.rs +++ b/src/suggest/tests/generate_suggestions_tests.rs @@ -1512,3 +1512,56 @@ fn manifest_constraint_is_scoped_to_the_declaring_dependent() { _ => panic!("expected clap to be Suggest: member_b's ^3 must not apply"), } } + +#[test] +fn stale_cached_index_records_missing_dependent_version_are_refetched() { + // The cache holds "app"'s index records as of an earlier, older + // release ("1.0.0"). The lockfile has since moved to "1.0.1", + // which the cache entry (still within its max age) doesn't list. + // Looking up "1.0.1" must fall back to a live fetch rather than + // silently treating the requirement as unreadable. + let dir = tempfile::tempdir().unwrap(); + let cache_path = dir.path().join("cache.json"); + let mut cache = crate::cache::ResponseCache::load(Some(&cache_path)); + cache.set_index_records( + "app", + vec![crate::api::IndexRecord { + vers: "1.0.0".to_string(), + yanked: false, + deps: vec![], + }], + ); + cache.save().unwrap(); + + let transport = FakeTransport::default(); + transport.index_ok( + "app", + r#"{"vers":"1.0.1","deps":[{"name":"foo","req":"^1.5"}]}"#, + ); + let mut client = CratesIoClient::with_transport( + transport, + Some(&cache_path), + 24, + RetryPolicy { + retry_count: NonZeroU32::new(1).unwrap(), + retry_delay: Duration::from_millis(0), + pacing_delay: Duration::from_millis(0), + }, + ); + + let packages = vec![pkg("app", "1.0.1", &[("foo", "1.5.0")])]; + let index = build_indexes(&packages).0; + let gathered = gather_constraints( + &mut client, + &index, + &[], + Path::new("/work"), + "foo", + "1.5.0", + Some(CRATES_IO_SOURCE), + ); + + assert!(gathered.unverified_dependents.is_empty()); + assert_eq!(gathered.constraints.len(), 1); + assert!(!gathered.constraints[0].req.matches(&v("1.4.0"))); +} From 0c67af568a883a6b9ee4eabf12f3c2bd6d680b8a Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Thu, 17 Sep 2026 01:26:31 -0400 Subject: [PATCH 30/34] style(tests): move test doc comments inside function bodies Match the convention used elsewhere in this file rather than placing the comment between #[test] and the function signature. --- src/suggest/tests/walk_tests.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/suggest/tests/walk_tests.rs b/src/suggest/tests/walk_tests.rs index 4ec33d0..0a4396a 100644 --- a/src/suggest/tests/walk_tests.rs +++ b/src/suggest/tests/walk_tests.rs @@ -45,10 +45,9 @@ fn blocked_when_no_candidate_satisfies_every_constraint() { } #[test] -// Pins that the blocker is the constraint rejecting every candidate. -// `<=1.3` comes first and rejects the newest candidate, but 1.2.0 -// satisfies it — only `>=1.4.5` makes every downgrade impossible. fn blocker_is_the_constraint_that_rejects_every_candidate() { + // `<=1.3` comes first and rejects the newest candidate, but 1.2.0 + // satisfies it — only `>=1.4.5` makes every downgrade impossible. let candidates = vec![(v("1.4.0"), 5), (v("1.2.0"), 20)]; let constraints = vec![constraint("<=1.3"), constraint(">=1.4.5")]; @@ -61,10 +60,10 @@ fn blocker_is_the_constraint_that_rejects_every_candidate() { } #[test] -// Pins the fallback: when no single constraint rejects every -// candidate, the block is a genuine combination, so we fall back to -// the first constraint that rejects the newest candidate. fn blocker_falls_back_when_no_single_constraint_blocks_all_candidates() { + // When no single constraint rejects every candidate, the block is + // a genuine combination, so we fall back to the first constraint + // that rejects the newest candidate. let candidates = vec![(v("1.4.0"), 5), (v("1.2.0"), 20)]; let constraints = vec![constraint("<=1.3"), constraint(">=1.4")]; From f1b5becceef75ed059a543cdbfcf507a131a03f4 Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Fri, 18 Sep 2026 15:52:01 -0400 Subject: [PATCH 31/34] fix(api): cache empty sparse-index responses Cache every successful sparse-index response, including empty results from a 404 or whitespace-only body, so they are reused until normal expiry instead of being refetched on every lookup. --- src/api.rs | 130 ++++++++++++++++++++++++++++++++++++++++++++++++--- src/cache.rs | 17 +++++++ 2 files changed, 141 insertions(+), 6 deletions(-) diff --git a/src/api.rs b/src/api.rs index bfc9c9c..ce7e805 100644 --- a/src/api.rs +++ b/src/api.rs @@ -392,9 +392,8 @@ impl CratesIoClient { /// A non-empty cached record list that doesn't include /// `needed_version` (e.g. a dependent published after the cache entry /// was written) is treated as a cache miss: the index is refetched so - /// that version isn't silently missed. An empty cached list (an - /// unknown crate) is still treated as a hit, since refetching it - /// can't produce the missing version either. + /// that version isn't silently missed. An empty cached list is reused + /// until the configured cache expiry. /// /// Unlike the other two fetches, this one is not subject to the /// crates.io API's inter-request pacing: the sparse index is a static @@ -414,9 +413,7 @@ impl CratesIoClient { self.with_retry(|client| { let result = client.fetch_index_record_uncached(name)?; - if !result.is_empty() { - client.cache.set_index_records(name, result.clone()); - } + client.cache.set_index_records(name, result.clone()); Ok(result) }) } @@ -498,6 +495,7 @@ mod tests { use super::*; use std::num::NonZeroU32; use std::time::Instant; + use tempfile::tempdir; fn version_url(name: &str, version: &str) -> String { format!("https://crates.io/api/v1/crates/{name}/{version}") @@ -767,6 +765,126 @@ mod tests { assert!(records.is_empty()); } + #[test] + fn empty_sparse_index_result_is_cached_within_one_client() { + let url = index_url("does-not-exist"); + let transport = FakeTransport::new(); + transport.push(&url, ScriptedResponse::Http(404, String::new())); + + let mut client = fast_client(transport); + assert!( + client + .fetch_index_record("does-not-exist", "1.0.0") + .unwrap() + .is_empty() + ); + assert!( + client + .fetch_index_record("does-not-exist", "2.0.0") + .unwrap() + .is_empty() + ); + assert_eq!(client.transport.call_count(), 1); + } + + #[test] + fn blank_sparse_index_response_is_cached_within_one_client() { + let url = index_url("empty-index"); + let transport = FakeTransport::new(); + transport.push(&url, ScriptedResponse::Http(200, " \n\t\n ".to_string())); + + let mut client = fast_client(transport); + assert!( + client + .fetch_index_record("empty-index", "1.0.0") + .unwrap() + .is_empty() + ); + assert!( + client + .fetch_index_record("empty-index", "2.0.0") + .unwrap() + .is_empty() + ); + assert_eq!(client.transport.call_count(), 1); + } + + #[test] + fn empty_sparse_index_result_persists_after_client_finish() { + let dir = tempdir().unwrap(); + let cache_path = dir.path().join("cache.json"); + let url = index_url("does-not-exist"); + let first_transport = FakeTransport::new(); + first_transport.push(&url, ScriptedResponse::Http(404, String::new())); + + let mut first_client = CratesIoClient::with_transport( + first_transport, + Some(&cache_path), + 24, + RetryPolicy { + retry_count: NonZeroU32::new(1).unwrap(), + retry_delay: Duration::ZERO, + pacing_delay: Duration::ZERO, + }, + ); + assert!( + first_client + .fetch_index_record("does-not-exist", "1.0.0") + .unwrap() + .is_empty() + ); + first_client.finish(); + + let second_transport = FakeTransport::new(); + let mut second_client = CratesIoClient::with_transport( + second_transport, + Some(&cache_path), + 24, + RetryPolicy { + retry_count: NonZeroU32::new(1).unwrap(), + retry_delay: Duration::ZERO, + pacing_delay: Duration::ZERO, + }, + ); + assert!( + second_client + .fetch_index_record("does-not-exist", "2.0.0") + .unwrap() + .is_empty() + ); + assert_eq!(second_client.transport.call_count(), 0); + } + + #[test] + fn sparse_index_transport_failure_is_not_cached() { + let url = index_url("retryable"); + let transport = FakeTransport::new(); + transport.push(&url, ScriptedResponse::Error); + transport.push(&url, ScriptedResponse::Http(404, String::new())); + + let mut client = CratesIoClient::with_transport( + transport, + None, + 24, + RetryPolicy { + retry_count: NonZeroU32::new(1).unwrap(), + retry_delay: Duration::ZERO, + pacing_delay: Duration::ZERO, + }, + ); + assert!(matches!( + client.fetch_index_record("retryable", "1.0.0"), + Err(FetchError::Retryable(_)) + )); + assert!( + client + .fetch_index_record("retryable", "1.0.0") + .unwrap() + .is_empty() + ); + assert_eq!(client.transport.call_count(), 2); + } + #[test] fn fetch_index_record_cache_hit_issues_no_request() { let transport = FakeTransport::new(); diff --git a/src/cache.rs b/src/cache.rs index e5f9ac5..33cb1e4 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -228,6 +228,23 @@ mod tests { ); } + #[test] + fn empty_index_record_cache_entry_expires() { + let mut cache = ResponseCache::load(None); + cache.set_index_records("does-not-exist", vec![]); + + assert!( + cache + .get_index_records("does-not-exist", Duration::hours(1)) + .is_some_and(|records| records.is_empty()) + ); + assert!( + cache + .get_index_records("does-not-exist", Duration::seconds(-1)) + .is_none() + ); + } + #[test] fn corrupt_file_starts_fresh() { let dir = tempdir().unwrap(); From c460feb76a0a0d6d443180f1a635a1e6fab3e306 Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Fri, 18 Sep 2026 16:35:14 -0400 Subject: [PATCH 32/34] fix(api): memoize sparse-index fetches per run instead of caching empty results Replace the durable empty-result cache entry with a per-run memo of fetched index records. A persisted cache entry is now a hit only when it is fresh and contains the needed version, so a transient miss is retried on the next run instead of persisting for the configured cache expiry. --- src/api.rs | 109 ++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 99 insertions(+), 10 deletions(-) diff --git a/src/api.rs b/src/api.rs index ce7e805..0e05858 100644 --- a/src/api.rs +++ b/src/api.rs @@ -2,6 +2,7 @@ use anyhow::Result; use chrono::{DateTime, Duration as ChronoDuration, Utc}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::num::NonZeroU32; use std::path::Path; use std::time::Duration; @@ -190,6 +191,9 @@ pub struct CratesIoClient { cache: ResponseCache, cache_max_age_hours: u64, retry_policy: RetryPolicy, + /// Per-run memo of successfully fetched index records, keyed by crate + /// name. Confined to this process; never persisted. + fetched_index_records: HashMap>, } impl CratesIoClient { @@ -219,6 +223,7 @@ impl CratesIoClient { cache: ResponseCache::load(cache_path), cache_max_age_hours, retry_policy, + fetched_index_records: HashMap::new(), } } @@ -389,11 +394,15 @@ impl CratesIoClient { /// that version's own dependency requirements. An unknown crate yields /// an empty vector rather than an error. /// - /// A non-empty cached record list that doesn't include - /// `needed_version` (e.g. a dependent published after the cache entry - /// was written) is treated as a cache miss: the index is refetched so - /// that version isn't silently missed. An empty cached list is reused - /// until the configured cache expiry. + /// A lookup first consults a per-run memo of index records already + /// fetched successfully in this process, keyed by crate name; a memo + /// hit is returned as-is, with no age check. Otherwise, a persisted + /// cache entry satisfies the lookup only when it is fresh and contains + /// a record whose `vers` equals `needed_version`; a non-empty entry + /// that lacks that version (e.g. a dependent published after the + /// cache entry was written) is treated as a miss, same as an empty + /// entry. A miss falls through to the network, and a successful fetch + /// is written to both the memo and the persisted cache. /// /// Unlike the other two fetches, this one is not subject to the /// crates.io API's inter-request pacing: the sparse index is a static @@ -403,10 +412,14 @@ impl CratesIoClient { name: &str, needed_version: &str, ) -> Result, FetchError> { + if let Some(records) = self.fetched_index_records.get(name) { + return Ok(records.clone()); + } + let max_age = ChronoDuration::hours(self.cache_max_age_hours as i64); if let Some(records) = self.cache.get_index_records(name, max_age) - && (records.is_empty() || records.iter().any(|r| r.vers == needed_version)) + && records.iter().any(|r| r.vers == needed_version) { return Ok(records); } @@ -414,6 +427,9 @@ impl CratesIoClient { self.with_retry(|client| { let result = client.fetch_index_record_uncached(name)?; client.cache.set_index_records(name, result.clone()); + client + .fetched_index_records + .insert(name.to_string(), result.clone()); Ok(result) }) } @@ -810,7 +826,10 @@ mod tests { } #[test] - fn empty_sparse_index_result_persists_after_client_finish() { + fn empty_sparse_index_result_is_refetched_by_a_new_client() { + // A new client opened against a cache file holding an empty entry + // must not treat that entry as a hit: it issues a request rather + // than reusing the stale empty result. let dir = tempdir().unwrap(); let cache_path = dir.path().join("cache.json"); let url = index_url("does-not-exist"); @@ -836,6 +855,7 @@ mod tests { first_client.finish(); let second_transport = FakeTransport::new(); + second_transport.push(&url, ScriptedResponse::Http(404, String::new())); let mut second_client = CratesIoClient::with_transport( second_transport, Some(&cache_path), @@ -852,7 +872,7 @@ mod tests { .unwrap() .is_empty() ); - assert_eq!(second_client.transport.call_count(), 0); + assert_eq!(second_client.transport.call_count(), 1); } #[test] @@ -889,10 +909,18 @@ mod tests { fn fetch_index_record_cache_hit_issues_no_request() { let transport = FakeTransport::new(); let mut client = fast_client(transport); - client.cache.set_index_records("serde", vec![]); + client.cache.set_index_records( + "serde", + vec![IndexRecord { + vers: "1.0.0".to_string(), + yanked: false, + deps: vec![], + }], + ); let records = client.fetch_index_record("serde", "1.0.0").unwrap(); - assert!(records.is_empty()); + assert_eq!(records.len(), 1); + assert_eq!(records[0].vers, "1.0.0"); assert_eq!(client.transport.call_count(), 0); } @@ -920,4 +948,65 @@ mod tests { assert_eq!(records[0].vers, "1.0.1"); assert_eq!(client.transport.call_count(), 1); } + + #[test] + fn fetch_index_record_missing_version_is_refetched_once_per_run() { + // A non-empty cached entry that lacks the needed version is fetched + // once per run across repeated lookups, not once per lookup. + let url = index_url("serde"); + let transport = FakeTransport::new(); + transport.push( + &url, + ScriptedResponse::Http(200, r#"{"vers":"1.0.1","yanked":false}"#.to_string()), + ); + + let mut client = fast_client(transport); + client.cache.set_index_records( + "serde", + vec![IndexRecord { + vers: "1.0.0".to_string(), + yanked: false, + deps: vec![], + }], + ); + + for _ in 0..2 { + let records = client.fetch_index_record("serde", "1.0.1").unwrap(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].vers, "1.0.1"); + } + assert_eq!(client.transport.call_count(), 1); + } + + #[test] + fn fetch_index_record_memo_holds_records_even_with_zero_cache_age() { + // With `cache_max_age_hours = 0`, two lookups in one run issue one + // request and both return the real records. This must fail if the + // memo were a name set that re-reads the cache, since a + // just-written cache entry would then be judged expired. + let url = index_url("serde"); + let transport = FakeTransport::new(); + transport.push( + &url, + ScriptedResponse::Http(200, r#"{"vers":"1.0.0","yanked":false}"#.to_string()), + ); + + let mut client = CratesIoClient::with_transport( + transport, + None, + 0, + RetryPolicy { + retry_count: NonZeroU32::new(3).unwrap(), + retry_delay: Duration::from_millis(0), + pacing_delay: Duration::from_millis(0), + }, + ); + + for _ in 0..2 { + let records = client.fetch_index_record("serde", "1.0.0").unwrap(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].vers, "1.0.0"); + } + assert_eq!(client.transport.call_count(), 1); + } } From 817a681ef00a23419a23385c74193f810dd8f194 Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Fri, 18 Sep 2026 19:50:48 -0400 Subject: [PATCH 33/34] Address comments --- src/report.rs | 2 +- src/suggest/tests/filter_candidates_tests.rs | 4 +- tests/suggest_fix_cli.rs | 55 +++++++++++++++++++- 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/report.rs b/src/report.rs index 857fa81..6c6e250 100644 --- a/src/report.rs +++ b/src/report.rs @@ -170,7 +170,7 @@ pub fn print_suggestions(outcomes: &[Outcome]) { locked_version, } => { println!( - " {package} {locked_version}: no version at least the minimum age old within its compatible range" + " {package} {locked_version}: no eligible downgrade at least the minimum age old within its compatible range" ); } Outcome::Suggest { .. } => unreachable!(), diff --git a/src/suggest/tests/filter_candidates_tests.rs b/src/suggest/tests/filter_candidates_tests.rs index 022c597..9b3e4e2 100644 --- a/src/suggest/tests/filter_candidates_tests.rs +++ b/src/suggest/tests/filter_candidates_tests.rs @@ -61,7 +61,7 @@ fn publish_date_breaks_ties_in_equal_semver_precedence() { #[test] fn prerelease_excluded_by_default() { let versions = vec![make_version("1.1.0-beta.1", 100, false)]; - let result = filter_candidates(&versions, &v("1.0.0"), 30, now(), false); + let result = filter_candidates(&versions, &v("1.2.0"), 30, now(), false); assert!(result.is_empty()); } @@ -69,7 +69,7 @@ fn prerelease_excluded_by_default() { fn prerelease_included_with_flag_when_range_matches() { // Same compatible zone (1.0.0), prerelease allowed by the flag. let versions = vec![make_version("1.0.0-beta.1", 100, false)]; - let result = filter_candidates(&versions, &v("1.0.0-beta.2"), 30, now(), true); + let result = filter_candidates(&versions, &v("1.0.0"), 30, now(), true); assert_eq!(result.len(), 1); assert_eq!(result[0].0.to_string(), "1.0.0-beta.1"); } diff --git a/tests/suggest_fix_cli.rs b/tests/suggest_fix_cli.rs index e85b7a1..0957e69 100644 --- a/tests/suggest_fix_cli.rs +++ b/tests/suggest_fix_cli.rs @@ -127,7 +127,12 @@ fn suggest_fix_cli_reports_mixed_outcomes_and_preserves_project_files() { && stdout.contains("consumer 2.0.0") && stdout.contains("^1.5") ); - assert!(stdout.contains("delta 1.5.0") && stdout.contains("no version")); + assert!( + stdout.contains( + "delta 1.5.0: no eligible downgrade at least the minimum age old within its compatible range" + ), + "stdout was:\n{stdout}" + ); assert!(stdout.contains("best-effort")); assert_eq!( fs::read(project.path().join("Cargo.toml")).unwrap(), @@ -139,6 +144,54 @@ fn suggest_fix_cli_reports_mixed_outcomes_and_preserves_project_files() { ); } +#[test] +fn suggest_fix_cli_reports_excluded_lower_versions_as_no_eligible_downgrade() { + for (candidate, yanked) in [("1.4.0", true), ("1.4.0-beta.1", false)] { + let project = copied_fixture(); + let cache = write_cache( + project.path(), + &[ + ("alpha", "1.5.0", 100, vec![]), + ("beta", "1.5.0", 100, vec![]), + ("gamma", "1.5.0", 100, vec![]), + ("delta", "1.5.0", 2, vec![("1.5.0", 2), (candidate, 80)]), + ("consumer", "2.0.0", 100, vec![]), + ], + ); + let mut cache_json: serde_json::Value = + serde_json::from_slice(&fs::read(&cache).unwrap()).unwrap(); + cache_json["all_versions"]["delta"]["versions"][1]["yanked"] = serde_json::json!(yanked); + fs::write(&cache, serde_json::to_vec(&cache_json).unwrap()).unwrap(); + + let output = run_oxidate( + project.path(), + &[ + "--min-age-days", + "30", + "--suggest-fix", + "--cache-path", + cache.to_str().unwrap(), + ], + ); + + assert_eq!(output.status.code(), Some(1)); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!( + stdout.lines().any(|line| { + line + == " delta 1.5.0: no eligible downgrade at least the minimum age old within its compatible range" + }), + "stdout was:\n{stdout}" + ); + assert!( + !stdout + .lines() + .any(|line| line.trim_start().starts_with("cargo update -p delta@")), + "stdout was:\n{stdout}" + ); + } +} + #[test] fn suggest_fix_cli_retains_best_effort_qualification() { let project = tempdir().unwrap(); From fabcee6d6d6bec800627feb9b8eba5a2895ab0e5 Mon Sep 17 00:00:00 2001 From: Duc Thanh Nguyen Date: Fri, 18 Sep 2026 19:58:14 -0400 Subject: [PATCH 34/34] Update README --- README.md | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 31adb5e..c322ef1 100644 --- a/README.md +++ b/README.md @@ -36,24 +36,24 @@ At least one of `--min-age-days` or `--max-age-days` must be specified. ## `--suggest-fix` -`--suggest-fix` finds the newest older version that satisfies, on a best-effort basis, every -dependency requirement it can verify from your lockfile and workspace manifests, and prints a -`cargo update` command for it. Candidates are limited to the locked version's existing compatible -zone (same major, or same minor when major is `0`, or same patch when both are `0`) — it does not -consider every eligible version across a major boundary. - -Requirement verification differs by source. For registry (crates.io) dependents, only a -requirement behind an optional dependency that can't be confirmed active is annotated as -unverified rather than enforced; target-specific requirements are always enforced, and Cargo may -still reject that suggestion. For local/workspace manifests, -activation isn't tracked at all, so every declared requirement — including optional and -target-specific ones — is treated as mandatory; an inactive local optional or target-specific -requirement can therefore still block an otherwise valid downgrade, without any "unverified" -annotation. - -The tool does not build your project and does not guarantee Cargo will accept every suggested -command. Run your tests after applying a suggestion. If no version fits, it reports the -requirement that prevents a downgrade. Apply suggestions in order, then run the command again. +`--suggest-fix` prints a `cargo update --precise` command for the newest eligible downgrade of +each package that is too new. It checks dependency requirements it can verify from `Cargo.lock` +and workspace manifests. + +A candidate must be old enough, not yanked, older than the locked version, and in its compatible +version zone. The zone keeps the same major version, except that `0.x` keeps the same minor and +`0.0.x` keeps the same patch. Prereleases are excluded unless you pass `--include-prerelease` or +the locked version is itself a prerelease. + +Registry requirements come from the crates.io index. An optional registry declaration that cannot +be confirmed active is shown as unverified. Target-specific registry declarations are enforced. +For local and workspace manifests, the tool does not determine feature or target activation, so it +treats every declared requirement, including optional and target-specific ones, as mandatory. + +Suggestions are best effort. The tool does not run Cargo's resolver or build your project, so Cargo +can still reject a suggested command. Apply suggestions in order, then run the command again and +run your tests. If the tool cannot find an eligible downgrade, it reports the requirement that +blocks one when it knows that requirement. ## Exit Codes