Files
pkh/src/deb/local.rs
T
vhaudiquet 5f36a54e7c deb: run the rootless build step under fakeroot, sharing the state
The rules run as the unprivileged build user, so an ownership change
they make during the build (a sysroot chown) failed per file with
EPERM, spamming the build view and log and silently not happening.

Wrap the build step in fakeroot like the binary step already is, and
share one session state between the two — the way dpkg-buildpackage
-rfakeroot wraps every rules target: the chown succeeds faked and
carries into the .deb assembly. With the context's own user the
build step stays bare. The state file lives in the staged build
root, outside the package tree, and is removed before each build so
an interrupted previous attempt cannot seed the session.
2026-10-02 17:36:05 +02:00

2074 lines
76 KiB
Rust

/// 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<Arc<dyn LineSink>>,
) -> &'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<String> {
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<String, String>, 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<Context>,
workroot: &Path,
ccache_dir: Option<&str>,
) -> Result<(String, String), Box<dyn Error>> {
// 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<Context>, 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<Context>) -> Result<(), Box<dyn Error>> {
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<Context>,
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<Context>,
view: &dyn BuildView,
jobs: Option<usize>,
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<Vec<PathBuf>, Box<dyn Error>> {
let sink: Option<Arc<dyn LineSink>> = 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::<String, String>::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::<usize>()
.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<String> {
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<PkgRelation>],
unsatisfied: &[usize],
cursors: &mut [usize],
candidates: &BTreeMap<String, Vec<Candidate>>,
build_arch: &str,
host_arch: &str,
) -> Vec<String> {
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<Context>,
names: &[String],
build_arch: &str,
host_arch: &str,
) -> BTreeMap<String, Vec<Candidate>> {
let mut query: Vec<String> = 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<String, Vec<Candidate>> = 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<Context>) -> 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<String, String>, ctx: &Arc<Context>) -> Vec<String> {
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<Context>, 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<String, String>,
ctx: Arc<Context>,
view: &dyn BuildView,
sink: &Option<Arc<dyn LineSink>>,
timings: &mut session::BuildTimings,
profiler: &mut BuildProfiler,
) -> Result<(), Box<dyn Error>> {
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::<Vec<_>>()
.join(", ");
let conflicts_value = [
"Build-Conflicts",
"Build-Conflicts-Arch",
"Build-Conflicts-Indep",
]
.iter()
.filter_map(|f| source.get(f))
.collect::<Vec<_>>()
.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<Vec<PkgRelation>> = 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<Vec<PkgRelation>> = 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<String> = clauses
.iter()
.chain(conflict_clauses.iter())
.flatten()
.map(|rel| rel.package.clone())
.collect::<BTreeSet<_>>()
.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<usize> = (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<String> = unsatisfied
.iter()
.map(|&i| {
clauses[i]
.iter()
.map(PkgRelation::output)
.collect::<Vec<_>>()
.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
/// (`<build_root>/<filename>`). `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<Context>,
package_dir: &str,
build_root: &str,
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
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<String, String>,
ctx: &Arc<Context>,
) -> Result<(PathBuf, PathBuf), Box<dyn Error>> {
// 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<String, String> =
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-<version>'.
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[-<version>]' 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<String, String>,
ctx: Arc<Context>,
view: &dyn BuildView,
sink: &Option<Arc<dyn LineSink>>,
pop_first: bool,
profiler: &mut BuildProfiler,
) -> Result<(), Box<dyn Error>> {
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[-<version>]' 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::<Vec<_>>()
.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<Context>) -> Result<(), Box<dyn Error>> {
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<String, String>,
ctx: Arc<Context>,
view: &dyn BuildView,
sink: &Option<Arc<dyn LineSink>>,
timings: &mut session::BuildTimings,
profiler: &mut BuildProfiler,
) -> Result<(), Box<dyn Error>> {
log::info!("Installing injected packages: {:?}", packages);
enter_phase(view, Phase::InjectingPackages, profiler);
// Separate .deb files from package names
let mut deb_files: Vec<String> = 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<Context>,
) -> Result<(), Box<dyn Error>> {
// 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"));
}
}