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
  <profile restriction> 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.
This commit is contained in:
2026-08-24 11:58:06 +02:00
parent 489b2aa29b
commit dfaab0606a
5 changed files with 1498 additions and 1 deletions
+1 -1
View File
@@ -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, `<profiles>` 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, `<profiles>` 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)) |
+176
View File
@@ -28,6 +28,10 @@ pub struct SourceBuildOptions {
pub sign_keyid: Option<String>,
/// 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::<Vec<_>>()
.join("\n");
// Native checker with equivalent options.
let mut profiles: Vec<String> = 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 <a@b.c>\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 <stage1>", "", &["-P", "stage1"]);
case("missing-abc <stage1>", "", &["-P", "cross"]);
case("missing-abc <!stage1>", "", &["-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.
+1313
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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;
+7
View File
@@ -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::<pkh::debian::deps::UnmetBuildDependencies>()
.is_some()
{
std::process::exit(3);
}
std::process::exit(1);
}
}