deps: treat corrupt Provides as undecidable, not unmet
CI / build (push) Failing after 2m58s
CI / test (push) Skipped
CI / snap (push) Skipped

A versioned Provides whose version failed to parse was silently
skipped, so a corrupt dpkg status entry could yield a wrong 'unmet'
verdict where the truth is 'cannot decide': unparseable provided
versions now set lackinfos like unparseable installed versions do.
Provides alternatives with a non-= constraint are likewise rejected as
a whole field (dpkg rejects the entry), replacing the skip-per-
alternative behavior that contradicted the code's own comment.
This commit is contained in:
2026-09-17 20:08:20 +02:00
parent 54274e9079
commit e7b35f5c37
+145 -18
View File
@@ -10,7 +10,7 @@
//! (<https://manpages.debian.org/libdpkg-perl>) 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<String, Vec<InstalledPkg>>,
provided: HashMap<String, Vec<ProvidedPkg>>,
/// 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<String>,
}
/// 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<Item = &str> {
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<String> = 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");