Files
pkh/src/build/changes.rs
T
vhaudiquet 0ad020ae19 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).
2026-08-23 11:05:36 +02:00

246 lines
8.5 KiB
Rust

//! Native `.changes` generation (Format 1.8), mirroring `dpkg-genchanges`
//! for the artifact aggregation part: checksums, per-file sections and
//! priorities, changelog-derived fields.
use std::path::Path;
use crate::debian::checksums::FileChecksums;
use crate::debian::control::{Paragraph, write_paragraph};
use crate::debian::files::FilesList;
/// Everything needed to render a `.changes` file.
#[derive(Debug, Clone)]
pub struct ChangesInput {
/// `Date` field: the changelog entry date (verbatim trailer date).
pub date: String,
/// `Source` field, including the ` (sourceversion)` suffix for binNMUs.
pub source: String,
/// Sorted binary package names with artifacts (empty for source-only).
pub binaries: Vec<String>,
/// Active build profiles (`Built-For-Profiles`); omitted when empty.
pub built_for_profiles: Vec<String>,
/// `Architecture` field value in encounter order (e.g. `source`,
/// `amd64 all`, ...).
pub architecture: String,
/// Full version.
pub version: String,
/// Distribution(s).
pub distribution: String,
/// Urgency.
pub urgency: String,
/// `Maintainer` from the control source stanza.
pub maintainer: Option<String>,
/// `Changed-By` from the changelog maintainer.
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).
pub checksums: FileChecksums,
/// Registry providing section/priority per file.
pub files_list: FilesList,
}
/// Wrap an overly long single-line field value (> 980 characters) over
/// multiple lines at spaces, like dpkg does for `Binary`.
fn wrap_long(value: &str) -> String {
if value.len() <= 980 {
return value.to_string();
}
let mut out = String::with_capacity(value.len() + 8);
let mut line_len = 0usize;
for (i, word) in value.split(' ').enumerate() {
if i > 0 {
if line_len + 1 + word.len() > 980 {
out.push('\n');
line_len = 0;
} else {
out.push(' ');
line_len += 1;
}
}
out.push_str(word);
line_len += word.len();
}
out
}
/// Format one `Description` line: `%-10s - %-.65s` plus a ` (type)` suffix
/// for non-deb package types, matching `format_desc()` in dpkg-genchanges.
pub fn format_description(package: &str, package_type: &str, summary: &str) -> String {
let mut line = format!("{:<10} - {:.65}", package, summary);
if package_type != "deb" && !package_type.is_empty() {
line.push_str(&format!(" ({})", package_type));
}
line
}
/// Render the `.changes` document (without signature), with fields in dpkg's
/// canonical order for `CTRL_FILE_CHANGES`.
///
/// Note: the legacy `Files` field carries md5+size+section+priority+name,
/// while `Checksums-Sha1`/`Checksums-Sha256` carry the stronger hashes;
/// `Checksums-Md5` is deliberately omitted as redundant, exactly like
/// dpkg-genchanges does.
pub fn render_changes(input: &ChangesInput) -> Paragraph {
let mut p = Paragraph::new();
p.set("Format", "1.8");
p.set("Date", &input.date);
p.set("Source", &input.source);
if !input.binaries.is_empty() {
let joined = input.binaries.join(" ");
p.set("Binary", &wrap_long(&joined));
}
if !input.built_for_profiles.is_empty() {
p.set("Built-For-Profiles", &input.built_for_profiles.join(" "));
}
p.set("Architecture", &input.architecture);
p.set("Version", &input.version);
p.set("Distribution", &input.distribution);
p.set("Urgency", &input.urgency);
if let Some(maintainer) = &input.maintainer {
p.set("Maintainer", maintainer);
}
if let Some(changed_by) = &input.changed_by {
p.set("Changed-By", changed_by);
}
if !input.descriptions.is_empty() {
let mut sorted = input.descriptions.clone();
sorted.sort();
// 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() {
p.set("Checksums-Sha1", &input.checksums.field_sha1());
p.set("Checksums-Sha256", &input.checksums.field_sha256());
// Legacy Files field: md5 size section priority filename
let mut files = String::new();
for (key, entry) in input.checksums.iter() {
let (section, priority) = input
.files_list
.get(key)
.map(|f| (f.section.as_str(), f.priority.as_str()))
.unwrap_or(("-", "-"));
files.push('\n');
files.push_str(&entry.md5);
files.push(' ');
files.push_str(&entry.size.to_string());
files.push(' ');
files.push_str(section);
files.push(' ');
files.push_str(priority);
files.push(' ');
files.push_str(key);
}
p.set("Files", &files);
}
p
}
/// Serialize and atomically write a `.changes` file.
pub fn save_changes(path: &Path, paragraph: &Paragraph) -> Result<(), Box<dyn std::error::Error>> {
let tmp = path.with_extension("new");
std::fs::write(&tmp, write_paragraph(paragraph))
.map_err(|e| format!("cannot write '{}': {}", tmp.display(), e))?;
std::fs::rename(&tmp, path)
.map_err(|e| format!("cannot install '{}': {}", path.display(), e).into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn description_formatting() {
assert_eq!(
format_description("hello", "deb", "The classic greeting"),
"hello - The classic greeting"
);
assert_eq!(
format_description("verylongpkgname", "udeb", "short"),
"verylongpkgname - short (udeb)"
);
let long_summary = "x".repeat(100);
assert_eq!(
format_description("p", "deb", &long_summary).len(),
10 + 3 + 65
);
}
#[test]
fn render_source_only_changes() {
let dir = tempfile::tempdir().unwrap();
let dsc_path = dir.path().join("pkg_1.0.dsc");
std::fs::write(&dsc_path, b"content\n").unwrap();
let mut checksums = FileChecksums::new();
checksums.add_file(&dsc_path).unwrap();
let mut files_list = FilesList::new();
files_list.add(crate::debian::FilesEntry::new(
"pkg_1.0.dsc",
"utils",
"optional",
));
let input = ChangesInput {
date: "Sat, 22 Aug 2026 10:00:00 +0000".to_string(),
source: "pkg".to_string(),
binaries: vec![],
built_for_profiles: vec![],
architecture: "source".to_string(),
version: "1.0".to_string(),
distribution: "unstable".to_string(),
urgency: "medium".to_string(),
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,
};
let p = render_changes(&input);
let keys: Vec<&str> = p.iter().map(|(k, _)| k).collect();
assert_eq!(
keys,
vec![
"Format",
"Date",
"Source",
"Architecture",
"Version",
"Distribution",
"Urgency",
"Maintainer",
"Changed-By",
"Changes",
"Checksums-Sha1",
"Checksums-Sha256",
"Files"
]
);
// No Binary / Description / Checksums-Md5 for source-only uploads.
assert!(p.get("Binary").is_none());
assert!(p.get("Description").is_none());
assert!(p.get("Checksums-Md5").is_none());
let files_value = p.get("Files").unwrap();
assert_eq!(
files_value,
"\n<md5> 8 utils optional pkg_1.0.dsc"
.replace("<md5>", files_value.split_whitespace().next().unwrap_or(""))
);
}
}