Hand over the M3a plan: 22 tasks, their files, and the check record
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>
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user