Plan M4a: gatewayd in 15 tasks, with skeletons and given tests

Each task's tests were run against a reference at its end state; the end states were replayed
from master in order with the gate at each step (650 to 762 tests); each skeleton compiles
against its tests and fails them. The reference is kept off this machine. Lessons T27 (every
wait in a test has a limit) and T28 (mutate the reference before hand-over) come from this work.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 19:05:44 -07:00
co-authored by Claude Opus 5.5
parent f38dc8d474
commit 0339dc13b2
69 changed files with 7790 additions and 3 deletions
+85
View File
@@ -0,0 +1,85 @@
# M4a task 04: secrets, and the runbook entries for `gatewayd`
**Branch:** `m4a` (run `git switch m4a`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `gatewayd: secrets from a credential, the environment or a file; runbook entries`
## Goal
`gatewayd`'s one secret is the Mattermost token. It can come from three places, chosen per secret
in `gatewayd.toml` (spec section 4, P15 in the brief):
| Form | Reads | Refused when |
|---|---|---|
| `credential = "<name>"` | `$CREDENTIALS_DIRECTORY/<name>`, which systemd fills for a service with `LoadCredentialEncrypted=` | the variable is unset, or the file cannot be read |
| `env = "<VAR>"` | the environment variable | unset, or not UTF-8 |
| `file = "<absolute path>"` | the file | not absolute; a symbolic link; not a regular file; not owned by the user `gatewayd` runs as; any group or other permission bit set |
In every form one trailing newline is removed, and an empty value is refused. A secret read from a
**file** is in plaintext on disk: `gatewayd` still starts, but `load` returns this warning for it,
exactly (`<name>` and `<path>` filled in, `path.display()`):
```text
gatewayd: warning: secret <name> is read in plaintext from <path>; a systemd credential keeps it encrypted at rest (see docs/runbook.md#secret-in-a-file)
```
The value must never reach a log: `Secret` has no `Display`, its `Debug` prints `Secret(…)`, it is
wiped when dropped (`zeroize::Zeroizing`), and `expose()` is the only way to the text.
This task also adds the runbook entries for every fail-closed state of `gatewayd`, all seven at
once, so that later tasks can point at them.
## Files
- Copy: `crates/gatewayd/tests/secrets.rs`, and the skeleton `crates/gatewayd/src/secrets.rs`
- Append: `docs/plans/M4a/files/runbook-gatewayd.md` to the end of `docs/runbook.md`
- Modify: `crates/gatewayd/src/lib.rs` (`pub mod secrets;`), `docs/implementer-log.md`
## The skeleton
Written: `Secret` (with `new`, `expose` and its `Debug`), `Loaded { secret, warning }`,
`SecretError { name, why }` and its `Display` (`secret <name>: <why>` then a newline and
`see docs/runbook.md#secret-unavailable`), and the constants `RUNBOOK` and `RUNBOOK_FILE`. To fill:
`load`, `check_file` and `value`.
`load` takes the environment as a function, `env: &dyn Fn(&str) -> Option<OsString>`, so that tests
can pass their own: read `CREDENTIALS_DIRECTORY` and `env` variables through it, never through
`std::env`. The uid of the user `gatewayd` runs as is the owner of `/proc/self`
(`std::fs::metadata("/proc/self")?.uid()`, from `std::os::unix::fs::MetadataExt`); no `unsafe`, no
`libc`.
The `why` of each error, for the reader: say what is wrong and never the value. The reference used
these, and you may too:
| Case | `why` |
|---|---|
| credential, variable unset | `CREDENTIALS_DIRECTORY is not set: gatewayd was not started by systemd with LoadCredentialEncrypted=` |
| credential, unreadable | `cannot read the credential <path>: <error>` |
| env unset | `the environment variable <VAR> is not set` |
| env not UTF-8 | `the environment variable <VAR> is not UTF-8` |
| file checks | `<path> is not an absolute path`, `cannot read <path>: <error>`, `<path> is a symbolic link`, `<path> is not a regular file`, `<path> is not owned by the user gatewayd runs as`, `<path> has mode <mode as {:03o}>; only the owner may read it (0600 or 0400)` |
| the value | `the value is not UTF-8`, `the value is empty` |
## Steps
- [ ] **1. Copy.** `git switch m4a`, then
`cp docs/plans/M4a/files/crates/gatewayd/tests/secrets.rs crates/gatewayd/tests/ && cp docs/plans/M4a/files/crates/gatewayd/src/secrets.rs crates/gatewayd/src/`
and `cat docs/plans/M4a/files/runbook-gatewayd.md >> docs/runbook.md`. Add `pub mod secrets;` to
`crates/gatewayd/src/lib.rs`.
- [ ] **2. See it fail.** `cargo test -p gatewayd --test secrets`. Expected: it compiles and 8
tests fail.
- [ ] **3. Fill `value`, then `check_file`, then `load`.** `cargo check -p gatewayd` after each.
- [ ] **4. See it pass.** `cargo test -p gatewayd --test secrets`. Expected: 8 passed.
- [ ] **5. Check the runbook.** `grep -c '^## ' docs/runbook.md` is 7 more than before step 1, and
`sh scripts/check-runbook.sh` prints nothing and exits 0.
- [ ] **6. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
- [ ] **7. Log and commit.**
`git add crates/gatewayd docs/runbook.md docs/implementer-log.md Cargo.lock && git commit`
## Done when
- `cargo test -p gatewayd --test secrets` passes; the seven entries are in `docs/runbook.md`;
`make gate` prints `gate: ok`.
## Stop and report if
- A test needs the secret's value in an error message, or `unsafe` looks necessary.