//! DEP-8 `Depends:` substitution: `@`, `@builddeps@` and `@recommends@` //! expanded into concrete package specs for `apt-get install`. //! //! The built binaries themselves are installed separately (from the //! staged `.deb` files, so tests exercise exactly what pkh built); //! expansion substitutes bare binary *names*, which apt then resolves //! against the installed binaries first and the archive second. use crate::debian::control::ControlInfo; use crate::debian::deps::{Deps, ParseOpts}; /// Expand a test stanza's `Depends:` value into apt package specs. /// /// `raw` is the value as written (`None` = absent, the DEP-8 default /// `@`). The substitutions, per DEP-8: /// /// - `@` in an or-clause replicates the clause once per binary package /// built from the source, with the name substituted (other /// alternatives and version constraints are kept); /// - `@builddeps@` becomes the source's Build-Depends, /// Build-Depends-Indep and Build-Depends-Arch plus `build-essential`; /// - `@recommends@` becomes the Recommends of every binary package, /// with `${...}` substvar references dropped (autopkgtest's /// "variables stripped") but versions kept. /// /// Returns one apt spec per comma-separated clause, in order; an empty /// result means nothing to install. pub fn expand_depends( raw: Option<&str>, control: &ControlInfo, binaries: &[String], host_arch: &str, ) -> Result, String> { let raw = raw.unwrap_or("@"); let mut specs = Vec::new(); for token in split_top_level_commas(raw) { match token.as_str() { "@builddeps@" => { for field in ["Build-Depends", "Build-Depends-Indep", "Build-Depends-Arch"] { if let Some(value) = non_empty(control.source.get(field)) { for clause in split_top_level_commas(value) { if !clause.is_empty() { specs.push(clause); } } } } specs.push("build-essential".to_string()); } "@recommends@" => { for paragraph in &control.binaries { if let Some(value) = non_empty(paragraph.get("Recommends")) { specs.extend(name_only_clauses(value, host_arch)?); } } } token if token.split_whitespace().any(|word| word == "@") => { // Textual substitution: DEP-8 @ is a word-level // placeholder the deps grammar does not know. The clause // replicates once per binary with the name substituted, // constraints and alternatives kept verbatim. for binary in binaries { specs.push( token .split_whitespace() .map(|word| { if word == "@" { binary.clone() } else { word.to_string() } }) .collect::>() .join(" "), ); } } token => specs.push(token.to_string()), } } Ok(specs) } /// Split on commas that sit outside any parentheses: or-clauses carry /// their own commas inside `(...)` constraints, top-level commas separate /// dependencies. fn split_top_level_commas(input: &str) -> Vec { let mut parts = Vec::new(); let mut depth = 0usize; let mut current = String::new(); for character in input.chars() { match character { '(' => { depth += 1; current.push(character); } ')' => { depth = depth.saturating_sub(1); current.push(character); } ',' if depth == 0 => { parts.push(current.trim().to_string()); current.clear(); } _ => current.push(character), } } parts.push(current.trim().to_string()); parts } /// Render each or-clause of a dependency list, dropping alternatives /// that are `${...}` substvar references (autopkgtest's "variables /// stripped") and keeping everything else verbatim. fn name_only_clauses(input: &str, host_arch: &str) -> Result, String> { let opts = ParseOpts::build_deps(host_arch.to_string(), Vec::new()); let deps = Deps::parse(input, &opts)?; let mut clauses = Vec::new(); for clause in deps.clauses() { let kept: Vec = clause .iter() .filter(|relation| !relation.package.contains('$')) .map(|relation| relation.output()) .collect(); if !kept.is_empty() { clauses.push(kept.join(" | ")); } } Ok(clauses) } /// A field value with its surrounding whitespace trimmed; `None` when the /// field is absent or whitespace-only. fn non_empty(value: Option<&str>) -> Option<&str> { value.map(str::trim).filter(|value| !value.is_empty()) } /// The binary package names declared by a control file, in stanza order. pub fn binary_names(control: &ControlInfo) -> Vec { control .binaries .iter() .filter_map(|paragraph| paragraph.get("Package")) .map(|name| name.trim().to_string()) .collect() } #[cfg(test)] mod tests { use super::*; use std::str::FromStr; fn control() -> ControlInfo { ControlInfo::from_str( "Source: hello\n\ Build-Depends: debhelper-compat (= 13),\n meson\n\ Build-Depends-Indep: dh-python\n\ \n\ Package: hello\n\ Architecture: any\n\ Recommends: hello-doc, pulseaudio (>= 15)\n\ \n\ Package: hello-doc\n\ Architecture: all\n\ Recommends: hello\n", ) .unwrap() } fn binaries() -> Vec { vec!["hello".to_string(), "hello-doc".to_string()] } /// The default (absent) Depends is `@`: one spec per binary. #[test] fn default_is_at() { let specs = expand_depends(None, &control(), &binaries(), "amd64").unwrap(); assert_eq!(specs, ["hello", "hello-doc"]); } /// An or-clause containing @ replicates once per binary, keeping the /// other alternatives and version constraints. #[test] fn or_clauses_replicate_per_binary() { let specs = expand_depends( Some("@ (>= 1.0) | fallback, jq"), &control(), &binaries(), "amd64", ) .unwrap(); assert_eq!( specs, [ "hello (>= 1.0) | fallback", "hello-doc (>= 1.0) | fallback", "jq" ] ); } /// @builddeps@ pulls the three Build-* fields plus build-essential. #[test] fn builddeps_expansion() { let specs = expand_depends(Some("@builddeps@, @"), &control(), &binaries(), "amd64").unwrap(); assert_eq!( specs, [ "debhelper-compat (= 13)", "meson", "dh-python", "build-essential", "hello", "hello-doc" ] ); } /// @recommends@ collects every binary's Recommends, stripped to names. #[test] fn recommends_expansion() { let specs = expand_depends(Some("@recommends@"), &control(), &binaries(), "amd64").unwrap(); assert_eq!(specs, ["hello-doc", "pulseaudio (>= 15)", "hello"]); } /// Plain dependencies pass through untouched; a token mentioning @ in /// a version constraint is still a substitution. #[test] fn plain_tokens_pass_through() { let specs = expand_depends( Some("ca-certificates, python3:any"), &control(), &binaries(), "amd64", ) .unwrap(); assert_eq!(specs, ["ca-certificates", "python3:any"]); } /// Commas inside version constraints do not split clauses. #[test] fn parenthesised_commas_stay_together() { let specs = expand_depends(Some("foo (>= 1) , bar"), &control(), &binaries(), "amd64").unwrap(); assert_eq!(specs, ["foo (>= 1)", "bar"]); } }