Files
pkh/src/test/expand.rs
T
vhaudiquet 299b5c28c3 test: add pkh test, the native DEP-8 runner for local and chroot
The DEP-8 run-time contract is one page, so pkh implements it natively
instead of wrapping autopkgtest (see plans/pkh-test.md): the testbed is
a pkh context — chroot mode reuses the deb ephemeral chroots and their
cached mmdebstrap tarballs, local mode runs on the current context —
and the binaries under test are the freshness-guarded pkh deb output
next to the tree, rebuilt through the deb flow when stale or missing.

Per test: restrictions gate on the testbed (isolation-machine,
needs-reboot and container isolation skip with a pointer at --mode vm),
the stanza Depends are expanded (@, @builddeps@, @recommends@) and
installed, then the script or Test-Command runs in the staged tree with
the AUTOPKGTEST_* (and legacy ADT_*) environment, a per-test timeout
and the stderr/exit-77 verdict rules; flaky tests retry once. stdout,
stderr and $AUTOPKGTEST_ARTIFACTS are collected per test under
/var/tmp/pkh/tests/ (pruned separately). --shell/--shell-fail drop
into the testbed for debugging.

pin_pocket and install_injected_packages become crate-internal helpers
shared with the deb flow, which they mirror.
2026-09-27 21:00:34 +02:00

249 lines
8.4 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)?;
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"]);
}
}