//! The audit chain verifier against the fixture logs in `tests/fixtures/audit/`. Do not edit //! this file or the fixtures: their hashes are real, and one changed byte changes the verdict. //! //! Every fixture is a small audit directory. `good` is an undamaged two-day log; the others are //! `good` with one thing done to it, named by the directory. use proto::{AuditRecord, ChainReport, ChainVerifier, Hash32, Location, sha256}; const D1: &str = "2026-09-17.jsonl"; const D2: &str = "2026-09-18.jsonl"; fn dir(case: &str) -> String { format!("{}/tests/fixtures/audit/{case}", env!("CARGO_MANIFEST_DIR")) } /// The `.jsonl` files of a case, in name order, with their bytes. fn files(case: &str) -> Vec<(String, Vec)> { let dir = dir(case); let mut names: Vec = std::fs::read_dir(&dir) .unwrap_or_else(|e| panic!("{dir}: {e}")) .map(|entry| entry.unwrap().file_name().into_string().unwrap()) .filter(|name| name.ends_with(".jsonl")) .collect(); names.sort(); assert!(!names.is_empty(), "{dir}: no files"); names .into_iter() .map(|name| { let bytes = std::fs::read(format!("{dir}/{name}")).unwrap(); (name, bytes) }) .collect() } fn verify(case: &str) -> ChainReport { let mut verifier = ChainVerifier::new(); for (name, bytes) in files(case) { verifier.feed(&name, &bytes); } verifier.finish() } /// Line `line` (1-based) of a file of a case, without its newline. fn line_of(case: &str, file: &str, line: usize) -> Vec { let (_, bytes) = files(case) .into_iter() .find(|(name, _)| name == file) .unwrap(); bytes.split(|b| *b == b'\n').nth(line - 1).unwrap().to_vec() } fn hash_of(case: &str, file: &str, line: usize) -> Hash32 { sha256(&line_of(case, file, line)).unwrap() } fn at(file: &str, line: u64) -> Location { Location { file: file.to_string(), line, } } #[test] fn good_log_verifies() { let report = verify("good"); assert_eq!(report.failure, None); assert_eq!(report.records, 10); assert_eq!(report.next_seq, 10); assert_eq!(report.head, Some(hash_of("good", D2, 5))); assert_eq!( report.abandoned, vec![6], "the ask at seq 6 has no approval" ); assert_eq!( report.unfinished, vec![7], "the allowed call at seq 7 has no result" ); assert!(report.recoveries.is_empty()); assert!(report.accepted_breaks.is_empty()); assert!(report.clock_warnings.is_empty()); assert_eq!(report.torn_tail, None); } /// The tampering suite: each case fails, at this file and line, with this text. #[test] fn tampering_is_found_at_the_right_line() { let parse = "does not parse as an audit record"; let cases = [ // The changed line still parses and chains; the line after it no longer chains from it. ( "changed-byte", D1, 4, "prev is not the hash of the line before", ), ("deleted-line", D1, 3, "seq is 3, expected 2"), ("swapped-lines", D1, 2, "seq is 2, expected 1"), ("seq-gap", D1, 3, "seq is 3, expected 2"), ( "file-not-chained", D2, 1, "does not chain from the last line of the file before", ), ("cut-short", D1, 3, parse), ( "break-wrong-line", D1, 4, "prev is not the hash of the line before", ), ( "break-wrong-last-good", D1, 4, "prev is not the hash of the line before", ), ( "break-wrong-prev", D1, 4, "prev is not the hash of the line before", ), ( "break-wrong-seq", D1, 4, "prev is not the hash of the line before", ), ( "break-without-failure", D2, 6, "an accepted break with no failure before it", ), ("recovery-wrong-hash", D2, 6, parse), ("recovery-wrong-length", D2, 6, parse), ( "recovery-describes-nothing", D2, 6, "a recovery record that does not describe the line before it", ), ("torn-recovery", D2, 6, parse), ]; for (case, file, line, what) in cases { let failure = verify(case) .failure .unwrap_or_else(|| panic!("{case}: verified, but it is damaged")); assert_eq!( (failure.file.as_str(), failure.line, failure.what.as_str()), (file, line, what), "{case}" ); } } #[test] fn a_failure_says_what_a_break_record_must_carry() { let failure = verify("changed-byte").failure.unwrap(); assert_eq!(failure.last_good, hash_of("changed-byte", D1, 3)); assert_eq!(failure.break_prev, hash_of("changed-byte", D2, 5)); // The failing line should have had seq 3; seven lines run from it to the end of the log. assert_eq!(failure.break_seq, 10); assert!(!failure.tail_torn); // A failure at the very first line: nothing verified, so last_good is all zeros. let mut verifier = ChainVerifier::new(); verifier.file(D1); verifier.line(b"not json", true); verifier.line(b"nor this", true); let failure = verifier.finish().failure.unwrap(); assert_eq!( (failure.line, failure.last_good, failure.break_seq), (1, Hash32::ZERO, 2) ); assert_eq!(failure.break_prev, sha256(b"nor this").unwrap()); let failure = verify("torn-recovery").failure.unwrap(); assert!(failure.tail_torn, "the last line has no newline"); assert_eq!( failure.break_seq, 12, "seq 10 for line 6, and two lines to the end" ); } #[test] fn verification_stops_counting_at_a_failure() { let report = verify("changed-byte"); assert_eq!(report.records, 3); assert_eq!(report.head, Some(hash_of("changed-byte", D1, 3))); assert_eq!(report.next_seq, 3); assert_eq!(report.torn_tail, None); } #[test] fn a_torn_tail_is_not_a_failure() { // (case, file, line, has_newline, records, recovery_seq, file and line of the record before) let cases = [ ("torn-tail", D2, 6, false, 10, 10, (D2, 5)), // Complete JSON that lacks only its newline is torn all the same. ("torn-tail-complete-json", D2, 6, false, 10, 10, (D2, 5)), // A crash between ending a torn line and writing its Recovery. ("torn-unparseable-newline", D2, 6, true, 10, 10, (D2, 5)), ("torn-first-line", D2, 1, false, 5, 5, (D1, 5)), ]; for (case, file, line, has_newline, records, seq, before) in cases { let report = verify(case); assert_eq!(report.failure, None, "{case}"); assert_eq!(report.records, records, "{case}"); let torn = report .torn_tail .unwrap_or_else(|| panic!("{case}: no torn tail")); let bytes = line_of(case, file, line as usize); assert_eq!(torn.at, at(file, line), "{case}"); assert_eq!(torn.has_newline, has_newline, "{case}"); assert_eq!(torn.bytes, bytes.len() as u64, "{case}"); assert_eq!(torn.sha256, sha256(&bytes).unwrap(), "{case}"); assert_eq!(torn.recovery_seq, seq, "{case}"); assert_eq!( torn.recovery_prev, hash_of(case, before.0, before.1), "{case}" ); assert_eq!( report.next_seq, seq, "{case}: the torn line is not a record" ); } let whole = line_of("torn-tail-complete-json", D2, 6); assert!( serde_json::from_slice::(&whole).is_ok(), "this case must be a line that parses" ); } #[test] fn an_empty_latest_file_is_fine() { let report = verify("empty-latest"); assert_eq!((report.failure, report.torn_tail), (None, None)); assert_eq!((report.records, report.next_seq), (5, 5)); } #[test] fn a_recovered_line_is_not_a_record_and_not_a_failure() { // (case, where the recovered line is, records, abandoned, unfinished) let cases = [ ("recovered", at(D2, 6), 12, vec![6], vec![]), // The recovered line is complete JSON with seq 10; the Recovery takes seq 10 again. ("recovered-complete-json", at(D2, 6), 12, vec![6], vec![]), // Torn on one day, recovered on the next: the Recovery is in the torn line's file. ("recovered-next-day", at(D1, 6), 11, vec![7], vec![8]), ("recovered-first-line", at(D2, 1), 6, vec![], vec![]), ]; for (case, recovered, records, abandoned, unfinished) in cases { let report = verify(case); assert_eq!(report.failure, None, "{case}"); assert_eq!(report.torn_tail, None, "{case}"); assert_eq!(report.recoveries, vec![recovered], "{case}"); assert_eq!(report.records, records, "{case}"); assert_eq!(report.abandoned, abandoned, "{case}"); assert_eq!(report.unfinished, unfinished, "{case}"); assert!(report.clock_warnings.is_empty(), "{case}"); } } #[test] fn a_clock_stepped_back_is_a_warning() { let report = verify("clock-back"); assert_eq!(report.failure, None); assert_eq!(report.records, 11); assert_eq!(report.clock_warnings, vec![at(D2, 6)]); } #[test] fn an_accepted_break_clears_the_failure_before_it() { // (case, where the break record is, records, next_seq) let cases = [ ("accepted-break", at(D1, 6), 5, 7), ("accepted-break-older-file", at(D2, 6), 5, 12), // A deleted line in day 2 as well: one break covers every failure before it. ("accepted-break-two-failures", at(D2, 5), 4, 10), // A line in the region claims seq 18446744073709551615. The break's seq is counted // from lines, so it is 10 all the same. ("accepted-break-max-seq", at(D2, 6), 4, 11), ]; for (case, break_at, records, next_seq) in cases { let report = verify(case); assert_eq!(report.failure, None, "{case}"); assert_eq!(report.accepted_breaks, vec![break_at], "{case}"); assert_eq!(report.records, records, "{case}"); assert_eq!(report.next_seq, next_seq, "{case}"); } // Records inside the region are not vouched for: the approval of seq 2 is in it. let report = verify("accepted-break"); assert_eq!(report.abandoned, vec![2]); assert_eq!(report.unfinished, vec![6]); } /// A verifier that starts at the latest file cannot judge a break that names an older one. It /// checks the break's `prev` and goes on; the full verification judges the rest. #[test] fn a_resumed_verifier_accepts_a_break_naming_an_earlier_file() { let case = "accepted-break-older-file"; let last: AuditRecord = serde_json::from_slice(&line_of(case, D1, 5)).unwrap(); let mut verifier = ChainVerifier::resume(last.seq + 1, hash_of(case, D1, 5)); let (_, day2) = files(case).into_iter().nth(1).unwrap(); verifier.feed(D2, &day2); let report = verifier.finish(); assert_eq!(report.failure, None); assert_eq!(report.accepted_breaks, vec![at(D2, 6)]); assert_eq!((report.records, report.next_seq), (7, 12)); // The same break with a wrong prev is not accepted, resumed or not. let mut verifier = ChainVerifier::resume(last.seq + 1, hash_of(case, D1, 5)); let (_, day2) = files("break-wrong-prev").into_iter().nth(1).unwrap(); verifier.feed(D2, &day2); assert!(verifier.finish().failure.is_some()); } #[test] fn resume_continues_from_the_file_before() { let last: AuditRecord = serde_json::from_slice(&line_of("good", D1, 5)).unwrap(); let (_, day2) = files("good").into_iter().nth(1).unwrap(); let mut verifier = ChainVerifier::resume(last.seq + 1, hash_of("good", D1, 5)); verifier.feed(D2, &day2); let report = verifier.finish(); assert_eq!(report.failure, None); assert_eq!((report.records, report.next_seq), (5, 10)); // Resumed from the wrong hash, the first line of the file does not chain. let mut verifier = ChainVerifier::resume(last.seq + 1, Hash32::ZERO); verifier.feed(D2, &day2); let failure = verifier.finish().failure.unwrap(); assert_eq!((failure.file.as_str(), failure.line), (D2, 1)); assert_eq!( failure.what, "does not chain from the last line of the file before" ); assert_eq!( failure.last_good, Hash32::ZERO, "the hash it was resumed with" ); } /// `feed` is `file` and then `line` for each line; both ways must give the same report. #[test] fn feed_is_file_then_lines() { for case in [ "good", "torn-tail", "recovered", "changed-byte", "empty-latest", ] { let mut verifier = ChainVerifier::new(); for (name, bytes) in files(case) { verifier.file(&name); let mut rest: &[u8] = &bytes; while !rest.is_empty() { match rest.iter().position(|b| *b == b'\n') { Some(end) => { verifier.line(&rest[..end], true); rest = &rest[end + 1..]; } None => { verifier.line(rest, false); rest = &[]; } } } } assert_eq!(verifier.finish(), verify(case), "{case}"); } } #[test] fn an_empty_log_is_fine() { let report = ChainVerifier::new().finish(); assert_eq!( (report.failure, report.torn_tail, report.head), (None, None, None) ); assert_eq!((report.records, report.next_seq), (0, 0)); }