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>,
|
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,
|
||||||
|
|||||||
+664
-12
@@ -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
|
||||||
|
// .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)?;
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user