deps: treat corrupt Provides as undecidable, not unmet
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:
+145
-18
@@ -10,7 +10,7 @@
|
|||||||
//! (<https://manpages.debian.org/libdpkg-perl>) and were validated
|
//! (<https://manpages.debian.org/libdpkg-perl>) and were validated
|
||||||
//! differentially against the real tool.
|
//! differentially against the real tool.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
@@ -583,6 +583,24 @@ pub struct Facts {
|
|||||||
build_arch: String,
|
build_arch: String,
|
||||||
installed: HashMap<String, Vec<InstalledPkg>>,
|
installed: HashMap<String, Vec<InstalledPkg>>,
|
||||||
provided: HashMap<String, Vec<ProvidedPkg>>,
|
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 {
|
impl Facts {
|
||||||
@@ -593,6 +611,7 @@ impl Facts {
|
|||||||
build_arch: build_arch.to_string(),
|
build_arch: build_arch.to_string(),
|
||||||
installed: HashMap::new(),
|
installed: HashMap::new(),
|
||||||
provided: HashMap::new(),
|
provided: HashMap::new(),
|
||||||
|
unreadable_provides: HashSet::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,6 +628,11 @@ impl Facts {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Record that `provider` provides the virtual package `virtual_name`.
|
/// 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(
|
pub fn add_provided(
|
||||||
&mut self,
|
&mut self,
|
||||||
virtual_name: &str,
|
virtual_name: &str,
|
||||||
@@ -639,7 +663,10 @@ impl Facts {
|
|||||||
/// Only stanzas whose `Status` ends with `ok installed` participate;
|
/// Only stanzas whose `Status` ends with `ok installed` participate;
|
||||||
/// their `Provides` field registers versioned/unversioned virtual
|
/// their `Provides` field registers versioned/unversioned virtual
|
||||||
/// packages (architecture-restricted provides are reduced against
|
/// 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 {
|
pub fn from_status(content: &str, host_arch: &str, build_arch: &str) -> Facts {
|
||||||
let mut facts = Facts::new(host_arch, build_arch);
|
let mut facts = Facts::new(host_arch, build_arch);
|
||||||
for para in crate::debian::control::parse_paragraphs(content) {
|
for para in crate::debian::control::parse_paragraphs(content) {
|
||||||
@@ -664,20 +691,38 @@ impl Facts {
|
|||||||
union: true,
|
union: true,
|
||||||
build_dep: false,
|
build_dep: false,
|
||||||
};
|
};
|
||||||
// Virtual (Provides) fields only accept '=' relations; a
|
// Virtual (Provides) fields only accept versionless or
|
||||||
// parse failure skips the whole field, like dpkg does.
|
// exactly '='-versioned alternatives; a field that fails
|
||||||
let Ok(parsed) = Deps::parse_inner(provides, &opts, true) else {
|
// 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;
|
continue;
|
||||||
};
|
};
|
||||||
for clause in parsed.clauses() {
|
for clause in parsed.clauses() {
|
||||||
for alt in clause {
|
for alt in clause {
|
||||||
if alt
|
|
||||||
.constraint
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|c| c.relation != Relation::Eq)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let provided_version = alt
|
let provided_version = alt
|
||||||
.constraint
|
.constraint
|
||||||
.as_ref()
|
.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) {
|
if let Some(providers) = self.provided.get(&rel.package) {
|
||||||
for vp in providers {
|
for vp in providers {
|
||||||
// Only unversioned provides and strictly-versioned provides
|
// Only unversioned provides and exactly-versioned provides
|
||||||
// can satisfy a dependency.
|
// 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) {
|
if vp.relation.is_some_and(|r| r != Relation::Eq) {
|
||||||
|
lackinfos = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match &rel.constraint {
|
match &rel.constraint {
|
||||||
@@ -767,10 +822,15 @@ impl Facts {
|
|||||||
let Some(vp_version) = &vp.version else {
|
let Some(vp_version) = &vp.version else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if let Ok(vp_v) = DebianVersion::parse(vp_version)
|
match DebianVersion::parse(vp_version) {
|
||||||
&& constraint.relation.eval(&vp_v, &constraint.version)
|
Ok(vp_v) if constraint.relation.eval(&vp_v, &constraint.version) => {
|
||||||
{
|
return Some(true);
|
||||||
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),
|
None => return Some(true),
|
||||||
@@ -1251,6 +1311,73 @@ Provides: old-virtual (= 0.5)
|
|||||||
assert_eq!(facts.evaluate_relation(&o("old-virtual")), Some(true));
|
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]
|
#[test]
|
||||||
fn simplify_reports_unmet() {
|
fn simplify_reports_unmet() {
|
||||||
let facts = Facts::from_status(STATUS, "amd64", "amd64");
|
let facts = Facts::from_status(STATUS, "amd64", "amd64");
|
||||||
|
|||||||
Reference in New Issue
Block a user