Replace dpkg-checkbuilddeps with a native implementation: - full dependency grammar: comma clauses, | alternatives, << <= = >= >> relations, :arch qualifiers (any/native/specific), [arch lists] and <profile restriction> formulas per alternative; - restriction reduction against active build profiles and the host arch at parse time (reduce_restrictions semantics); - evaluation against a parsed dpkg status database with Multi-Arch semantics (foreign/allowed) and versioned Provides rules (unversioned provides never satisfy versioned deps; versioned ones must satisfy the relation); - clause simplification with implication-based deduplication, rendering dpkg-compatible 'unmet build dependencies/conflicts' diagnostics. check_build_depends() consumes debian/control + CheckOpts (-A/-B/-I equivalents). run_source_build performs the check when forced (-D parity); source-only builds skip it entirely like dpkg-buildpackage, and unsatisfied deps propagate as UnmetBuildDependencies -> exit 3. Unit tests port the Dpkg_Deps.t reduction matrices; differential gate runs 24 scenarios (alternatives, versions, arch/profile restrictions, Multi-Arch, Provides, conflicts, -A/-B flags) against real dpkg-checkbuilddeps comparing exit status and diagnostics.
1314 lines
44 KiB
Rust
1314 lines
44 KiB
Rust
//! Debian dependency grammar and evaluation.
|
|
//!
|
|
//! Replaces `dpkg-checkbuilddeps`: parsing of dependency fields
|
|
//! (alternatives, version relations, architecture qualifiers, bracketed
|
|
//! restrictions), reduction against active build profiles and architectures,
|
|
//! and evaluation against a package status database (including versioned
|
|
//! `Provides`).
|
|
//!
|
|
//! Semantics follow the documented behavior of `Dpkg::Deps`
|
|
//! (<https://manpages.debian.org/libdpkg-perl>) and were validated
|
|
//! differentially against the real tool.
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::OnceLock;
|
|
|
|
use regex::Regex;
|
|
|
|
use crate::debian::arch;
|
|
use crate::debian::control::ControlInfo;
|
|
use crate::debian::version::{compare as version_cmp, DebianVersion};
|
|
|
|
/// Version relation operator between a package and a version.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Relation {
|
|
/// Strictly earlier (`<<`).
|
|
Lt,
|
|
/// Earlier or equal (`<=`).
|
|
Le,
|
|
/// Exactly equal (`=`).
|
|
Eq,
|
|
/// Later or equal (`>=`).
|
|
Ge,
|
|
/// Strictly later (`>>`).
|
|
Gt,
|
|
}
|
|
|
|
impl Relation {
|
|
/// Evaluate the relation between two parsed versions.
|
|
pub fn eval(self, a: &DebianVersion, b: &DebianVersion) -> bool {
|
|
use std::cmp::Ordering;
|
|
match self {
|
|
Relation::Lt => version_cmp(a, b) == Ordering::Less,
|
|
Relation::Le => version_cmp(a, b) != Ordering::Greater,
|
|
Relation::Eq => version_cmp(a, b) == Ordering::Equal,
|
|
Relation::Ge => version_cmp(a, b) != Ordering::Less,
|
|
Relation::Gt => version_cmp(a, b) == Ordering::Greater,
|
|
}
|
|
}
|
|
|
|
/// Canonical spelling used when rendering dependencies back out.
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Relation::Lt => "<<",
|
|
Relation::Le => "<=",
|
|
Relation::Eq => "=",
|
|
Relation::Ge => ">=",
|
|
Relation::Gt => ">>",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A version constraint attached to a package relation.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct VersionConstraint {
|
|
/// Relation operator.
|
|
pub relation: Relation,
|
|
/// Right-hand side version.
|
|
pub version: DebianVersion,
|
|
}
|
|
|
|
/// One simple (single-package) dependency alternative.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct PkgRelation {
|
|
/// Package (or virtual package) name.
|
|
pub package: String,
|
|
/// Architecture qualifier after `:` (`any`, `native`, or an arch name).
|
|
pub arch_qualifier: Option<String>,
|
|
/// Version constraint, when present.
|
|
pub constraint: Option<VersionConstraint>,
|
|
/// Bracketed architecture restriction list, when present.
|
|
pub arches: Option<Vec<String>>,
|
|
/// Build-profile restriction formula, in disjunctive normal form: each
|
|
/// inner list is a conjunction of (possibly negated) profile names.
|
|
pub restrictions: Vec<Vec<String>>,
|
|
}
|
|
|
|
impl PkgRelation {
|
|
/// Whether the bracketed architecture restriction applies to
|
|
/// `host_arch`. Alternatives without restrictions always apply.
|
|
pub fn arch_is_concerned(&self, host_arch: &str) -> bool {
|
|
match &self.arches {
|
|
None => true,
|
|
Some(arches) => {
|
|
let refs: Vec<&str> = arches.iter().map(String::as_str).collect();
|
|
arch::is_concerned(host_arch, &refs)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether the build-profile restriction formula applies to the active
|
|
/// `profiles`. Alternatives without restrictions always apply.
|
|
pub fn profile_is_concerned(&self, profiles: &[String]) -> bool {
|
|
if self.restrictions.is_empty() {
|
|
return true;
|
|
}
|
|
// Disjunction of conjunctions.
|
|
self.restrictions.iter().any(|terms| {
|
|
terms.iter().all(|term| {
|
|
let (negated, name) = match term.strip_prefix('!') {
|
|
Some(rest) => (true, rest),
|
|
None => (false, term.as_str()),
|
|
};
|
|
profiles.iter().any(|p| p == name) != negated
|
|
})
|
|
})
|
|
}
|
|
|
|
/// Render back to the canonical textual form
|
|
/// (`name[:qual] [(op version)] [arches] <restrictions>`).
|
|
pub fn output(&self) -> String {
|
|
let mut out = self.package.clone();
|
|
if let Some(qual) = &self.arch_qualifier {
|
|
out.push(':');
|
|
out.push_str(qual);
|
|
}
|
|
if let Some(c) = &self.constraint {
|
|
out.push_str(" (");
|
|
out.push_str(c.relation.as_str());
|
|
out.push(' ');
|
|
out.push_str(&c.version.full());
|
|
out.push(')');
|
|
}
|
|
if let Some(arches) = &self.arches {
|
|
out.push_str(&format!(" [{}]", arches.join(" ")));
|
|
}
|
|
for terms in &self.restrictions {
|
|
out.push_str(&format!(" <{}>", terms.join(" ")));
|
|
}
|
|
out
|
|
}
|
|
}
|
|
|
|
fn dep_regex() -> &'static Regex {
|
|
static REGEX: OnceLock<Regex> = OnceLock::new();
|
|
REGEX.get_or_init(|| {
|
|
Regex::new(concat!(
|
|
r"^(\s*)([a-zA-Z0-9][a-zA-Z0-9+.\-]*)", // package name
|
|
r"(?::([a-zA-Z0-9][a-zA-Z0-9\-]*))?", // optional :arch qualifier
|
|
r"(\s*\(\s*(<<|<=|=|>=|>>|[<>])\s*([^\)\s]+)\s*\))?", // optional version
|
|
r"(\s*\[\s*([^\]]+?)\s*\])?", // optional [arch list]
|
|
r"((?:\s*<\s*[^>]+?\s*>)+)?(\s*)$", // optional <restrictions>
|
|
))
|
|
.expect("valid dependency regex")
|
|
})
|
|
}
|
|
|
|
fn restriction_group_regex() -> &'static Regex {
|
|
static REGEX: OnceLock<Regex> = OnceLock::new();
|
|
REGEX.get_or_init(|| Regex::new(r"<\s*([^>]+?)\s*>").expect("valid restriction regex"))
|
|
}
|
|
|
|
fn profile_name_regex() -> &'static Regex {
|
|
static REGEX: OnceLock<Regex> = OnceLock::new();
|
|
REGEX.get_or_init(|| {
|
|
Regex::new(r"^!?[?/;:=@%*~_a-zA-Z0-9+.\-]+$").expect("valid profile name regex")
|
|
})
|
|
}
|
|
|
|
/// Parse one simple dependency alternative.
|
|
///
|
|
/// Fails when the text does not match the grammar, when `:native` appears in
|
|
/// a non-build-dependency context, or when the architecture/version/profile
|
|
/// syntax is invalid.
|
|
pub fn parse_simple(dep: &str, build_dep: bool) -> Result<PkgRelation, String> {
|
|
let caps = dep_regex()
|
|
.captures(dep)
|
|
.ok_or_else(|| format!("cannot parse dependency '{dep}'"))?;
|
|
|
|
let package = caps[2].to_string();
|
|
let arch_qualifier = caps.get(3).map(|m| m.as_str().to_string());
|
|
if arch_qualifier.as_deref() == Some("native") && !build_dep {
|
|
return Err(format!(
|
|
"':native' qualifier only allowed in build dependencies: '{dep}'"
|
|
));
|
|
}
|
|
|
|
let constraint = match (caps.get(5), caps.get(6)) {
|
|
(Some(op), Some(version)) => {
|
|
let relation = match op.as_str() {
|
|
"<<" | "<" => Relation::Lt,
|
|
"<=" => Relation::Le,
|
|
"=" => Relation::Eq,
|
|
">=" => Relation::Ge,
|
|
">>" | ">" => Relation::Gt,
|
|
other => return Err(format!("invalid relation '{other}' in '{dep}'")),
|
|
};
|
|
let version = DebianVersion::parse(version.as_str())
|
|
.map_err(|e| format!("invalid version in dependency '{dep}': {e}"))?;
|
|
Some(VersionConstraint { relation, version })
|
|
}
|
|
_ => None,
|
|
};
|
|
|
|
let arches = match caps.get(8) {
|
|
Some(list) => Some(
|
|
arch::list_parse(list.as_str())
|
|
.map_err(|e| format!("invalid architecture list in '{dep}': {e}"))?,
|
|
),
|
|
None => None,
|
|
};
|
|
|
|
let mut restrictions = Vec::new();
|
|
if let Some(formula) = caps.get(9) {
|
|
for group in restriction_group_regex().captures_iter(formula.as_str()) {
|
|
let terms: Vec<String> = group[1].split_whitespace().map(str::to_string).collect();
|
|
if terms.is_empty() {
|
|
return Err(format!("empty restriction formula in '{dep}'"));
|
|
}
|
|
for term in &terms {
|
|
if !profile_name_regex().is_match(term) {
|
|
return Err(format!(
|
|
"'{}' is not a valid build profile restriction formula",
|
|
formula.as_str()
|
|
));
|
|
}
|
|
}
|
|
restrictions.push(terms);
|
|
}
|
|
}
|
|
|
|
Ok(PkgRelation {
|
|
package,
|
|
arch_qualifier,
|
|
constraint,
|
|
arches,
|
|
restrictions,
|
|
})
|
|
}
|
|
|
|
/// Options controlling how a dependency field is parsed and reduced.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ParseOpts {
|
|
/// Host architecture (packages built for).
|
|
pub host_arch: String,
|
|
/// Build architecture (machine running the build), used by `:native`.
|
|
pub build_arch: String,
|
|
/// Active build profiles.
|
|
pub build_profiles: Vec<String>,
|
|
/// Evaluate architecture/profile restrictions at parse time and drop
|
|
/// the alternatives (then clauses) that do not apply.
|
|
pub reduce_restrictions: bool,
|
|
/// Parse a conflicts field: comma-separated items form a union and only
|
|
/// single alternatives are allowed.
|
|
pub union: bool,
|
|
/// Allow the `:native` architecture qualifier.
|
|
pub build_dep: bool,
|
|
}
|
|
|
|
impl ParseOpts {
|
|
/// Options for evaluating build dependencies on the current machine.
|
|
pub fn build_deps(host_arch: String, build_profiles: Vec<String>) -> ParseOpts {
|
|
ParseOpts {
|
|
build_arch: host_arch.clone(),
|
|
host_arch,
|
|
build_profiles,
|
|
reduce_restrictions: true,
|
|
union: false,
|
|
build_dep: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A parsed dependency field: AND over clauses, each clause being an OR over
|
|
/// alternatives. In `union` mode (conflicts) every clause holds a single
|
|
/// alternative.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Deps {
|
|
clauses: Vec<Vec<PkgRelation>>,
|
|
union: bool,
|
|
}
|
|
|
|
impl Deps {
|
|
/// Parse a dependency field value.
|
|
///
|
|
/// Empty clauses are skipped. With [`ParseOpts::reduce_restrictions`],
|
|
/// alternatives whose architecture or profile restrictions do not apply
|
|
/// are dropped first; clauses losing all their alternatives disappear,
|
|
/// and a fully reduced field yields an empty [`Deps`].
|
|
pub fn parse(input: &str, opts: &ParseOpts) -> Result<Deps, String> {
|
|
Self::parse_inner(input, opts, false)
|
|
}
|
|
|
|
fn parse_inner(
|
|
input: &str,
|
|
opts: &ParseOpts,
|
|
reduce_arch_only: bool,
|
|
) -> Result<Deps, String> {
|
|
if opts.host_arch.is_empty() || arch::is_invalid(&opts.host_arch, true) {
|
|
return Err(format!("invalid host_arch {}", opts.host_arch));
|
|
}
|
|
|
|
// Merge continuation lines and normalize whitespace.
|
|
let mut line = String::new();
|
|
for word in input.split_whitespace() {
|
|
if !line.is_empty() {
|
|
line.push(' ');
|
|
}
|
|
line.push_str(word);
|
|
}
|
|
|
|
let mut clauses = Vec::new();
|
|
for clause_text in line.split(',') {
|
|
if clause_text.trim().is_empty() {
|
|
continue;
|
|
}
|
|
let mut alternatives = Vec::new();
|
|
for alt_text in clause_text.split('|') {
|
|
let mut rel = parse_simple(alt_text.trim(), opts.build_dep)?;
|
|
|
|
if reduce_arch_only || opts.reduce_restrictions {
|
|
if !rel.arch_is_concerned(&opts.host_arch) {
|
|
continue;
|
|
}
|
|
rel.arches = None;
|
|
}
|
|
if !reduce_arch_only && opts.reduce_restrictions {
|
|
if !rel.profile_is_concerned(&opts.build_profiles) {
|
|
continue;
|
|
}
|
|
rel.restrictions = Vec::new();
|
|
}
|
|
alternatives.push(rel);
|
|
}
|
|
if alternatives.is_empty() {
|
|
continue;
|
|
}
|
|
if opts.union && alternatives.len() > 1 {
|
|
return Err(
|
|
"an union dependency can only contain simple dependencies".to_string(),
|
|
);
|
|
}
|
|
clauses.push(alternatives);
|
|
}
|
|
|
|
Ok(Deps {
|
|
clauses,
|
|
union: opts.union,
|
|
})
|
|
}
|
|
|
|
/// True when nothing remains after reduction.
|
|
pub fn is_empty(&self) -> bool {
|
|
self.clauses.is_empty()
|
|
}
|
|
|
|
/// Iterate over the remaining clauses (each a list of alternatives).
|
|
pub fn clauses(&self) -> impl Iterator<Item = &[PkgRelation]> {
|
|
self.clauses.iter().map(Vec::as_slice)
|
|
}
|
|
|
|
/// Whether this dependency was parsed as a union (conflicts field).
|
|
pub fn is_union(&self) -> bool {
|
|
self.union
|
|
}
|
|
|
|
/// Render back to the canonical textual representation.
|
|
pub fn output(&self) -> String {
|
|
self.clauses
|
|
.iter()
|
|
.map(|alts| {
|
|
alts
|
|
.iter()
|
|
.map(PkgRelation::output)
|
|
.collect::<Vec<_>>()
|
|
.join(" | ")
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
}
|
|
|
|
/// Evaluate one clause against the facts: `Some(true)` when satisfied,
|
|
/// `Some(false)` when certainly unsatisfied, `None` when undecidable
|
|
/// (missing information).
|
|
fn evaluate_clause(alternatives: &[PkgRelation], facts: &Facts) -> Option<bool> {
|
|
let mut undecidable = false;
|
|
for alt in alternatives {
|
|
match facts.evaluate_relation(alt) {
|
|
Some(true) => return Some(true),
|
|
Some(false) => {}
|
|
None => undecidable = true,
|
|
}
|
|
}
|
|
if undecidable { None } else { Some(false) }
|
|
}
|
|
|
|
/// Reduce the dependency against the facts, like
|
|
/// `Dpkg::Deps::Deps->simplify_deps()`: satisfied clauses are removed;
|
|
/// unsatisfied ones keep all their alternatives; duplicate clauses
|
|
/// implied by another clause are dropped.
|
|
pub fn simplify(&mut self, facts: &Facts) {
|
|
let mut remaining: Vec<Vec<PkgRelation>> = Vec::new();
|
|
let mut work = self.clauses.clone();
|
|
|
|
'outer: while !work.is_empty() {
|
|
let clause = work.remove(0);
|
|
|
|
if Deps::evaluate_clause(&clause, facts) == Some(true) {
|
|
continue;
|
|
}
|
|
for kept in &remaining {
|
|
if clause_implies(kept, &clause) == Some(true) {
|
|
continue 'outer;
|
|
}
|
|
}
|
|
// When a following clause implies this one, invert the order
|
|
// ("a | b, c, a" becomes "a, c" and not "c, a").
|
|
for idx in 0..work.len() {
|
|
if clause_implies(&clause, &work[idx]) == Some(true) {
|
|
let moved = work.remove(idx);
|
|
work.insert(0, moved);
|
|
continue 'outer;
|
|
}
|
|
}
|
|
remaining.push(clause);
|
|
}
|
|
self.clauses = remaining;
|
|
}
|
|
}
|
|
|
|
/// Implication between two clauses: `Some(true)` when `p` implies `q`,
|
|
/// otherwise falsy/undecidable (mirrors `Dpkg::Deps::OR::implies`).
|
|
fn clause_implies(p: &[PkgRelation], q: &[PkgRelation]) -> Option<bool> {
|
|
for pr in p {
|
|
let mut found = false;
|
|
for qr in q {
|
|
if relation_implies(pr, qr) == Some(true) {
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
if !found {
|
|
return None;
|
|
}
|
|
}
|
|
Some(true)
|
|
}
|
|
|
|
/// Implication between two simple relations (mirrors
|
|
/// `Dpkg::Deps::Simple::implies`): `Some(true)` implies, `Some(false)`
|
|
/// disproves, `None` undecidable.
|
|
fn relation_implies(p: &PkgRelation, q: &PkgRelation) -> Option<bool> {
|
|
if p.package != q.package {
|
|
return Some(false);
|
|
}
|
|
if !arch_set_is_superset(p.arches.as_ref(), q.arches.as_ref()) {
|
|
return Some(false);
|
|
}
|
|
// Qualifiers must be identical to conclude anything.
|
|
if p.arch_qualifier != q.arch_qualifier {
|
|
return Some(false);
|
|
}
|
|
if !restrictions_imply(&p.restrictions, &q.restrictions) {
|
|
return Some(false);
|
|
}
|
|
// No version constraint on `q`: any constraint on `p` is stronger.
|
|
let Some(qc) = &q.constraint else {
|
|
return Some(true);
|
|
};
|
|
let Some(pc) = &p.constraint else {
|
|
return Some(false);
|
|
};
|
|
eval_implication(pc, qc)
|
|
}
|
|
|
|
/// Whether the architecture set `p` covers `q`.
|
|
fn arch_set_is_superset(p: Option<&Vec<String>>, q: Option<&Vec<String>>) -> bool {
|
|
let Some(p_list) = p else {
|
|
return true;
|
|
};
|
|
let Some(q_list) = q else {
|
|
return false;
|
|
};
|
|
let p_neg = p_list.first().is_some_and(|a| a.starts_with('!'));
|
|
let q_neg = q_list.first().is_some_and(|a| a.starts_with('!'));
|
|
|
|
match (p_neg, q_neg) {
|
|
(false, false) => q_list.iter().all(|a| p_list.contains(a)),
|
|
(true, true) => p_list.iter().all(|a| q_list.contains(a)),
|
|
(false, true) => false,
|
|
(true, false) => p_list.iter().all(|a| {
|
|
let stripped = a.strip_prefix('!').unwrap_or(a);
|
|
!q_list.contains(&stripped.to_string())
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Whether the restriction formula `p` implies `q`: every conjunction of
|
|
/// `q` must appear verbatim in `p`.
|
|
fn restrictions_imply(p: &[Vec<String>], q: &[Vec<String>]) -> bool {
|
|
if p.is_empty() {
|
|
return true;
|
|
}
|
|
if q.is_empty() {
|
|
return false;
|
|
}
|
|
q.iter().all(|q_terms| {
|
|
p.iter().any(|p_terms| {
|
|
let mut ps = p_terms.clone();
|
|
let mut qs = q_terms.clone();
|
|
ps.sort();
|
|
qs.sort();
|
|
ps == qs
|
|
})
|
|
})
|
|
}
|
|
|
|
/// Decide whether `p`'s constraint implies `q`'s constraint:
|
|
/// `Some(true)` implies, `Some(false)` disproves, `None` undecidable.
|
|
fn eval_implication(p: &VersionConstraint, q: &VersionConstraint) -> Option<bool> {
|
|
use Relation::*;
|
|
use std::cmp::Ordering::{Equal, Greater, Less};
|
|
let c = version_cmp(&p.version, &q.version);
|
|
let (lt, eq, gt) = (c == Less, c == Equal, c == Greater);
|
|
let le = !gt;
|
|
let ge = !lt;
|
|
|
|
Some(match (p.relation, q.relation) {
|
|
// «q» wants an exact version: «p» either pins it or cannot decide.
|
|
(Lt, Eq) if le => false,
|
|
(Le, Eq) if lt => false,
|
|
(Gt, Eq) if ge => false,
|
|
(Ge, Eq) if gt => false,
|
|
(Eq, Eq) => eq,
|
|
// «q» caps from above (<=).
|
|
(Gt, Le) if ge => false,
|
|
(Ge, Le) if gt => false,
|
|
(Eq, Le) => le,
|
|
(Lt, Le) | (Le, Le) if le => true,
|
|
// «q» requires strictly earlier (<<).
|
|
(Gt, Lt) | (Ge, Lt) => false,
|
|
(Lt, Lt) if le => true,
|
|
(Eq, Lt) => lt,
|
|
(Le, Lt) if lt => true,
|
|
// «q» floors from below (>=).
|
|
(Lt, Ge) if le => false,
|
|
(Le, Ge) if lt => false,
|
|
(Eq, Ge) => ge,
|
|
(Gt, Ge) | (Ge, Ge) if ge => true,
|
|
// «q» requires strictly later (>>).
|
|
(Lt, Gt) | (Le, Gt) if le => false,
|
|
(Gt, Gt) if ge => true,
|
|
(Eq, Gt) => gt,
|
|
(Ge, Gt) if gt => true,
|
|
_ => return None,
|
|
})
|
|
}
|
|
|
|
/// One installed binary package relevant for dependency resolution.
|
|
#[derive(Debug, Clone)]
|
|
pub struct InstalledPkg {
|
|
/// Installed version.
|
|
pub version: String,
|
|
/// Debian architecture.
|
|
pub arch: String,
|
|
/// Multi-Arch attribute (`no`, `foreign`, `allowed`, `same`).
|
|
pub multiarch: String,
|
|
}
|
|
|
|
/// One virtual package provided by an installed package.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ProvidedPkg {
|
|
/// Relation of a versioned provide; `None` for unversioned provides.
|
|
pub relation: Option<Relation>,
|
|
/// Version of a versioned provide.
|
|
pub version: Option<String>,
|
|
/// Name of the providing package.
|
|
pub provider: String,
|
|
}
|
|
|
|
/// A snapshot of installed real and virtual packages, equivalent to
|
|
/// `Dpkg::Deps::KnownFacts`.
|
|
#[derive(Debug, Default)]
|
|
pub struct Facts {
|
|
host_arch: String,
|
|
build_arch: String,
|
|
installed: HashMap<String, Vec<InstalledPkg>>,
|
|
provided: HashMap<String, Vec<ProvidedPkg>>,
|
|
}
|
|
|
|
impl Facts {
|
|
/// An empty fact set, bound to the given architectures.
|
|
pub fn new(host_arch: &str, build_arch: &str) -> Facts {
|
|
Facts {
|
|
host_arch: host_arch.to_string(),
|
|
build_arch: build_arch.to_string(),
|
|
installed: HashMap::new(),
|
|
provided: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
/// Record an installed package instance.
|
|
pub fn add_installed(&mut self, package: &str, version: &str, pkg_arch: &str, multiarch: &str) {
|
|
self.installed
|
|
.entry(package.to_string())
|
|
.or_default()
|
|
.push(InstalledPkg {
|
|
version: version.to_string(),
|
|
arch: pkg_arch.to_string(),
|
|
multiarch: multiarch.to_string(),
|
|
});
|
|
}
|
|
|
|
/// Record that `provider` provides the virtual package `virtual_name`.
|
|
pub fn add_provided(
|
|
&mut self,
|
|
virtual_name: &str,
|
|
relation: Option<Relation>,
|
|
version: Option<&str>,
|
|
provider: &str,
|
|
) {
|
|
self.provided
|
|
.entry(virtual_name.to_string())
|
|
.or_default()
|
|
.push(ProvidedPkg {
|
|
relation,
|
|
version: version.map(str::to_string),
|
|
provider: provider.to_string(),
|
|
});
|
|
}
|
|
|
|
/// Load a dpkg status file (e.g. `/var/lib/dpkg/status`), binding the
|
|
/// facts to the given host/build architectures.
|
|
pub fn load_status(path: &Path, host_arch: &str, build_arch: &str) -> Result<Facts, String> {
|
|
let content = std::fs::read_to_string(path)
|
|
.map_err(|e| format!("cannot read status file '{}': {}", path.display(), e))?;
|
|
Ok(Facts::from_status(&content, host_arch, build_arch))
|
|
}
|
|
|
|
/// Parse a dpkg status database from its textual content.
|
|
///
|
|
/// 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`).
|
|
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) {
|
|
let status = para.get("Status").unwrap_or("");
|
|
if !status.ends_with("ok installed") {
|
|
continue;
|
|
}
|
|
let Some(package) = para.get("Package") else {
|
|
continue;
|
|
};
|
|
let version = para.get("Version").unwrap_or("");
|
|
let pkg_arch = para.get("Architecture").unwrap_or("");
|
|
let multiarch = para.get("Multi-Arch").unwrap_or("no");
|
|
facts.add_installed(package, version, pkg_arch, multiarch);
|
|
|
|
if let Some(provides) = para.get("Provides") {
|
|
let opts = ParseOpts {
|
|
host_arch: host_arch.to_string(),
|
|
build_arch: build_arch.to_string(),
|
|
build_profiles: Vec::new(),
|
|
reduce_restrictions: false,
|
|
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 {
|
|
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()
|
|
.map(|c| c.version.full())
|
|
.map(|v| v.to_string());
|
|
facts.add_provided(
|
|
&alt.package,
|
|
alt.constraint.as_ref().map(|c| c.relation),
|
|
provided_version.as_deref(),
|
|
package,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
facts
|
|
}
|
|
|
|
/// Find an installed instance matching the relation's name/architecture
|
|
/// qualification.
|
|
fn find_package(&self, rel: &PkgRelation) -> Option<&InstalledPkg> {
|
|
let instances = self.installed.get(&rel.package)?;
|
|
for p in instances {
|
|
match rel.arch_qualifier.as_deref() {
|
|
None => {
|
|
if p.multiarch == "foreign" || p.arch == self.host_arch || p.arch == "all" {
|
|
return Some(p);
|
|
}
|
|
}
|
|
Some("any") => {
|
|
if p.multiarch == "allowed" {
|
|
return Some(p);
|
|
}
|
|
}
|
|
Some("native") => {
|
|
// A foreign instance aborts the whole lookup for a
|
|
// :native qualifier.
|
|
if p.multiarch == "foreign" {
|
|
return None;
|
|
}
|
|
if p.arch == self.build_arch || p.arch == "all" {
|
|
return Some(p);
|
|
}
|
|
}
|
|
Some(qual) => {
|
|
if p.arch == qual {
|
|
return Some(p);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Evaluate one simple relation against the facts: `Some(true)` when
|
|
/// satisfied, `Some(false)` when not, `None` when information is
|
|
/// missing.
|
|
pub fn evaluate_relation(&self, rel: &PkgRelation) -> Option<bool> {
|
|
let mut lackinfos = false;
|
|
|
|
if let Some(p) = self.find_package(rel) {
|
|
match &rel.constraint {
|
|
Some(constraint) => match DebianVersion::parse(&p.version) {
|
|
Ok(installed_version) => {
|
|
if constraint
|
|
.relation
|
|
.eval(&installed_version, &constraint.version)
|
|
{
|
|
return Some(true);
|
|
}
|
|
}
|
|
Err(_) => lackinfos = true,
|
|
},
|
|
None => return Some(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.
|
|
if vp.relation.is_some_and(|r| r != Relation::Eq) {
|
|
continue;
|
|
}
|
|
match &rel.constraint {
|
|
Some(constraint) => {
|
|
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);
|
|
}
|
|
}
|
|
None => return Some(true),
|
|
}
|
|
}
|
|
}
|
|
|
|
if lackinfos { None } else { Some(false) }
|
|
}
|
|
}
|
|
|
|
/// Options for [`check_build_depends`].
|
|
#[derive(Debug, Clone)]
|
|
pub struct CheckOpts {
|
|
/// Host architecture (defaults to the native architecture).
|
|
pub host_arch: String,
|
|
/// Active build profiles.
|
|
pub build_profiles: Vec<String>,
|
|
/// Ignore `Build-Depends-Arch`/`Build-Conflicts-Arch` (`-A`).
|
|
pub ignore_arch: bool,
|
|
/// Ignore `Build-Depends-Indep`/`Build-Conflicts-Indep` (`-B`).
|
|
pub ignore_indep: bool,
|
|
/// Ignore built-in build dependencies and conflicts (`-I`); pkh knows
|
|
/// no vendor builtin dependencies, so this only mirrors the flag.
|
|
pub ignore_builtin: bool,
|
|
/// dpkg administrative directory containing `status`.
|
|
pub admindir: PathBuf,
|
|
}
|
|
|
|
impl Default for CheckOpts {
|
|
fn default() -> Self {
|
|
CheckOpts {
|
|
host_arch: arch::native().unwrap_or_default(),
|
|
build_profiles: Vec::new(),
|
|
ignore_arch: false,
|
|
ignore_indep: false,
|
|
ignore_builtin: true,
|
|
admindir: PathBuf::from("/var/lib/dpkg"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Error raised when build dependencies or conflicts are unsatisfied.
|
|
///
|
|
/// Carries the dpkg-compatible diagnostics; callers map it to exit
|
|
/// status 3, like `dpkg-buildpackage` does.
|
|
#[derive(Debug)]
|
|
pub struct UnmetBuildDependencies(pub UnmetReport);
|
|
|
|
impl std::fmt::Display for UnmetBuildDependencies {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "build dependencies/conflicts unsatisfied; aborting")
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for UnmetBuildDependencies {}
|
|
|
|
/// Outcome of a build-dependency check.
|
|
#[derive(Debug, Default, PartialEq, Eq)]
|
|
pub struct UnmetReport {
|
|
/// Unsatisfied dependency clauses, canonically rendered.
|
|
pub unmet: Vec<String>,
|
|
/// Violated conflict clauses, canonically rendered.
|
|
pub conflicts: Vec<String>,
|
|
}
|
|
|
|
impl UnmetReport {
|
|
/// True when everything is satisfied.
|
|
pub fn is_ok(&self) -> bool {
|
|
self.unmet.is_empty() && self.conflicts.is_empty()
|
|
}
|
|
|
|
/// dpkg-compatible diagnostic text (one line per problem kind), as
|
|
/// printed by `dpkg-checkbuilddeps`.
|
|
pub fn message(&self) -> String {
|
|
let mut lines = Vec::new();
|
|
if !self.unmet.is_empty() {
|
|
lines.push(format!(
|
|
"unmet build dependencies: {}",
|
|
self.unmet.join(" ")
|
|
));
|
|
}
|
|
if !self.conflicts.is_empty() {
|
|
lines.push(format!(
|
|
"unmet build conflicts: {}",
|
|
self.conflicts.join(" ")
|
|
));
|
|
}
|
|
lines.join("\n")
|
|
}
|
|
}
|
|
|
|
/// Check the `Build-*` fields of a parsed `debian/control` against the
|
|
/// installed package database, mirroring `dpkg-checkbuilddeps`.
|
|
///
|
|
/// On success the report is empty; otherwise its [`UnmetReport::message`]
|
|
/// carries the dpkg-compatible diagnostics.
|
|
pub fn check_build_depends(control: &ControlInfo, opts: &CheckOpts) -> Result<UnmetReport, String> {
|
|
let source = &control.source;
|
|
|
|
let mut bd_parts: Vec<&str> = Vec::new();
|
|
if let Some(v) = source.get("Build-Depends") {
|
|
bd_parts.push(v);
|
|
}
|
|
if !opts.ignore_arch
|
|
&& let Some(v) = source.get("Build-Depends-Arch")
|
|
{
|
|
bd_parts.push(v);
|
|
}
|
|
if !opts.ignore_indep
|
|
&& let Some(v) = source.get("Build-Depends-Indep")
|
|
{
|
|
bd_parts.push(v);
|
|
}
|
|
let bd_value = bd_parts.join(", ");
|
|
|
|
let mut bc_parts: Vec<&str> = Vec::new();
|
|
if let Some(v) = source.get("Build-Conflicts") {
|
|
bc_parts.push(v);
|
|
}
|
|
if !opts.ignore_arch
|
|
&& let Some(v) = source.get("Build-Conflicts-Arch")
|
|
{
|
|
bc_parts.push(v);
|
|
}
|
|
if !opts.ignore_indep
|
|
&& let Some(v) = source.get("Build-Conflicts-Indep")
|
|
{
|
|
bc_parts.push(v);
|
|
}
|
|
let bc_value = bc_parts.join(", ");
|
|
|
|
let status_path = opts.admindir.join("status");
|
|
let facts = Facts::load_status(&status_path, &opts.host_arch, &opts.host_arch)?;
|
|
|
|
let mut report = UnmetReport::default();
|
|
|
|
if !bd_value.trim().is_empty() {
|
|
let parse_opts = ParseOpts {
|
|
host_arch: opts.host_arch.clone(),
|
|
build_arch: opts.host_arch.clone(),
|
|
build_profiles: opts.build_profiles.clone(),
|
|
reduce_restrictions: true,
|
|
union: false,
|
|
build_dep: true,
|
|
};
|
|
let mut deps = Deps::parse(&bd_value, &parse_opts)?;
|
|
deps.simplify(&facts);
|
|
for clause in deps.clauses() {
|
|
report.unmet.push(
|
|
clause
|
|
.iter()
|
|
.map(PkgRelation::output)
|
|
.collect::<Vec<_>>()
|
|
.join(" | "),
|
|
);
|
|
}
|
|
}
|
|
|
|
if !bc_value.trim().is_empty() {
|
|
let parse_opts = ParseOpts {
|
|
host_arch: opts.host_arch.clone(),
|
|
build_arch: opts.host_arch.clone(),
|
|
build_profiles: opts.build_profiles.clone(),
|
|
reduce_restrictions: true,
|
|
union: true,
|
|
build_dep: true,
|
|
};
|
|
let deps = Deps::parse(&bc_value, &parse_opts)?;
|
|
for clause in deps.clauses() {
|
|
for alt in clause {
|
|
if facts.evaluate_relation(alt) == Some(true) {
|
|
report.conflicts.push(alt.output());
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(report)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn opts(host: &str, profiles: &[&str]) -> ParseOpts {
|
|
ParseOpts {
|
|
host_arch: host.to_string(),
|
|
build_arch: host.to_string(),
|
|
build_profiles: profiles.iter().map(|s| s.to_string()).collect(),
|
|
reduce_restrictions: true,
|
|
union: false,
|
|
build_dep: true,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_and_output_roundtrip() {
|
|
// Without reduction, restrictions are preserved verbatim.
|
|
let plain = ParseOpts {
|
|
host_arch: "amd64".into(),
|
|
build_arch: "amd64".into(),
|
|
build_profiles: vec![],
|
|
reduce_restrictions: false,
|
|
union: false,
|
|
build_dep: true,
|
|
};
|
|
let d = Deps::parse(
|
|
"libatk1.0-0 (>= 1.13.2), libc6 (>= 2.5-5) [!alpha !hurd-i386], python (<< 2.5)",
|
|
&plain,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
d.output(),
|
|
"libatk1.0-0 (>= 1.13.2), libc6 (>= 2.5-5) [!alpha !hurd-i386], python (<< 2.5)"
|
|
);
|
|
|
|
// With reduction on amd64, the bracketed list disappears.
|
|
let d = Deps::parse(
|
|
"libatk1.0-0 (>= 1.13.2), libc6 (>= 2.5-5) [!alpha !hurd-i386], python (<< 2.5)",
|
|
&opts("amd64", &[]),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
d.output(),
|
|
"libatk1.0-0 (>= 1.13.2), libc6 (>= 2.5-5), python (<< 2.5)"
|
|
);
|
|
|
|
// Empty clauses are skipped, whitespace normalized.
|
|
let d = Deps::parse(
|
|
" , , libgtk2.0-common (= 2.10.13-1) , libc6 (>=\n 2.5-5)",
|
|
&opts("amd64", &[]),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
d.output(),
|
|
"libgtk2.0-common (= 2.10.13-1), libc6 (>= 2.5-5)"
|
|
);
|
|
|
|
assert!(Deps::parse("", &opts("amd64", &[])).unwrap().is_empty());
|
|
assert!(Deps::parse("a, , b", &opts("amd64", &[])).is_ok());
|
|
|
|
// Invalid syntax fails.
|
|
assert!(Deps::parse("@builddeps@", &opts("amd64", &[])).is_err());
|
|
let non_build = ParseOpts {
|
|
host_arch: "amd64".into(),
|
|
build_arch: "amd64".into(),
|
|
build_profiles: vec![],
|
|
reduce_restrictions: false,
|
|
union: false,
|
|
build_dep: false,
|
|
};
|
|
assert!(Deps::parse("foo:native", &non_build).is_err());
|
|
assert!(Deps::parse("foo:native", &opts("amd64", &[])).is_ok());
|
|
}
|
|
|
|
/// Ported from dpkg `t/Dpkg_Deps.t`: architecture reduction.
|
|
#[test]
|
|
fn arch_reduction() {
|
|
let field = "libc6 (>= 2.5) [!alpha !hurd-i386], libc6.1 [alpha], libc0.1 [hurd-i386]";
|
|
let i386 = Deps::parse(field, &opts("i386", &[])).unwrap();
|
|
assert_eq!(i386.output(), "libc6 (>= 2.5)");
|
|
let alpha = Deps::parse(field, &opts("alpha", &[])).unwrap();
|
|
assert_eq!(alpha.output(), "libc6.1");
|
|
let hurd = Deps::parse(field, &opts("hurd-i386", &[])).unwrap();
|
|
assert_eq!(hurd.output(), "libc0.1");
|
|
}
|
|
|
|
/// Ported from dpkg `t/Dpkg_Deps.t`: profile reduction.
|
|
#[test]
|
|
fn profile_reduction() {
|
|
let field = "dep1 <!stage1 !nocheck>, \
|
|
dep2 <stage1 !nocheck>, \
|
|
dep3 <nocheck !stage1>, \
|
|
dep4 <stage1 nocheck>, \
|
|
dep5 <stage1>, dep6 <!stage1>, \
|
|
dep7 <stage1> | dep8 <nocheck>, \
|
|
dep9 <!stage1> <!nocheck>, \
|
|
dep10 <stage1> <!nocheck>, \
|
|
dep11 <stage1> <nocheck>, \
|
|
dep12 <!nocheck> <!stage1>, \
|
|
dep13 <nocheck> <!stage1>, \
|
|
dep14 <nocheck> <stage1>";
|
|
|
|
let noprof = Deps::parse(field, &opts("amd64", &[])).unwrap();
|
|
assert_eq!(noprof.output(), "dep1, dep6, dep9, dep10, dep12, dep13");
|
|
|
|
let stage1 = Deps::parse(field, &opts("amd64", &["stage1"])).unwrap();
|
|
assert_eq!(
|
|
stage1.output(),
|
|
"dep2, dep5, dep7, dep9, dep10, dep11, dep12, dep14"
|
|
);
|
|
|
|
let nocheck = Deps::parse(field, &opts("amd64", &["nocheck"])).unwrap();
|
|
assert_eq!(
|
|
nocheck.output(),
|
|
"dep3, dep6, dep8, dep9, dep11, dep12, dep13, dep14"
|
|
);
|
|
|
|
let both = Deps::parse(field, &opts("amd64", &["stage1", "nocheck"])).unwrap();
|
|
assert_eq!(
|
|
both.output(),
|
|
"dep4, dep5, dep7 | dep8, dep10, dep11, dep13, dep14"
|
|
);
|
|
}
|
|
|
|
/// Ported from dpkg `t/Dpkg_Deps.t`: unknown restrictions reduce away.
|
|
#[test]
|
|
fn unknown_restrictions_reduce() {
|
|
let field = "dep1 <!bootstrap !restrict>, \
|
|
dep2 <bootstrap restrict>, \
|
|
dep3 <!restrict>, \
|
|
dep4 <restrict>, \
|
|
dep5 <!bootstrap> <!restrict>, \
|
|
dep6 <bootstrap> <restrict>";
|
|
let reduced = Deps::parse(field, &opts("amd64", &[])).unwrap();
|
|
assert_eq!(reduced.output(), "dep1, dep3, dep5");
|
|
}
|
|
|
|
const STATUS: &str = "\
|
|
Package: mypackage
|
|
Status: install ok installed
|
|
Version: 1.3.4-1
|
|
Architecture: amd64
|
|
Multi-Arch: no
|
|
|
|
Package: mypackage2
|
|
Status: install ok installed
|
|
Version: 1.3.4-1
|
|
Architecture: somearch
|
|
Multi-Arch: no
|
|
|
|
Package: pkg-ma-foreign
|
|
Status: install ok installed
|
|
Version: 1.3.4-1
|
|
Architecture: somearch
|
|
Multi-Arch: foreign
|
|
|
|
Package: pkg-ma-foreign2
|
|
Status: install ok installed
|
|
Version: 1.3.4-1
|
|
Architecture: amd64
|
|
Multi-Arch: foreign
|
|
|
|
Package: pkg-ma-allowed
|
|
Status: install ok installed
|
|
Version: 1.3.4-1
|
|
Architecture: somearch
|
|
Multi-Arch: allowed
|
|
|
|
Package: virtual-dep
|
|
Status: install ok installed
|
|
Version: 9.9
|
|
Architecture: amd64
|
|
Provides: virtual-pkg (= 1.0), plain-virtual
|
|
|
|
Package: old-provider
|
|
Status: install ok installed
|
|
Version: 0.5
|
|
Architecture: amd64
|
|
Provides: old-virtual (= 0.5)
|
|
";
|
|
|
|
#[test]
|
|
fn evaluation_against_facts() {
|
|
let facts = Facts::from_status(STATUS, "amd64", "amd64");
|
|
let o = |s: &str| parse_simple(s, true).unwrap();
|
|
|
|
// Real packages.
|
|
assert_eq!(facts.evaluate_relation(&o("mypackage")), Some(true));
|
|
assert_eq!(facts.evaluate_relation(&o("mypackage (>= 1.3)")), Some(true));
|
|
assert_eq!(
|
|
facts.evaluate_relation(&o("mypackage (>> 1.3.4-1)")),
|
|
Some(false)
|
|
);
|
|
assert_eq!(facts.evaluate_relation(&o("not-there")), Some(false));
|
|
|
|
// Multi-Arch semantics.
|
|
assert_eq!(
|
|
facts.evaluate_relation(&o("pkg-ma-foreign:somearch")),
|
|
Some(true)
|
|
);
|
|
assert_eq!(facts.evaluate_relation(&o("pkg-ma-allowed:any")), Some(true));
|
|
assert_eq!(facts.evaluate_relation(&o("pkg-ma-allowed")), Some(false));
|
|
assert_eq!(facts.evaluate_relation(&o("pkg-ma-foreign2")), Some(true));
|
|
|
|
// Virtual packages: unversioned dep matches any provide.
|
|
assert_eq!(facts.evaluate_relation(&o("plain-virtual")), Some(true));
|
|
// Versioned dep requires a versioned provide whose version
|
|
// satisfies the relation.
|
|
assert_eq!(
|
|
facts.evaluate_relation(&o("virtual-pkg (>= 1.0)")),
|
|
Some(true)
|
|
);
|
|
assert_eq!(
|
|
facts.evaluate_relation(&o("virtual-pkg (>> 1.0)")),
|
|
Some(false)
|
|
);
|
|
// A versioned provide satisfies when its version matches.
|
|
assert_eq!(
|
|
facts.evaluate_relation(&o("old-virtual (>= 0.1)")),
|
|
Some(true)
|
|
);
|
|
assert_eq!(
|
|
facts.evaluate_relation(&o("old-virtual (>> 0.5)")),
|
|
Some(false)
|
|
);
|
|
// An unversioned provide never satisfies a versioned dependency.
|
|
assert_eq!(
|
|
facts.evaluate_relation(&o("plain-virtual (>= 0.1)")),
|
|
Some(false)
|
|
);
|
|
assert_eq!(facts.evaluate_relation(&o("old-virtual")), Some(true));
|
|
}
|
|
|
|
#[test]
|
|
fn simplify_reports_unmet() {
|
|
let facts = Facts::from_status(STATUS, "amd64", "amd64");
|
|
let mut d = Deps::parse(
|
|
"mypackage, missing-one | missing-two, mypackage (>= 9.0)",
|
|
&opts("amd64", &[]),
|
|
)
|
|
.unwrap();
|
|
d.simplify(&facts);
|
|
assert_eq!(d.output(), "missing-one | missing-two, mypackage (>= 9.0)");
|
|
|
|
// Duplicates implied by another clause collapse.
|
|
let mut d = Deps::parse("foo, foo", &opts("amd64", &[])).unwrap();
|
|
d.simplify(&facts);
|
|
assert_eq!(d.output(), "foo");
|
|
|
|
// Everything satisfied -> empty.
|
|
let mut d = Deps::parse("mypackage (>= 1.0)", &opts("amd64", &[])).unwrap();
|
|
d.simplify(&facts);
|
|
assert!(d.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn implication_logic() {
|
|
let v = |s: &str| DebianVersion::parse(s).unwrap();
|
|
let mk = |rel: Relation, ver: &str| PkgRelation {
|
|
package: "x".to_string(),
|
|
arch_qualifier: None,
|
|
constraint: Some(VersionConstraint {
|
|
relation: rel,
|
|
version: v(ver),
|
|
}),
|
|
arches: None,
|
|
restrictions: vec![],
|
|
};
|
|
|
|
// x (>= 1) implies x (>= 0.5) and x, but not x (>= 2).
|
|
assert_eq!(
|
|
relation_implies(&mk(Relation::Ge, "1"), &mk(Relation::Ge, "0.5")),
|
|
Some(true)
|
|
);
|
|
assert_eq!(
|
|
relation_implies(&mk(Relation::Ge, "1"), &mk(Relation::Le, "0.5")),
|
|
Some(false)
|
|
);
|
|
assert_eq!(
|
|
relation_implies(&mk(Relation::Ge, "1"), &mk(Relation::Ge, "2")),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
relation_implies(&mk(Relation::Ge, "1"), &mk(Relation::Lt, "0.5")),
|
|
Some(false)
|
|
);
|
|
assert_eq!(
|
|
relation_implies(&mk(Relation::Eq, "1"), &mk(Relation::Ge, "1")),
|
|
Some(true)
|
|
);
|
|
assert_eq!(
|
|
relation_implies(&mk(Relation::Ge, "1"), &mk(Relation::Eq, "1")),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn check_build_depends_end_to_end() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let admindir = dir.path();
|
|
|
|
let control_text = "\
|
|
Source: t
|
|
Maintainer: a <a@b.c>
|
|
Build-Depends: mypackage (>= 1.0), definitely-not-installed-xyz
|
|
Build-Conflicts: libc6 (<< 1)
|
|
|
|
Package: t
|
|
Architecture: any
|
|
Description: x
|
|
y
|
|
";
|
|
let control = ControlInfo::parse_content(control_text).unwrap();
|
|
|
|
let status = "\
|
|
Package: mypackage
|
|
Status: install ok installed
|
|
Version: 1.3.4-1
|
|
Architecture: amd64
|
|
|
|
Package: libc6
|
|
Status: install ok installed
|
|
Version: 2.39-0ubuntu8
|
|
Architecture: amd64
|
|
";
|
|
std::fs::write(admindir.join("status"), status).unwrap();
|
|
|
|
let opts = CheckOpts {
|
|
admindir: admindir.to_path_buf(),
|
|
..Default::default()
|
|
};
|
|
let report = check_build_depends(&control, &opts).unwrap();
|
|
assert_eq!(
|
|
report.message(),
|
|
"unmet build dependencies: definitely-not-installed-xyz"
|
|
);
|
|
|
|
// Satisfiable configuration.
|
|
let control_ok = ControlInfo::parse_content(
|
|
"Source: t\nMaintainer: a <a@b.c>\nBuild-Depends: mypackage (>= 1.0)\nBuild-Conflicts: libc6 (<< 1)\n\nPackage: t\nArchitecture: any\nDescription: x\n y\n",
|
|
)
|
|
.unwrap();
|
|
let report = check_build_depends(&control_ok, &opts).unwrap();
|
|
assert!(report.is_ok());
|
|
|
|
// Conflicts trigger on installed packages.
|
|
let control_conflict = ControlInfo::parse_content(
|
|
"Source: t\nMaintainer: a <a@b.c>\nBuild-Conflicts: mypackage\n\nPackage: t\nArchitecture: any\nDescription: x\n y\n",
|
|
)
|
|
.unwrap();
|
|
let report = check_build_depends(&control_conflict, &opts).unwrap();
|
|
assert_eq!(report.message(), "unmet build conflicts: mypackage");
|
|
}
|
|
}
|