Task files, the files they copy in (byte-identical to the reference on m3a-ref), each area's check record, and a README with the per-task table of what each check exposed. The handoff note is done with. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7.3 KiB
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(addpub mod audit;),docs/implementer-log.md
The tests read the fixture logs of task 03 from crates/proto/tests/fixtures/audit/.
Interfaces
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<proto::ChainFailure>), // 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<proto::ChainFailure>, // the failure --accept-break accepted
}
impl Writer {
pub fn open(dir: &Path, accept_break: bool) -> Result<Opened, AuditError>;
pub fn append(&mut self, time: Timestamp, event: AuditEvent) -> Result<u64, AuditError>;
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<proto::ChainReport>;
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):
// 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
- Create
dirand its parents if missing, mode 0700 (DirBuilder,recursive(true)). - Open
dir/.lock(create, write, mode 0600, do not truncate) andtry_lockit.WouldBlock→Err(Locked).Error(e)→Err(Io). Keep theFilein theWriter: the lock lasts as long as the writer. - 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\nis removed), and if it parses as anAuditRecord, useChainVerifier::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, useverify_dir. - The report has a failure and
accept_breakis false →Err(Broken). Write nothing. - The report has a failure and
accept_breakis true → append (rule 8) anAcceptedBreak { file, line, last_good }from the failure, withseq = break_seqandprev = break_prev; iftail_torn, write one\nfirst.acceptedis the failure. - No failure and
accept_breakis true →Err(NothingToAccept), even if there is a torn tail. Write nothing. - No failure, a torn tail → append (rule 8) a
Recovery { torn_bytes: bytes, torn_sha256: sha256 }withseq = recovery_seqandprev = recovery_prev; ifhas_newlineis false, write one\nfirst.recoveredis true. - Records written by
openuseTimestamp::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\nand the line go out in the same single write as inappend. - Otherwise the writer continues from the report:
next_seq, andprevishead, or the resume hash if the latest file was empty, or zeros for an empty log. - Every I/O error on the way is
Err(Io)with the path inwhat. A directory or file that cannot be read is an error, never an empty log.
What append does
- If an earlier write failed →
Err(Stopped). Write nothing. - The file is
<first ten characters of time.to_rfc3339()>.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.) - The record is
AuditRecord { seq: next_seq, time, prev, event }, serialised byserde_json. Write the line and its\nwith onewrite_all, thensync_all. A new file is created with mode 0600, and the directory is synced after the create. - On success
prevbecomes the hash of the line (without\n),next_seqgoes up by one, and the record'sseqis returned. - 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 writesopenmakes.
Steps
- 1. Copy.
mkdir -p crates/brokerd/tests/support, thencp docs/plans/M3a/files/crates/brokerd/tests/audit.rs docs/plans/M3a/files/crates/brokerd/tests/audit_startup.rs crates/brokerd/tests/andcp 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. Runcargo fmt --all. - 4. See the tests pass.
cargo test -p brokerd --test audit --test audit_startup, five times. Expected:9 passedand7 passedevery 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 gateprintsgate: ok.
Stop and report if
a_failed_write_stops_the_writerprintsskipped: the tests are running as root.crates/brokerd/Cargo.tomldoes not already haveserde_json.workspace = true(task 04 adds it). Do not add a dependency in this task.- You need anything from
libc, orunsafe, for the lock or the file modes. - A test seems to need
opento truncate, rewrite or delete anything. Nothing ever does.