/// Local binary package building /// Directly calling 'debian/rules' in current context use crate::context::{Context, ContextCommand, LineSink}; use crate::deb::{BuildProfiler, Phase, enter_phase, find_dsc_file}; use crate::logfmt::QuiltClassifier; use crate::report::BuildView; use log::warn; 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::deb::session; use crate::debian::control::ControlInfo; use crate::debian::deps::{Deps, Facts, ParseOpts, PkgRelation}; use std::time::Instant; /// Attach the capture sink to a command when the live UI is active fn cap<'a>( cmd: &'a mut ContextCommand<'a>, sink: &Option>, ) -> &'a mut ContextCommand<'a> { if let Some(s) = sink { cmd.capture(s.clone()); } cmd } /// The input stamp of the patch phase, computed from the host tree: the /// only tree phase the record journal tracks (the environment phases /// rerun in every fresh chroot and are not recorded). fn patch_stamp(session: &session::Session) -> Option { let host_tree = PathBuf::from(session.manifest().host_tree); session::patches_stamp(&host_tree) } /// The ccache size cap of a build: `$PKH_CCACHE_MAXSIZE`, default 10G /// (the unit syntax of ccache's own configuration: `10G`, `512M`, ...). fn ccache_maxsize() -> String { std::env::var("PKH_CCACHE_MAXSIZE").unwrap_or_else(|_| "10G".to_string()) } /// Wire the build environment to the shared ccache (on by default, /// `pkh deb --no-ccache` skips it entirely): the compilers resolve to /// ccache through a PATH shim, the cache is the identity's shared /// directory mounted in the chroot, and the cache checks are keyed on /// content. /// /// Mechanics: the tarball extraction preserves mtimes, so the toolchain /// and libc headers (inside the buildd tarball) stay older than the /// carried objects and make keeps them; the build dependencies /// reinstall fresh each run, so their headers are newer and make /// rebuilds their dependents — ccache turns those rebuilds into hits, /// and a real archive library change is a cache miss that rebuilds /// them (the correct, if slow, path). No mtime clamping anywhere: /// clamping would silence exactly that signal. /// /// `CCACHE_COMPILERCHECK=content` is critical: the default is /// mtime-based and would never hit across fresh chroots, where every /// toolchain's mtime is the extraction's. pub(crate) fn apply_ccache_env(env: &mut HashMap, enabled: bool, maxsize: &str) { if !enabled { return; } // The shim shadows the real compilers with ccache invocations; // the rest of PATH keeps resolving the other build tools. let path = env .get("PATH") .cloned() .or_else(|| std::env::var("PATH").ok()) .unwrap_or_default(); env.insert( "PATH".to_string(), format!("{}:{path}", crate::deb::ephemeral::CCACHE_SHIM_DIR), ); env.insert( "CCACHE_DIR".to_string(), crate::deb::ephemeral::CCACHE_MOUNT.to_string(), ); env.insert("CCACHE_COMPILERCHECK".to_string(), "content".to_string()); env.insert("CCACHE_MAXSIZE".to_string(), maxsize.to_string()); env.insert( "CCACHE_BASEDIR".to_string(), crate::deb::session::BUILD_ROOT.to_string(), ); env.insert("CCACHE_NOHASHDIR".to_string(), "true".to_string()); } #[allow(clippy::too_many_arguments)] /// The unprivileged user the rules run as inside a pkh-managed chroot: /// Launchpad's buildds and sbuild compile as a non-root user, and packages /// rely on it (coreutils' configure refuses to run as root). The user is /// created on demand in the build context; contexts pkh does not control /// (ssh, schroot) keep running the rules as their own user. pub const BUILD_USER: &str = "pkh-build"; /// Ensure [`BUILD_USER`] exists in the build context and owns everything /// the rules must write: the staged tree (whose parent is where /// dh_builddeb drops the .debs), the scratch HOME and the shared ccache /// directory. Returns the (uid, gid) for the setpriv invocation. Ownership /// is re-applied on every build: a resumed build stages the tree fresh /// from the host, where everything is root-owned again. fn ensure_build_user( ctx: &Arc, workroot: &Path, ccache_dir: Option<&str>, ) -> Result<(String, String), Box> { // Through sh with the stderr dropped: a missing user is the expected // state in a fresh chroot, not a message the build log needs. let id_of = |ctx: &Arc, what: &str| { ctx.command("sh") .arg("-c") .arg(format!("id {what} {BUILD_USER} 2>/dev/null")) .output() }; let probe = id_of(ctx, "-u").map_err(|e| format!("cannot look up {BUILD_USER}: {e}"))?; let fresh = !probe.status.success(); if fresh { create_build_user(ctx)?; } let mut ids = (String::new(), String::new()); for (flag, slot) in [("-u", 0), ("-g", 1)] { let output = id_of(ctx, flag).map_err(|e| format!("cannot look up {BUILD_USER}: {e}"))?; if !output.status.success() { return Err(format!("cannot resolve the ids of '{BUILD_USER}'").into()); } let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); if slot == 0 { ids.0 = value; } else { ids.1 = value; } } // The shared ccache directory moves once, when the user is fresh: it // was root-owned by earlier root builds, and root keeps writing // through the new ownership anyway. if fresh && let Some(dir) = ccache_dir { let _ = ctx .command("chown") .arg("-R") .arg(format!("{}:{}", ids.0, ids.1)) .arg(dir) .status(); } for dir in [workroot, Path::new("/tmp/pkh-home")] { let status = ctx .command("chown") .arg("-R") .arg(format!("{}:{}", ids.0, ids.1)) .arg(dir) .status() .map_err(|e| format!("cannot hand {} to {BUILD_USER}: {e}", dir.display()))?; if !status.success() { return Err(format!("cannot hand '{}' to '{BUILD_USER}'", dir.display()).into()); } } Ok(ids) } /// Create [`BUILD_USER`] in the build context. Minimal mmdebstrap chroots /// carry no passwd package (no useradd), so the passwd and group entries /// are appended directly when needed, as sbuild's build-user setup does. fn create_build_user(ctx: &Arc) -> Result<(), Box> { let have_useradd = ctx .command("sh") .arg("-c") .arg("command -v useradd") .output() .is_ok_and(|out| out.status.success() && !out.stdout.is_empty()); if have_useradd { let status = ctx .command("useradd") .arg("--home-dir") .arg("/tmp/pkh-home") .arg("--shell") .arg("/usr/sbin/nologin") .arg("--user-group") .arg(BUILD_USER) .status() .map_err(|e| format!("cannot run useradd: {e}"))?; if !status.success() { return Err(format!("useradd failed for '{BUILD_USER}'").into()); } return Ok(()); } // The first free id from 64500 on: below nobody (65534), above the // range chroots hand to installed users. let passwd = ctx.read_file(Path::new("/etc/passwd"))?; let group = ctx.read_file(Path::new("/etc/group"))?; let used = |text: &str, id: u32| { text.lines().any(|line| { line.split(':') .nth(2) .is_some_and(|field| field == id.to_string()) }) }; let uid = (64500..=65500) .find(|id| !used(&passwd, *id) && !used(&group, *id)) .ok_or("no free uid for the build user")?; for (file, entry) in [ ("/etc/group", format!("{BUILD_USER}:x:{uid}:")), ( "/etc/passwd", format!("{BUILD_USER}:x:{uid}:{uid}:/tmp/pkh-home:/usr/sbin/nologin"), ), ] { let status = ctx .command("sh") .arg("-c") .arg(format!("echo {} >> {file}", shlex_quote(&entry))) .status() .map_err(|e| format!("cannot append to {file}: {e}"))?; if !status.success() { return Err(format!("cannot append the {BUILD_USER} entry to {file}").into()); } } Ok(()) } /// Single-quote a string for /bin/sh. fn shlex_quote(value: &str) -> String { format!("'{}'", value.replace('\'', r"'\''")) } /// The rules command for the given user: `setpriv` drops to the build user /// without touching the environment (a login shell would reset it), so the /// dpkg architecture variables and DEB_BUILD_OPTIONS survive the /// transition; without a user the rules run as the context's own. fn rules_command<'a>( ctx: &'a Arc, user: Option<&(String, String)>, program: &str, ) -> ContextCommand<'a> { match user { Some((uid, gid)) => { let mut cmd = ctx.command("setpriv"); cmd.arg(format!("--reuid={uid}")) .arg(format!("--regid={gid}")) .arg("--init-groups") .arg("--") .arg(program); cmd } None => ctx.command(program), } } /// The fakeroot state file shared by the rules steps of a rootless /// build, in the staged build root: outside the package tree, so it /// pollutes neither the sources nor the collected artifacts. fn fakeroot_state_file(build_root: &str) -> PathBuf { Path::new(build_root).join(".pkh-fakeroot.state") } #[allow(clippy::too_many_arguments)] pub async fn build( package: &str, version: &str, arch: &str, series: &str, pocket: Option<&str>, mirror: Option<&str>, build_root: &str, package_dir: &Path, cross: bool, ppa: &[String], inject_packages: &[String], ctx: Arc, view: &dyn BuildView, jobs: Option, session: Option<&session::Session>, build_resume: bool, ccache: bool, // Some(&BUILD_USER) hands the rules to an unprivileged build user // (rootless builds); none runs them as the build context's own user. build_user: Option<&str>, // Accumulator of the measured dependency-installation and build // durations; the caller records it on the build record. timings: &mut session::BuildTimings, // The per-phase profile the caller records on the build record: // fed by every enter_phase call of the build flow. profiler: &mut BuildProfiler, ) -> Result, Box> { let sink: Option> = view.sink(); // Journal stamp for a record build; recording is a no-op without one. let stamps = session.and_then(patch_stamp); // Environment let mut env = HashMap::::new(); env.insert("LANG".to_string(), "C".to_string()); env.insert("DEBIAN_FRONTEND".to_string(), "noninteractive".to_string()); apply_ccache_env(&mut env, ccache, &ccache_maxsize()); // Parallel building: honor an explicit -j/--jobs count, otherwise detect // the number of cores available inside the build context (nproc). let num_cores = match jobs { Some(j) => j, None => ctx .command("nproc") .output() .map(|output| { if output.status.success() { String::from_utf8_lossy(&output.stdout) .trim() .parse::() .unwrap_or(1) } else { 1 // Default to 1 if nproc fails } }) .unwrap_or(1), // Default to 1 if we can't execute the command }; // Build options: parallel, disable tests by default env.insert( "DEB_BUILD_OPTIONS".to_string(), format!("parallel={} nocheck", num_cores), ); // The build context inherits pkh's environment: without an override, // HOME is the invoking user's host path, which does not exist inside // the chroot - tools keying user config on it (texi2dvi's TeX live // setup, anything writing dotfiles) then fail mid-build. sbuild points // HOME at a non-writable path, Launchpad's buildd user at a real one; // a writable scratch directory matches the latter. env.insert("HOME".to_string(), "/tmp/pkh-home".to_string()); let status = ctx .command("mkdir") .arg("-p") .arg("/tmp/pkh-home") .status() .map_err(|e| format!("cannot create the build HOME: {e}"))?; if !status.success() { return Err("cannot create the build HOME (/tmp/pkh-home)".into()); } if cross { log::debug!("Setting up environment for local cross build..."); cross::setup_environment(&mut env, arch, ctx.clone())?; cross::ensure_repositories(arch, series, pocket, mirror, ctx.clone())?; } else { // sbuild-style environment for debian/rules: the rules run outside // dpkg-buildpackage, which exports none of the dpkg architecture // variables — packages using $(DEB_HOST_MULTIARCH) and friends // without including /usr/share/dpkg/architecture.mk would expand // them empty. Cross builds already carry the target's set from // cross::setup_environment. let output = ctx .command("dpkg-architecture") .output() .map_err(|e| format!("cannot run dpkg-architecture: {e}. Is 'dpkg-dev' installed?"))?; if !output.status.success() { return Err( format!("'dpkg-architecture' failed with status: {}", output.status).into(), ); } let dpkg_architecture = String::from_utf8(output.stdout) .map_err(|e| format!("Invalid UTF-8 in dpkg-architecture output: {e}"))?; cross::parse_dpkg_architecture_output(&dpkg_architecture, &mut env); } let mut sources = apt::sources::load(Some(ctx.clone()))?; let mut modified = false; let mut added_ppas: Vec<(&str, &str)> = Vec::new(); // Add PPA repositories if specified for ppa_str in ppa { let (ppa_user, ppa_name) = crate::package_info::split_ppa(ppa_str)?; let base_url = crate::package_info::ppa_to_base_url(ppa_user, ppa_name); // Add new PPA source if not found if !sources.iter().any(|s| s.uri.contains(&base_url)) { // Get host and target architectures let host_arch = crate::get_current_arch(); let target_arch = arch; // Create architectures list with both host and target if different let mut architectures = vec![host_arch.clone()]; if host_arch != *target_arch { architectures.push(target_arch.to_string()); } // Create suite list with all Ubuntu series let suites = vec![series.to_string()]; let new_source = crate::apt::sources::SourceEntry { enabled: true, kind: crate::apt::sources::SourceKind::Deb, components: vec!["main".to_string()], architectures: architectures.clone(), signed_by: None, trusted: None, suite: suites, uri: base_url, // No origin: saved to the pkh-owned added-sources file origin: None, }; sources.push(new_source); modified = true; added_ppas.push((ppa_user, ppa_name)); log::info!( "Added PPA: {} for series {} with architectures {:?}", ppa_str, series, architectures ); } } // UBUNTU: Ensure the 'universe' component is enabled on official // Ubuntu sources (many build dependencies live there). The old // `uri.contains("ubuntu")` gate also caught third-party repositories // whose URL merely mentions Ubuntu; the mirror-data check leaves them // alone. 'universe' is only added when Ubuntu's component list still // carries it. A mirror-pinned source (the tarball was bootstrapped // from the mirror) gets the same treatment: it IS the archive here. let ubuntu_components = crate::distro_info::get_dist_components("ubuntu")?; if ubuntu_components.iter().any(|c| c == "universe") { for source in &mut sources { if (crate::distro_info::is_official_source("ubuntu", &source.uri) || mirror.is_some_and(|m| source.uri == m)) && !source.components.contains(&"universe".to_string()) { source.components.push("universe".to_string()); modified = true; } } } // Enable the requested pocket on archive sources, so build-dependencies // are resolved from that pocket if let Some(pocket_name) = pocket { let pocket_suite = format!("{series}-{pocket_name}"); log::info!("Enabling pocket '{}' for build dependencies", pocket_suite); // Debian serves security updates from a dedicated archive: appending // the suite to the main mirror would 404 on 'apt-get update' (ubuntu // serves every pocket from the same archive, where appending works) let security_archive = sources.iter().position(|source| { source.uri.contains("deb.debian.org/debian") && !source.uri.contains("debian-security") && source.suite.iter().any(|suite| suite == series) }); if pocket_name == "security" && let Some(index) = security_archive { let mut security = sources[index].clone(); security.uri = sources[index] .uri .replace("deb.debian.org/debian", "deb.debian.org/debian-security"); security.suite = vec![pocket_suite.clone()]; sources.push(security); modified = true; } else { for source in &mut sources { if crate::deb::is_archive_source_pinned(&source.uri, mirror) && !source.suite.contains(&pocket_suite) { source.suite.push(pocket_suite.clone()); modified = true; } } } // 'proposed' pockets are marked 'NotAutomatic' in their Release file, // giving them an apt priority of 1: without an explicit pin, apt // would ignore them even when enabled if pocket_name.starts_with("proposed") { pin_pocket(&pocket_suite, &ctx)?; } } if modified { // Each entry is written back to its origin file in its own format; // new PPA entries go to the pkh-owned added-sources file apt::sources::save(Some(ctx.clone()), sources)?; // Download and import PPA keys for all added PPAs for (user, ppa_name) in added_ppas { if let Err(e) = crate::apt::keyring::download_trust_ppa_key(Some(ctx.clone()), user, ppa_name).await { warn!( "Failed to download PPA key for {}/{}: {}", user, ppa_name, e ); } } } // Update package lists log::debug!("Updating package lists for local build..."); enter_phase(view, Phase::UpdatingPackageLists, profiler); let started = Instant::now(); let status = cap( ctx.command("apt-get").envs(env.clone()).arg("update"), &sink, ) .status() .map_err(|e| { format!( "Failed to run 'apt-get update' inside the build context: {}. \ If this is a local build, make sure apt-get is available and \ try executing with sudo.", e ) })?; timings.add_deps(started.elapsed()); if !status.success() { return Err("apt-get update failed inside the build context. \ If this is a local build, try executing with sudo, \ or re-run with RUST_LOG=debug for more details." .into()); } // Install essential packages log::debug!("Installing essential packages for local build..."); let mut cmd = ctx.command("apt-get"); cmd.envs(env.clone()) .arg("-y") .arg("install") .arg("build-essential") .arg("dose-builddebcheck") .arg("fakeroot"); // The compiler cache comes free from the shared download cache; the // build env wires the compilers to it (see apply_ccache_env). if ccache { cmd.arg("ccache"); } if cross { cmd.arg(format!("crossbuild-essential-{arch}")); cmd.arg(format!("libc6-{arch}-cross")); cmd.arg(format!("libc6-dev-{arch}-cross")); cmd.arg("dpkg-cross"); cmd.arg(format!("libc6:{arch}")); cmd.arg(format!("libc6-dev:{arch}")); } enter_phase(view, Phase::InstallingEssentials, profiler); let started = Instant::now(); let status = cap(&mut cmd, &sink).status()?; timings.add_deps(started.elapsed()); if !status.success() { return Err("Could not install essential packages for the build".into()); } // The package directory was resolved by the caller (the staged copy of // the tree the user pointed at, or the name-pattern search fallback) let package_dir_str = package_dir .to_str() .ok_or("Invalid package directory path")?; // Reproducibility: export SOURCE_DATE_EPOCH from the changelog entry, // like dpkg-buildpackage does. match ctx.read_file(&package_dir.join("debian/changelog")) { Ok(content) => { if let Ok(entry) = crate::debian::parse_changelog_entry_from_str(&content) { env.insert("SOURCE_DATE_EPOCH".to_string(), entry.timestamp.to_string()); } } Err(e) => log::debug!("cannot read changelog for SOURCE_DATE_EPOCH: {}", e), } // Apply quilt patches if the package provides a patch series. On a // build resuming over a carried upperdir the previous attempt's // patches may still be applied: pop them first when the patch tree // changed since. let patch_stamp = stamps.clone(); let pop_patches = build_resume && { let previous = session .and_then(|s| s.manifest().phases.get("patches").map(|p| p.stamp.clone())) .unwrap_or(None); let applied = ctx .exists(&Path::new(package_dir_str).join(".pc/applied-patches")) .unwrap_or(false); applied && previous != patch_stamp }; apply_quilt_patches( package_dir_str, &env, ctx.clone(), view, &sink, pop_patches, profiler, )?; if let Some(s) = session { s.record_phase("patches", patch_stamp); } // Install injected packages if specified if !inject_packages.is_empty() { install_injected_packages( inject_packages, &env, ctx.clone(), view, &sink, timings, profiler, )?; } // 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, series, package_dir_str, build_root, cross, &env, ctx.clone(), view, &sink, timings, profiler, )?; // (The build-dependency phase reruns in every fresh chroot: nothing // to journal.) // A build resuming over a carried upperdir continues in the previous // attempt's tree. Packages whose `debian/rules` keeps an // input-untracked stamp cache (quirk: `resume_clear`, e.g. the // kernels' `debian/stamps`, whose flavour config rule exports .config // with no config prerequisite) would build from the previous // attempt's configuration: drop the listed caches so the wrapper // steps re-run over the incremental inner builds. if build_resume { for rel in crate::quirks::get_resume_clear_paths(package, series) { let path = Path::new(package_dir_str).join(&rel); if !ctx.exists(&path).unwrap_or(false) { continue; } log::info!("Dropping the resumed build's cached '{rel}' (quirk)"); let status = ctx .command("rm") .arg("-rf") .arg(&path) .status() .map_err(|e| format!("cannot drop '{rel}': {e}"))?; if !status.success() { return Err(format!("cannot drop '{rel}'").into()); } } } // dpkg-buildpackage normally exports the rules' root requirement from // debian/control; running the rules directly does not, and tools key // their assembly strategy on it — without it debputy refuses to // assemble packages shipping root-owned paths at all (apache2). Policy // defaults to 'binary-targets' when control does not declare it. let control = ctx .command("cat") .current_dir(package_dir_str) .arg("debian/control") .output() .map_err(|e| format!("cannot read debian/control: {e}"))?; let rules_requires_root = String::from_utf8_lossy(&control.stdout) .lines() .find(|line| { line.to_ascii_lowercase() .starts_with("rules-requires-root:") }) .and_then(|line| line.split_once(':')) .map(|(_, value)| value.trim().to_string()) .unwrap_or_else(|| "binary-targets".to_string()); env.insert("DEB_RULES_REQUIRES_ROOT".to_string(), rules_requires_root); // Rootless builds: the rules (fakeroot-wrapped, see below) run as an // unprivileged user owning the staged tree. Everything before this // point - dependency installation, quilt patch application, staging // cleanup - legitimately needs the context's root and stays with it. let rule_ids = match build_user { Some(_) => { let workroot = Path::new(package_dir_str) .parent() .ok_or("cannot find the staged tree's parent directory")? .to_path_buf(); let ccache_dir = env.get("CCACHE_DIR").cloned(); Some(ensure_build_user(&ctx, &workroot, ccache_dir.as_deref())?) } None => None, }; // Rootless: the rules run under fakeroot with one session state // shared between the build and binary steps, the way // dpkg-buildpackage -rfakeroot wraps every rules target. An // ownership change made during the build (a sysroot chown) then // succeeds — faked — and carries into the .deb, instead of failing // per file with EPERM spam. With the context's own user the build // step runs bare; the binary step keeps its unconditional wrap. let fakeroot_state = fakeroot_state_file(build_root); if rule_ids.is_some() { // A state left by an interrupted previous attempt must not // seed this session. let _ = ctx.command("rm").arg("-f").arg(&fakeroot_state).status(); } let mut rules = match rule_ids.as_ref() { Some(_) => { let mut cmd = rules_command(&ctx, rule_ids.as_ref(), "fakeroot"); cmd.arg("-s").arg(&fakeroot_state).arg("debian/rules"); cmd } None => rules_command(&ctx, None, "debian/rules"), }; // Run the build step log::debug!("Building (debian/rules build) package..."); enter_phase(view, Phase::Building, profiler); let started = Instant::now(); let status = cap( rules .current_dir(package_dir_str) .envs(env.clone()) .arg("build"), &sink, ) .status()?; timings.add_build(started.elapsed()); if !status.success() { return Err("Error while building the package".into()); } if let Some(s) = session { s.record_phase("build", None); } // On a resumed build the previous attempt's debian/files would make // the artifact collection below surface its outputs: only the new // attempt's registration may remain. if build_resume { let _ = ctx .command("rm") .arg("-f") .arg(Path::new(package_dir_str).join("debian/files")) .status(); } // Run the 'binary' step to produce deb enter_phase(view, Phase::ProducingBinaries, profiler); let started = Instant::now(); let mut rules = rules_command(&ctx, rule_ids.as_ref(), "fakeroot"); if rule_ids.is_some() { // Pick up the ownership changes the build step recorded. rules .arg("-i") .arg(&fakeroot_state) .arg("-s") .arg(&fakeroot_state); } let status = cap( rules .current_dir(package_dir_str) .envs(env.clone()) .arg("debian/rules") .arg("binary"), &sink, ) .status()?; timings.add_build(started.elapsed()); if !status.success() { return Err( "Error while building the binary artifacts (.deb) from the built package".into(), ); } // Collect the exact set of artifacts produced by this build so the caller // retrieves only those: the binary packages registered in debian/files // (the contract between dh_builddeb/dpkg-gencontrol and the artifact // generators) plus the .buildinfo/.changes generated below. This avoids // globbing the build root, which would also surface stale files copied // alongside the package tree. let mut artifacts = collect_binary_artifacts(&ctx, package_dir_str, build_root)?; // Generate the upload metadata (.buildinfo + .changes) natively, the // equivalent of dpkg-genbuildinfo -b + dpkg-genchanges -b, consuming // debian/files produced by the build. Failures are logged but do not // discard the produced binaries. match generate_upload_metadata(package_dir_str, build_root, arch, cross, &env, &ctx) { Ok((buildinfo, changes)) => { log::info!( "generated upload metadata: {} and {}", buildinfo.display(), changes.display() ); artifacts.push(buildinfo); artifacts.push(changes); } Err(e) => { warn!("failed to generate .buildinfo/.changes: {}", e); } } if let Some(s) = session { s.record_phase("binary", None); } // Trim the shared download cache before the chroot goes away: drop // the .debs apt can no longer download (removed from the archive), // keep the rest for the next run of this target. Best-effort — a // failure costs cache space, never the build. let status = cap( ctx.command("apt-get") .envs(env.clone()) .arg("-y") .arg("autoclean"), &sink, ) .status(); match status { Ok(status) if status.success() => {} outcome => log::warn!("apt-get autoclean failed ({outcome:?})"), } 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, series: &str, package_dir: &str, build_root: &str, cross: bool, env: &HashMap, ctx: Arc, view: &dyn BuildView, sink: &Option>, timings: &mut session::BuildTimings, profiler: &mut BuildProfiler, ) -> Result<(), Box> { enter_phase(view, Phase::InstallingBuildDeps, profiler); 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 mut clauses: Vec> = Deps::parse(&deps_value, &parse_opts)? .clauses() .map(<[PkgRelation]>::to_vec) .collect(); // Package-specific workarounds (see data/quirks.yml), before anything // derives candidate queries or install specs from the clauses. crate::quirks::apply_dependency_quirks(package, series, &mut clauses, &parse_opts)?; 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 started = Instant::now(); let status = cap(&mut cmd, sink).status()?; timings.add_deps(started.elapsed()); if !status.success() { view.suspend(); // Diagnosing a dependency failure the user interrupted themselves // is wasted work if !crate::interrupt::interrupted() && 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 /// which binary packages `debian/rules binary` produced, so we retrieve /// exactly those instead of globbing the build root (which would also pick /// up stale files copied alongside the package tree). fn collect_binary_artifacts( ctx: &Arc, package_dir: &str, build_root: &str, ) -> Result, Box> { let files_content = ctx .read_file(&Path::new(package_dir).join("debian/files")) .unwrap_or_default(); let files_list = crate::debian::FilesList::parse(&files_content) .map_err(|e| format!("invalid debian/files in {package_dir}: {e}"))?; let upload_dir = Path::new(build_root); let mut artifacts = Vec::new(); for entry in files_list.iter() { if entry.is_binary_artifact() { artifacts.push(upload_dir.join(&entry.filename)); } } Ok(artifacts) } /// Generate `.buildinfo` and `.changes` for the finished binary build, /// inside the build context. fn generate_upload_metadata( package_dir: &str, build_root: &str, arch: &str, cross: bool, env: &HashMap, ctx: &Arc, ) -> Result<(PathBuf, PathBuf), Box> { // Build architecture: the machine inside the build context. let build_arch = 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); let host_arch = if cross { arch.to_string() } else { build_arch.clone() }; // Vendor resolution inside the context (falls back to the host view); // shared `Vendor:`/`Origin:` parsing with the source-build path. 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); // The recorded profiles must describe what the build actually ran with: // the DEB_BUILD_PROFILES exported to the build steps ('cross' for cross // builds), else the vendor defaults. let profiles = match env.get("DEB_BUILD_PROFILES") { Some(value) => value .split(',') .map(|p| p.trim().to_string()) .filter(|p| !p.is_empty()) .collect(), None => crate::build::env::resolve_build_profiles(&[], &vendor), }; // Record exactly the environment exported to the build steps // (DEB_BUILD_OPTIONS with the real parallel count and 'nocheck', LANG=C, // SOURCE_DATE_EPOCH, cross DEB_* variables, ...), not values recomputed // from host state; buildinfo_environment filters out non-dpkg variables. let exported_env: BTreeMap = env.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); let opts = crate::build::binary::BinaryMetadataOptions { profiles, vendor, exported_env, build_arch, host_arch, }; let (buildinfo, changes) = crate::build::binary::generate_binary_metadata( ctx, Path::new(package_dir), Path::new(build_root), &opts, )?; Ok((buildinfo, changes)) } /// Is this series entry the ambient single-debian-patch placeholder? /// `dpkg-source -b` names it 'debian-changes' or 'debian-changes-'. fn is_debian_changes(entry: &str) -> bool { entry == "debian-changes" || entry.starts_with("debian-changes-") } /// Pure decision core of the series classification in /// [`apply_quilt_patches`]: the entries quilt must apply. Blank and comment /// lines never apply; in a `single-debian-patch` tree the ambient /// 'debian-changes[-]' entries are dropped (their content stays in /// the working tree — `quilt push -a` would refuse them). fn series_patches_to_apply(series_content: &str, single_debian_patch: bool) -> Vec<&str> { series_content .lines() .map(str::trim) .filter(|entry| { !entry.is_empty() && !entry.starts_with('#') && !(single_debian_patch && is_debian_changes(entry)) }) .collect() } /// Apply quilt patches before building, if the package provides a /// 'debian/patches/series' file. `pop_first` un-applies the previous /// attempt's patches first (resumed session whose patch tree changed). fn apply_quilt_patches( package_dir: &str, env: &HashMap, ctx: Arc, view: &dyn BuildView, sink: &Option>, pop_first: bool, profiler: &mut BuildProfiler, ) -> Result<(), Box> { let series_path = Path::new(package_dir).join("debian/patches/series"); if !ctx.exists(&series_path)? { log::debug!( "No '{}' found, skipping quilt patch application", series_path.display() ); return Ok(()); } // Skip patch application if the series file contains no patches let series_content = ctx.read_file(&series_path)?; // Classify the series entries. In a `single-debian-patch` tree, // `dpkg-source --before-build` applies every series entry except the // ambient 'debian-changes[-]' one: `dpkg-source -b` folded // the working-tree delta into it and registered it in the series // WITHOUT applying it — the content stays ambient in the working tree // (e.g. the vendored rust '.cargo/config.toml'). Raw `quilt push -a` // would refuse that entry ('file already exists'); only dpkg's own // patch(1)-based application tolerates it. Real patches listed // alongside it — or in a tree that declares `single-debian-patch` // while carrying an unapplied quilt series, like a pkh-scaffolded // package that grew real patches — must still be applied. let single_debian_patch = uses_single_debian_patch(&ctx, Path::new(package_dir)); let real_patches = series_patches_to_apply(&series_content, single_debian_patch); let total_patches = real_patches.len(); if total_patches == 0 { if single_debian_patch { log::info!( "Tree uses single-debian-patch: the working tree already \ carries the patch content, skipping quilt patch application" ); } else { log::debug!( "'{}' contains no patches, skipping quilt patch application", series_path.display() ); } return Ok(()); } if single_debian_patch && series_content .lines() .any(|line| is_debian_changes(line.trim())) { // Mirror dpkg-source: drop the ambient debian-changes entries from // the staged series so `quilt push -a` below only sees the real // patches. The staged copy is per-session, so the host tree keeps // the pristine series. let filtered = series_content .lines() .filter(|line| !is_debian_changes(line.trim())) .collect::>() .join("\n"); ctx.write_file(&series_path, &format!("{}\n", filtered))?; log::info!( "Tree uses single-debian-patch: dropped the ambient \ 'debian-changes' entries from the staged series, applying \ the real patches" ); } // Make sure quilt is available in the build context log::debug!("Installing quilt for patch application..."); let status = cap( ctx.command("apt-get") .envs(env.clone()) .arg("-y") .arg("install") .arg("quilt"), sink, ) .status()?; if !status.success() { return Err("Could not install 'quilt', required to apply patches".into()); } let mut patch_env = env.clone(); patch_env.insert("QUILT_PATCHES".to_string(), "debian/patches".to_string()); // Un-apply the previous attempt's patches when the patch tree changed: // best-effort (a failing pop leaves the state quilt reports; the fresh // `push -a` below then fails loudly on the actual problem). if pop_first { log::info!("Patch series changed since the previous attempt, un-applying patches"); let status = cap( ctx.command("quilt") .current_dir(package_dir) .envs(patch_env.clone()) .arg("pop") .arg("-a"), sink, ) .status()?; if !status.success() { warn!("'quilt pop -a' failed; continuing with 'quilt push -a'"); } } // Apply all patches listed in the series (a custom classifier: the // phase reports per-patch progress, not the default's raw output) profiler.enter(Phase::ApplyingPatches); view.phase( Phase::ApplyingPatches.label(), Box::new(QuiltClassifier::new(total_patches)), ); let status = cap( ctx.command("quilt") .current_dir(package_dir) .envs(patch_env) .arg("push") .arg("-a"), sink, ) .status()?; if !status.success() { return Err("Failed to apply quilt patches ('quilt push -a')".into()); } Ok(()) } /// Whether the source options of the package tree at `package_dir` (inside /// the build context) declare the `single-debian-patch` mode: either /// `debian/source/local-options` or `debian/source/options` contains a /// line whose trimmed content — after stripping a leading `--` long-option /// dash — is exactly `single-debian-patch` (both spellings exist in the /// wild). A line merely containing the token as a substring (e.g. /// `--single-debian-patch-foo`) does not count. fn uses_single_debian_patch(ctx: &Context, package_dir: &Path) -> bool { let local_options = ctx .read_file(&package_dir.join("debian/source/local-options")) .ok(); let options = ctx .read_file(&package_dir.join("debian/source/options")) .ok(); options_declare_single_debian_patch(local_options.as_deref(), options.as_deref()) } /// Pure decision core of [`uses_single_debian_patch`]: do the (optional) /// contents of `debian/source/local-options` / `debian/source/options` /// declare `single-debian-patch`? fn options_declare_single_debian_patch(local_options: Option<&str>, options: Option<&str>) -> bool { let declares = |content: Option<&str>| { content.is_some_and(|content| { content.lines().any(|line| { let line = line.trim(); let line = line.strip_prefix("--").unwrap_or(line); line == "single-debian-patch" }) }) }; declares(local_options) || declares(options) } /// Pin a 'NotAutomatic' pocket (e.g. '-proposed') so apt takes it into /// account during dependency resolution. /// /// Apt preferences are global: a single 'release' pin matches the pinned /// suite on every repository carrying it (archive, security and ports), /// for all architectures, so this also covers cross-builds pulling /// dependencies from 'ports.ubuntu.com'. pub(crate) fn pin_pocket(pocket_suite: &str, ctx: &Arc) -> Result<(), Box> { let pin_path = format!("/etc/apt/preferences.d/pkh-{}", pocket_suite); let pin_content = format!( "Package: *\nPin: release a={}\nPin-Priority: 600\n", pocket_suite ); log::info!("Pinning pocket '{}' with priority 600", pocket_suite); ctx.write_file(Path::new(&pin_path), &pin_content)?; Ok(()) } pub(crate) fn install_injected_packages( packages: &[String], env: &HashMap, ctx: Arc, view: &dyn BuildView, sink: &Option>, timings: &mut session::BuildTimings, profiler: &mut BuildProfiler, ) -> Result<(), Box> { log::info!("Installing injected packages: {:?}", packages); enter_phase(view, Phase::InjectingPackages, profiler); // Separate .deb files from package names let mut deb_files: Vec = Vec::new(); let mut package_names: Vec<&str> = Vec::new(); for pkg in packages { // Check if it's a .deb file path (ends with .deb and exists as a file) let pkg_path = Path::new(pkg); if pkg.ends_with(".deb") && pkg_path.exists() { // Copy the .deb file into the build context let dest_root = ctx.create_temp_dir()?; let chroot_path = ctx.ensure_available(pkg_path, &dest_root)?; log::debug!( "Copied .deb file '{}' to chroot path '{}'", pkg, chroot_path.display() ); deb_files.push(chroot_path.to_string_lossy().to_string()); } else { package_names.push(pkg.as_str()); } } // Install .deb files if !deb_files.is_empty() || !package_names.is_empty() { log::info!("Installing .deb files: {:?}", deb_files); let mut cmd = ctx.command("apt-get"); cmd.envs(env.clone()) .arg("-y") .arg("--allow-downgrades") .arg("install"); // Add the .deb file paths with ./ prefix for apt to recognize them as local files for deb_path in &deb_files { cmd.arg(format!("./{}", deb_path.trim_start_matches('/'))); } if !package_names.is_empty() { cmd.args(&package_names); } let started = Instant::now(); let status = cap(&mut cmd, sink).status()?; timings.add_deps(started.elapsed()); if !status.success() { return Err(format!("Could not install injected packages: {:?}", deb_files).into()); } } Ok(()) } fn dose3_explain_dependencies( package: &str, version: &str, arch: &str, build_root: &str, cross: bool, ctx: Arc, ) -> Result<(), Box> { // Construct the list of Packages files let mut bg_args = Vec::new(); let mut cmd = ctx.command("apt-get"); cmd.arg("indextargets") .arg("--format") .arg("$(FILENAME)") .arg("Created-By: Packages"); let output = cmd.output()?; if output.status.success() { let filenames = String::from_utf8_lossy(&output.stdout); for file in filenames.lines() { let file = file.trim(); if !file.is_empty() { bg_args.push(file.to_string()); } } } // Transform the dsc file into a 'Source' stanza (replacing 'Source' with 'Package') // TODO: Remove potential GPG headers/signature let dsc_path = find_dsc_file(build_root, package, version, &ctx)?; let mut dsc_content = ctx.read_file(&dsc_path)?; dsc_content = dsc_content.replace("Source", "Package"); ctx.write_file( Path::new(&format!("{build_root}/dsc-processed")), &dsc_content, )?; // Call dose-builddebcheck let local_arch = crate::get_current_arch(); let mut cmd = ctx.command("dose-builddebcheck"); cmd.arg("--verbose") .arg("--failures") .arg("--explain") .arg("--summary") .arg(format!("--deb-native-arch={}", local_arch)); if cross { cmd.arg(format!("--deb-host-arch={}", arch)) .arg("--deb-profiles=cross") .arg(format!("--deb-foreign-archs={}", arch)); } cmd.args(bg_args).arg(format!("{build_root}/dsc-processed")); cmd.status()?; Ok(()) } #[cfg(test)] 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() } /// The build env carries the ccache wiring when it is enabled: the /// compiler shim first on PATH, the shared cache dir, content-keyed /// compiler checks and the size cap. #[test] fn ccache_env_is_applied_when_enabled() { let mut env = HashMap::new(); env.insert("PATH".to_string(), "/usr/bin:/bin".to_string()); apply_ccache_env(&mut env, true, "10G"); assert_eq!( env.get("PATH").unwrap(), &format!("{}:/usr/bin:/bin", crate::deb::ephemeral::CCACHE_SHIM_DIR), "the shim must shadow the real compilers" ); assert_eq!( env.get("CCACHE_DIR").unwrap(), crate::deb::ephemeral::CCACHE_MOUNT ); assert_eq!( env.get("CCACHE_COMPILERCHECK").unwrap(), "content", "mtime checks would never hit across fresh chroots" ); assert_eq!(env.get("CCACHE_MAXSIZE").unwrap(), "10G"); assert_eq!( env.get("CCACHE_BASEDIR").unwrap(), crate::deb::session::BUILD_ROOT ); assert_eq!(env.get("CCACHE_NOHASHDIR").unwrap(), "true"); } /// The size cap is the caller's choice: PKH_CCACHE_MAXSIZE flows /// through as-is. #[test] fn ccache_env_honors_the_size_cap() { let mut env = HashMap::new(); apply_ccache_env(&mut env, true, "512M"); assert_eq!(env.get("CCACHE_MAXSIZE").unwrap(), "512M"); } /// With `--no-ccache` the build env is left untouched: no shim, no /// cache variables. #[test] fn ccache_env_is_skipped_when_disabled() { let mut env = HashMap::new(); env.insert("PATH".to_string(), "/usr/bin:/bin".to_string()); apply_ccache_env(&mut env, false, &ccache_maxsize()); assert_eq!(env.get("PATH").unwrap(), "/usr/bin:/bin"); assert!(!env.contains_key("CCACHE_DIR")); assert!(!env.contains_key("CCACHE_COMPILERCHECK")); assert!(!env.contains_key("CCACHE_MAXSIZE")); } /// 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"); } /// Cross parity with dpkg's checker and sbuild's apt-resolved dummy /// package: an unqualified Multi-Arch: same name resolves to the /// host-architecture variant even when a build-architecture candidate /// exists — the build-architecture side is only ever installed /// through an explicit `:native` declaration (or a pkh quirk /// injecting one), never alongside the host variant. #[test] fn install_spec_unqualified_same_package_never_dual_installs() { let host = "arm64"; let build = "amd64"; let spec = install_spec_for( &rel("t"), &[cand("amd64", "same"), cand("arm64", "same")], build, host, ) .unwrap(); assert_eq!(spec, "t:arm64"); // The build-architecture side comes from `:native` declarations: // the co-installable build-arch variant of the same source // package. let spec = install_spec_for( &rel("t:native"), &[cand("amd64", "same"), cand("arm64", "same")], build, host, ) .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. assert!(options_declare_single_debian_patch( Some("single-debian-patch\n"), None )); // The other common spelling in either file: `--`-prefixed. assert!(options_declare_single_debian_patch( Some("--single-debian-patch\n"), None )); assert!(options_declare_single_debian_patch( None, Some("--single-debian-patch\n") )); // Trailing whitespace and blank lines around the token. assert!(options_declare_single_debian_patch( Some("\n single-debian-patch \n"), None )); // Declared in options while local-options carries other options. assert!(options_declare_single_debian_patch( Some("--extend-diff-ignore='^vendor/'\n"), Some("single-debian-patch\n") )); } #[test] fn detector_rejects_substrings_empty_and_absent_files() { // The token must not match as a substring of another option. assert!(!options_declare_single_debian_patch( Some("--single-debian-patch-ignore=^foo\n"), None )); assert!(!options_declare_single_debian_patch( Some("--no-single-debian-patch\n"), Some("single-debian-patching\n") )); // Absent or empty files: not single-debian-patch. assert!(!options_declare_single_debian_patch(None, None)); assert!(!options_declare_single_debian_patch(Some(""), Some(""))); // Unrelated content. assert!(!options_declare_single_debian_patch( Some("--extend-diff-ignore='^vendor/'\n"), Some("tar-ignore = .git\n") )); } /// A tree with `single-debian-patch` in its local-options is detected /// through the real build-context file access; a tree without the /// declaration is not. #[test] fn detector_on_a_real_tree_through_the_context() { let ctx = Context::new(ContextConfig::Local).unwrap(); let dir = tempfile::tempdir().unwrap(); let tree = dir.path().join("pkg"); std::fs::create_dir_all(tree.join("debian/source")).unwrap(); std::fs::write( tree.join("debian/source/local-options"), "single-debian-patch\n", ) .unwrap(); assert!(uses_single_debian_patch(&ctx, &tree)); // No source options at all. let dir = tempfile::tempdir().unwrap(); let tree = dir.path().join("pkg"); std::fs::create_dir_all(tree.join("debian/source")).unwrap(); assert!(!uses_single_debian_patch(&ctx, &tree)); } /// Direct call of the patch-application phase on a tree simulating the /// `dpkg-source -b` state of a single-debian-patch scaffold: /// 'debian-changes' is registered in the series while its content is /// already ambient in the working tree (raw `quilt push -a` would /// refuse it with 'file already exists'). The phase must skip the /// application entirely and succeed. #[test] fn apply_phase_skips_single_debian_patch_trees() { let ctx = Arc::new(Context::new(ContextConfig::Local).unwrap()); // Dashed spelling through debian/source/options this time. let dir = tempfile::tempdir().unwrap(); let tree = dir.path().join("pkg"); std::fs::create_dir_all(tree.join("debian/patches")).unwrap(); std::fs::create_dir_all(tree.join("debian/source")).unwrap(); // The touched file already carries the patched content: applying // the patch would fail ('README' already exists / differs only in // the patch's imagination). std::fs::write(tree.join("README"), "patched\n").unwrap(); std::fs::write( tree.join("debian/patches/debian-changes"), "--- a/README\n+++ b/README\n@@ -1 +1 @@\n-orig\n+patched\n", ) .unwrap(); std::fs::write(tree.join("debian/patches/series"), "debian-changes\n").unwrap(); std::fs::write( tree.join("debian/source/options"), "--single-debian-patch\n", ) .unwrap(); apply_quilt_patches( tree.to_str().unwrap(), &HashMap::new(), ctx, &crate::report::Quiet, &None, false, &mut BuildProfiler::new(), ) .unwrap(); } /// Series classification for `apply_quilt_patches`: real patches apply; /// blank and comment lines never do; a `single-debian-patch` tree's /// ambient 'debian-changes' entries are dropped while real patches /// listed alongside them still apply (pkh must mirror dpkg-source's /// `--before-build` behavior). #[test] fn series_patches_to_apply_classification() { // Plain quilt tree: every entry applies. assert_eq!( series_patches_to_apply("0001-a.patch\n0002-b.patch\n", false), vec!["0001-a.patch", "0002-b.patch"] ); // Comments and blank lines never apply. assert_eq!( series_patches_to_apply("# header\n\n0001-a.patch\n \n", false), vec!["0001-a.patch"] ); // single-debian-patch tree with only the ambient entry: nothing to // apply — the working tree already carries the content. assert_eq!( series_patches_to_apply("debian-changes-2.1.8\n", true), Vec::<&str>::new() ); // single-debian-patch tree whose series grew real patches: apply // the real ones, drop the ambient entries. assert_eq!( series_patches_to_apply( "0001-real.patch\ndebian-changes-2.1.8\n0002-real.patch\n", true ), vec!["0001-real.patch", "0002-real.patch"] ); // The same series in a plain tree keeps the debian-changes entry: // without the single-debian-patch declaration it is a real patch. assert_eq!( series_patches_to_apply("debian-changes-2.1.8\n", false), vec!["debian-changes-2.1.8"] ); // The versioned and unversioned ambient names are both recognized. assert!(is_debian_changes("debian-changes")); assert!(is_debian_changes("debian-changes-1.0")); assert!(!is_debian_changes("0001-real.patch")); } }