//! Native Debian source-package build pipeline. //! //! Re-implements the orchestration performed by `dpkg-buildpackage -S` //! (environment setup, `dpkg-source` lifecycle, `.buildinfo` / `.changes` //! generation and OpenPGP signing) natively in Rust, while delegating the //! source-tree work (tarballs, diffs, patches) to `dpkg-source` as a //! subprocess. pub mod binary; pub mod buildinfo; pub mod buildtype; pub mod changes; pub mod env; use std::collections::{BTreeMap, HashMap}; use std::error::Error; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::Arc; use crate::context::capture::pump; use crate::context::{LineSink, Stream}; use crate::debian::{ ChecksumEntry, ControlInfo, FileChecksums, FilesEntry, FilesList, parse_paragraphs, }; use crate::logfmt::{DpkgSourceClassifier, GenericClassifier}; use crate::report::{BuildTarget, BuildView, Prompter}; /// Whether the upload distributes the upstream orig tarballs (`--orig`), /// mirroring the `dpkg-genchanges` source styles. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum OrigSourceMode { /// Include them only when the upstream version changed since the /// previous changelog entry (`-si`, dpkg's default). #[default] Auto, /// Always include them (`-sa`). Always, /// Never include them (`-sd`). Never, } /// Options for a native source-package build. #[derive(Debug, Clone, Default)] pub struct SourceBuildOptions { /// Explicit signing key id / fingerprint (`-k`). When unset, a secret /// key matching the changelog maintainer email is searched. pub sign_keyid: Option, /// Sign even for an UNRELEASED changelog (`--force-sign`). pub force_sign: bool, /// Force build-dependency checking even though this is a source-only /// build (`-D`). `dpkg-buildpackage` skips `dpkg-checkbuilddeps` /// entirely for source-only builds unless forced. pub force_dep_check: bool, /// Whether the upload distributes the upstream orig tarballs /// (`--orig`). Defaults to [`OrigSourceMode::Auto`], like /// `dpkg-genchanges`' `-si`. pub orig_source: OrigSourceMode, } /// Artifacts produced by a successful source build. #[derive(Debug, Clone)] pub struct SourceBuildOutput { /// The generated `.dsc`. pub dsc: PathBuf, /// The generated `.buildinfo`. pub buildinfo: PathBuf, /// The generated `.changes`. pub changes: PathBuf, /// Source tarballs referenced by the `.dsc` (orig, debian tar...). pub tarballs: Vec, /// Whether all artifacts were signed. pub signed: bool, } impl SourceBuildOutput { /// All produced artifacts in distribution order: dsc → tarballs → /// buildinfo → changes. pub fn artifacts(&self) -> Vec { let mut artifacts = Vec::with_capacity(3 + self.tarballs.len()); artifacts.push(self.dsc.clone()); artifacts.extend(self.tarballs.iter().cloned()); artifacts.push(self.buildinfo.clone()); artifacts.push(self.changes.clone()); artifacts } } /// Parameters of one [`build_source_package`] call: where to build, the /// domain options, and the reporting ports (view and prompter). pub struct BuildSourceOptions<'a> { /// Source tree to package. When unset, the process's current working /// directory is used (resolved to an absolute path). pub source: Option, /// Domain options (signing, orig-tarball inclusion, ...). pub options: SourceBuildOptions, /// Where build events (target, phases, messages, outcome) are reported. pub view: &'a dyn BuildView, /// Who answers the questions the flow may ask (e.g. the re-vendor /// retry offer). pub prompter: &'a dyn Prompter, } impl Default for BuildSourceOptions<'_> { fn default() -> Self { static QUIET: crate::report::Quiet = crate::report::Quiet; BuildSourceOptions { source: None, options: SourceBuildOptions::default(), view: &QUIET, prompter: &QUIET, } } } /// Build a Debian source package (to a .dsc) using the native pipeline. /// /// Subprocess output is captured into the view (status line + rolling /// pane for the terminal adapter) and tee'd to a log file; on failure the /// view prints a summary of the last captured errors. Headless callers use /// [`crate::report::Quiet`], in which case commands still run with captured /// output in test builds. /// /// A `dpkg-source -b` failure is classified (see /// [`classify_dpkg_source_failure`]); when the vendored rust dependencies /// diverged from the orig-vendor component, the flow asks the prompter /// whether to re-vendor, recreates the component and retries the build /// exactly once. /// /// On success the produced artifacts are reported through the view and /// returned. pub fn build_source_package( opts: BuildSourceOptions<'_>, ) -> Result> { // Default to the process's current working directory, resolved to an // absolute path: the output directory is derived from `cwd.parent()` // downstream, which only yields a real directory for an absolute `cwd` // (the parent of "." is the empty path). let cwd = match opts.source { Some(ref p) => p.clone(), None => std::env::current_dir() .map_err(|e| format!("cannot determine the current working directory: {e}"))?, }; let output = match run_source_build(&cwd, &opts.options, opts.view) { Ok(output) => output, Err(e) if e.downcast_ref::().is_some() => { return retry_after_revendor(&cwd, opts.view, opts.prompter, &opts.options, e); } Err(e) => { opts.view.finish_failure(); return Err(e); } }; opts.view.finish_success(&output.artifacts()); Ok(output) } /// The re-vendor retry hook for a [`VendorDriftError`]: offer to re-run the /// vendoring step through the prompter (the same helper the rust template /// uses at scaffold time), recreate the `orig-vendor` component from the /// fresh `vendor/` tree and retry the source build exactly once. Without an /// accepting answer (headless prompters answer with the default, `false`) /// the original error is returned untouched. fn retry_after_revendor( cwd: &Path, view: &dyn BuildView, prompter: &dyn Prompter, opts: &SourceBuildOptions, original: Box, ) -> Result> { log::error!("{original}"); if !prompter .confirm( "Re-vendor the Cargo dependencies and retry the build?", false, ) .unwrap_or(false) { view.finish_failure(); return Err(original); } // 1. Re-vendor into the tree. The vendoring helper never overwrites an // existing `.cargo/config.toml` (scaffold-time safety); in a package // we vendored before, that config is ours — remove it so it is // refreshed. A foreign config (no vendored-source marker) is left // alone and makes the vendoring report incomplete below. let config = cwd.join(".cargo/config.toml"); if config.exists() && std::fs::read_to_string(&config) .is_ok_and(|content| content.contains("[source.crates-io]")) { std::fs::remove_file(&config)?; } if !crate::new::templates::rust::vendor_dependencies(cwd)? { view.finish_failure(); return Err( "Re-vendoring did not complete: the tree is unchanged, fix the \ vendoring by hand and build again." .into(), ); } // 2. Recreate the orig-vendor component from the fresh vendor/ tree, // under the name dpkg-source globs for: the changelog version's // UPSTREAM part (`0.14.0`, not the full `0.14.0-1` — the stale // component would survive and the next build would fail again). let entry = crate::debian::parse_changelog_entry(&cwd.join("debian/changelog"))?; let uversion = crate::new::orig::component_upstream_version(&entry.version); let component = crate::new::orig::vendor_component_path(cwd, &entry.source, uversion) .ok_or_else(|| { format!( "cannot determine the output directory of '{}' ", cwd.display() ) })?; if component.exists() { std::fs::remove_file(&component)?; } crate::new::orig::create_vendor_component(cwd, &entry.source, uversion)?; // 3. One retry, with the options of the original attempt. run_source_build(cwd, opts, view) } /// Run the full native source-build pipeline in `cwd`. /// /// Steps (mirroring `dpkg-buildpackage -S -I -i -nc -d`): /// 1. sanity checks and metadata resolution (changelog, control); a /// `binary-only=yes` changelog entry is refused, like `dpkg-source -b`, /// 2. environment setup (`SOURCE_DATE_EPOCH`, `DEB_BUILD_OPTIONS`, arch vars), /// 3. signing decision (key discovery, UNRELEASED handling), /// 4. `dpkg-source --before-build` then `dpkg-source -b`, /// 5. native `.buildinfo` generation (+ registration in `debian/files`), /// 6. native `.changes` generation, /// 7. `dpkg-source --after-build`, /// 8. signing cascade: dsc → buildinfo → changes, recomputing checksums of /// already-generated files at each step. pub fn run_source_build( cwd: &Path, opts: &SourceBuildOptions, view: &dyn BuildView, ) -> Result> { // The view consumes the captured lines itself (live view + tee log); // without one, test runs still capture command output into the per-test // log file instead of letting it inherit the terminal let sink: Option> = view.sink().or_else(crate::test_support::subprocess_sink); // ------------------------------------------------------------------ // 1. Sanity checks // ------------------------------------------------------------------ let parent = cwd .parent() .filter(|p| !p.as_os_str().is_empty()) .ok_or_else(|| format!("cannot determine output directory from '{}'", cwd.display()))? .to_path_buf(); let rules_path = cwd.join("debian/rules"); if !rules_path.exists() { return Err(format!( "'{}' not found: '{}' does not look like a Debian source tree", rules_path.display(), cwd.display() ) .into()); } let changelog_path = cwd.join("debian/changelog"); let control_path = cwd.join("debian/control"); if !changelog_path.exists() { return Err(format!("'{}' not found", changelog_path.display()).into()); } if !control_path.exists() { return Err(format!("'{}' not found", control_path.display()).into()); } // ------------------------------------------------------------------ // 2. Metadata resolution // ------------------------------------------------------------------ // The current entry plus the one below it: the previous entry drives // both the binNMU metadata references and the orig-tarball inclusion // decision. let mut entries = crate::debian::changelog::parse_changelog_entries(&changelog_path, Some(2))?; let entry = entries.remove(0); let previous_entry = entries.into_iter().next(); // A binary-only (binNMU) changelog entry is a binary publication whose // source is already in the archive: like dpkg-source, refuse to build // source for it instead of producing binNMU-style source metadata. if entry.binary_only { return Err( "cannot build source for a binary-only publication: the changelog \ entry sets binary-only=yes (dpkg-source refuses it too)" .into(), ); } let ctrl = ControlInfo::parse(&control_path)?; view.target(BuildTarget { package: &entry.source, version: &entry.version.full(), target: &entry.distribution, source_only: true, }); let source_display = entry.source.clone(); let sversion = entry.version.no_epoch(); let dsc_name = format!("{}_{}.dsc", entry.source, sversion); let dsc_path = parent.join(&dsc_name); let buildinfo_name = format!("{}_{}_source.buildinfo", entry.source, sversion); let buildinfo_path = parent.join(&buildinfo_name); let changes_name = format!("{}_{}_source.changes", entry.source, sversion); let changes_path = parent.join(&changes_name); // ------------------------------------------------------------------ // 3. Environment setup // ------------------------------------------------------------------ let vendor = env::current_vendor(); let profiles = env::resolve_build_profiles(&[], &vendor); let mut pipeline_env = env::build_env(entry.timestamp, env::num_parallel(), &profiles); let arch_vars = env::arch_env(None)?; pipeline_env.extend(arch_vars.clone()); // ------------------------------------------------------------------ // 4. Signing decision // ------------------------------------------------------------------ let mut signing_key = opts.sign_keyid.clone(); if signing_key.is_none() { match crate::utils::gpg::find_signing_key_for_email(&entry.maintainer_email) { Ok(Some(key)) => { log::info!("Using GPG key {} for signing", key); signing_key = Some(key); } Ok(None) => { log::warn!( "No GPG secret key found for {} <{}>, building without signing", entry.maintainer_name, entry.maintainer_email ); } Err(e) => { log::warn!( "Failed to check for GPG key: {}, building without signing", e ); } } } let do_sign = match &signing_key { None => false, Some(_) if entry.distribution == "UNRELEASED" && !opts.force_sign => { log::warn!("Not signing UNRELEASED build; use force_sign to override"); false } Some(_) => true, }; // ------------------------------------------------------------------ // 5. dpkg-source lifecycle: before-build + source build // ------------------------------------------------------------------ view.phase("Applying patches", Box::new(DpkgSourceClassifier::new())); run_command( cwd, "dpkg-source", &["-I", "-i", "--before-build", "."], &pipeline_env, sink.as_ref(), )?; // Build-dependency check (native dpkg-checkbuilddeps equivalent). // dpkg-buildpackage skips it entirely for source-only builds unless // forced with -D; unsatisfied dependencies abort with exit status 3. if opts.force_dep_check { view.message("Checking build dependencies"); let check_opts = crate::debian::deps::CheckOpts { host_arch: arch_vars .get("DEB_HOST_ARCH") .cloned() .unwrap_or_else(|| crate::debian::arch::native().unwrap_or_default()), build_arch: arch_vars .get("DEB_BUILD_ARCH") .cloned() .unwrap_or_else(|| crate::debian::arch::native().unwrap_or_default()), build_profiles: profiles.clone(), ..Default::default() }; let report = crate::debian::deps::check_build_depends(&ctrl, &check_opts)?; if !report.is_ok() { eprintln!("{}", report.message()); return Err(Box::new(crate::debian::deps::UnmetBuildDependencies( report, ))); } } view.phase( "Building source package", Box::new(DpkgSourceClassifier::new()), ); if let Err(failure) = run_command_capturing( cwd, "dpkg-source", &["-I", "-i", "-b", "."], &pipeline_env, sink.as_ref(), ) { return Err(dpkg_source_failure_error( classify_dpkg_source_failure(&failure.stderr), &failure.stderr, failure.error, )); } if !dsc_path.exists() { return Err(format!( "dpkg-source did not produce the expected '{}'", dsc_path.display() ) .into()); } // ------------------------------------------------------------------ // 6. .buildinfo generation (native dpkg-genbuildinfo equivalent) // ------------------------------------------------------------------ view.message("Generating .buildinfo"); // What the .buildinfo itself records: like dpkg-genbuildinfo, only the // referenced .dsc — not the tarballs, and never the buildinfo itself. let mut buildinfo_checksums = FileChecksums::new(); buildinfo_checksums.add_file_as(&dsc_path, &dsc_name)?; let status_path = PathBuf::from("/var/lib/dpkg/status"); let bd_fields = [ctrl.source.get("Build-Depends").unwrap_or("")]; let installed_bd = buildinfo::installed_build_depends(&status_path, &bd_fields)?; let environment = env::buildinfo_environment(&pipeline_env); let render_buildinfo_doc = |checksums: &FileChecksums| { buildinfo::render_buildinfo(&buildinfo::BuildInfoInput { source: source_display.clone(), binaries: Vec::new(), // source-only build architecture: "source".to_string(), version: entry.version.full(), // A binary-only entry never reaches a source build (refused // above), so the buildinfo never carries Binary-Only-Changes. binary_only_changes: None, build_origin: vendor.clone(), build_architecture: arch_vars .get("DEB_BUILD_ARCH") .cloned() .unwrap_or_else(crate::get_current_arch), build_date: chrono::Local::now().to_rfc2822(), checksums: checksums.clone(), installed_build_depends: installed_bd.clone(), environment: environment.clone(), }) }; buildinfo::save_buildinfo(&buildinfo_path, &render_buildinfo_doc(&buildinfo_checksums))?; // Register the .buildinfo in debian/files (as dpkg-genbuildinfo does). let files_path = cwd.join("debian/files"); let mut files_list = FilesList::load(&files_path)?; files_list.retain(|e| { !(e.package.as_deref() == Some(entry.source.as_str()) && e.package_type.as_deref() == Some("buildinfo")) }); files_list.add(FilesEntry::new( &buildinfo_name, ctrl.section(), ctrl.priority(), )); files_list.save_atomic(&files_path)?; // ------------------------------------------------------------------ // 7. .changes generation (native dpkg-genchanges equivalent) // ------------------------------------------------------------------ view.message("Generating .changes"); // What the .changes distributes: the .dsc (with its recorded digests), // the tarballs listed in it (below), and the .buildinfo (last, as // dpkg-genchanges does when it consumes debian/files). let mut checksums = buildinfo_checksums.clone(); // Pull the tarball checksums out of the referenced .dsc so they are // distributed through the .changes like dpkg-genchanges does, in the // order the .dsc itself lists them. let dsc_content = std::fs::read_to_string(&dsc_path) .map_err(|e| format!("cannot read '{}': {}", dsc_path.display(), e))?; let dsc_para = parse_paragraphs(crate::debian::control::strip_clearsigned_armour( &dsc_content, )) .into_iter() .next() .ok_or_else(|| format!("'{}' is empty", dsc_path.display()))?; // Whether the upload redistributes the upstream tarballs (dpkg // -sa/-si/-sd source styles). Stripping only applies to a split source // package — a native one has no orig tarball, and an explicit `never` // for one is only a warning, like dpkg-genchanges with -sd. let include_orig = changes::include_orig_tarball(opts.orig_source, &entry, previous_entry.as_ref()); let mut tarball_paths = Vec::new(); let mut dsc_file_names: Vec = Vec::new(); let mut dsc_files: HashMap = HashMap::new(); // Distribution order follows the Checksums fields (Checksums-Sha1 then // Checksums-Sha256), like dpkg-genchanges; the `Files` field only // supplements the md5 digests. for field in ["Checksums-Sha1", "Checksums-Sha256", "Files"] { let Some(value) = dsc_para.get(field) else { continue; }; for cl in parse_checksum_field(field, value) .map_err(|e| format!("cannot parse '{}': {e}", dsc_path.display()))? { if !dsc_files.contains_key(&cl.name) { dsc_file_names.push(cl.name.clone()); } let slot = dsc_files.entry(cl.name.clone()).or_default(); match field { "Checksums-Sha1" => slot.sha1 = Some(cl.digest), "Checksums-Sha256" => slot.sha256 = Some(cl.digest), _ => slot.md5 = Some(cl.digest), } slot.size = Some(cl.size); } } let has_debian_part = dsc_file_names .iter() .any(|n| changes::is_debian_tarball_or_diff(n)); let strip_origs = !include_orig && has_debian_part; if opts.orig_source == OrigSourceMode::Never && !has_debian_part { log::warn!("ignoring --orig never for a native Debian package"); } view.message(if strip_origs { "Not including original source code in upload" } else { "Including full source code in upload" }); // Stripped orig tarballs (and their detached .asc signatures) are not // distributed at all: not hashed, not required on disk, like // dpkg-genchanges. let is_stripped = |name: &str| { strip_origs && (changes::is_orig_tarball(name) || (name.ends_with(".asc") && changes::is_orig_tarball(name.strip_suffix(".asc").unwrap_or(name)))) }; for name in &dsc_file_names { if name == &dsc_name { continue; // already computed directly above } if is_stripped(name) { continue; } let path = parent.join(name); if !path.exists() { return Err(format!( "file '{}' referenced by '{}' is missing", name, dsc_path.display() ) .into()); } let partial = dsc_files.get(name).ok_or_else(|| { format!( "file '{name}' listed in '{}' has no checksum entry", dsc_path.display() ) })?; checksums.insert_entry( name, ChecksumEntry { size: partial.size.unwrap_or(0), md5: partial.md5.clone().unwrap_or_default(), sha1: partial.sha1.clone().unwrap_or_default(), sha256: partial.sha256.clone().unwrap_or_default(), // The .dsc records no SHA-512 (dpkg only writes sha1/sha256 // there); the empty digest keeps the .buildinfo's // `Checksums-Sha512` field omitted. sha512: String::new(), }, ); tarball_paths.push(path); } // The .buildinfo itself is distributed through the .changes, last (as // dpkg-genchanges does when it consumes debian/files). checksums.add_file_as(&buildinfo_path, &buildinfo_name)?; // The .changes lists section/priority for every distributed file; the // dsc and tarballs use the source stanza defaults (not persisted into // debian/files, matching dpkg). let mut changes_files = files_list.clone(); changes_files.add(FilesEntry::new(&dsc_name, ctrl.section(), ctrl.priority())); for name in &dsc_file_names { if name != &dsc_name && !is_stripped(name) { changes_files.add(FilesEntry::new(name, ctrl.section(), ctrl.priority())); } } let changed_by = format!("{} <{}>", entry.maintainer_name, entry.maintainer_email); let render_changes_doc = |checksums: &FileChecksums| { changes::render_changes(&changes::ChangesInput { date: entry.date_raw.clone(), source: source_display.clone(), binaries: Vec::new(), // source-only upload binary_only: entry.binary_only, built_for_profiles: profiles.clone(), architecture: "source".to_string(), version: entry.version.full(), distribution: entry.distribution.clone(), urgency: entry.urgency.clone(), maintainer: ctrl.source.get("Maintainer").map(str::to_string), changed_by: Some(changed_by.clone()), descriptions: Vec::new(), closes: entry.closes.clone(), changes_field: entry.changes_field.clone(), checksums: checksums.clone(), files_list: changes_files.clone(), }) }; changes::save_changes(&changes_path, &render_changes_doc(&checksums))?; // ------------------------------------------------------------------ // 8. dpkg-source after-build (unapplies quilt patches it applied) // ------------------------------------------------------------------ view.phase("Restoring patches", Box::new(DpkgSourceClassifier::new())); run_command( cwd, "dpkg-source", &["-I", "-i", "--after-build", "."], &pipeline_env, sink.as_ref(), )?; // ------------------------------------------------------------------ // 9. Signing cascade: dsc -> buildinfo -> changes // ------------------------------------------------------------------ let mut signed = false; if let Some(keyid) = signing_key.filter(|_| do_sign) { crate::utils::gpg::validate_key_id(&keyid)?; view.phase("Signing artifacts", Box::new(GenericClassifier::new())); log::info!("Signing {}", dsc_name); crate::utils::gpg::clearsign_file(&dsc_path, &keyid)?; // The freshly built .dsc changed: refresh its digests in both // checksum sets. buildinfo_checksums.add_file_as(&dsc_path, &dsc_name)?; checksums.add_file_as(&dsc_path, &dsc_name)?; // Re-render the .buildinfo from its own set (the .dsc only, like // dpkg-genbuildinfo): it must not list the tarballs or itself. buildinfo::save_buildinfo(&buildinfo_path, &render_buildinfo_doc(&buildinfo_checksums))?; log::info!("Signing {}", buildinfo_name); crate::utils::gpg::clearsign_file(&buildinfo_path, &keyid)?; // Both .dsc and .buildinfo changed: refresh the .changes with the // signed buildinfo's fresh digests. checksums.add_file_as(&buildinfo_path, &buildinfo_name)?; changes::save_changes(&changes_path, &render_changes_doc(&checksums))?; log::info!("Signing {}", changes_name); crate::utils::gpg::clearsign_file(&changes_path, &keyid)?; signed = true; } Ok(SourceBuildOutput { dsc: dsc_path, buildinfo: buildinfo_path, changes: changes_path, tarballs: tarball_paths, signed, }) } /// A partially-known checksum entry taken from a `.dsc` checksum field. #[derive(Debug, Default)] struct PartialChecksum { size: Option, md5: Option, sha1: Option, sha256: Option, } /// One validated line of a `Checksums-Sha1` / `Checksums-Sha256` / `Files` /// field body. #[derive(Debug, Clone, PartialEq, Eq)] struct ChecksumLine { /// Digest as written in the first column. digest: String, /// File size in bytes. size: u64, /// File name (last column of the line). name: String, } /// Parse the body of a `Checksums-Sha1` / `Checksums-Sha256` / `Files` field /// (one file per line) into validated entries. Shared by the source-build /// pipeline and the binary-only metadata generation so both accept exactly /// the same lines. /// /// Both layouts are accepted, detected by column count: /// - 3 columns: ` ` (modern `Checksums-*` fields and /// the `Files` field of freshly built `.dsc`/`.changes`), /// - 5 columns: `
` (legacy /// `Files` fields, where the name is the last token). /// /// Any other line (notably 4 columns) or a non-numeric size is a malformed /// line and yields an error naming `field` and the offending line. fn parse_checksum_field(field: &str, value: &str) -> Result, String> { let mut entries = Vec::new(); for line in value.lines() { if line.trim().is_empty() { continue; } let tokens: Vec<&str> = line.split_whitespace().collect(); let (digest, size, name) = match tokens.as_slice() { [digest, size, name] => (*digest, *size, *name), // Legacy 5-column `Files` layout: digest size section priority name. [digest, size, _section, _priority, name] => (*digest, *size, *name), _ => { return Err(format!( "malformed '{field}' line (expected 'checksum size name' \ or legacy 'checksum size section priority name', got {} \ columns): '{line}'", tokens.len() )); } }; let size: u64 = size.parse().map_err(|_| { format!("malformed '{field}' line (size '{size}' is not a number): '{line}'") })?; entries.push(ChecksumLine { digest: digest.to_string(), size, name: name.to_string(), }); } Ok(entries) } /// Why a `dpkg-source -b` run failed, classified from its captured stderr. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum DpkgSourceFailure { /// A path under `vendor/` (or the `orig-vendor` component itself) is /// named in the error: the vendored dependencies changed since the /// orig-vendor component was created. VendorDrift, /// Unrepresentable changes to source outside `vendor/`: the upstream /// tree drifted beyond the orig snapshot. UpstreamDrift, /// Anything else (changelog errors, missing files, …). Other, } /// Classify a captured `dpkg-source -b` stderr. Any error line naming a /// `vendor/` path or the `orig-vendor` component wins (the vendored tree is /// what the build choked on); the bare "unrepresentable changes to source" /// summary without a vendor mention points at general upstream drift. pub(crate) fn classify_dpkg_source_failure(stderr: &str) -> DpkgSourceFailure { if stderr .lines() .any(|line| line.contains("vendor/") || line.contains("orig-vendor")) { return DpkgSourceFailure::VendorDrift; } if stderr.contains("unrepresentable changes to source") { return DpkgSourceFailure::UpstreamDrift; } DpkgSourceFailure::Other } /// The typed error of the [`DpkgSourceFailure::VendorDrift`] case, letting /// the build wrapper offer the re-vendor retry. The type must survive all /// the way to [`build_source_package`], so the offending dpkg-source line /// travels inside the error instead of being string-wrapped around it. #[derive(Debug)] pub(crate) struct VendorDriftError { /// The offending dpkg-source stderr line, when one was captured. detail: Option, } impl std::fmt::Display for VendorDriftError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, "dpkg-source failed: the vendored dependencies changed since \ the orig-vendor component was created. Re-vendor the tree \ (`cargo vendor`) and recreate the _.\ orig-vendor.tar.xz component, then build again." )?; if let Some(detail) = &self.detail { write!(f, " (dpkg-source said: {detail})")?; } Ok(()) } } impl Error for VendorDriftError {} /// The user-facing error for a classified `dpkg-source -b` failure: a /// pointed explanation for the two known drift cases, the raw command /// failure otherwise. fn dpkg_source_failure_error( failure: DpkgSourceFailure, stderr: &str, command_error: Box, ) -> Box { match failure { DpkgSourceFailure::VendorDrift => { let detail = stderr .lines() .find(|line| line.contains("vendor")) .map(str::trim) .filter(|line| !line.is_empty()); Box::new(VendorDriftError { detail: detail.map(str::to_string), }) } DpkgSourceFailure::UpstreamDrift => format!( "dpkg-source failed: the upstream tree drifted beyond the orig \ snapshot — bump the version (`pkh chlog`) or re-scaffold. \ (raw error: {command_error})" ) .into(), DpkgSourceFailure::Other => command_error, } } /// A command failure together with the stderr captured while it ran (empty /// when the streams were inherited, e.g. verbose mode). struct CommandFailure { error: Box, stderr: String, } /// Run a build command in `cwd` with extra environment variables layered on /// top of the inherited environment. /// /// When `sink` is set, stdout/stderr are piped and every line is forwarded to /// it (live view + tee log) while the stderr is additionally captured for /// the failure classification; otherwise stdio is inherited from the /// terminal. Returns an error (with the captured stderr) on non-zero exit /// status. A panicking stdout/stderr reader thread also yields an error /// (the captured output would be incomplete), but only after the command's /// own failure, which takes precedence. fn run_command_capturing( cwd: &Path, program: &str, args: &[&str], env: &BTreeMap, sink: Option<&Arc>, ) -> Result<(), CommandFailure> { log::debug!( "running: {} {} (in {})", program, args.join(" "), cwd.display() ); let mut cmd = Command::new(program); cmd.current_dir(cwd).envs(env).args(args); // The last 64 KiB of stderr, kept for the dpkg-source failure // classification. let stderr_capture = Arc::new(std::sync::Mutex::new(String::new())); // Printable panic message from a reader thread, if one died mid-pump. let mut reader_panic = None; let status = match sink { None => cmd.status().map_err(|e| CommandFailure { error: format!("failed to run '{}': {}", program, e).into(), stderr: String::new(), })?, Some(sink) => { cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); let mut child = cmd.spawn().map_err(|e| CommandFailure { error: format!("failed to run '{}': {}", program, e).into(), stderr: String::new(), })?; let stdout = child.stdout.take(); let stderr = child.stderr.take(); // One reader thread per stream; interleaving across streams is // approximate (channel arrival order), acceptable for display. let out_sink = sink.clone(); let err_sink = sink.clone(); let err_capture = Arc::clone(&stderr_capture); let out_thread = std::thread::spawn(move || { if let Some(out) = stdout { pump(out, Stream::Stdout, &*out_sink); } }); let err_thread = std::thread::spawn(move || { if let Some(stderr) = stderr { pump( stderr, Stream::Stderr, &CapturingSink { inner: err_sink, capture: err_capture, }, ); } }); // The threads end on EOF, i.e. once the child exited and closed // its streams; a panic from either means the captured output is // incomplete. reader_panic = reader_panic_message(out_thread.join()) .or_else(|| reader_panic_message(err_thread.join())); child.wait().map_err(|e| CommandFailure { error: format!("failed to wait for '{}': {}", program, e).into(), stderr: String::new(), })? } }; if !status.success() { // The command's own failure takes precedence over a dead reader; the // panic is still logged so the truncated-log cause is not lost. if let Some(message) = &reader_panic { log::error!("the build output reader failed: {message}"); } return Err(CommandFailure { error: format!( "'{} {}' failed with status: {}", program, args.join(" "), status ) .into(), stderr: std::mem::take(&mut stderr_capture.lock().unwrap_or_else(|e| e.into_inner())), }); } // The command succeeded but a reader thread panicked: the captured output // (live view + tee log) is incomplete, so this cannot pass as a success. if let Some(message) = reader_panic { return Err(CommandFailure { error: format!("the build output reader failed: {message}").into(), stderr: std::mem::take(&mut stderr_capture.lock().unwrap_or_else(|e| e.into_inner())), }); } Ok(()) } /// Extract a printable message from a reader-thread join result; `None` when /// the thread finished normally. fn reader_panic_message(join: std::thread::Result<()>) -> Option { join.err().map(|payload| { payload .downcast_ref::<&str>() .map(|s| (*s).to_string()) .or_else(|| payload.downcast_ref::().cloned()) .unwrap_or_else(|| "non-string panic payload".to_string()) }) } /// Run a build command, discarding the captured stderr. fn run_command( cwd: &Path, program: &str, args: &[&str], env: &BTreeMap, sink: Option<&Arc>, ) -> Result<(), Box> { run_command_capturing(cwd, program, args, env, sink).map_err(|failure| failure.error) } /// A [`LineSink`] forwarding every line to the live view while appending /// stderr lines to the classification buffer. struct CapturingSink { inner: Arc, capture: Arc>, } const STDERR_CAPTURE_LIMIT: usize = 64 * 1024; impl LineSink for CapturingSink { fn line(&self, stream: Stream, line: &str) { if stream == Stream::Stderr { let mut buffer = self.capture.lock().unwrap_or_else(|e| e.into_inner()); buffer.push_str(line); buffer.push('\n'); // Keep the tail: dpkg-source reports the offending path last. if buffer.len() > STDERR_CAPTURE_LIMIT { let cut = buffer.len() - STDERR_CAPTURE_LIMIT; let start = buffer[cut..] .find('\n') .map(|index| cut + index + 1) .unwrap_or(buffer.len()); buffer.drain(..start); } } self.inner.line(stream, line); } } #[cfg(test)] mod tests { use super::*; use crate::debian::DebianVersion; /// A binary-only (binNMU) changelog entry is a binary publication: like /// `dpkg-source -b`, the source build must refuse it outright instead of /// producing binNMU-style source metadata referencing the previous /// version's `.dsc`. #[test] fn source_build_refuses_binary_only_changelog() { let base = tempfile::tempdir().expect("tempdir"); let tree = base.path().join("hello-1.0"); std::fs::create_dir_all(tree.join("debian")).expect("mkdir tree"); std::fs::write( tree.join("debian/changelog"), "hello (1.0-1+b1) unstable; urgency=medium, binary-only=yes\n\n \ * Binary-only rebuild.\n\n -- A B Mon, 01 Jan 2024 00:00:00 +0000\n", ) .expect("write changelog"); std::fs::write( tree.join("debian/control"), "Source: hello\nMaintainer: A B \n", ) .expect("write control"); std::fs::write(tree.join("debian/rules"), "#!/usr/bin/make -f\n").expect("write rules"); let err = run_source_build(&tree, &SourceBuildOptions::default(), &crate::report::Quiet) .expect_err("binary-only entries must not build a source package"); let err = err.to_string(); assert!(err.contains("binary-only"), "{err}"); assert!(err.contains("source"), "{err}"); } #[test] fn partial_checksum_defaults() { let p = PartialChecksum::default(); assert!(p.size.is_none()); assert!(p.md5.is_none()); } #[test] fn debian_version_from_debian_module_usable() { let v = DebianVersion::parse("1.0-2").unwrap(); assert_eq!(v.no_epoch(), "1.0-2"); } #[test] fn checksum_field_parses_three_column_lines() { let entries = parse_checksum_field( "Checksums-Sha256", " aaa111 12 hello_1.0.orig.tar.xz\n bbb222 3 hello_1.0-1.debian.tar.xz", ) .expect("valid 3-column field"); assert_eq!( entries, vec![ ChecksumLine { digest: "aaa111".into(), size: 12, name: "hello_1.0.orig.tar.xz".into(), }, ChecksumLine { digest: "bbb222".into(), size: 3, name: "hello_1.0-1.debian.tar.xz".into(), }, ] ); } #[test] fn checksum_field_parses_legacy_five_column_files() { // Old archive .dsc/.changes carry `Files` as // md5 size section priority name. let entries = parse_checksum_field( "Files", " d111 100 editors optional hello_1.0.orig.tar.gz\n \ d222 55 web optional hello_1.0-1.diff.gz", ) .expect("valid legacy 5-column field"); assert_eq!(entries.len(), 2); assert_eq!(entries[0].digest, "d111"); assert_eq!(entries[0].size, 100); assert_eq!(entries[0].name, "hello_1.0.orig.tar.gz"); assert_eq!(entries[1].name, "hello_1.0-1.diff.gz"); } #[test] fn checksum_field_rejects_four_column_line() { let err = parse_checksum_field( "Checksums-Sha256", " aaa111 12 hello_1.0.orig.tar.xz\n ccc333 12 bogus hello_1.0-1.debian.tar.xz", ) .expect_err("4-column line must be rejected"); assert!(err.contains("Checksums-Sha256"), "{err}"); assert!(err.contains("hello_1.0-1.debian.tar.xz"), "{err}"); } #[test] fn checksum_field_rejects_non_numeric_size() { let err = parse_checksum_field("Files", " d111 twelve hello.tar.xz") .expect_err("non-numeric size must be rejected"); assert!(err.contains("twelve"), "{err}"); } #[test] fn checksum_field_skips_blank_lines() { let entries = parse_checksum_field("Files", "\n d111 100 hello.tar.xz\n\n") .expect("blank lines are ignored"); assert_eq!(entries.len(), 1); assert_eq!(entries[0].name, "hello.tar.xz"); } /// Real-shape `dpkg-source -b` stderr excerpts and their class. #[test] fn dpkg_source_failure_classification() { // Vendored dependencies diverged from the orig-vendor component // (dpkg names the offending paths before the summary line). let vendor = concat!( "dpkg-source: info: building mytool using existing \ ./mytool_0.1.0.orig.tar.xz\n", "dpkg-source: info: using source format version 3.0 (quilt)\n", "dpkg-source: error: cannot represent change to \ vendor/libc/src/unix/linux_like/mod.rs: binary file contents \ changed\n", "dpkg-source: error: unrepresentable changes to source\n", ); assert_eq!( classify_dpkg_source_failure(vendor), DpkgSourceFailure::VendorDrift ); // A missing orig-vendor component makes the vendored files look // "new"; still vendor drift. let missing_component = concat!( "dpkg-source: error: cannot represent change to \ vendor/libc/Cargo.toml: new file is binary\n", "dpkg-source: error: unrepresentable changes to source\n", ); assert_eq!( classify_dpkg_source_failure(missing_component), DpkgSourceFailure::VendorDrift ); // The component named in the error is vendor drift too. assert_eq!( classify_dpkg_source_failure( "dpkg-source: error: orig-vendor component tarball \ checksum mismatch\n", ), DpkgSourceFailure::VendorDrift ); // Upstream tree drift outside vendor/. let upstream = concat!( "dpkg-source: error: cannot represent change to \ assets/logo.png: binary file contents changed\n", "dpkg-source: error: unrepresentable changes to source\n", ); assert_eq!( classify_dpkg_source_failure(upstream), DpkgSourceFailure::UpstreamDrift ); assert_eq!( classify_dpkg_source_failure("dpkg-source: error: unrepresentable changes to source\n"), DpkgSourceFailure::UpstreamDrift ); // Anything else. assert_eq!( classify_dpkg_source_failure( "dpkg-source: error: syntax error in debian/control at line 3\n" ), DpkgSourceFailure::Other ); assert_eq!(classify_dpkg_source_failure(""), DpkgSourceFailure::Other); } /// The user-facing wording of the classified failures. #[test] fn dpkg_source_failure_messages() { let vendor = "dpkg-source: error: cannot represent change to \ vendor/serde/src/de/mod.rs: binary file contents changed\n\ dpkg-source: error: unrepresentable changes to source\n"; let error = dpkg_source_failure_error( DpkgSourceFailure::VendorDrift, vendor, "'dpkg-source -I -i -b .' failed with status: exit status: 2".into(), ); let message = error.to_string(); assert!( message.contains("vendored dependencies changed"), "{message}" ); assert!(message.contains("orig-vendor"), "{message}"); // The offending dpkg line travels along as the detail. assert!(message.contains("vendor/serde/src/de/mod.rs"), "{message}"); // Regression: the typed error must SURVIVE the message building, or // the re-vendor retry hook never fires. assert!(error.downcast_ref::().is_some()); let upstream_error = dpkg_source_failure_error( DpkgSourceFailure::UpstreamDrift, "dpkg-source: error: unrepresentable changes to source\n", "'dpkg-source -I -i -b .' failed with status: exit status: 2".into(), ); let message = upstream_error.to_string(); assert!( message.contains("drifted beyond the orig snapshot"), "{message}" ); assert!(message.contains("pkh chlog"), "{message}"); // Other: the raw command error passes through untouched. let other = dpkg_source_failure_error(DpkgSourceFailure::Other, "", "boom".into()); assert_eq!(other.to_string(), "boom"); // A vendor-drift error (with or without detail) is recognized by // the retry hook. for detail in [None, Some("dpkg-source: error: nope".to_string())] { let boxed: Box = Box::new(VendorDriftError { detail }); assert!(boxed.downcast_ref::().is_some()); } } } /// Differential tests: build synthetic (or real archive) source packages /// with both real `dpkg-buildpackage` and the native pipeline, then compare /// the produced `.dsc` / `.changes` / `.buildinfo`. /// /// Fields that legitimately depend on machine state (`Installed-Build-Depends`, /// `Environment`, `Build-Date`, `Build-Tainted-By`) and checksum lines of the /// `.buildinfo` itself are excluded from the comparison; everything else must /// match byte-for-byte. #[cfg(test)] mod differential_tests { use super::*; use crate::debian::control::{Paragraph, parse_paragraphs}; use std::fs; const MAINTAINER: &str = "Pkh Diff "; const DATE: &str = "Thu, 01 Jan 2026 00:00:00 +0000"; /// Specification of a synthetic source package. struct FixtureSpec { name: &'static str, version: &'static str, distribution: &'static str, urgency: &'static str, /// `debian/source/format` content ("3.0 (native)", "3.0 (quilt)", "1.0"). format: &'static str, binaries: &'static [(&'static str, &'static str)], section: &'static str, priority: &'static str, body: &'static [&'static str], patches: &'static [&'static str], extra_source_fields: &'static [(&'static str, &'static str)], /// Version of the previous changelog entry, when the fixture has one. previous_version: Option<&'static str>, /// Mark the newest changelog entry `binary-only=yes` (binNMU). binary_only_marker: bool, } impl FixtureSpec { fn new(name: &'static str, version: &'static str, distribution: &'static str) -> Self { FixtureSpec { name, version, distribution, urgency: "medium", format: "3.0 (native)", binaries: &[], section: "utils", priority: "optional", body: &["* Something changed."], patches: &[], extra_source_fields: &[], previous_version: None, binary_only_marker: false, } } fn sversion(&self) -> String { // Version without epoch, as used in artifact file names. self.version .split_once(':') .map(|(_, rest)| rest.to_string()) .unwrap_or_else(|| self.version.to_string()) } fn changelog(&self) -> String { let params = if self.binary_only_marker { format!("urgency={}, binary-only=yes", self.urgency) } else { format!("urgency={}", self.urgency) }; let mut out = format!( "{} ({}) {}; {}\n\n", self.name, self.version, self.distribution, params ); for line in self.body { out.push_str(" * "); out.push_str(line); out.push('\n'); } out.push_str(&format!("\n -- {MAINTAINER} {DATE}\n")); if let Some(prev) = self.previous_version { out.push_str(&format!( "\n{p} ({pv}) {d}; urgency={u}\n\n * Initial release.\n\n -- {MAINTAINER} {DATE}\n", p = self.name, pv = prev, d = self.distribution, u = self.urgency )); } out } fn control(&self) -> String { let mut out = format!( "Source: {}\nSection: {}\nPriority: {}\nMaintainer: {MAINTAINER}\nBuild-Depends: build-essential\n", self.name, self.section, self.priority ); for (k, v) in self.extra_source_fields { out.push_str(k); out.push_str(": "); out.push_str(v); out.push('\n'); } // dpkg-source refuses trees without any binary stanza. let binaries: &[(&str, &str)] = if self.binaries.is_empty() { &[(self.name, "all")] } else { self.binaries }; for (bname, barch) in binaries { out.push_str(&format!( "\nPackage: {bname}\nArchitecture: {barch}\nDescription: {bname} component\n A component of {}.\n", self.name )); } out } } /// Materialize a fixture tree under `base`, along with an orig tarball /// when the source format requires one. Returns the tree path. fn write_fixture(base: &Path, spec: &FixtureSpec) -> PathBuf { let sversion = spec.sversion(); let dir_name = format!("{}-{}", spec.name, sversion); let dir = base.join(&dir_name); fs::create_dir_all(dir.join("debian/source")).expect("create debian/source"); if !spec.patches.is_empty() { fs::create_dir_all(dir.join("debian/patches")).expect("create debian/patches"); } fs::write(dir.join("hello.txt"), "upstream content v1\n").expect("write upstream file"); fs::write(dir.join("debian/changelog"), spec.changelog()).expect("write changelog"); fs::write(dir.join("debian/control"), spec.control()).expect("write control"); fs::write( dir.join("debian/source/format"), format!("{}\n", spec.format), ) .expect("write format"); let rules = dir.join("debian/rules"); fs::write(&rules, "#!/usr/bin/make -f\n%:\n\tdh $@\n").expect("write rules"); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; fs::set_permissions(&rules, fs::Permissions::from_mode(0o755)).expect("chmod rules"); } for (i, patch) in spec.patches.iter().enumerate() { let name = format!("{:02}-fix.patch", i + 1); fs::write(dir.join("debian/patches").join(&name), format!("{patch}\n")) .expect("write patch"); } if !spec.patches.is_empty() { let series = (1..=spec.patches.len()) .map(|i| format!("{:02}-fix.patch", i)) .collect::>() .join("\n"); fs::write(dir.join("debian/patches/series"), series + "\n").expect("write series"); } // quilt and 1.0 formats need a pristine orig tarball next to the tree, // named after the *upstream* version. if spec.format != "3.0 (native)" { let uversion = crate::debian::DebianVersion::parse(spec.version) .expect("valid fixture version") .upstream; let upstream = base.join(".upstream"); fs::create_dir_all(upstream.join(&dir_name)).expect("create upstream dir"); fs::write( upstream.join(&dir_name).join("hello.txt"), "upstream content v1\n", ) .expect("write pristine upstream file"); let ext = if spec.format == "1.0" { "gz" } else { "xz" }; let tarball = base.join(format!("{}_{}.orig.tar.{}", spec.name, uversion, ext)); let mut cmd = Command::new("tar"); cmd.current_dir(&upstream); match ext { "gz" => { cmd.arg("-cz"); } _ => { cmd.arg("-cJ"); } } cmd.arg("-f").arg(&tarball).arg(&dir_name); let status = crate::test_support::run_logged(&mut cmd).expect("run tar"); assert!(status.success(), "tar failed for {}", tarball.display()); } dir } fn copy_path(src: &Path, dst_root: &Path) { let status = crate::test_support::run_logged(Command::new("cp").arg("-a").arg(src).arg(dst_root)) .expect("run cp -a"); assert!( status.success(), "cp -a {} {} failed", src.display(), dst_root.display() ); } fn run_dpkg(tree: &Path, source_style: &[&str]) { let status = crate::test_support::run_logged( Command::new("dpkg-buildpackage") .current_dir(tree) .args(["-S", "-I", "-i", "-nc", "-d", "--no-sign"]) .args(source_style), ) .expect("failed to run dpkg-buildpackage (is dpkg-dev installed?)"); assert!(status.success(), "dpkg-buildpackage failed"); } fn strip_signature(text: &str) -> &str { match text.find("-----BEGIN PGP SIGNATURE-----") { Some(i) => &text[..i], None => text, } } fn first_paragraph(text: &str) -> Paragraph { parse_paragraphs(text) .into_iter() .next() .expect("document has a paragraph") } /// Compare a `.changes`: every field must be identical, except checksum /// lines referring to the `.buildinfo` itself (whose content legitimately /// differs on machine-dependent fields). fn assert_changes_equivalent(golden: &Path, ours: &Path) { let g = first_paragraph(strip_signature( &fs::read_to_string(golden).expect("read golden changes"), )); let o = first_paragraph(strip_signature( &fs::read_to_string(ours).expect("read our changes"), )); let gkeys: std::collections::BTreeSet<&str> = g.iter().map(|(k, _)| k).collect(); let okeys: std::collections::BTreeSet<&str> = o.iter().map(|(k, _)| k).collect(); assert_eq!(gkeys, okeys, ".changes field sets differ"); for (key, gvalue) in g.iter() { let ovalue = o.get(key).unwrap(); if matches!(key, "Checksums-Sha1" | "Checksums-Sha256" | "Files") { let without_buildinfo = |v: &str| -> String { v.lines() .filter(|l| !l.trim_end().ends_with(".buildinfo")) .collect::>() .join("\n") }; assert_eq!( without_buildinfo(gvalue), without_buildinfo(ovalue), ".changes field {} differs", key ); } else { assert_eq!(gvalue, ovalue, ".changes field {} differs", key); } } } /// Compare a `.buildinfo` structure, skipping machine-dependent fields. fn assert_buildinfo_equivalent(golden: &Path, ours: &Path) { const SKIP: &[&str] = &[ "Installed-Build-Depends", "Environment", "Build-Date", "Build-Tainted-By", ]; let g = first_paragraph(strip_signature( &fs::read_to_string(golden).expect("read golden buildinfo"), )); let o = first_paragraph(strip_signature( &fs::read_to_string(ours).expect("read our buildinfo"), )); for (key, gvalue) in g.iter() { if SKIP.contains(&key) { continue; } let ovalue = o .get(key) .unwrap_or_else(|| panic!(".buildinfo missing field {}", key)); assert_eq!(gvalue, ovalue, ".buildinfo field {} differs", key); } for (key, _) in o.iter() { assert!( SKIP.contains(&key) || g.get(key).is_some(), ".buildinfo has unexpected extra field {}", key ); } } /// Build `src_tree` with both implementations and compare all artifacts, /// the native side running with `opts` (dpkg receives the matching /// source style so both sides make the same orig-tarball decision). fn differential_on_tree(src_tree: &Path, opts: &SourceBuildOptions) { let src_parent = src_tree.parent().expect("tree has a parent directory"); let tree_name = src_tree.file_name().expect("tree has a name").to_owned(); let base = tempfile::tempdir().expect("tempdir"); let golden_root = base.path().join("golden"); let ours_root = base.path().join("ours"); fs::create_dir_all(&golden_root).expect("mkdir golden"); fs::create_dir_all(&ours_root).expect("mkdir ours"); copy_path(src_tree, &golden_root); copy_path(src_tree, &ours_root); // Sibling orig tarballs are required by quilt/1.0 formats; sibling // .dsc files are required by binary-only (binNMU) builds. for entry in fs::read_dir(src_parent).expect("list source parent") { let entry = entry.expect("dir entry"); let name = entry.file_name().to_string_lossy().to_string(); if entry.path().is_file() && (name.contains(".orig.tar.") || name.ends_with(".dsc")) { copy_path(&entry.path(), &golden_root); copy_path(&entry.path(), &ours_root); } } let golden_tree = golden_root.join(&tree_name); let ours_tree = ours_root.join(&tree_name); let source_style: &[&str] = match opts.orig_source { OrigSourceMode::Auto => &[], OrigSourceMode::Always => &["-sa"], OrigSourceMode::Never => &["-sd"], }; run_dpkg(&golden_tree, source_style); run_source_build(&ours_tree, opts, &crate::report::Quiet) .expect("native source pipeline failed"); let entry = crate::debian::parse_changelog_entry(&ours_tree.join("debian/changelog")).unwrap(); let sversion = entry.version.no_epoch(); let dsc = golden_root.join(format!("{}_{}.dsc", entry.source, sversion)); let changes = golden_root.join(format!("{}_{}_source.changes", entry.source, sversion)); let buildinfo = golden_root.join(format!("{}_{}_source.buildinfo", entry.source, sversion)); assert!( dsc.exists() && changes.exists() && buildinfo.exists(), "native pipeline did not produce all artifacts" ); // The .dsc payload must be byte-identical (both unsigned here). let g_dsc_text = fs::read_to_string(&dsc).expect("read golden dsc"); let o_dsc_text = fs::read_to_string(ours_root.join(&dsc)).expect("read our dsc"); assert_eq!( strip_signature(&g_dsc_text), strip_signature(&o_dsc_text), ".dsc payload differs" ); assert_changes_equivalent(&changes, &ours_root.join(&changes)); assert_buildinfo_equivalent(&buildinfo, &ours_root.join(&buildinfo)); } fn differential_case(spec: &FixtureSpec) { let base = tempfile::tempdir().expect("tempdir"); let tree = write_fixture(base.path(), spec); differential_on_tree(&tree, &SourceBuildOptions::default()); } /// Differential check of [`crate::debian::arch::arch_env`] against real /// `dpkg-architecture -f -a ` for one architecture. fn diff_arch_env_one(arch: Option<&str>) { let mut cmd = Command::new("dpkg-architecture"); cmd.arg("-f"); if let Some(a) = arch { cmd.args(["-a", a]); } let output = cmd .output() .expect("run dpkg-architecture (is dpkg-dev installed?)"); assert!( output.status.success(), "dpkg-architecture -f {arch:?} failed: {}", String::from_utf8_lossy(&output.stderr) ); let mut expected = BTreeMap::new(); for line in String::from_utf8_lossy(&output.stdout).lines() { if let Some((key, value)) = line.split_once('=') { expected.insert(key.to_string(), value.to_string()); } } let ours = crate::debian::arch::arch_env(arch) .unwrap_or_else(|e| panic!("native arch_env({arch:?}) failed: {e}")); assert_eq!( ours, expected, "arch_env({arch:?}) differs from dpkg-architecture" ); } /// Every architecture known to the local dpkg must produce an identical /// environment dump (`dpkg-architecture -L`). #[test] fn diff_arch_env_all_known_arches() { let output = Command::new("dpkg-architecture") .arg("-L") .output() .expect("run dpkg-architecture -L (is dpkg-dev installed?)"); assert!(output.status.success()); for arch in String::from_utf8_lossy(&output.stdout).lines() { let arch = arch.trim(); if arch.is_empty() { continue; } diff_arch_env_one(Some(arch)); } } /// Native (no explicit host architecture) must match too. #[test] fn diff_arch_env_native() { diff_arch_env_one(None); } /// Differential check of [`crate::debian::deps::check_build_depends`] /// against real `dpkg-checkbuilddeps` on one fixture: exit status and /// reported unmet/conflict lists must match. fn diff_checkbuilddeps_case(control: &str, status: &str, args: &[&str]) { let dir = tempfile::tempdir().expect("tempdir"); std::fs::write(dir.path().join("control"), control).expect("write control"); let admindir = dir.path().join("admin"); fs::create_dir_all(&admindir).expect("mkdir admindir"); fs::write(admindir.join("status"), status).expect("write status"); // Real tool. Profiles are always pinned via -P so the comparison is // independent of the local vendor defaults; -I skips the vendor // builtin dependencies (build-essential:native), matching the // native checker which knows no builtins. All options must precede // the control-file operand (POSIX-style option parsing). // The diagnostics are compared against the native checker's English // messages, so the tool must run under the C locale regardless of // the host configuration. let output = Command::new("dpkg-checkbuilddeps") .current_dir(dir.path()) .env("LC_ALL", "C") .arg("--admindir") .arg(&admindir) .args(args) .arg("-I") .arg("control") .output() .expect("run dpkg-checkbuilddeps (is dpkg-dev installed?)"); let real_exit = output.status.code().unwrap_or(-1); let real_msg = String::from_utf8_lossy(&output.stderr) .lines() .filter_map(|l| l.split_once("error: ").map(|(_, m)| m.trim())) .collect::>() .join("\n"); // Native checker with equivalent options. let mut profiles: Vec = Vec::new(); let mut ignore_arch = false; let mut ignore_indep = false; let mut i = 0; while i < args.len() { match args[i] { "-A" => ignore_arch = true, "-B" => ignore_indep = true, "-P" => { i += 1; profiles = args .get(i) .map(|p| p.split(',').map(str::to_string).collect()) .unwrap_or_default(); } _ => {} } i += 1; } let host_arch = crate::debian::arch::native().unwrap_or_else(|_| "amd64".into()); let opts = crate::debian::deps::CheckOpts { host_arch: host_arch.clone(), build_arch: host_arch, build_profiles: profiles, ignore_arch, ignore_indep, ignore_builtin: true, admindir: admindir.clone(), }; let control_info = crate::debian::ControlInfo::parse_content(control).expect("parse control"); let report = crate::debian::deps::check_build_depends(&control_info, &opts) .expect("native parse failure"); let ours_exit = if report.is_ok() { 0 } else { 1 }; assert_eq!( ours_exit, real_exit, "exit status mismatch for {control:?} {args:?}" ); assert_eq!( report.message(), real_msg, "diagnostics mismatch for {control:?} {args:?}" ); } /// Matrix of dependency-checking scenarios validated against the real /// tool: alternatives, version relations, arch/profile restrictions, /// conflicts and `-A`/`-B`/`-P` flag handling. #[test] fn diff_checkbuilddeps_matrix() { const STATUS: &str = "\ Package: libc6 Status: install ok installed Version: 2.39-0ubuntu8 Architecture: amd64 Package: libfoo-dev Status: install ok installed Version: 1.2-3 Architecture: amd64 Package: ma-foreign-pkg Status: install ok installed Version: 1.0 Architecture: i386 Multi-Arch: foreign Package: provider Status: install ok installed Version: 5.0 Architecture: amd64 Provides: virtual-thing (= 2.0), plain-virtual "; const HEAD: &str = "Source: t\nMaintainer: a \n"; const TAIL: &str = "\nPackage: t\nArchitecture: any\nDescription: x\n y\n"; let case = |bd: &str, bc: &str, args: &[&str]| { let mut control = String::from(HEAD); if !bd.is_empty() { control.push_str(&format!("Build-Depends: {bd}\n")); } if !bc.is_empty() { control.push_str(&format!("Build-Conflicts: {bc}\n")); } control.push_str(TAIL); diff_checkbuilddeps_case(&control, STATUS, args); }; // Satisfied / unsatisfied basics. case("libc6 (>= 1)", "", &["-P", "cross"]); case("missing-abc", "", &["-P", "cross"]); case("libc6 (>> 999)", "", &["-P", "cross"]); // Alternatives. case("missing-a | libc6", "", &["-P", "cross"]); case("missing-a | missing-b", "", &["-P", "cross"]); // Architecture restrictions (host is the native arch). case("missing-abc [!amd64]", "", &["-P", "cross"]); case("missing-abc [amd64]", "", &["-P", "cross"]); // Profile restrictions. case("missing-abc ", "", &["-P", "stage1"]); case("missing-abc ", "", &["-P", "cross"]); case("missing-abc ", "", &["-P", "stage1"]); // Multi-Arch foreign satisfies unqualified deps. case("ma-foreign-pkg", "", &["-P", "cross"]); // Provides: versioned provide satisfying / not satisfying. case("virtual-thing (>= 1.0)", "", &["-P", "cross"]); case("virtual-thing (>= 3.0)", "", &["-P", "cross"]); case("plain-virtual", "", &["-P", "cross"]); case("plain-virtual (>= 1.0)", "", &["-P", "cross"]); // Conflicts. case("", "libc6 (<< 1)", &["-P", "cross"]); case("", "libc6", &["-P", "cross"]); case("", "missing-abc", &["-P", "cross"]); // -A/-B field handling. let control_ab = format!( "{HEAD}Build-Depends: libc6\nBuild-Depends-Arch: missing-arch-dep\nBuild-Depends-Indep: missing-indep-dep\n{TAIL}" ); diff_checkbuilddeps_case(&control_ab, STATUS, &["-P", "cross"]); diff_checkbuilddeps_case(&control_ab, STATUS, &["-A", "-P", "cross"]); diff_checkbuilddeps_case(&control_ab, STATUS, &["-B", "-P", "cross"]); // Combined unmet + conflict reporting in one run. case( "missing-one, libc6 (>> 999)", "libfoo-dev", &["-P", "cross"], ); } /// Differential check of [`crate::debian::version`] against real /// `dpkg --compare-versions` over every ported dpkg test vector and /// every relation operator. #[test] fn diff_version_compare_against_dpkg() { let vectors = crate::debian::version::test_vectors::COMPARE; assert!(!vectors.is_empty()); for (a, b, expected) in vectors { let va = crate::debian::DebianVersion::parse(a).unwrap_or_else(|e| panic!("parse {a}: {e}")); let vb = crate::debian::DebianVersion::parse(b).unwrap_or_else(|e| panic!("parse {b}: {e}")); let ours = match va.cmp(&vb) { std::cmp::Ordering::Less => -1, std::cmp::Ordering::Equal => 0, std::cmp::Ordering::Greater => 1, }; assert_eq!(ours, *expected, "native compare: {a} cmp {b}"); // Cross-check the relation operators against the real tool. for (op, holds) in [ ("<<", *expected < 0), ("<=", *expected <= 0), ("=", *expected == 0), (">=", *expected >= 0), (">>", *expected > 0), ] { let output = Command::new("dpkg") .args(["--compare-versions", "--", a, op, b]) .status() .expect("run dpkg --compare-versions"); assert_eq!( output.success(), holds, "dpkg --compare-versions -- {a} {op} {b}" ); } } } /// Differential check of the binary-build metadata generation against /// real `dpkg-buildpackage -b`: both sides build the same tree (rules /// driving dpkg-gencontrol/dpkg-deb directly, no debhelper needed), /// then the produced `.changes`/`.buildinfo` are compared field by /// field modulo machine-dependent values. #[test] fn diff_binary_build_metadata() { const NAME: &str = "pkh-diff-m"; let changelog = format!( "{NAME} (1.0-1) unstable; urgency=medium\n\n * Binary build test.\n\n -- {MAINTAINER} {DATE}\n" ); diff_binary_metadata_case(NAME, &changelog, "1.0-1", false); } /// debian/control shared by the binary-metadata differential cases: one /// arch:any deb and one arch:all udeb. fn binary_test_control(name: &str) -> String { format!( "Source: {name}\nSection: utils\nPriority: optional\nMaintainer: {MAINTAINER}\nBuild-Depends: libc6\n\n\ Package: {name}\nArchitecture: any\nDescription: test package main\n long description\n\n\ Package: {name}-u\nPackage-Type: udeb\nArchitecture: all\nDescription: test udeb\n short\n" ) } /// debian/rules driving dpkg-gencontrol/dpkg-deb directly (no debhelper). fn binary_test_rules(name: &str) -> String { format!( "#!/usr/bin/make -f\nV = $(shell dpkg-parsechangelog -S Version)\nA = $(shell dpkg-architecture -qDEB_HOST_ARCH)\n\nbuild:\n\tmkdir -p debian/tmp/usr/bin\n\tprintf '#!/bin/sh\\necho hi\\n' > debian/tmp/usr/bin/hello\n\tchmod 755 debian/tmp/usr/bin/hello\n\ttouch $@\n\nbinary: build\n\trm -rf debian/{name} debian/{name}-u\n\tmkdir -p debian/{name}/usr/bin debian/{name}/DEBIAN\n\tcp -r debian/tmp/. debian/{name}/\n\tdpkg-gencontrol -p{name} -Pdebian/{name}\n\tdpkg-deb --build debian/{name} ..\n\tmkdir -p debian/{name}-u/usr/share debian/{name}-u/DEBIAN\n\techo data > debian/{name}-u/usr/share/data.txt\n\tdpkg-gencontrol -p{name}-u -Pdebian/{name}-u\n\tdpkg-deb --build debian/{name}-u ..\n\tmv ../{name}-u_$(V)_all.deb ../{name}-u_$(V)_all.udeb\n\nclean:\n\trm -rf debian/tmp debian/{name} debian/{name}-u build-stamp debian/files debian/*.substvars\n\n.PHONY: build binary clean\n" ) } /// Both-sides binary metadata comparison for one changelog: golden /// `dpkg-buildpackage -b` against the pkh deb flow (rules targets with a /// dpkg-buildpackage-like environment) plus native metadata generation. /// `artifact_version` is the full version the artifacts are named after; /// `with_prev_source` additionally places the previous version's `.dsc` /// and tarball next to the tree on both sides (the binNMU trap: they must /// not be redistributed). fn diff_binary_metadata_case( name: &str, changelog: &str, artifact_version: &str, with_prev_source: bool, ) { let control = binary_test_control(name); let rules = binary_test_rules(name); let write_tree = |root: &Path| { fs::create_dir_all(root.join(format!("{name}/debian/source"))).expect("mkdir tree"); let tree = root.join(name); fs::write(tree.join("debian/control"), &control).expect("write control"); fs::write(tree.join("debian/changelog"), changelog).expect("write changelog"); fs::write(tree.join("debian/source/format"), "3.0 (native)\n").expect("write format"); fs::write(tree.join("debian/rules"), &rules).expect("write rules"); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; fs::set_permissions(tree.join("debian/rules"), fs::Permissions::from_mode(0o755)) .expect("chmod rules"); } tree }; let base = tempfile::tempdir().expect("tempdir"); let golden_root = base.path().join("golden"); let ours_root = base.path().join("ours"); fs::create_dir_all(&golden_root).expect("mkdir golden"); fs::create_dir_all(&ours_root).expect("mkdir ours"); let golden_tree = write_tree(&golden_root); let ours_tree = write_tree(&ours_root); if with_prev_source { for root in [&golden_root, &ours_root] { let dsc = format!( "Format: 3.0 (native)\nSource: {name}\nBinary: {name}\nArchitecture: all\n\ Version: 1.0-1\nMaintainer: {MAINTAINER}\nChecksums-Sha1:\n aaa111 12 \ {name}_1.0.tar.xz\nChecksums-Sha256:\n bbb222 12 {name}_1.0.tar.xz\nFiles:\n \ ddd333 12 utils optional {name}_1.0.tar.xz\n" ); fs::write(root.join(format!("{name}_1.0-1.dsc")), dsc).expect("write previous dsc"); fs::write(root.join(format!("{name}_1.0.tar.xz")), "tarball byte") .expect("write previous tarball"); } } // Golden side: real dpkg-buildpackage binary build. let status = crate::test_support::run_logged( Command::new("dpkg-buildpackage") .current_dir(&golden_tree) .args(["-b", "-d", "--no-sign"]), ) .expect("run dpkg-buildpackage (is dpkg-dev installed?)"); assert!(status.success(), "golden dpkg-buildpackage -b failed"); // Ours: emulate the pkh deb flow (rules build + rules binary with a // dpkg-buildpackage-like environment), then run the native metadata // generation through a local context. dpkg-buildpackage runs the // rules targets directly by default (missing Rules-Requires-Root is // treated as 'no'), so no fakeroot wrapper here either. let entry = crate::debian::parse_changelog_entry_from_str(changelog).expect("parse changelog"); let vendor = env::current_vendor(); let profiles = env::resolve_build_profiles(&[], &vendor); let parallel = env::num_parallel(); let build_env_vars: BTreeMap = [ ("LANG".to_string(), "C".to_string()), ( "DEB_BUILD_OPTIONS".to_string(), format!("parallel={parallel}"), ), ("SOURCE_DATE_EPOCH".to_string(), entry.timestamp.to_string()), ] .into_iter() .collect(); for target in ["build", "binary"] { let status = crate::test_support::run_logged( Command::new("debian/rules") .current_dir(&ours_tree) .envs(build_env_vars.clone()) .arg(target), ) .expect("run rules target"); assert!(status.success(), "debian/rules {target} failed"); } let ctx = std::sync::Arc::new( crate::context::Context::new(crate::context::ContextConfig::Local).unwrap(), ); let native_arch = crate::debian::arch::native().unwrap_or_else(|_| "amd64".into()); let opts = crate::build::binary::BinaryMetadataOptions { profiles, vendor, // The metadata records exactly the environment exported to the // build steps above. exported_env: build_env_vars, build_arch: native_arch.clone(), host_arch: native_arch, }; crate::build::binary::generate_binary_metadata(&ctx, &ours_tree, &ours_root, &opts) .expect("native binary metadata generation failed"); // Compare artifacts. assert_changes_equivalent( &golden_root.join(format!("{name}_{artifact_version}_amd64.changes")), &ours_root.join(format!("{name}_{artifact_version}_amd64.changes")), ); assert_buildinfo_equivalent( &golden_root.join(format!("{name}_{artifact_version}_amd64.buildinfo")), &ours_root.join(format!("{name}_{artifact_version}_amd64.buildinfo")), ); } /// Differential check of the binary-only (binNMU) metadata against real /// `dpkg-buildpackage -b`: with the previous source artifacts sitting /// next to the tree (as after a source build), the `.changes` must /// distribute only the binaries and the `.buildinfo`, both documents /// referencing the previous version textually only. Regression guard for /// the previous-source redistribution pkh used to emit. #[test] fn diff_binmu_binary_metadata() { const NAME: &str = "pkh-diff-r"; let changelog = format!( "{NAME} (1.0-1+b1) unstable; urgency=medium, binary-only=yes\n\n * Binary-only \ rebuild.\n\n -- {MAINTAINER} {DATE}\n\n{NAME} (1.0-1) unstable; urgency=medium\n\n \ * Initial release.\n\n -- {MAINTAINER} {DATE}\n" ); diff_binary_metadata_case(NAME, &changelog, "1.0-1+b1", true); } #[test] fn diff_native_minimal() { differential_case(&FixtureSpec::new("pkh-diff-a", "1.0-1", "unstable")); } #[test] fn diff_native_epoch() { differential_case(&FixtureSpec::new("pkh-diff-b", "3:2.4.1-1", "unstable")); } #[test] fn diff_native_tilde() { differential_case(&FixtureSpec::new("pkh-diff-c", "1.0~rc2-1", "unstable")); } #[test] fn diff_quilt_ubuntu_focal_high_urgency() { let mut spec = FixtureSpec::new("pkh-diff-d", "1.2-1", "focal"); spec.format = "3.0 (quilt)"; spec.patches = &[ "--- a/hello.txt\n+++ b/hello.txt\n@@ -1 +1 @@\n-upstream content v1\n+patched by debian\n", ]; spec.urgency = "high"; differential_case(&spec); } #[test] fn diff_quilt_noble_multiple_binaries() { let mut spec = FixtureSpec::new("pkh-diff-e", "0.9-2", "noble"); spec.format = "3.0 (quilt)"; spec.patches = &[ "--- a/hello.txt\n+++ b/hello.txt\n@@ -1 +1 @@\n-upstream content v1\n+patched by debian\n", ]; spec.binaries = &[("pkh-diff-e-bin", "any"), ("pkh-diff-e-common", "all")]; differential_case(&spec); } #[test] fn diff_quilt_single_patch() { let mut spec = FixtureSpec::new("pkh-diff-f", "2.10-3", "unstable"); spec.format = "3.0 (quilt)"; spec.patches = &[ "--- a/hello.txt\n+++ b/hello.txt\n@@ -1 +1 @@\n-upstream content v1\n+patched by debian\n", ]; differential_case(&spec); } #[test] fn diff_quilt_epoch_two_patches_jammy() { let mut spec = FixtureSpec::new("pkh-diff-g", "9:1.5-2", "jammy"); spec.format = "3.0 (quilt)"; spec.patches = &[ "--- a/hello.txt\n+++ b/hello.txt\n@@ -1 +1 @@\n-upstream content v1\n+patched once\n", "--- a/hello.txt\n+++ b/hello.txt\n@@ -1 +1 @@\n-patched once\n+patched twice\n", ]; differential_case(&spec); } #[test] fn diff_format_1_0() { let mut spec = FixtureSpec::new("pkh-diff-h", "0.1-1", "unstable"); spec.format = "1.0"; differential_case(&spec); } #[test] fn diff_binmu_binary_only() { let mut spec = FixtureSpec::new("pkh-diff-i", "1.0-1+b1", "unstable"); spec.body = &["* Binary-only rebuild."]; spec.previous_version = Some("1.0-1"); // A binNMU-style version number (+b1) with a previous entry, but // WITHOUT the binary-only marker: this is a plain source build, and // the sibling previous-version .dsc (as left by an earlier source // build) must not change either side's output. let base = tempfile::tempdir().expect("tempdir"); let tree = write_fixture(base.path(), &spec); let prev_dsc = format!( "Format: 3.0 (native)\nSource: {}\nBinary: {}\nArchitecture: all\nVersion: 1.0-1\nMaintainer: {MAINTAINER}\n", spec.name, spec.name ); fs::write( base.path().join(format!("{}_1.0-1.dsc", spec.name)), prev_dsc, ) .expect("write previous dsc"); differential_on_tree(&tree, &SourceBuildOptions::default()); } /// Both implementations must refuse a source build of a changelog entry /// marked `binary-only=yes`: dpkg-source errors out, and pkh must refuse /// the same way instead of producing binNMU-style source metadata /// referencing the previous version. #[test] fn diff_source_build_rejects_binary_only_marker() { let mut spec = FixtureSpec::new("pkh-diff-s", "1.0-1+b1", "unstable"); spec.body = &["* Binary-only rebuild."]; spec.previous_version = Some("1.0-1"); spec.binary_only_marker = true; let base = tempfile::tempdir().expect("tempdir"); let tree = write_fixture(base.path(), &spec); // Golden side: real dpkg refuses. let status = crate::test_support::run_logged( Command::new("dpkg-buildpackage").current_dir(&tree).args([ "-S", "-I", "-i", "-nc", "-d", "--no-sign", ]), ) .expect("run dpkg-buildpackage (is dpkg-dev installed?)"); assert!( !status.success(), "dpkg-buildpackage -S must refuse a binary-only changelog entry" ); // Ours: the native pipeline refuses likewise. let err = run_source_build(&tree, &SourceBuildOptions::default(), &crate::report::Quiet) .expect_err("native source build must refuse a binary-only entry"); assert!(err.to_string().contains("binary-only"), "{err}"); } #[test] fn diff_closes_bugs() { let mut spec = FixtureSpec::new("pkh-diff-j", "2.0-1", "unstable"); spec.body = &[ "* Fix crash (Closes: #123456)", "* Another fix (Closes: #42)", ]; differential_case(&spec); } #[test] fn diff_unreleased_no_sign() { differential_case(&FixtureSpec::new("pkh-diff-k", "1.1-1", "UNRELEASED")); } #[test] fn diff_extra_control_fields() { let mut spec = FixtureSpec::new("pkh-diff-l", "5.3-1", "trixie"); spec.extra_source_fields = &[ ("Homepage", "https://example.com/pkh-diff"), ("Vcs-Git", "https://example.com/git/pkh-diff.git"), ]; differential_case(&spec); } /// The default dpkg-genchanges source style (-si): a revision bump /// within the same upstream version must NOT redistribute the orig /// tarball in the `.changes`. #[test] fn diff_quilt_revision_bump_excludes_orig() { let mut spec = FixtureSpec::new("pkh-diff-n", "1.4-2", "unstable"); spec.format = "3.0 (quilt)"; spec.patches = &[ "--- a/hello.txt\n+++ b/hello.txt\n@@ -1 +1 @@\n-upstream content v1\n+patched by debian\n", ]; spec.previous_version = Some("1.4-1"); differential_case(&spec); } /// Conversely, a new upstream version must redistribute the orig /// tarball, even though a previous entry exists. #[test] fn diff_quilt_new_upstream_includes_orig() { let mut spec = FixtureSpec::new("pkh-diff-o", "2.0-1", "unstable"); spec.format = "3.0 (quilt)"; spec.patches = &[ "--- a/hello.txt\n+++ b/hello.txt\n@@ -1 +1 @@\n-upstream content v1\n+patched by debian\n", ]; spec.previous_version = Some("1.4-2"); differential_case(&spec); } /// `--orig always` (-sa) forces the tarball into a same-upstream /// revision bump upload. #[test] fn diff_orig_always_forces_inclusion() { let mut spec = FixtureSpec::new("pkh-diff-p", "1.4-2", "unstable"); spec.format = "3.0 (quilt)"; spec.patches = &[ "--- a/hello.txt\n+++ b/hello.txt\n@@ -1 +1 @@\n-upstream content v1\n+patched by debian\n", ]; spec.previous_version = Some("1.4-1"); let base = tempfile::tempdir().expect("tempdir"); let tree = write_fixture(base.path(), &spec); differential_on_tree( &tree, &SourceBuildOptions { orig_source: OrigSourceMode::Always, ..Default::default() }, ); } /// `--orig never` (-sd) forces the tarball out of a new-upstream upload. #[test] fn diff_orig_never_forces_exclusion() { let mut spec = FixtureSpec::new("pkh-diff-q", "2.0-1", "unstable"); spec.format = "3.0 (quilt)"; spec.patches = &[ "--- a/hello.txt\n+++ b/hello.txt\n@@ -1 +1 @@\n-upstream content v1\n+patched by debian\n", ]; spec.previous_version = Some("1.4-2"); let base = tempfile::tempdir().expect("tempdir"); let tree = write_fixture(base.path(), &spec); differential_on_tree( &tree, &SourceBuildOptions { orig_source: OrigSourceMode::Never, ..Default::default() }, ); } /// Differential check of a single real archive package: pull it with /// pkh's own [`crate::pull`] (archive download mode) from `dist` /// (optionally `series`), then compare artifacts produced by real /// `dpkg-buildpackage` against the native pipeline. Requires network /// access. fn differential_real_archive_package(package: &str, dist: &str, series: Option<&str>) { let fetch_dir = tempfile::tempdir().expect("tempdir"); let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); let package_info = rt .block_on(crate::package_info::lookup( package, None, series, "", Some(dist), None, None, None, )) .unwrap_or_else(|e| panic!("package lookup failed for {}: {}", package, e)); rt.block_on(crate::pull::pull( &package_info, Some(fetch_dir.path()), None, true, )) .unwrap_or_else(|e| panic!("pull failed for {}: {}", package, e)); // pull extracts the source tree under '//', // with the orig tarball and .dsc alongside it. let tree = fetch_dir.path().join(package).join(package); assert!( tree.join("debian/changelog").exists(), "pulled tree for {} has no debian/changelog", package ); log::info!( "differential test against real package: {} ({}/{})", package, dist, series.unwrap_or("latest") ); differential_on_tree(&tree, &SourceBuildOptions::default()); } #[test] fn diff_real_hello_ubuntu_noble() { differential_real_archive_package("hello", "ubuntu", Some("noble")); } #[test] fn diff_real_dosfstools_debian_trixie() { differential_real_archive_package("dosfstools", "debian", Some("trixie")); } #[test] fn diff_real_sl_ubuntu_resolute() { differential_real_archive_package("sl", "ubuntu", Some("resolute")); } #[test] fn diff_real_linux_ubuntu_resolute() { differential_real_archive_package("linux", "ubuntu", Some("resolute")); } #[test] fn diff_real_linux_riscv_ubuntu_resolute() { differential_real_archive_package("linux-riscv", "ubuntu", Some("resolute")); } #[test] fn diff_real_2048_universe_ubuntu_end_to_end() { differential_real_archive_package("2048", "ubuntu", Some("noble")); } #[test] fn diff_real_1oom_contrib_debian_end_to_end() { differential_real_archive_package("1oom", "debian", Some("trixie")); } #[test] fn diff_real_agg_svn_fallback_ok() { differential_real_archive_package("agg", "debian", Some("trixie")); } #[test] fn diff_real_hello_debian_latest_end_to_end() { differential_real_archive_package("hello", "debian", None); } #[test] fn diff_real_hello_ubuntu_latest_end_to_end() { differential_real_archive_package("hello", "ubuntu", None); } /// Arbitrary corpus via environment variables, for ad-hoc broad runs: /// ```text /// PKH_DIFF_PACKAGES="bash coreutils curl" \ /// PKH_DIFF_DIST=ubuntu \ /// PKH_DIFF_SERIES=noble \ /// cargo test --lib differential_real_archive_packages -- --ignored /// ``` #[test] #[ignore = "requires network access; intended for ad-hoc broad runs"] fn differential_real_archive_packages() { let packages = std::env::var("PKH_DIFF_PACKAGES") .unwrap_or_else(|_| "hello".to_string()) .split_whitespace() .map(str::to_string) .collect::>(); assert!( !packages.is_empty(), "PKH_DIFF_PACKAGES contained no package names" ); let dist = std::env::var("PKH_DIFF_DIST").unwrap_or_else(|_| "ubuntu".to_string()); let series = std::env::var("PKH_DIFF_SERIES").ok(); for name in &packages { differential_real_archive_package(name, &dist, series.as_deref()); } } }