//! File checksum computation and formatting for `.changes` / `.buildinfo` //! fields (MD5, SHA-1, SHA-256 + size), mirroring `Dpkg::Checksums`. use std::collections::HashMap; use std::io::Read; use std::path::Path; use md5::Md5; use sha1::Sha1; use sha2::{Digest, Sha256}; /// Checksums and size of a single file. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Entry { /// File size in bytes. pub size: u64, /// Lowercase hexadecimal MD5 digest. pub md5: String, /// Lowercase hexadecimal SHA-1 digest. pub sha1: String, /// Lowercase hexadecimal SHA-256 digest. pub sha256: String, } /// Compute all supported checksums of a file. fn compute(path: &Path) -> Result> { let mut file = std::fs::File::open(path) .map_err(|e| format!("cannot open '{}' for checksumming: {}", path.display(), e))?; let mut md5_hasher = Md5::new(); let mut sha1_hasher = Sha1::new(); let mut sha256_hasher = Sha256::new(); let mut size: u64 = 0; let mut buf = [0u8; 64 * 1024]; loop { let n = file.read(&mut buf)?; if n == 0 { break; } md5_hasher.update(&buf[..n]); sha1_hasher.update(&buf[..n]); sha256_hasher.update(&buf[..n]); size += n as u64; } Ok(Entry { size, md5: hex::encode(md5_hasher.finalize()), sha1: hex::encode(sha1_hasher.finalize()), sha256: hex::encode(sha256_hasher.finalize()), }) } /// A registry of checksummed files, keyed by the name they are distributed /// under (which may differ from the on-disk path). /// /// Insertion order is preserved, matching the order in which /// `dpkg-genchanges` accumulates artifacts (dsc, tarballs, debs, buildinfo). #[derive(Debug, Clone, Default)] pub struct FileChecksums { entries: Vec<(String, Entry)>, index: HashMap, } impl FileChecksums { /// Create an empty registry. pub fn new() -> Self { Self::default() } /// Add a file, registering it under its own file name. pub fn add_file(&mut self, path: &Path) -> Result<(), Box> { let key = path .file_name() .and_then(|n| n.to_str()) .ok_or_else(|| format!("invalid file name: {}", path.display()))? .to_string(); self.add_file_as(path, &key) } /// Add a file, registering it under an explicit distribution key. pub fn add_file_as( &mut self, path: &Path, key: &str, ) -> Result<(), Box> { let entry = compute(path)?; self.insert_entry(key, entry); Ok(()) } /// Insert a pre-computed entry (e.g. taken from a `.dsc` checksum field). /// Re-inserting an existing key updates it in place, keeping its position. pub fn insert_entry(&mut self, key: &str, entry: Entry) { if let Some(&pos) = self.index.get(key) { self.entries[pos].1 = entry; return; } self.index.insert(key.to_string(), self.entries.len()); self.entries.push((key.to_string(), entry)); } /// Remove a file from the registry. Returns true if it was present. pub fn remove(&mut self, key: &str) -> bool { match self.index.remove(key) { Some(pos) => { self.entries.remove(pos); // Reindex the shifted tail. for (i, (k, _)) in self.entries.iter().enumerate().skip(pos) { self.index.insert(k.clone(), i); } true } None => false, } } /// Look up the entry for a given key. pub fn get(&self, key: &str) -> Option<&Entry> { self.index.get(key).map(|&pos| &self.entries[pos].1) } /// Iterate over `(key, entry)` pairs in insertion order. pub fn iter(&self) -> impl Iterator { self.entries.iter().map(|(k, e)| (k, e)) } /// Number of registered files. pub fn len(&self) -> usize { self.entries.len() } /// True if no file is registered. pub fn is_empty(&self) -> bool { self.entries.is_empty() } /// Format a `Checksums-*` style field value: one `\n`-separated line per /// file of the form `" "`. fn format_field(&self, hash_of: F) -> String where F: Fn(&Entry) -> &str, { let mut out = String::new(); for (key, e) in self.iter() { out.push('\n'); out.push_str(hash_of(e)); out.push(' '); out.push_str(&e.size.to_string()); out.push(' '); out.push_str(key); } out } /// Value for the `Checksums-Md5` field (empty string if no file). pub fn field_md5(&self) -> String { self.format_field(|e| &e.md5) } /// Value for the `Checksums-Sha1` field (empty string if no file). pub fn field_sha1(&self) -> String { self.format_field(|e| &e.sha1) } /// Value for the `Checksums-Sha256` field (empty string if no file). pub fn field_sha256(&self) -> String { self.format_field(|e| &e.sha256) } } #[cfg(test)] mod tests { use super::*; #[test] fn known_digests() { let dir = tempfile::tempdir().unwrap(); let p = dir.path().join("sample.txt"); std::fs::write(&p, b"hello world\n").unwrap(); let mut cs = FileChecksums::new(); cs.add_file(&p).unwrap(); let e = cs.get("sample.txt").unwrap(); // Verified with coreutils: echo "hello world" | md5sum / sha1sum / sha256sum assert_eq!(e.md5, "6f5902ac237024bdd0c176cb93063dc4"); assert_eq!(e.sha1, "22596363b3de40b06f981fb85d82312e8c0ed511"); assert_eq!( e.sha256, "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447" ); assert_eq!(e.size, 12); } #[test] fn insertion_order_preserved() { 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(); // Insert b first: insertion order (not alphabetical) must be kept, // matching dpkg's artifact accumulation order. cs.add_file(&b).unwrap(); cs.add_file(&a).unwrap(); let keys: Vec<&str> = cs.iter().map(|(k, _)| k.as_str()).collect(); assert_eq!(keys, vec!["b.txt", "a.txt"]); assert_eq!( cs.field_md5(), "\n21ad0bd836b90d08f4cf640b4c298e7c 2 b.txt\n47bce5c74f589f4867dbd57e9ca9f808 3 a.txt" ); } #[test] fn reinsert_updates_in_place() { 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(); std::fs::write(&a, b"bbbb").unwrap(); cs.add_file(&a).unwrap(); // updated in place, same position assert_eq!(cs.len(), 1); assert_eq!(cs.get("a.txt").unwrap().size, 4); } #[test] fn remove_keeps_order() { let dir = tempfile::tempdir().unwrap(); let a = dir.path().join("a.txt"); let b = dir.path().join("b.txt"); let c = dir.path().join("c.txt"); std::fs::write(&a, b"1").unwrap(); std::fs::write(&b, b"2").unwrap(); std::fs::write(&c, b"3").unwrap(); let mut cs = FileChecksums::new(); cs.add_file(&a).unwrap(); cs.add_file(&b).unwrap(); cs.add_file(&c).unwrap(); assert!(cs.remove("b.txt")); assert!(!cs.remove("b.txt")); let keys: Vec<&str> = cs.iter().map(|(k, _)| k.as_str()).collect(); assert_eq!(keys, vec!["a.txt", "c.txt"]); } }