Add bxctl audit verify
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -36,9 +36,19 @@ fn main() -> ExitCode {
|
||||
reason,
|
||||
} => exit(|| cmd_refuse(&admin_socket, approval, reason.as_deref())),
|
||||
Command::GrantsCheck { admin_socket } => exit(|| cmd_grants_check(&admin_socket)),
|
||||
Command::AuditVerify { home: _ } => {
|
||||
eprintln!("bxctl: audit verify is not implemented yet");
|
||||
ExitCode::from(1)
|
||||
Command::AuditVerify { home } => {
|
||||
let mut out = std::io::stdout().lock();
|
||||
match bxctl::verify::run(&home, &mut out) {
|
||||
Ok(true) => ExitCode::SUCCESS,
|
||||
Ok(false) => ExitCode::from(1),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"bxctl: cannot read the audit log under {}: {e}",
|
||||
home.display()
|
||||
);
|
||||
ExitCode::from(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,90 @@
|
||||
//! Placeholder for the audit-log verifier (task 19).
|
||||
//! Verifies the whole audit log under a home directory with `proto::ChainVerifier`, reading the
|
||||
//! files itself so it works when `brokerd` will not start.
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use proto::ChainVerifier;
|
||||
|
||||
/// Verifies the whole audit log under `home` and prints the report to `out`.
|
||||
/// `Ok(true)`: the chain verifies. `Ok(false)`: it does not. `Err`: the log cannot be read.
|
||||
pub fn run(home: &Path, out: &mut dyn Write) -> std::io::Result<bool> {
|
||||
let audit_dir = home.join("audit");
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
for entry in std::fs::read_dir(&audit_dir)? {
|
||||
let entry = entry?;
|
||||
let name = match entry.file_name().into_string() {
|
||||
Ok(name) => name,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if is_audit_log(&name) {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
names.sort();
|
||||
|
||||
let mut verifier = ChainVerifier::new();
|
||||
for name in &names {
|
||||
let bytes = std::fs::read(audit_dir.join(name))?;
|
||||
verifier.feed(name, &bytes);
|
||||
}
|
||||
let report = verifier.finish();
|
||||
|
||||
if let Some(failure) = &report.failure {
|
||||
writeln!(out, "{}:{}: {}", failure.file, failure.line, failure.what)?;
|
||||
writeln!(out, "see docs/runbook.md#audit-chain-broken")?;
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let head = match report.head {
|
||||
Some(hash) => format!("head {}", hash.to_hex()),
|
||||
None => "head none".to_string(),
|
||||
};
|
||||
writeln!(out, "audit: ok, {} records, {}", report.records, head)?;
|
||||
for loc in &report.recoveries {
|
||||
writeln!(out, "recovered line: {}:{}", loc.file, loc.line)?;
|
||||
}
|
||||
for loc in &report.accepted_breaks {
|
||||
writeln!(out, "accepted break: {}:{}", loc.file, loc.line)?;
|
||||
}
|
||||
for seq in &report.abandoned {
|
||||
writeln!(out, "pending or abandoned: approval {}", seq)?;
|
||||
}
|
||||
for seq in &report.unfinished {
|
||||
writeln!(out, "running or unfinished: decision {}", seq)?;
|
||||
}
|
||||
for loc in &report.clock_warnings {
|
||||
writeln!(out, "clock went backwards: {}:{}", loc.file, loc.line)?;
|
||||
}
|
||||
if let Some(torn) = &report.torn_tail {
|
||||
writeln!(
|
||||
out,
|
||||
"torn final line: {}:{} (brokerd recovers it at its next start)",
|
||||
torn.at.file, torn.at.line
|
||||
)?;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// True if `name` is a `YYYY-MM-DD.jsonl` audit log: ten characters, digits with `-` at positions
|
||||
/// 4 and 7, then `.jsonl`.
|
||||
fn is_audit_log(name: &str) -> bool {
|
||||
let date = match name.strip_suffix(".jsonl") {
|
||||
Some(date) => date,
|
||||
None => return false,
|
||||
};
|
||||
if date.len() != 10 {
|
||||
return false;
|
||||
}
|
||||
let bytes = date.as_bytes();
|
||||
for (i, &b) in bytes.iter().enumerate() {
|
||||
if i == 4 || i == 7 {
|
||||
if b != b'-' {
|
||||
return false;
|
||||
}
|
||||
} else if !b.is_ascii_digit() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//! `bxctl audit verify` against the fixture logs in `crates/proto/tests/fixtures/audit/`.
|
||||
//! Do not edit. The output is compared byte for byte: the owner reads it, and so do scripts.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
static NEXT: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
/// A home directory whose `audit/` is a copy of the fixture log `case`. Removed when dropped.
|
||||
struct Home {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl Home {
|
||||
fn with_case(case: &str) -> Home {
|
||||
let n = NEXT.fetch_add(1, Ordering::SeqCst);
|
||||
let name = format!("bxctl-verify-{}-{n}", std::process::id());
|
||||
let path = std::env::temp_dir().join(name);
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
let audit = path.join("audit");
|
||||
std::fs::create_dir_all(&audit).unwrap();
|
||||
let from = format!(
|
||||
"{}/../proto/tests/fixtures/audit/{case}",
|
||||
env!("CARGO_MANIFEST_DIR")
|
||||
);
|
||||
for entry in std::fs::read_dir(&from).unwrap_or_else(|e| panic!("{from}: {e}")) {
|
||||
let entry = entry.unwrap();
|
||||
std::fs::copy(entry.path(), audit.join(entry.file_name())).unwrap();
|
||||
}
|
||||
Home { path }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Home {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
fn run(case: &str) -> (bool, String) {
|
||||
let home = Home::with_case(case);
|
||||
// What brokerd leaves beside the log must not be read as part of it.
|
||||
std::fs::write(home.path.join("audit/.lock"), "").unwrap();
|
||||
let mut out = Vec::new();
|
||||
let ok = bxctl::verify::run(&home.path, &mut out).unwrap();
|
||||
(ok, String::from_utf8(out).unwrap())
|
||||
}
|
||||
|
||||
/// The hex of the hash of the last line of `file` in `case`.
|
||||
fn head_of(case: &str, file: &str) -> String {
|
||||
let path = format!(
|
||||
"{}/../proto/tests/fixtures/audit/{case}/{file}",
|
||||
env!("CARGO_MANIFEST_DIR")
|
||||
);
|
||||
let text = std::fs::read_to_string(path).unwrap();
|
||||
proto::sha256(text.lines().last().unwrap().as_bytes())
|
||||
.unwrap()
|
||||
.to_hex()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_good_log() {
|
||||
let (ok, out) = run("good");
|
||||
assert!(ok);
|
||||
let head = head_of("good", "2026-09-18.jsonl");
|
||||
assert_eq!(
|
||||
out,
|
||||
format!(
|
||||
"audit: ok, 10 records, head {head}\n\
|
||||
pending or abandoned: approval 6\n\
|
||||
running or unfinished: decision 7\n"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everything_worth_knowing_is_listed_one_per_line() {
|
||||
let (ok, out) = run("recovered-next-day");
|
||||
assert!(ok);
|
||||
let lines: Vec<&str> = out.lines().collect();
|
||||
assert!(
|
||||
lines[0].starts_with("audit: ok, 11 records, head "),
|
||||
"{out}"
|
||||
);
|
||||
assert_eq!(
|
||||
lines[1..],
|
||||
[
|
||||
"recovered line: 2026-09-17.jsonl:6",
|
||||
"pending or abandoned: approval 7",
|
||||
"running or unfinished: decision 8",
|
||||
]
|
||||
);
|
||||
|
||||
let (ok, out) = run("accepted-break-older-file");
|
||||
assert!(ok);
|
||||
assert!(
|
||||
out.contains("\naccepted break: 2026-09-18.jsonl:6\n"),
|
||||
"{out}"
|
||||
);
|
||||
|
||||
let (ok, out) = run("clock-back");
|
||||
assert!(ok);
|
||||
assert!(
|
||||
out.ends_with("\nclock went backwards: 2026-09-18.jsonl:6\n"),
|
||||
"{out}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A torn final line is what a crash, or a `brokerd` in the middle of a write, leaves. It is
|
||||
/// reported and is not a failure.
|
||||
#[test]
|
||||
fn a_torn_tail_is_reported_and_is_ok() {
|
||||
let (ok, out) = run("torn-tail");
|
||||
assert!(ok);
|
||||
assert!(out.starts_with("audit: ok, 10 records, head "), "{out}");
|
||||
assert!(
|
||||
out.ends_with(
|
||||
"\ntorn final line: 2026-09-18.jsonl:6 (brokerd recovers it at its next start)\n"
|
||||
),
|
||||
"{out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_chain_is_two_lines_and_false() {
|
||||
let cases = [
|
||||
(
|
||||
"changed-byte",
|
||||
"2026-09-17.jsonl:4: prev is not the hash of the line before",
|
||||
),
|
||||
("deleted-line", "2026-09-17.jsonl:3: seq is 3, expected 2"),
|
||||
(
|
||||
"cut-short",
|
||||
"2026-09-17.jsonl:3: does not parse as an audit record",
|
||||
),
|
||||
(
|
||||
"file-not-chained",
|
||||
"2026-09-18.jsonl:1: does not chain from the last line of the file before",
|
||||
),
|
||||
(
|
||||
"break-wrong-line",
|
||||
"2026-09-17.jsonl:4: prev is not the hash of the line before",
|
||||
),
|
||||
];
|
||||
for (case, first) in cases {
|
||||
let (ok, out) = run(case);
|
||||
assert!(!ok, "{case}");
|
||||
assert_eq!(
|
||||
out,
|
||||
format!("{first}\nsee docs/runbook.md#audit-chain-broken\n"),
|
||||
"{case}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_audit_directory_is_an_empty_log() {
|
||||
let home = Home::with_case("good");
|
||||
for entry in std::fs::read_dir(home.path.join("audit")).unwrap() {
|
||||
std::fs::remove_file(entry.unwrap().path()).unwrap();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
assert!(bxctl::verify::run(&home.path, &mut out).unwrap());
|
||||
assert_eq!(
|
||||
String::from_utf8(out).unwrap(),
|
||||
"audit: ok, 0 records, head none\n"
|
||||
);
|
||||
}
|
||||
|
||||
/// A home with no audit directory is a mistake in `--home`, not a clean log.
|
||||
#[test]
|
||||
fn a_missing_audit_directory_is_an_error() {
|
||||
let home = Home::with_case("good");
|
||||
std::fs::remove_dir_all(home.path.join("audit")).unwrap();
|
||||
let mut out = Vec::new();
|
||||
let error = bxctl::verify::run(&home.path, &mut out).unwrap_err();
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
Reference in New Issue
Block a user