diff --git a/src/build/changes.rs b/src/build/changes.rs index 3038967..84f0015 100644 --- a/src/build/changes.rs +++ b/src/build/changes.rs @@ -34,6 +34,8 @@ pub struct ChangesInput { pub changed_by: Option, /// Formatted per-package description lines (empty for source-only). pub descriptions: Vec, + /// Bug numbers collected from the changelog (`Closes` field), if any. + pub closes: Option, /// 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 ".to_string()), changed_by: Some("A B ".to_string()), descriptions: vec![], + closes: None, changes_field: "pkg (1.0) unstable; urgency=medium\n.\n * Something.".to_string(), checksums, files_list, diff --git a/src/build/mod.rs b/src/build/mod.rs index 0f8b82b..5f37996 100644 --- a/src/build/mod.rs +++ b/src/build/mod.rs @@ -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 = 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 "; + 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::>() + .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::>() + .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 '//', + // 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::>(); + + 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()); + } + } +} diff --git a/src/debian/changelog.rs b/src/debian/changelog.rs index 3b2fac8..aaad3f7 100644 --- a/src/debian/changelog.rs +++ b/src/debian/changelog.rs @@ -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, } /// Parse the most recent entry of a Debian changelog file. @@ -163,6 +167,8 @@ pub fn parse_changelog_entry(path: &Path) -> Result Result Option { + let re = Regex::new(r"(?i)\(closes:\s*([^)]*)\)").ok()?; + let mut numbers: Vec = 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::() { + numbers.push(n); + } + } + } + } + } + if numbers.is_empty() { + return None; + } + numbers.sort_unstable(); + numbers.dedup(); + Some( + numbers + .iter() + .map(u64::to_string) + .collect::>() + .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, Box> { + 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 {