Add the ledger: the audit writer and session state behind one lock

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-20 03:30:37 -07:00
parent caf8fd6eca
commit cff22ce579
7 changed files with 1342 additions and 0 deletions
+118
View File
@@ -0,0 +1,118 @@
//! A home for ledger and broker tests: grants, audit log, session state, a flaky audit sink and
//! a log to read. Do not edit.
//!
//! Included with `#[path = "support/rig.rs"] mod rig;`, beside `tmp` and `sink`. The broker
//! tests add `client`.
#![allow(dead_code)] // each test file uses a different part of this module
use std::path::PathBuf;
use brokerd::audit::Writer;
use brokerd::config::{Approvals, Config, Paths, Sockets};
use brokerd::ledger::Ledger;
use brokerd::state::StateStore;
use proto::{AuditEvent, AuditRecord, CallId, SessionId, ToolRequest};
use crate::sink::{Flaky, Lines, Switch};
use crate::tmp::TempDir;
pub struct Rig {
pub dir: TempDir,
pub cfg: Config,
pub switch: Switch,
pub lines: Lines,
}
impl Rig {
pub fn new(tag: &str) -> Rig {
Rig::with_ttl(tag, 900_000)
}
pub fn with_ttl(tag: &str, ttl_ms: u64) -> Rig {
let dir = TempDir::new(tag);
let grants = dir.path().join("grants");
std::fs::create_dir_all(&grants).unwrap();
let cfg = Config {
paths: Paths {
home: dir.path().to_path_buf(),
grants,
},
sockets: Sockets::default(),
approvals: Approvals { ttl_ms },
};
Rig {
dir,
cfg,
switch: Switch::default(),
lines: Lines::default(),
}
}
pub fn state(&self) -> StateStore {
StateStore::new(&self.cfg.state_dir())
}
/// Opens the audit log (once: the writer holds its lock) behind the flaky sink.
pub fn ledger(&self) -> Ledger {
let opened = Writer::open(&self.cfg.audit_dir(), false).unwrap();
let sink = Flaky {
writer: opened.writer,
switch: self.switch.clone(),
};
Ledger::new(Box::new(sink), self.state(), self.lines.sink())
}
/// Writes `grants/<id>.toml`.
pub fn grant(&self, id: &str, text: &str) {
std::fs::write(self.cfg.paths.grants.join(format!("{id}.toml")), text).unwrap();
}
pub fn remove_grant(&self, id: &str) {
std::fs::remove_file(self.cfg.paths.grants.join(format!("{id}.toml"))).unwrap();
}
pub fn state_file(&self, session: &str) -> PathBuf {
self.cfg.state_dir().join(format!("{session}.json"))
}
/// Every record in the audit log, in order.
pub fn records(&self) -> Vec<AuditRecord> {
let dir = self.cfg.audit_dir();
let mut names: Vec<String> = std::fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap())
.filter(|n| n.ends_with(".jsonl"))
.collect();
names.sort();
let mut out = Vec::new();
for name in names {
let text = std::fs::read_to_string(dir.join(name)).unwrap();
for line in text.lines() {
out.push(serde_json::from_str(line).unwrap());
}
}
out
}
pub fn events(&self) -> Vec<AuditEvent> {
self.records().into_iter().map(|r| r.event).collect()
}
}
/// A grant file's text. `extra` goes before `[constraints]`, `constraints` after it.
pub fn grant_text(tool: &str, mode: &str, extra: &str, constraints: &str) -> String {
format!(
"tool = \"{tool}\"\nmode = \"{mode}\"\nmax_taint = \"secret\"\nresult_class = \"private\"\n\
untrusted = false\n{extra}\n[constraints]\n{constraints}\n"
)
}
pub fn request(session: &str, call: u64, tool: &str, arguments: &str) -> ToolRequest {
ToolRequest {
session: SessionId::new(session).unwrap(),
call: CallId(call),
tool: tool.to_string(),
arguments: arguments.to_string(),
}
}
+78
View File
@@ -0,0 +1,78 @@
//! An audit sink that fails on demand, and a log that tests can read. Do not edit.
//!
//! Included with `#[path = "support/sink.rs"] mod sink;`.
#![allow(dead_code)] // each test file uses a different part of this module
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use brokerd::audit::{AuditError, Writer};
use brokerd::ledger::AuditSink;
use proto::{AuditEvent, Timestamp};
/// Switches shared between a test and its `Flaky` sink.
#[derive(Clone, Default)]
pub struct Switch {
fail: Arc<AtomicBool>,
panic: Arc<AtomicBool>,
attempts: Arc<AtomicUsize>,
}
impl Switch {
/// Every append from now on fails, without writing anything.
pub fn fail(&self, on: bool) {
self.fail.store(on, Ordering::SeqCst);
}
/// The next append panics, as a bug part-way through a write would.
pub fn panic_next(&self) {
self.panic.store(true, Ordering::SeqCst);
}
/// How many appends the ledger has asked for.
pub fn attempts(&self) -> usize {
self.attempts.load(Ordering::SeqCst)
}
}
/// A real `Writer` behind a switch.
pub struct Flaky {
pub writer: Writer,
pub switch: Switch,
}
impl AuditSink for Flaky {
fn append(&mut self, time: Timestamp, event: AuditEvent) -> Result<u64, AuditError> {
self.switch.attempts.fetch_add(1, Ordering::SeqCst);
if self.switch.panic.swap(false, Ordering::SeqCst) {
panic!("a bug part-way through a write");
}
if self.switch.fail.load(Ordering::SeqCst) {
return Err(AuditError::Io {
what: "cannot write to the test log".to_string(),
source: std::io::Error::other("the disk is full"),
});
}
self.writer.append(time, event)
}
}
/// Collects every line a ledger or broker prints.
#[derive(Clone, Default)]
pub struct Lines(Arc<Mutex<Vec<String>>>);
impl Lines {
pub fn sink(&self) -> Box<dyn Fn(&str) + Send + Sync> {
let lines = Arc::clone(&self.0);
Box::new(move |line| lines.lock().unwrap().push(line.to_string()))
}
pub fn all(&self) -> Vec<String> {
self.0.lock().unwrap().clone()
}
/// The lines that hold `text`.
pub fn with(&self, text: &str) -> Vec<String> {
self.all()
.into_iter()
.filter(|l| l.contains(text))
.collect()
}
}