Add the audit writer with its startup check
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
//! The audit writer: the chain it writes, the lock, rollover, and stopping after a failed
|
||||
//! write. Do not edit. Startup checks, recovery and accepted breaks are in `audit_startup.rs`.
|
||||
|
||||
#[path = "support/audit_dir.rs"]
|
||||
mod audit_dir;
|
||||
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
use audit_dir::{D1, D2, TempDir, denied, lines, ts};
|
||||
use brokerd::audit::{AuditError, RECOVERED_NOTICE, Writer, verify_dir};
|
||||
use proto::{AuditRecord, Hash32, sha256};
|
||||
|
||||
fn mode(path: &std::path::Path) -> u32 {
|
||||
std::fs::metadata(path).unwrap().permissions().mode() & 0o777
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_record_starts_the_chain() {
|
||||
let dir = TempDir::unmade("first");
|
||||
let opened = Writer::open(&dir.path, false).unwrap();
|
||||
assert!(!opened.recovered);
|
||||
assert!(opened.accepted.is_none());
|
||||
let mut writer = opened.writer;
|
||||
assert_eq!(writer.next_seq(), 0);
|
||||
|
||||
let seq = writer
|
||||
.append(ts("2026-09-17T08:00:00.000Z"), denied(1))
|
||||
.unwrap();
|
||||
assert_eq!(seq, 0);
|
||||
assert_eq!(writer.next_seq(), 1);
|
||||
|
||||
let text = std::fs::read_to_string(dir.path.join(D1)).unwrap();
|
||||
assert!(text.ends_with('\n'), "a record is one line and its newline");
|
||||
assert_eq!(text.lines().count(), 1);
|
||||
let record: AuditRecord = serde_json::from_str(text.lines().next().unwrap()).unwrap();
|
||||
assert_eq!((record.seq, record.prev), (0, Hash32::ZERO));
|
||||
assert_eq!(record.time, ts("2026-09-17T08:00:00.000Z"));
|
||||
assert_eq!(record.event, denied(1));
|
||||
|
||||
assert_eq!(mode(&dir.path), 0o700, "the directory open() made");
|
||||
assert_eq!(mode(&dir.path.join(D1)), 0o600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_chain_runs_across_a_day_boundary() {
|
||||
let dir = TempDir::unmade("days");
|
||||
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
|
||||
let times = [
|
||||
"2026-09-17T23:59:58.000Z",
|
||||
"2026-09-17T23:59:59.999Z",
|
||||
"2026-09-18T00:00:00.000Z",
|
||||
"2026-09-18T00:00:01.000Z",
|
||||
];
|
||||
for (i, time) in times.iter().enumerate() {
|
||||
assert_eq!(writer.append(ts(time), denied(i as u64)).unwrap(), i as u64);
|
||||
}
|
||||
let (day1, day2) = (lines(&dir.path, D1), lines(&dir.path, D2));
|
||||
assert_eq!((day1.len(), day2.len()), (2, 2));
|
||||
|
||||
// seq goes on across files, and the new file chains from the last line of the old one.
|
||||
let first: AuditRecord = serde_json::from_str(&day2[0]).unwrap();
|
||||
assert_eq!(first.seq, 2);
|
||||
assert_eq!(first.prev, sha256(day1[1].as_bytes()).unwrap());
|
||||
|
||||
let report = verify_dir(&dir.path).unwrap();
|
||||
assert_eq!(report.failure, None);
|
||||
assert_eq!((report.records, report.next_seq), (4, 4));
|
||||
assert!(report.clock_warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopening_continues_the_chain() {
|
||||
let dir = TempDir::unmade("reopen");
|
||||
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
|
||||
writer
|
||||
.append(ts("2026-09-17T08:00:00.000Z"), denied(1))
|
||||
.unwrap();
|
||||
writer
|
||||
.append(ts("2026-09-17T08:00:01.000Z"), denied(2))
|
||||
.unwrap();
|
||||
drop(writer);
|
||||
|
||||
// One file: the whole of it is checked.
|
||||
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
|
||||
assert_eq!(writer.next_seq(), 2);
|
||||
assert_eq!(
|
||||
writer
|
||||
.append(ts("2026-09-18T08:00:00.000Z"), denied(3))
|
||||
.unwrap(),
|
||||
2
|
||||
);
|
||||
drop(writer);
|
||||
|
||||
// Two files: the latest is checked, resumed from the last line of the one before.
|
||||
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
|
||||
assert_eq!(writer.next_seq(), 3);
|
||||
assert_eq!(
|
||||
writer
|
||||
.append(ts("2026-09-18T08:00:01.000Z"), denied(4))
|
||||
.unwrap(),
|
||||
3
|
||||
);
|
||||
drop(writer);
|
||||
|
||||
let report = verify_dir(&dir.path).unwrap();
|
||||
assert_eq!(report.failure, None);
|
||||
assert_eq!(report.records, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_latest_file_gets_the_next_record_as_its_first_line() {
|
||||
let dir = TempDir::case("empty-latest", None);
|
||||
let opened = Writer::open(&dir.path, false).unwrap();
|
||||
assert!(!opened.recovered);
|
||||
let mut writer = opened.writer;
|
||||
assert_eq!(
|
||||
writer
|
||||
.append(ts("2026-09-18T08:00:00.000Z"), denied(9))
|
||||
.unwrap(),
|
||||
5
|
||||
);
|
||||
|
||||
let day2 = lines(&dir.path, D2);
|
||||
assert_eq!(day2.len(), 1);
|
||||
let record: AuditRecord = serde_json::from_str(&day2[0]).unwrap();
|
||||
assert_eq!(
|
||||
record.prev,
|
||||
sha256(lines(&dir.path, D1)[4].as_bytes()).unwrap()
|
||||
);
|
||||
assert_eq!(verify_dir(&dir.path).unwrap().failure, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_writer_is_refused() {
|
||||
let dir = TempDir::unmade("lock");
|
||||
let first = Writer::open(&dir.path, false).unwrap();
|
||||
let error = Writer::open(&dir.path, false).unwrap_err();
|
||||
assert!(matches!(error, AuditError::Locked), "{error}");
|
||||
let text = error.to_string();
|
||||
assert!(text.starts_with("brokerd is already running"), "{text}");
|
||||
assert!(
|
||||
text.ends_with("see docs/runbook.md#brokerd-already-running"),
|
||||
"{text}"
|
||||
);
|
||||
|
||||
// The lock goes when the writer goes, however that happens.
|
||||
drop(first);
|
||||
assert!(Writer::open(&dir.path, false).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_writer_never_goes_back_to_an_earlier_file() {
|
||||
let dir = TempDir::unmade("clock");
|
||||
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
|
||||
writer
|
||||
.append(ts("2026-09-18T00:00:05.000Z"), denied(1))
|
||||
.unwrap();
|
||||
// The clock is stepped back over midnight.
|
||||
writer
|
||||
.append(ts("2026-09-17T23:59:50.000Z"), denied(2))
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
!dir.path.join(D1).exists(),
|
||||
"a record went into an earlier file"
|
||||
);
|
||||
assert_eq!(lines(&dir.path, D2).len(), 2);
|
||||
let report = verify_dir(&dir.path).unwrap();
|
||||
assert_eq!(report.failure, None);
|
||||
assert_eq!(report.clock_warnings.len(), 1);
|
||||
|
||||
// It holds across a restart too.
|
||||
drop(writer);
|
||||
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
|
||||
writer
|
||||
.append(ts("2026-09-17T23:59:55.000Z"), denied(3))
|
||||
.unwrap();
|
||||
assert!(!dir.path.join(D1).exists());
|
||||
assert_eq!(verify_dir(&dir.path).unwrap().failure, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_that_are_not_log_files_are_ignored() {
|
||||
let dir = TempDir::case("good", None);
|
||||
std::fs::write(dir.path.join("notes.txt"), "not a log\n").unwrap();
|
||||
std::fs::write(dir.path.join("2026-09-19.jsonl.bak"), "not a log\n").unwrap();
|
||||
std::fs::write(dir.path.join("latest.jsonl"), "not a log\n").unwrap();
|
||||
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
|
||||
assert_eq!(writer.next_seq(), 10);
|
||||
writer
|
||||
.append(ts("2026-09-18T10:00:00.000Z"), denied(9))
|
||||
.unwrap();
|
||||
assert_eq!(verify_dir(&dir.path).unwrap().records, 11);
|
||||
}
|
||||
|
||||
/// After one failed write the writer writes nothing more, even when the cause has gone: part of
|
||||
/// a line may be on disk, and only the next start deals with that.
|
||||
#[test]
|
||||
fn a_failed_write_stops_the_writer() {
|
||||
let dir = TempDir::unmade("sticky");
|
||||
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
|
||||
writer
|
||||
.append(ts("2026-09-17T08:00:00.000Z"), denied(1))
|
||||
.unwrap();
|
||||
|
||||
// A new day needs a new file, and the directory no longer allows one.
|
||||
std::fs::set_permissions(&dir.path, std::fs::Permissions::from_mode(0o500)).unwrap();
|
||||
if std::fs::write(dir.path.join("probe"), "").is_ok() {
|
||||
eprintln!("skipped: this user can write to a read-only directory (root?)");
|
||||
std::fs::set_permissions(&dir.path, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
return;
|
||||
}
|
||||
let error = writer
|
||||
.append(ts("2026-09-18T08:00:00.000Z"), denied(2))
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, AuditError::Io { .. }), "{error}");
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.ends_with("see docs/runbook.md#audit-unavailable"),
|
||||
"{error}"
|
||||
);
|
||||
|
||||
std::fs::set_permissions(&dir.path, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
for time in ["2026-09-18T08:00:01.000Z", "2026-09-17T08:00:02.000Z"] {
|
||||
let error = writer.append(ts(time), denied(3)).unwrap_err();
|
||||
assert!(matches!(error, AuditError::Stopped), "{error}");
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.ends_with("see docs/runbook.md#audit-unavailable"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
lines(&dir.path, D1).len(),
|
||||
1,
|
||||
"a stopped writer wrote something"
|
||||
);
|
||||
assert!(!dir.path.join(D2).exists());
|
||||
|
||||
// A restart puts it right.
|
||||
drop(writer);
|
||||
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
|
||||
assert_eq!(
|
||||
writer
|
||||
.append(ts("2026-09-18T08:00:03.000Z"), denied(4))
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(verify_dir(&dir.path).unwrap().failure, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_recovered_notice_names_its_runbook_entry() {
|
||||
assert!(RECOVERED_NOTICE.starts_with("audit: recovered a torn final line"));
|
||||
assert!(RECOVERED_NOTICE.ends_with("see docs/runbook.md#audit-recovered"));
|
||||
}
|
||||
Reference in New Issue
Block a user