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