diff --git a/crates/bxctl/src/main.rs b/crates/bxctl/src/main.rs index ef7bb1a..17441ac 100644 --- a/crates/bxctl/src/main.rs +++ b/crates/bxctl/src/main.rs @@ -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) + } + } } } } diff --git a/crates/bxctl/src/verify.rs b/crates/bxctl/src/verify.rs index 76b0fa6..4fbb329 100644 --- a/crates/bxctl/src/verify.rs +++ b/crates/bxctl/src/verify.rs @@ -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 { + let audit_dir = home.join("audit"); + let mut names: Vec = 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 +} diff --git a/crates/bxctl/tests/verify.rs b/crates/bxctl/tests/verify.rs new file mode 100644 index 0000000..b89e141 --- /dev/null +++ b/crates/bxctl/tests/verify.rs @@ -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()); +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index a6cd608..7996cdb 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -7,6 +7,7 @@ reviewer adds findings under "Reviews" once per milestone. | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | |---|---|---|---|---|---|---|---| | M3a/19-bxctl-audit-verify | 2026-09-21 | stopped | 0 | n/a | none | The task's "The command" section says to grep `crates/bxctl/src/main.rs` for `cannot read the audit log` and stop if it is absent. `grep` returned no match: `main.rs`'s `Command::AuditVerify { home: _ }` arm (line 39) is still the placeholder that prints `bxctl: audit verify is not implemented yet`, ignores `home`, never calls `bxctl::verify::run`, and never prints the error message. The task forbids editing `main.rs` or `lib.rs`, so `bxctl audit verify` cannot be wired to `run` without that edit, and step 5 (which relies on the wiring) cannot pass. Stopped per the task's explicit instruction rather than editing a forbidden file. | ? | +| M3a/19-bxctl-audit-verify | 2026-09-21 | done | 1 | pass | Edited crates/bxctl/src/main.rs to wire `audit verify` to `bxctl::verify::run` (the placeholder arm at main.rs:39 was never wired by task 18; the owner authorized this as a documented deviation). The task's step 5 shorthand `run(&home)` omits the required `out` writer, which carries the report to stdout. | Wrote crates/bxctl/src/verify.rs: `run` lists `/audit/`, keeps only `YYYY-MM-DD.jsonl` names (date dashes at 0-indexed positions 4 and 7, so the real fixture dates match), sorts them, feeds each to `proto::ChainVerifier`, and prints the report exactly (the two-line failure form, or the ok form in the task's list order); `.lock` and malformed names are ignored. A missing dir is an error, an existing empty dir is an empty log, and every io error propagates with `?`. 6 verify tests pass; `make gate` prints `gate: ok`. | ? | | M3a/13-brokerd-broker | 2026-09-20 | done | 3 | fail | none | Wrote crates/brokerd/src/broker.rs (313 lines): Broker { cfg, ledger, table, runtime, log, printed: Mutex>> } with grants() printing each distinct set of problems once (recovering a poisoned lock with into_inner); kind (all fourteen snake_case wire names, no wildcard arm); send (write_frame(..).is_ok()); read_request (Closed->None, other errors send an error frame id 0 final true with BadVersion/BadMessage/BadFrame and the error text); forbid (log the runbook line, send Forbidden); alive (10ms read timeout, WouldBlock/TimedOut->true); handle (decide, then Denied/Allowed->run/Ask->pending, one final frame carrying the request id); run (Call::of, runner::run, ledger.finish); pending (expires = earlier of now+ttl or the grant's own expiry, insert the table entry, send one PendingApproval final:false, then wait on recv_timeout with the gone/taken lookups and AuditUnavailable). GONE is pub so the tests can name it. Every record goes through the ledger, nothing is written to it here. Three suites pass five runs in a row (9, 5, 2); the pending suite takes ~1s on its one-second look. First gate failed on fmt import order then clippy (three needless returns, one collapsible_if, one needless borrow on grants::render); fixed all and `make gate` prints `gate: ok`. | ? | | M3a/12-brokerd-ledger | 2026-09-20 | done | 2 | fail | none | Wrote crates/brokerd/src/ledger.rs (499 lines): Ledger + Inner { audit, state, stopped } behind one Mutex, and the three steps that hold it. decide copies the request out, reads state then policy::decide, and records the outcome (allowed/ask/denied, grant fields set together) as AuditEvent::Decision; answer re-decides an approval (approved only) and records AuditEvent::Approval with the answer/by/reason; finish raises the state for a Result and records AuditEvent::Result by its message otherwise, returning response unchanged only once the raised taint and the record are both on disk. Helpers not_recorded/audit_unavailable/denied; every append Err sets stopped through the one append method, and finish logs the raise error "brokerd: {e}" before stopping. Step 5 verified: each numbered exit points at a line and every append Err goes through the one stopped place. Trimmed 588 to 499 by compressing the module doc; one clippy fix (needless `return` in the answer append match, which is the tail expression). 11 + 9 tests pass; `make gate` prints `gate: ok`. | ? | | M3a/08-brokerd-state | 2026-09-18 | done | 1 | pass | none | Wrote crates/brokerd/src/state.rs: RUNBOOK, StateError (Unreadable/Write with hand-written Display ending in RUNBOOK and std::error::Error), StateStore (new does not touch disk, path joins /.json, read, raise) and the private StateFile with deny_unknown_fields. read has exactly one default path (ErrorKind::NotFound); Public taint is Unreadable; raise computes max(taint,label,Private) and ORs untrusted, always writes atomically in six steps mapping any error to Write(path, err). `cargo fmt` put `state` after `runner` in lib.rs. 9 tests pass; `make gate` prints `gate: ok`. | Laguna S 2.1 |