build: add differential tests against real dpkg-buildpackage

Add an automated differential test harness in build/mod.rs that builds
the same source tree twice - once with real 'dpkg-buildpackage -S
-I -i -nc -d --no-sign', once with the native pipeline - and compares
all produced artifacts:

- .dsc payload byte-for-byte,
- .changes field-by-field (checksum lines of the .buildinfo itself
  excluded, as its content legitimately differs on machine-dependent
  fields),
- .buildinfo structure (Installed-Build-Depends, Environment,
  Build-Date and Build-Tainted-By excluded).
This commit is contained in:
2026-08-23 11:05:36 +02:00
parent 4a8ff9ac0d
commit 0ad020ae19
3 changed files with 735 additions and 13 deletions
+6
View File
@@ -34,6 +34,8 @@ pub struct ChangesInput {
pub changed_by: Option<String>, pub changed_by: Option<String>,
/// Formatted per-package description lines (empty for source-only). /// Formatted per-package description lines (empty for source-only).
pub descriptions: Vec<String>, pub descriptions: Vec<String>,
/// Bug numbers collected from the changelog (`Closes` field), if any.
pub closes: Option<String>,
/// Rendered `Changes` field value from the changelog entry. /// Rendered `Changes` field value from the changelog entry.
pub changes_field: String, pub changes_field: String,
/// Computed artifact checksums (dsc, tarballs, debs, buildinfo). /// Computed artifact checksums (dsc, tarballs, debs, buildinfo).
@@ -111,6 +113,9 @@ pub fn render_changes(input: &ChangesInput) -> Paragraph {
// Leading `\n`: pre-wrapped multiline field, dpkg-style. // Leading `\n`: pre-wrapped multiline field, dpkg-style.
p.set("Description", &format!("\n{}", sorted.join("\n"))); p.set("Description", &format!("\n{}", sorted.join("\n")));
} }
if let Some(closes) = &input.closes {
p.set("Closes", closes);
}
p.set("Changes", &input.changes_field); p.set("Changes", &input.changes_field);
if !input.checksums.is_empty() { if !input.checksums.is_empty() {
@@ -199,6 +204,7 @@ mod tests {
maintainer: Some("A B <a@b.c>".to_string()), maintainer: Some("A B <a@b.c>".to_string()),
changed_by: Some("A B <a@b.c>".to_string()), changed_by: Some("A B <a@b.c>".to_string()),
descriptions: vec![], descriptions: vec![],
closes: None,
changes_field: "pkg (1.0) unstable; urgency=medium\n.\n * Something.".to_string(), changes_field: "pkg (1.0) unstable; urgency=medium\n.\n * Something.".to_string(),
checksums, checksums,
files_list, files_list,
+665 -13
View File
@@ -115,6 +115,28 @@ pub fn run_source_build(
log::info!("source version {}", entry.version.full()); log::info!("source version {}", entry.version.full());
log::info!("source distribution {}", entry.distribution); log::info!("source distribution {}", entry.distribution);
// binNMU builds reference the *previous* (source) version in their
// artifact metadata, like dpkg-genchanges/genbuildinfo do.
let previous_version = if entry.binary_only {
crate::debian::changelog::parse_previous_version(&changelog_path)?
} else {
None
};
let source_display = if entry.binary_only {
match previous_version.as_deref() {
Some(prev) => format!("{} ({})", entry.source, prev),
None => entry.source.clone(),
}
} else {
entry.source.clone()
};
let binary_only_changes = entry.binary_only.then(|| {
format!(
"{}\n\n -- {} <{}> {}",
entry.changes_field, entry.maintainer_name, entry.maintainer_email, entry.date_raw
)
});
let sversion = entry.version.no_epoch(); let sversion = entry.version.no_epoch();
let dsc_name = format!("{}_{}.dsc", entry.source, sversion); let dsc_name = format!("{}_{}.dsc", entry.source, sversion);
let dsc_path = parent.join(&dsc_name); let dsc_path = parent.join(&dsc_name);
@@ -186,11 +208,32 @@ pub fn run_source_build(
.into()); .into());
} }
// Binary-only uploads redistribute the *previous* source: metadata
// references the previous version's .dsc (which must already exist in
// the output directory), exactly like dpkg-genchanges/genbuildinfo.
let ref_dsc_name = match previous_version.as_deref().filter(|_| entry.binary_only) {
Some(prev) => {
let prev_version = crate::debian::DebianVersion::parse(prev)?;
let name = format!("{}_{}.dsc", entry.source, prev_version.no_epoch());
if !parent.join(&name).exists() {
return Err(format!(
"binary-only build requires the previous source '{} \
{}' to exist next to the package",
entry.source, prev
)
.into());
}
name
}
None => dsc_name.clone(),
};
let ref_dsc_path = parent.join(&ref_dsc_name);
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// 6. .buildinfo generation (native dpkg-genbuildinfo equivalent) // 6. .buildinfo generation (native dpkg-genbuildinfo equivalent)
// ------------------------------------------------------------------ // ------------------------------------------------------------------
let mut checksums = FileChecksums::new(); let mut checksums = FileChecksums::new();
checksums.add_file_as(&dsc_path, &dsc_name)?; checksums.add_file_as(&ref_dsc_path, &ref_dsc_name)?;
let status_path = PathBuf::from("/var/lib/dpkg/status"); let status_path = PathBuf::from("/var/lib/dpkg/status");
let bd_fields = [ctrl.source.get("Build-Depends").unwrap_or("")]; let bd_fields = [ctrl.source.get("Build-Depends").unwrap_or("")];
@@ -199,11 +242,11 @@ pub fn run_source_build(
let render_buildinfo_doc = |checksums: &FileChecksums| { let render_buildinfo_doc = |checksums: &FileChecksums| {
buildinfo::render_buildinfo(&buildinfo::BuildInfoInput { buildinfo::render_buildinfo(&buildinfo::BuildInfoInput {
source: entry.source.clone(), source: source_display.clone(),
binaries: Vec::new(), // source-only build binaries: Vec::new(), // source-only build
architecture: "source".to_string(), architecture: "source".to_string(),
version: entry.version.full(), version: entry.version.full(),
binary_only_changes: None, binary_only_changes: binary_only_changes.clone(),
build_origin: vendor.clone(), build_origin: vendor.clone(),
build_architecture: arch_vars build_architecture: arch_vars
.get("DEB_BUILD_ARCH") .get("DEB_BUILD_ARCH")
@@ -234,15 +277,15 @@ pub fn run_source_build(
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// 7. .changes generation (native dpkg-genchanges equivalent) // 7. .changes generation (native dpkg-genchanges equivalent)
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Pull the tarball checksums out of the generated .dsc so they are // Pull the tarball checksums out of the referenced .dsc so they are
// distributed through the .changes like dpkg-genchanges does, in the // distributed through the .changes like dpkg-genchanges does, in the
// order the .dsc itself lists them. // order the .dsc itself lists them.
let dsc_content = std::fs::read_to_string(&dsc_path) let dsc_content = std::fs::read_to_string(&ref_dsc_path)
.map_err(|e| format!("cannot read '{}': {}", dsc_path.display(), e))?; .map_err(|e| format!("cannot read '{}': {}", ref_dsc_path.display(), e))?;
let dsc_para = parse_paragraphs(&dsc_content) let dsc_para = parse_paragraphs(&dsc_content)
.into_iter() .into_iter()
.next() .next()
.ok_or_else(|| format!("'{}' is empty", dsc_path.display()))?; .ok_or_else(|| format!("'{}' is empty", ref_dsc_path.display()))?;
let mut tarball_paths = Vec::new(); let mut tarball_paths = Vec::new();
let mut dsc_file_names: Vec<String> = Vec::new(); let mut dsc_file_names: Vec<String> = Vec::new();
@@ -277,7 +320,7 @@ pub fn run_source_build(
} }
} }
for name in &dsc_file_names { for name in &dsc_file_names {
if name == &dsc_name { if name == &ref_dsc_name {
continue; // already computed directly above continue; // already computed directly above
} }
let path = parent.join(name); let path = parent.join(name);
@@ -310,9 +353,13 @@ pub fn run_source_build(
// dsc and tarballs use the source stanza defaults (not persisted into // dsc and tarballs use the source stanza defaults (not persisted into
// debian/files, matching dpkg). // debian/files, matching dpkg).
let mut changes_files = files_list.clone(); let mut changes_files = files_list.clone();
changes_files.add(FilesEntry::new(&dsc_name, ctrl.section(), ctrl.priority())); changes_files.add(FilesEntry::new(
&ref_dsc_name,
ctrl.section(),
ctrl.priority(),
));
for name in &dsc_file_names { for name in &dsc_file_names {
if name != &dsc_name { if name != &ref_dsc_name {
changes_files.add(FilesEntry::new(name, ctrl.section(), ctrl.priority())); changes_files.add(FilesEntry::new(name, ctrl.section(), ctrl.priority()));
} }
} }
@@ -321,7 +368,7 @@ pub fn run_source_build(
let render_changes_doc = |checksums: &FileChecksums| { let render_changes_doc = |checksums: &FileChecksums| {
changes::render_changes(&changes::ChangesInput { changes::render_changes(&changes::ChangesInput {
date: entry.date_raw.clone(), date: entry.date_raw.clone(),
source: entry.source.clone(), source: source_display.clone(),
binaries: Vec::new(), // source-only upload binaries: Vec::new(), // source-only upload
built_for_profiles: profiles.clone(), built_for_profiles: profiles.clone(),
architecture: "source".to_string(), architecture: "source".to_string(),
@@ -331,6 +378,7 @@ pub fn run_source_build(
maintainer: ctrl.source.get("Maintainer").map(str::to_string), maintainer: ctrl.source.get("Maintainer").map(str::to_string),
changed_by: Some(changed_by.clone()), changed_by: Some(changed_by.clone()),
descriptions: Vec::new(), descriptions: Vec::new(),
closes: entry.closes.clone(),
changes_field: entry.changes_field.clone(), changes_field: entry.changes_field.clone(),
checksums: checksums.clone(), checksums: checksums.clone(),
files_list: changes_files.clone(), files_list: changes_files.clone(),
@@ -357,8 +405,13 @@ pub fn run_source_build(
println!("signfile {}", dsc_name); println!("signfile {}", dsc_name);
crate::utils::gpg::clearsign_file(&dsc_path, &keyid)?; crate::utils::gpg::clearsign_file(&dsc_path, &keyid)?;
// The .dsc changed: refresh its checksums inside the .buildinfo. // The freshly built .dsc changed: refresh its checksums inside the
checksums.add_file_as(&dsc_path, &dsc_name)?; // .buildinfo. For binary-only builds the metadata references the
// *previous* .dsc (untouched by this build), so there is nothing to
// refresh.
if !entry.binary_only {
checksums.add_file_as(&dsc_path, &dsc_name)?;
}
buildinfo::save_buildinfo(&buildinfo_path, &render_buildinfo_doc(&checksums))?; buildinfo::save_buildinfo(&buildinfo_path, &render_buildinfo_doc(&checksums))?;
println!("signfile {}", buildinfo_name); println!("signfile {}", buildinfo_name);
@@ -443,3 +496,602 @@ mod tests {
assert_eq!(v.no_epoch(), "1.0-2"); assert_eq!(v.no_epoch(), "1.0-2");
} }
} }
/// 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)],
/// Two-entry changelog (previous entry) for binNMU cases.
with_previous_entry: 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: &[],
with_previous_entry: 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 mut out = format!(
"{} ({}) {}; urgency={}\n\n",
self.name, self.version, self.distribution, self.urgency
);
for line in self.body {
out.push_str(" * ");
out.push_str(line);
out.push('\n');
}
out.push_str(&format!("\n -- {MAINTAINER} {DATE}\n"));
if self.with_previous_entry {
let prev = match self.version.split_once(':') {
Some((_, r)) => r.to_string(),
None => self.version.to_string(),
};
// Turn "1.0-1+b1" into "1.0-1" for the previous entry.
let prev = prev.rsplit_once('+').map(|(p, _)| p).unwrap_or(&prev);
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 = cmd.status().expect("run tar");
assert!(status.success(), "tar failed for {}", tarball.display());
}
dir
}
fn copy_path(src: &Path, dst_root: &Path) {
let status = Command::new("cp")
.arg("-a")
.arg(src)
.arg(dst_root)
.status()
.expect("run cp -a");
assert!(
status.success(),
"cp -a {} {} failed",
src.display(),
dst_root.display()
);
}
fn run_dpkg(tree: &Path) {
let status = Command::new("dpkg-buildpackage")
.current_dir(tree)
.args(["-S", "-I", "-i", "-nc", "-d", "--no-sign"])
.status()
.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("_source.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.
fn differential_on_tree(src_tree: &Path) {
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);
run_dpkg(&golden_tree);
run_source_build(&ours_tree, &SourceBuildOptions::default())
.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);
}
#[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.with_previous_entry = true;
// Binary-only metadata references the previous version's .dsc, which
// must already exist next to the package tree.
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);
}
#[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);
}
/// 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,
))
.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);
}
#[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());
}
}
}
+64
View File
@@ -3,6 +3,7 @@
use std::path::Path; use std::path::Path;
use chrono::DateTime; use chrono::DateTime;
use regex::Regex;
use super::version::DebianVersion; use super::version::DebianVersion;
@@ -30,6 +31,9 @@ pub struct ChangelogEntry {
/// Value for the `.changes` `Changes` field: header line, blank lines /// Value for the `.changes` `Changes` field: header line, blank lines
/// converted to `.`, body lines verbatim; without the trailer line. /// converted to `.`, body lines verbatim; without the trailer line.
pub changes_field: String, pub changes_field: String,
/// Bug numbers collected from `(Closes: #NNN)` mentions in the body,
/// sorted numerically and de-duplicated (like dpkg's `find_closes`).
pub closes: Option<String>,
} }
/// Parse the most recent entry of a Debian changelog file. /// Parse the most recent entry of a Debian changelog file.
@@ -163,6 +167,8 @@ pub fn parse_changelog_entry(path: &Path) -> Result<ChangelogEntry, Box<dyn std:
changes_field.push_str(&body_lines.join("\n")); changes_field.push_str(&body_lines.join("\n"));
} }
let closes = find_closes(&body_lines);
Ok(ChangelogEntry { Ok(ChangelogEntry {
source, source,
version, version,
@@ -174,9 +180,67 @@ pub fn parse_changelog_entry(path: &Path) -> Result<ChangelogEntry, Box<dyn std:
date_raw, date_raw,
timestamp, timestamp,
changes_field, changes_field,
closes,
}) })
} }
/// Extract bug numbers from `(Closes: #NNN)` mentions in changelog body
/// lines, sorted numerically and de-duplicated (a lenient port of dpkg's
/// `find_closes`).
fn find_closes(body_lines: &[String]) -> Option<String> {
let re = Regex::new(r"(?i)\(closes:\s*([^)]*)\)").ok()?;
let mut numbers: Vec<u64> = Vec::new();
for line in body_lines {
for capture in re.captures_iter(line) {
if let Some(inner) = capture.get(1) {
for token in inner.as_str().split(|c: char| !c.is_ascii_digit()) {
if let Ok(n) = token.parse::<u64>() {
numbers.push(n);
}
}
}
}
}
if numbers.is_empty() {
return None;
}
numbers.sort_unstable();
numbers.dedup();
Some(
numbers
.iter()
.map(u64::to_string)
.collect::<Vec<_>>()
.join(" "),
)
}
/// Return the version of the *previous* changelog entry (the second header
/// in the file), or `None` when only one entry exists.
pub fn parse_previous_version(path: &Path) -> Result<Option<String>, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("failed to read changelog '{}': {}", path.display(), e))?;
let mut seen_first = false;
for line in content.lines() {
let line = line.trim_end();
if looks_like_header(line) {
if !seen_first {
seen_first = true;
continue;
}
let open = line.find('(').ok_or_else(|| {
format!("invalid changelog header in '{}': {}", path.display(), line)
})?;
let close = line[open..]
.find(')')
.ok_or_else(|| format!("unbalanced parenthesis in changelog header '{}'", line))?;
return Ok(Some(line[open + 1..open + close].to_string()));
}
}
Ok(None)
}
/// Heuristic check for a changelog entry header line /// Heuristic check for a changelog entry header line
/// (`name (version) dist; urgency=...`). /// (`name (version) dist; urgency=...`).
fn looks_like_header(line: &str) -> bool { fn looks_like_header(line: &str) -> bool {