/// 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>, ) -> &'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, cross: bool, ppa: &[String], inject_packages: &[String], ctx: Arc, view: &dyn BuildView, jobs: Option, ) -> Result, Box> { let sink: Option> = view.sink(); // Environment let mut env = HashMap::::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::() .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()); } // Find the actual package directory // Find the actual package directory let package_dir = crate::deb::find_package_directory(Path::new(build_root), package, version, series, &ctx)?; 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 { let qualified = |arch: &str| format!("{}:{}", rel.package, arch); match rel.arch_qualifier.as_deref() { Some("native") => { if candidates .iter() .any(|c| c.arch == build_arch && c.multiarch == "foreign") { return None; } candidates .iter() .find(|c| c.arch == build_arch) .map(|_| qualified(build_arch)) .or_else(|| { candidates .iter() .find(|c| c.arch == "all") .map(|_| rel.package.clone()) }) } Some("any") => candidates .iter() .filter(|c| c.multiarch == "allowed") .find(|c| c.arch == host_arch) .or_else(|| candidates.iter().find(|c| c.multiarch == "allowed")) .map(|c| qualified(&c.arch)), Some(qual) => candidates .iter() .find(|c| c.arch == qual) .map(|_| qualified(qual)), None => { if candidates.is_empty() { return Some(rel.package.clone()); } candidates .iter() .find(|c| c.arch == build_arch && c.multiarch == "foreign") .map(|_| qualified(build_arch)) .or_else(|| { candidates .iter() .find(|c| c.arch == host_arch) .map(|_| qualified(host_arch)) }) .or_else(|| { candidates .iter() .find(|c| c.arch == "all") .map(|_| rel.package.clone()) }) } } } /// Pure core of the resolver loop: the next batch of install /// specifications, one alternative per still-unsatisfied clause, /// advancing each clause's cursor past the alternatives that no candidate /// can satisfy per dpkg semantics. fn next_install_pass( clauses: &[Vec], unsatisfied: &[usize], cursors: &mut [usize], candidates: &BTreeMap>, build_arch: &str, host_arch: &str, ) -> Vec { let mut specs = Vec::new(); for &i in unsatisfied { let empty = Vec::new(); while cursors[i] < clauses[i].len() { let rel = &clauses[i][cursors[i]]; let cands = candidates.get(&rel.package).unwrap_or(&empty); let spec = install_spec_for(rel, cands, build_arch, host_arch); cursors[i] += 1; if let Some(spec) = spec { specs.push(spec); break; } } } specs } /// Query the candidates available in the build context for `names`: one /// entry per (package, architecture). The plain name surfaces the /// build-architecture and `all` candidates; the host-qualified name is /// what surfaces foreign-arch candidates. Names missing from the indexes /// and virtual names simply produce no stanza. fn query_candidates( ctx: &Arc, names: &[String], build_arch: &str, host_arch: &str, ) -> BTreeMap> { let mut query: Vec = Vec::new(); for name in names { query.push(name.clone()); if host_arch != build_arch { query.push(format!("{name}:{host_arch}")); } } let Ok(output) = ctx.command("apt-cache").arg("show").args(&query).output() else { return BTreeMap::new(); }; let mut map: BTreeMap> = BTreeMap::new(); for para in crate::debian::control::parse_paragraphs(&String::from_utf8_lossy(&output.stdout)) { let Some(name) = para.get("Package") else { continue; }; let Some(arch) = para.get("Architecture") else { continue; }; let entry = map.entry(name.to_string()).or_default(); if entry.iter().any(|c| c.arch == arch) { continue; } entry.push(Candidate { arch: arch.to_string(), multiarch: para.get("Multi-Arch").unwrap_or("no").to_string(), }); } map } /// Build architecture inside the build context. fn context_build_arch(ctx: &Arc) -> String { ctx.command("dpkg") .arg("--print-architecture") .output() .ok() .filter(|o| o.status.success()) .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) .filter(|s| !s.is_empty()) .unwrap_or_else(crate::get_current_arch) } /// Active build profiles for the dependency evaluation: what the build /// steps run with (`DEB_BUILD_PROFILES`, set to `cross` for cross /// builds), else the vendor defaults. fn build_profiles_for(env: &HashMap, ctx: &Arc) -> Vec { match env.get("DEB_BUILD_PROFILES") { Some(value) => value .split(',') .map(|p| p.trim().to_string()) .filter(|p| !p.is_empty()) .collect(), None => { let vendor = ctx .read_file(Path::new("/etc/dpkg/origins/default")) .ok() .and_then(|content| crate::build::env::vendor_from_origins_content(&content)) .unwrap_or_else(crate::build::env::current_vendor); crate::build::env::resolve_build_profiles(&[], &vendor) } } } /// Installed-package facts inside the build context. An unreadable status /// database (fresh chroot) yields empty facts: everything declared then /// installs. fn load_context_facts(ctx: &Arc, build_arch: &str, host_arch: &str) -> Facts { match ctx.read_file(Path::new("/var/lib/dpkg/status")) { Ok(content) => Facts::from_status(&content, host_arch, build_arch), Err(e) => { log::debug!("cannot read the context dpkg status: {e}"); Facts::new(host_arch, build_arch) } } } /// Resolve and install the Build-* dependencies with dpkg's cross /// semantics: the clauses of `debian/control` are evaluated natively (the /// `crate::debian::deps` `dpkg-checkbuilddeps` equivalent) against the /// context's installed-package state, and the unsatisfied ones install /// through explicitly architecture-qualified names. /// /// Build conflicts abort before anything installs. The loop installs one /// alternative per still-unsatisfied clause per apt transaction and /// re-evaluates after each; a clause whose alternatives no candidate can /// satisfy per dpkg semantics aborts the build. On failure the dose3 /// diagnosis runs, like the historical `build-dep` passes. #[allow(clippy::too_many_arguments)] fn install_build_dependencies( package: &str, version: &str, arch: &str, series: &str, package_dir: &str, build_root: &str, cross: bool, env: &HashMap, ctx: Arc, view: &dyn BuildView, sink: &Option>, ) -> Result<(), Box> { enter_phase(view, Phase::InstallingBuildDeps); let host_arch = arch.to_string(); let build_arch = context_build_arch(&ctx); let profiles = build_profiles_for(env, &ctx); let control_content = ctx .read_file(&Path::new(package_dir).join("debian/control")) .map_err(|e| format!("cannot read debian/control: {e}"))?; let control = ControlInfo::parse_content(&control_content) .map_err(|e| format!("invalid debian/control in {package_dir}: {e}"))?; let source = &control.source; let deps_value = ["Build-Depends", "Build-Depends-Arch", "Build-Depends-Indep"] .iter() .filter_map(|f| source.get(f)) .collect::>() .join(", "); let conflicts_value = [ "Build-Conflicts", "Build-Conflicts-Arch", "Build-Conflicts-Indep", ] .iter() .filter_map(|f| source.get(f)) .collect::>() .join(", "); if deps_value.trim().is_empty() && conflicts_value.trim().is_empty() { return Ok(()); } let parse_opts = ParseOpts { host_arch: host_arch.clone(), build_arch: build_arch.clone(), build_profiles: profiles, reduce_restrictions: true, union: false, build_dep: true, }; let mut clauses: Vec> = Deps::parse(&deps_value, &parse_opts)? .clauses() .map(<[PkgRelation]>::to_vec) .collect(); // Package-specific workarounds (see data/quirks.yml), before anything // derives candidate queries or install specs from the clauses. crate::quirks::apply_dependency_quirks(package, series, &mut clauses, &parse_opts)?; let conflict_clauses: Vec> = if conflicts_value.trim().is_empty() { Vec::new() } else { let union_opts = ParseOpts { union: true, ..parse_opts.clone() }; Deps::parse(&conflicts_value, &union_opts)? .clauses() .map(<[PkgRelation]>::to_vec) .collect() }; let names: Vec = clauses .iter() .chain(conflict_clauses.iter()) .flatten() .map(|rel| rel.package.clone()) .collect::>() .into_iter() .collect(); let candidates = query_candidates(&ctx, &names, &build_arch, &host_arch); let mut cursors = vec![0usize; clauses.len()]; loop { let facts = load_context_facts(&ctx, &build_arch, &host_arch); // Build conflicts use the same lookup as dependencies in dpkg's // checker: a satisfied conflict clause aborts before anything // installs. let mut violated = Vec::new(); for alternatives in &conflict_clauses { for rel in alternatives { if facts.evaluate_relation(rel) == Some(true) { violated.push(rel.output()); break; } } } if !violated.is_empty() { view.suspend(); if let Err(e) = dose3_explain_dependencies(package, version, arch, build_root, cross, ctx.clone()) { log::debug!("dose-builddebcheck diagnosis failed: {e}"); } return Err(format!( "build dependencies conflict with installed packages: {}", violated.join(", ") ) .into()); } let unsatisfied: Vec = (0..clauses.len()) .filter(|&i| { !clauses[i] .iter() .any(|rel| facts.evaluate_relation(rel) == Some(true)) }) .collect(); if unsatisfied.is_empty() { return Ok(()); } let specs = next_install_pass( &clauses, &unsatisfied, &mut cursors, &candidates, &build_arch, &host_arch, ); if specs.is_empty() { view.suspend(); if let Err(e) = dose3_explain_dependencies(package, version, arch, build_root, cross, ctx.clone()) { log::debug!("dose-builddebcheck diagnosis failed: {e}"); } let remaining: Vec = unsatisfied .iter() .map(|&i| { clauses[i] .iter() .map(PkgRelation::output) .collect::>() .join(" | ") }) .collect(); return Err(format!( "Could not satisfy build dependencies per dpkg cross semantics: {}", remaining.join(", ") ) .into()); } log::debug!("Installing build dependencies: {:?}", specs); let mut cmd = ctx.command("apt-get"); cmd.envs(env.clone()).arg("-y").arg("install"); for spec in &specs { cmd.arg(spec); } let status = cap(&mut cmd, sink).status()?; if !status.success() { view.suspend(); if let Err(e) = dose3_explain_dependencies(package, version, arch, build_root, cross, ctx.clone()) { log::debug!("dose-builddebcheck diagnosis failed: {e}"); } return Err(format!( "Could not install build-dependencies for the build: {}", specs.join(" ") ) .into()); } } } /// Collect the binary artifacts (.deb/.udeb) registered by the build in /// `debian/files`, returning their paths inside the build context /// (`/`). `debian/files` is the canonical record of /// which binary packages `debian/rules binary` produced, so we retrieve /// exactly those instead of globbing the build root (which would also pick /// up stale files copied alongside the package tree). fn collect_binary_artifacts( ctx: &Arc, package_dir: &str, build_root: &str, ) -> Result, Box> { let files_content = ctx .read_file(&Path::new(package_dir).join("debian/files")) .unwrap_or_default(); let files_list = crate::debian::FilesList::parse(&files_content) .map_err(|e| format!("invalid debian/files in {package_dir}: {e}"))?; let upload_dir = Path::new(build_root); let mut artifacts = Vec::new(); for entry in files_list.iter() { if 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, ctx: &Arc, ) -> Result<(PathBuf, PathBuf), Box> { // Build architecture: the machine inside the build context. let build_arch = ctx .command("dpkg") .arg("--print-architecture") .output() .ok() .filter(|o| o.status.success()) .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) .filter(|s| !s.is_empty()) .unwrap_or_else(crate::get_current_arch); let host_arch = if cross { arch.to_string() } else { build_arch.clone() }; // Vendor resolution inside the context (falls back to the host view); // shared `Vendor:`/`Origin:` parsing with the source-build path. let vendor = ctx .read_file(Path::new("/etc/dpkg/origins/default")) .ok() .and_then(|content| crate::build::env::vendor_from_origins_content(&content)) .unwrap_or_else(crate::build::env::current_vendor); // The recorded profiles must describe what the build actually ran with: // the DEB_BUILD_PROFILES exported to the build steps ('cross' for cross // builds), else the vendor defaults. let profiles = match env.get("DEB_BUILD_PROFILES") { Some(value) => value .split(',') .map(|p| p.trim().to_string()) .filter(|p| !p.is_empty()) .collect(), None => crate::build::env::resolve_build_profiles(&[], &vendor), }; // Record exactly the environment exported to the build steps // (DEB_BUILD_OPTIONS with the real parallel count and 'nocheck', LANG=C, // SOURCE_DATE_EPOCH, cross DEB_* variables, ...), not values recomputed // from host state; buildinfo_environment filters out non-dpkg variables. let exported_env: BTreeMap = env.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); let opts = crate::build::binary::BinaryMetadataOptions { profiles, vendor, exported_env, build_arch, host_arch, }; let (buildinfo, changes) = crate::build::binary::generate_binary_metadata( ctx, Path::new(package_dir), Path::new(build_root), &opts, )?; Ok((buildinfo, changes)) } /// Apply quilt patches before building, if the package provides a /// 'debian/patches/series' file fn apply_quilt_patches( package_dir: &str, env: &HashMap, ctx: Arc, view: &dyn BuildView, sink: &Option>, ) -> Result<(), Box> { let series_path = Path::new(package_dir).join("debian/patches/series"); if !ctx.exists(&series_path)? { log::debug!( "No '{}' found, skipping quilt patch application", series_path.display() ); return Ok(()); } // Skip patch application if the series file contains no patches let series_content = ctx.read_file(&series_path)?; 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) -> Result<(), Box> { let pin_path = format!("/etc/apt/preferences.d/pkh-{}", pocket_suite); let pin_content = format!( "Package: *\nPin: release a={}\nPin-Priority: 600\n", pocket_suite ); log::info!("Pinning pocket '{}' with priority 600", pocket_suite); ctx.write_file(Path::new(&pin_path), &pin_content)?; Ok(()) } fn install_injected_packages( packages: &[String], env: &HashMap, ctx: Arc, view: &dyn BuildView, sink: &Option>, ) -> Result<(), Box> { log::info!("Installing injected packages: {:?}", packages); enter_phase(view, Phase::InjectingPackages); // Separate .deb files from package names let mut deb_files: Vec = Vec::new(); let mut package_names: Vec<&str> = Vec::new(); for pkg in packages { // Check if it's a .deb file path (ends with .deb and exists as a file) let pkg_path = Path::new(pkg); if pkg.ends_with(".deb") && pkg_path.exists() { // Copy the .deb file into the build context let dest_root = ctx.create_temp_dir()?; let chroot_path = ctx.ensure_available(pkg_path, &dest_root)?; log::debug!( "Copied .deb file '{}' to chroot path '{}'", pkg, chroot_path.display() ); deb_files.push(chroot_path.to_string_lossy().to_string()); } else { package_names.push(pkg.as_str()); } } // Install .deb files if !deb_files.is_empty() || !package_names.is_empty() { log::info!("Installing .deb files: {:?}", deb_files); let mut cmd = ctx.command("apt-get"); cmd.envs(env.clone()) .arg("-y") .arg("--allow-downgrades") .arg("install"); // Add the .deb file paths with ./ prefix for apt to recognize them as local files for deb_path in &deb_files { cmd.arg(format!("./{}", deb_path.trim_start_matches('/'))); } if !package_names.is_empty() { cmd.args(&package_names); } let 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, ) -> Result<(), Box> { // Construct the list of Packages files let mut bg_args = Vec::new(); let mut cmd = ctx.command("apt-get"); cmd.arg("indextargets") .arg("--format") .arg("$(FILENAME)") .arg("Created-By: Packages"); let output = cmd.output()?; if output.status.success() { let filenames = String::from_utf8_lossy(&output.stdout); for file in filenames.lines() { let file = file.trim(); if !file.is_empty() { bg_args.push(file.to_string()); } } } // Transform the dsc file into a 'Source' stanza (replacing 'Source' with 'Package') // TODO: Remove potential GPG headers/signature let dsc_path = find_dsc_file(build_root, package, version, &ctx)?; let mut dsc_content = ctx.read_file(&dsc_path)?; dsc_content = dsc_content.replace("Source", "Package"); ctx.write_file( Path::new(&format!("{build_root}/dsc-processed")), &dsc_content, )?; // Call dose-builddebcheck let local_arch = crate::get_current_arch(); let mut cmd = ctx.command("dose-builddebcheck"); cmd.arg("--verbose") .arg("--failures") .arg("--explain") .arg("--summary") .arg(format!("--deb-native-arch={}", local_arch)); if cross { cmd.arg(format!("--deb-host-arch={}", arch)) .arg("--deb-profiles=cross") .arg(format!("--deb-foreign-archs={}", arch)); } cmd.args(bg_args).arg(format!("{build_root}/dsc-processed")); cmd.status()?; Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::context::ContextConfig; fn cand(arch: &str, ma: &str) -> Candidate { Candidate { arch: arch.to_string(), multiarch: ma.to_string(), } } fn rel(s: &str) -> PkgRelation { crate::debian::deps::parse_simple(s, true).unwrap() } /// 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(); } }