Implemented proto::ChainVerifier as a pure line-holding state machine: each line is judged only once the next has arrived, so a Recovery record can mark the line before it not-a-record. Adds ChainFailure, TornTail, ChainReport, Location and ChainVerifier, wired through lib.rs and the matching re-export in audit.rs. 13 chain tests pass; make gate prints gate: ok. Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
390 lines
14 KiB
Rust
390 lines
14 KiB
Rust
//! 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<u8>)> {
|
|
let dir = dir(case);
|
|
let mut names: Vec<String> = 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<u8> {
|
|
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::<AuditRecord>(&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));
|
|
}
|