From af9845c480ffbedc8bd1d7f20bee0f9c192550e1 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Sun, 20 Sep 2026 18:04:13 +0200 Subject: [PATCH] deb: install build-deps per dpkg cross semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the apt-get build-dep passes with a native resolver: the Build-* clauses are evaluated against the context's package state by crate::debian::deps (the dpkg-checkbuilddeps equivalent), and the unsatisfied ones install through explicitly architecture-qualified names. apt's --host-architecture build-dep resolution is coarser than the checker it feeds: unqualified Multi-Arch: same libraries only ever get the host-architecture variant, and there is no way to also satisfy the build-architecture needs of a cross build without a native re-resolution pass — which installs co-install partners that break unpacking for -dev packages whose variants conflict on arch-differing files (curl-config; the libcurl4-gnutls-dev regression). The resolver now applies the same dpkg rules as the checker, preferring the runnable build-architecture variant of Multi-Arch: foreign tools, and passes virtual names through to apt. Build-Conflicts are now checked before anything installs, which the build-dep passes never did, and a failed dose3 diagnosis no longer masks the resolver error (a latent flaw of the passes: binary-only builds have no .dsc for dose-builddebcheck to read). An end-to-end test pins the expected failure: a cross build whose build-dependencies cannot satisfy dpkg's cross semantics aborts, naming the dependency. --- src/deb/local.rs | 622 +++++++++++++++++++++++++++++++++++++++++++---- src/deb/mod.rs | 70 ++++++ 2 files changed, 638 insertions(+), 54 deletions(-) diff --git a/src/deb/local.rs b/src/deb/local.rs index 296b9a3..53929a0 100644 --- a/src/deb/local.rs +++ b/src/deb/local.rs @@ -5,13 +5,15 @@ use crate::deb::{Phase, enter_phase, find_dsc_file}; use crate::logfmt::QuiltClassifier; use crate::report::BuildView; use log::warn; -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::error::Error; use std::path::{Path, PathBuf}; use std::sync::Arc; use crate::apt; use crate::deb::cross; +use crate::debian::control::ControlInfo; +use crate::debian::deps::{Deps, Facts, ParseOpts, PkgRelation}; /// Attach the capture sink to a command when the live UI is active fn cap<'a>( @@ -255,59 +257,22 @@ pub async fn build( install_injected_packages(inject_packages, &env, ctx.clone(), view, &sink)?; } - // Install arch-specific build dependencies - log::debug!("Installing arch-specific build dependencies..."); - enter_phase(view, Phase::InstallingBuildDeps); - let mut cmd = ctx.command("apt-get"); - cmd.current_dir(package_dir_str) - .envs(env.clone()) - .arg("-y") - .arg("build-dep"); - if cross { - cmd.arg(format!("--host-architecture={arch}")); - } - cmd.arg("--arch-only"); - let status = cap(&mut cmd, &sink).arg("./").status()?; - - // If build-dep fails, we try to explain the failure using dose-debcheck - if !status.success() { - view.suspend(); - dose3_explain_dependencies(package, version, arch, build_root, cross, ctx.clone())?; - return Err("Could not install build-dependencies for the build".into()); - } - - // Install arch-independant build dependencies, only if the source declares - // any: without --arch-only this pass resolves the whole Build-Depends field - // too, which is redundant after the first pass and breaks cross builds. - let has_indep_deps = match ctx.read_file(&package_dir.join("debian/control")) { - Ok(control) => control - .lines() - .any(|l| l.to_ascii_lowercase().starts_with("build-depends-indep:")), - Err(e) => { - log::debug!("cannot read debian/control for indep build-deps: {}", e); - true - } - }; - if has_indep_deps { - log::debug!("Installing arch-independant build dependencies..."); - let mut cmd = ctx.command("apt-get"); - cmd.current_dir(package_dir_str) - .envs(env.clone()) - .arg("-y") - .arg("build-dep"); - if cross { - cmd.arg(format!("--host-architecture={arch}")); - } - cmd.arg("./"); - let status = cap(&mut cmd, &sink).status()?; - - // If build-dep fails, we try to explain the failure using dose-debcheck - if !status.success() { - view.suspend(); - dose3_explain_dependencies(package, version, arch, build_root, cross, ctx.clone())?; - return Err("Could not install build-dependencies for the build".into()); - } - } + // Resolve and install the Build-* dependencies with dpkg's cross + // semantics; this replaces the historical `apt-get build-dep` passes, + // whose `--host-architecture` resolution cannot express the + // Multi-Arch-aware variant choice dpkg's checker requires. + install_build_dependencies( + package, + version, + arch, + package_dir_str, + build_root, + cross, + &env, + ctx.clone(), + view, + &sink, + )?; // Run the build step log::debug!("Building (debian/rules build) package..."); @@ -371,6 +336,403 @@ pub async fn build( Ok(artifacts) } +/// One candidate binary package for a dependency name, as reported by +/// `apt-cache show`. +#[derive(Debug, Clone)] +struct Candidate { + /// Debian architecture of the candidate (`amd64`, `arm64`, `all`, ...). + arch: String, + /// Multi-Arch attribute; an absent field means `no`. + multiarch: String, +} + +/// Pure core of the install strategy: the apt install specification that +/// satisfies `rel` among the `candidates` available for its package name. +/// +/// The mapping reproduces the dpkg cross semantics that +/// [`crate::debian::deps`] evaluates against (`Dpkg::Deps::KnownFacts +/// _find_package`), preferring the runnable build-architecture variant +/// whenever dpkg's rules accept several: +/// +/// - unqualified: a `Multi-Arch: foreign` build-architecture candidate +/// (tools: the variant that runs on the build machine), then the +/// host-architecture candidate (the variant dpkg checks unqualified +/// dependencies against), then an `Architecture: all` candidate. A +/// foreign-arch candidate of a non-foreign package satisfies nothing in +/// cross mode — installing it would diverge from the checker; +/// - `:native`: the build-architecture candidate (`Architecture: all` +/// accepted); a `Multi-Arch: foreign` candidate aborts the lookup, +/// like dpkg's checker; +/// - `:any`: a `Multi-Arch: allowed` candidate, host architecture first; +/// - explicit `:arch`: exactly that candidate; +/// - a name with no candidate at all is passed through unqualified, so +/// apt resolves virtual packages (`debhelper-compat`, dbus session +/// alternatives, ...) to a satisfying provider. +/// +/// `None` when no candidate can satisfy the relation per dpkg semantics: +/// the caller falls through to the next alternative of the clause. +fn install_spec_for( + rel: &PkgRelation, + candidates: &[Candidate], + build_arch: &str, + host_arch: &str, +) -> Option { + let qualified = |arch: &str| format!("{}:{}", rel.package, arch); + match rel.arch_qualifier.as_deref() { + Some("native") => { + if candidates + .iter() + .any(|c| c.arch == build_arch && c.multiarch == "foreign") + { + return None; + } + candidates + .iter() + .find(|c| c.arch == build_arch) + .map(|_| qualified(build_arch)) + .or_else(|| { + candidates + .iter() + .find(|c| c.arch == "all") + .map(|_| rel.package.clone()) + }) + } + Some("any") => candidates + .iter() + .filter(|c| c.multiarch == "allowed") + .find(|c| c.arch == host_arch) + .or_else(|| candidates.iter().find(|c| c.multiarch == "allowed")) + .map(|c| qualified(&c.arch)), + Some(qual) => candidates + .iter() + .find(|c| c.arch == qual) + .map(|_| qualified(qual)), + None => { + if candidates.is_empty() { + return Some(rel.package.clone()); + } + candidates + .iter() + .find(|c| c.arch == build_arch && c.multiarch == "foreign") + .map(|_| qualified(build_arch)) + .or_else(|| { + candidates + .iter() + .find(|c| c.arch == host_arch) + .map(|_| qualified(host_arch)) + }) + .or_else(|| { + candidates + .iter() + .find(|c| c.arch == "all") + .map(|_| rel.package.clone()) + }) + } + } +} + +/// Pure core of the resolver loop: the next batch of install +/// specifications, one alternative per still-unsatisfied clause, +/// advancing each clause's cursor past the alternatives that no candidate +/// can satisfy per dpkg semantics. +fn next_install_pass( + clauses: &[Vec], + unsatisfied: &[usize], + cursors: &mut [usize], + candidates: &BTreeMap>, + build_arch: &str, + host_arch: &str, +) -> Vec { + let mut specs = Vec::new(); + for &i in unsatisfied { + let empty = Vec::new(); + while cursors[i] < clauses[i].len() { + let rel = &clauses[i][cursors[i]]; + let cands = candidates.get(&rel.package).unwrap_or(&empty); + let spec = install_spec_for(rel, cands, build_arch, host_arch); + cursors[i] += 1; + if let Some(spec) = spec { + specs.push(spec); + break; + } + } + } + specs +} + +/// Query the candidates available in the build context for `names`: one +/// entry per (package, architecture). The plain name surfaces the +/// build-architecture and `all` candidates; the host-qualified name is +/// what surfaces foreign-arch candidates. Names missing from the indexes +/// and virtual names simply produce no stanza. +fn query_candidates( + ctx: &Arc, + names: &[String], + build_arch: &str, + host_arch: &str, +) -> BTreeMap> { + let mut query: Vec = Vec::new(); + for name in names { + query.push(name.clone()); + if host_arch != build_arch { + query.push(format!("{name}:{host_arch}")); + } + } + let Ok(output) = ctx.command("apt-cache").arg("show").args(&query).output() else { + return BTreeMap::new(); + }; + + let mut map: BTreeMap> = BTreeMap::new(); + for para in crate::debian::control::parse_paragraphs(&String::from_utf8_lossy(&output.stdout)) { + let Some(name) = para.get("Package") else { + continue; + }; + let Some(arch) = para.get("Architecture") else { + continue; + }; + let entry = map.entry(name.to_string()).or_default(); + if entry.iter().any(|c| c.arch == arch) { + continue; + } + entry.push(Candidate { + arch: arch.to_string(), + multiarch: para.get("Multi-Arch").unwrap_or("no").to_string(), + }); + } + map +} + +/// Build architecture inside the build context. +fn context_build_arch(ctx: &Arc) -> String { + ctx.command("dpkg") + .arg("--print-architecture") + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(crate::get_current_arch) +} + +/// Active build profiles for the dependency evaluation: what the build +/// steps run with (`DEB_BUILD_PROFILES`, set to `cross` for cross +/// builds), else the vendor defaults. +fn build_profiles_for(env: &HashMap, ctx: &Arc) -> Vec { + match env.get("DEB_BUILD_PROFILES") { + Some(value) => value + .split(',') + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()) + .collect(), + None => { + let vendor = ctx + .read_file(Path::new("/etc/dpkg/origins/default")) + .ok() + .and_then(|content| crate::build::env::vendor_from_origins_content(&content)) + .unwrap_or_else(crate::build::env::current_vendor); + crate::build::env::resolve_build_profiles(&[], &vendor) + } + } +} + +/// Installed-package facts inside the build context. An unreadable status +/// database (fresh chroot) yields empty facts: everything declared then +/// installs. +fn load_context_facts(ctx: &Arc, build_arch: &str, host_arch: &str) -> Facts { + match ctx.read_file(Path::new("/var/lib/dpkg/status")) { + Ok(content) => Facts::from_status(&content, host_arch, build_arch), + Err(e) => { + log::debug!("cannot read the context dpkg status: {e}"); + Facts::new(host_arch, build_arch) + } + } +} + +/// Resolve and install the Build-* dependencies with dpkg's cross +/// semantics: the clauses of `debian/control` are evaluated natively (the +/// `crate::debian::deps` `dpkg-checkbuilddeps` equivalent) against the +/// context's installed-package state, and the unsatisfied ones install +/// through explicitly architecture-qualified names. +/// +/// Build conflicts abort before anything installs. The loop installs one +/// alternative per still-unsatisfied clause per apt transaction and +/// re-evaluates after each; a clause whose alternatives no candidate can +/// satisfy per dpkg semantics aborts the build. On failure the dose3 +/// diagnosis runs, like the historical `build-dep` passes. +#[allow(clippy::too_many_arguments)] +fn install_build_dependencies( + package: &str, + version: &str, + arch: &str, + package_dir: &str, + build_root: &str, + cross: bool, + env: &HashMap, + ctx: Arc, + view: &dyn BuildView, + sink: &Option>, +) -> Result<(), Box> { + enter_phase(view, Phase::InstallingBuildDeps); + + let host_arch = arch.to_string(); + let build_arch = context_build_arch(&ctx); + let profiles = build_profiles_for(env, &ctx); + + let control_content = ctx + .read_file(&Path::new(package_dir).join("debian/control")) + .map_err(|e| format!("cannot read debian/control: {e}"))?; + let control = ControlInfo::parse_content(&control_content) + .map_err(|e| format!("invalid debian/control in {package_dir}: {e}"))?; + let source = &control.source; + let deps_value = ["Build-Depends", "Build-Depends-Arch", "Build-Depends-Indep"] + .iter() + .filter_map(|f| source.get(f)) + .collect::>() + .join(", "); + let conflicts_value = [ + "Build-Conflicts", + "Build-Conflicts-Arch", + "Build-Conflicts-Indep", + ] + .iter() + .filter_map(|f| source.get(f)) + .collect::>() + .join(", "); + if deps_value.trim().is_empty() && conflicts_value.trim().is_empty() { + return Ok(()); + } + + let parse_opts = ParseOpts { + host_arch: host_arch.clone(), + build_arch: build_arch.clone(), + build_profiles: profiles, + reduce_restrictions: true, + union: false, + build_dep: true, + }; + let clauses: Vec> = Deps::parse(&deps_value, &parse_opts)? + .clauses() + .map(<[PkgRelation]>::to_vec) + .collect(); + let conflict_clauses: Vec> = if conflicts_value.trim().is_empty() { + Vec::new() + } else { + let union_opts = ParseOpts { + union: true, + ..parse_opts.clone() + }; + Deps::parse(&conflicts_value, &union_opts)? + .clauses() + .map(<[PkgRelation]>::to_vec) + .collect() + }; + + let names: Vec = clauses + .iter() + .chain(conflict_clauses.iter()) + .flatten() + .map(|rel| rel.package.clone()) + .collect::>() + .into_iter() + .collect(); + let candidates = query_candidates(&ctx, &names, &build_arch, &host_arch); + + let mut cursors = vec![0usize; clauses.len()]; + loop { + let facts = load_context_facts(&ctx, &build_arch, &host_arch); + + // Build conflicts use the same lookup as dependencies in dpkg's + // checker: a satisfied conflict clause aborts before anything + // installs. + let mut violated = Vec::new(); + for alternatives in &conflict_clauses { + for rel in alternatives { + if facts.evaluate_relation(rel) == Some(true) { + violated.push(rel.output()); + break; + } + } + } + if !violated.is_empty() { + view.suspend(); + if let Err(e) = + dose3_explain_dependencies(package, version, arch, build_root, cross, ctx.clone()) + { + log::debug!("dose-builddebcheck diagnosis failed: {e}"); + } + return Err(format!( + "build dependencies conflict with installed packages: {}", + violated.join(", ") + ) + .into()); + } + + let unsatisfied: Vec = (0..clauses.len()) + .filter(|&i| { + !clauses[i] + .iter() + .any(|rel| facts.evaluate_relation(rel) == Some(true)) + }) + .collect(); + if unsatisfied.is_empty() { + return Ok(()); + } + + let specs = next_install_pass( + &clauses, + &unsatisfied, + &mut cursors, + &candidates, + &build_arch, + &host_arch, + ); + if specs.is_empty() { + view.suspend(); + if let Err(e) = + dose3_explain_dependencies(package, version, arch, build_root, cross, ctx.clone()) + { + log::debug!("dose-builddebcheck diagnosis failed: {e}"); + } + let remaining: Vec = unsatisfied + .iter() + .map(|&i| { + clauses[i] + .iter() + .map(PkgRelation::output) + .collect::>() + .join(" | ") + }) + .collect(); + return Err(format!( + "Could not satisfy build dependencies per dpkg cross semantics: {}", + remaining.join(", ") + ) + .into()); + } + + log::debug!("Installing build dependencies: {:?}", specs); + let mut cmd = ctx.command("apt-get"); + cmd.envs(env.clone()).arg("-y").arg("install"); + for spec in &specs { + cmd.arg(spec); + } + let status = cap(&mut cmd, sink).status()?; + if !status.success() { + view.suspend(); + if let Err(e) = + dose3_explain_dependencies(package, version, arch, build_root, cross, ctx.clone()) + { + log::debug!("dose-builddebcheck diagnosis failed: {e}"); + } + return Err(format!( + "Could not install build-dependencies for the build: {}", + specs.join(" ") + ) + .into()); + } + } +} + /// Collect the binary artifacts (.deb/.udeb) registered by the build in /// `debian/files`, returning their paths inside the build context /// (`/`). `debian/files` is the canonical record of @@ -724,6 +1086,158 @@ mod tests { use super::*; use crate::context::ContextConfig; + fn cand(arch: &str, ma: &str) -> Candidate { + Candidate { + arch: arch.to_string(), + multiarch: ma.to_string(), + } + } + + fn rel(s: &str) -> PkgRelation { + crate::debian::deps::parse_simple(s, true).unwrap() + } + + /// Unqualified dependencies install the dpkg-satisfying variant, + /// preferring the runnable build-architecture one: Multi-Arch: + /// foreign candidates from the build architecture, then the + /// host-architecture candidate dpkg checks them against, then + /// `Architecture: all`. A foreign-arch candidate of a non-foreign + /// package satisfies nothing in cross mode. + #[test] + fn install_spec_unqualified_prefers_runnable_then_host() { + let host = "arm64"; + let build = "amd64"; + + let spec = install_spec_for( + &rel("t"), + &[cand("amd64", "foreign"), cand("arm64", "same")], + build, + host, + ) + .unwrap(); + assert_eq!(spec, "t:amd64"); + + let spec = install_spec_for( + &rel("t"), + &[cand("arm64", "same"), cand("amd64", "no")], + build, + host, + ) + .unwrap(); + assert_eq!(spec, "t:arm64"); + + assert_eq!( + install_spec_for(&rel("t"), &[cand("amd64", "no")], build, host), + None + ); + + let spec = install_spec_for(&rel("t"), &[cand("all", "foreign")], build, host).unwrap(); + assert_eq!(spec, "t"); + + // Native builds (host == build): a plain Multi-Arch: no candidate + // is the host candidate. + let spec = install_spec_for(&rel("t"), &[cand("amd64", "no")], "amd64", "amd64").unwrap(); + assert_eq!(spec, "t:amd64"); + } + + /// Qualified dependencies install exactly the variant their qualifier + /// selects per dpkg semantics; virtual names (no candidates) pass + /// through unqualified for apt to resolve a provider. + #[test] + fn install_spec_qualifiers() { + let host = "arm64"; + let build = "amd64"; + + // :native: the build-architecture candidate; an Architecture: all + // candidate passes under the plain name; a Multi-Arch: foreign + // candidate aborts the lookup, like dpkg's checker. + assert_eq!( + install_spec_for(&rel("t:native"), &[cand("amd64", "no")], build, host).unwrap(), + "t:amd64" + ); + assert_eq!( + install_spec_for(&rel("t:native"), &[cand("all", "no")], build, host).unwrap(), + "t" + ); + assert_eq!( + install_spec_for(&rel("t:native"), &[cand("amd64", "foreign")], build, host), + None + ); + + // :any: a Multi-Arch: allowed candidate, host first. + assert_eq!( + install_spec_for( + &rel("t:any"), + &[cand("amd64", "allowed"), cand("arm64", "allowed")], + build, + host + ) + .unwrap(), + "t:arm64" + ); + assert_eq!( + install_spec_for(&rel("t:any"), &[cand("riscv64", "allowed")], build, host).unwrap(), + "t:riscv64" + ); + assert_eq!( + install_spec_for(&rel("t:any"), &[cand("amd64", "same")], build, host), + None + ); + + // Explicit qualifier: exactly that candidate. + assert_eq!( + install_spec_for(&rel("t:arm64"), &[cand("arm64", "same")], build, host).unwrap(), + "t:arm64" + ); + assert_eq!( + install_spec_for(&rel("t:arm64"), &[cand("amd64", "same")], build, host), + None + ); + + // Virtual package: debhelper-compat and friends. + assert_eq!( + install_spec_for(&rel("t (= 13)"), &[], build, host).unwrap(), + "t" + ); + } + + /// The resolver loop tries the alternatives of a clause in order, + /// falling past the ones no candidate can satisfy, and stops + /// contributing a clause once its alternatives are exhausted. + #[test] + fn next_install_pass_falls_through_alternatives() { + // `a:arm64` has only an amd64 candidate: no candidate satisfies + // the explicit qualifier, so the clause falls through to `t`. + let clauses = vec![ + vec![rel("a:arm64"), rel("t")], + vec![rel("u")], + vec![rel("v")], + ]; + let mut candidates = BTreeMap::new(); + candidates.insert("a".to_string(), vec![cand("amd64", "same")]); + candidates.insert("t".to_string(), vec![cand("arm64", "same")]); + candidates.insert("v".to_string(), vec![cand("amd64", "foreign")]); + let mut cursors = vec![0; clauses.len()]; + + let host = "arm64"; + let build = "amd64"; + let specs = next_install_pass(&clauses, &[0, 1, 2], &mut cursors, &candidates, build, host); + assert_eq!( + specs, + vec![ + "t:arm64".to_string(), + "u".to_string(), + "v:amd64".to_string() + ] + ); + assert_eq!(cursors, vec![2, 1, 1]); + + // An exhausted clause contributes nothing; the loop aborts with + // the remaining clauses when nothing installs any more. + let specs = next_install_pass(&clauses, &[1], &mut cursors, &candidates, build, host); + assert!(specs.is_empty()); + } + #[test] fn detector_matches_local_options_options_and_dashed_spellings() { // The pkh scaffold spelling: bare token in local-options. diff --git a/src/deb/mod.rs b/src/deb/mod.rs index 35b842c..1f7ae9b 100644 --- a/src/deb/mod.rs +++ b/src/deb/mod.rs @@ -739,4 +739,74 @@ mod tests { "arch-independant .deb not produced, got: {deb_files:?}" ); } + + /// A cross build whose build-dependencies cannot be satisfied under + /// dpkg's cross semantics must abort at the install step, naming the + /// unsatisfied dependency: the expected-failure counterpart of the + /// cross builds above. + #[tokio::test] + #[test_log::test] + #[cfg(target_arch = "x86_64")] + async fn test_deb_cross_unsatisfiable_build_dep_fails_end_to_end() { + let temp_dir = tempfile::tempdir().unwrap(); + let pkg_dir = temp_dir.path().join("pkh-crosstest"); + std::fs::create_dir_all(pkg_dir.join("debian/source")).unwrap(); + + std::fs::write( + pkg_dir.join("debian/changelog"), + "pkh-crosstest (1.0) noble; urgency=medium\n\n \ + * Synthetic package declaring an unsatisfiable build-dependency.\n\n \ + -- pkh tests Tue, 15 Sep 2026 08:00:00 +0000\n", + ) + .unwrap(); + + std::fs::write( + pkg_dir.join("debian/control"), + "Source: pkh-crosstest\n\ + Section: devel\n\ + Priority: optional\n\ + Maintainer: pkh tests \n\ + Standards-Version: 4.7.4\n\ + Build-Depends: pkh-no-such-package-xyz\n\ + Architecture: any\n\ + \n\ + Package: pkh-crosstest\n\ + Architecture: any\n\ + Depends: ${misc:Depends}, ${shlibs:Depends}\n\ + Description: Cross-build negative regression package\n \ + Declares a build-dependency absent from the archive.\n", + ) + .unwrap(); + + std::fs::write( + pkg_dir.join("debian/rules"), + "#!/usr/bin/make -f\n%:\n\tdh $@\n", + ) + .unwrap(); + use std::os::unix::fs::PermissionsExt; + let rules = pkg_dir.join("debian/rules"); + let mut perms = std::fs::metadata(&rules).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&rules, perms).unwrap(); + + std::fs::write(pkg_dir.join("debian/source/format"), "3.0 (native)\n").unwrap(); + + let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local).unwrap()); + + let err = crate::deb::build_binary_package(DebBuildOptions { + arch: Some("arm64".to_string()), + series: Some("noble".to_string()), + cwd: Some(pkg_dir), + cross: true, + ctx: Some(ctx), + ..Default::default() + }) + .await + .expect_err("an unsatisfiable build-dependency must fail the cross build"); + + assert!( + err.to_string().contains("pkh-no-such-package-xyz"), + "error should name the unsatisfied dependency: {err}" + ); + } }