//! 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, /// Active build profiles (`Built-For-Profiles`); omitted when empty. pub built_for_profiles: Vec, /// `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, /// `Changed-By` from the changelog maintainer. 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). 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> { 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 ".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, }; 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 8 utils optional pkg_1.0.dsc" .replace("", files_value.split_whitespace().next().unwrap_or("")) ); } }