Files
pkh/src/build/mod.rs
T
vhaudiquet d64e472845 report: grow the Prompter port and move display_path out of the ui module
Prompter gains interactive(), select() and text() (with the Validator
type), and confirm() now propagates cancellation as Err so flows abort
instead of silently taking a default when the user hits Ctrl+C. The
terminal prompter implements the full port; the port also re-exports
the path display helper, which is pure presentation formatting used by
events and messages rather than terminal code.
2026-09-18 20:34:33 +02:00

2375 lines
93 KiB
Rust

//! 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<String>,
/// 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<PathBuf>,
/// 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<PathBuf> {
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<PathBuf>,
/// 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<SourceBuildOutput, Box<dyn Error>> {
// 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::<VendorDriftError>().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<dyn Error>,
) -> Result<SourceBuildOutput, Box<dyn Error>> {
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<SourceBuildOutput, Box<dyn Error>> {
// 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<Arc<dyn LineSink>> = 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<String> = Vec::new();
let mut dsc_files: HashMap<String, PartialChecksum> = 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<u64>,
md5: Option<String>,
sha1: Option<String>,
sha256: Option<String>,
}
/// 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: `<digest> <size> <name>` (modern `Checksums-*` fields and
/// the `Files` field of freshly built `.dsc`/`.changes`),
/// - 5 columns: `<digest> <size> <section> <priority> <name>` (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<Vec<ChecksumLine>, 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<String>,
}
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 <name>_<version>.\
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<dyn Error>,
) -> Box<dyn Error> {
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<dyn Error>,
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<String, String>,
sink: Option<&Arc<dyn LineSink>>,
) -> 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<String> {
join.err().map(|payload| {
payload
.downcast_ref::<&str>()
.map(|s| (*s).to_string())
.or_else(|| payload.downcast_ref::<String>().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<String, String>,
sink: Option<&Arc<dyn LineSink>>,
) -> Result<(), Box<dyn Error>> {
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<dyn LineSink>,
capture: Arc<std::sync::Mutex<String>>,
}
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 <a@b.c> Mon, 01 Jan 2024 00:00:00 +0000\n",
)
.expect("write changelog");
std::fs::write(
tree.join("debian/control"),
"Source: hello\nMaintainer: A B <a@b.c>\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::<VendorDriftError>().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<dyn Error> = Box::new(VendorDriftError { detail });
assert!(boxed.downcast_ref::<VendorDriftError>().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 <pkh-diff@example.invalid>";
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::<Vec<_>>()
.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::<Vec<_>>()
.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 <arch>` 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::<Vec<_>>()
.join("\n");
// Native checker with equivalent options.
let mut profiles: Vec<String> = 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 <a@b.c>\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 <stage1>", "", &["-P", "stage1"]);
case("missing-abc <stage1>", "", &["-P", "cross"]);
case("missing-abc <!stage1>", "", &["-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<String, String> = [
("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 '<fetch_dir>/<package>/<package>',
// 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::<Vec<_>>();
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());
}
}
}