dpkg-genchanges/genbuildinfo handle a binary-only upload by referencing the previous source version textually (Source: pkg (prev), Binary-Only: yes, Binary-Only-Changes) while distributing no source files at all: pkh instead pulled the previous .dsc and its tarballs into both documents whenever they sat next to the artifacts, re-uploading the whole source on every binNMU. Drop that redistribution (and include_dsc_artifacts with it), and emit the missing Binary-Only: yes field, which the new differential test against real dpkg-buildpackage -b caught. The binNMU case shares its runner with the regular binary metadata differential; a unit test pins the exclusion even with the previous artifacts present.
386 lines
14 KiB
Rust
386 lines
14 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 super::OrigSourceMode;
|
|
use crate::debian::changelog::ChangelogEntry;
|
|
use crate::debian::checksums::FileChecksums;
|
|
use crate::debian::control::{Paragraph, write_paragraph};
|
|
use crate::debian::files::FilesList;
|
|
|
|
/// Compression suffixes dpkg recognizes on source tarballs.
|
|
const TARBALL_COMPRESSIONS: &[&str] = &[".gz", ".bz2", ".xz", ".lzma", ".zst"];
|
|
|
|
/// Whether this `.dsc`-listed file is an upstream orig tarball
|
|
/// (`*.orig.tar.<ext>` or a component tarball `*.orig-<c>.tar.<ext>`),
|
|
/// mirroring dpkg-genchanges' strip pattern `\.orig(-.+)?\.tar\.$ext`.
|
|
pub fn is_orig_tarball(name: &str) -> bool {
|
|
TARBALL_COMPRESSIONS.iter().any(|ext| {
|
|
name.strip_suffix(ext)
|
|
.and_then(|s| s.strip_suffix(".tar"))
|
|
.is_some_and(|stem| stem.ends_with(".orig") || stem.contains(".orig-"))
|
|
})
|
|
}
|
|
|
|
/// Whether this `.dsc`-listed file is the Debian part of the source package
|
|
/// (`*.debian.tar.<ext>` for the 3.0 formats, `*.diff.<ext>` for 1.0).
|
|
pub fn is_debian_tarball_or_diff(name: &str) -> bool {
|
|
TARBALL_COMPRESSIONS.iter().any(|ext| {
|
|
name.ends_with(&format!(".debian.tar{ext}")) || name.ends_with(&format!(".diff{ext}"))
|
|
})
|
|
}
|
|
|
|
/// Whether the upload redistributes the upstream tarballs, mirroring the
|
|
/// dpkg-genchanges source styles: `Always`/`Never` are the forced
|
|
/// `-sa`/`-sd`, while `Auto` is the default `-si` — include them only when
|
|
/// there is no previous changelog entry (first upload) or the source name or
|
|
/// upstream version changed since it. Like dpkg, the comparison uses the
|
|
/// epoch-less upstream version: a plain revision bump reuses the tarball
|
|
/// already in the archive.
|
|
pub fn include_orig_tarball(
|
|
mode: OrigSourceMode,
|
|
current: &ChangelogEntry,
|
|
previous: Option<&ChangelogEntry>,
|
|
) -> bool {
|
|
match mode {
|
|
OrigSourceMode::Always => true,
|
|
OrigSourceMode::Never => false,
|
|
OrigSourceMode::Auto => match previous {
|
|
None => true,
|
|
Some(prev) => {
|
|
prev.source != current.source || prev.version.upstream != current.version.upstream
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
/// 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>,
|
|
/// Whether the changelog entry is a binary-only (binNMU) upload
|
|
/// (`Binary-Only: yes` field).
|
|
pub binary_only: bool,
|
|
/// 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.binary_only {
|
|
p.set("Binary-Only", "yes");
|
|
}
|
|
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 orig_tarball_detection() {
|
|
assert!(is_orig_tarball("pkg_1.0.orig.tar.gz"));
|
|
assert!(is_orig_tarball("pkg_1.0.orig.tar.xz"));
|
|
assert!(is_orig_tarball("pkg_1.0.orig.tar.zst"));
|
|
assert!(is_orig_tarball("pkg_1.0~rc1.orig.tar.bz2"));
|
|
// Component tarballs.
|
|
assert!(is_orig_tarball("pkg_1.0.orig-docs.tar.xz"));
|
|
assert!(is_orig_tarball("pkg_1.0.orig-vendor.tar.gz"));
|
|
// Not orig tarballs.
|
|
assert!(!is_orig_tarball("pkg_1.0.debian.tar.xz"));
|
|
assert!(!is_debian_tarball_or_diff("pkg_1.0.orig.tar.xz"));
|
|
assert!(!is_orig_tarball("pkg_1.0.tar.xz")); // native tarball
|
|
assert!(!is_orig_tarball("pkg_1.0.dsc"));
|
|
assert!(!is_orig_tarball("pkg_1.0.orig.tar")); // no compression suffix
|
|
}
|
|
|
|
#[test]
|
|
fn debian_tarball_detection() {
|
|
assert!(is_debian_tarball_or_diff("pkg_1.0.debian.tar.xz"));
|
|
assert!(is_debian_tarball_or_diff("pkg_1.0.diff.gz"));
|
|
assert!(!is_debian_tarball_or_diff("pkg_1.0.orig.tar.xz"));
|
|
assert!(!is_debian_tarball_or_diff("pkg_1.0.tar.xz"));
|
|
}
|
|
|
|
/// Build a minimal changelog entry for one source/version pair.
|
|
fn entry(src: &str, ver: &str) -> ChangelogEntry {
|
|
crate::debian::changelog::parse_changelog_entries_from_str(
|
|
&format!(
|
|
"{src} ({ver}) unstable; urgency=medium\n\n * x\n\n \
|
|
-- A B <a@b.c> Thu, 01 Jan 2026 00:00:00 +0000\n"
|
|
),
|
|
Some(1),
|
|
)
|
|
.unwrap()
|
|
.remove(0)
|
|
}
|
|
|
|
#[test]
|
|
fn orig_inclusion_matrix() {
|
|
let cur = entry("pkg", "1.4-2");
|
|
let prev_same_upstream = entry("pkg", "1.4-1");
|
|
let prev_new_upstream = entry("pkg", "2.0-1");
|
|
let prev_renamed = entry("renamed", "1.4-1");
|
|
|
|
// -sa / -sd force the outcome.
|
|
assert!(include_orig_tarball(
|
|
OrigSourceMode::Always,
|
|
&cur,
|
|
Some(&prev_same_upstream)
|
|
));
|
|
assert!(!include_orig_tarball(
|
|
OrigSourceMode::Never,
|
|
&cur,
|
|
Some(&prev_new_upstream)
|
|
));
|
|
|
|
// -si: first upload includes; a revision bump excludes; a new
|
|
// upstream version or a renamed source includes.
|
|
assert!(include_orig_tarball(OrigSourceMode::Auto, &cur, None));
|
|
assert!(!include_orig_tarball(
|
|
OrigSourceMode::Auto,
|
|
&cur,
|
|
Some(&prev_same_upstream)
|
|
));
|
|
assert!(include_orig_tarball(
|
|
OrigSourceMode::Auto,
|
|
&cur,
|
|
Some(&prev_new_upstream)
|
|
));
|
|
assert!(include_orig_tarball(
|
|
OrigSourceMode::Auto,
|
|
&cur,
|
|
Some(&prev_renamed)
|
|
));
|
|
|
|
// The epoch is not part of the comparison, like dpkg's version().
|
|
let cur_epoch = entry("pkg", "2:1.4-2");
|
|
assert!(!include_orig_tarball(
|
|
OrigSourceMode::Auto,
|
|
&cur_epoch,
|
|
Some(&prev_same_upstream)
|
|
));
|
|
}
|
|
|
|
#[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![],
|
|
binary_only: false,
|
|
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(""))
|
|
);
|
|
}
|
|
}
|