Files
pkh/src/test/expand.rs
T
vhaudiquet 82a15c7426 debian: type the errors of the format primitives
Give every parser of the shared format layer a proper error enum
(thiserror): VersionParseError, ArchError, deps::ParseError (wrapping
the version and arch failures as #[source] so the context chain
survives), ControlParseError, ChecksumParseError, FilesParseError and
TestsControlParseError, each carrying its structured pieces (the
offending string, the stanza number, the field name) instead of a
pre-formatted String.

Display texts are byte-identical to the old format! strings, so
user-facing output and the message-asserting tests are unchanged; the
few tests asserting on error text now render the error first. Flow
boundaries that still take String (build::env, quirks, test::expand)
render with to_string() at the edge — typing those flows is a later
step. ControlInfo's FromStr impl follows parse_content's error type.
2026-10-03 02:17:22 +02:00

249 lines
8.5 KiB
Rust

//! 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<Vec<String>, 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::<Vec<_>>()
.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<String> {
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<Vec<String>, String> {
let opts = ParseOpts::build_deps(host_arch.to_string(), Vec::new());
let deps = Deps::parse(input, &opts).map_err(|e| e.to_string())?;
let mut clauses = Vec::new();
for clause in deps.clauses() {
let kept: Vec<String> = 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<String> {
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<String> {
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"]);
}
}