//! Stage 3: verify extracted files against a committed hash table. //! //! The table records sizes and SHA-1 values only — never content. Its purpose //! is to let a test assert "this parse matches retail" rather than "this parse //! matches whatever happened to be on my disk". See `AGENTS.md`. use std::collections::BTreeMap; use std::path::Path; /// One expected file: path, size in bytes, SHA-1. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Expected { pub path: String, pub size: u64, pub sha1: String, } /// Outcome of verifying a directory. #[derive(Debug, Default)] pub struct VerifyReport { pub checked: usize, pub missing: Vec, pub size_mismatch: Vec<(String, u64, u64)>, pub hash_mismatch: Vec<(String, String, String)>, /// Files present in the directory but absent from the table. pub unexpected: Vec, } impl VerifyReport { pub fn ok(&self) -> bool { self.missing.is_empty() && self.size_mismatch.is_empty() && self.hash_mismatch.is_empty() && self.unexpected.is_empty() } /// A short human-readable summary. pub fn summary(&self) -> String { if self.ok() { return format!("OK: {} files match the recorded table.", self.checked); } let mut lines = vec![format!( "FAILED: {} files checked, {} problems", self.checked, self.missing.len() + self.size_mismatch.len() + self.hash_mismatch.len() + self.unexpected.len() )]; for p in self.missing.iter().take(10) { lines.push(format!(" missing: {p}")); } for (p, want, got) in self.size_mismatch.iter().take(10) { lines.push(format!(" size: {p}: expected {want}, got {got}")); } for (p, want, got) in self.hash_mismatch.iter().take(10) { lines.push(format!(" hash: {p}: expected {want}, got {got}")); } for p in self.unexpected.iter().take(10) { lines.push(format!(" unexpected: {p}")); } lines.join("\n") } } /// Parse a table from TSV lines of `sha1sizepath`. pub fn parse_table(text: &str) -> Vec { text.lines() .filter(|l| !l.trim().is_empty() && !l.starts_with('#')) .filter_map(|line| { let mut parts = line.split('\t'); let sha1 = parts.next()?.trim().to_string(); let size: u64 = parts.next()?.trim().parse().ok()?; let path = parts.next()?.trim().to_string(); if sha1.len() != 40 || path.is_empty() { return None; } Some(Expected { path, size, sha1 }) }) .collect() } /// Render entries as a TSV table, sorted by path. pub fn render_table(entries: &[(String, u64, String)]) -> String { let mut sorted: Vec<_> = entries.to_vec(); sorted.sort_by(|a, b| a.0.cmp(&b.0)); let mut out = String::new(); for (path, size, sha1) in sorted { out.push_str(&format!("{sha1}\t{size}\t{path}\n")); } out } /// Verify `dir` against `expected`. pub fn verify(dir: &Path, expected: &[Expected]) -> VerifyReport { let mut report = VerifyReport::default(); let present = walk(dir); for e in expected { report.checked += 1; match std::fs::read(dir.join(&e.path)) { Ok(data) => { if data.len() as u64 != e.size { report .size_mismatch .push((e.path.clone(), e.size, data.len() as u64)); continue; } let got = crate::identify::sha1_hex(&data); if got != e.sha1 { report .hash_mismatch .push((e.path.clone(), e.sha1.clone(), got)); } } Err(_) => report.missing.push(e.path.clone()), } } let expected_set: BTreeMap<&str, ()> = expected.iter().map(|e| (e.path.as_str(), ())).collect(); for p in present { if !expected_set.contains_key(p.as_str()) { report.unexpected.push(p); } } report } /// List files under `dir`, as `/`-separated relative paths, sorted. fn walk(dir: &Path) -> Vec { let mut out = Vec::new(); collect(dir, dir, &mut out); out.sort(); out } fn collect(root: &Path, dir: &Path, out: &mut Vec) { let Ok(entries) = std::fs::read_dir(dir) else { return; }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { collect(root, &path, out); } else if let Ok(rel) = path.strip_prefix(root) { out.push(rel.to_string_lossy().replace('\\', "/")); } } } #[cfg(test)] mod tests { use super::*; #[test] fn parses_a_table_and_skips_comments() { let text = concat!( "# a comment\n", "da39a3ee5e6b4b0d3255bfef95601890afd80709\t0\tempty.bin\n", "a9993e364706816aba3e25717850c26c9cd0d89d\t3\tabc.bin\n", "\n", ); let t = parse_table(text); assert_eq!(t.len(), 2); assert_eq!(t[0].path, "empty.bin"); assert_eq!(t[0].size, 0); assert_eq!(t[1].path, "abc.bin"); assert_eq!(t[1].size, 3); } #[test] fn ignores_malformed_rows() { let text = "notasha1\t3\tx.bin\n\ a9993e364706816aba3e25717850c26c9cd0d89d\tnope\tx.bin\n\ a9993e364706816aba3e25717850c26c9cd0d89d\t3\t"; assert!(parse_table(text).is_empty()); } #[test] fn round_trips_a_rendered_table() { let entries = vec![ ( "b.bin".to_string(), 2u64, "a9993e364706816aba3e25717850c26c9cd0d89d".to_string(), ), ( "a.bin".to_string(), 1u64, "da39a3ee5e6b4b0d3255bfef95601890afd80709".to_string(), ), ]; let text = render_table(&entries); assert!( text.lines().next().unwrap().starts_with("da39a3ee"), "sorted by path" ); assert_eq!(parse_table(&text).len(), 2); } #[test] fn verify_detects_a_missing_file() { let dir = std::env::temp_dir().join(format!("gc-verify-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let expected = vec![Expected { path: "nope.bin".to_string(), size: 3, sha1: "a9993e364706816aba3e25717850c26c9cd0d89d".to_string(), }]; let report = verify(&dir, &expected); assert!(!report.ok()); assert_eq!(report.missing, vec!["nope.bin".to_string()]); assert!(report.summary().contains("missing")); let _ = std::fs::remove_dir_all(&dir); } #[test] fn verify_detects_a_hash_mismatch() { let dir = std::env::temp_dir().join(format!("gc-verify-hash-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("a.bin"), b"xyz").unwrap(); let expected = vec![Expected { path: "a.bin".to_string(), size: 3, // SHA-1 of "abc", not "xyz". sha1: "a9993e364706816aba3e25717850c26c9cd0d89d".to_string(), }]; let report = verify(&dir, &expected); assert_eq!(report.hash_mismatch.len(), 1); assert!(report.summary().contains("hash")); let _ = std::fs::remove_dir_all(&dir); } #[test] fn verify_detects_an_unexpected_file() { let dir = std::env::temp_dir().join(format!("gc-verify-extra-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("extra.bin"), b"q").unwrap(); let report = verify(&dir, &[]); assert_eq!(report.unexpected, vec!["extra.bin".to_string()]); let _ = std::fs::remove_dir_all(&dir); } #[test] fn verify_passes_a_correct_directory() { let dir = std::env::temp_dir().join(format!("gc-verify-ok-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("a.bin"), b"abc").unwrap(); let expected = vec![Expected { path: "a.bin".to_string(), size: 3, sha1: "a9993e364706816aba3e25717850c26c9cd0d89d".to_string(), }]; let report = verify(&dir, &expected); assert!(report.ok(), "{}", report.summary()); let _ = std::fs::remove_dir_all(&dir); } }