Files
pkh/src/deb/local.rs
T
vhaudiquet 7a6337e1cb
CI / build (push) Successful in 3m16s
CI / test (push) Skipped
CI / publish (push) Skipped
CI / snap (push) Failing after 7m17s
deb: build the tree the caller pointed at before the name search
build_binary_package stages the parent of the requested cwd, then
re-derived the package directory inside the staging area from
package/version name patterns plus the calling process's working
directory. That only works by accident for interactive users sitting
in the package directory: an embedded caller whose tree lives at
<job>/tree matches no pattern, and the process cwd means nothing to
a library consumer — the bc build above failed here even though the
tree was staged correctly.

The pointed-at tree is authoritative anyway: its changelog defined
the package, version and series for this build. Resolve its staged
copy outright when it carries a debian/ tree, keep the pattern
search (with the quirks overrides) as a fallback, and hand the
resolved directory to local::build instead of searching again.
2026-09-22 14:31:25 +02:00

1369 lines
48 KiB
Rust

/// Local binary package building
/// Directly calling 'debian/rules' in current context
use crate::context::{Context, ContextCommand, LineSink};
use crate::deb::{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::debian::control::ControlInfo;
use crate::debian::deps::{Deps, Facts, ParseOpts, PkgRelation};
/// Attach the capture sink to a command when the live UI is active
fn cap<'a>(
cmd: &'a mut ContextCommand<'a>,
sink: &Option<Arc<dyn LineSink>>,
) -> &'a mut ContextCommand<'a> {
if let Some(s) = sink {
cmd.capture(s.clone());
}
cmd
}
#[allow(clippy::too_many_arguments)]
pub async fn build(
package: &str,
version: &str,
arch: &str,
series: &str,
pocket: Option<&str>,
build_root: &str,
package_dir: &Path,
cross: bool,
ppa: &[String],
inject_packages: &[String],
ctx: Arc<Context>,
view: &dyn BuildView,
jobs: Option<usize>,
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
let sink: Option<Arc<dyn LineSink>> = view.sink();
// 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());
// 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),
);
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, ctx.clone())?;
}
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.
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)
&& !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);
for source in &mut sources {
if crate::deb::is_archive_source(&source.uri) && !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);
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
)
})?;
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");
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);
let status = cap(&mut cmd, &sink).status()?;
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
apply_quilt_patches(package_dir_str, &env, ctx.clone(), view, &sink)?;
// Install injected packages if specified
if !inject_packages.is_empty() {
install_injected_packages(inject_packages, &env, ctx.clone(), view, &sink)?;
}
// 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,
)?;
// Run the build step
log::debug!("Building (debian/rules build) package...");
enter_phase(view, Phase::Building);
let status = cap(
ctx.command("debian/rules")
.current_dir(package_dir_str)
.envs(env.clone())
.arg("build"),
&sink,
)
.status()?;
if !status.success() {
return Err("Error while building the package".into());
}
// Run the 'binary' step to produce deb
enter_phase(view, Phase::ProducingBinaries);
let status = cap(
ctx.command("fakeroot")
.current_dir(package_dir_str)
.envs(env.clone())
.arg("debian/rules")
.arg("binary"),
&sink,
)
.status()?;
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);
}
}
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>>,
) -> Result<(), Box<dyn Error>> {
enter_phase(view, Phase::InstallingBuildDeps);
let host_arch = arch.to_string();
let build_arch = context_build_arch(&ctx);
let profiles = build_profiles_for(env, &ctx);
let control_content = ctx
.read_file(&Path::new(package_dir).join("debian/control"))
.map_err(|e| format!("cannot read debian/control: {e}"))?;
let control = ControlInfo::parse_content(&control_content)
.map_err(|e| format!("invalid debian/control in {package_dir}: {e}"))?;
let source = &control.source;
let deps_value = ["Build-Depends", "Build-Depends-Arch", "Build-Depends-Indep"]
.iter()
.filter_map(|f| source.get(f))
.collect::<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 status = cap(&mut cmd, sink).status()?;
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 matches!(entry.package_type.as_deref(), Some("deb") | Some("udeb")) {
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))
}
/// Apply quilt patches before building, if the package provides a
/// 'debian/patches/series' file
fn apply_quilt_patches(
package_dir: &str,
env: &HashMap<String, String>,
ctx: Arc<Context>,
view: &dyn BuildView,
sink: &Option<Arc<dyn LineSink>>,
) -> 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)?;
let total_patches = series_content
.lines()
.filter(|line| !line.trim().is_empty() && !line.trim().starts_with('#'))
.count();
let has_patches = total_patches > 0;
if !has_patches {
log::debug!(
"'{}' contains no patches, skipping quilt patch application",
series_path.display()
);
return Ok(());
}
// A `single-debian-patch` tree is already patched by construction:
// `dpkg-source -b` folds the working-tree delta into
// 'debian/patches/debian-changes' and registers 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 then refuse the patch ('file already exists'); only dpkg's own
// patch(1)-based application tolerates that. Since `dpkg-source -b`
// regenerates the patch from the working tree at source-build time,
// applying patches is wrong in principle here: skip the step.
if uses_single_debian_patch(&ctx, Path::new(package_dir)) {
log::info!(
"Tree uses single-debian-patch: the working tree already \
carries the patch content, skipping quilt patch application"
);
return Ok(());
}
// 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());
}
// Apply all patches listed in the series
view.phase(
Phase::ApplyingPatches.label(),
Box::new(QuiltClassifier::new(total_patches)),
);
let mut patch_env = env.clone();
patch_env.insert("QUILT_PATCHES".to_string(), "debian/patches".to_string());
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'.
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(())
}
fn install_injected_packages(
packages: &[String],
env: &HashMap<String, String>,
ctx: Arc<Context>,
view: &dyn BuildView,
sink: &Option<Arc<dyn LineSink>>,
) -> Result<(), Box<dyn Error>> {
log::info!("Installing injected packages: {:?}", packages);
enter_phase(view, Phase::InjectingPackages);
// 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 status = cap(&mut cmd, sink).status()?;
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()
}
/// Unqualified dependencies install the dpkg-satisfying variant,
/// preferring the runnable build-architecture one: Multi-Arch:
/// foreign candidates from the build architecture, then the
/// host-architecture candidate dpkg checks them against, then
/// `Architecture: all`. A foreign-arch candidate of a non-foreign
/// package satisfies nothing in cross mode.
#[test]
fn install_spec_unqualified_prefers_runnable_then_host() {
let host = "arm64";
let build = "amd64";
let spec = install_spec_for(
&rel("t"),
&[cand("amd64", "foreign"), cand("arm64", "same")],
build,
host,
)
.unwrap();
assert_eq!(spec, "t:amd64");
let spec = install_spec_for(
&rel("t"),
&[cand("arm64", "same"), cand("amd64", "no")],
build,
host,
)
.unwrap();
assert_eq!(spec, "t:arm64");
assert_eq!(
install_spec_for(&rel("t"), &[cand("amd64", "no")], build, host),
None
);
let spec = install_spec_for(&rel("t"), &[cand("all", "foreign")], build, host).unwrap();
assert_eq!(spec, "t");
// Native builds (host == build): a plain Multi-Arch: no candidate
// is the host candidate.
let spec = install_spec_for(&rel("t"), &[cand("amd64", "no")], "amd64", "amd64").unwrap();
assert_eq!(spec, "t:amd64");
}
/// Qualified dependencies install exactly the variant their qualifier
/// selects per dpkg semantics; virtual names (no candidates) pass
/// through unqualified for apt to resolve a provider.
#[test]
fn install_spec_qualifiers() {
let host = "arm64";
let build = "amd64";
// :native: the build-architecture candidate; an Architecture: all
// candidate passes under the plain name; a Multi-Arch: foreign
// candidate aborts the lookup, like dpkg's checker.
assert_eq!(
install_spec_for(&rel("t:native"), &[cand("amd64", "no")], build, host).unwrap(),
"t:amd64"
);
assert_eq!(
install_spec_for(&rel("t:native"), &[cand("all", "no")], build, host).unwrap(),
"t"
);
assert_eq!(
install_spec_for(&rel("t:native"), &[cand("amd64", "foreign")], build, host),
None
);
// :any: a Multi-Arch: allowed candidate, host first.
assert_eq!(
install_spec_for(
&rel("t:any"),
&[cand("amd64", "allowed"), cand("arm64", "allowed")],
build,
host
)
.unwrap(),
"t:arm64"
);
assert_eq!(
install_spec_for(&rel("t:any"), &[cand("riscv64", "allowed")], build, host).unwrap(),
"t:riscv64"
);
assert_eq!(
install_spec_for(&rel("t:any"), &[cand("amd64", "same")], build, host),
None
);
// Explicit qualifier: exactly that candidate.
assert_eq!(
install_spec_for(&rel("t:arm64"), &[cand("arm64", "same")], build, host).unwrap(),
"t:arm64"
);
assert_eq!(
install_spec_for(&rel("t:arm64"), &[cand("amd64", "same")], build, host),
None
);
// Virtual package: debhelper-compat and friends.
assert_eq!(
install_spec_for(&rel("t (= 13)"), &[], build, host).unwrap(),
"t"
);
}
/// The resolver loop tries the alternatives of a clause in order,
/// falling past the ones no candidate can satisfy, and stops
/// contributing a clause once its alternatives are exhausted.
#[test]
fn next_install_pass_falls_through_alternatives() {
// `a:arm64` has only an amd64 candidate: no candidate satisfies
// the explicit qualifier, so the clause falls through to `t`.
let clauses = vec![
vec![rel("a:arm64"), rel("t")],
vec![rel("u")],
vec![rel("v")],
];
let mut candidates = BTreeMap::new();
candidates.insert("a".to_string(), vec![cand("amd64", "same")]);
candidates.insert("t".to_string(), vec![cand("arm64", "same")]);
candidates.insert("v".to_string(), vec![cand("amd64", "foreign")]);
let mut cursors = vec![0; clauses.len()];
let host = "arm64";
let build = "amd64";
let specs = next_install_pass(&clauses, &[0, 1, 2], &mut cursors, &candidates, build, host);
assert_eq!(
specs,
vec![
"t:arm64".to_string(),
"u".to_string(),
"v:amd64".to_string()
]
);
assert_eq!(cursors, vec![2, 1, 1]);
// An exhausted clause contributes nothing; the loop aborts with
// the remaining clauses when nothing installs any more.
let specs = next_install_pass(&clauses, &[1], &mut cursors, &candidates, build, host);
assert!(specs.is_empty());
}
#[test]
fn detector_matches_local_options_options_and_dashed_spellings() {
// The pkh scaffold spelling: bare token in local-options.
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,
)
.unwrap();
}
}