checksums: add SHA-512 and a Checksums-* field parser

The checksum model only carried md5/sha1/sha256 while deb-buildinfo(5)
defines Checksums-Sha512, and there was no way to parse a Checksums-*
field body back into entries. Add ChecksumKind::Sha512 (computed
alongside the others), a field parser validating the
'<hex> <size> <name>' grammar, and an only-if-populated
Checksums-Sha512 emission in .buildinfo — deliberately dormant in the
dpkg-parity flows, which never emit it, and .dsc/.changes untouched.
This commit is contained in:
2026-09-17 19:44:54 +02:00
parent d6bad9fbbe
commit 93296c26c1
5 changed files with 364 additions and 4 deletions
+12
View File
@@ -110,6 +110,9 @@ pub fn generate_binary_metadata(
let entry_hashes = hashes let entry_hashes = hashes
.remove(name) .remove(name)
.ok_or_else(|| format!("artifact '{name}' listed in debian/files but not found"))?; .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( checksums.insert_entry(
name, name,
ChecksumEntry { ChecksumEntry {
@@ -117,6 +120,7 @@ pub fn generate_binary_metadata(
md5: entry_hashes.md5, md5: entry_hashes.md5,
sha1: entry_hashes.sha1, sha1: entry_hashes.sha1,
sha256: entry_hashes.sha256, sha256: entry_hashes.sha256,
sha512: String::new(),
}, },
); );
// Architecture accumulation in encounter order (dpkg-genchanges). // Architecture accumulation in encounter order (dpkg-genchanges).
@@ -285,6 +289,9 @@ pub fn generate_binary_metadata(
md5: h.md5.clone(), md5: h.md5.clone(),
sha1: h.sha1.clone(), sha1: h.sha1.clone(),
sha256: h.sha256.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(), md5: h.md5.clone(),
sha1: h.sha1.clone(), sha1: h.sha1.clone(),
sha256: h.sha256.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(), md5: p.md5.clone().unwrap_or_default(),
sha1: p.sha1.clone().unwrap_or_default(), sha1: p.sha1.clone().unwrap_or_default(),
sha256: p.sha256.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(),
}, },
); );
} }
+80
View File
@@ -296,6 +296,12 @@ pub fn render_buildinfo(input: &BuildInfoInput) -> Paragraph {
p.set("Checksums-Md5", &input.checksums.field_md5()); p.set("Checksums-Md5", &input.checksums.field_md5());
p.set("Checksums-Sha1", &input.checksums.field_sha1()); p.set("Checksums-Sha1", &input.checksums.field_sha1());
p.set("Checksums-Sha256", &input.checksums.field_sha256()); 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-Origin", &input.build_origin);
p.set("Build-Architecture", &input.build_architecture); p.set("Build-Architecture", &input.build_architecture);
@@ -494,4 +500,78 @@ Architecture: amd64
); );
assert_eq!(p.get("Format"), Some("1.0")); 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);
}
} }
+4
View File
@@ -544,6 +544,10 @@ pub fn run_source_build(
md5: partial.md5.clone().unwrap_or_default(), md5: partial.md5.clone().unwrap_or_default(),
sha1: partial.sha1.clone().unwrap_or_default(), sha1: partial.sha1.clone().unwrap_or_default(),
sha256: partial.sha256.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); tarball_paths.push(path);
+267 -3
View File
@@ -1,5 +1,5 @@
//! File checksum computation and formatting for `.changes` / `.buildinfo` //! 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::collections::HashMap;
use std::io::Read; use std::io::Read;
@@ -7,7 +7,7 @@ use std::path::Path;
use md5::Md5; use md5::Md5;
use sha1::Sha1; use sha1::Sha1;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256, Sha512};
/// Checksums and size of a single file. /// Checksums and size of a single file.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -20,6 +20,40 @@ pub struct Entry {
pub sha1: String, pub sha1: String,
/// Lowercase hexadecimal SHA-256 digest. /// Lowercase hexadecimal SHA-256 digest.
pub sha256: String, 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. /// Compute all supported checksums of a file.
@@ -30,6 +64,7 @@ fn compute(path: &Path) -> Result<Entry, Box<dyn std::error::Error>> {
let mut md5_hasher = Md5::new(); let mut md5_hasher = Md5::new();
let mut sha1_hasher = Sha1::new(); let mut sha1_hasher = Sha1::new();
let mut sha256_hasher = Sha256::new(); let mut sha256_hasher = Sha256::new();
let mut sha512_hasher = Sha512::new();
let mut size: u64 = 0; let mut size: u64 = 0;
let mut buf = [0u8; 64 * 1024]; let mut buf = [0u8; 64 * 1024];
@@ -41,6 +76,7 @@ fn compute(path: &Path) -> Result<Entry, Box<dyn std::error::Error>> {
md5_hasher.update(&buf[..n]); md5_hasher.update(&buf[..n]);
sha1_hasher.update(&buf[..n]); sha1_hasher.update(&buf[..n]);
sha256_hasher.update(&buf[..n]); sha256_hasher.update(&buf[..n]);
sha512_hasher.update(&buf[..n]);
size += n as u64; size += n as u64;
} }
@@ -49,6 +85,7 @@ fn compute(path: &Path) -> Result<Entry, Box<dyn std::error::Error>> {
md5: hex::encode(md5_hasher.finalize()), md5: hex::encode(md5_hasher.finalize()),
sha1: hex::encode(sha1_hasher.finalize()), sha1: hex::encode(sha1_hasher.finalize()),
sha256: hex::encode(sha256_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 { pub fn field_sha256(&self) -> String {
self.format_field(|e| &e.sha256) 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<String> {
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 `"<hex digest> <size> <name>"`; 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<Vec<(String, Entry)>, 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)] #[cfg(test)]
@@ -184,16 +299,165 @@ mod tests {
cs.add_file(&p).unwrap(); cs.add_file(&p).unwrap();
let e = cs.get("sample.txt").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.md5, "6f5902ac237024bdd0c176cb93063dc4");
assert_eq!(e.sha1, "22596363b3de40b06f981fb85d82312e8c0ed511"); assert_eq!(e.sha1, "22596363b3de40b06f981fb85d82312e8c0ed511");
assert_eq!( assert_eq!(
e.sha256, e.sha256,
"a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447" "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447"
); );
assert_eq!(
e.sha512,
"db3974a97f2407b7cae1ae637c0030687a11913274d578492558e39c16c017de\
84eacdc8c62fe34ee4e12b4b1428817f09b6a2760c3f8a664ceae94d2434a593"
);
assert_eq!(e.size, 12); 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] #[test]
fn insertion_order_preserved() { fn insertion_order_preserved() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
+1 -1
View File
@@ -23,7 +23,7 @@ pub use changelog::{
ChangelogEntry, parse_changelog_entry, parse_changelog_entry_from_str, ChangelogEntry, parse_changelog_entry, parse_changelog_entry_from_str,
parse_previous_version_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::{ pub use control::{
ControlInfo, Paragraph, parse_paragraphs, strip_clearsigned_armour, write_paragraph, ControlInfo, Paragraph, parse_paragraphs, strip_clearsigned_armour, write_paragraph,
}; };