M5c-1 plan: task 05, brokerd answers memoryd's session-state question on state.sock

brokerd serves a third socket, [sockets] state (default
<home>/run/memory-broker/state.sock), bound by the same listen (dir 0700,
stale socket removed, socket 0600), one thread per connection, one
question per connection. The answer is StateStore::read: the stored taint
and flag, the store's default (private, trusted) for a session with no
file. A damaged state is an internal error with the store's text, never an
answer; other kinds are forbidden; malformed frames get read_request's
error. A new tests/serve.rs waits for an answer before reading the
startup notices (a race that failed about one run in ten under load,
before this task too).

Checks: every before block found once at ref 04 (20 edits); the new file
in the task is byte-identical to the reference; the given tests fail
before as the task says (config and state_socket do not compile;
serve_state 5 of 5 fail); gate ok with 1171 tests on the reference and on
a replay from the task text in a fresh ref-04 worktree with its own build
dir (byte-identical to the reference; removed); /tmp unchanged around the
gate; 25 mutations, 24 caught, the one missed (state bound last) changes
no tested behaviour; the socket tests 25 runs under load, 0 failures;
no task-text line over 100 characters outside tables and code.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 01:52:06 -07:00
co-authored by Claude Opus 5.5
parent 5454d7ea1b
commit ba8267c7dc
6 changed files with 1817 additions and 0 deletions
@@ -0,0 +1,138 @@
//! Tests for `brokerd`'s configuration. Do not edit: these define the required behaviour.
use brokerd::config::{Approvals, Config, ConfigError, Sockets};
use std::path::{Path, PathBuf};
fn fixture(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/config")
.join(name)
}
/// What `home` must default to in this process. The test does not set the variable: changing the
/// environment of a running test binary would race with the other tests.
fn default_home() -> PathBuf {
std::env::var_os("BOXMAKER_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/var/lib/boxmaker"))
}
#[test]
fn an_empty_file_gets_every_default() {
let c = Config::load(&fixture("empty.toml")).unwrap();
assert_eq!(c.paths.home, default_home());
assert_eq!(c.paths.grants, PathBuf::from("/etc/boxmaker/grants"));
assert_eq!(c.sockets, Sockets::default());
assert_eq!(c.approvals, Approvals { ttl_ms: 900_000 });
assert_eq!(Approvals::default(), Approvals { ttl_ms: 900_000 });
assert_eq!(c, Config::parse("").unwrap());
assert_eq!(c, Config::default());
}
#[test]
fn sockets_and_directories_default_to_places_under_home() {
let c = Config::load(&fixture("home_only.toml")).unwrap();
assert_eq!(c.paths.home, PathBuf::from("/srv/boxmaker"));
assert_eq!(
c.broker_socket(),
PathBuf::from("/srv/boxmaker/run/loop-broker/broker.sock")
);
assert_eq!(
c.admin_socket(),
PathBuf::from("/srv/boxmaker/run/owner-broker/admin.sock")
);
assert_eq!(
c.state_socket(),
PathBuf::from("/srv/boxmaker/run/memory-broker/state.sock")
);
assert_eq!(c.audit_dir(), PathBuf::from("/srv/boxmaker/audit"));
assert_eq!(
c.state_dir(),
PathBuf::from("/srv/boxmaker/broker/sessions")
);
// The grants are not under home: the owner writes them, brokerd only reads them.
assert_eq!(c.paths.grants, PathBuf::from("/etc/boxmaker/grants"));
}
#[test]
fn every_key_can_be_set() {
let c = Config::load(&fixture("full.toml")).unwrap();
assert_eq!(c.paths.home, PathBuf::from("/srv/boxmaker"));
assert_eq!(c.paths.grants, PathBuf::from("/srv/boxmaker-grants"));
assert_eq!(c.broker_socket(), PathBuf::from("/run/bx/broker.sock"));
assert_eq!(c.admin_socket(), PathBuf::from("/run/bx/admin.sock"));
assert_eq!(c.state_socket(), PathBuf::from("/run/bx/state.sock"));
assert_eq!(c.approvals.ttl_ms, 60_000);
// The two directories always follow home.
assert_eq!(c.audit_dir(), PathBuf::from("/srv/boxmaker/audit"));
assert_eq!(
c.state_dir(),
PathBuf::from("/srv/boxmaker/broker/sessions")
);
}
#[test]
fn one_socket_set_leaves_the_other_at_its_default() {
let c =
Config::parse("[paths]\nhome = \"/h\"\n[sockets]\nadmin = \"/x/admin.sock\"\n").unwrap();
assert_eq!(c.admin_socket(), PathBuf::from("/x/admin.sock"));
assert_eq!(
c.broker_socket(),
PathBuf::from("/h/run/loop-broker/broker.sock")
);
assert_eq!(
c.state_socket(),
PathBuf::from("/h/run/memory-broker/state.sock")
);
let c =
Config::parse("[paths]\nhome = \"/h\"\n[sockets]\nstate = \"/x/state.sock\"\n").unwrap();
assert_eq!(c.state_socket(), PathBuf::from("/x/state.sock"));
assert_eq!(
c.broker_socket(),
PathBuf::from("/h/run/loop-broker/broker.sock")
);
assert_eq!(
c.admin_socket(),
PathBuf::from("/h/run/owner-broker/admin.sock")
);
}
#[test]
fn unknown_keys_and_tables_are_errors() {
for name in ["unknown_key.toml", "unknown_table.toml", "wrong_type.toml"] {
match Config::load(&fixture(name)) {
Err(ConfigError::Parse(path, _)) => assert_eq!(path, fixture(name)),
other => panic!("{name}: expected a parse error, got {other:?}"),
}
}
// In every table, not only the one the fixture shows.
for text in [
"[paths]\nhome = \"/h\"\nhouse = \"/h\"\n",
"[sockets]\nbroker = \"/b.sock\"\nloop = \"/l.sock\"\n",
"[approvals]\nttl_ms = 1\nttl_s = 1\n",
"top = 1\n",
"[approvals]\nttl_ms = -5\n",
] {
assert!(Config::parse(text).is_err(), "accepted: {text}");
}
}
#[test]
fn a_missing_file_is_a_read_error_that_names_the_file() {
let path = fixture("does-not-exist.toml");
match Config::load(&path) {
Err(ConfigError::Read(p, _)) => assert_eq!(p, path),
other => panic!("expected a read error, got {other:?}"),
}
let text = Config::load(&path).unwrap_err().to_string();
assert!(text.contains("does-not-exist.toml"), "{text}");
}
#[test]
fn a_parse_error_names_the_file_and_the_key() {
let text = Config::load(&fixture("unknown_key.toml"))
.unwrap_err()
.to_string();
assert!(text.contains("unknown_key.toml"), "{text}");
assert!(text.contains("ttl"), "{text}");
}
@@ -0,0 +1,12 @@
# Every key set.
[paths]
home = "/srv/boxmaker"
grants = "/srv/boxmaker-grants"
[sockets]
broker = "/run/bx/broker.sock"
admin = "/run/bx/admin.sock"
state = "/run/bx/state.sock"
[approvals]
ttl_ms = 60000
@@ -0,0 +1,400 @@
//! `brokerd serve` as a process: its startup, both sockets, and the expiry thread. Do not edit.
#[path = "support/tmp.rs"]
mod tmp;
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Output, Stdio};
use std::time::{Duration, Instant};
use brokerd::audit::RECOVERED_NOTICE;
use brokerd::runner::REFUSING;
use bxcore::{
AuditEvent, CallId, DenyReason, Empty, Envelope, ErrorCode, Message, PROTOCOL_VERSION,
ResultStatus, SessionId, ToolRequest, ToolResponse,
};
use tmp::TempDir;
struct Home {
dir: TempDir,
config: PathBuf,
}
impl Home {
fn new(tag: &str, ttl_ms: u64) -> Home {
let dir = TempDir::new(tag);
std::fs::create_dir_all(dir.path().join("grants")).unwrap();
let text = format!(
"[paths]\nhome = \"{home}\"\ngrants = \"{home}/grants\"\n[approvals]\nttl_ms = {ttl_ms}\n",
home = dir.path().display()
);
let config = dir.write("brokerd.toml", &text);
Home { dir, config }
}
fn path(&self, relative: &str) -> PathBuf {
self.dir.path().join(relative)
}
fn tools(&self) -> PathBuf {
self.path("run/loop-broker/broker.sock")
}
fn admin(&self) -> PathBuf {
self.path("run/owner-broker/admin.sock")
}
fn grant(&self, id: &str, mode: &str) {
let text = format!(
"tool = \"read_file\"\nmode = \"{mode}\"\nmax_taint = \"secret\"\n\
result_class = \"private\"\nuntrusted = false\n[constraints]\npaths = [\"/n\"]\n"
);
std::fs::write(self.path(&format!("grants/{id}.toml")), text).unwrap();
}
fn command(&self, extra: &[&str]) -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_brokerd"));
command
.args(["serve", "--config"])
.arg(&self.config)
.args(extra);
command
}
/// Starts `brokerd serve` and waits until both sockets answer. A socket accepts connections
/// as soon as it is bound, before `brokerd` has printed its startup notices; the admin
/// socket's answer comes from a handler thread, which runs only after they are printed.
fn serve(&self) -> Running {
let child = self
.command(&[])
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let running = Running(Some(child));
let until = Instant::now() + Duration::from_secs(10);
while UnixStream::connect(self.tools()).is_err()
|| UnixStream::connect(self.admin()).is_err()
{
assert!(Instant::now() < until, "brokerd never listened");
std::thread::sleep(Duration::from_millis(20));
}
let answer = exchange(&self.admin(), 1, Message::Approvals(Empty {}));
assert!(
matches!(&answer[0].msg, Message::ApprovalList(_)),
"{answer:?}"
);
running
}
/// Runs `brokerd serve` expecting it to exit by itself.
fn run(&self, extra: &[&str]) -> Output {
self.command(extra).output().unwrap()
}
fn events(&self) -> Vec<AuditEvent> {
let dir = self.path("audit");
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();
names
.iter()
.flat_map(|n| {
let text = std::fs::read_to_string(dir.join(n)).unwrap();
text.lines()
.map(|l| {
serde_json::from_str::<bxcore::AuditRecord>(l)
.unwrap()
.event
})
.collect::<Vec<_>>()
})
.collect()
}
}
/// Kills the daemon when dropped; `stop` returns what it printed.
struct Running(Option<Child>);
impl Running {
fn stop(mut self) -> String {
let mut child = self.0.take().unwrap();
child.kill().unwrap();
let output = child.wait_with_output().unwrap();
String::from_utf8_lossy(&output.stderr).to_string()
}
}
impl Drop for Running {
fn drop(&mut self) {
if let Some(child) = &mut self.0 {
let _ = child.kill();
let _ = child.wait();
}
}
}
fn mode(path: &Path) -> u32 {
std::fs::metadata(path).unwrap().permissions().mode() & 0o777
}
fn exchange(socket: &Path, id: u64, msg: Message) -> Vec<Envelope> {
let mut stream = UnixStream::connect(socket).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.unwrap();
let env = Envelope {
v: PROTOCOL_VERSION,
id,
r#final: true,
msg,
};
bxcore::write_frame(&mut stream, &env).unwrap();
let mut frames = Vec::new();
loop {
let env = bxcore::read_frame(&mut stream).unwrap();
let last = env.r#final;
frames.push(env);
if last {
return frames;
}
}
}
fn read_notes(call: u64) -> Message {
Message::ToolRequest(ToolRequest {
session: SessionId::new("s1").unwrap(),
call: CallId(call),
tool: "read_file".to_string(),
arguments: r#"{"path":"/n/a"}"#.to_string(),
})
}
fn last_response(frames: &[Envelope]) -> &ToolResponse {
match &frames.last().unwrap().msg {
Message::ToolResponse(r) => r,
other => panic!("{other:?}"),
}
}
fn stderr(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).to_string()
}
#[test]
fn it_makes_its_directories_0700_and_its_sockets_0600() {
let home = Home::new("serve-modes", 900_000);
// One directory found too open, one made.
std::fs::create_dir_all(home.path("run/owner-broker")).unwrap();
std::fs::set_permissions(
home.path("run/owner-broker"),
std::fs::Permissions::from_mode(0o755),
)
.unwrap();
let running = home.serve();
assert_eq!(mode(&home.path("run/loop-broker")), 0o700);
assert_eq!(mode(&home.path("run/owner-broker")), 0o700);
assert_eq!(mode(&home.tools()), 0o600);
assert_eq!(mode(&home.admin()), 0o600);
assert_eq!(mode(&home.path("audit")), 0o700);
let printed = running.stop();
assert!(printed.contains("serving"), "{printed}");
}
#[test]
fn a_stale_socket_is_replaced() {
let home = Home::new("serve-stale", 900_000);
std::fs::create_dir_all(home.path("run/loop-broker")).unwrap();
drop(UnixListener::bind(home.tools()).unwrap());
assert!(home.tools().exists(), "the stale socket file is there");
let _running = home.serve();
let frames = exchange(&home.tools(), 3, read_notes(3));
assert_eq!(
last_response(&frames),
&ToolResponse::Denied {
reason: DenyReason::NoGrant,
detail: None,
}
);
}
#[test]
fn it_answers_each_socket_and_refuses_the_other_kinds() {
let home = Home::new("serve-kinds", 900_000);
home.grant("notes", "auto");
let running = home.serve();
let frames = exchange(&home.tools(), 7, read_notes(7));
assert_eq!(frames[0].id, 7);
// The production runtime runs nothing.
assert_eq!(
last_response(&frames),
&ToolResponse::Failed {
message: REFUSING.to_string()
}
);
let wrong = exchange(&home.tools(), 8, Message::Approvals(Empty {}));
assert!(matches!(&wrong[0].msg, Message::Error(e) if e.code == ErrorCode::Forbidden));
let wrong = exchange(&home.admin(), 9, read_notes(9));
assert!(matches!(&wrong[0].msg, Message::Error(e) if e.code == ErrorCode::Forbidden));
let printed = running.stop();
assert!(
printed.contains("approvals on broker.sock\nsee docs/runbook.md#socket-forbidden"),
"{printed}"
);
assert!(
printed.contains("tool_request on admin.sock\nsee docs/runbook.md#socket-forbidden"),
"{printed}"
);
assert!(matches!(
home.events().as_slice(),
[
AuditEvent::Decision { .. },
AuditEvent::Result {
status: ResultStatus::Failed,
..
}
]
));
}
#[test]
fn an_approval_nobody_answers_expires() {
let home = Home::new("serve-expire", 100);
home.grant("notes", "ask");
let _running = home.serve();
let started = Instant::now();
let frames = exchange(&home.tools(), 2, read_notes(2));
assert_eq!(frames.len(), 2, "{frames:?}");
assert!(matches!(
&frames[0].msg,
Message::ToolResponse(ToolResponse::PendingApproval { approval: 0, .. })
));
assert_eq!(
last_response(&frames),
&ToolResponse::Denied {
reason: DenyReason::ApprovalExpired,
detail: None,
}
);
// The expiry thread looks every second.
assert!(
started.elapsed() < Duration::from_secs(5),
"{:?}",
started.elapsed()
);
}
fn copy_case(home: &Home, case: &str, only: &[&str]) {
let from = format!(
"{}/../bxcore/tests/fixtures/audit/{case}",
env!("CARGO_MANIFEST_DIR")
);
std::fs::create_dir_all(home.path("audit")).unwrap();
for name in only {
std::fs::copy(
format!("{from}/{name}"),
home.path(&format!("audit/{name}")),
)
.unwrap();
}
}
fn snapshot(dir: &Path) -> Vec<(String, Vec<u8>)> {
let mut all: Vec<(String, Vec<u8>)> = std::fs::read_dir(dir)
.unwrap()
.map(|e| e.unwrap())
.filter(|e| e.file_name().to_string_lossy().ends_with(".jsonl"))
.map(|e| {
(
e.file_name().into_string().unwrap(),
std::fs::read(e.path()).unwrap(),
)
})
.collect();
all.sort();
all
}
#[test]
fn a_broken_chain_stops_it_before_any_socket_and_nothing_is_written() {
let home = Home::new("serve-broken", 900_000);
copy_case(&home, "changed-byte", &["2026-09-17.jsonl"]);
let before = snapshot(&home.path("audit"));
let output = home.run(&[]);
assert_eq!(output.status.code(), Some(1));
let text = stderr(&output);
assert!(text.contains("2026-09-17.jsonl:4: "), "{text}");
assert!(
text.trim_end()
.ends_with("see docs/runbook.md#audit-chain-broken"),
"{text}"
);
assert_eq!(snapshot(&home.path("audit")), before);
assert!(!home.tools().exists() && !home.admin().exists());
}
#[test]
fn a_torn_tail_is_recovered_and_it_serves() {
let home = Home::new("serve-torn", 900_000);
copy_case(
&home,
"torn-tail",
&["2026-09-17.jsonl", "2026-09-18.jsonl"],
);
let running = home.serve();
let printed = running.stop();
assert!(printed.contains(RECOVERED_NOTICE), "{printed}");
assert!(RECOVERED_NOTICE.ends_with("see docs/runbook.md#audit-recovered"));
}
#[test]
fn accept_break_with_nothing_to_accept_exits_2() {
let home = Home::new("serve-nothing", 900_000);
let output = home.run(&["--accept-break"]);
assert_eq!(output.status.code(), Some(2));
assert!(
stderr(&output).contains("nothing to accept"),
"{}",
stderr(&output)
);
}
#[test]
fn bad_arguments_and_bad_configs_do_not_start() {
let brokerd = env!("CARGO_BIN_EXE_brokerd");
for args in [
&[][..],
&["serve"][..],
&["serve", "--config"][..],
&["serve", "--config", "a", "--config", "b"][..],
&["serve", "--config", "a", "--loud"][..],
&["run", "--config", "a"][..],
] {
let output = Command::new(brokerd).args(args).output().unwrap();
assert_eq!(output.status.code(), Some(2), "{args:?}");
assert!(
stderr(&output).starts_with("usage: brokerd serve"),
"{args:?}"
);
}
let home = Home::new("serve-config", 900_000);
std::fs::write(&home.config, "[paths]\nhoem = \"/x\"\n").unwrap();
let output = home.run(&[]);
assert_eq!(output.status.code(), Some(1));
assert!(
stderr(&output).contains("brokerd.toml"),
"{}",
stderr(&output)
);
std::fs::remove_file(&home.config).unwrap();
assert_eq!(home.run(&[]).status.code(), Some(1));
assert!(
!home.path("audit").exists(),
"nothing made before the config is read"
);
}
@@ -0,0 +1,269 @@
//! `brokerd serve` answers `memoryd`'s session-state question on its third socket,
//! `run/memory-broker/state.sock` or `[sockets] state` (M5c spec, section 7). Do not edit.
#[path = "support/tmp.rs"]
mod tmp;
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use bxcore::{
DataClass, Envelope, ErrorCode, Message, PROTOCOL_VERSION, SessionId,
SessionState as StateQuestion, SessionStateReply,
};
use tmp::TempDir;
struct Home {
dir: TempDir,
config: PathBuf,
}
impl Home {
/// A home whose config has `sockets` as the body of its `[sockets]` table.
fn new(tag: &str, sockets: &str) -> Home {
let dir = TempDir::new(tag);
std::fs::create_dir_all(dir.path().join("grants")).unwrap();
let text = format!(
"[paths]\nhome = \"{home}\"\ngrants = \"{home}/grants\"\n[sockets]\n{sockets}\n",
home = dir.path().display()
);
let config = dir.write("brokerd.toml", &text);
Home { dir, config }
}
fn path(&self, relative: &str) -> PathBuf {
self.dir.path().join(relative)
}
fn state(&self) -> PathBuf {
self.path("run/memory-broker/state.sock")
}
/// Writes the session's state file, as the ledger would.
fn session(&self, id: &str, text: &str) {
std::fs::create_dir_all(self.path("broker/sessions")).unwrap();
std::fs::write(self.path(&format!("broker/sessions/{id}.json")), text).unwrap();
}
/// Starts `brokerd serve` and waits until the tool socket, the admin socket and `state`
/// accept a connection.
fn serve(&self, state: &Path) -> Running {
let child = Command::new(env!("CARGO_BIN_EXE_brokerd"))
.args(["serve", "--config"])
.arg(&self.config)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let running = Running(Some(child));
let until = Instant::now() + Duration::from_secs(10);
for socket in [
self.path("run/loop-broker/broker.sock"),
self.path("run/owner-broker/admin.sock"),
state.to_path_buf(),
] {
while UnixStream::connect(&socket).is_err() {
assert!(
Instant::now() < until,
"brokerd never listened on {}",
socket.display()
);
std::thread::sleep(Duration::from_millis(20));
}
}
running
}
}
/// Kills the daemon when dropped, and waits for it; `stop` returns what it printed.
struct Running(Option<Child>);
impl Running {
fn stop(mut self) -> String {
let mut child = self.0.take().unwrap();
child.kill().unwrap();
let output = child.wait_with_output().unwrap();
String::from_utf8_lossy(&output.stderr).to_string()
}
}
impl Drop for Running {
fn drop(&mut self) {
if let Some(child) = &mut self.0 {
let _ = child.kill();
let _ = child.wait();
}
}
}
fn mode(path: &Path) -> u32 {
std::fs::metadata(path).unwrap().permissions().mode() & 0o777
}
/// A connection with a ten-second read limit, so a daemon that never answers fails the test.
fn connect(socket: &Path) -> UnixStream {
let stream = UnixStream::connect(socket).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.unwrap();
stream
}
/// Sends one message under `id` and returns the one final frame that answers it.
fn exchange(socket: &Path, id: u64, msg: Message) -> Envelope {
let mut stream = connect(socket);
bxcore::write_frame(
&mut stream,
&Envelope {
v: PROTOCOL_VERSION,
id,
r#final: true,
msg,
},
)
.unwrap();
let env = bxcore::read_frame(&mut stream).unwrap();
assert_eq!((env.id, env.r#final), (id, true), "{env:?}");
env
}
fn question(session: &str) -> Message {
Message::SessionState(StateQuestion {
session: SessionId::new(session).unwrap(),
})
}
fn reply(env: &Envelope) -> SessionStateReply {
match &env.msg {
Message::SessionStateReply(reply) => reply.clone(),
other => panic!("wanted a session_state_reply, got {other:?}"),
}
}
#[test]
fn it_answers_on_its_default_path_from_the_state_files() {
let home = Home::new("serve-state-answers", "");
home.session("s1", "{\"taint\":\"secret\",\"untrusted\":true}\n");
home.session("s2", "{\"taint\":\"private\",\"untrusted\":true}\n");
let running = home.serve(&home.state());
for (id, session, class, untrusted) in [
(1, "s1", DataClass::Secret, true),
(2, "s2", DataClass::Private, true),
(3, "unseen", DataClass::Private, false),
] {
assert_eq!(
reply(&exchange(&home.state(), id, question(session))),
SessionStateReply { class, untrusted },
"{session}"
);
}
assert!(!home.path("broker/sessions/unseen.json").exists());
let printed = running.stop();
assert!(
printed.contains(&format!(
"brokerd: serving session state on {}\n",
home.state().display()
)),
"{printed}"
);
}
/// As for the other two sockets: the directory is made private even when it was there at 0755,
/// the socket is 0600, and a stale socket left by a killed `brokerd` is replaced.
#[test]
fn its_directory_is_0700_its_socket_0600_and_a_stale_one_is_replaced() {
let home = Home::new("serve-state-modes", "");
std::fs::create_dir_all(home.path("run/memory-broker")).unwrap();
std::fs::set_permissions(
home.path("run/memory-broker"),
std::fs::Permissions::from_mode(0o755),
)
.unwrap();
drop(UnixListener::bind(home.state()).unwrap());
assert!(home.state().exists(), "the stale socket file is there");
let _running = home.serve(&home.state());
assert_eq!(mode(&home.path("run/memory-broker")), 0o700);
assert_eq!(mode(&home.state()), 0o600);
assert_eq!(
reply(&exchange(&home.state(), 9, question("s9"))),
SessionStateReply {
class: DataClass::Private,
untrusted: false,
}
);
}
#[test]
fn sockets_state_in_the_config_moves_it() {
let dir = TempDir::new("serve-state-moved-sock");
let moved = dir.path().join("st/state.sock");
let home = Home::new(
"serve-state-moved",
&format!("state = \"{}\"", moved.display()),
);
home.session("s1", "{\"taint\":\"secret\",\"untrusted\":false}\n");
let running = home.serve(&moved);
assert_eq!(
reply(&exchange(&moved, 1, question("s1"))),
SessionStateReply {
class: DataClass::Secret,
untrusted: false,
}
);
assert_eq!(mode(&dir.path().join("st")), 0o700);
assert!(
!home.path("run/memory-broker").exists(),
"nothing at the default"
);
let printed = running.stop();
assert!(
printed.contains(&format!(
"brokerd: serving session state on {}\n",
moved.display()
)),
"{printed}"
);
}
/// A connection that sends nothing holds only its own thread: the next question is answered.
#[test]
fn a_silent_connection_does_not_hold_up_the_next() {
let home = Home::new("serve-state-silent", "");
let _running = home.serve(&home.state());
let _silent = connect(&home.state());
assert_eq!(
reply(&exchange(&home.state(), 2, question("s2"))),
SessionStateReply {
class: DataClass::Private,
untrusted: false,
}
);
}
/// The question belongs on `state.sock` only: the tool and admin sockets refuse it.
#[test]
fn the_other_sockets_refuse_the_question() {
let home = Home::new("serve-state-others", "");
let running = home.serve(&home.state());
for (id, socket) in [
(4, home.path("run/loop-broker/broker.sock")),
(5, home.path("run/owner-broker/admin.sock")),
] {
match exchange(&socket, id, question("s1")).msg {
Message::Error(e) => assert_eq!(e.code, ErrorCode::Forbidden, "{e:?}"),
other => panic!("{other:?}"),
}
}
let printed = running.stop();
assert!(
printed.contains("session_state on broker.sock\nsee docs/runbook.md#socket-forbidden"),
"{printed}"
);
assert!(
printed.contains("session_state on admin.sock\nsee docs/runbook.md#socket-forbidden"),
"{printed}"
);
}
@@ -0,0 +1,371 @@
//! One `state.sock` connection: `memoryd` asks for a session's state, and `brokerd` answers from
//! its state store, changing nothing (M5c spec, section 7). Every error is an error frame, never
//! an answer. Do not edit.
#[path = "support/client.rs"]
mod client;
#[path = "support/rig.rs"]
mod rig;
#[path = "support/runtime.rs"]
mod runtime;
#[path = "support/sink.rs"]
mod sink;
#[path = "support/tmp.rs"]
mod tmp;
use std::io::Write;
use std::os::unix::net::UnixStream;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use brokerd::broker::Broker;
use brokerd::policy::{Label, SessionState};
use brokerd::state_socket;
use bxcore::{
CallId, DataClass, Empty, Envelope, ErrorCode, FrameError, Message, PROTOCOL_VERSION,
SessionId, SessionState as StateQuestion, SessionStateReply, SessionUpdated, ToolRequest,
WireError,
};
use client::{Serve, next, open};
use rig::Rig;
use runtime::Recording;
fn broker(rig: &Rig) -> Arc<Broker> {
rig.broker(&Recording::answering("x"))
}
fn question(session: &str) -> Message {
Message::SessionState(StateQuestion {
session: SessionId::new(session).unwrap(),
})
}
/// Asks once, under `id`, and returns the one frame that comes back.
fn ask(broker: &Arc<Broker>, id: u64, msg: Message) -> Envelope {
let mut stream = open(broker, state_socket::handle, id, msg);
let env = next(&mut stream);
assert_eq!((env.id, env.r#final), (id, true), "{env:?}");
env
}
fn reply(env: &Envelope) -> SessionStateReply {
match &env.msg {
Message::SessionStateReply(reply) => reply.clone(),
other => panic!("wanted a session_state_reply, got {other:?}"),
}
}
fn error(env: &Envelope) -> WireError {
match &env.msg {
Message::Error(e) => e.clone(),
other => panic!("wanted an error, got {other:?}"),
}
}
/// Raises a session's state the way the ledger does, through the store's own `raise`.
fn raise(rig: &Rig, session: &str, class: DataClass, untrusted: bool) {
rig.state()
.raise(
&SessionId::new(session).unwrap(),
SessionState::default(),
Label { class, untrusted },
)
.unwrap();
}
/// Every file under `dir`, with its bytes, sorted; empty when `dir` does not exist.
fn files(dir: &Path) -> Vec<(String, Vec<u8>)> {
let mut all = Vec::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries {
let entry = entry.unwrap();
all.push((
entry.file_name().into_string().unwrap(),
std::fs::read(entry.path()).unwrap_or_default(),
));
}
}
all.sort();
all
}
#[test]
fn the_answer_is_the_stored_class_and_flag() {
let rig = Rig::new("state-sock-stored");
raise(&rig, "a", DataClass::Private, false);
raise(&rig, "b", DataClass::Private, true);
raise(&rig, "c", DataClass::Secret, false);
raise(&rig, "d", DataClass::Secret, true);
// A public result leaves a session at private: a session is never below private.
raise(&rig, "e", DataClass::Public, true);
let broker = broker(&rig);
for (i, (session, class, untrusted)) in [
("a", DataClass::Private, false),
("b", DataClass::Private, true),
("c", DataClass::Secret, false),
("d", DataClass::Secret, true),
("e", DataClass::Private, true),
]
.into_iter()
.enumerate()
{
let id = 40 + i as u64;
assert_eq!(
reply(&ask(&broker, id, question(session))),
SessionStateReply { class, untrusted },
"session {session}"
);
}
assert!(rig.lines.all().is_empty(), "{:?}", rig.lines.all());
}
/// A file written by hand, as the runbook tells the owner to write one, is read the same way.
#[test]
fn a_state_file_the_owner_wrote_is_answered_as_it_says() {
let rig = Rig::new("state-sock-owner");
std::fs::create_dir_all(rig.cfg.state_dir()).unwrap();
std::fs::write(
rig.state_file("s1"),
"{\"taint\":\"secret\",\"untrusted\":true}\n",
)
.unwrap();
let env = ask(&broker(&rig), 7, question("s1"));
assert_eq!(
reply(&env),
SessionStateReply {
class: DataClass::Secret,
untrusted: true,
}
);
}
/// A session `brokerd` has not seen has made no tool call: `private`, trusted (M5c spec, section
/// 7). It is answered without making its file, or the directory.
#[test]
fn a_session_brokerd_has_not_seen_is_private_and_trusted() {
let rig = Rig::new("state-sock-unseen");
let broker = broker(&rig);
let env = ask(&broker, 3, question("never-seen"));
assert_eq!(
reply(&env),
SessionStateReply {
class: DataClass::Private,
untrusted: false,
}
);
assert!(!rig.cfg.state_dir().exists(), "asking makes no directory");
// With the directory there and another session in it, still no file for this one.
raise(&rig, "other", DataClass::Secret, true);
let env = ask(&broker, 4, question("never-seen"));
assert_eq!(
reply(&env),
SessionStateReply {
class: DataClass::Private,
untrusted: false,
}
);
assert!(!rig.state_file("never-seen").exists());
}
#[test]
fn asking_changes_nothing() {
let rig = Rig::new("state-sock-nothing");
raise(&rig, "s1", DataClass::Secret, true);
raise(&rig, "s2", DataClass::Private, false);
let before = files(&rig.cfg.state_dir());
let broker = broker(&rig);
for id in 0..3 {
for session in ["s1", "s2", "s3"] {
ask(&broker, id, question(session));
}
}
assert_eq!(files(&rig.cfg.state_dir()), before);
assert!(
rig.events().is_empty(),
"nothing is recorded for a question"
);
assert!(rig.lines.all().is_empty(), "{:?}", rig.lines.all());
}
/// A state file that cannot be read is an `internal` error with the store's reason and runbook
/// pointer, logged once, never an answer: `memoryd` then leaves the turn undecided.
#[test]
fn a_damaged_state_is_an_error_never_an_answer() {
let cases: [(&str, Option<&str>); 5] = [
("not json", Some("{\"taint\":")),
(
"public",
Some("{\"taint\":\"public\",\"untrusted\":false}\n"),
),
(
"unknown key",
Some("{\"taint\":\"private\",\"untrusted\":false,\"x\":1}\n"),
),
(
"unknown class",
Some("{\"taint\":\"loud\",\"untrusted\":false}\n"),
),
("a directory", None),
];
for (i, (name, text)) in cases.into_iter().enumerate() {
let rig = Rig::new("state-sock-damaged");
std::fs::create_dir_all(rig.cfg.state_dir()).unwrap();
match text {
Some(text) => std::fs::write(rig.state_file("s1"), text).unwrap(),
None => std::fs::create_dir(rig.state_file("s1")).unwrap(),
}
let before = files(&rig.cfg.state_dir());
let env = ask(&broker(&rig), 60 + i as u64, question("s1"));
let e = error(&env);
assert_eq!(e.code, ErrorCode::Internal, "{name}: {e:?}");
assert!(e.detail.contains("s1.json"), "{name}: {}", e.detail);
assert!(
e.detail
.ends_with("see docs/runbook.md#broker-state-damaged"),
"{name}: {}",
e.detail
);
let lines = rig.lines.all();
assert_eq!(lines.len(), 1, "{name}: {lines:?}");
assert!(lines[0].starts_with("brokerd: "), "{name}: {lines:?}");
assert!(lines[0].contains("s1.json"), "{name}: {lines:?}");
assert!(
lines[0].ends_with("see docs/runbook.md#broker-state-damaged"),
"{name}: {lines:?}"
);
assert_eq!(files(&rig.cfg.state_dir()), before, "{name}");
}
}
#[test]
fn every_other_kind_is_forbidden_and_logged() {
let rig = Rig::new("state-sock-forbidden");
raise(&rig, "s1", DataClass::Secret, true);
let broker = broker(&rig);
let session = SessionId::new("s1").unwrap();
let kinds = vec![
Message::ToolRequest(ToolRequest {
session: session.clone(),
call: CallId(1),
tool: "read_file".to_string(),
arguments: "{}".to_string(),
}),
Message::Approvals(Empty {}),
Message::CheckGrants(Empty {}),
Message::Ok(Empty {}),
Message::SessionUpdated(SessionUpdated {
session: session.clone(),
turn: 1,
}),
// An answer sent as a question is not a question.
Message::SessionStateReply(SessionStateReply {
class: DataClass::Private,
untrusted: false,
}),
];
let count = kinds.len();
for (i, msg) in kinds.into_iter().enumerate() {
let id = 100 + i as u64;
let e = error(&ask(&broker, id, msg));
assert_eq!(e.code, ErrorCode::Forbidden, "{e:?}");
assert!(e.detail.ends_with("is not accepted on state.sock"), "{e:?}");
}
let lines = rig.lines.with("on state.sock");
assert_eq!(lines.len(), count, "{:?}", rig.lines.all());
for line in &lines {
assert!(
line.ends_with("\nsee docs/runbook.md#socket-forbidden"),
"{line}"
);
}
assert_eq!(
rig.lines
.with("refused the message kind session_state_reply on state.sock")
.len(),
1
);
assert!(rig.events().is_empty(), "nothing is recorded");
}
/// A frame that is not a well-formed question is answered with the frame error, id 0, as on the
/// other sockets. A session id that is not one (a path, say) is refused when the frame is read.
#[test]
fn a_malformed_frame_is_answered_with_an_error() {
let rig = Rig::new("state-sock-malformed");
raise(&rig, "s1", DataClass::Secret, true);
let broker = broker(&rig);
let body = |session: &str, v: u32, extra: &str| {
format!(
"{{\"v\":{v},\"id\":5,\"final\":true,\"msg\":{{\"kind\":\"session_state\",\
\"body\":{{\"session\":\"{session}\"{extra}}}}}}}"
)
};
let cases = [
("not json", "{{{".to_string(), ErrorCode::BadMessage),
("a path", body("../s1", 1, ""), ErrorCode::BadMessage),
("upper case", body("S1", 1, ""), ErrorCode::BadMessage),
(
"unknown key",
body("s1", 1, ",\"x\":1"),
ErrorCode::BadMessage,
),
("version 2", body("s1", 2, ""), ErrorCode::BadVersion),
];
for (name, text, code) in cases {
let (mut client, server) = UnixStream::pair().unwrap();
let b = Arc::clone(&broker);
std::thread::spawn(move || state_socket::handle(server, &b));
client
.write_all(&(text.len() as u32).to_be_bytes())
.unwrap();
client.write_all(text.as_bytes()).unwrap();
let env = next(&mut client);
assert_eq!((env.id, env.r#final), (0, true), "{name}");
assert_eq!(error(&env).code, code, "{name}");
}
// The well-formed question, for contrast.
let env = ask(&broker, 5, question("s1"));
assert_eq!(
reply(&env),
SessionStateReply {
class: DataClass::Secret,
untrusted: true,
}
);
}
/// One question per connection: after its answer the connection is closed, and a second question
/// on it is never answered.
#[test]
fn a_second_question_on_one_connection_is_not_answered() {
let rig = Rig::new("state-sock-once");
raise(&rig, "s1", DataClass::Secret, true);
let broker = broker(&rig);
let mut stream = open(&broker, state_socket::handle, 1, question("s1"));
assert!(matches!(
next(&mut stream).msg,
Message::SessionStateReply(_)
));
// The handler may have closed its end already, so the second frame may not be written.
let _ = bxcore::write_frame(
&mut stream,
&Envelope {
v: PROTOCOL_VERSION,
id: 2,
r#final: true,
msg: question("s1"),
},
);
// Once the peer has closed, macOS refuses the timeout with EINVAL (22); the read cannot block
// then.
if let Err(e) = stream.set_read_timeout(Some(Duration::from_secs(10))) {
assert_eq!(e.raw_os_error(), Some(22), "set_read_timeout: {e}");
}
match bxcore::read_frame(&mut stream) {
Err(FrameError::Closed) => {}
// Linux resets a Unix socket closed with unread data in it: closed as well.
Err(FrameError::Io(e)) if e.kind() == std::io::ErrorKind::ConnectionReset => {}
other => panic!("wanted the connection closed, got {other:?}"),
}
}