From dfaab0606a0ad1ac7b2d0a69f1255165bd60585e Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Mon, 24 Aug 2026 09:11:10 +0200 Subject: [PATCH] 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 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. --- plans/native-build.md | 2 +- src/build/mod.rs | 176 ++++++ src/debian/deps.rs | 1313 +++++++++++++++++++++++++++++++++++++++++ src/debian/mod.rs | 1 + src/main.rs | 7 + 5 files changed, 1498 insertions(+), 1 deletion(-) create mode 100644 src/debian/deps.rs diff --git a/plans/native-build.md b/plans/native-build.md index 511f476..b837c91 100644 --- a/plans/native-build.md +++ b/plans/native-build.md @@ -113,7 +113,7 @@ Exit codes matter: e.g. unsatisfied build-deps ⇒ exit 3. | `dpkg-parsechangelog` | source/version/maintainer/distribution/timestamp | **Low** — documented format; crates exist (`debian-changelog`, `deb822-parser` ecosystem) | Replaced (see §11, [`metadata.rs`](../src/build/metadata.rs)) | | `dpkg-version` compare | epoch/upstream/revision ordering | **Low** — small well-specified algorithm; crate `debversion` | **Replaced** (§11, [`debian/version.rs`](../src/debian/version.rs): `Ord`/`compare`/`later_than`) | | `dpkg-architecture` | arch ↔ triplet tables, multiarch tuple, env dump | **Low-medium** — embed cputable/ostable/tupletable/abitable data (stable for years) | **Replaced** (§11, [`debian/arch.rs`](../src/debian/arch.rs)) | -| `dpkg-checkbuilddeps` | deps vs installed status | **Medium** — `Dpkg::Deps` grammar (alternatives, arch qualifiers, `` restrictions, versioned Provides subtleties, Multi-Arch facts) + status-file scan | Phase 2; keep `apt-get build-dep`/subprocess until then | +| `dpkg-checkbuilddeps` | deps vs installed status | **Medium** — `Dpkg::Deps` grammar (alternatives, arch qualifiers, `` restrictions, versioned Provides subtleties, Multi-Arch facts) + status-file scan | **Replaced** (§11, [`debian/deps.rs`](../src/debian/deps.rs); wired into the pipeline behind `-D`, source-only builds skip it like dpkg-buildpackage) | | `dpkg-genbuildinfo` | `.buildinfo` | **Medium** — deb822 emit + status snapshot + checksums | Native (see §11, [`buildinfo.rs`](../src/build/buildinfo.rs)) | | `dpkg-genchanges` | `.changes` | **Medium** — deb822 emit + `debian/files` consumption + `.deb` control extraction (ar+tar, trivial with crates) | Native for source uploads (§11, [`changes.rs`](../src/build/changes.rs)); binary aggregation next | | `dpkg-distaddfile`/`debian/files` protocol | build outputs registry | **Trivial** — one append-only line format | Native ([`files.rs`](../src/build/files.rs)) | diff --git a/src/build/mod.rs b/src/build/mod.rs index 6b29814..5b4c731 100644 --- a/src/build/mod.rs +++ b/src/build/mod.rs @@ -28,6 +28,10 @@ pub struct SourceBuildOptions { pub sign_keyid: Option, /// Sign even for an UNRELEASED changelog (`--force-sign`). pub force_sign: bool, + /// Force build-dependency checking even though this is a source-only + /// build (`-D`). `dpkg-buildpackage` skips `dpkg-checkbuilddeps` + /// entirely for source-only builds unless forced. + pub force_dep_check: bool, } /// Artifacts produced by a successful source build. @@ -198,6 +202,26 @@ pub fn run_source_build( &["-I", "-i", "--before-build", "."], &pipeline_env, )?; + + // Build-dependency check (native dpkg-checkbuilddeps equivalent). + // dpkg-buildpackage skips it entirely for source-only builds unless + // forced with -D; unsatisfied dependencies abort with exit status 3. + if opts.force_dep_check { + let check_opts = crate::debian::deps::CheckOpts { + host_arch: arch_vars + .get("DEB_HOST_ARCH") + .cloned() + .unwrap_or_else(|| crate::debian::arch::native().unwrap_or_default()), + build_profiles: profiles.clone(), + ..Default::default() + }; + let report = crate::debian::deps::check_build_depends(&ctrl, &check_opts)?; + if !report.is_ok() { + eprintln!("{}", report.message()); + return Err(Box::new(crate::debian::deps::UnmetBuildDependencies(report))); + } + } + run_command(cwd, "dpkg-source", &["-I", "-i", "-b", "."], &pipeline_env)?; if !dsc_path.exists() { @@ -909,6 +933,158 @@ mod differential_tests { diff_arch_env_one(None); } + /// Differential check of [`crate::debian::deps::check_build_depends`] + /// against real `dpkg-checkbuilddeps` on one fixture: exit status and + /// reported unmet/conflict lists must match. + fn diff_checkbuilddeps_case(control: &str, status: &str, args: &[&str]) { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("control"), control).expect("write control"); + let admindir = dir.path().join("admin"); + fs::create_dir_all(&admindir).expect("mkdir admindir"); + fs::write(admindir.join("status"), status).expect("write status"); + + // Real tool. Profiles are always pinned via -P so the comparison is + // independent of the local vendor defaults; -I skips the vendor + // builtin dependencies (build-essential:native), matching the + // native checker which knows no builtins. All options must precede + // the control-file operand (POSIX-style option parsing). + let output = Command::new("dpkg-checkbuilddeps") + .current_dir(dir.path()) + .arg("--admindir") + .arg(&admindir) + .args(args) + .arg("-I") + .arg("control") + .output() + .expect("run dpkg-checkbuilddeps (is dpkg-dev installed?)"); + let real_exit = output.status.code().unwrap_or(-1); + let real_msg = String::from_utf8_lossy(&output.stderr) + .lines() + .filter_map(|l| l.split_once("error: ").map(|(_, m)| m.trim())) + .collect::>() + .join("\n"); + + // Native checker with equivalent options. + let mut profiles: Vec = Vec::new(); + let mut ignore_arch = false; + let mut ignore_indep = false; + let mut i = 0; + while i < args.len() { + match args[i] { + "-A" => ignore_arch = true, + "-B" => ignore_indep = true, + "-P" => { + i += 1; + profiles = args + .get(i) + .map(|p| p.split(',').map(str::to_string).collect()) + .unwrap_or_default(); + } + _ => {} + } + i += 1; + } + let opts = crate::debian::deps::CheckOpts { + host_arch: crate::debian::arch::native().unwrap_or_else(|_| "amd64".into()), + build_profiles: profiles, + ignore_arch, + ignore_indep, + ignore_builtin: true, + admindir: admindir.clone(), + }; + let control_info = crate::debian::ControlInfo::parse_content(control).expect("parse control"); + let report = crate::debian::deps::check_build_depends(&control_info, &opts) + .expect("native parse failure"); + + let ours_exit = if report.is_ok() { 0 } else { 1 }; + assert_eq!(ours_exit, real_exit, "exit status mismatch for {control:?} {args:?}"); + assert_eq!( + report.message(), + real_msg, + "diagnostics mismatch for {control:?} {args:?}" + ); + } + + /// Matrix of dependency-checking scenarios validated against the real + /// tool: alternatives, version relations, arch/profile restrictions, + /// conflicts and `-A`/`-B`/`-P` flag handling. + #[test] + fn diff_checkbuilddeps_matrix() { + const STATUS: &str = "\ +Package: libc6 +Status: install ok installed +Version: 2.39-0ubuntu8 +Architecture: amd64 + +Package: libfoo-dev +Status: install ok installed +Version: 1.2-3 +Architecture: amd64 + +Package: ma-foreign-pkg +Status: install ok installed +Version: 1.0 +Architecture: i386 +Multi-Arch: foreign + +Package: provider +Status: install ok installed +Version: 5.0 +Architecture: amd64 +Provides: virtual-thing (= 2.0), plain-virtual +"; + const HEAD: &str = "Source: t\nMaintainer: a \n"; + const TAIL: &str = "\nPackage: t\nArchitecture: any\nDescription: x\n y\n"; + + let case = |bd: &str, bc: &str, args: &[&str]| { + let mut control = String::from(HEAD); + if !bd.is_empty() { + control.push_str(&format!("Build-Depends: {bd}\n")); + } + if !bc.is_empty() { + control.push_str(&format!("Build-Conflicts: {bc}\n")); + } + control.push_str(TAIL); + diff_checkbuilddeps_case(&control, STATUS, args); + }; + + // Satisfied / unsatisfied basics. + case("libc6 (>= 1)", "", &["-P", "cross"]); + case("missing-abc", "", &["-P", "cross"]); + case("libc6 (>> 999)", "", &["-P", "cross"]); + // Alternatives. + case("missing-a | libc6", "", &["-P", "cross"]); + case("missing-a | missing-b", "", &["-P", "cross"]); + // Architecture restrictions (host is the native arch). + case("missing-abc [!amd64]", "", &["-P", "cross"]); + case("missing-abc [amd64]", "", &["-P", "cross"]); + // Profile restrictions. + case("missing-abc ", "", &["-P", "stage1"]); + case("missing-abc ", "", &["-P", "cross"]); + case("missing-abc ", "", &["-P", "stage1"]); + // Multi-Arch foreign satisfies unqualified deps. + case("ma-foreign-pkg", "", &["-P", "cross"]); + // Provides: versioned provide satisfying / not satisfying. + case("virtual-thing (>= 1.0)", "", &["-P", "cross"]); + case("virtual-thing (>= 3.0)", "", &["-P", "cross"]); + case("plain-virtual", "", &["-P", "cross"]); + case("plain-virtual (>= 1.0)", "", &["-P", "cross"]); + // Conflicts. + case("", "libc6 (<< 1)", &["-P", "cross"]); + case("", "libc6", &["-P", "cross"]); + case("", "missing-abc", &["-P", "cross"]); + // -A/-B field handling. + let control_ab = format!( + "{HEAD}Build-Depends: libc6\nBuild-Depends-Arch: missing-arch-dep\nBuild-Depends-Indep: missing-indep-dep\n{TAIL}" + ); + diff_checkbuilddeps_case(&control_ab, STATUS, &["-P", "cross"]); + diff_checkbuilddeps_case(&control_ab, STATUS, &["-A", "-P", "cross"]); + diff_checkbuilddeps_case(&control_ab, STATUS, &["-B", "-P", "cross"]); + + // Combined unmet + conflict reporting in one run. + case("missing-one, libc6 (>> 999)", "libfoo-dev", &["-P", "cross"]); + } + /// Differential check of [`crate::debian::version`] against real /// `dpkg --compare-versions` over every ported dpkg test vector and /// every relation operator. diff --git a/src/debian/deps.rs b/src/debian/deps.rs new file mode 100644 index 0000000..ce24b06 --- /dev/null +++ b/src/debian/deps.rs @@ -0,0 +1,1313 @@ +//! 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` +//! () 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, + /// Version constraint, when present. + pub constraint: Option, + /// Bracketed architecture restriction list, when present. + pub arches: Option>, + /// Build-profile restriction formula, in disjunctive normal form: each + /// inner list is a conjunction of (possibly negated) profile names. + pub restrictions: Vec>, +} + +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] `). + 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 = 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 + )) + .expect("valid dependency regex") + }) +} + +fn restriction_group_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| Regex::new(r"<\s*([^>]+?)\s*>").expect("valid restriction regex")) +} + +fn profile_name_regex() -> &'static Regex { + static REGEX: OnceLock = 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 { + 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 = 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, + /// 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) -> 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>, + 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 { + Self::parse_inner(input, opts, false) + } + + fn parse_inner( + input: &str, + opts: &ParseOpts, + reduce_arch_only: bool, + ) -> Result { + 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 { + 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::>() + .join(" | ") + }) + .collect::>() + .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 { + 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::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 { + 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 { + 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>, q: Option<&Vec>) -> 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], q: &[Vec]) -> 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 { + 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, + /// Version of a versioned provide. + pub version: Option, + /// 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>, + provided: HashMap>, +} + +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, + 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 { + 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 { + 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, + /// 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, + /// Violated conflict clauses, canonically rendered. + pub conflicts: Vec, +} + +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 { + 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::>() + .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 , \ +dep2 , \ +dep3 , \ +dep4 , \ +dep5 , dep6 , \ +dep7 | dep8 , \ +dep9 , \ +dep10 , \ +dep11 , \ +dep12 , \ +dep13 , \ +dep14 "; + + 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 , \ +dep2 , \ +dep3 , \ +dep4 , \ +dep5 , \ +dep6 "; + 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 +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 \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 \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"); + } +} diff --git a/src/debian/mod.rs b/src/debian/mod.rs index 8b61192..a977f81 100644 --- a/src/debian/mod.rs +++ b/src/debian/mod.rs @@ -15,6 +15,7 @@ pub mod arch; pub mod changelog; pub mod checksums; pub mod control; +pub mod deps; pub mod files; pub mod version; diff --git a/src/main.rs b/src/main.rs index 4f3a834..9fc07a7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -252,6 +252,13 @@ fn main() { let cwd = current_dir_or_exit(); if let Err(e) = pkh::build::build_source_package(Some(&cwd)) { error!("{}", e); + // Unmet build dependencies/conflicts exit with status 3, + // like dpkg-buildpackage does. + if e.downcast_ref::() + .is_some() + { + std::process::exit(3); + } std::process::exit(1); } }