# 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 `/broker/sessions/.json`, one line: ```json {"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.rs` is already there from task 06) - Create: `crates/brokerd/src/state.rs` - Modify: `crates/brokerd/src/lib.rs` (add `pub mod state;`), `docs/implementer-log.md` ## Interfaces `SessionState` and `Label` come from `crate::policy` (task 07). Do not define them again. ```rust 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; // /.json pub fn read(&self, session: &SessionId) -> Result; pub fn raise(&self, session: &SessionId, current: SessionState, label: Label) -> Result; } ``` The file's format is a private struct, so `SessionState` itself stays free of serde: ```rust #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct StateFile { taint: DataClass, untrusted: bool } ``` `StateError`'s `Display`, by hand: `cannot read the session state : ; ` or `cannot write the session state : ; `, and then `RUNBOOK`. **Both messages end with `RUNBOOK`.** Implement `std::error::Error` too. ## `read`: every exit 1. `read_to_string` fails with `ErrorKind::NotFound` → `Ok(SessionState::default())`. This is the **only** case that counts as "no file". Reading creates nothing on disk. 2. `read_to_string` fails any other way (no permission, not UTF-8, it is a directory) → `Err(Unreadable(path, the error's text))`. 3. `serde_json::from_str::` 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. 4. `taint` is `Public` → `Err(Unreadable(path, "a session's taint is never below private"))`. `brokerd` never writes such a file, so someone else did. 5. 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))`: 1. 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 with `recursive`). 2. Serialise `StateFile` with `serde_json::to_string` and add `\n`. Map a serde error with `std::io::Error::other`. 3. Open `.json.tmp` (`path.with_extension("json.tmp")`) with `OpenOptions`: `write`, `create`, `truncate`, and `.mode(0o600)` (`std::os::unix::fs::OpenOptionsExt`). `truncate` is what lets a leftover `.tmp` from a crash be replaced. 4. `write_all`, then `sync_all` on the file. 5. `std::fs::rename` the `.tmp` over `.json`. 6. `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 `.json` is untouched, because nothing wrote to it. ## Steps - [ ] **1. Copy.** `git switch m3a`, then `cp 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.rs`** and add `pub mod state;` to `lib.rs`. Run `cargo 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 `read` has 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 state` reports 9 passed; `make gate` prints `gate: 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.