diff --git a/src/debian/deps.rs b/src/debian/deps.rs index 59d8918..71602a9 100644 --- a/src/debian/deps.rs +++ b/src/debian/deps.rs @@ -10,7 +10,7 @@ //! () and were validated //! differentially against the real tool. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::OnceLock; @@ -583,6 +583,24 @@ pub struct Facts { build_arch: String, installed: HashMap>, provided: HashMap>, + /// Virtual packages whose `Provides` entries had to be rejected as a + /// whole (unparseable field, malformed version, or a relation other + /// than `=`), mirroring a dpkg rejection of the entry: versioned + /// relations on them stay undecidable instead of turning unmet. + unreadable_provides: HashSet, +} + +/// Best-effort virtual package names mentioned in a raw `Provides` value, +/// used to remember rejected entries whose content cannot even be parsed +/// (the full dependency grammar is deliberately not re-applied here). +fn raw_provides_names(field: &str) -> impl Iterator { + field + .split([',', '|']) + .filter_map(|alt| alt.split_whitespace().next()) + .map(|tok| match tok.split_once(':') { + Some((name, _qual)) => name, + None => tok, + }) } impl Facts { @@ -593,6 +611,7 @@ impl Facts { build_arch: build_arch.to_string(), installed: HashMap::new(), provided: HashMap::new(), + unreadable_provides: HashSet::new(), } } @@ -609,6 +628,11 @@ impl Facts { } /// Record that `provider` provides the virtual package `virtual_name`. + /// + /// A `relation` other than [`Relation::Eq`], or a `version` that fails + /// to parse, makes the provide unusable: versioned relations on + /// `virtual_name` then evaluate to `None` (undecidable) instead of a + /// possibly-wrong `Some(false)`. pub fn add_provided( &mut self, virtual_name: &str, @@ -639,7 +663,10 @@ impl Facts { /// Only stanzas whose `Status` ends with `ok installed` participate; /// their `Provides` field registers versioned/unversioned virtual /// packages (architecture-restricted provides are reduced against - /// `host_arch`). + /// `host_arch`). A `Provides` field that fails to parse, or that + /// carries a relation other than `=`, is rejected as a whole (like + /// dpkg rejects the entry) and its virtual names are remembered as + /// unreadable. pub fn from_status(content: &str, host_arch: &str, build_arch: &str) -> Facts { let mut facts = Facts::new(host_arch, build_arch); for para in crate::debian::control::parse_paragraphs(content) { @@ -664,20 +691,38 @@ impl Facts { union: true, build_dep: false, }; - // Virtual (Provides) fields only accept '=' relations; a - // parse failure skips the whole field, like dpkg does. - let Ok(parsed) = Deps::parse_inner(provides, &opts, true) else { + // Virtual (Provides) fields only accept versionless or + // exactly '='-versioned alternatives; a field that fails + // to parse, or that carries any other relation, is + // rejected as a whole, like dpkg rejects the entry. The + // mentioned virtual names are remembered so versioned + // relations on them stay undecidable instead of turning + // into a possibly-wrong "unmet" verdict. + let mut parsed = None; + let mut rejected: Vec = Vec::new(); + match Deps::parse_inner(provides, &opts, true) { + Ok(deps) => { + if deps.clauses().flatten().any(|alt| { + alt.constraint + .as_ref() + .is_some_and(|c| c.relation != Relation::Eq) + }) { + rejected + .extend(deps.clauses().flatten().map(|alt| alt.package.clone())); + } else { + parsed = Some(deps); + } + } + // The field could not even be parsed: recover the raw + // names so the rejection is still remembered. + Err(_) => rejected.extend(raw_provides_names(provides).map(str::to_string)), + } + facts.unreadable_provides.extend(rejected); + let Some(parsed) = parsed else { continue; }; for clause in parsed.clauses() { for alt in clause { - if alt - .constraint - .as_ref() - .is_some_and(|c| c.relation != Relation::Eq) - { - continue; - } let provided_version = alt .constraint .as_ref() @@ -755,11 +800,21 @@ impl Facts { } } + // A rejected Provides entry for this virtual package carries + // information that could not be read: a versioned relation can + // then not be decided (an unversioned one only needs the name, + // which the rejection removed). + if rel.constraint.is_some() && self.unreadable_provides.contains(&rel.package) { + lackinfos = true; + } + if let Some(providers) = self.provided.get(&rel.package) { for vp in providers { - // Only unversioned provides and strictly-versioned provides - // can satisfy a dependency. + // Only unversioned provides and exactly-versioned provides + // can satisfy a dependency; anything else is an invalid + // provide and leaves the relation undecidable. if vp.relation.is_some_and(|r| r != Relation::Eq) { + lackinfos = true; continue; } match &rel.constraint { @@ -767,10 +822,15 @@ impl Facts { let Some(vp_version) = &vp.version else { continue; }; - if let Ok(vp_v) = DebianVersion::parse(vp_version) - && constraint.relation.eval(&vp_v, &constraint.version) - { - return Some(true); + match DebianVersion::parse(vp_version) { + Ok(vp_v) if constraint.relation.eval(&vp_v, &constraint.version) => { + return Some(true); + } + // An unreadable provided version, like an + // unreadable installed version, makes the + // relation undecidable instead of unmet. + Err(_) => lackinfos = true, + Ok(_) => {} } } None => return Some(true), @@ -1251,6 +1311,73 @@ Provides: old-virtual (= 0.5) assert_eq!(facts.evaluate_relation(&o("old-virtual")), Some(true)); } + /// A `Provides` entry that dpkg would reject as a whole (a malformed + /// provided version, or a relation other than `=`) must not degrade to + /// a bogus "unmet" verdict: versioned relations on the affected virtual + /// names stay undecidable. + #[test] + fn corrupt_provides_are_undecidable_not_unmet() { + // `not-a-version!` is rejected by DebianVersion::parse ('!' is not + // a legal version character), unlike dpkg-invalid but here-valid + // letter-only spellings. + let status = "\ +Package: bad-version-provider +Status: install ok installed +Version: 1.0 +Architecture: amd64 +Provides: virt (= not-a-version!) + +Package: bad-relation-provider +Status: install ok installed +Version: 1.0 +Architecture: amd64 +Provides: virt2 (>= 1.0), plain +"; + let facts = Facts::from_status(status, "amd64", "amd64"); + let o = |s: &str| parse_simple(s, true).unwrap(); + + // The provided version fails to parse: undecidable, not unmet. + assert_eq!(facts.evaluate_relation(&o("virt (>= 0.5)")), None); + // A non-'=' relation invalidates the whole Provides field, so + // neither of its alternatives may produce a verdict. + assert_eq!(facts.evaluate_relation(&o("virt2 (>= 0.5)")), None); + assert_eq!(facts.evaluate_relation(&o("plain (>= 0.5)")), None); + // Unversioned relations only need the name, which the rejected + // entries no longer provide: genuinely unmet. + assert_eq!(facts.evaluate_relation(&o("virt")), Some(false)); + assert_eq!(facts.evaluate_relation(&o("virt2")), Some(false)); + assert_eq!(facts.evaluate_relation(&o("plain")), Some(false)); + } + + /// The same undecidable verdicts through the direct facts API: an + /// unreadable provided version and an invalid (non-`=`) provide each + /// leave a versioned relation undecided, while a readable provider + /// elsewhere still satisfies it. + #[test] + fn unreadable_provided_version_and_relation_are_undecidable() { + let o = |s: &str| parse_simple(s, true).unwrap(); + + let mut facts = Facts::new("amd64", "amd64"); + facts.add_installed("provider", "1.0", "amd64", "no"); + // `virt (= not-a-version!)`: the version string fails to parse. + facts.add_provided( + "virt", + Some(Relation::Eq), + Some("not-a-version!"), + "provider", + ); + assert_eq!(facts.evaluate_relation(&o("virt (>= 0.5)")), None); + // `virt2 (>= 1.0)`: a non-'=' provide is invalid. + facts.add_provided("virt2", Some(Relation::Ge), Some("1.0"), "provider"); + assert_eq!(facts.evaluate_relation(&o("virt2 (>= 0.5)")), None); + + // A readable provider decides the relation when it matches; + // otherwise the unreadable entry keeps it undecidable. + facts.add_provided("virt", Some(Relation::Eq), Some("2.0"), "better"); + assert_eq!(facts.evaluate_relation(&o("virt (>= 0.5)")), Some(true)); + assert_eq!(facts.evaluate_relation(&o("virt (>> 2.0)")), None); + } + #[test] fn simplify_reports_unmet() { let facts = Facts::from_status(STATUS, "amd64", "amd64");