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>
120 lines
5.3 KiB
Markdown
120 lines
5.3 KiB
Markdown
# 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:
|
|
|
|
```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; // <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:
|
|
|
|
```rust
|
|
#[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
|
|
|
|
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::<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.
|
|
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 `<id>.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 `<id>.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 `<id>.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.
|