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:
@@ -34,6 +34,8 @@ pub struct ChangesInput {
|
||||
pub changed_by: Option<String>,
|
||||
/// Formatted per-package description lines (empty for source-only).
|
||||
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.
|
||||
pub changes_field: String,
|
||||
/// 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.
|
||||
p.set("Description", &format!("\n{}", sorted.join("\n")));
|
||||
}
|
||||
if let Some(closes) = &input.closes {
|
||||
p.set("Closes", closes);
|
||||
}
|
||||
p.set("Changes", &input.changes_field);
|
||||
|
||||
if !input.checksums.is_empty() {
|
||||
@@ -199,6 +204,7 @@ mod tests {
|
||||
maintainer: Some("A B <a@b.c>".to_string()),
|
||||
changed_by: Some("A B <a@b.c>".to_string()),
|
||||
descriptions: vec![],
|
||||
closes: None,
|
||||
changes_field: "pkg (1.0) unstable; urgency=medium\n.\n * Something.".to_string(),
|
||||
checksums,
|
||||
files_list,
|
||||
|
||||
+665
-13
@@ -115,6 +115,28 @@ pub fn run_source_build(
|
||||
log::info!("source version {}", entry.version.full());
|
||||
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 dsc_name = format!("{}_{}.dsc", entry.source, sversion);
|
||||
let dsc_path = parent.join(&dsc_name);
|
||||
@@ -186,11 +208,32 @@ pub fn run_source_build(
|
||||
.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)
|
||||
// ------------------------------------------------------------------
|
||||
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 bd_fields = [ctrl.source.get("Build-Depends").unwrap_or("")];
|
||||
@@ -199,11 +242,11 @@ pub fn run_source_build(
|
||||
|
||||
let render_buildinfo_doc = |checksums: &FileChecksums| {
|
||||
buildinfo::render_buildinfo(&buildinfo::BuildInfoInput {
|
||||
source: entry.source.clone(),
|
||||
source: source_display.clone(),
|
||||
binaries: Vec::new(), // source-only build
|
||||
architecture: "source".to_string(),
|
||||
version: entry.version.full(),
|
||||
binary_only_changes: None,
|
||||
binary_only_changes: binary_only_changes.clone(),
|
||||
build_origin: vendor.clone(),
|
||||
build_architecture: arch_vars
|
||||
.get("DEB_BUILD_ARCH")
|
||||
@@ -234,15 +277,15 @@ pub fn run_source_build(
|
||||
// ------------------------------------------------------------------
|
||||
// 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
|
||||
// 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_content = std::fs::read_to_string(&ref_dsc_path)
|
||||
.map_err(|e| format!("cannot read '{}': {}", ref_dsc_path.display(), e))?;
|
||||
let dsc_para = parse_paragraphs(&dsc_content)
|
||||
.into_iter()
|
||||
.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 dsc_file_names: Vec<String> = Vec::new();
|
||||
@@ -277,7 +320,7 @@ pub fn run_source_build(
|
||||
}
|
||||
}
|
||||
for name in &dsc_file_names {
|
||||
if name == &dsc_name {
|
||||
if name == &ref_dsc_name {
|
||||
continue; // already computed directly above
|
||||
}
|
||||
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
|
||||
// debian/files, matching dpkg).
|
||||
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 {
|
||||
if name != &dsc_name {
|
||||
if name != &ref_dsc_name {
|
||||
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| {
|
||||
changes::render_changes(&changes::ChangesInput {
|
||||
date: entry.date_raw.clone(),
|
||||
source: entry.source.clone(),
|
||||
source: source_display.clone(),
|
||||
binaries: Vec::new(), // source-only upload
|
||||
built_for_profiles: profiles.clone(),
|
||||
architecture: "source".to_string(),
|
||||
@@ -331,6 +378,7 @@ pub fn run_source_build(
|
||||
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(),
|
||||
@@ -357,8 +405,13 @@ pub fn run_source_build(
|
||||
|
||||
println!("signfile {}", dsc_name);
|
||||
crate::utils::gpg::clearsign_file(&dsc_path, &keyid)?;
|
||||
// The .dsc changed: refresh its checksums inside the .buildinfo.
|
||||
checksums.add_file_as(&dsc_path, &dsc_name)?;
|
||||
// The freshly built .dsc changed: refresh its checksums inside the
|
||||
// .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))?;
|
||||
|
||||
println!("signfile {}", buildinfo_name);
|
||||
@@ -443,3 +496,602 @@ mod tests {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use std::path::Path;
|
||||
|
||||
use chrono::DateTime;
|
||||
use regex::Regex;
|
||||
|
||||
use super::version::DebianVersion;
|
||||
|
||||
@@ -30,6 +31,9 @@ pub struct ChangelogEntry {
|
||||
/// Value for the `.changes` `Changes` field: header line, blank lines
|
||||
/// converted to `.`, body lines verbatim; without the trailer line.
|
||||
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.
|
||||
@@ -163,6 +167,8 @@ pub fn parse_changelog_entry(path: &Path) -> Result<ChangelogEntry, Box<dyn std:
|
||||
changes_field.push_str(&body_lines.join("\n"));
|
||||
}
|
||||
|
||||
let closes = find_closes(&body_lines);
|
||||
|
||||
Ok(ChangelogEntry {
|
||||
source,
|
||||
version,
|
||||
@@ -174,9 +180,67 @@ pub fn parse_changelog_entry(path: &Path) -> Result<ChangelogEntry, Box<dyn std:
|
||||
date_raw,
|
||||
timestamp,
|
||||
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
|
||||
/// (`name (version) dist; urgency=...`).
|
||||
fn looks_like_header(line: &str) -> bool {
|
||||
|
||||
Reference in New Issue
Block a user