Files
pkh/src/debian/deps.rs
T
vhaudiquet dfaab0606a debian/deps: native dependency grammar and build-dep checking
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.
2026-08-24 11:58:06 +02:00

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");
}
}