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>
139 lines
7.3 KiB
Markdown
139 lines
7.3 KiB
Markdown
# 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<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):
|
|
|
|
```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 `<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.)
|
|
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.
|