# M3a task 09: the audit writer **Branch:** `m3a` (run `git switch m3a`; `git status --short` must be empty, otherwise stop) **Commit subject:** `Add the audit writer with its startup check` ## Goal `brokerd::audit::Writer` is the only thing that writes the audit log. It appends, never edits. `open` checks the log it finds and refuses, recovers or accepts a break; `append` adds one chained record and syncs it. The verifier from task 03 does all the judging. ## Files - Copy: `crates/brokerd/tests/audit.rs`, `crates/brokerd/tests/audit_startup.rs`, `crates/brokerd/tests/support/audit_dir.rs` - Create: `crates/brokerd/src/audit.rs` - Modify: `crates/brokerd/src/lib.rs` (add `pub mod audit;`), `docs/implementer-log.md` The tests read the fixture logs of task 03 from `crates/proto/tests/fixtures/audit/`. ## Interfaces ```rust pub const RECOVERED_NOTICE: &str = "audit: recovered a torn final line\nsee docs/runbook.md#audit-recovered"; #[derive(Debug)] pub enum AuditError { Locked, Broken(Box), // boxed: clippy's result_large_err rejects it unboxed NothingToAccept, Io { what: String, source: std::io::Error }, Stopped, } #[derive(Debug)] pub struct Writer { /* private */ } #[derive(Debug)] pub struct Opened { pub writer: Writer, pub recovered: bool, // a torn tail was recovered pub accepted: Option, // the failure --accept-break accepted } impl Writer { pub fn open(dir: &Path, accept_break: bool) -> Result; pub fn append(&mut self, time: Timestamp, event: AuditEvent) -> Result; pub fn next_seq(&self) -> u64; } /// Verifies every log file in `dir`, in name order, from `ChainVerifier::new()`. pub fn verify_dir(dir: &Path) -> std::io::Result; ``` `AuditError` implements `Display` and `Error` (`source()` for `Io`). `Display`, exactly: | Variant | Text | |---|---| | `Locked` | `brokerd is already running\nsee docs/runbook.md#brokerd-already-running` | | `Broken(f)` | `{f.file}:{f.line}: {f.what}\nsee docs/runbook.md#audit-chain-broken` | | `NothingToAccept` | `nothing to accept: the audit log verifies` | | `Io` | `audit: {what}: {source}\nsee docs/runbook.md#audit-unavailable` | | `Stopped` | `audit: an earlier write failed; restart brokerd\nsee docs/runbook.md#audit-unavailable` | Verified in the std docs of Rust 1.98.1 (`rust-version` is 1.95): ```rust // std::fs::File, stable since 1.89. pub enum TryLockError { Error(io::Error), WouldBlock } pub fn try_lock(&self) -> Result<(), TryLockError>; // released when the File is dropped pub fn sync_all(&self) -> io::Result<()>; // File::open(dir)?.sync_all() syncs a directory // std::os::unix::fs::{OpenOptionsExt, DirBuilderExt}: fn mode(&mut self, mode: u32) -> &mut Self ``` A log file is a name of the form `YYYY-MM-DD.jsonl`: ten characters, digits with `-` at positions 4 and 7, then `.jsonl`. Everything else in the directory (`.lock`, `notes.txt`, `x.jsonl.bak`) is ignored, in `open`, in `append` and in `verify_dir`. ## What `open` does, in order 1. Create `dir` and its parents if missing, mode 0700 (`DirBuilder`, `recursive(true)`). 2. Open `dir/.lock` (create, write, mode 0600, do not truncate) and `try_lock` it. `WouldBlock` → `Err(Locked)`. `Error(e)` → `Err(Io)`. Keep the `File` in the `Writer`: the lock lasts as long as the writer. 3. Verify. With `accept_break`: `verify_dir`. Without: **the short check.** If there are two or more log files, read the one before the latest, take its last line (the bytes after the last `\n`, once one trailing `\n` is removed), and if it parses as an `AuditRecord`, use `ChainVerifier::resume(its seq + 1, its hash)` and feed only the latest file. If there is one file, no file, or that last line does not parse, use `verify_dir`. 4. The report has a failure and `accept_break` is false → `Err(Broken)`. **Write nothing.** 5. The report has a failure and `accept_break` is true → append (rule 8) an `AcceptedBreak { file, line, last_good }` from the failure, with `seq = break_seq` and `prev = break_prev`; if `tail_torn`, write one `\n` first. `accepted` is the failure. 6. No failure and `accept_break` is true → `Err(NothingToAccept)`, even if there is a torn tail. **Write nothing.** 7. No failure, a torn tail → append (rule 8) a `Recovery { torn_bytes: bytes, torn_sha256: sha256 }` with `seq = recovery_seq` and `prev = recovery_prev`; if `has_newline` is false, write one `\n` first. `recovered` is true. 8. Records written by `open` use `Timestamp::now()` and **always go in the latest log file, whatever today's date is** (in today's file only if there is no log file at all). The `\n` and the line go out in the same single write as in `append`. 9. Otherwise the writer continues from the report: `next_seq`, and `prev` is `head`, or the resume hash if the latest file was empty, or zeros for an empty log. 10. Every I/O error on the way is `Err(Io)` with the path in `what`. A directory or file that cannot be read is an error, never an empty log. ## What `append` does 1. If an earlier write failed → `Err(Stopped)`. Write nothing. 2. The file is `.jsonl`. **Never go back:** if that name sorts before the latest log file's name, use the latest file. (A clock stepped back over midnight must not put a record in an older file: files are verified in name order.) 3. The record is `AuditRecord { seq: next_seq, time, prev, event }`, serialised by `serde_json`. Write the line and its `\n` with **one** `write_all`, then `sync_all`. A new file is created with mode 0600, and the directory is synced after the create. 4. On success `prev` becomes the hash of the line (without `\n`), `next_seq` goes up by one, and the record's `seq` is returned. 5. **On any error, from any step above, including the create and both syncs, set the stopped flag before returning `Err(Io)`.** Part of a line may be on disk; only the next start deals with that. The same holds for the writes `open` makes. ## Steps - [ ] **1. Copy.** `mkdir -p crates/brokerd/tests/support`, then `cp docs/plans/M3a/files/crates/brokerd/tests/audit.rs docs/plans/M3a/files/crates/brokerd/tests/audit_startup.rs crates/brokerd/tests/` and `cp docs/plans/M3a/files/crates/brokerd/tests/support/audit_dir.rs crates/brokerd/tests/support/` - [ ] **2. See the tests fail.** `cargo test -p brokerd --test audit`. Expected: does not compile. - [ ] **3. Write `audit.rs`.** Run `cargo fmt --all`. - [ ] **4. See the tests pass.** `cargo test -p brokerd --test audit --test audit_startup`, five times. Expected: `9 passed` and `7 passed` every time. - [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`. - [ ] **6. Log and commit.** `git add crates/brokerd docs/implementer-log.md && git commit` ## Done when - The two suites report 9 and 7 passed, five runs in a row; `make gate` prints `gate: ok`. ## Stop and report if - `a_failed_write_stops_the_writer` prints `skipped`: the tests are running as root. - `crates/brokerd/Cargo.toml` does not already have `serde_json.workspace = true` (task 04 adds it). Do not add a dependency in this task. - You need anything from `libc`, or `unsafe`, for the lock or the file modes. - A test seems to need `open` to truncate, rewrite or delete anything. Nothing ever does.