Task files, the files they copy in (byte-identical to the reference on m3a-ref), each area's check record, and a README with the per-task table of what each check exposed. The handoff note is done with. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5.3 KiB
M3a task 08: session state files
Branch: m3a (run git switch m3a; git status --short must be empty, otherwise stop)
Commit subject: Keep each session's taint and untrusted flag in a file
Goal
brokerd::state keeps what brokerd knows about a session in
<home>/broker/sessions/<id>.json, one line:
{"taint":"private","untrusted":false}
Policy reads it to decide; a result raises it. If brokerd cannot read it, it does not know how
sensitive the session is, so that is an error, never "a new session". Neither value ever goes
down. Nothing here locks: the caller holds the ledger lock around read and raise (task 12).
Files
- Copy:
crates/brokerd/tests/state.rs(support/tmp.rsis already there from task 06) - Create:
crates/brokerd/src/state.rs - Modify:
crates/brokerd/src/lib.rs(addpub mod state;),docs/implementer-log.md
Interfaces
SessionState and Label come from crate::policy (task 07). Do not define them again.
pub const RUNBOOK: &str = "see docs/runbook.md#broker-state-damaged";
#[derive(Debug)]
pub enum StateError {
Unreadable(PathBuf, String), // the file exists and cannot be read, or is not a state
Write(PathBuf, std::io::Error), // the new state could not be put on disk
}
#[derive(Debug, Clone)]
pub struct StateStore { /* dir: PathBuf */ }
impl StateStore {
pub fn new(dir: &Path) -> StateStore; // does not touch the disk
pub fn path(&self, session: &SessionId) -> PathBuf; // <dir>/<id>.json
pub fn read(&self, session: &SessionId) -> Result<SessionState, StateError>;
pub fn raise(&self, session: &SessionId, current: SessionState, label: Label)
-> Result<SessionState, StateError>;
}
The file's format is a private struct, so SessionState itself stays free of serde:
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct StateFile { taint: DataClass, untrusted: bool }
StateError's Display, by hand: cannot read the session state <path>: <detail>; or
cannot write the session state <path>: <error>; , and then RUNBOOK. Both messages end with
RUNBOOK. Implement std::error::Error too.
read: every exit
read_to_stringfails withErrorKind::NotFound→Ok(SessionState::default()). This is the only case that counts as "no file". Reading creates nothing on disk.read_to_stringfails any other way (no permission, not UTF-8, it is a directory) →Err(Unreadable(path, the error's text)).serde_json::from_str::<StateFile>fails (empty, cut short, unknown field, missing field, wrong type, text after the object) →Err(Unreadable(path, the error's text)). A final newline, or none, is fine.taintisPublic→Err(Unreadable(path, "a session's taint is never below private")).brokerdnever writes such a file, so someone else did.- Otherwise
Ok.
raise: every exit
The new state is taint = max(current.taint, label.class, Private) and
untrusted = current.untrusted || label.untrusted. raise trusts current; it does not read the
file again. It always writes, even when nothing changed, so the file exists from the session's
first result on. Write atomically, and map an error from any of these steps to
Err(Write(path of the .json file, error)):
- Create the directory and any missing parents with mode 0700:
std::fs::DirBuilder::new().recursive(true).mode(0o700).create(&dir)(std::os::unix::fs::DirBuilderExt; an existing directory is not an error withrecursive). - Serialise
StateFilewithserde_json::to_stringand add\n. Map a serde error withstd::io::Error::other. - Open
<id>.json.tmp(path.with_extension("json.tmp")) withOpenOptions:write,create,truncate, and.mode(0o600)(std::os::unix::fs::OpenOptionsExt).truncateis what lets a leftover.tmpfrom a crash be replaced. write_all, thensync_allon the file.std::fs::renamethe.tmpover<id>.json.std::fs::File::open(&dir)?.sync_all(), so the rename itself is on disk.
Only after all six return Ok(new state). On an error the old <id>.json is untouched, because
nothing wrote to it.
Steps
- 1. Copy.
git switch m3a, thencp docs/plans/M3a/files/crates/brokerd/tests/state.rs crates/brokerd/tests/ - 2. See the test fail.
cargo test -p brokerd --test state. Expected: it does not compile. - 3. Write
state.rsand addpub mod state;tolib.rs. Runcargo fmt --all. - 4. See the tests pass.
cargo test -p brokerd --test state. Expected:9 passed. Two of the nine print "skipped" and pass if you are root; you should not be. - 5. Walk the exits. Go down the two numbered lists above and point at the line of your
code for each number. Check in particular that
readhas exactly one path that returns the default state. - 6. Run the gate.
make gate. Expected last line:gate: ok. - 7. Log and commit.
git add crates/brokerd docs/implementer-log.md && git commit
Done when
cargo test -p brokerd --test statereports 9 passed;make gateprintsgate: ok.
Stop and report if
- A test wants a damaged or unreadable file to be treated as a fresh session.
- A test wants taint or the untrusted flag to go down.