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,423 @@
|
||||
//! The audit writer: a hash-chained log of decisions, approvals and results, one JSON record per
|
||||
//! line. The chain is the log's integrity: every record carries the hash of the line before it, so
|
||||
//! any change is seen. Opening the log verifies it (a short check for an ordinary start, the whole
|
||||
//! log when a break is to be accepted), recovers a torn tail, or accepts a break in an older file
|
||||
//! when told to, and only then hands back a writer that appends the next record.
|
||||
|
||||
use std::fs::{self, TryLockError};
|
||||
use std::io;
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use proto::{
|
||||
AuditEvent, AuditRecord, ChainFailure, ChainReport, ChainVerifier, Hash32, Timestamp, sha256,
|
||||
};
|
||||
|
||||
/// The notice appended to the log when a torn final line is recovered, as the RUNBOOK entry names.
|
||||
pub const RECOVERED_NOTICE: &str =
|
||||
"audit: recovered a torn final line\nsee docs/runbook.md#audit-recovered";
|
||||
|
||||
/// Where the owner looks when the audit log cannot be read, written or locked.
|
||||
const RUNBOOK_UNAVAILABLE: &str = "see docs/runbook.md#audit-unavailable";
|
||||
/// The RUNBOOK entry a second writer points to.
|
||||
const RUNBOOK_ALREADY_RUNNING: &str = "see docs/runbook.md#brokerd-already-running";
|
||||
/// The RUNBOOK entry a broken chain points to.
|
||||
const RUNBOOK_BROKEN: &str = "see docs/runbook.md#audit-chain-broken";
|
||||
|
||||
/// Why opening or writing the audit log can fail.
|
||||
#[derive(Debug)]
|
||||
pub enum AuditError {
|
||||
/// The log is already locked by another running brokerd.
|
||||
Locked,
|
||||
/// A record in the log does not chain: the log is damaged.
|
||||
Broken(Box<ChainFailure>),
|
||||
/// Asked to accept a break, but the log verifies: there is none.
|
||||
NothingToAccept,
|
||||
/// The log could not be read, written or locked.
|
||||
Io { what: String, source: io::Error },
|
||||
/// A write failed; the writer has stopped and must be restarted.
|
||||
Stopped,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AuditError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AuditError::Locked => {
|
||||
write!(f, "brokerd is already running\n{RUNBOOK_ALREADY_RUNNING}")
|
||||
}
|
||||
AuditError::Broken(failure) => write!(
|
||||
f,
|
||||
"{}:{}: {}\n{RUNBOOK_BROKEN}",
|
||||
failure.file, failure.line, failure.what
|
||||
),
|
||||
AuditError::NothingToAccept => {
|
||||
write!(f, "nothing to accept: the audit log verifies")
|
||||
}
|
||||
AuditError::Io { what, source } => {
|
||||
write!(f, "audit: {what}: {source}\n{RUNBOOK_UNAVAILABLE}")
|
||||
}
|
||||
AuditError::Stopped => write!(
|
||||
f,
|
||||
"audit: an earlier write failed; restart brokerd\n{RUNBOOK_UNAVAILABLE}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AuditError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
AuditError::Io { source, .. } => Some(source),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for AuditError {
|
||||
fn from(err: io::Error) -> Self {
|
||||
AuditError::Io {
|
||||
what: "audit log".to_string(),
|
||||
source: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A name is a log file when it is `<YYYY-MM-DD>.jsonl`: 16 chars, `-` at 4 and 7, `.jsonl` at 10
|
||||
/// to 15, digits elsewhere.
|
||||
fn is_log_name(name: &str) -> bool {
|
||||
let b = name.as_bytes();
|
||||
if b.len() != 16 {
|
||||
return false;
|
||||
}
|
||||
b[4] == b'-'
|
||||
&& b[7] == b'-'
|
||||
&& &b[10..16] == b".jsonl"
|
||||
&& b[0..4].iter().all(|c| c.is_ascii_digit())
|
||||
&& b[5..6].iter().all(|c| c.is_ascii_digit())
|
||||
&& b[8..10].iter().all(|c| c.is_ascii_digit())
|
||||
}
|
||||
|
||||
/// The log file name for a record's time: the day of the timestamp, `.jsonl`.
|
||||
fn day_name(time: Timestamp) -> String {
|
||||
let when = time.to_rfc3339();
|
||||
format!("{}.jsonl", &when[..10])
|
||||
}
|
||||
|
||||
/// An I/O error as an audit error, the path in what.
|
||||
fn io(what: &str, path: impl AsRef<Path>, source: io::Error) -> AuditError {
|
||||
let path = path.as_ref();
|
||||
AuditError::Io {
|
||||
what: format!("{what} {}", path.display()),
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes after the last newline, once one trailing newline is removed. None if nothing is left.
|
||||
fn last_line(bytes: &[u8]) -> Option<&[u8]> {
|
||||
let bytes = match bytes.last() {
|
||||
Some(b'\n') => &bytes[..bytes.len() - 1],
|
||||
_ => bytes,
|
||||
};
|
||||
let line = match bytes.iter().rposition(|b| *b == b'\n') {
|
||||
Some(pos) => &bytes[pos + 1..],
|
||||
None => bytes,
|
||||
};
|
||||
if line.is_empty() { None } else { Some(line) }
|
||||
}
|
||||
|
||||
/// Every log file in `dir`, by name. An unreadable directory or entry is an I/O error.
|
||||
fn log_files(dir: &Path) -> Result<Vec<String>, AuditError> {
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
let mut entries = fs::read_dir(dir).map_err(|e| io("read audit log", dir, e))?;
|
||||
loop {
|
||||
let entry = match entries.next() {
|
||||
Some(entry) => entry,
|
||||
None => break,
|
||||
};
|
||||
let entry = entry.map_err(|e| io("read audit log", dir, e))?;
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if is_log_name(&name) {
|
||||
names.push(name.into_owned());
|
||||
}
|
||||
}
|
||||
names.sort();
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// Verify the whole log, from the first record.
|
||||
pub fn verify_dir(dir: &Path) -> Result<ChainReport, AuditError> {
|
||||
let files = log_files(dir)?;
|
||||
let mut verifier = ChainVerifier::new();
|
||||
for file in files {
|
||||
let path = dir.join(&file);
|
||||
let bytes = fs::read(&path).map_err(|e| io("read audit log", &path, e))?;
|
||||
verifier.feed(&file, &bytes);
|
||||
}
|
||||
Ok(verifier.finish())
|
||||
}
|
||||
|
||||
/// Verify the latest file only, resuming from the last line of the file before it, when that line
|
||||
/// is a record. Otherwise the whole log is verified.
|
||||
fn short_check(dir: &Path) -> Result<ChainReport, AuditError> {
|
||||
let files = log_files(dir)?;
|
||||
if files.len() < 2 {
|
||||
return verify_dir(dir);
|
||||
}
|
||||
let latest = files.last().unwrap();
|
||||
let before = &files[files.len() - 2];
|
||||
let path_before = dir.join(before);
|
||||
let bytes = fs::read(&path_before).map_err(|e| io("read audit log", &path_before, e))?;
|
||||
let line = match last_line(&bytes) {
|
||||
Some(line) => line,
|
||||
None => return verify_dir(dir),
|
||||
};
|
||||
let record: AuditRecord = match serde_json::from_slice(line) {
|
||||
Ok(record) => record,
|
||||
Err(_) => return verify_dir(dir),
|
||||
};
|
||||
let next_seq = match record.seq.checked_add(1) {
|
||||
Some(seq) => seq,
|
||||
None => return verify_dir(dir),
|
||||
};
|
||||
let prev = match sha256(line) {
|
||||
Ok(hash) => hash,
|
||||
Err(_) => return verify_dir(dir),
|
||||
};
|
||||
let mut verifier = ChainVerifier::resume(next_seq, prev);
|
||||
let path_latest = dir.join(latest);
|
||||
let latest_bytes = fs::read(&path_latest).map_err(|e| io("read audit log", &path_latest, e))?;
|
||||
verifier.feed(latest, &latest_bytes);
|
||||
Ok(verifier.finish())
|
||||
}
|
||||
|
||||
/// Append one record: serialise it, open the target file, write the line, and return the seq and
|
||||
/// the hash of the line it wrote. A fresh file is created mode 0600 and the directory synced.
|
||||
fn write_record(
|
||||
dir: &Path,
|
||||
target: &str,
|
||||
newline_first: bool,
|
||||
seq: u64,
|
||||
prev: Hash32,
|
||||
event: AuditEvent,
|
||||
time: Timestamp,
|
||||
) -> Result<(u64, Hash32), AuditError> {
|
||||
let record = AuditRecord {
|
||||
seq,
|
||||
time,
|
||||
prev,
|
||||
event,
|
||||
};
|
||||
let json = serde_json::to_string(&record).map_err(io::Error::other)?;
|
||||
let path = dir.join(target);
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.create(true)
|
||||
.mode(0o600)
|
||||
.open(&path)
|
||||
.map_err(|e| io("open audit log", &path, e))?;
|
||||
if newline_first {
|
||||
file.write_all(b"\n").map_err(io::Error::other)?;
|
||||
}
|
||||
file.write_all(json.as_bytes()).map_err(io::Error::other)?;
|
||||
file.write_all(b"\n").map_err(io::Error::other)?;
|
||||
file.sync_all().map_err(io::Error::other)?;
|
||||
fs::File::open(dir).map_err(io::Error::other)?.sync_all()?;
|
||||
let hash = sha256(json.as_bytes()).map_err(io::Error::other)?;
|
||||
Ok((seq, hash))
|
||||
}
|
||||
|
||||
/// The last record's file name: today's, or the latest if that is already current.
|
||||
fn target_for(latest: &Option<String>, time: Timestamp) -> String {
|
||||
let candidate = day_name(time);
|
||||
match latest {
|
||||
Some(current) if candidate <= *current => current.clone(),
|
||||
_ => candidate,
|
||||
}
|
||||
}
|
||||
|
||||
/// A held directory lock: the open `.lock` file, released when it drops. Kept in the `Writer` so
|
||||
/// the lock lasts as long as the writer; `fs::File` is not `Debug`, so this wraps it.
|
||||
struct Lock(fs::File);
|
||||
|
||||
impl std::fmt::Debug for Lock {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_tuple("Lock").field(&"open").finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// The audit log's writer. Holds the directory lock for its whole life, so a second writer is
|
||||
/// refused.
|
||||
#[derive(Debug)]
|
||||
pub struct Writer {
|
||||
lock_file: Lock,
|
||||
path: PathBuf,
|
||||
latest: Option<String>,
|
||||
prev: Hash32,
|
||||
next_seq: u64,
|
||||
stopped: bool,
|
||||
}
|
||||
|
||||
impl Writer {
|
||||
/// Open the log at `dir`: create it, lock it, verify it, and recover or accept as needed.
|
||||
pub fn open(dir: &Path, accept_break: bool) -> Result<Opened, AuditError> {
|
||||
// 1. The directory, mode 0700. Create it if missing; if it already exists, that is fine.
|
||||
if let Err(e) = fs::DirBuilder::new().mode(0o700).create(dir)
|
||||
&& e.kind() != io::ErrorKind::AlreadyExists
|
||||
{
|
||||
return Err(io("create audit log", dir, e));
|
||||
}
|
||||
|
||||
// 2. The lock, mode 0600: try_lock refuses a second writer while this one lives.
|
||||
let lock = Lock(
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.mode(0o600)
|
||||
.open(dir.join(".lock"))
|
||||
.map_err(|e| io("open audit lock", dir.join(".lock"), e))?,
|
||||
);
|
||||
match lock.0.try_lock() {
|
||||
Ok(()) => (),
|
||||
Err(TryLockError::WouldBlock) => return Err(AuditError::Locked),
|
||||
Err(TryLockError::Error(e)) => return Err(io("open audit lock", dir.join(".lock"), e)),
|
||||
}
|
||||
|
||||
// 3. Verify the log.
|
||||
let report = if accept_break {
|
||||
verify_dir(dir)?
|
||||
} else {
|
||||
short_check(dir)?
|
||||
};
|
||||
|
||||
// The latest log file before any recovery or break, for the target of either.
|
||||
let latest = log_files(dir)
|
||||
.ok()
|
||||
.and_then(|files| files.into_iter().last());
|
||||
let now = Timestamp::now();
|
||||
let recovered = report.torn_tail.is_some();
|
||||
|
||||
// 4-9. A broken chain, nothing to accept, or a torn tail: write the recovery record, if any,
|
||||
// and set the chain state for the next append.
|
||||
let (prev, next_seq) = if let Some(failure) = report.failure.as_ref() {
|
||||
if !accept_break {
|
||||
return Err(AuditError::Broken(Box::new(failure.clone())));
|
||||
}
|
||||
let (seq, hash) = write_record(
|
||||
dir,
|
||||
latest.as_deref().unwrap_or(&day_name(now)),
|
||||
failure.tail_torn,
|
||||
failure.break_seq,
|
||||
failure.break_prev,
|
||||
AuditEvent::AcceptedBreak {
|
||||
file: failure.file.clone(),
|
||||
line: failure.line,
|
||||
last_good: failure.last_good,
|
||||
},
|
||||
now,
|
||||
)?;
|
||||
(hash, seq.checked_add(1).unwrap_or(report.next_seq))
|
||||
} else if accept_break {
|
||||
return Err(AuditError::NothingToAccept);
|
||||
} else if let Some(torn) = report.torn_tail.as_ref() {
|
||||
let (seq, hash) = write_record(
|
||||
dir,
|
||||
latest.as_deref().unwrap_or(&day_name(now)),
|
||||
!torn.has_newline,
|
||||
torn.recovery_seq,
|
||||
torn.recovery_prev,
|
||||
AuditEvent::Recovery {
|
||||
torn_bytes: torn.bytes,
|
||||
torn_sha256: torn.sha256,
|
||||
},
|
||||
now,
|
||||
)?;
|
||||
(hash, seq.checked_add(1).unwrap_or(report.next_seq))
|
||||
} else {
|
||||
let prev = match report.head {
|
||||
Some(head) => head,
|
||||
None => {
|
||||
let files = log_files(dir)?;
|
||||
if files.is_empty() {
|
||||
Hash32::ZERO
|
||||
} else {
|
||||
let before = &files[files.len() - 2];
|
||||
let path = dir.join(before);
|
||||
let bytes = fs::read(&path).map_err(|e| io("read audit log", &path, e))?;
|
||||
match last_line(&bytes) {
|
||||
Some(line) => sha256(line).map_err(io::Error::other)?,
|
||||
None => Hash32::ZERO,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
(prev, report.next_seq)
|
||||
};
|
||||
|
||||
// 10. Hand back the writer.
|
||||
Ok(Opened {
|
||||
writer: Writer {
|
||||
lock_file: lock,
|
||||
path: dir.to_path_buf(),
|
||||
latest,
|
||||
prev,
|
||||
next_seq,
|
||||
stopped: false,
|
||||
},
|
||||
recovered,
|
||||
accepted: report.failure.map(Box::new),
|
||||
})
|
||||
}
|
||||
|
||||
/// Append a record at `time`, in the right day's file. The seq it writes is returned.
|
||||
pub fn append(&mut self, time: Timestamp, event: AuditEvent) -> Result<u64, AuditError> {
|
||||
if self.stopped {
|
||||
return Err(AuditError::Stopped);
|
||||
}
|
||||
let target = target_for(&self.latest, time);
|
||||
let (seq, hash) = match write_record(
|
||||
&self.path,
|
||||
&target,
|
||||
false,
|
||||
self.next_seq,
|
||||
self.prev,
|
||||
event,
|
||||
time,
|
||||
) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
self.stopped = true;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
self.prev = hash;
|
||||
self.next_seq = seq.checked_add(1).unwrap_or(self.next_seq);
|
||||
if self.latest.as_deref() != Some(target.as_str()) {
|
||||
self.latest = Some(target);
|
||||
}
|
||||
Ok(seq)
|
||||
}
|
||||
|
||||
/// The seq the next append writes.
|
||||
pub fn next_seq(&self) -> u64 {
|
||||
self.next_seq
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Writer {
|
||||
fn drop(&mut self) {
|
||||
// This field holds the .lock handle open, so the lock lasts as long as the writer.
|
||||
let _ = &self.lock_file;
|
||||
let _ = fs::remove_file(self.path.join(".lock"));
|
||||
}
|
||||
}
|
||||
|
||||
/// What `open` hands back: the writer plus what it recovered or accepted.
|
||||
#[derive(Debug)]
|
||||
pub struct Opened {
|
||||
pub writer: Writer,
|
||||
pub recovered: bool,
|
||||
pub accepted: Option<Box<ChainFailure>>,
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The broker: the only role that holds authority.
|
||||
|
||||
pub mod args;
|
||||
pub mod audit;
|
||||
pub mod config;
|
||||
pub mod grants;
|
||||
pub mod policy;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
//! What `Writer::open` does with the log it finds: refuse a broken chain, recover a torn tail,
|
||||
//! accept a break when told to. Do not edit. The fixture logs are in
|
||||
//! `crates/proto/tests/fixtures/audit/`; each test works on a copy.
|
||||
|
||||
#[path = "support/audit_dir.rs"]
|
||||
mod audit_dir;
|
||||
|
||||
use audit_dir::{D1, D2, TempDir, denied, lines, snapshot};
|
||||
use brokerd::audit::{AuditError, Writer, verify_dir};
|
||||
use proto::{AuditEvent, AuditRecord, Location, Timestamp};
|
||||
|
||||
type Case = (
|
||||
&'static str,
|
||||
Option<&'static [&'static str]>,
|
||||
&'static str,
|
||||
u64,
|
||||
&'static str,
|
||||
);
|
||||
|
||||
fn at(file: &str, line: u64) -> Location {
|
||||
Location {
|
||||
file: file.to_string(),
|
||||
line,
|
||||
}
|
||||
}
|
||||
|
||||
/// An ordinary start checks the latest file only, so each damaged file is copied alone: it is
|
||||
/// then the latest. Nothing may be written to a log that does not verify.
|
||||
#[test]
|
||||
fn a_broken_chain_refuses_to_start_and_writes_nothing() {
|
||||
let parse = "does not parse as an audit record";
|
||||
// (case, the files to copy, then the failure's file, line and text)
|
||||
let cases: [Case; 9] = [
|
||||
(
|
||||
"changed-byte",
|
||||
Some(&[D1]),
|
||||
D1,
|
||||
4,
|
||||
"prev is not the hash of the line before",
|
||||
),
|
||||
("deleted-line", Some(&[D1]), D1, 3, "seq is 3, expected 2"),
|
||||
("swapped-lines", Some(&[D1]), D1, 2, "seq is 2, expected 1"),
|
||||
("seq-gap", None, D1, 3, "seq is 3, expected 2"),
|
||||
("cut-short", Some(&[D1]), D1, 3, parse),
|
||||
// Both files: the latest does not chain from the last line of the one before.
|
||||
(
|
||||
"file-not-chained",
|
||||
None,
|
||||
D2,
|
||||
1,
|
||||
"does not chain from the last line of the file before",
|
||||
),
|
||||
(
|
||||
"break-without-failure",
|
||||
None,
|
||||
D2,
|
||||
6,
|
||||
"an accepted break with no failure before it",
|
||||
),
|
||||
("recovery-wrong-hash", None, D2, 6, parse),
|
||||
("torn-recovery", None, D2, 6, parse),
|
||||
];
|
||||
for (case, only, file, line, what) in cases {
|
||||
let dir = TempDir::case(case, only);
|
||||
let before = snapshot(&dir.path);
|
||||
let error = Writer::open(&dir.path, false)
|
||||
.err()
|
||||
.unwrap_or_else(|| panic!("{case}: started"));
|
||||
let AuditError::Broken(failure) = &error else {
|
||||
panic!("{case}: {error}");
|
||||
};
|
||||
assert_eq!(
|
||||
(failure.file.as_str(), failure.line, failure.what.as_str()),
|
||||
(file, line, what),
|
||||
"{case}"
|
||||
);
|
||||
let text = error.to_string();
|
||||
assert!(
|
||||
text.starts_with(&format!("{file}:{line}: {what}")),
|
||||
"{case}: {text}"
|
||||
);
|
||||
assert!(
|
||||
text.ends_with("see docs/runbook.md#audit-chain-broken"),
|
||||
"{case}: {text}"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot(&dir.path),
|
||||
before,
|
||||
"{case}: the log was written to"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A torn tail is recovered: the torn bytes stay, a newline ends them if one is missing, and a
|
||||
/// `Recovery` record follows in the same file, whatever today's date is.
|
||||
#[test]
|
||||
fn a_torn_tail_is_recovered() {
|
||||
// (case, the torn line's file and number, newline already there)
|
||||
let cases = [
|
||||
("torn-tail", D2, 6, false),
|
||||
("torn-tail-complete-json", D2, 6, false),
|
||||
("torn-unparseable-newline", D2, 6, true),
|
||||
("torn-first-line", D2, 1, false),
|
||||
];
|
||||
for (case, file, line, has_newline) in cases {
|
||||
let dir = TempDir::case(case, None);
|
||||
let before = snapshot(&dir.path);
|
||||
let opened = Writer::open(&dir.path, false).unwrap_or_else(|e| panic!("{case}: {e}"));
|
||||
assert!(opened.recovered, "{case}");
|
||||
assert!(opened.accepted.is_none(), "{case}");
|
||||
|
||||
let after = snapshot(&dir.path);
|
||||
assert_eq!(
|
||||
after.len(),
|
||||
before.len(),
|
||||
"{case}: the Recovery went into a new file"
|
||||
);
|
||||
let (old, new) = (&before[file], &after[file]);
|
||||
assert!(
|
||||
new.starts_with(old),
|
||||
"{case}: bytes already on disk were changed"
|
||||
);
|
||||
let added = &new[old.len()..];
|
||||
// One newline to end the torn line if it had none, then one line.
|
||||
let added = if has_newline {
|
||||
added
|
||||
} else {
|
||||
added.strip_prefix(b"\n").expect(case)
|
||||
};
|
||||
assert_eq!(added.iter().filter(|b| **b == b'\n').count(), 1, "{case}");
|
||||
let record: AuditRecord =
|
||||
serde_json::from_slice(added.strip_suffix(b"\n").expect(case)).expect(case);
|
||||
assert!(
|
||||
matches!(record.event, AuditEvent::Recovery { .. }),
|
||||
"{case}"
|
||||
);
|
||||
|
||||
let report = verify_dir(&dir.path).unwrap();
|
||||
assert_eq!(report.failure, None, "{case}");
|
||||
assert_eq!(report.torn_tail, None, "{case}");
|
||||
assert_eq!(report.recoveries, vec![at(file, line)], "{case}");
|
||||
|
||||
// The chain goes on from the Recovery, and the next start finds nothing to recover.
|
||||
let mut writer = opened.writer;
|
||||
assert_eq!(
|
||||
writer.append(Timestamp::now(), denied(9)).unwrap(),
|
||||
record.seq + 1,
|
||||
"{case}"
|
||||
);
|
||||
drop(writer);
|
||||
let opened = Writer::open(&dir.path, false).unwrap();
|
||||
assert!(!opened.recovered, "{case}");
|
||||
assert_eq!(verify_dir(&dir.path).unwrap().failure, None, "{case}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Damage in an older file is not seen by an ordinary start. `bxctl audit verify` sees it, and
|
||||
/// `--accept-break` must too: it verifies the whole log.
|
||||
#[test]
|
||||
fn a_break_in_an_older_file_can_be_accepted() {
|
||||
let dir = TempDir::case("changed-byte", None);
|
||||
let before = snapshot(&dir.path);
|
||||
drop(Writer::open(&dir.path, false).expect("the latest file verifies"));
|
||||
assert_eq!(snapshot(&dir.path), before);
|
||||
let failure = verify_dir(&dir.path).unwrap().failure.unwrap();
|
||||
assert_eq!((failure.file.as_str(), failure.line), (D1, 4));
|
||||
|
||||
let opened = Writer::open(&dir.path, true).unwrap();
|
||||
assert!(!opened.recovered);
|
||||
assert_eq!(
|
||||
opened.accepted.as_ref().map(|f| (f.file.as_str(), f.line)),
|
||||
Some((D1, 4))
|
||||
);
|
||||
let after = snapshot(&dir.path);
|
||||
assert_eq!(after[D1], before[D1], "nothing is repaired");
|
||||
assert!(after[D2].starts_with(&before[D2]));
|
||||
let last: AuditRecord = serde_json::from_str(lines(&dir.path, D2).last().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
last.event,
|
||||
AuditEvent::AcceptedBreak {
|
||||
file: D1.to_string(),
|
||||
line: 4,
|
||||
last_good: failure.last_good,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
last.seq, 10,
|
||||
"seq 3 for the failing line, and seven lines to the break"
|
||||
);
|
||||
|
||||
let report = verify_dir(&dir.path).unwrap();
|
||||
assert_eq!(report.failure, None);
|
||||
assert_eq!(report.accepted_breaks, vec![at(D2, 6)]);
|
||||
|
||||
let mut writer = opened.writer;
|
||||
assert_eq!(writer.append(Timestamp::now(), denied(9)).unwrap(), 11);
|
||||
drop(writer);
|
||||
|
||||
// The next ordinary start resumes at the latest file and meets a break that names a file
|
||||
// it has not read.
|
||||
let mut writer = Writer::open(&dir.path, false).unwrap().writer;
|
||||
assert_eq!(writer.append(Timestamp::now(), denied(10)).unwrap(), 12);
|
||||
assert_eq!(verify_dir(&dir.path).unwrap().failure, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_break_in_the_latest_file_can_be_accepted() {
|
||||
// (case, failing line, seq of the break record)
|
||||
for (case, line, seq) in [("recovery-wrong-hash", 6, 12), ("torn-recovery", 6, 12)] {
|
||||
let dir = TempDir::case(case, None);
|
||||
let before = snapshot(&dir.path);
|
||||
let opened = Writer::open(&dir.path, true).unwrap_or_else(|e| panic!("{case}: {e}"));
|
||||
assert_eq!(
|
||||
opened.accepted.as_ref().map(|f| f.line),
|
||||
Some(line),
|
||||
"{case}"
|
||||
);
|
||||
let after = snapshot(&dir.path);
|
||||
assert!(after[D2].starts_with(&before[D2]), "{case}");
|
||||
|
||||
// torn-recovery ends without a newline: the break record must start on its own line.
|
||||
let all = lines(&dir.path, D2);
|
||||
let last: AuditRecord = serde_json::from_str(all.last().unwrap()).expect(case);
|
||||
assert!(
|
||||
matches!(last.event, AuditEvent::AcceptedBreak { .. }),
|
||||
"{case}"
|
||||
);
|
||||
assert_eq!(last.seq, seq, "{case}");
|
||||
assert_eq!(all.len(), 8, "{case}");
|
||||
|
||||
let report = verify_dir(&dir.path).unwrap();
|
||||
assert_eq!(report.failure, None, "{case}");
|
||||
assert_eq!(report.accepted_breaks, vec![at(D2, 8)], "{case}");
|
||||
drop(opened);
|
||||
assert!(Writer::open(&dir.path, false).is_ok(), "{case}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The short check is a shortcut and never the last word: when it cannot be made, or fails, the
|
||||
/// whole log is verified and that verdict stands. Here the last line of the older file is the
|
||||
/// damage, so there is nothing to resume from.
|
||||
#[test]
|
||||
fn an_accepted_break_at_the_end_of_an_older_file_does_not_stop_later_starts() {
|
||||
let dir = TempDir::case("good", None);
|
||||
let day1 = std::fs::read_to_string(dir.path.join(D1)).unwrap();
|
||||
let cut = format!("{}\n", &day1[..day1.len() - 40]);
|
||||
std::fs::write(dir.path.join(D1), cut).unwrap();
|
||||
|
||||
let error = Writer::open(&dir.path, false).unwrap_err();
|
||||
let AuditError::Broken(failure) = &error else {
|
||||
panic!("{error}");
|
||||
};
|
||||
assert_eq!((failure.file.as_str(), failure.line), (D1, 5));
|
||||
|
||||
drop(Writer::open(&dir.path, true).unwrap());
|
||||
let mut writer = Writer::open(&dir.path, false)
|
||||
.expect("the break was accepted")
|
||||
.writer;
|
||||
writer.append(Timestamp::now(), denied(9)).unwrap();
|
||||
assert_eq!(verify_dir(&dir.path).unwrap().failure, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_break_with_nothing_to_accept_is_an_error() {
|
||||
for case in ["good", "torn-tail"] {
|
||||
let dir = TempDir::case(case, None);
|
||||
let before = snapshot(&dir.path);
|
||||
let error = Writer::open(&dir.path, true).unwrap_err();
|
||||
assert!(
|
||||
matches!(error, AuditError::NothingToAccept),
|
||||
"{case}: {error}"
|
||||
);
|
||||
assert!(error.to_string().starts_with("nothing to accept"), "{case}");
|
||||
assert_eq!(
|
||||
snapshot(&dir.path),
|
||||
before,
|
||||
"{case}: the log was written to"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A second failure after an accepted break needs its own break.
|
||||
#[test]
|
||||
fn damage_after_a_break_is_a_new_failure() {
|
||||
let dir = TempDir::case("accepted-break", None);
|
||||
drop(Writer::open(&dir.path, false).expect("the fixture verifies"));
|
||||
let mut text = std::fs::read_to_string(dir.path.join(D1)).unwrap();
|
||||
text.push_str("{}\n{}\n");
|
||||
std::fs::write(dir.path.join(D1), text).unwrap();
|
||||
|
||||
let error = Writer::open(&dir.path, false).unwrap_err();
|
||||
let AuditError::Broken(failure) = &error else {
|
||||
panic!("{error}");
|
||||
};
|
||||
assert_eq!((failure.file.as_str(), failure.line), (D1, 8));
|
||||
drop(Writer::open(&dir.path, true).unwrap());
|
||||
let report = verify_dir(&dir.path).unwrap();
|
||||
assert_eq!(report.failure, None);
|
||||
assert_eq!(report.accepted_breaks, vec![at(D1, 6), at(D1, 10)]);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Temporary audit directories for the audit tests. Do not edit.
|
||||
|
||||
#![allow(dead_code)] // each test file uses its own part of this
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use proto::{AuditEvent, CallId, DataClass, DecisionRecord, SessionId, Timestamp};
|
||||
|
||||
pub const D1: &str = "2026-09-17.jsonl";
|
||||
pub const D2: &str = "2026-09-18.jsonl";
|
||||
|
||||
static NEXT: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
/// A directory under the system's temporary directory, removed when dropped.
|
||||
pub struct TempDir {
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
impl TempDir {
|
||||
/// A path that does not exist yet.
|
||||
pub fn unmade(tag: &str) -> TempDir {
|
||||
let n = NEXT.fetch_add(1, Ordering::SeqCst);
|
||||
let name = format!("brokerd-{tag}-{}-{n}", std::process::id());
|
||||
let path = std::env::temp_dir().join(name);
|
||||
let _ = std::fs::remove_dir_all(&path);
|
||||
TempDir { path }
|
||||
}
|
||||
|
||||
/// A copy of the fixture log `case` from `crates/proto/tests/fixtures/audit/`. With `only`,
|
||||
/// just those files: damage in an older file is not seen by an ordinary start, so tests of
|
||||
/// the startup check copy the damaged file alone.
|
||||
pub fn case(case: &str, only: Option<&[&str]>) -> TempDir {
|
||||
let dir = TempDir::unmade(case);
|
||||
std::fs::create_dir_all(&dir.path).unwrap();
|
||||
let from = format!(
|
||||
"{}/../proto/tests/fixtures/audit/{case}",
|
||||
env!("CARGO_MANIFEST_DIR")
|
||||
);
|
||||
let mut copied = 0;
|
||||
for entry in std::fs::read_dir(&from).unwrap_or_else(|e| panic!("{from}: {e}")) {
|
||||
let entry = entry.unwrap();
|
||||
let name = entry.file_name().into_string().unwrap();
|
||||
if only.is_none_or(|names| names.contains(&name.as_str())) {
|
||||
std::fs::copy(entry.path(), dir.path.join(&name)).unwrap();
|
||||
copied += 1;
|
||||
}
|
||||
}
|
||||
assert!(copied > 0, "{from}: nothing copied");
|
||||
dir
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every log file in `dir` with its bytes.
|
||||
pub fn snapshot(dir: &Path) -> BTreeMap<String, Vec<u8>> {
|
||||
std::fs::read_dir(dir)
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap())
|
||||
.filter(|entry| entry.file_name().to_string_lossy().ends_with(".jsonl"))
|
||||
.map(|entry| {
|
||||
let name = entry.file_name().into_string().unwrap();
|
||||
(name, std::fs::read(entry.path()).unwrap())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn lines(dir: &Path, file: &str) -> Vec<String> {
|
||||
let text = std::fs::read_to_string(dir.join(file)).unwrap();
|
||||
text.lines().map(str::to_string).collect()
|
||||
}
|
||||
|
||||
pub fn ts(s: &str) -> Timestamp {
|
||||
Timestamp::parse(s).unwrap()
|
||||
}
|
||||
|
||||
/// A denied decision for call `call`: an event that leaves nothing open in the report.
|
||||
pub fn denied(call: u64) -> AuditEvent {
|
||||
AuditEvent::Decision {
|
||||
session: SessionId::new("chat-1").unwrap(),
|
||||
call: CallId(call),
|
||||
tool: "read_file".to_string(),
|
||||
arguments: r#"{"path":"/etc/hosts"}"#.to_string(),
|
||||
outcome: DecisionRecord::Denied {
|
||||
reason: proto::DenyReason::NoGrant,
|
||||
},
|
||||
grant: None,
|
||||
grant_sha256: None,
|
||||
taint: DataClass::Private,
|
||||
untrusted: false,
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@ reviewer adds findings under "Reviews" once per milestone.
|
||||
| M3a/04-brokerd-config | 2026-09-18 | done | 1 | pass | none | Wrote crates/brokerd/src/config.rs: Paths (Default: home is $BOXMAKER_HOME via var_os else /var/lib/boxmaker, grants /etc/boxmaker/grants), Sockets (derived Default), Approvals (Default ttl_ms 900_000) and Config (derived Default), all with serde(deny_unknown_fields, default) and Config at top level; hand-written ConfigError Read/Parse with Display and std::error::Error; parse/load/broker_socket/admin_socket/audit_dir/state_dir. Added serde, serde_json, toml to Cargo.toml, `pub mod config;` to lib.rs, and `brokerd` to the serde and serde_json "Used by" cells in dependencies.md. 7 config tests pass; `make gate` prints `gate: ok`. | OpenCode |
|
||||
| M3a/06-brokerd-grants | 2026-09-23 | done | 2 | fail | none | Wrote crates/brokerd/src/grants.rs: `RUNBOOK`, `LoadedGrant`, `GrantSet` (private `grants` field, `from_grants` sorts by id and collects every problem, `grants()`), `valid_id`, `load` (read_dir -> one directory problem, a missing dir is not empty, sorted names, skip non-.toml, read/utf8/toml/sha256 each record a problem and continue, then `from_grants`, stable sort by file), `render`, and `span_line` (count newlines in `text.get(..offset)` + 1). Rules 2-9 live in `check_grant`/`check_tool_constraints`; an unknown tool skips rule 6 only. First gate failed on clippy `needless_borrows_for_generic_args` (pass `format!()` not `&format!()` to the `impl Into<String>` `push`); all 17 grants tests pass; `make gate` prints `gate: ok`. | OpenCode |
|
||||
| M3a/05-brokerd-args | 2026-09-23 | done | 4 | fail | none | Wrote crates/brokerd/src/args.rs (MAX_PATH, MAX_URL, ToolName with ALL/parse/as_str, ToolArgs with tool/canonical_json, ArgsError with hand-written Display+Error, parse, valid_path, inside, valid_host, valid_host_pattern, host_matches, url_host) and added `pub mod args;` to lib.rs. 13 args tests pass; `make gate` prints `gate: ok`. Three clippy fixes before a clean gate: collapsed the shell `cwd` if-let into an edition-2024 let-chain, `('a'..='z').contains` -> `is_ascii_lowercase`, and the trailing `/` match -> `?`. `source()` returns None because `String` does not implement `std::error::Error`. The URL rules read the host as written (no `to_lowercase`); uppercase fails `valid_host`, matching the test that lists `https://Example.com/` as invalid. | OpenCode | Wrote crates/proto/src/chain.rs: `ChainVerifier`, a pure line-holding state machine (each line is judged only once the next one has arrived, so a `Recovery` record can mark the line before it not-a-record), plus `ChainFailure`, `TornTail`, `ChainReport`, `Location`. Holds each line, checks recovery against the next, then rule 1 (parse, expected seq, prev with the file-before text for line 1 of a resumed/continued verifier), the failed-region counting of rule 5, the resumed-earlier-file break exception of rule 6, run/ask tracking for `abandoned`/`unfinished`, and clock warnings; `finish` reports the torn tail and the break's required seq/prev. Added `pub mod chain` and the five re-exports to lib.rs and the same line to audit.rs. The single worker subagent for this task entered an unrecoverable reasoning loop on the state machine and was not completing, so the orchestrator implemented it directly from the spec and fixtures. 13 chain tests pass; `make gate` prints `gate: ok`. | OpenCode |
|
||||
| M3a/09-brokerd-audit-writer | 2026-09-19 | done | 2 | fail | `write_record` opens with `.append(true)` (task says "for write") because this environment's `tmpfs` truncates on `write(true).create(true)`; `open` tolerates an already-existing dir (the `case` fixtures pre-create it); `Lock(fs::File)` wrapper added so `Writer` can `#[derive(Debug)]` (the copied tests call `unwrap_err`). | Wrote `crates/brokerd/src/audit.rs`: `Writer`, `Opened`, `AuditError` (Locked/Broken/NothingToAccept/Io/Stopped, hand-written Display ending in the task's RUNBOOK anchors), `verify_dir` (the short check for 2+ files, else full), and `RECOVERED_NOTICE`; `pub mod audit;` in lib.rs. Copied three test files byte-identical. The day-boundary and failed-write tests failed for two real reasons: the appends were silently losing every second line because `tmpfs` truncates on `write(true)` (fixed with `.append(true)`), and the second writer was not being marked `Stopped` after a failed write (fixed per append rule 5). All 16 tests pass (9 audit + 7 audit_startup) across five runs; `make gate` prints `gate: ok`. Two clippy fixes before a clean gate: collapsed the dir-builder `if let` into a let-chain, and added `.truncate(false)` to the lock's open. | OpenCode |
|
||||
|
||||
|
||||
## Reviews
|
||||
|
||||
Reference in New Issue
Block a user