diff --git a/src/build/binary.rs b/src/build/binary.rs index 52e5edb..064b8e2 100644 --- a/src/build/binary.rs +++ b/src/build/binary.rs @@ -110,6 +110,9 @@ pub fn generate_binary_metadata( let entry_hashes = hashes .remove(name) .ok_or_else(|| format!("artifact '{name}' listed in debian/files but not found"))?; + // SHA-512 stays unknown here: like dpkg-genbuildinfo, no SHA-512 + // digest is computed for the artifacts, and an empty digest keeps + // the `Checksums-Sha512` field of the `.buildinfo` omitted. checksums.insert_entry( name, ChecksumEntry { @@ -117,6 +120,7 @@ pub fn generate_binary_metadata( md5: entry_hashes.md5, sha1: entry_hashes.sha1, sha256: entry_hashes.sha256, + sha512: String::new(), }, ); // Architecture accumulation in encounter order (dpkg-genchanges). @@ -285,6 +289,9 @@ pub fn generate_binary_metadata( md5: h.md5.clone(), sha1: h.sha1.clone(), sha256: h.sha256.clone(), + // No SHA-512 digest available (see above); keeps the + // `Checksums-Sha512` `.buildinfo` field omitted. + sha512: String::new(), }, ); } @@ -457,6 +464,8 @@ fn include_dsc_artifacts( md5: h.md5.clone(), sha1: h.sha1.clone(), sha256: h.sha256.clone(), + // No SHA-512 digest available (see above). + sha512: String::new(), }, ); } @@ -474,6 +483,9 @@ fn include_dsc_artifacts( md5: p.md5.clone().unwrap_or_default(), sha1: p.sha1.clone().unwrap_or_default(), sha256: p.sha256.clone().unwrap_or_default(), + // The `.dsc` records no SHA-512 (dpkg only writes + // sha1/sha256 there). + sha512: String::new(), }, ); } diff --git a/src/build/buildinfo.rs b/src/build/buildinfo.rs index 52aca96..f9c201c 100644 --- a/src/build/buildinfo.rs +++ b/src/build/buildinfo.rs @@ -296,6 +296,12 @@ pub fn render_buildinfo(input: &BuildInfoInput) -> Paragraph { p.set("Checksums-Md5", &input.checksums.field_md5()); p.set("Checksums-Sha1", &input.checksums.field_sha1()); p.set("Checksums-Sha256", &input.checksums.field_sha256()); + // Only-if-populated: entries merged from a `.dsc` carry no SHA-512 + // (dpkg only records sha1/sha256 there), and an incomplete checksum + // list must never be rendered. + if let Some(sha512) = input.checksums.field_sha512() { + p.set("Checksums-Sha512", &sha512); + } } p.set("Build-Origin", &input.build_origin); p.set("Build-Architecture", &input.build_architecture); @@ -494,4 +500,78 @@ Architecture: amd64 ); assert_eq!(p.get("Format"), Some("1.0")); } + + /// `Checksums-Sha512` is emitted (after `Checksums-Sha256`) only when + /// every distributed file has a SHA-512 digest; entries merged without + /// one (e.g. taken from a `.dsc`) omit the field entirely instead of + /// rendering an incomplete checksum list. + #[test] + fn checksums_sha512_emitted_only_when_populated() { + let dir = tempfile::tempdir().unwrap(); + let artifact = dir.path().join("hello_1.0_all.deb"); + std::fs::write(&artifact, b"deb payload").unwrap(); + + let mut checksums = FileChecksums::new(); + checksums.add_file(&artifact).unwrap(); + + let mk_input = |checksums: FileChecksums| BuildInfoInput { + source: "hello".to_string(), + binaries: vec![], + architecture: "all".to_string(), + version: "1.0".to_string(), + binary_only_changes: None, + build_origin: "debian".to_string(), + build_architecture: "amd64".to_string(), + build_date: "Sat, 22 Aug 2026 23:08:42 +0200".to_string(), + checksums, + installed_build_depends: String::new(), + environment: String::new(), + }; + + // All digests computed: the field is present and parses back. + let p = render_buildinfo(&mk_input(checksums.clone())); + let keys: Vec<&str> = p.iter().map(|(k, _)| k).collect(); + assert_eq!( + keys, + vec![ + "Format", + "Source", + "Architecture", + "Version", + "Checksums-Md5", + "Checksums-Sha1", + "Checksums-Sha256", + "Checksums-Sha512", + "Build-Origin", + "Build-Architecture", + "Build-Date", + ] + ); + let sha512_field = p.get("Checksums-Sha512").unwrap(); + let parsed = + FileChecksums::parse_field(crate::debian::ChecksumKind::Sha512, sha512_field).unwrap(); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].0, "hello_1.0_all.deb"); + assert_eq!( + parsed[0].1.sha512, + checksums.get("hello_1.0_all.deb").unwrap().sha512 + ); + + // An entry without SHA-512 (as merged from a `.dsc`) suppresses the + // field; the other Checksums fields keep listing every file. + checksums.insert_entry( + "hello_1.0.orig.tar.xz", + crate::debian::ChecksumEntry { + size: 3, + md5: checksums.get("hello_1.0_all.deb").unwrap().md5.clone(), + sha1: String::new(), + sha256: String::new(), + sha512: String::new(), + }, + ); + let p = render_buildinfo(&mk_input(checksums)); + assert!(p.get("Checksums-Sha512").is_none()); + let sha256_lines = p.get("Checksums-Sha256").unwrap().lines(); + assert_eq!(sha256_lines.filter(|l| !l.is_empty()).count(), 2); + } } diff --git a/src/build/mod.rs b/src/build/mod.rs index dd09ace..5c6aba4 100644 --- a/src/build/mod.rs +++ b/src/build/mod.rs @@ -544,6 +544,10 @@ pub fn run_source_build( md5: partial.md5.clone().unwrap_or_default(), sha1: partial.sha1.clone().unwrap_or_default(), sha256: partial.sha256.clone().unwrap_or_default(), + // The .dsc records no SHA-512 (dpkg only writes sha1/sha256 + // there); the empty digest keeps the .buildinfo's + // `Checksums-Sha512` field omitted. + sha512: String::new(), }, ); tarball_paths.push(path); diff --git a/src/debian/checksums.rs b/src/debian/checksums.rs index 7ce9dc1..be041cb 100644 --- a/src/debian/checksums.rs +++ b/src/debian/checksums.rs @@ -1,5 +1,5 @@ //! File checksum computation and formatting for `.changes` / `.buildinfo` -//! fields (MD5, SHA-1, SHA-256 + size), mirroring `Dpkg::Checksums`. +//! fields (MD5, SHA-1, SHA-256, SHA-512 + size), mirroring `Dpkg::Checksums`. use std::collections::HashMap; use std::io::Read; @@ -7,7 +7,7 @@ use std::path::Path; use md5::Md5; use sha1::Sha1; -use sha2::{Digest, Sha256}; +use sha2::{Digest, Sha256, Sha512}; /// Checksums and size of a single file. #[derive(Debug, Clone, PartialEq, Eq)] @@ -20,6 +20,40 @@ pub struct Entry { pub sha1: String, /// Lowercase hexadecimal SHA-256 digest. pub sha256: String, + /// Lowercase hexadecimal SHA-512 digest. + pub sha512: String, +} + +/// The checksum algorithm carried by a `Checksums-*` field body, as handled +/// by [`FileChecksums::parse_field`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChecksumKind { + /// SHA-1 (`Checksums-Sha1` field). + Sha1, + /// SHA-256 (`Checksums-Sha256` field). + Sha256, + /// SHA-512 (`Checksums-Sha512` field). + Sha512, +} + +impl ChecksumKind { + /// The `Checksums-*` field name carrying this digest. + pub fn field_name(self) -> &'static str { + match self { + ChecksumKind::Sha1 => "Checksums-Sha1", + ChecksumKind::Sha256 => "Checksums-Sha256", + ChecksumKind::Sha512 => "Checksums-Sha512", + } + } + + /// Length in lowercase hex characters of one digest of this kind. + fn digest_len(self) -> usize { + match self { + ChecksumKind::Sha1 => 40, + ChecksumKind::Sha256 => 64, + ChecksumKind::Sha512 => 128, + } + } } /// Compute all supported checksums of a file. @@ -30,6 +64,7 @@ fn compute(path: &Path) -> Result> { let mut md5_hasher = Md5::new(); let mut sha1_hasher = Sha1::new(); let mut sha256_hasher = Sha256::new(); + let mut sha512_hasher = Sha512::new(); let mut size: u64 = 0; let mut buf = [0u8; 64 * 1024]; @@ -41,6 +76,7 @@ fn compute(path: &Path) -> Result> { md5_hasher.update(&buf[..n]); sha1_hasher.update(&buf[..n]); sha256_hasher.update(&buf[..n]); + sha512_hasher.update(&buf[..n]); size += n as u64; } @@ -49,6 +85,7 @@ fn compute(path: &Path) -> Result> { md5: hex::encode(md5_hasher.finalize()), sha1: hex::encode(sha1_hasher.finalize()), sha256: hex::encode(sha256_hasher.finalize()), + sha512: hex::encode(sha512_hasher.finalize()), }) } @@ -168,6 +205,84 @@ impl FileChecksums { pub fn field_sha256(&self) -> String { self.format_field(|e| &e.sha256) } + + /// Value for the `Checksums-Sha512` field, or `None` when any registered + /// file has no SHA-512 digest (e.g. entries merged from a `.dsc`, which + /// dpkg only writes with sha1/sha256 checksums): renderers omit the + /// field instead of writing an incomplete checksum list. + pub fn field_sha512(&self) -> Option { + if self.iter().any(|(_, e)| e.sha512.is_empty()) { + return None; + } + Some(self.format_field(|e| &e.sha512)) + } + + /// Parse the body of a `Checksums-Sha1` / `Checksums-Sha256` / + /// `Checksums-Sha512` field (as rendered by [`FileChecksums::field_sha1`], + /// [`FileChecksums::field_sha256`] or [`FileChecksums::field_sha512`]) + /// into `(name, entry)` pairs, ready to be fed into + /// [`FileChecksums::insert_entry`] (e.g. when consuming a `.dsc`). + /// + /// Each non-blank line holds `" "`; blank lines + /// are tolerated and anything else is a malformed line, reported as an + /// error naming [`ChecksumKind::field_name`] and the offending line. Only + /// the digest selected by `kind` is filled in the returned entries: the + /// other digest fields are left empty and must be completed from the + /// remaining `Checksums-*` fields (or by recomputation) before rendering. + /// + /// Note: this deliberately re-implements the line grammar of the private + /// `build::parse_checksum_field` helper (which additionally accepts the + /// legacy 5-column `Files` layout); the two are intentionally not unified + /// across modules. + pub fn parse_field(kind: ChecksumKind, value: &str) -> Result, String> { + let mut entries = Vec::new(); + for line in value.lines() { + if line.trim().is_empty() { + continue; + } + let tokens: Vec<&str> = line.split_whitespace().collect(); + let [digest, size, name] = tokens.as_slice() else { + return Err(format!( + "malformed '{}' line (expected 'checksum size name', got {} \ + columns): '{line}'", + kind.field_name(), + tokens.len() + )); + }; + let size: u64 = size.parse().map_err(|_| { + format!( + "malformed '{}' line (size '{size}' is not a number): '{line}'", + kind.field_name() + ) + })?; + let digest_ok = digest.len() == kind.digest_len() + && digest + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)); + if !digest_ok { + return Err(format!( + "malformed '{}' line (digest '{digest}' is not {} lowercase \ + hex characters): '{line}'", + kind.field_name(), + kind.digest_len() + )); + } + let mut entry = Entry { + size, + md5: String::new(), + sha1: String::new(), + sha256: String::new(), + sha512: String::new(), + }; + match kind { + ChecksumKind::Sha1 => entry.sha1 = digest.to_string(), + ChecksumKind::Sha256 => entry.sha256 = digest.to_string(), + ChecksumKind::Sha512 => entry.sha512 = digest.to_string(), + } + entries.push((name.to_string(), entry)); + } + Ok(entries) + } } #[cfg(test)] @@ -184,16 +299,165 @@ mod tests { cs.add_file(&p).unwrap(); let e = cs.get("sample.txt").unwrap(); - // Verified with coreutils: echo "hello world" | md5sum / sha1sum / sha256sum + // Verified with coreutils: echo "hello world" | md5sum / sha1sum / sha256sum / sha512sum assert_eq!(e.md5, "6f5902ac237024bdd0c176cb93063dc4"); assert_eq!(e.sha1, "22596363b3de40b06f981fb85d82312e8c0ed511"); assert_eq!( e.sha256, "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447" ); + assert_eq!( + e.sha512, + "db3974a97f2407b7cae1ae637c0030687a11913274d578492558e39c16c017de\ + 84eacdc8c62fe34ee4e12b4b1428817f09b6a2760c3f8a664ceae94d2434a593" + ); assert_eq!(e.size, 12); } + /// SHA-512 of the empty input is a well-known constant: a zero-size file + /// must still carry it (never an empty digest string, which is reserved + /// for "digest unknown", e.g. entries merged from a `.dsc`). + #[test] + fn sha512_of_empty_file_is_the_known_constant() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("empty.txt"); + std::fs::write(&p, b"").unwrap(); + + let mut cs = FileChecksums::new(); + cs.add_file(&p).unwrap(); + + let e = cs.get("empty.txt").unwrap(); + assert_eq!(e.size, 0); + assert_eq!( + e.sha512, + "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce\ + 47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e" + ); + } + + /// All four digests render; the `Checksums-Sha512` field round-trips + /// through [`FileChecksums::parse_field`] back into a registry with + /// identical names (insertion order), sizes and SHA-512 digests. + #[test] + fn sha512_field_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let a = dir.path().join("a.txt"); + let b = dir.path().join("b.txt"); + std::fs::write(&a, b"aaa").unwrap(); + std::fs::write(&b, b"bb").unwrap(); + + let mut cs = FileChecksums::new(); + cs.add_file(&b).unwrap(); + cs.add_file(&a).unwrap(); + + // Every digest kind must be populated and render a full field. + assert!(!cs.field_md5().is_empty()); + assert!(!cs.field_sha1().is_empty()); + assert!(!cs.field_sha256().is_empty()); + let sha512_field = cs.field_sha512().expect("all entries have sha512"); + + // Round-trip the Checksums-Sha512 field through the parser. + let mut reparsed = FileChecksums::new(); + for (key, entry) in FileChecksums::parse_field(ChecksumKind::Sha512, &sha512_field).unwrap() + { + reparsed.insert_entry(&key, entry); + } + let keys: Vec<&str> = reparsed.iter().map(|(k, _)| k.as_str()).collect(); + assert_eq!(keys, vec!["b.txt", "a.txt"], "insertion order preserved"); + for (key, e) in cs.iter() { + let got = reparsed.get(key).unwrap(); + assert_eq!(got.size, e.size, "{key}"); + assert_eq!(got.sha512, e.sha512, "{key}"); + } + // Rendering the re-parsed registry yields the same field value. + assert_eq!(reparsed.field_sha512().unwrap(), sha512_field); + + // The parser dispatches on `kind`: a Checksums-Sha256 body fills the + // sha256 column, leaving the others (including sha512) unknown. + let (key, entry) = + &FileChecksums::parse_field(ChecksumKind::Sha256, &cs.field_sha256()).unwrap()[0]; + assert_eq!(entry.sha256, cs.get(key).unwrap().sha256); + assert!(entry.sha512.is_empty()); + let (_, entry) = + &FileChecksums::parse_field(ChecksumKind::Sha512, &sha512_field).unwrap()[0]; + assert!(entry.md5.is_empty() && entry.sha1.is_empty() && entry.sha256.is_empty()); + assert!(!entry.sha512.is_empty()); + } + + /// Malformed `Checksums-Sha512` bodies (wrong column count, non-numeric + /// size, wrong digest shape) must be rejected with an error naming the + /// field and the offending line; blank lines are tolerated. + #[test] + fn parse_field_rejects_malformed_lines() { + // 128 lowercase hex characters, as rendered by field_sha512. + let digest = "ab".repeat(64); + + // Blank lines are skipped. + let entries = + FileChecksums::parse_field(ChecksumKind::Sha512, &format!("\n {digest} 12 a.txt\n\n")) + .unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].0, "a.txt"); + assert_eq!(entries[0].1.size, 12); + + // 2 columns: missing the name. + let err = + FileChecksums::parse_field(ChecksumKind::Sha512, &format!("{digest} 12")).unwrap_err(); + assert!(err.contains("Checksums-Sha512"), "{err}"); + assert!(err.contains(&format!("{digest} 12")), "{err}"); + + // 4 columns. + let err = + FileChecksums::parse_field(ChecksumKind::Sha512, &format!(" {digest} 12 bogus a.txt")) + .unwrap_err(); + assert!(err.contains("Checksums-Sha512"), "{err}"); + assert!(err.contains("a.txt"), "{err}"); + + // Non-numeric size. + let err = + FileChecksums::parse_field(ChecksumKind::Sha512, &format!(" {digest} twelve a.txt")) + .unwrap_err(); + assert!(err.contains("not a number"), "{err}"); + assert!(err.contains("twelve"), "{err}"); + + // Digest that is not 128 lowercase hex characters. + let err = FileChecksums::parse_field(ChecksumKind::Sha512, " abc123 12 a.txt").unwrap_err(); + assert!(err.contains("lowercase hex"), "{err}"); + } + + /// `field_sha512` is only-if-populated: an entry merged without a SHA-512 + /// digest (e.g. taken from a `.dsc`) suppresses the whole field instead + /// of rendering an incomplete checksum list. + #[test] + fn field_sha512_omitted_when_any_digest_missing() { + let dir = tempfile::tempdir().unwrap(); + let a = dir.path().join("a.txt"); + std::fs::write(&a, b"aaa").unwrap(); + + let mut cs = FileChecksums::new(); + cs.add_file(&a).unwrap(); + assert!(cs.field_sha512().is_some()); + + cs.insert_entry( + "from.dsc", + Entry { + size: 12, + md5: "d41d8cd98f00b204e9800998ecf8427e".to_string(), + sha1: "da39a3ee5e6b4b0d3255bfef95601890afd80709".to_string(), + sha256: format!("e3b0{:0>62}", "0"), + sha512: String::new(), // not recorded in .dsc files + }, + ); + assert!( + cs.field_sha512().is_none(), + "one incomplete entry must suppress Checksums-Sha512" + ); + // The other kinds are unaffected. + assert!(!cs.field_md5().is_empty()); + assert!(!cs.field_sha1().is_empty()); + assert!(!cs.field_sha256().is_empty()); + } + #[test] fn insertion_order_preserved() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/debian/mod.rs b/src/debian/mod.rs index 3048f7d..6da6e49 100644 --- a/src/debian/mod.rs +++ b/src/debian/mod.rs @@ -23,7 +23,7 @@ pub use changelog::{ ChangelogEntry, parse_changelog_entry, parse_changelog_entry_from_str, parse_previous_version_from_str, }; -pub use checksums::{Entry as ChecksumEntry, FileChecksums}; +pub use checksums::{ChecksumKind, Entry as ChecksumEntry, FileChecksums}; pub use control::{ ControlInfo, Paragraph, parse_paragraphs, strip_clearsigned_armour, write_paragraph, };