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
+3
View File
@@ -62,6 +62,9 @@ How it is used:
| T25 | Size a task by the largest function the model must hold in one turn, not by the task. Ornith writes one function with a few branches well; a function with half a dozen branches and threads (M3b's `run`) it plans in its head until the turn runs out, with nothing written. Give such a task a compiling skeleton with the big function already written as glue over small `todo!()` helpers, and say to fill one at a time with `cargo check` between. | M3b task 11: four sessions. Two wrote nothing; a whole-file skeleton got five of six functions; the finer skeleton finished it in ten minutes, and tasks 12 and 13 followed without a stop. |
| T26 | Replay each task's end state on its own, **and** read the task file against the reference for anything the reference has that the task does not ask for. The replay proves the tests can pass; only the reading finds a field the reference reads through a getter the task never mentions. | M3b task 11: the task stored `egress_dir` without the reference's getter, so the field was never read and clippy failed; the replay passed because the reference had the getter. |
| T27 | Every wait in a given test has a limit (`recv_timeout`, a deadline loop, `is_finished` before `join`). A test that waits forever on a broken implementation hangs the driver instead of failing, and the implementer cannot tell a hang from slowness. | M4a planning: breaking the reference's ping made `ws_conn` hang on `rx.recv()`; retrying a refused token made the serve test hang on `join()`. Both now fail within 5 s. |
| T28 | Before hand-over, break the reference on purpose, one line at a time (drop a check, move a bound by one, skip a save), and run the given tests against each change. A change the tests still pass is a missing test, unless it cannot change behaviour. Rustfmt reflows lines: match on text that survives formatting. | M4a planning: 69 changes over six modules; the tests missed 8. Five were real gaps and got tests (a reconnect that re-sent "interrupted" for a running turn was one), two could not change behaviour, and one was left (an event frame with another id, which `loopd` never sends). |
## What worked and should be kept
- Byte-exact fixtures, compared in both directions. No wire-format defect reached review.
+60
View File
@@ -0,0 +1,60 @@
# M4a task 01: SHA-1 in `proto`
**Branch:** `m4a` (run `git switch m4a`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `proto: sha1, for the WebSocket handshake check`
## Goal
`gatewayd` speaks WebSocket to Mattermost. The server proves it understood the handshake by
sending back the SHA-1 of our key (RFC 6455, section 4.2.2), so we need SHA-1. It lives in
`proto`, which has no dependencies of its own. It is used for that check and nothing else: it must
never protect anything.
## Files
- Copy: `crates/proto/tests/sha1.rs`, and the skeleton `crates/proto/src/sha1.rs`
- Modify: `crates/proto/src/lib.rs` (`pub mod sha1;`), `docs/implementer-log.md`
## Interface (in the skeleton)
```rust
pub fn sha1(data: &[u8]) -> [u8; 20]; // written: new, update, finish
pub struct Sha1 { state: [u32; 5], block: [u8; 64], filled: usize, length: u64 }
impl Sha1 {
pub fn new() -> Sha1; // written: the five initial words
pub fn update(&mut self, data: &[u8]); // todo
pub fn finish(self) -> [u8; 20]; // todo
fn compress(&mut self, block: &[u8; 64]); // todo
}
```
`length` counts **bits** fed so far. Each `todo!()` has its steps above it. No indexing that can go
out of bounds: `w[i]` on the fixed `[u32; 80]` inside `compress` is fine; slicing `data` or `block`
by a computed range is not (use `get`, `get_mut`, `split_at`). Use `as_chunks::<4>()` to read
big-endian words.
## Steps
- [ ] **1. Copy.** `git switch m4a`, then
`cp docs/plans/M4a/files/crates/proto/tests/sha1.rs crates/proto/tests/` and
`cp docs/plans/M4a/files/crates/proto/src/sha1.rs crates/proto/src/`. Add `pub mod sha1;` to
`crates/proto/src/lib.rs`.
- [ ] **2. See it fail.** `cargo test -p proto --test sha1`. Expected: it compiles, and 4 tests
fail with `not yet implemented`.
- [ ] **3. Fill the three functions**, one at a time: `compress`, then `update`, then `finish`. Run
`cargo check -p proto` after each.
- [ ] **4. See it pass.** `cargo test -p proto --test sha1`. Expected: 4 passed. The tests include
FIPS 180 and RFC 3174 vectors, a million `a`s, the lengths around the 64-byte block where the
padding changes shape (checked with `sha1sum`), and the RFC 6455 handshake example; every case is
also fed in pieces.
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
- [ ] **6. Log and commit.** `git add crates/proto docs/implementer-log.md Cargo.lock && git commit`
## Done when
- `cargo test -p proto --test sha1` passes; `make gate` prints `gate: ok`.
## Stop and report if
- A test vector fails and you cannot find why after checking `compress` against the comment twice.
+86
View File
@@ -0,0 +1,86 @@
# M4a task 02: `gatewayd`'s dependencies and test certificates
**Branch:** `m4a` (run `git switch m4a`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `gatewayd: dependencies for TLS and secrets; test-only certificates`
## Goal
`gatewayd` talks to Mattermost over TLS when the URL is `https` (the owner's decision: the tailnet
serves it that way). This task adds the dependencies it needs and nothing that uses them yet, so
that the next tasks can. It also copies in the test-only certificates the TLS tests use.
## Files
- Copy: `deny.toml` (replaces the old one), `crates/gatewayd/tests/fixtures/tls/` (the whole
directory: 9 files)
- Modify: `Cargo.toml`, `crates/gatewayd/Cargo.toml`, `crates/gatewayd/src/lib.rs`,
`docs/dependencies.md`, `docs/implementer-log.md`, `Cargo.lock`
## The changes, exactly
**`Cargo.toml`**, at the end of `[workspace.dependencies]`, after `emsha`:
```toml
rustls = { version = "0.23.45", default-features = false, features = ["ring", "std", "tls12"] }
rustls-native-certs = "0.8.4"
zeroize = "1.9.0"
```
**`crates/gatewayd/Cargo.toml`**, `[dependencies]` becomes:
```toml
[dependencies]
proto.workspace = true
rustls.workspace = true
rustls-native-certs.workspace = true
serde.workspace = true
serde_json.workspace = true
toml.workspace = true
zeroize.workspace = true
```
**`crates/gatewayd/src/lib.rs`**: replace its one comment line with
```rust
//! `gatewayd`: the Mattermost channel. It carries the owner's messages to `loopd` as turns and posts
//! the answers back, over outbound connections only. M4a spec: `docs/specs/2026-09-23-m4a-gateway.md`.
```
**`deny.toml`**: copied, not edited. It allows the licences ISC and BSD-3-Clause (owner's approval,
2026-09-23), and judges dependencies only for Linux and the Mac, since `rustls` pulls in a second
`windows-sys` for Windows that we never build.
**`docs/dependencies.md`**: in the table, the rows for `serde` and `serde_json` add `` `gatewayd` ``
to "Used by", the `toml` row adds `` `gatewayd` from M4a ``, and three rows are added at the end:
```markdown
| `rustls` | 0.23.45 | `gatewayd` | TLS to Mattermost over the tailnet (M4a, owner's decision). `default-features = false` with `ring`, `std`, `tls12`. With its dependencies on Linux: `ring` (Apache-2.0 AND ISC; builds C and assembly), `rustls-webpki` and `untrusted` (ISC), `rustls-pki-types`, `subtle` (BSD-3-Clause), `zeroize`, `once_cell`, `getrandom`, `libc`, `cfg-if`. Apache-2.0 OR ISC OR MIT. |
| `rustls-native-certs` | 0.8.4 | `gatewayd` | The host's trusted certificates, so a CA installed on the host is trusted too; adds `openssl-probe`. Apache-2.0 OR ISC OR MIT. |
| `zeroize` | 1.9.0 | `gatewayd` | Wipes a secret's memory when it is dropped (`Secret`). Already a dependency of `rustls`. Apache-2.0 OR MIT. |
```
The fixtures' `README.md` says what each certificate is. Every key in that directory is public and
for these tests only.
## Steps
- [ ] **1. Copy.** `git switch m4a`, then `cp docs/plans/M4a/files/deny.toml deny.toml` and
`mkdir -p crates/gatewayd/tests/fixtures && cp -r docs/plans/M4a/files/crates/gatewayd/tests/fixtures/tls crates/gatewayd/tests/fixtures/`
- [ ] **2. Make the four edits above.**
- [ ] **3. Build once.** `cargo build -p gatewayd`, so that `Cargo.lock` is updated. Expected: it
builds (the crates are already in the local cache; if cargo says it needs the network and cannot
reach it, stop and report).
- [ ] **4. Check.** `git status --short` lists `Cargo.lock`, `Cargo.toml`, `deny.toml`, the two
`gatewayd` files, `docs/dependencies.md` and the fixtures directory, and nothing else.
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
`cargo-deny` must pass: if it reports a licence or a duplicate, stop and report it word for word.
- [ ] **6. Log and commit.**
`git add Cargo.toml Cargo.lock deny.toml crates/gatewayd docs/dependencies.md docs/implementer-log.md && git commit`
## Done when
- `make gate` prints `gate: ok` with the new dependencies in `Cargo.lock`.
## Stop and report if
- `cargo-deny` fails, or cargo wants a version other than the ones above.
+95
View File
@@ -0,0 +1,95 @@
# M4a task 03: `gatewayd.toml`
**Branch:** `m4a` (run `git switch m4a`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `gatewayd: config, gatewayd.toml into a checked Config`
## Goal
`gatewayd.toml` says where Mattermost is, where the token comes from, who may talk to Boxmaker and
in which channels, and a few limits. It is **our** format: an unknown key anywhere is an error, and
a value that is wrong is refused at start with a message that names it. Spec section 3.
```toml
[mattermost]
url = "https://straylight.scylla-hammerhead.ts.net" # https or http; no path
ca_file = "/etc/boxmaker/extra-ca.pem" # optional; added to the system's roots
[secrets.mattermost_token]
credential = "mattermost-token" # exactly one of: credential, env, file
[allow]
users = ["abcdefghijklmnopqrstuvwxyz"] # Mattermost user ids (26 characters)
channels = [] # channels and group messages allowed, by id; default none
[loop]
socket = "" # empty: <home>/run/loop/loop.sock
[paths]
home = "/var/lib/boxmaker" # default: BOXMAKER_HOME, then /var/lib/boxmaker
[limits]
queue = 20
typing_every_ms = 3000
ping_every_ms = 30000
dead_after_ms = 60000
```
## Files
- Copy: `crates/gatewayd/tests/config.rs`, `crates/gatewayd/tests/support/tmp.rs`, and the
skeleton `crates/gatewayd/src/config.rs`
- Modify: `crates/gatewayd/src/lib.rs` (`pub mod config;`), `docs/implementer-log.md`
## The skeleton
Every type is written, with its serde attributes: `Config`, `MattermostConfig`, `SecretSpec`,
`SecretSource`, `AllowConfig`, `LoopConfig`, `Paths`, `Limits` (with their defaults), `ServerUrl`,
`ConfigError`, and the constant `MATTERMOST_TOKEN = "mattermost_token"`. Do not change them. The
functions are `todo!()`, each with its steps above it.
## The messages, exactly
`Config::problem` returns the first problem, in this order:
| Problem | Message |
|---|---|
| `url` fails `parse_url` | `parse_url`'s message: `[mattermost] url "<url>" must be http:// or https://, a host, an optional port, and nothing else` (the url with `{:?}`) |
| `ca_file` not absolute | `[mattermost] ca_file "<path>" must be an absolute path` (`{:?}`) |
| no `[secrets.mattermost_token]` | `[secrets.mattermost_token] is missing` |
| a secret whose `source()` fails | `[secrets.<name>] <source's message>` |
| `allow.users` empty | `[allow] users is empty: a gateway that answers nobody is a mistake` |
| an id in `allow.users` or `allow.channels` that is not `valid_id` | `[allow] "<id>" is not a Mattermost id (26 characters of a-z and 0-9)` (`{:?}`) |
| a limit that is 0 (checked in the order queue, typing_every_ms, ping_every_ms, dead_after_ms) | `[limits] <name> must be at least 1` |
`SecretSpec::source`'s messages:
| Case | Message |
|---|---|
| not exactly one of the three | `needs exactly one of credential, env and file` |
| `credential` empty or with a byte that is not an ASCII letter, digit, `_`, `.` or `-` | `credential "<name>" is not a credential name (letters, digits, _ . -)` |
| `env` empty or with a byte that is not `A-Z`, `0-9` or `_` | `env "<var>" is not a variable name (A-Z, 0-9, _)` |
| `file` not absolute | `file "<path>" must be an absolute path` |
`Config::token_source` gives `source()` of `mattermost_token`, or `[secrets.mattermost_token] is
missing`. `ConfigError`'s `Display` is `<path>: <error or message>`.
## Steps
- [ ] **1. Copy.** `git switch m4a`, then
`mkdir -p crates/gatewayd/tests/support && cp docs/plans/M4a/files/crates/gatewayd/tests/config.rs crates/gatewayd/tests/ && cp docs/plans/M4a/files/crates/gatewayd/tests/support/tmp.rs crates/gatewayd/tests/support/ && cp docs/plans/M4a/files/crates/gatewayd/src/config.rs crates/gatewayd/src/`.
Add `pub mod config;` to `crates/gatewayd/src/lib.rs`.
- [ ] **2. See it fail.** `cargo test -p gatewayd --test config`. Expected: it compiles and 7 tests
fail with `not yet implemented`.
- [ ] **3. Fill the functions one at a time**: `valid_id`, `parse_url`, `SecretSpec::source`,
`ConfigError`'s `fmt`, then the `Config` methods. `cargo check -p gatewayd` after each.
- [ ] **4. See it pass.** `cargo test -p gatewayd --test config`. Expected: 7 passed.
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
- [ ] **6. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
## Done when
- `cargo test -p gatewayd --test config` passes; `make gate` prints `gate: ok`.
## Stop and report if
- A test wants a type changed. The types are the format; report instead.
+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.
+63
View File
@@ -0,0 +1,63 @@
# M4a task 05: a connection, plain or TLS
**Branch:** `m4a` (run `git switch m4a`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `gatewayd: net, TCP or verified TLS to the Mattermost server`
## Goal
Every REST call and the WebSocket run over one kind of stream: plain TCP for an `http` URL, or TCP
wrapped in TLS through `rustls` for `https`. TLS verifies the server's name against the host in the
URL and its chain against the host's trusted certificates (`rustls-native-certs`) plus `ca_file` if
given. **Nothing can turn verification off.** Spec section 5.
## Files
- Copy: `crates/gatewayd/tests/net.rs`, `crates/gatewayd/tests/support/tls_server.rs`, and the
skeleton `crates/gatewayd/src/net.rs`
- Modify: `crates/gatewayd/src/lib.rs` (`pub mod net;`), `crates/gatewayd/Cargo.toml` (below),
`docs/implementer-log.md`
The tests' TLS server needs `rustls` too. Add to the end of `crates/gatewayd/Cargo.toml`:
```toml
[dev-dependencies]
rustls.workspace = true
```
## The skeleton
Written, because they are mostly `rustls` API (checked against rustls 0.23.45):
`client_config(ca_file)`, which builds the root store and the `ClientConfig` with the `ring`
provider, and `Connector::connect`, which connects to each address in turn, sets the read and
write timeouts, and for TLS runs the handshake to its end so that a bad certificate is an error
from `connect` and not from a later read. Read both before you start.
Also written: `NetError` (`Roots`, `Connect`, `Tls`) and its `Display`, and the types `Stream`
(`Plain(TcpStream)` or `Tls(Box<StreamOwned<ClientConnection, TcpStream>>)`) and `Connector`.
To fill: `Stream::tcp`, `Stream::set_read_timeout`, `Read` and `Write` for `Stream` (forward to
the inner stream in each variant), `Connector::new` and `Connector::server`.
## Steps
- [ ] **1. Copy.** `git switch m4a`, then
`cp docs/plans/M4a/files/crates/gatewayd/tests/net.rs crates/gatewayd/tests/ && cp docs/plans/M4a/files/crates/gatewayd/tests/support/tls_server.rs crates/gatewayd/tests/support/ && cp docs/plans/M4a/files/crates/gatewayd/src/net.rs crates/gatewayd/src/`.
Add `pub mod net;` to `lib.rs` and the `[dev-dependencies]` above.
- [ ] **2. See it fail.** `cargo test -p gatewayd --test net`. Expected: it compiles and 7 tests
fail.
- [ ] **3. Fill the functions**, `cargo check -p gatewayd` after each.
- [ ] **4. See it pass.** `cargo test -p gatewayd --test net`. Expected: 7 passed: plain TCP; TLS
with the test CA; an unknown CA and a wrong name refused at connect; TLS to a plain server fails
without hanging; a bad `ca_file` refused before any connection; nobody listening.
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
- [ ] **6. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
## Done when
- `cargo test -p gatewayd --test net` passes; `make gate` prints `gate: ok`.
## Stop and report if
- A test passes only with verification weakened in any way (a custom verifier, a skipped name
check). That is never the fix.
+57
View File
@@ -0,0 +1,57 @@
# M4a task 06: HTTP/1.1 requests
**Branch:** `m4a` (run `git switch m4a`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `gatewayd: http, requests over a stream with size caps and rate-limit waits`
## Goal
Mattermost's REST calls are HTTP/1.1, one connection per request (`Connection: close`), over a
`net::Stream`. This module writes a request and reads the response. The server is not trusted: a
response head over 16 KiB or a body over 4 MiB is an error, and every limit is checked **before**
reading or allocating. The WebSocket handshake (task 07) reuses `read_head`, which therefore must
not read a byte past the blank line that ends the head: the WebSocket's first frame may follow at
once.
## Files
- Copy: `crates/gatewayd/tests/http.rs`, and the skeleton `crates/gatewayd/src/http.rs`
- Modify: `crates/gatewayd/src/lib.rs` (`pub mod http;`), `docs/implementer-log.md`
## The skeleton
Written: the constants `MAX_HEAD` (16 KiB), `MAX_BODY` (4 MiB) and `MAX_RATE_WAIT` (60 s);
`HttpError` (`Io`, `Protocol(String)`, `TooLarge(&'static str)`) with `Display` and
`From<std::io::Error>`; `Head { status, headers }` and `Response { head, body }`.
To fill, each with its steps above it: `Head::header`, `write_request`, `request`, `read_head`,
`read_body`, `read_line`, `read_chunked`, `rate_limit_wait`.
Rules that apply to all of them:
- `read_head` reads **one byte at a time**. That is slow and it is right: it must stop exactly at
the end of the head.
- A retry on `ErrorKind::Interrupted`, and nowhere else.
- No subtraction that can wrap and no indexing that can go out of bounds: `saturating_sub`, `get`,
`get_mut`. `raw.truncate(raw.len() - 2)` is fine only right after checking `raw.ends_with(b"\r\n")`.
- `rate_limit_wait` is about Mattermost's `X-Ratelimit-Reset`, which is a Unix time in seconds; the
test also gives it a small number of seconds, which some servers send.
## Steps
- [ ] **1. Copy.** `git switch m4a`, then
`cp docs/plans/M4a/files/crates/gatewayd/tests/http.rs crates/gatewayd/tests/ && cp docs/plans/M4a/files/crates/gatewayd/src/http.rs crates/gatewayd/src/`.
Add `pub mod http;` to `lib.rs`.
- [ ] **2. See it fail.** `cargo test -p gatewayd --test http`. Expected: it compiles and 6 tests
fail.
- [ ] **3. Fill the functions in the order above**, `cargo check -p gatewayd` after each.
- [ ] **4. See it pass.** `cargo test -p gatewayd --test http`. Expected: 6 passed.
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
- [ ] **6. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
## Done when
- `cargo test -p gatewayd --test http` passes; `make gate` prints `gate: ok`.
## Stop and report if
- A test needs `read_head` to read ahead into a buffer.
@@ -0,0 +1,63 @@
# M4a task 07: the WebSocket handshake
**Branch:** `m4a` (run `git switch m4a`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `gatewayd: ws, the WebSocket error type and the opening handshake`
## Goal
Mattermost pushes events over a WebSocket at `/api/v4/websocket` (RFC 6455). Our client is our own:
blocking, one connection, tested against hostile input. This task is its first part: the error
type, base64, and the opening handshake (RFC 6455, section 4.1; spec section 6).
The request, exactly (it is written for you in `request_text`):
```text
GET <path> HTTP/1.1\r\n
Host: <host>\r\n
Upgrade: websocket\r\n
Connection: Upgrade\r\n
Sec-WebSocket-Key: <16 random bytes in base64>\r\n
Sec-WebSocket-Version: 13\r\n
Authorization: Bearer <token>\r\n
\r\n
```
The answer must be status 101, with `Upgrade: websocket`, a `Connection` header holding the token
`upgrade`, and `Sec-WebSocket-Accept` equal to base64(SHA-1(key + the GUID
`258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)). Anything else is an error. SHA-1 is `proto::sha1::sha1`
(task 01); the head is read with `http::read_head` (task 06), which stops at the blank line.
## Files
- Copy: `crates/gatewayd/tests/ws_handshake.rs`, and `crates/gatewayd/src/ws/mod.rs` (complete)
and the skeleton `crates/gatewayd/src/ws/handshake.rs`
- Modify: `crates/gatewayd/src/lib.rs` (`pub mod ws;`), `docs/implementer-log.md`
`ws/mod.rs` is complete: `WsError` (`Handshake`, `Protocol`, `TooLarge`, `Closed`, `Dead`, `Io`)
with `Display` and `From<std::io::Error>`, and `pub mod handshake;`. Tasks 08 and 09 add their
`pub mod` lines to it.
## The skeleton
Written: `GUID`, `ALPHABET`, `request_text`. To fill: `base64`, `accept_for`, `new_key`,
`check_response`, `handshake`. The key's 16 bytes come from `random: &mut dyn Read` (in `gatewayd`,
`/dev/urandom`; in the tests, fixed bytes), so the tests know the key.
## Steps
- [ ] **1. Copy.** `git switch m4a`, then
`mkdir -p crates/gatewayd/src/ws && cp docs/plans/M4a/files/crates/gatewayd/src/ws/mod.rs docs/plans/M4a/files/crates/gatewayd/src/ws/handshake.rs crates/gatewayd/src/ws/ && cp docs/plans/M4a/files/crates/gatewayd/tests/ws_handshake.rs crates/gatewayd/tests/`.
Add `pub mod ws;` to `lib.rs`.
- [ ] **2. See it fail.** `cargo test -p gatewayd --test ws_handshake`. Expected: it compiles; 6
fail and 1 passes (`the_request_is_exactly_this`, since `request_text` is given).
- [ ] **3. Fill `base64`, `accept_for`, `new_key`, `check_response`, `handshake`**, in that order,
`cargo check -p gatewayd` after each.
- [ ] **4. See it pass.** `cargo test -p gatewayd --test ws_handshake`. Expected: 7 passed,
including the RFC 6455 example (`dGhlIHNhbXBsZSBub25jZQ==` gives `s3pPLMBiTxaQ9kYGzzhZRbK+xOo=`)
and a handshake whose first frame arrives in the same packet as the head and is left unread.
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
- [ ] **6. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
## Done when
- `cargo test -p gatewayd --test ws_handshake` passes; `make gate` prints `gate: ok`.
+73
View File
@@ -0,0 +1,73 @@
# M4a task 08: WebSocket frames
**Branch:** `m4a` (run `git switch m4a`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `gatewayd: ws frames, a strict decoder and a masked encoder`
## Goal
After the handshake, everything is frames (RFC 6455, section 5). This task is the codec, without
I/O: a `Decoder` that is fed bytes as they arrive, in any pieces, and yields whole messages, and
`encode`, which builds our frames. Everything the server sends is untrusted, so the decoder is
strict and **checks every length before it allocates** (spec section 6):
- A frame from the server that is masked, sets a reserved bit, uses an opcode other than
continuation (0), text (1), close (8), ping (9) or pong (10), is a control frame (8 to 10) over
125 bytes or not final, is a continuation with nothing to continue, or a new text frame while a
message is unfinished, is a `Protocol` error.
- A length not in its shortest form (126 for a length under 126; 127 for one that fits in 16
bits), or a 64-bit length with its top bit set, is a `Protocol` error.
- A message over `MAX_MESSAGE` (1 MiB), counted from the length fields of its frames **before**
their payloads arrive, is `TooLarge`.
- Text must be UTF-8 once whole (a character may be split across frames).
- A close frame's payload is empty, or a 2-byte code and a UTF-8 reason; one byte is an error.
Every frame **we** send is final, masked with the 4 bytes we are given.
## Files
- Copy: `crates/gatewayd/tests/ws_frame.rs`, and the skeleton `crates/gatewayd/src/ws/frame.rs`
- Modify: `crates/gatewayd/src/ws/mod.rs` (`pub mod frame;`), `docs/implementer-log.md`
## The skeleton
Written: `MAX_MESSAGE`, the opcode constants, `Incoming` (`Text`, `Ping`, `Pong`,
`Close(Option<u16>, String)`), `Decoder { buf, partial }` with `new` and `feed`, and the private
`Header { fin, opcode, header_len, payload_len }`.
To fill, in this order, each with its steps above it:
1. `Decoder::header(&self) -> Result<Option<Header>, WsError>`: the next frame's header once all of
it is in `buf`, checked against every rule above that does not need the payload. `Ok(None)`
means "wait for more bytes". It only reads `buf`; it removes nothing.
2. `close(payload) -> Result<Incoming, WsError>`.
3. `Decoder::next_message(&mut self)`: uses `header`, and when the whole frame is in `buf`, takes it
out (`drain`) and acts on it. Control frames may come between the frames of a text message and
are returned at once.
4. `encode(opcode, payload, mask) -> Vec<u8>`.
No indexing that can go out of bounds, no `as` casts: read lengths with `get(2..4)` and
`u16::from_be_bytes`, and convert with `usize::try_from` / `u8::try_from`.
## Steps
- [ ] **1. Copy.** `git switch m4a`, then
`cp docs/plans/M4a/files/crates/gatewayd/tests/ws_frame.rs crates/gatewayd/tests/ && cp docs/plans/M4a/files/crates/gatewayd/src/ws/frame.rs crates/gatewayd/src/ws/`.
Add `pub mod frame;` to `crates/gatewayd/src/ws/mod.rs`.
- [ ] **2. See it fail.** `cargo test -p gatewayd --test ws_frame`. Expected: it compiles and 7
tests fail.
- [ ] **3. Fill the four functions**, `cargo check -p gatewayd` after each.
- [ ] **4. See it pass.** `cargo test -p gatewayd --test ws_frame`. Expected: 7 passed. The last
test feeds 300 random streams, half of them with bits flipped, in random pieces, and compares
your decoder with a simple one written inside the test: they must return the same messages, and
both fail or both not.
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
- [ ] **6. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
## Done when
- `cargo test -p gatewayd --test ws_frame` passes; `make gate` prints `gate: ok`.
## Stop and report if
- The random-stream test fails on a case you believe the naive decoder gets wrong. Report the seed
and the bytes; do not change the test.
+71
View File
@@ -0,0 +1,71 @@
# M4a task 09: the WebSocket connection
**Branch:** `m4a` (run `git switch m4a`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `gatewayd: ws conn, messages, pings, closing and a dead peer`
## Goal
The connection that uses tasks 07 and 08: open it, send text, and wait for the next message while
keeping the connection alive (spec section 6):
- A ping from the server is answered with a pong carrying the same payload.
- We send a ping every `ping_every`. No bytes at all for `dead_after` means the peer is dead.
- A close frame is answered with a close frame, and the connection ends (`Closed`); so does the end
of the stream.
- Any protocol error ends the connection. `gatewayd` then reconnects (task 14); nothing panics.
## Files
- Copy: `crates/gatewayd/tests/ws_conn.rs`, `crates/gatewayd/tests/support/ws_server.rs`, and the
skeleton `crates/gatewayd/src/ws/conn.rs`
- Modify: `crates/gatewayd/src/ws/mod.rs` (`pub mod conn;`), `docs/implementer-log.md`
## The skeleton
```rust
pub const PATH: &str = "/api/v4/websocket";
pub struct Timing { pub ping_every: Duration, pub dead_after: Duration } // Clone, Copy
pub struct Ws { stream, decoder, random, timing, last_heard, last_ping }
impl Ws {
pub fn open(connector: &Connector, token: &str, timing: Timing,
mut random: Box<dyn Read + Send>) -> Result<Ws, WsError>;
fn send(&mut self, opcode: u8, payload: &[u8]) -> Result<(), WsError>;
pub fn send_text(&mut self, text: &str) -> Result<(), WsError>;
pub fn poll(&mut self, wait: Duration) -> Result<Option<String>, WsError>;
pub fn close(mut self);
}
pub fn host_header(server: &ServerUrl) -> String;
```
All are `todo!()`, each with its steps above it. `poll` is the one with the most in it: it returns
the next text message, or `None` after about `wait` with none, and does the pinging, the pong
answers and the dead-peer check on the way. Its read timeout is always the time to the **next
thing it must do** (the end of `wait`, the next ping, or the dead-after limit), so a quiet
connection neither spins nor oversleeps. Write it as its comment says, step by step; a helper
function for step 1 is fine.
`random` gives the handshake key and a fresh 4-byte mask for every frame we send (in `gatewayd`,
`/dev/urandom`).
## Steps
- [ ] **1. Copy.** `git switch m4a`, then
`cp docs/plans/M4a/files/crates/gatewayd/tests/ws_conn.rs crates/gatewayd/tests/ && cp docs/plans/M4a/files/crates/gatewayd/tests/support/ws_server.rs crates/gatewayd/tests/support/ && cp docs/plans/M4a/files/crates/gatewayd/src/ws/conn.rs crates/gatewayd/src/ws/`.
Add `pub mod conn;` to `crates/gatewayd/src/ws/mod.rs`.
- [ ] **2. See it fail.** `cargo test -p gatewayd --test ws_conn`. Expected: it compiles and 10
tests fail.
- [ ] **3. Fill `host_header`, `send`, `send_text`, `open`, `close`, then `poll`**,
`cargo check -p gatewayd` after each.
- [ ] **4. See it pass.** `cargo test -p gatewayd --test ws_conn`, five times. Expected: 10 passed
each time, in under a second.
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
- [ ] **6. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
## Done when
- `cargo test -p gatewayd --test ws_conn` passes five times running; `make gate` prints `gate: ok`.
## Stop and report if
- A test passes only sometimes, or takes seconds.
+70
View File
@@ -0,0 +1,70 @@
# M4a task 10: Mattermost's events and REST calls
**Branch:** `m4a` (run `git switch m4a`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `gatewayd: mm, Mattermost's events and the four REST calls, typed`
## Goal
Mattermost's JSON, typed, and the REST calls `gatewayd` makes. This is **another program's
format**: unknown fields are ignored. But every id we keep ends up in a session id or a URL path,
so every id must be a valid Mattermost id (26 characters of `a-z0-9`), or the whole post is refused.
Facts from Mattermost's source at the owner's server version, v11.11.0 (checked by the design
model; the tests use these shapes):
- A WebSocket event is `{"event", "data", "broadcast", "seq"}`. The first is `hello`. `posted`
carries the post as a **JSON string** in `data.post` (a string holding JSON, not an object), and
the channel's type in `data.channel_type`: `D` direct, `G` group, `O` open, `P` private. A reply
to one of our requests has no `event`.
- `user_typing` is a request we send: `{"action": "user_typing", "seq": <n>, "data":
{"channel_id", "parent_id"}}`; it shows the bot as typing in that thread.
- `GET /api/v4/channels/{id}/posts?since=<ms>` returns `{"order": [ids], "posts": {id: post}}`
with the posts **changed** after that time. Only the ids in `order` changed; `posts` also holds
the roots of their threads, which may be old. Edited and deleted posts come back too. The server
takes at most 1000 (`SINCE_LIMIT`); a full answer may have left some out.
- REST errors: 401 or 403 is a refused token, 429 is a rate limit (wait for `X-Ratelimit-Reset`),
5xx is worth trying again.
## Files
- Copy: `crates/gatewayd/tests/mm_json.rs`, `crates/gatewayd/tests/mm_rest.rs`,
`crates/gatewayd/tests/support/http_server.rs`, and the skeletons
`crates/gatewayd/src/mm/mod.rs` and `crates/gatewayd/src/mm/rest.rs`
- Modify: `crates/gatewayd/src/lib.rs` (`pub mod mm;`), `docs/implementer-log.md`
## The skeletons
`mm/mod.rs`, written: `SINCE_LIMIT`; `MmError` (`Net`, `Auth(u16)`, `RateLimited(Duration)`,
`Status(u16, String)`, `Json`) and its `Display`, which **quotes** a server's body (`{:?}`) so that
a newline in it cannot forge a log line; `Me { id, username }`; `Post { id, user_id, channel_id,
root_id, message, create_at, delete_at, kind }` (`kind` is the JSON `type`); `Event` (`Hello`,
`Posted { post, channel_type }`, `Other(String)`); `Since { posts, full }`; and the private serde
shapes `RawEvent` and `PostList`. To fill: `Post::check`, `json`, `parse_event`, `typing`,
`since_list`.
`mm/rest.rs`, written: `RETRY_5XX` (500 ms), `RETRIES` (2), `BODY_KEPT` (200), `Client`. To fill:
`new`, `connector`, `token`, `once`, `call`, `me`, `create_post`, `posts_since`, `direct_channel`.
The token reaches the wire only in `once`, as `Authorization: Bearer <token>`; nowhere else calls
`expose()`.
## Steps
- [ ] **1. Copy.** `git switch m4a`, then
`mkdir -p crates/gatewayd/src/mm && cp docs/plans/M4a/files/crates/gatewayd/src/mm/mod.rs docs/plans/M4a/files/crates/gatewayd/src/mm/rest.rs crates/gatewayd/src/mm/ && cp docs/plans/M4a/files/crates/gatewayd/tests/mm_json.rs docs/plans/M4a/files/crates/gatewayd/tests/mm_rest.rs crates/gatewayd/tests/ && cp docs/plans/M4a/files/crates/gatewayd/tests/support/http_server.rs crates/gatewayd/tests/support/`.
Add `pub mod mm;` to `lib.rs`.
- [ ] **2. See it fail.** `cargo test -p gatewayd --no-fail-fast --test mm_json --test mm_rest`.
Expected: it compiles; `mm_json` 6 fail and 1 passes (the `Display` test), `mm_rest` 10 fail.
- [ ] **3. Fill `mm/mod.rs`, then `mm/rest.rs`**, one function at a time, `cargo check -p gatewayd`
after each.
- [ ] **4. See it pass.** `cargo test -p gatewayd --test mm_json --test mm_rest`. Expected: 7 and
10 passed. `mm_rest` takes about 3 s: two tests wait out a rate limit on purpose.
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
- [ ] **6. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
## Done when
- Both suites pass; `make gate` prints `gate: ok`.
## Stop and report if
- A test needs a post with an invalid id to be accepted.
+80
View File
@@ -0,0 +1,80 @@
# M4a task 11: which posts become turns, and the queue per session
**Branch:** `m4a` (run `git switch m4a`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `gatewayd: sessions, routing posts to sessions, commands and the queue`
## Goal
The decisions, as pure code: no network, no files. For each new post, **in this order** (spec
section 7):
1. Ignore it if it is the bot's own, or a system message (`kind` not empty).
2. Ignore it, **silently**, unless the author is in `allow.users`.
3. Is it for this Boxmaker? A direct message (`"D"`): always. A channel or group message (`"O"`,
`"P"`, `"G"`) whose id is in `allow.channels`: if it **names this bot**, or if it is a reply in a
thread this Boxmaker already has a session for **and names nobody else**. Anything else: ignore.
`@channel`, `@here` and `@all` name nobody: every agent would answer them.
4. The session: its root is `root_id`, or the post's own id when that is empty; the session id is
`mm-<root>`. A top-level post is a new session (`resume: false`); a reply resumes it.
5. Commands: a message starting with `!`. `!!…` is not a command: one `!` is removed and the rest
goes on as a message. `!approve …` and `!deny …` are answered with `M4B_COMMAND`; any other `!`
with `UNKNOWN_COMMAND`. A command never reaches `loopd`.
6. Otherwise the message joins its session's queue.
"Names" means `@<username>` in the message, case-insensitive, where the name is the longest run of
`a-z`, `0-9`, `.`, `-` and `_` after the `@`, without trailing dots. The spec's examples, with this
bot called `boxmaker-straylight` and another agent called Hermes in the channel (the first test
checks every row):
| Post | For Boxmaker? |
|---|---|
| `@boxmaker-straylight summarise the audit log` (top level) | yes: a new session rooted here |
| a reply in that thread: `and the older files?` | yes: its thread, nobody else named |
| a reply in that thread: `@hermes what do you think?` | no |
| `@boxmaker-straylight @hermes compare notes` | yes (and Hermes answers too) |
| `@boxmaker-straylightx hello` | no: a different name |
| `@channel standup in five` | no |
**The queue.** One turn at a time per session. A message for a session with no turn running
starts one at once. Messages that arrive while it runs wait; when it ends, **all** waiting messages
go together as the next turn, joined with a blank line (`"\n\n"`), in the order they came. A
session with `limit` messages already waiting drops the next one, and the caller answers `BUSY`.
## Files
- Copy: `crates/gatewayd/tests/sessions.rs`, and the skeleton `crates/gatewayd/src/sessions.rs`
- Modify: `crates/gatewayd/src/lib.rs` (`pub mod sessions;`), `docs/implementer-log.md`
## The skeleton
Written: the three answer texts (`M4B_COMMAND`, `UNKNOWN_COMMAND`, `BUSY`), `EVERYONE`, `Ignored`
(`Own`, `System`, `NotAllowed`, `NotForUs`), `Thread { channel, root }`, `Message { session,
thread, resume, text, joins_thread }`, `Route` (`Ignore`, `Reply { thread, text }`, `Queue`),
`Router`, `Batch { session, thread, resume, text }`, `Pushed` (`Start(Batch)`, `Waiting`,
`Full(Thread)`), `Pending`, `Queues`.
To fill: `named`, `Router::new`, `Router::for_us`, `Router::route`, `Queues::new`, `push`,
`finish`, `running`, `threads`. `route` is a straight line of early returns in the order above; the
`known` function it is given answers "does this Boxmaker have a session for this thread root?"
(task 14 answers it from the state file). `SessionId` has no `Ord`, so `Queues` keys a `HashMap`.
## Steps
- [ ] **1. Copy.** `git switch m4a`, then
`cp docs/plans/M4a/files/crates/gatewayd/tests/sessions.rs crates/gatewayd/tests/ && cp docs/plans/M4a/files/crates/gatewayd/src/sessions.rs crates/gatewayd/src/`.
Add `pub mod sessions;` to `lib.rs`.
- [ ] **2. See it fail.** `cargo test -p gatewayd --test sessions`. Expected: it compiles and 8
tests fail.
- [ ] **3. Fill `named`, then the `Router`, then the `Queues`**, `cargo check -p gatewayd` after
each function.
- [ ] **4. See it pass.** `cargo test -p gatewayd --test sessions`. Expected: 8 passed.
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
- [ ] **6. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
## Done when
- `cargo test -p gatewayd --test sessions` passes; `make gate` prints `gate: ok`.
## Stop and report if
- A row of the table above seems to need a different order of the steps.
+63
View File
@@ -0,0 +1,63 @@
# M4a task 12: the state file
**Branch:** `m4a` (run `git switch m4a`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `gatewayd: state, what was handled, our threads and turns in flight`
## Goal
`<home>/gateway/state.json` lets `gatewayd` pick up where it left off (spec section 9):
```json
{"channels": {"<channel id>": 1758650000000}, "recent": ["<post id>", ],
"threads": ["<root post id>", ],
"in_flight": [{"session": "mm-…", "channel": "<id>", "root": "<id>"}]}
```
- `channels`: the `create_at` of the last post handled in each channel; catching up starts there.
A mark never moves back.
- `recent`: the ids of the last 500 posts handled (`RECENT_KEPT`), so a post seen twice (live and
in a catch-up) is handled once.
- `threads`: the roots of the threads this Boxmaker takes part in, in channels; the newest 5,000
(`THREADS_KEPT`).
- `in_flight`: turns sent to `loopd` and not yet answered; after a restart each gets an
"interrupted" reply.
It is **our** format: unknown fields are errors, and every id must be a valid Mattermost id. It is
written atomically after every change, in the same six steps as `brokerd/src/state.rs`'s
`persist` (read it). A file that exists but cannot be read, parsed or written stops `gatewayd`
with `see docs/runbook.md#gateway-state-damaged`: guessing would answer posts twice. **Only a
missing file** is a first start.
## Files
- Copy: `crates/gatewayd/tests/state.rs`, and the skeleton `crates/gatewayd/src/state.rs`
- Modify: `crates/gatewayd/src/lib.rs` (`pub mod state;`), `docs/implementer-log.md`
## The skeleton
Written: `RECENT_KEPT`, `THREADS_KEPT`, `RUNBOOK`, `StateError` (`Read(PathBuf, String)`,
`Write(PathBuf, io::Error)`) and its `Display` (the path, the problem, and the pointer on its own
line), `InFlight`, the private `StateFile`, `State { path, file }`.
To fill: `StateFile::problem`, `State::load`, `save`, `persist`, and the methods `seen`, `handled`,
`since`, `channels`, `mark`, `knows_thread`, `join_thread`, `start_turn`, `end_turn`,
`take_in_flight`. Every method that changes the state saves it before it returns, and returns the
save's error.
## Steps
- [ ] **1. Copy.** `git switch m4a`, then
`cp docs/plans/M4a/files/crates/gatewayd/tests/state.rs crates/gatewayd/tests/ && cp docs/plans/M4a/files/crates/gatewayd/src/state.rs crates/gatewayd/src/`.
Add `pub mod state;` to `lib.rs`.
- [ ] **2. See it fail.** `cargo test -p gatewayd --test state`. Expected: it compiles and 4 tests
fail.
- [ ] **3. Fill `problem`, `load`, `persist`, `save`, then the methods**, `cargo check -p gatewayd`
after each.
- [ ] **4. See it pass.** `cargo test -p gatewayd --test state`. Expected: 4 passed, in well under a
second.
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
- [ ] **6. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
## Done when
- `cargo test -p gatewayd --test state` passes; `make gate` prints `gate: ok`.
+61
View File
@@ -0,0 +1,61 @@
# M4a task 13: a turn on `loop.sock`, and its answer in the thread
**Branch:** `m4a` (run `git switch m4a`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `gatewayd: deliver, turns on loop.sock and answers posted in their thread`
## Goal
A batch of messages (task 11) becomes one `turn` on `loopd`'s socket, exactly as `bxctl chat` sends
one (read `crates/bxctl/src/chat.rs`, `run_turn`): one frame `{"kind": "turn", "body": {"session",
"content", "resume"}}` with id 1, then events until a final `turn_done` or `error`. What comes back
is posted in the thread (spec section 8):
- No thinking and no status reach Mattermost. The one event that is shown: `approval_pending`,
posted once as `waiting for approval <id>: approve or deny it with `bxctl` (Mattermost approvals
arrive in M4b)` (the skeleton's `approval_text`).
- `turn_done` → its content, split into posts of at most 16,000 **characters** (a Mattermost post
holds 16,383), at the last newline before the limit, or at the limit when there is none, in
order. An empty answer is posted as `(the answer was empty)`: Mattermost refuses an empty post.
- An `error` frame → `Error: <code>: <detail>`, where the code is the snake_case name
(`no_such_session`), and the detail carries `loopd`'s runbook pointer when it has one.
- `loop.sock` cannot be reached, or closes early, or sends a frame that does not belong →
`LOOP_DOWN`, and the messages are not sent again: `loopd` may have run them.
- A reply in a thread `loopd` does not know (`no_such_session` with `resume: true`) is sent once
more with `resume: false`, creating the session, as `bxctl chat --session` does. Only then.
## Files
- Copy: `crates/gatewayd/tests/deliver.rs`, `crates/gatewayd/tests/support/fake_loop.rs`, and the
skeleton `crates/gatewayd/src/deliver.rs`
- Modify: `crates/gatewayd/src/lib.rs` (`pub mod deliver;`), `docs/implementer-log.md`
## The skeleton
Written: `MAX_POST` (16,000), `LOOP_DOWN`, `EMPTY_ANSWER`, the trait `Poster` (somewhere to post:
Mattermost's `Client`, or a test's record) and its `impl` for `Client`, `Outcome` (`Answer`,
`Refused`, `LoopDown(why)`), `approval_text`.
To fill: `error_text`, `split_answer`, `one_turn`, `run_turn`, `deliver`. `split_answer` counts
**characters**, not bytes, and never cuts inside one: find the byte index of the 16,000th character
with `char_indices().nth(MAX_POST)`.
## Steps
- [ ] **1. Copy.** `git switch m4a`, then
`cp docs/plans/M4a/files/crates/gatewayd/tests/deliver.rs crates/gatewayd/tests/ && cp docs/plans/M4a/files/crates/gatewayd/tests/support/fake_loop.rs crates/gatewayd/tests/support/ && cp docs/plans/M4a/files/crates/gatewayd/src/deliver.rs crates/gatewayd/src/`.
Add `pub mod deliver;` to `lib.rs`.
- [ ] **2. See it fail.** `cargo test -p gatewayd --test deliver`. Expected: it compiles and 9
tests fail.
- [ ] **3. Fill `error_text`, `split_answer`, `one_turn`, `run_turn`, `deliver`**,
`cargo check -p gatewayd` after each.
- [ ] **4. See it pass.** `cargo test -p gatewayd --test deliver`. Expected: 9 passed.
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
- [ ] **6. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
## Done when
- `cargo test -p gatewayd --test deliver` passes; `make gate` prints `gate: ok`.
## Stop and report if
- A test seems to need a turn sent twice in any case other than the one above.
+79
View File
@@ -0,0 +1,79 @@
# M4a task 14: the serve loop
**Branch:** `m4a` (run `git switch m4a`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `gatewayd: serve, the event loop, typing, catch-up and reconnecting`
## Goal
The loop that ties tasks 03 to 13 together (spec sections 8 and 9):
- **Connecting**, at start and after every loss: `GET /users/me`, open the WebSocket, wait for
`hello`, then log `gatewayd: connected to <url> as <username>`. A connection that fails is tried
again after 1, 2, 5, 10, then every 30 seconds, with one log line per attempt:
`gatewayd: cannot reach <url>: <error>; trying again in <n> s`, then a newline and
`see docs/runbook.md#mattermost-unreachable`. A refused token (401 or 403) is never tried again:
`run` returns `Stop::Auth`.
- **After a restart** (the first connection only): every turn left in flight gets
`interrupted: gatewayd restarted before the answer arrived; ask again` in its thread. A later
reconnect must not do this: those turns are still running.
- **Catching up**: the direct channel with each allowed user, and each allowed channel, from its
mark. A channel without a mark is marked "now" and not caught up: history is not answered.
- **Each post**, live or caught up: posts in channels we do not track are not recorded at all. A
tracked post is recorded as handled **before** it is acted on, so after a crash it is not
answered twice (the in-flight record reports it instead). Then it is routed (task 11): a stranger
is logged by user id and post id only, **never with the text**.
- **Turns** run on their own threads (task 13), recorded in flight while they run. When one ends,
its session's waiting messages start the next.
- **Typing**: every `typing_every_ms`, `user_typing` for every thread with a turn running.
## Files
- Copy: `crates/gatewayd/tests/serve.rs`, `crates/gatewayd/tests/serve_restart.rs`,
`crates/gatewayd/tests/support/fake_mm.rs`, `crates/gatewayd/tests/support/gateway.rs`, and the
skeletons `crates/gatewayd/src/serve/mod.rs` and `crates/gatewayd/src/serve/handle.rs`
- Modify: `crates/gatewayd/src/lib.rs` (`pub mod serve;`), `docs/implementer-log.md`
## The skeletons
`serve/mod.rs`, written: the pointers, `INTERRUPTED`, `Log`, `Stop` (`Auth`, `State`, `Start`,
`Asked`) and its `Display`, `Tuning` and its default, the `Gateway` struct, and the two functions
that are glue: **`run`** (the connect-or-back-off loop) and **`Gateway::event_loop`** (finish turns,
send typing, wait for an event, handle it). Read both first: they call everything you write.
To fill: `From<StateError> for Stop`, `backoff`, `sleep_unless`, `connect`, `Gateway::new`,
`Gateway::connected`.
`serve/handle.rs`, all to fill: `now_ms`, `post`, `tracked`, `handle_post`, `start`, `finished`,
`typing`, `catch_up`.
Each `todo!()` has its steps above it, with the exact log lines. `run` takes the token already
loaded and a `stop` flag, so the tests need no secrets and can end it; task 15's `main` passes a
flag that is never set.
## Steps
- [ ] **1. Copy.** `git switch m4a`, then
`mkdir -p crates/gatewayd/src/serve && cp docs/plans/M4a/files/crates/gatewayd/src/serve/mod.rs docs/plans/M4a/files/crates/gatewayd/src/serve/handle.rs crates/gatewayd/src/serve/`,
`cp docs/plans/M4a/files/crates/gatewayd/tests/serve.rs docs/plans/M4a/files/crates/gatewayd/tests/serve_restart.rs crates/gatewayd/tests/`
and
`cp docs/plans/M4a/files/crates/gatewayd/tests/support/fake_mm.rs docs/plans/M4a/files/crates/gatewayd/tests/support/gateway.rs crates/gatewayd/tests/support/`.
Add `pub mod serve;` to `lib.rs`.
- [ ] **2. See it fail.** `cargo test -p gatewayd --no-fail-fast --test serve --test serve_restart`.
Expected: it compiles; `serve` 6 fail; `serve_restart` 6 fail and 1 passes (a damaged state file
stops `run` before anything you write is called).
- [ ] **3. Fill `mod.rs` first** (`from`, `backoff`, `sleep_unless`, `Gateway::new`, `connect`,
`connected`), **then `handle.rs`** (`now_ms`, `post`, `tracked`, `finished`, `typing`, `start`,
`handle_post`, `catch_up`). `cargo check -p gatewayd` after each function.
- [ ] **4. See it pass.** `cargo test -p gatewayd --test serve --test serve_restart`, five times.
Expected: 6 and 7 passed each time, in about 2 s.
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
- [ ] **6. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
## Done when
- Both suites pass five times running; `make gate` prints `gate: ok`.
## Stop and report if
- A test passes only sometimes, or a test takes 5 s or more (that is a wait that timed out, not a
pass).
- `run` or `event_loop` seems to need a change. They are given; report instead.
+57
View File
@@ -0,0 +1,57 @@
# M4a task 15: `gatewayd serve --config <path>`
**Branch:** `m4a` (run `git switch m4a`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `gatewayd: main, serve --config with its start checks`
## Goal
The program. `gatewayd serve --config <path>` loads the configuration and the token, prepares its
directory, and runs the serve loop (task 14) until it must stop, then exits 1. Anything else on the
command line prints `usage: gatewayd serve --config <path>` and exits 2. Every failure at start
prints one line and its runbook pointer and exits 1, **before** anything is created on disk and
before any connection:
| Failure | Output |
|---|---|
| the config cannot be read, parsed or checked | `gatewayd: <error>` then `see docs/runbook.md#gatewayd-start-failed` |
| the token's source is wrong | `gatewayd: <path>: <why>` then the same pointer |
| the token cannot be loaded | `gatewayd: secret mattermost_token: <why>` then `see docs/runbook.md#secret-unavailable` (the error's own `Display`) |
| `<home>/gateway` cannot be created | `gatewayd: cannot prepare <dir>: <error>` then the start-failed pointer |
A token read from a file prints task 04's warning, and `gatewayd` goes on. The token itself is
never printed: the last test runs the real program and looks for it in everything it wrote.
## Files
- Copy: `crates/gatewayd/tests/main.rs`, and the skeleton `crates/gatewayd/src/main.rs` (replaces
the placeholder that printed "not implemented")
- Modify: `docs/implementer-log.md`
## The skeleton
Written: `main`, which parses the arguments like `loopd`'s. To fill: `serve(path)`, whose steps
are above its `todo!()`. The directory is `config.state_path()`'s parent, created with
`std::fs::DirBuilder`, `recursive(true)` and `mode(0o700)` (`std::os::unix::fs::DirBuilderExt`).
## Steps
- [ ] **1. Copy.** `git switch m4a`, then
`cp docs/plans/M4a/files/crates/gatewayd/tests/main.rs crates/gatewayd/tests/ && cp docs/plans/M4a/files/crates/gatewayd/src/main.rs crates/gatewayd/src/`
- [ ] **2. See it fail.** `cargo test -p gatewayd --test main`. Expected: it compiles; 4 fail and 1
passes (`usage`, since `main` is given).
- [ ] **3. Fill `serve`.** `cargo check -p gatewayd`.
- [ ] **4. See it pass.** `cargo test -p gatewayd --test main`. Expected: 5 passed.
- [ ] **5. Run everything.** `cargo test -p gatewayd`. Expected: every suite passes.
- [ ] **6. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`,
with about 762 tests.
- [ ] **7. Log and commit.** `git add crates/gatewayd docs/implementer-log.md Cargo.lock && git commit`
This is the last task of M4a. Stop after the commit.
## Done when
- `cargo test -p gatewayd` passes; `make gate` prints `gate: ok`.
## Stop and report if
- A test's output contains the token.
+75
View File
@@ -0,0 +1,75 @@
# M4a implementation plan: `gatewayd`, conversations over Mattermost
> **For the implementing model:** do not work from this file. The owner gives you one task file at
> a time (`01-…` to `15-…`). This file is the index for the owner and the reviewer.
**Goal:** a direct message to Boxmaker's bot account on Mattermost becomes a turn in `loopd`, and
the answer is posted in its thread. In channels shared with other agents, Boxmaker answers only
posts that name it, and replies in its own threads that name nobody else. Anyone not on the
allowlist gets nothing at all. `gatewayd` opens no listening port.
**Architecture:** `proto` gains SHA-1 (01). `gatewayd` gets its dependencies and test certificates
(02), its configuration (03), its secrets and the runbook entries (04), then a network layer built
bottom-up: TCP or TLS (05), HTTP/1.1 (06), the WebSocket handshake (07), frames (08) and the
connection (09). Mattermost's JSON and REST calls (10), the routing of posts to sessions (11), the
state file (12) and delivery on `loop.sock` (13) are each one module. The serve loop ties them
together (14), and `main` starts it (15).
**Spec:** `docs/specs/2026-09-23-m4a-gateway.md`. Brief: `docs/design.md` (P15 applied). Every
fail-closed message ends with a pointer into `docs/runbook.md`; task 04 adds all seven entries
this milestone needs.
**No task needs Mattermost or a network.** Everything runs against fakes: TLS test servers with a
test-only CA, a scripted WebSocket server, a scripted HTTP server, a fake Mattermost and a fake
`loopd`. The checks against the owner's server are done by the design model afterwards.
## Global constraints
- Everything in `AGENTS.md`, including "Lessons from earlier reviews".
- New dependencies, all in task 02 and no others: `rustls` 0.23.45 (no default features; `ring`,
`std`, `tls12`), `rustls-native-certs` 0.8.4, `zeroize` 1.9.0; `gatewayd` also uses `serde`,
`serde_json` and `toml`, already vetted.
- Branch `m4a`. One task, one fresh OpenCode session, one commit. Run `cargo fmt --all` before the
gate. Review happens once, after task 15.
- Most tasks hand over a **skeleton**: the file with its types, constants and signatures written,
and `todo!()` bodies with the steps as comments above them. Fill one function at a time and run
`cargo check -p <crate>` after each (tip T25). Keep the comments; they say why.
- Mattermost's JSON is another program's format: unknown fields are ignored. Our formats
(`gatewayd.toml`, `state.json`) reject them.
## Tasks
The last column is how the given tests were checked before hand-over (tip T17). Every task had a
reference implementation, and the given tests were run against it at that task's end state. Then
each task's end state was rebuilt on its own from master in this order, with the gate at every
step (tip T26), and the reference was deleted so it cannot be read (tip T18). Each skeleton was
checked to compile against its task's tests and fail them.
| # | File | Delivers | Tests | Check |
|---|---|---|---|---|
| 01 | `01-proto-sha1.md` | `proto::sha1` | `proto/tests/sha1.rs` | reference; vectors checked with `sha1sum` |
| 02 | `02-gatewayd-deps.md` | dependencies, `deny.toml`, TLS test certificates | none new | reference |
| 03 | `03-gatewayd-config.md` | `gatewayd.toml` | `config.rs`, `support/tmp.rs` | reference |
| 04 | `04-gatewayd-secrets.md` | `SecretStore`: credential, env, file; seven runbook entries | `secrets.rs` | reference |
| 05 | `05-gatewayd-net.md` | TCP or TLS, verified | `net.rs`, `support/tls_server.rs` | reference |
| 06 | `06-gatewayd-http.md` | HTTP/1.1 requests, size caps, rate-limit waits | `http.rs` | reference |
| 07 | `07-gatewayd-ws-handshake.md` | base64, the WebSocket handshake | `ws_handshake.rs` | reference |
| 08 | `08-gatewayd-ws-frames.md` | the frame decoder and encoder | `ws_frame.rs` (a naive decoder as oracle, 300 seeds) | reference |
| 09 | `09-gatewayd-ws-conn.md` | the connection: pings, close, a dead peer | `ws_conn.rs`, `support/ws_server.rs` | reference; 7 mutations, all caught once a test for the `Host` header was added |
| 10 | `10-gatewayd-mm.md` | Mattermost's events and REST calls | `mm_json.rs`, `mm_rest.rs`, `support/http_server.rs` | reference; 11 mutations, 10 caught, 1 that cannot change behaviour |
| 11 | `11-gatewayd-sessions.md` | routing, commands, the queue per session | `sessions.rs` | reference; 14 mutations, 13 caught, 1 that cannot change behaviour |
| 12 | `12-gatewayd-state.md` | `state.json` | `state.rs` | reference; 12 mutations, all caught |
| 13 | `13-gatewayd-deliver.md` | turns on `loop.sock`, answers in the thread | `deliver.rs`, `support/fake_loop.rs` | reference; 11 mutations, 10 caught; the one left accepts an event frame with another id, which `loopd` never sends |
| 14 | `14-gatewayd-serve.md` | the serve loop: routing, typing, catch-up, reconnecting | `serve.rs`, `serve_restart.rs`, `support/fake_mm.rs`, `support/gateway.rs` | reference; 14 mutations, all caught once four tests were added; 8 runs clean |
| 15 | `15-gatewayd-main.md` | `gatewayd serve --config <path>` | `main.rs` | reference; run against the owner's server, token never printed |
At the end of task 15: about 762 tests (650 before task 01).
## Running it
```sh
git switch m4a
BOXMAKER_MODEL=straylight/ornith-1.5-35b-a3b tools/run-plan.sh docs/plans/M4a
```
Keep the OpenCode TUI closed while it runs.
@@ -0,0 +1,194 @@
//! `gatewayd.toml` into a typed `Config`. Our own format: unknown keys are errors in every table.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
pub mattermost: MattermostConfig,
pub secrets: BTreeMap<String, SecretSpec>,
pub allow: AllowConfig,
#[serde(default, rename = "loop")]
pub loop_: LoopConfig,
#[serde(default)]
pub paths: Paths,
#[serde(default)]
pub limits: Limits,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MattermostConfig {
pub url: String,
#[serde(default)]
pub ca_file: Option<PathBuf>,
}
/// Where one secret comes from: exactly one of the three is set (checked by `Config::load`).
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct SecretSpec {
#[serde(default)]
pub credential: Option<String>,
#[serde(default)]
pub env: Option<String>,
#[serde(default)]
pub file: Option<PathBuf>,
}
/// A checked `SecretSpec`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SecretSource {
Credential(String),
Env(String),
File(PathBuf),
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AllowConfig {
pub users: Vec<String>,
#[serde(default)]
pub channels: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
#[serde(deny_unknown_fields, default)]
pub struct LoopConfig {
pub socket: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct Paths {
pub home: PathBuf,
}
impl Default for Paths {
fn default() -> Self {
Paths {
home: std::env::var_os("BOXMAKER_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/var/lib/boxmaker")),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct Limits {
pub queue: u32,
pub typing_every_ms: u64,
pub ping_every_ms: u64,
pub dead_after_ms: u64,
}
impl Default for Limits {
fn default() -> Self {
Limits {
queue: 20,
typing_every_ms: 3_000,
ping_every_ms: 30_000,
dead_after_ms: 60_000,
}
}
}
/// `url` taken apart.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServerUrl {
pub tls: bool,
pub host: String,
pub port: u16,
}
#[derive(Debug)]
pub enum ConfigError {
Read(PathBuf, std::io::Error),
Parse(PathBuf, toml::de::Error),
Invalid(PathBuf, String),
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// Each variant: "<path>: <error or why>", with `path.display()`.
todo!()
}
}
impl std::error::Error for ConfigError {}
/// The one secret M4a needs.
pub const MATTERMOST_TOKEN: &str = "mattermost_token";
impl Config {
/// Parse without the checks `load` makes.
pub fn parse(text: &str) -> Result<Config, toml::de::Error> {
// `toml::from_str`.
todo!()
}
pub fn load(path: &Path) -> Result<Config, ConfigError> {
// Read the file (else Read), parse (else Parse), then `problem()` (Some(why) is Invalid).
todo!()
}
/// The first thing wrong with the values, or `None`.
pub fn problem(&self) -> Option<String> {
// The first of these, in this order, with the exact messages in the task: the url
// (`parse_url`); ca_file not absolute; [secrets.mattermost_token] missing; each secret
// whose `source()` fails; allow.users empty; any id in allow.users or allow.channels not
// `valid_id`; any limit that is 0.
todo!()
}
/// The server's address, from `url`. Call only on a checked `Config`.
pub fn server(&self) -> Result<ServerUrl, String> {
// `parse_url` of the url.
todo!()
}
pub fn token_source(&self) -> Result<SecretSource, String> {
// The `source()` of [secrets.mattermost_token], or an error if it is missing.
todo!()
}
pub fn loop_socket(&self) -> PathBuf {
// [loop] socket, or <home>/run/loop/loop.sock when it is empty.
todo!()
}
pub fn state_path(&self) -> PathBuf {
// <home>/gateway/state.json.
todo!()
}
}
impl SecretSpec {
/// Exactly one source, well formed.
pub fn source(&self) -> Result<SecretSource, String> {
// Exactly one of the three set, else "needs exactly one of credential, env and file".
// credential: not empty, only ASCII letters, digits, _ . -. env: not empty, only A-Z, 0-9,
// _. file: an absolute path. The messages are in the task.
todo!()
}
}
/// A Mattermost id: 26 characters of `a-z0-9`.
pub fn valid_id(id: &str) -> bool {
// 26 bytes, each a-z or 0-9.
todo!()
}
/// `http://host[:port]` or `https://host[:port]`, nothing else.
pub fn parse_url(url: &str) -> Result<ServerUrl, String> {
// http:// or https://, then a host, then optionally ":" and a port. The port: 1..=65535 written
// exactly as `port.to_string()` (so no "+1", no "080"). Default 443 for https, 80 for http. The
// host: 1..=253 bytes of a-z, 0-9, "." and "-", not starting or ending with "." or "-".
// Anything else, including a path, a user or an upper-case letter, is the one error message in
// the task.
todo!()
}
@@ -0,0 +1,96 @@
//! One turn on `loop.sock` and its answer in the thread (M4a spec, section 8). Typing is shown by
//! `serve`, which owns the WebSocket; this module only sends the turn and posts what comes back.
use std::os::unix::net::UnixStream;
use std::path::Path;
use proto::{
Envelope, ErrorCode, Message, PROTOCOL_VERSION, Turn, TurnEvent, WireError, read_frame,
write_frame,
};
use crate::mm::MmError;
use crate::mm::rest::Client;
use crate::sessions::Batch;
/// The longest reply we post, in characters (a post holds at most 16,383).
pub const MAX_POST: usize = 16_000;
pub const LOOP_DOWN: &str = "Boxmaker's loop is not running (see docs/runbook.md#loop-unavailable)";
pub const EMPTY_ANSWER: &str = "(the answer was empty)";
/// Somewhere to post: Mattermost, or a test's record.
pub trait Poster: Send + Sync {
fn post(&self, channel: &str, root: &str, text: &str) -> Result<(), MmError>;
}
impl Poster for Client {
fn post(&self, channel: &str, root: &str, text: &str) -> Result<(), MmError> {
self.create_post(channel, root, text).map(|_| ())
}
}
/// How a turn ended.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
Answer(String),
Refused(WireError),
/// `loop.sock` could not be reached or closed early; why, for the log.
LoopDown(String),
}
pub fn approval_text(approval: u64) -> String {
format!(
"waiting for approval {approval}: approve or deny it with `bxctl` (Mattermost approvals arrive in M4b)"
)
}
/// "Error: <code>: <detail>"; the detail carries `loopd`'s runbook pointer when there is one.
pub fn error_text(error: &WireError) -> String {
// "Error: <code>: <detail>", where <code> is the snake_case name serde gives the ErrorCode
// (`serde_json::to_value(code)` is a JSON string, e.g. "no_such_session").
todo!()
}
/// An answer in posts of at most `MAX_POST` characters: each cut at the last newline before the
/// limit (the newline is dropped), or at the limit when there is none.
pub fn split_answer(text: &str) -> Vec<String> {
// Blank (only whitespace) -> [EMPTY_ANSWER]. Otherwise, while the rest is longer than MAX_POST
// *characters*: take the first MAX_POST characters; if they hold a newline past position 0, cut
// at the last one and drop that newline; else cut at MAX_POST characters. The last part is the
// rest.
todo!()
}
/// Send one turn and read it to its end; `on_event` sees every event.
fn one_turn(
socket: &Path,
batch: &Batch,
resume: bool,
on_event: &mut dyn FnMut(&TurnEvent),
) -> Outcome {
// 1. Connect to the socket (else LoopDown("cannot connect to <path>: <e>")).
// 2. `write_frame` one Envelope: v PROTOCOL_VERSION, id 1, final true, msg Turn (else
// LoopDown).
// 3. `read_frame` until the end: (id 1, not final, TurnEvent) -> on_event; (1, final, TurnDone)
// -> Answer with its content; (1, final, Error) -> Refused; a read error -> LoopDown("the
// turn ended early: <e>"); anything else -> LoopDown("an unexpected frame").
todo!()
}
/// A turn for a batch. A reply in a thread `loopd` does not know creates the session, as
/// `bxctl chat --session` does.
pub fn run_turn(socket: &Path, batch: &Batch, on_event: &mut dyn FnMut(&TurnEvent)) -> Outcome {
// `one_turn` with batch.resume. When that is Refused with NoSuchSession and batch.resume was
// true, one more `one_turn` with resume false. Otherwise the first outcome.
todo!()
}
/// Run a batch's turn and post what comes of it in its thread. A post that fails is logged.
pub fn deliver(poster: &dyn Poster, socket: &Path, batch: &Batch, log: &dyn Fn(&str)) {
// Post in the batch thread. An ApprovalPending event posts `approval_text(approval)` at once.
// Then: Answer -> every part of `split_answer`, in order; Refused -> `error_text`;
// LoopDown(why) -> log "gatewayd: <session>: <why>" and post LOOP_DOWN. A post that fails is
// logged, exactly "gatewayd: cannot post in <channel> (thread <root>): <error>", and the rest
// goes on.
todo!()
}
@@ -0,0 +1,127 @@
//! HTTP/1.1 over a connected stream: one request, one response, `Connection: close` (M4a spec,
//! section 5). The response head is read a byte at a time, so nothing past it is consumed: the
//! WebSocket handshake reads its frames after it from the same stream.
use std::io::{Read, Write};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
pub const MAX_HEAD: usize = 16 * 1024;
pub const MAX_BODY: usize = 4 * 1024 * 1024;
/// The longest a rate limit is waited out, whatever the server says.
pub const MAX_RATE_WAIT: Duration = Duration::from_secs(60);
#[derive(Debug)]
pub enum HttpError {
Io(std::io::Error),
/// The response is not HTTP/1.1 as we read it.
Protocol(String),
/// A head or body over its cap.
TooLarge(&'static str),
}
impl std::fmt::Display for HttpError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
HttpError::Io(e) => write!(f, "{e}"),
HttpError::Protocol(why) => write!(f, "bad HTTP response: {why}"),
HttpError::TooLarge(what) => write!(f, "the response {what} is too large"),
}
}
}
impl std::error::Error for HttpError {}
impl From<std::io::Error> for HttpError {
fn from(e: std::io::Error) -> Self {
HttpError::Io(e)
}
}
/// A status line and headers.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Head {
pub status: u16,
pub headers: Vec<(String, String)>,
}
impl Head {
/// The first header named `name`, compared case-insensitively.
pub fn header(&self, name: &str) -> Option<&str> {
// The value of the first header whose name matches, ignoring ASCII case.
todo!()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Response {
pub head: Head,
pub body: Vec<u8>,
}
/// Write one request. `host` is the `Host` header; `headers` come after it, then
/// `Content-Length` when there is a body, then `Connection: close`.
pub fn write_request(
stream: &mut dyn Write,
method: &str,
host: &str,
path: &str,
headers: &[(&str, &str)],
body: Option<&[u8]>,
) -> Result<(), HttpError> {
// Exactly: "<method> <path> HTTP/1.1\r\nHost: <host>\r\n", each header as "<k>: <v>\r\n",
// "Content-Length: <n>\r\n" when there is a body, "Connection: close\r\n\r\n", then the body.
// Flush.
todo!()
}
/// One request and its whole response.
pub fn request(
stream: &mut (impl Read + Write),
method: &str,
host: &str,
path: &str,
headers: &[(&str, &str)],
body: Option<&[u8]>,
) -> Result<Response, HttpError> {
// `write_request`, then `read_head`, then `read_body`.
todo!()
}
/// The status line and headers, up to and including the blank line, and not a byte more.
pub fn read_head(stream: &mut dyn Read) -> Result<Head, HttpError> {
// One byte at a time until CRLF CRLF, never more; over MAX_HEAD is TooLarge("head"); end of
// stream is Protocol; retry Interrupted. Then: UTF-8; status line "HTTP/1.1" or "HTTP/1.0", a
// 3-digit status in 100..=599; each header line "name: value" with a non-empty name without
// spaces, the value trimmed. Anything else is Protocol.
todo!()
}
/// The body after `head`: chunked, `Content-Length`, or to the end; at most `MAX_BODY`.
pub fn read_body(stream: &mut dyn Read, head: &Head) -> Result<Vec<u8>, HttpError> {
// Transfer-Encoding: chunked (any case) -> `read_chunked`. Else Content-Length: parse (else
// Protocol), over MAX_BODY is TooLarge("body"), then read_exact that many. Else read to the end
// through `take(MAX_BODY + 1)`; more than MAX_BODY is TooLarge("body").
todo!()
}
fn read_line(stream: &mut dyn Read, cap: usize) -> Result<String, HttpError> {
// Bytes up to CRLF (dropped), at most `cap` (else TooLarge("chunk header")); end of stream is
// Protocol.
todo!()
}
fn read_chunked(stream: &mut dyn Read) -> Result<Vec<u8>, HttpError> {
// Loop: a size line (hex, before any ";"), at most 1024 bytes. Size 0: read trailer lines (8
// KiB each) until an empty one, and return. Otherwise the size must fit in MAX_BODY minus what
// is already read (else TooLarge("body")), read it, then exactly CRLF (else Protocol).
todo!()
}
/// How long a 429 asks us to wait, from `X-Ratelimit-Reset`: a Unix time if it is one, else a
/// number of seconds; never more than `MAX_RATE_WAIT`. One second if the header is missing or bad.
pub fn rate_limit_wait(head: &Head, now: SystemTime) -> Duration {
// X-Ratelimit-Reset as u64: above 1_000_000_000 it is a Unix time (wait = it - now, at least 1
// s), otherwise seconds (at least 1). Missing or not a number: 1 s. Never more than
// MAX_RATE_WAIT.
todo!()
}
@@ -0,0 +1,37 @@
//! `gatewayd serve --config <path>`: the Mattermost channel. It loads its configuration and its
//! token, prepares its directory, then serves until it must stop (exit 1).
use std::os::unix::fs::DirBuilderExt;
use std::path::Path;
use std::process::ExitCode;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use gatewayd::config::{Config, MATTERMOST_TOKEN};
use gatewayd::secrets;
use gatewayd::serve::{START_FAILED, Tuning, run};
fn main() -> ExitCode {
// `args_os`: the config path need not be UTF-8, and `args` would panic on one that is not.
let args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
let words: Vec<Option<&str>> = args.iter().map(|a| a.to_str()).collect();
match (words.as_slice(), args.get(2)) {
([Some("serve"), Some("--config"), _], Some(path)) => serve(Path::new(path)),
_ => {
eprintln!("usage: gatewayd serve --config <path>");
ExitCode::from(2)
}
}
}
fn serve(path: &Path) -> ExitCode {
// Each failure prints one line (plus its pointer) and returns ExitCode::from(1):
// 1. `Config::load`: "gatewayd: <error>\n<START_FAILED>". 2. `token_source`: "gatewayd: <path>:
// <why>\n<START_FAILED>".
// 3. `secrets::load(MATTERMOST_TOKEN, &source, &|k| std::env::var_os(k))`: "gatewayd: <error>"
// (it carries its pointer). Print the warning, if any, as it is. 4. Create the state file
// directory, recursive, 0700: "gatewayd: cannot prepare <dir>: <e>\n<START_FAILED>". 5.
// `run` with Tuning::default(), a log that prints each line to standard error, and a stop
// flag that is never set; print the Stop it returns.
todo!()
}
@@ -0,0 +1,149 @@
//! Mattermost's JSON, typed: the posts and users we read, the events that arrive over the
//! WebSocket, and the requests we send over it. Mattermost's JSON is not ours: unknown fields are
//! ignored, but every id we keep must be a valid id.
pub mod rest;
use std::time::Duration;
use serde::Deserialize;
use crate::config::valid_id;
/// At most this many changed posts come back from one `posts?since` call (Mattermost v11.11.0,
/// `SqlPostStore::GetPostsSince`); a full answer may have left some out.
pub const SINCE_LIMIT: usize = 1000;
#[derive(Debug)]
pub enum MmError {
/// No answer: connecting, TLS, or the HTTP exchange failed.
Net(String),
/// 401 or 403: the token is refused.
Auth(u16),
/// 429, still, after waiting as asked.
RateLimited(Duration),
/// Any other status that is not 2xx, with the start of the body.
Status(u16, String),
/// The answer is not the JSON we expect.
Json(String),
}
impl std::fmt::Display for MmError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MmError::Net(why) => write!(f, "{why}"),
MmError::Auth(status) => write!(f, "Mattermost refused the token ({status})"),
MmError::RateLimited(wait) => {
write!(f, "rate limited; asked to wait {} s", wait.as_secs())
}
// Quoted: the body is the server's text, and must not forge a log line.
MmError::Status(status, body) => write!(f, "status {status}: {body:?}"),
MmError::Json(why) => write!(f, "unexpected JSON: {why}"),
}
}
}
impl std::error::Error for MmError {}
/// This bot, from `GET /users/me`.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct Me {
pub id: String,
pub username: String,
}
/// The fields of a post that `gatewayd` uses.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct Post {
pub id: String,
pub user_id: String,
pub channel_id: String,
#[serde(default)]
pub root_id: String,
#[serde(default)]
pub message: String,
pub create_at: i64,
#[serde(default)]
pub delete_at: i64,
/// Empty for a message a user wrote; anything else is a system message.
#[serde(default, rename = "type")]
pub kind: String,
}
impl Post {
/// Every id is a Mattermost id (the root may be empty): they end up in session ids and paths.
pub(crate) fn check(self) -> Result<Post, MmError> {
// id, user_id and channel_id must be `valid_id`, and root_id empty or `valid_id`; otherwise
// Json("a post with an invalid id: <id quoted with {:?}>").
todo!()
}
}
/// An event from the WebSocket.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Event {
Hello,
/// A new post, and the type of its channel: `D` direct, `G` group, `O` open, `P` private.
Posted {
post: Post,
channel_type: String,
},
/// Any other event, or a reply to one of our requests, by its name (empty for a reply).
Other(String),
}
#[derive(Deserialize)]
struct RawEvent {
#[serde(default)]
event: String,
#[serde(default)]
data: serde_json::Map<String, serde_json::Value>,
}
pub(crate) fn json<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> Result<T, MmError> {
// `serde_json::from_slice`; its error becomes Json(e.to_string()).
todo!()
}
/// One WebSocket text message as an event. `posted` carries the post as a JSON **string**.
pub fn parse_event(text: &str) -> Result<Event, MmError> {
// Parse into RawEvent. "hello" -> Hello. "posted" -> data.post must be a JSON *string*; parse
// that string as a Post and `check` it (a missing or non-string post is Json). channel_type is
// data.channel_type when it is a string, else "". Any other event name -> Other(name) (a reply
// has no event: Other("")).
todo!()
}
/// The WebSocket request that shows this bot as typing in a thread (`parent` is the root).
pub fn typing(seq: u64, channel: &str, parent: &str) -> String {
// serde_json::json!({"action": "user_typing", "seq": seq, "data": {"channel_id": channel,
// "parent_id": parent}}) as a string.
todo!()
}
/// What a `posts?since` call gave back.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Since {
/// Posts created after the time, not deleted, oldest first.
pub posts: Vec<Post>,
/// Mattermost's limit was reached: some posts may be missing.
pub full: bool,
}
#[derive(Deserialize)]
struct PostList {
#[serde(default)]
order: Vec<String>,
#[serde(default)]
posts: std::collections::HashMap<String, Post>,
}
/// The body of `GET /channels/{id}/posts?since=<since>`. Only ids in `order` changed after
/// `since`; `posts` also holds the roots of their threads, which may be older. Edited and deleted
/// posts come back too: only new posts count.
pub fn since_list(body: &[u8], since: i64) -> Result<Since, MmError> {
// Parse a PostList. full = order.len() >= SINCE_LIMIT. For each id in `order` (never the keys
// of `posts`), skip it if it is not in `posts`; keep the post if create_at > since and
// delete_at == 0, after `check`. Sort by (create_at, id), remove repeated ids.
todo!()
}
@@ -0,0 +1,95 @@
//! Mattermost's REST calls, one connection per request, with the token in the `Authorization`
//! header. A 429 waits as asked; a 5xx is tried twice more; a 401 or 403 is `Auth` at once.
use std::time::{Duration, SystemTime};
use serde::Deserialize;
use crate::config::valid_id;
use crate::http::{self, Response, rate_limit_wait};
use crate::mm::{Me, MmError, Post, Since, json, since_list};
use crate::net::Connector;
use crate::secrets::Secret;
use crate::ws::conn::host_header;
/// The pause before trying a call again after a 5xx.
pub const RETRY_5XX: Duration = Duration::from_millis(500);
/// How many times a call is tried again after a 5xx, and waits after a 429.
pub const RETRIES: u32 = 2;
/// How much of an error body is kept for the message.
const BODY_KEPT: usize = 200;
pub struct Client {
connector: Connector,
token: Secret,
timeout: Duration,
}
#[derive(Deserialize)]
struct Channel {
id: String,
}
impl Client {
/// `timeout` bounds connecting and each read: no bytes for that long is an error.
pub fn new(connector: Connector, token: Secret, timeout: Duration) -> Client {
// Store the three.
todo!()
}
pub fn connector(&self) -> &Connector {
// The Connector.
todo!()
}
pub fn token(&self) -> &Secret {
// The Secret.
todo!()
}
fn once(&self, method: &str, path: &str, body: Option<&[u8]>) -> Result<Response, MmError> {
// 1. `self.connector.connect(self.timeout)`, then `set_read_timeout(Some(self.timeout))`.
// 2. Headers: Authorization "Bearer <token>" (the only use of `expose`), Accept
// "application/json", and Content-Type "application/json" when there is a body. Host is
// `host_header(self.connector.server())`.
// 3. `http::request`. Every error on the way is Net("<method> <path>: <error>").
todo!()
}
/// One call, tried again as the module comment says; the body of a 2xx answer.
fn call(&self, method: &str, path: &str, body: Option<&[u8]>) -> Result<Vec<u8>, MmError> {
// Loop over `once`: 2xx -> the body. 401 or 403 -> Auth(status) at once. 429 -> after
// RETRIES waits already, RateLimited(wait); else sleep `rate_limit_wait(&head,
// SystemTime::now())` and try again. 5xx -> up to RETRIES more tries, sleeping RETRY_5XX
// before each. Anything else -> Status(status, the first BODY_KEPT characters of the body,
// lossy UTF-8).
todo!()
}
/// `GET /api/v4/users/me`: who this token is.
pub fn me(&self) -> Result<Me, MmError> {
// GET /api/v4/users/me into Me; an id that is not `valid_id` or an empty username is Json.
todo!()
}
/// `POST /api/v4/posts`: a post in `channel`, in the thread of `root` (empty: top level).
pub fn create_post(&self, channel: &str, root: &str, message: &str) -> Result<Post, MmError> {
// POST /api/v4/posts with {"channel_id", "root_id", "message"}; the answer is a Post,
// `check`ed.
todo!()
}
/// `GET /api/v4/channels/{channel}/posts?since=<ms>`: the posts created after `since`.
pub fn posts_since(&self, channel: &str, since: i64) -> Result<Since, MmError> {
// A channel that is not `valid_id` is Json, and nothing is sent. Otherwise GET
// /api/v4/channels/<channel>/posts?since=<since>, through `since_list`.
todo!()
}
/// `POST /api/v4/channels/direct`: the id of the direct channel between two users.
pub fn direct_channel(&self, a: &str, b: &str) -> Result<String, MmError> {
// POST /api/v4/channels/direct with the JSON array [a, b]; the answer has an "id", which
// must be `valid_id` (else Json).
todo!()
}
}
@@ -0,0 +1,168 @@
//! A connection to the Mattermost server: TCP, or TCP with TLS through `rustls`, verified against
//! the host's trusted certificates plus an optional CA file (M4a spec, section 5). Verification
//! cannot be turned off.
use std::io::{Read, Write};
use std::net::{TcpStream, ToSocketAddrs};
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use rustls::pki_types::pem::PemObject;
use rustls::pki_types::{CertificateDer, ServerName};
use rustls::{ClientConfig, ClientConnection, RootCertStore, StreamOwned};
use crate::config::ServerUrl;
#[derive(Debug)]
pub enum NetError {
/// The CA file or the host's certificates could not be loaded.
Roots(String),
/// No address of the server accepted a connection.
Connect(String),
/// The TLS handshake failed: an unknown CA, a wrong name, an old protocol.
Tls(String),
}
impl std::fmt::Display for NetError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
NetError::Roots(why) => write!(f, "cannot load trusted certificates: {why}"),
NetError::Connect(why) => write!(f, "cannot connect: {why}"),
NetError::Tls(why) => write!(f, "TLS failed: {why}"),
}
}
}
impl std::error::Error for NetError {}
/// A connected stream, plain or TLS.
pub enum Stream {
Plain(TcpStream),
Tls(Box<StreamOwned<ClientConnection, TcpStream>>),
}
impl Stream {
/// The TCP socket underneath, for timeouts and shutdown.
pub fn tcp(&self) -> &TcpStream {
// The TcpStream: itself for Plain; `get_ref()` for Tls.
todo!()
}
pub fn set_read_timeout(&self, timeout: Option<Duration>) -> std::io::Result<()> {
// On `self.tcp()`.
todo!()
}
}
impl Read for Stream {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
// Forward to the inner stream for each variant.
todo!()
}
}
impl Write for Stream {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
// Forward to the inner stream for each variant.
todo!()
}
fn flush(&mut self) -> std::io::Result<()> {
// Forward to the inner stream for each variant.
todo!()
}
}
/// Makes connections to one server.
#[derive(Clone)]
pub struct Connector {
server: ServerUrl,
tls: Option<Arc<ClientConfig>>,
}
impl Connector {
/// For `https`, loads the host's trusted certificates and `ca_file`; an error in either is an
/// error here, before any connection.
pub fn new(server: ServerUrl, ca_file: Option<&Path>) -> Result<Connector, NetError> {
// For tls, `client_config(ca_file)?` in an Arc; for plain, None.
todo!()
}
pub fn server(&self) -> &ServerUrl {
// The ServerUrl.
todo!()
}
/// Connect, and for TLS complete the handshake, within `timeout` for each step.
pub fn connect(&self, timeout: Duration) -> Result<Stream, NetError> {
let addrs = (self.server.host.as_str(), self.server.port)
.to_socket_addrs()
.map_err(|e| NetError::Connect(format!("{}: {e}", self.server.host)))?;
let mut last = format!("{} has no address", self.server.host);
let mut tcp = None;
for addr in addrs {
match TcpStream::connect_timeout(&addr, timeout) {
Ok(s) => {
tcp = Some(s);
break;
}
Err(e) => last = format!("{addr}: {e}"),
}
}
let tcp = tcp.ok_or(NetError::Connect(last))?;
tcp.set_read_timeout(Some(timeout))
.map_err(|e| NetError::Connect(e.to_string()))?;
tcp.set_write_timeout(Some(timeout))
.map_err(|e| NetError::Connect(e.to_string()))?;
let _ = tcp.set_nodelay(true);
let Some(config) = &self.tls else {
return Ok(Stream::Plain(tcp));
};
let name = ServerName::try_from(self.server.host.clone())
.map_err(|e| NetError::Tls(e.to_string()))?;
let conn = ClientConnection::new(Arc::clone(config), name)
.map_err(|e| NetError::Tls(e.to_string()))?;
let mut stream = StreamOwned::new(conn, tcp);
while stream.conn.is_handshaking() {
stream
.conn
.complete_io(&mut stream.sock)
.map_err(|e| NetError::Tls(e.to_string()))?;
}
Ok(Stream::Tls(Box::new(stream)))
}
}
fn client_config(ca_file: Option<&Path>) -> Result<ClientConfig, NetError> {
let mut roots = RootCertStore::empty();
let native = rustls_native_certs::load_native_certs();
let (added, _ignored) = roots.add_parsable_certificates(native.certs);
if let Some(path) = ca_file {
let certs: Vec<CertificateDer<'static>> = CertificateDer::pem_file_iter(path)
.map_err(|e| NetError::Roots(format!("{}: {e}", path.display())))?
.collect::<Result<_, _>>()
.map_err(|e| NetError::Roots(format!("{}: {e}", path.display())))?;
if certs.is_empty() {
return Err(NetError::Roots(format!(
"{} holds no certificate",
path.display()
)));
}
for cert in certs {
roots
.add(cert)
.map_err(|e| NetError::Roots(format!("{}: {e}", path.display())))?;
}
} else if added == 0 {
return Err(NetError::Roots(
"the host has no trusted certificates and no ca_file is set".to_string(),
));
}
let provider = Arc::new(rustls::crypto::ring::default_provider());
let config = ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|e| NetError::Roots(e.to_string()))?
.with_root_certificates(roots)
.with_no_client_auth();
Ok(config)
}
@@ -0,0 +1,83 @@
//! The `SecretStore`: a secret from a systemd credential, an environment variable, or an owner-only
//! file (M4a spec, section 4; the brief after P15). A `Secret` cannot be printed.
use std::ffi::OsString;
use std::os::unix::fs::{MetadataExt, PermissionsExt};
use std::path::{Path, PathBuf};
use zeroize::Zeroizing;
use crate::config::SecretSource;
pub const RUNBOOK: &str = "see docs/runbook.md#secret-unavailable";
pub const RUNBOOK_FILE: &str = "see docs/runbook.md#secret-in-a-file";
/// A secret's text. No `Display`; `Debug` shows nothing of it; wiped when dropped.
pub struct Secret(Zeroizing<String>);
impl Secret {
pub fn new(text: String) -> Secret {
Secret(Zeroizing::new(text))
}
/// The text, for the one place that must send it.
pub fn expose(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for Secret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Secret(…)")
}
}
/// A loaded secret, and the warning to print for it, if any.
#[derive(Debug)]
pub struct Loaded {
pub secret: Secret,
pub warning: Option<String>,
}
#[derive(Debug)]
pub struct SecretError {
pub name: String,
pub why: String,
}
impl std::fmt::Display for SecretError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "secret {}: {}\n{RUNBOOK}", self.name, self.why)
}
}
impl std::error::Error for SecretError {}
/// Load secret `name` from `source`. `env` reads an environment variable (in `gatewayd`,
/// `std::env::var_os`); tests pass their own.
pub fn load(
name: &str,
source: &SecretSource,
env: &dyn Fn(&str) -> Option<OsString>,
) -> Result<Loaded, SecretError> {
// By source (spec section 4). Credential: read $CREDENTIALS_DIRECTORY/<name> (through `env`,
// not std::env); an unset variable, or a file that cannot be read, is an error. Env: the
// variable, UTF-8; unset is an error. File: `check_file` first, then read it, and set `warning`
// to the exact text in the task. Every value goes through `value`. Every error is a
// `SecretError` naming the secret, never the value.
todo!()
}
/// An owner-only regular file, not a link.
fn check_file(path: &Path) -> Result<(), String> {
// In this order, each its own error: not absolute; `symlink_metadata` fails; a symbolic link;
// not a regular file; owner uid differs from the uid of /proc/self; mode & 0o077 != 0.
todo!()
}
/// The text without one trailing newline; not empty; UTF-8.
fn value(bytes: Vec<u8>) -> Result<Secret, String> {
// UTF-8 (else an error), one trailing newline removed, not empty. Keep it in `Zeroizing`
// throughout.
todo!()
}
@@ -0,0 +1,78 @@
//! What the event loop does with a post, a finished turn, and the time between: routing, starting
//! turns, typing, and catching up after a gap.
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::deliver::{LOOP_DOWN, deliver};
use crate::mm::{Post, typing};
use crate::serve::{Gateway, Stop};
use crate::sessions::{BUSY, Batch, Ignored, Pushed, Route};
use crate::state::InFlight;
use crate::ws::WsError;
use crate::ws::conn::Ws;
/// Now, in Mattermost's milliseconds.
fn now_ms() -> i64 {
// Milliseconds since the Unix epoch as i64, with try_from (i64::MAX if it does not fit).
todo!()
}
impl Gateway {
/// Post in a thread; a failure is logged, not fatal.
pub(super) fn post(&self, channel: &str, root: &str, text: &str) {
// `self.client.create_post`; an error is logged, "gatewayd: cannot post in <channel>
// (thread <root>): <error>".
todo!()
}
/// Is this channel one whose posts `gatewayd` keeps track of?
fn tracked(&self, channel: &str, channel_type: &str) -> bool {
// channel_type "D", or the channel is in allow.channels.
todo!()
}
/// One new post, live or caught up.
pub(super) fn handle_post(&mut self, post: &Post, channel_type: &str) -> Result<(), Stop> {
// 1. Not tracked, or seen -> nothing. 2. `state.handled(...)?` before anything else. 3.
// Route it: NotAllowed -> log "gatewayd: ignored post <id> from <user>: not allowed"
// (never the message); other Ignore -> nothing; Reply -> post it; Queue -> join the
// thread when joins_thread, then push: Start -> `start`, Waiting -> nothing, Full ->
// post BUSY.
todo!()
}
/// Record the turn as in flight and run it on its own thread.
fn start(&mut self, batch: Batch) -> Result<(), Stop> {
// `state.start_turn` (session, channel, root). Spawn with std::thread::Builder: `deliver`
// with the client, the loop socket, the batch and the log, then send the session on
// done_tx. If spawning fails: log "gatewayd: cannot start a thread for <session>: <e>",
// post LOOP_DOWN in the thread, and send the session on done_tx.
todo!()
}
/// Turns that ended: out of flight, and the next batch of each session started.
pub(super) fn finished(&mut self) -> Result<(), Stop> {
// For each session on done_rx (try_recv, never blocking): `end_turn`, then `queues.finish`;
// a batch it returns is started.
todo!()
}
/// Show this bot as typing in every thread with a turn running.
pub(super) fn typing(&mut self, ws: &mut Ws) -> Result<(), WsError> {
// For each of `queues.threads()`: seq += 1, send `typing(seq, channel, root)` as text.
todo!()
}
/// Answer what arrived while `gatewayd` was away: the direct channel with each allowed user,
/// and each allowed channel. A channel seen for the first time starts from now.
pub(super) fn catch_up(&mut self) -> Result<(), Stop> {
// Channels: the direct channel with each allowed user ("D"; an error is logged "gatewayd:
// no direct channel with <user>: <e>" and skipped), then each allowed channel ("O"). For
// each: no mark -> `mark(channel, now_ms())` and skip; else `posts_since(channel, mark)`
// (an error is logged "gatewayd: cannot catch up <channel>: <e>" and skipped); when full,
// log "gatewayd: <channel>: too many posts to catch up; some may be missed"; `handle_post`
// each post in order.
todo!()
}
}
@@ -0,0 +1,232 @@
//! Startup, the event loop and reconnecting (M4a spec, section 9). `run` returns only when
//! `gatewayd` must stop: a refused token, a state file it cannot keep, or a stop asked by a test.
mod handle;
use std::fs::File;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, Receiver, Sender};
use std::time::{Duration, Instant};
use proto::SessionId;
use crate::config::Config;
use crate::mm::rest::Client;
use crate::mm::{Event, Me, MmError, parse_event};
use crate::net::Connector;
use crate::secrets::Secret;
use crate::sessions::{Queues, Router};
use crate::state::{State, StateError};
use crate::ws::conn::{Timing, Ws};
pub const UNREACHABLE: &str = "see docs/runbook.md#mattermost-unreachable";
pub const AUTH_FAILED: &str = "see docs/runbook.md#mattermost-auth-failed";
pub const START_FAILED: &str = "see docs/runbook.md#gatewayd-start-failed";
pub const INTERRUPTED: &str =
"interrupted: gatewayd restarted before the answer arrived; ask again";
/// A log line: `stderr` in `main`, a record in tests.
pub type Log = Arc<dyn Fn(&str) + Send + Sync>;
/// Why `run` returned.
#[derive(Debug)]
pub enum Stop {
/// Mattermost refused the token (401 or 403).
Auth(u16),
State(StateError),
/// Something `gatewayd` needs at start is missing; the message names it.
Start(String),
/// The stop flag was set.
Asked,
}
impl std::fmt::Display for Stop {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Stop::Auth(status) => {
write!(
f,
"gatewayd: Mattermost refused the token ({status})\n{AUTH_FAILED}"
)
}
Stop::State(e) => write!(f, "gatewayd: {e}"),
Stop::Start(why) => write!(f, "gatewayd: {why}\n{START_FAILED}"),
Stop::Asked => write!(f, "gatewayd: stopped"),
}
}
}
impl From<StateError> for Stop {
fn from(e: StateError) -> Stop {
// Stop::State(e).
todo!()
}
}
/// Timings a test shortens.
#[derive(Debug, Clone)]
pub struct Tuning {
/// The waits between connection attempts; the last repeats.
pub backoff: Vec<Duration>,
/// How long one wait for a WebSocket message lasts, at most.
pub poll: Duration,
/// Connecting and each REST read.
pub rest_timeout: Duration,
}
impl Default for Tuning {
fn default() -> Self {
let secs = [1, 2, 5, 10, 30].map(Duration::from_secs);
Tuning {
backoff: secs.to_vec(),
poll: Duration::from_millis(200),
rest_timeout: Duration::from_secs(30),
}
}
}
/// Everything the event loop works with.
pub(crate) struct Gateway {
config: Config,
client: Arc<Client>,
router: Router,
me: Me,
state: State,
queues: Queues,
loop_socket: PathBuf,
done_tx: Sender<SessionId>,
done_rx: Receiver<SessionId>,
log: Log,
/// The typing requests' sequence number.
seq: u64,
/// The turns a restart cut off have been answered.
restarted: bool,
}
/// The wait before attempt `n` (from 0) after a loss.
pub fn backoff(tuning: &Tuning, n: usize) -> Duration {
// tuning.backoff[n], or its last entry when n is past the end (30 s if the list is empty). No
// indexing.
todo!()
}
/// Sleep for `d`, in short steps, unless `stop` is set.
fn sleep_unless(stop: &AtomicBool, d: Duration) {
// Sleep in steps of at most 20 ms until `d` has passed or `stop` is set.
todo!()
}
/// Run `gatewayd` with its token, until it must stop.
pub fn run(config: Config, token: Secret, tuning: Tuning, log: Log, stop: &AtomicBool) -> Stop {
let server = match config.server() {
Ok(s) => s,
Err(why) => return Stop::Start(why),
};
let connector = match Connector::new(server, config.mattermost.ca_file.as_deref()) {
Ok(c) => c,
Err(e) => return Stop::Start(e.to_string()),
};
let state = match State::load(&config.state_path()) {
Ok(s) => s,
Err(e) => return Stop::State(e),
};
let client = Arc::new(Client::new(connector, token, tuning.rest_timeout));
let mut g = Gateway::new(config, client, state, log);
let mut failures = 0;
loop {
if stop.load(Ordering::SeqCst) {
return Stop::Asked;
}
let stopped = match connect(&g.config, &g.client, &tuning) {
Ok((me, mut ws)) => {
failures = 0;
g.connected(me)
.and_then(|()| g.catch_up())
.and_then(|()| g.event_loop(&mut ws, &tuning, stop))
}
Err(MmError::Auth(status)) => Err(Stop::Auth(status)),
Err(e) => {
let wait = backoff(&tuning, failures);
failures += 1;
(g.log)(&format!(
"gatewayd: cannot reach {}: {e}; trying again in {} s\n{UNREACHABLE}",
g.config.mattermost.url,
wait.as_secs()
));
sleep_unless(stop, wait);
Ok(())
}
};
if let Err(stop) = stopped {
return stop;
}
}
}
/// Who we are, and a WebSocket that has said hello.
fn connect(config: &Config, client: &Client, tuning: &Tuning) -> Result<(Me, Ws), MmError> {
// 1. `client.me()?`. 2. Timing from limits (ping_every_ms, dead_after_ms). 3. Open /dev/urandom
// (an error is Net("/dev/urandom: <e>")). 4. `Ws::open(client.connector(),
// client.token().expose(), timing, ..)`.
// 5. Poll with tuning.poll until a text that `parse_event`s to Hello, for at most dead_after.
// Every WebSocket error, and no hello in time, is Net("websocket: <why>").
todo!()
}
impl Gateway {
fn new(config: Config, client: Arc<Client>, state: State, log: Log) -> Gateway {
// A done channel, a Router with empty ids (set on connect), an empty Me, loop_socket from
// config, Queues with limit limits.queue (usize::try_from), seq 0, restarted false.
todo!()
}
/// A connection is up: say so, route as this user, and on the first one, answer the turns a
/// restart cut off.
fn connected(&mut self, me: Me) -> Result<(), Stop> {
// Log exactly "gatewayd: connected to <url> as <username>". A new Router from me and the
// allow lists; store me. The first time only (`restarted`): for each turn `take_in_flight`
// gives back, post INTERRUPTED in its channel and root. A later reconnect must not: those
// turns are still running.
todo!()
}
/// Read events until the connection is lost (`Ok`) or `gatewayd` must stop.
fn event_loop(&mut self, ws: &mut Ws, tuning: &Tuning, stop: &AtomicBool) -> Result<(), Stop> {
let typing_every = Duration::from_millis(self.config.limits.typing_every_ms);
let mut last_typing = Instant::now();
loop {
if stop.load(Ordering::SeqCst) {
return Err(Stop::Asked);
}
self.finished()?;
if last_typing.elapsed() >= typing_every {
last_typing = Instant::now();
if let Err(e) = self.typing(ws) {
(self.log)(&format!(
"gatewayd: lost the connection: {e}\n{UNREACHABLE}"
));
return Ok(());
}
}
let text = match ws.poll(tuning.poll.min(typing_every)) {
Ok(Some(text)) => text,
Ok(None) => continue,
Err(e) => {
(self.log)(&format!(
"gatewayd: lost the connection: {e}\n{UNREACHABLE}"
));
return Ok(());
}
};
match parse_event(&text) {
Ok(Event::Posted { post, channel_type }) => {
self.handle_post(&post, &channel_type)?
}
Ok(_) => {}
Err(e) => (self.log)(&format!("gatewayd: ignored an event: {e}")),
}
}
}
}
@@ -0,0 +1,162 @@
//! Which posts `gatewayd` acts on, which session each belongs to, commands, and the queue of
//! messages per session (M4a spec, section 7). Pure: the state file and the network are elsewhere.
use std::collections::{BTreeSet, HashMap};
use proto::SessionId;
use crate::mm::Post;
pub const M4B_COMMAND: &str = "approvals over Mattermost arrive in M4b; use `bxctl approvals`";
pub const UNKNOWN_COMMAND: &str = "unknown command; the commands are !approve and !deny";
pub const BUSY: &str = "busy: too many messages are waiting in this conversation";
/// Names that name nobody: every agent in the channel would answer them.
const EVERYONE: [&str; 3] = ["channel", "here", "all"];
/// Why a post was not acted on. Only `NotAllowed` is logged, by user id and post id.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ignored {
Own,
System,
NotAllowed,
NotForUs,
}
/// Where an answer goes: a channel, and the root of the thread in it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Thread {
pub channel: String,
pub root: String,
}
/// A message for a session.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Message {
pub session: SessionId,
pub thread: Thread,
/// A reply in a thread: the session should exist already.
pub resume: bool,
pub text: String,
/// A thread in a channel or group message that this Boxmaker now takes part in.
pub joins_thread: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Route {
Ignore(Ignored),
/// Answer in the thread without a turn (a command).
Reply {
thread: Thread,
text: String,
},
Queue(Message),
}
pub struct Router {
me_id: String,
me_name: String,
users: BTreeSet<String>,
channels: BTreeSet<String>,
}
/// Every `@name` in a message, lower-cased: `a-z`, `0-9`, `.`, `-` and `_` after an `@`, without
/// trailing dots.
pub fn named(message: &str) -> Vec<String> {
// Find each "@". The name after it is the longest run of ASCII letters, digits, ".", "-" and
// "_", with trailing "." removed and lower-cased. Skip empty names. Continue after the name.
todo!()
}
impl Router {
pub fn new(me_id: &str, me_name: &str, users: &[String], channels: &[String]) -> Router {
// Store the ids, the username lower-cased, and the two lists as sets.
todo!()
}
/// Is a post in a channel or group message for this Boxmaker? `known` says whether it has a
/// session for a thread root.
fn for_us(&self, post: &Post, known: &dyn Fn(&str) -> bool) -> bool {
// Named this bot -> true. Otherwise true only for a reply (root_id not empty) in a known
// thread (`known(root_id)`) that names nobody but channel, here or all.
todo!()
}
/// What to do with a new post (a post seen before is dropped by the caller first).
pub fn route(&self, post: &Post, channel_type: &str, known: &dyn Fn(&str) -> bool) -> Route {
// Section 7 of the spec, in this order: own post -> Ignore(Own); kind not empty ->
// Ignore(System); user not allowed -> Ignore(NotAllowed); then the channel: "D" is always
// ours; "O", "P" or "G" is ours when its id is allowed and `for_us`; anything else ->
// Ignore(NotForUs). The thread root is root_id, or the post id when root_id is empty. Then
// commands: "!!..." drops one "!" and goes on as a message; "!" then a first word approve
// or deny -> Reply M4B_COMMAND; any other "!" -> Reply UNKNOWN_COMMAND. Then Queue: session
// "mm-<root>" (if SessionId::new fails, Ignore(NotForUs)), resume = root_id not empty,
// joins_thread = not "D".
todo!()
}
}
/// A turn to send: every message that was waiting, joined with a blank line.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Batch {
pub session: SessionId,
pub thread: Thread,
pub resume: bool,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Pushed {
/// No turn was running: send this one now.
Start(Batch),
/// A turn is running; the message waits for the next.
Waiting,
/// Too many are waiting: the message is dropped, answer `BUSY`.
Full(Thread),
}
struct Pending {
thread: Thread,
resume: bool,
waiting: Vec<String>,
}
/// The sessions with a turn running, and the messages waiting for each.
pub struct Queues {
limit: usize,
running: HashMap<SessionId, Pending>,
}
impl Queues {
/// `limit` is the most messages that may wait per session.
pub fn new(limit: usize) -> Queues {
// An empty map.
todo!()
}
pub fn push(&mut self, message: Message) -> Pushed {
// If the session is running: with `limit` messages already waiting -> Full(thread); else
// add the text to waiting -> Waiting. Otherwise insert it as running (its later batches
// resume: true) and return Start with this message alone.
todo!()
}
/// A session's turn ended: the next batch, or `None`, and the session is no longer running.
pub fn finish(&mut self, session: &SessionId) -> Option<Batch> {
// Not running -> None. Nothing waiting -> remove it, None. Otherwise take every waiting
// text, joined with "\n\n", as the next Batch (it stays running).
todo!()
}
/// How many sessions have a turn running.
pub fn running(&self) -> usize {
// How many sessions are running.
todo!()
}
/// The threads with a turn running, for showing this bot as typing in them.
pub fn threads(&self) -> Vec<Thread> {
// The thread of every running session.
todo!()
}
}
@@ -0,0 +1,158 @@
//! `<home>/gateway/state.json`: what `gatewayd` has handled, the threads it takes part in, and the
//! turns in flight (M4a spec, section 9). Written atomically after every change. A file that
//! cannot be read, parsed or written stops `gatewayd`: guessing would answer posts twice.
use std::collections::BTreeMap;
use std::fs;
use std::io::{self, Write};
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::config::valid_id;
pub const RECENT_KEPT: usize = 500;
pub const THREADS_KEPT: usize = 5000;
pub const RUNBOOK: &str = "see docs/runbook.md#gateway-state-damaged";
#[derive(Debug)]
pub enum StateError {
Read(PathBuf, String),
Write(PathBuf, io::Error),
}
impl std::fmt::Display for StateError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StateError::Read(path, why) => write!(f, "{}: {why}\n{RUNBOOK}", path.display()),
StateError::Write(path, err) => {
write!(f, "{}: cannot write: {err}\n{RUNBOOK}", path.display())
}
}
}
}
impl std::error::Error for StateError {}
/// A turn sent to `loopd` and not yet answered.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InFlight {
pub session: String,
pub channel: String,
pub root: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct StateFile {
channels: BTreeMap<String, i64>,
recent: Vec<String>,
threads: Vec<String>,
in_flight: Vec<InFlight>,
}
impl StateFile {
/// Every id is a Mattermost id; a session is `mm-<id>`.
fn problem(&self) -> Option<String> {
// Every key of channels, every entry of recent and threads must be `valid_id` ("not a
// Mattermost id: <id with {:?}>"). Each in_flight entry: session is "mm-" + a valid id,
// channel and root valid ids ("a turn in flight is not valid: <session with {:?}>"). None
// when all are good.
todo!()
}
}
pub struct State {
path: PathBuf,
file: StateFile,
}
impl State {
/// Read the state; a missing file is a first start.
pub fn load(path: &Path) -> Result<State, StateError> {
// Read the file. Missing (NotFound only) -> an empty StateFile. Any other read error, a
// parse error (serde_json), or `problem()` -> Read(path, why).
todo!()
}
/// Write the state to its path atomically, in six steps; an error leaves the old file.
fn save(&self) -> Result<(), StateError> {
// `persist`, its error as Write(path, error).
todo!()
}
fn persist(&self) -> io::Result<()> {
// The six steps of brokerd/src/state.rs, persist: 1. the parent directory, recursive, 0700;
// 2. serde_json::to_string plus "\n"; 3. "<path>.tmp" (with_extension("json.tmp")), create
// + truncate, mode 0600; 4. write_all and sync_all; 5. rename over the path; 6. open the
// directory and sync_all.
todo!()
}
/// Was this post handled already?
pub fn seen(&self, post_id: &str) -> bool {
// Is the id in recent?
todo!()
}
/// A post was handled (acted on or ignored): remember its id and move its channel's mark.
pub fn handled(
&mut self,
post_id: &str,
channel: &str,
create_at: i64,
) -> Result<(), StateError> {
// If not seen: push the id to recent, then drop the oldest beyond RECENT_KEPT. The channel
// mark becomes the larger of its old value and create_at (a new channel starts at
// create_at). Save.
todo!()
}
/// The `create_at` of the last post handled in a channel, if any.
pub fn since(&self, channel: &str) -> Option<i64> {
// The mark of the channel.
todo!()
}
/// The channels with a mark, for catching up.
pub fn channels(&self) -> Vec<String> {
// The channel ids that have a mark.
todo!()
}
/// Start catching up a channel from `at` (milliseconds), if it has no mark yet.
pub fn mark(&mut self, channel: &str, at: i64) -> Result<(), StateError> {
// A channel that has a mark is left alone (nothing saved). Otherwise set it to `at` and
// save.
todo!()
}
pub fn knows_thread(&self, root: &str) -> bool {
// Is the root in threads?
todo!()
}
/// This Boxmaker takes part in a thread; the newest `THREADS_KEPT` are kept.
pub fn join_thread(&mut self, root: &str) -> Result<(), StateError> {
// Known -> nothing. Else push it, drop the oldest beyond THREADS_KEPT, save.
todo!()
}
pub fn start_turn(&mut self, turn: InFlight) -> Result<(), StateError> {
// Remove any entry of the same session, push this one, save.
todo!()
}
pub fn end_turn(&mut self, session: &str) -> Result<(), StateError> {
// Remove the entries of the session, save.
todo!()
}
/// The turns left in flight by the last run, removed from the state.
pub fn take_in_flight(&mut self) -> Result<Vec<InFlight>, StateError> {
// Take the whole list out (std::mem::take); save only when it was not empty.
todo!()
}
}
@@ -0,0 +1,89 @@
//! One WebSocket connection to Mattermost (M4a spec, section 6): open it, send text, and poll for
//! the next text message while answering pings, sending our own, and noticing a dead peer.
use std::io::{Read, Write};
use std::time::{Duration, Instant};
use crate::config::ServerUrl;
use crate::net::{Connector, Stream};
use crate::ws::WsError;
use crate::ws::frame::{CLOSE, Decoder, Incoming, PING, PONG, TEXT, encode};
use crate::ws::handshake::handshake;
/// Mattermost's WebSocket path.
pub const PATH: &str = "/api/v4/websocket";
/// How often we ping, and how long silence may last.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Timing {
pub ping_every: Duration,
pub dead_after: Duration,
}
pub struct Ws {
stream: Stream,
decoder: Decoder,
random: Box<dyn Read + Send>,
timing: Timing,
last_heard: Instant,
last_ping: Instant,
}
impl Ws {
/// Connect, and complete the handshake with `token`. `random` supplies the key and every mask
/// (in `gatewayd`, `/dev/urandom`).
pub fn open(
connector: &Connector,
token: &str,
timing: Timing,
mut random: Box<dyn Read + Send>,
) -> Result<Ws, WsError> {
// 1. `connector.connect(timing.dead_after)`; its error becomes
// WsError::Handshake(e.to_string()).
// 2. `handshake(&mut stream, &host_header(connector.server()), PATH, token, &mut random)?`.
// 3. A Ws with a new Decoder, and last_heard and last_ping both now.
todo!()
}
fn send(&mut self, opcode: u8, payload: &[u8]) -> Result<(), WsError> {
// 4 mask bytes from `random` (read_exact), then write `encode(opcode, payload, mask)` and
// flush.
todo!()
}
pub fn send_text(&mut self, text: &str) -> Result<(), WsError> {
// `send` with TEXT.
todo!()
}
/// The next text message, or `None` after about `wait` with none. Pings are answered and sent
/// here; a close frame is answered and ends the connection (`Closed`); silence past the
/// dead-after limit is `Dead`.
pub fn poll(&mut self, wait: Duration) -> Result<Option<String>, WsError> {
// Loop, with `until = now + wait`:
// 1. Every whole message the decoder has: Text -> return it; Ping(p) -> send PONG with p;
// Pong -> nothing; Close(code, _) -> send CLOSE with the code as 2 bytes (or empty),
// ignore that error, return Closed.
// 2. Silence since last_heard >= dead_after -> Dead.
// 3. Since last_ping >= ping_every -> send an empty PING, last_ping = now.
// 4. now >= until -> Ok(None).
// 5. Read timeout: the least of (until - now), (last_ping + ping_every - now) and
// (last_heard + dead_after - now), at least 1 ms. Read into a 16 KiB buffer: 0 bytes ->
// Closed; n bytes -> feed them, last_heard = now; WouldBlock, TimedOut or Interrupted ->
// go round; any other error -> Io.
todo!()
}
/// Send a close frame, best effort, and drop the connection.
pub fn close(mut self) {
// Send CLOSE with 1000 as 2 big-endian bytes; ignore the error.
todo!()
}
}
/// The `Host` header for a server: the port is written only when it is not the scheme's default.
pub fn host_header(server: &ServerUrl) -> String {
// The host alone when the port is the default for the scheme (443 for tls, 80 otherwise), else
// "host:port".
todo!()
}
@@ -0,0 +1,91 @@
//! WebSocket frames (RFC 6455, section 5), without I/O. `Decoder` is fed the bytes as they arrive,
//! in any pieces, and yields whole messages; `encode` builds our masked frames. Everything the
//! server sends is untrusted: every length is checked before anything is allocated.
use crate::ws::WsError;
/// The largest message we accept, counted from the length fields.
pub const MAX_MESSAGE: usize = 1 << 20;
pub const CONTINUATION: u8 = 0x0;
pub const TEXT: u8 = 0x1;
pub const CLOSE: u8 = 0x8;
pub const PING: u8 = 0x9;
pub const PONG: u8 = 0xA;
/// A whole message from the server.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Incoming {
Text(String),
Ping(Vec<u8>),
Pong(Vec<u8>),
/// A close frame: the status code if there is one, and the reason.
Close(Option<u16>, String),
}
/// Reassembles frames into messages.
#[derive(Debug, Default)]
pub struct Decoder {
buf: Vec<u8>,
/// A text message whose first frame has come and whose last has not.
partial: Option<Vec<u8>>,
}
/// A parsed header: what it says and how long it is.
struct Header {
fin: bool,
opcode: u8,
header_len: usize,
payload_len: usize,
}
impl Decoder {
pub fn new() -> Decoder {
Decoder::default()
}
/// Append bytes as they arrived.
pub fn feed(&mut self, bytes: &[u8]) {
self.buf.extend_from_slice(bytes);
}
/// The next whole message, `None` if more bytes are needed, or the error that ends the
/// connection. After an error, do not call again.
pub fn next_message(&mut self) -> Result<Option<Incoming>, WsError> {
// Loop: `self.header()?`, None -> Ok(None). If the buffer holds less than header_len +
// payload_len, Ok(None). Otherwise take the payload out and drain the frame from the
// buffer, then by opcode: PING -> Ping, PONG -> Pong, CLOSE -> `close(&payload)`. TEXT
// starts a new message, CONTINUATION extends `partial`; with fin the whole message must be
// UTF-8 (else Protocol) and is returned as Text; without fin it is kept in `partial` and
// the loop goes on. Any other opcode is Protocol.
todo!()
}
/// The next frame's header, once it is all here, checked against every rule that does not need
/// the payload.
fn header(&self) -> Result<Option<Header>, WsError> {
// Return Ok(None) while the bytes the header needs have not all arrived. The rules, each an
// error: a reserved bit (0x70 of byte 0) is Protocol; the mask bit (0x80 of byte 1) is
// Protocol; an opcode that is not CONTINUATION, TEXT, CLOSE, PING or PONG is Protocol.
// Length 126: a u16 in the next 2 bytes, below 126 is Protocol (not the shortest form).
// 127: a u64 in the next 8 bytes; top bit set is Protocol; <= 0xFFFF is Protocol. Control
// frames (opcode & 0x8): not fin, or over 125 bytes, is Protocol. Data frames: TEXT while
// `partial` is Some, or CONTINUATION while it is None, is Protocol; a payload over
// MAX_MESSAGE minus what `partial` holds is TooLarge. All of it before any allocation.
todo!()
}
}
fn close(payload: &[u8]) -> Result<Incoming, WsError> {
// Empty: Close(None, ""). One byte: Protocol. Otherwise a big-endian u16 code and a UTF-8
// reason (else Protocol).
todo!()
}
/// One whole frame from us: FIN set, masked with `mask`.
pub fn encode(opcode: u8, payload: &[u8], mask: [u8; 4]) -> Vec<u8> {
// Byte 0: 0x80 | opcode. Byte 1: 0x80 | length, where the length is the 7-bit form below 126,
// 126 then a u16 up to 0xFFFF, else 127 then a u64. Then the 4 mask bytes, then each payload
// byte XOR mask[i % 4]. No `as` casts: use try_from.
todo!()
}
@@ -0,0 +1,60 @@
//! The opening handshake (RFC 6455, section 4.1) and the base64 it needs.
use std::io::{Read, Write};
use crate::http::{Head, read_head};
use crate::ws::WsError;
/// RFC 6455's magic string, appended to the key before hashing.
pub const GUID: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/// Standard base64 with padding (RFC 4648, section 4).
pub fn base64(bytes: &[u8]) -> String {
// Standard alphabet (ALPHABET), "=" padding: each 3 bytes become 4 characters; a last group of
// 1 or 2 bytes becomes 2 or 3 characters and 2 or 1 "=". No indexing that can go out of bounds.
todo!()
}
/// The `Sec-WebSocket-Accept` a server must send for `key`.
pub fn accept_for(key: &str) -> String {
// base64(sha1(key + GUID)), with `proto::sha1::sha1`.
todo!()
}
/// A fresh key: 16 bytes from `random` (in `gatewayd`, `/dev/urandom`), in base64.
pub fn new_key(random: &mut dyn Read) -> std::io::Result<String> {
// 16 bytes read from `random` with read_exact, then base64.
todo!()
}
/// The request, exactly.
pub fn request_text(host: &str, path: &str, key: &str, token: &str) -> String {
format!(
"GET {path} HTTP/1.1\r\nHost: {host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\
Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\nAuthorization: Bearer {token}\r\n\r\n"
)
}
/// Is `head` a server's acceptance of `key`? Status 101, `Upgrade: websocket`, a `Connection`
/// holding the token `upgrade`, and the right `Sec-WebSocket-Accept` (case matters there).
pub fn check_response(head: &Head, key: &str) -> Result<(), WsError> {
// In this order, each a Handshake error: status is not 101 ("status <n>"); no Upgrade header
// equal to "websocket" ignoring case; no Connection header with a comma-separated token equal
// to "upgrade" ignoring case; Sec-WebSocket-Accept missing, or not exactly `accept_for(key)`.
todo!()
}
/// The whole handshake on `stream`. Nothing after the server's head is read.
pub fn handshake(
stream: &mut (impl Read + Write),
host: &str,
path: &str,
token: &str,
random: &mut dyn Read,
) -> Result<(), WsError> {
// A new key; write `request_text` and flush; `read_head` (its error is a Handshake error); then
// `check_response`. Read nothing after the head.
todo!()
}
@@ -0,0 +1,42 @@
//! The WebSocket client (RFC 6455; M4a spec, section 6): the handshake, the frame codec, and the
//! connection that uses them.
pub mod handshake;
/// Why a WebSocket ended or could not start. Every one of these ends the connection; `gatewayd`
/// then reconnects.
#[derive(Debug)]
pub enum WsError {
/// The server's answer to the handshake was not an upgrade to a WebSocket.
Handshake(String),
/// A frame broke the protocol.
Protocol(String),
/// A message over `MAX_MESSAGE`, refused from its length fields.
TooLarge,
/// The server closed the connection (a close frame, or the end of the stream).
Closed,
/// Nothing was heard for the dead-after limit.
Dead,
Io(std::io::Error),
}
impl std::fmt::Display for WsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
WsError::Handshake(why) => write!(f, "WebSocket handshake failed: {why}"),
WsError::Protocol(why) => write!(f, "WebSocket protocol error: {why}"),
WsError::TooLarge => write!(f, "WebSocket message too large"),
WsError::Closed => write!(f, "WebSocket closed"),
WsError::Dead => write!(f, "WebSocket silent for too long"),
WsError::Io(e) => write!(f, "WebSocket I/O: {e}"),
}
}
}
impl std::error::Error for WsError {}
impl From<std::io::Error> for WsError {
fn from(e: std::io::Error) -> Self {
WsError::Io(e)
}
}
@@ -0,0 +1,246 @@
//! `gatewayd.toml` (M4a spec, section 3). Do not edit.
#[path = "support/tmp.rs"]
mod tmp;
use std::path::PathBuf;
use gatewayd::config::{Config, ConfigError, SecretSource, ServerUrl, parse_url, valid_id};
use tmp::TempDir;
const OWNER: &str = "abcdefghijklmnopqrstuvwxyz";
const CHANNEL: &str = "0123456789abcdefghijklmnop";
fn minimal() -> String {
format!(
"[mattermost]\nurl = \"https://straylight.scylla-hammerhead.ts.net\"\n\
[secrets.mattermost_token]\ncredential = \"mattermost-token\"\n\
[allow]\nusers = [\"{OWNER}\"]\n"
)
}
fn load(text: &str) -> Result<Config, ConfigError> {
let dir = TempDir::new("cfg");
let path = dir.write("gatewayd.toml", text);
Config::load(&path)
}
fn invalid(text: &str) -> String {
match load(text) {
Err(ConfigError::Invalid(_, why)) => why,
other => panic!("expected Invalid for {text:?}, got {other:?}"),
}
}
#[test]
fn a_minimal_config_gets_every_default() {
let c = load(&minimal()).unwrap();
assert_eq!(
c.server().unwrap(),
ServerUrl {
tls: true,
host: "straylight.scylla-hammerhead.ts.net".to_string(),
port: 443
}
);
assert_eq!(c.mattermost.ca_file, None);
assert_eq!(
c.token_source().unwrap(),
SecretSource::Credential("mattermost-token".to_string())
);
assert_eq!(c.allow.users, vec![OWNER.to_string()]);
assert!(c.allow.channels.is_empty());
assert_eq!((c.limits.queue, c.limits.typing_every_ms), (20, 3_000));
assert_eq!(
(c.limits.ping_every_ms, c.limits.dead_after_ms),
(30_000, 60_000)
);
let home = std::env::var_os("BOXMAKER_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/var/lib/boxmaker"));
assert_eq!(c.loop_socket(), home.join("run/loop/loop.sock"));
assert_eq!(c.state_path(), home.join("gateway/state.json"));
}
#[test]
fn every_value_can_be_set() {
let text = format!(
"[mattermost]\nurl = \"http://127.0.0.1:8065\"\nca_file = \"/etc/boxmaker/ca.pem\"\n\
[secrets.mattermost_token]\nfile = \"/home/k/.config/boxmaker/token\"\n\
[allow]\nusers = [\"{OWNER}\"]\nchannels = [\"{CHANNEL}\"]\n\
[loop]\nsocket = \"/run/l.sock\"\n[paths]\nhome = \"/h\"\n\
[limits]\nqueue = 5\ntyping_every_ms = 1\nping_every_ms = 2\ndead_after_ms = 3\n"
);
let c = load(&text).unwrap();
assert_eq!(
c.server().unwrap(),
ServerUrl {
tls: false,
host: "127.0.0.1".to_string(),
port: 8065
}
);
assert_eq!(
c.mattermost.ca_file,
Some(PathBuf::from("/etc/boxmaker/ca.pem"))
);
assert_eq!(
c.token_source().unwrap(),
SecretSource::File(PathBuf::from("/home/k/.config/boxmaker/token"))
);
assert_eq!(c.allow.channels, vec![CHANNEL.to_string()]);
assert_eq!(c.loop_socket(), PathBuf::from("/run/l.sock"));
assert_eq!(c.state_path(), PathBuf::from("/h/gateway/state.json"));
assert_eq!(
(
c.limits.queue,
c.limits.typing_every_ms,
c.limits.ping_every_ms,
c.limits.dead_after_ms
),
(5, 1, 2, 3)
);
}
#[test]
fn an_env_secret() {
let text = minimal().replace(
"credential = \"mattermost-token\"",
"env = \"BOXMAKER_MM_TOKEN\"",
);
assert_eq!(
load(&text).unwrap().token_source().unwrap(),
SecretSource::Env("BOXMAKER_MM_TOKEN".to_string())
);
}
#[test]
fn urls() {
for (url, tls, host, port) in [
("https://a.example", true, "a.example", 443),
("https://a.example:8443", true, "a.example", 8443),
("http://localhost", false, "localhost", 80),
("http://127.0.0.1:8065", false, "127.0.0.1", 8065),
] {
assert_eq!(
parse_url(url),
Ok(ServerUrl {
tls,
host: host.to_string(),
port
}),
"{url}"
);
}
for url in [
"",
"a.example",
"ftp://a.example",
"https://",
"https://a.example/",
"https://a.example/api",
"https://A.example",
"https://a.example:0",
"https://a.example:65536",
"https://a.example:0443",
"https://a.example:",
"https://user@a.example",
"https://a.example?x",
"https://.a.example",
"https://a.example.",
"https://[::1]:443",
"https:// a.example",
] {
assert!(parse_url(url).is_err(), "{url:?} must be refused");
}
}
#[test]
fn ids() {
assert!(valid_id(OWNER));
assert!(valid_id(CHANNEL));
for id in [
"",
"abc",
"abcdefghijklmnopqrstuvwxyZ",
"abcdefghijklmnopqrstuvwxy-",
"abcdefghijklmnopqrstuvwxyza",
] {
assert!(!valid_id(id), "{id:?}");
}
}
#[test]
fn bad_values_are_named() {
let token = "credential = \"mattermost-token\"";
let cases: Vec<(String, &str)> = vec![
(
minimal().replace(
"https://straylight.scylla-hammerhead.ts.net",
"https://x.example/path",
),
"url",
),
(
minimal()
.replace(
"[secrets.mattermost_token]",
"[mattermost2]\n[secrets.other]",
)
.replace("[mattermost2]\n", ""),
"mattermost_token",
),
(
minimal().replace(token, "credential = \"a b\""),
"credential",
),
(minimal().replace(token, "env = \"lower\""), "env"),
(
minimal().replace(token, "file = \"relative/token\""),
"absolute",
),
(
minimal().replace(token, "credential = \"x\"\nenv = \"Y\""),
"exactly one",
),
(minimal().replace(token, ""), "exactly one"),
(
minimal().replace(&format!("[\"{OWNER}\"]"), "[]"),
"users is empty",
),
(minimal().replace(OWNER, "tooshort"), "not a Mattermost id"),
(
format!("{}channels = [\"NOTANID\"]\n", minimal()),
"not a Mattermost id",
),
(
minimal().replace("[mattermost]\n", "[mattermost]\nca_file = \"ca.pem\"\n"),
"ca_file",
),
(format!("{}[limits]\nqueue = 0\n", minimal()), "queue"),
(
format!("{}[limits]\ndead_after_ms = 0\n", minimal()),
"dead_after_ms",
),
];
for (text, word) in cases {
let why = invalid(&text);
assert!(why.contains(word), "{word}: {why}");
}
}
#[test]
fn unknown_keys_and_missing_tables_are_parse_errors() {
for text in [
format!("{}[allow2]\n", minimal()),
minimal().replace("[mattermost]\n", "[mattermost]\nproxy = \"x\"\n"),
minimal().replace(
"credential = \"mattermost-token\"",
"credential = \"t\"\nkeyring = \"x\"",
),
minimal().replace(&format!("[allow]\nusers = [\"{OWNER}\"]\n"), ""),
format!("{}[limits]\nqueue = -1\n", minimal()),
] {
assert!(matches!(load(&text), Err(ConfigError::Parse(..))), "{text}");
}
}
@@ -0,0 +1,304 @@
//! A turn on `loop.sock` and its answer in the thread, against a fake `loopd`: the answer, long
//! answers, approvals, errors, an unknown session, and a loop that is not there (M4a spec, section
//! 8). Do not edit.
#[path = "support/fake_loop.rs"]
mod fake_loop;
#[path = "support/tmp.rs"]
mod tmp;
use std::sync::Mutex;
use std::time::Duration;
use fake_loop::{Reply, done, error, event, serve_loop};
use gatewayd::deliver::{EMPTY_ANSWER, LOOP_DOWN, MAX_POST, Poster, deliver, split_answer};
use gatewayd::mm::MmError;
use gatewayd::sessions::{Batch, Thread};
use proto::{DataClass, ErrorCode, SessionId, Timestamp, TurnEvent};
use tmp::TempDir;
const DM: &str = "d0000000000000000000000000";
const ROOT: &str = "r0000000000000000000000000";
#[derive(Default)]
struct Record {
posts: Mutex<Vec<(String, String, String)>>,
fail: bool,
}
impl Poster for Record {
fn post(&self, channel: &str, root: &str, text: &str) -> Result<(), MmError> {
if self.fail {
return Err(MmError::Status(500, "down".to_string()));
}
self.posts
.lock()
.unwrap()
.push((channel.to_string(), root.to_string(), text.to_string()));
Ok(())
}
}
impl Record {
fn texts(&self) -> Vec<String> {
let posts = self.posts.lock().unwrap();
assert!(
posts.iter().all(|(c, r, _)| c == DM && r == ROOT),
"every post in the thread"
);
posts.iter().map(|(_, _, t)| t.clone()).collect()
}
}
fn batch(resume: bool, text: &str) -> Batch {
Batch {
session: SessionId::new(&format!("mm-{ROOT}")).unwrap(),
thread: Thread {
channel: DM.to_string(),
root: ROOT.to_string(),
},
resume,
text: text.to_string(),
}
}
fn run(dir: &TempDir, poster: &Record, batch: &Batch) -> Vec<String> {
let log = Mutex::new(Vec::new());
deliver(poster, &dir.path().join("loop.sock"), batch, &|line| {
log.lock().unwrap().push(line.to_string())
});
log.into_inner().unwrap()
}
#[test]
fn the_answer_is_posted_and_nothing_else() {
let dir = TempDir::new("deliver-answer");
let turns = serve_loop(&dir.path().join("loop.sock"), |_, _| {
vec![
event(TurnEvent::Progress {
total: 10,
cache: 0,
processed: 10,
}),
event(TurnEvent::Reasoning {
text: "private thoughts".to_string(),
}),
event(TurnEvent::ToolCallStarted {
name: "read_file".to_string(),
}),
event(TurnEvent::ToolResult {
name: "read_file".to_string(),
class: DataClass::Private,
truncated: false,
}),
event(TurnEvent::Content {
text: "The ans".to_string(),
}),
done("The answer."),
]
});
let poster = Record::default();
let log = run(&dir, &poster, &batch(true, "one\n\ntwo"));
assert_eq!(poster.texts(), ["The answer."]);
assert!(log.is_empty(), "{log:?}");
let turn = turns.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(
(turn.session.as_str(), turn.content.as_str(), turn.resume),
(format!("mm-{ROOT}").as_str(), "one\n\ntwo", true)
);
}
#[test]
fn an_approval_is_announced_once_before_the_answer() {
let dir = TempDir::new("deliver-approval");
let expires = Timestamp::from_unix_millis(1_758_650_000_000).unwrap();
let _turns = serve_loop(&dir.path().join("loop.sock"), move |_, _| {
vec![
event(TurnEvent::ApprovalPending {
approval: 42,
tool: "shell".to_string(),
expires,
}),
done("done"),
]
});
let poster = Record::default();
run(&dir, &poster, &batch(false, "go"));
assert_eq!(
poster.texts(),
[
"waiting for approval 42: approve or deny it with `bxctl` (Mattermost approvals arrive in M4b)",
"done"
]
);
}
#[test]
fn errors_are_posted_with_their_code() {
let dir = TempDir::new("deliver-error");
let _turns = serve_loop(&dir.path().join("loop.sock"), |_, _| {
vec![error(
ErrorCode::Inference,
"the model server failed\nsee docs/runbook.md#loopd-selftest-failed",
)]
});
let poster = Record::default();
run(&dir, &poster, &batch(false, "go"));
assert_eq!(
poster.texts(),
["Error: inference: the model server failed\nsee docs/runbook.md#loopd-selftest-failed"]
);
}
#[test]
fn a_reply_in_a_thread_loopd_does_not_know_creates_the_session() {
let dir = TempDir::new("deliver-unknown");
let turns = serve_loop(&dir.path().join("loop.sock"), |n, _| {
if n == 0 {
vec![error(ErrorCode::NoSuchSession, "no such session")]
} else {
vec![done("hello")]
}
});
let poster = Record::default();
run(&dir, &poster, &batch(true, "hi"));
assert_eq!(poster.texts(), ["hello"]);
let first = turns.recv_timeout(Duration::from_secs(5)).unwrap();
let second = turns.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(
(first.resume, second.resume, second.content.as_str()),
(true, false, "hi")
);
}
#[test]
fn a_new_session_is_not_retried() {
let dir = TempDir::new("deliver-noretry");
let turns = serve_loop(&dir.path().join("loop.sock"), |_, _| {
vec![error(ErrorCode::NoSuchSession, "odd")]
});
let poster = Record::default();
run(&dir, &poster, &batch(false, "hi"));
assert_eq!(poster.texts(), ["Error: no_such_session: odd"]);
assert!(turns.recv_timeout(Duration::from_secs(5)).is_ok());
assert!(
turns.recv_timeout(Duration::from_millis(200)).is_err(),
"one turn only"
);
}
#[test]
fn a_loop_that_is_not_there_or_goes_away() {
let dir = TempDir::new("deliver-down");
let poster = Record::default();
let log = run(&dir, &poster, &batch(false, "hi"));
assert_eq!(poster.texts(), [LOOP_DOWN]);
assert_eq!(log.len(), 1, "{log:?}");
assert!(
log[0].starts_with(&format!("gatewayd: mm-{ROOT}: cannot connect to ")),
"{log:?}"
);
for (n, replies) in [
vec![
event(TurnEvent::Content {
text: "x".to_string(),
}),
Reply::Close,
],
vec![Reply::Bytes(b"\x00\x00\x00\x05{bad}".to_vec())],
vec![
Reply::Frame(proto::Envelope {
v: 1,
id: 2,
r#final: true,
msg: proto::Message::Ok(proto::Empty {}),
}),
done("late"),
],
]
.into_iter()
.enumerate()
{
let dir = TempDir::new(&format!("deliver-early-{n}"));
let replies = Mutex::new(Some(replies));
let _turns = serve_loop(&dir.path().join("loop.sock"), move |_, _| {
replies.lock().unwrap().take().unwrap_or_default()
});
let poster = Record::default();
let log = run(&dir, &poster, &batch(false, "hi"));
assert_eq!(poster.texts(), [LOOP_DOWN], "case {n}");
assert_eq!(log.len(), 1, "case {n}: {log:?}");
}
}
#[test]
fn a_post_that_fails_is_logged() {
let dir = TempDir::new("deliver-postfail");
let _turns = serve_loop(&dir.path().join("loop.sock"), |_, _| vec![done("lost")]);
let poster = Record {
fail: true,
..Record::default()
};
let log = run(&dir, &poster, &batch(false, "hi"));
assert_eq!(
log,
[format!(
"gatewayd: cannot post in {DM} (thread {ROOT}): status 500: \"down\""
)]
);
}
#[test]
fn long_answers_are_split_at_newlines() {
let short = "a".repeat(MAX_POST);
assert_eq!(split_answer(&short), [short.as_str()]);
let over = "a".repeat(MAX_POST + 1);
assert_eq!(split_answer(&over), ["a".repeat(MAX_POST), "a".to_string()]);
let lines = format!(
"{}\n{}\n{}",
"a".repeat(10_000),
"b".repeat(5_000),
"c".repeat(2_000)
);
assert_eq!(
split_answer(&lines),
[
format!("{}\n{}", "a".repeat(10_000), "b".repeat(5_000)),
"c".repeat(2_000)
]
);
let wide = "é".repeat(MAX_POST + 5);
let parts = split_answer(&wide);
assert_eq!(
parts.iter().map(|p| p.chars().count()).collect::<Vec<_>>(),
[MAX_POST, 5],
"characters, not bytes"
);
let leading = format!("\n{}", "x".repeat(MAX_POST + 1));
let parts = split_answer(&leading);
assert!(
parts
.iter()
.all(|p| !p.is_empty() && p.chars().count() <= MAX_POST),
"{:?}",
parts.iter().map(|p| p.len()).collect::<Vec<_>>()
);
assert_eq!(parts.concat(), leading, "a hard cut drops nothing");
assert_eq!(split_answer(""), [EMPTY_ANSWER]);
assert_eq!(split_answer(" \n "), [EMPTY_ANSWER]);
}
#[test]
fn a_long_answer_is_posted_in_order() {
let dir = TempDir::new("deliver-long");
let answer = format!("{}\n{}", "a".repeat(MAX_POST - 1), "b".repeat(MAX_POST));
let sent = answer.clone();
let _turns = serve_loop(&dir.path().join("loop.sock"), move |_, _| vec![done(&sent)]);
let poster = Record::default();
run(&dir, &poster, &batch(false, "hi"));
assert_eq!(
poster.texts(),
["a".repeat(MAX_POST - 1), "b".repeat(MAX_POST)]
);
}
@@ -0,0 +1,12 @@
# TEST-ONLY TLS fixtures
Generated once with `openssl` on 2026-09-23 for `gatewayd`'s TLS tests. Every key here is public
and must never be trusted anywhere but these tests. The two CA private keys were deleted after
signing; the server keys are kept because the test servers need them.
- `test-ca.pem`: the CA the tests trust (through `ca_file`).
- `server.pem`/`server.key`: `localhost` and `127.0.0.1`, signed by `test-ca`.
- `wrong-name.pem`/`wrong-name.key`: `wrong.example` only, signed by `test-ca` (a name mismatch).
- `other-ca.pem`, `other-server.pem`/`other-server.key`: a CA the tests do not trust.
Valid for 100 years from 2026-09-23.
@@ -0,0 +1,12 @@
-----BEGIN CERTIFICATE-----
MIIBszCCAVmgAwIBAgIUZK7y645vuYezo+uB8S2ktvXjLBUwCgYIKoZIzj0EAwIw
JjEkMCIGA1UEAwwbQm94bWFrZXIgVEVTVC1PTkxZIG90aGVyIENBMCAXDTI2MDky
NDAwNDgxN1oYDzIxMjYwODMxMDA0ODE3WjAmMSQwIgYDVQQDDBtCb3htYWtlciBU
RVNULU9OTFkgb3RoZXIgQ0EwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQV1sRm
ePvJps71wo1/QVUi8Y0Ra4kFhJ0WI7VLIqeINPpaQtBKHUH+SWrjR4mromEtJ8ZR
d3frK7jBFmI+AVmLo2MwYTAdBgNVHQ4EFgQUUBg/R1eSkXP7swTjnoEFSvcIGcww
HwYDVR0jBBgwFoAUUBg/R1eSkXP7swTjnoEFSvcIGcwwDwYDVR0TAQH/BAUwAwEB
/zAOBgNVHQ8BAf8EBAMCAgQwCgYIKoZIzj0EAwIDSAAwRQIhAIKFnNomDrIwpeOG
wdsm8NfXWydx7Mp2/ujRCXCqMyrAAiAo2hoprQhU3uRmyrTtokBAqE5kWFSKOa6K
BlnVrcFSvw==
-----END CERTIFICATE-----
@@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg940CxyChSA5oYsx2
W6tmkg66INWcYxOOfUcqkvi9TBehRANCAAQBOwVQrucb42OCoWScE/Grn6DnmMBk
6yceR+ZNU9wvYwMKBovg6sErdvjACNlYIsAkjjRuO7xYQbrxJ4ixoY3O
-----END PRIVATE KEY-----
@@ -0,0 +1,12 @@
-----BEGIN CERTIFICATE-----
MIIB2zCCAYCgAwIBAgIUWxsnA8gxRlvuY1SrXg+eQxRDq7cwCgYIKoZIzj0EAwIw
JjEkMCIGA1UEAwwbQm94bWFrZXIgVEVTVC1PTkxZIG90aGVyIENBMCAXDTI2MDky
NDAwNDgxN1oYDzIxMjYwODMxMDA0ODE3WjAuMSwwKgYDVQQDDCNsb2NhbGhvc3Qg
ZnJvbSBvdGhlciBDQSAoVEVTVCBPTkxZKTBZMBMGByqGSM49AgEGCCqGSM49AwEH
A0IABAE7BVCu5xvjY4KhZJwT8aufoOeYwGTrJx5H5k1T3C9jAwoGi+DqwSt2+MAI
2VgiwCSONG47vFhBuvEniLGhjc6jgYEwfzAaBgNVHREEEzARgglsb2NhbGhvc3SH
BH8AAAEwDAYDVR0TAQH/BAIwADATBgNVHSUEDDAKBggrBgEFBQcDATAdBgNVHQ4E
FgQUdpTjXgMrZvKV54i+QnheUhd17IswHwYDVR0jBBgwFoAUUBg/R1eSkXP7swTj
noEFSvcIGcwwCgYIKoZIzj0EAwIDSQAwRgIhAJqpsTc14FSZpyWvmn6G0Ar2bxLz
CYQNanzxCPLMDGTCAiEA/F1wQjxrCikZAfuQKBKL5cc2MHf2dsSjZq2Sg5OC6z0=
-----END CERTIFICATE-----
@@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgtj+G5HUdct3YHcZ2
rvTnI3blyTjkfEiwVwGTqOINYZChRANCAAQcwnbJi8KAjVQEQd/mIXFCSDGNcy9V
XRx5uZ+wqnAUqbmsj+LHl6q9KM1Y3bowFBIHQOjpBWvy8JA0oPRJPLWM
-----END PRIVATE KEY-----
@@ -0,0 +1,13 @@
-----BEGIN CERTIFICATE-----
MIIB4zCCAYigAwIBAgIUNQfYYHxiZvBYa5P4sYqm4yJqf9MwCgYIKoZIzj0EAwIw
PDE6MDgGA1UEAwwxQm94bWFrZXIgVEVTVC1PTkxZIENBIChuZXZlciB0cnVzdCBv
dXRzaWRlIHRlc3RzKTAgFw0yNjA5MjQwMDQ4MTdaGA8yMTI2MDgzMTAwNDgxN1ow
IDEeMBwGA1UEAwwVbG9jYWxob3N0IChURVNUIE9OTFkpMFkwEwYHKoZIzj0CAQYI
KoZIzj0DAQcDQgAEHMJ2yYvCgI1UBEHf5iFxQkgxjXMvVV0cebmfsKpwFKm5rI/i
x5eqvSjNWN26MBQSB0Do6QVr8vCQNKD0STy1jKOBgTB/MBoGA1UdEQQTMBGCCWxv
Y2FsaG9zdIcEfwAAATAMBgNVHRMBAf8EAjAAMBMGA1UdJQQMMAoGCCsGAQUFBwMB
MB0GA1UdDgQWBBRtebrcLsFuJloYqBEhrWKh4ghINTAfBgNVHSMEGDAWgBRZLO26
Eow46wSsnj/mQBJ5Hi7VZDAKBggqhkjOPQQDAgNJADBGAiEAn215O/7cosHkI5n4
7Kuq+30BXfrqBHnZ6FznHQIgIjsCIQDW8om0qRjIo5dXNIY4DLj757+KaleqaQdE
oFc079H45g==
-----END CERTIFICATE-----
@@ -0,0 +1,13 @@
-----BEGIN CERTIFICATE-----
MIIB3zCCAYWgAwIBAgIURpYQTJ2pH+M6c7wb2aMwpr96TQUwCgYIKoZIzj0EAwIw
PDE6MDgGA1UEAwwxQm94bWFrZXIgVEVTVC1PTkxZIENBIChuZXZlciB0cnVzdCBv
dXRzaWRlIHRlc3RzKTAgFw0yNjA5MjQwMDQ4MTdaGA8yMTI2MDgzMTAwNDgxN1ow
PDE6MDgGA1UEAwwxQm94bWFrZXIgVEVTVC1PTkxZIENBIChuZXZlciB0cnVzdCBv
dXRzaWRlIHRlc3RzKTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABMeSQ30pi+FZ
85pHjd7+q6bo30eJGcdwmiK2MwlASDejQb0nA4cOWqPLGdlNO4o5679DwiigSUnv
yh/V1yJ4KyqjYzBhMB0GA1UdDgQWBBRZLO26Eow46wSsnj/mQBJ5Hi7VZDAfBgNV
HSMEGDAWgBRZLO26Eow46wSsnj/mQBJ5Hi7VZDAPBgNVHRMBAf8EBTADAQH/MA4G
A1UdDwEB/wQEAwICBDAKBggqhkjOPQQDAgNIADBFAiA+LzwUA1QvGOcDNxMbnbb8
ycfuH+i16pebeH3rcJIwDAIhALgKBj1r2ItuB/Rag8Y0sYs9rx5Arlikzg2VGWoT
CYbm
-----END CERTIFICATE-----
@@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgflra3VFKl15oCyVi
0KDJ52JphSZfIFDqAmFIUdVow0ShRANCAAQLaVnu5yblt9VdhunVTXzxk4k1ZIAv
qs0WEHCiNRfR+Wex5GpMfRCDcHH6fFlqyq5YpFV0/ripVSlt3RnH9Ok5
-----END PRIVATE KEY-----
@@ -0,0 +1,13 @@
-----BEGIN CERTIFICATE-----
MIIB4jCCAYmgAwIBAgIUNQfYYHxiZvBYa5P4sYqm4yJqf9QwCgYIKoZIzj0EAwIw
PDE6MDgGA1UEAwwxQm94bWFrZXIgVEVTVC1PTkxZIENBIChuZXZlciB0cnVzdCBv
dXRzaWRlIHRlc3RzKTAgFw0yNjA5MjQwMDQ4MTdaGA8yMTI2MDgzMTAwNDgxN1ow
JDEiMCAGA1UEAwwZd3JvbmcuZXhhbXBsZSAoVEVTVCBPTkxZKTBZMBMGByqGSM49
AgEGCCqGSM49AwEHA0IABAtpWe7nJuW31V2G6dVNfPGTiTVkgC+qzRYQcKI1F9H5
Z7Hkakx9EINwcfp8WWrKrlikVXT+uKlVKW3dGcf06TmjfzB9MBgGA1UdEQQRMA+C
DXdyb25nLmV4YW1wbGUwDAYDVR0TAQH/BAIwADATBgNVHSUEDDAKBggrBgEFBQcD
ATAdBgNVHQ4EFgQU7JXvioL6xNp1Xd8wEN1vHw32ZC4wHwYDVR0jBBgwFoAUWSzt
uhKMOOsErJ4/5kASeR4u1WQwCgYIKoZIzj0EAwIDRwAwRAIgJ+BxEK1QVQUeI/PM
Ap1A7fHECE5GTgKazmJ79DiRBa4CIEEAw9AxKBjNn5gXcQWe/zSs+cGwD6jAxdAe
hbIC76Kx
-----END CERTIFICATE-----
@@ -0,0 +1,207 @@
//! The HTTP client over an in-memory stream: what it writes, how it reads each kind of body, its
//! caps, and the wait a 429 asks for (M4a spec, section 5). Do not edit.
use std::io::{Cursor, Read, Write};
use std::time::{Duration, UNIX_EPOCH};
use gatewayd::http::{Head, HttpError, MAX_BODY, MAX_HEAD, rate_limit_wait, read_head, request};
/// Reads from `input`, records what is written.
struct Duplex {
input: Cursor<Vec<u8>>,
output: Vec<u8>,
}
impl Duplex {
fn new(input: &[u8]) -> Duplex {
Duplex {
input: Cursor::new(input.to_vec()),
output: Vec::new(),
}
}
}
impl Read for Duplex {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.input.read(buf)
}
}
impl Write for Duplex {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.output.write(buf)
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn get(response: &[u8]) -> Result<(u16, Vec<u8>), HttpError> {
let mut d = Duplex::new(response);
let r = request(&mut d, "GET", "a.example", "/api/v4/users/me", &[], None)?;
Ok((r.head.status, r.body))
}
#[test]
fn the_request_is_exactly_this() {
let mut d = Duplex::new(b"HTTP/1.1 201 Created\r\nContent-Length: 2\r\n\r\n{}");
let r = request(
&mut d,
"POST",
"straylight.example",
"/api/v4/posts",
&[
("Authorization", "Bearer t"),
("Content-Type", "application/json"),
],
Some(b"{\"message\":\"hi\"}"),
)
.unwrap();
assert_eq!(
String::from_utf8(d.output).unwrap(),
"POST /api/v4/posts HTTP/1.1\r\nHost: straylight.example\r\nAuthorization: Bearer t\r\n\
Content-Type: application/json\r\nContent-Length: 16\r\nConnection: close\r\n\r\n{\"message\":\"hi\"}"
);
assert_eq!((r.head.status, r.body), (201, b"{}".to_vec()));
}
#[test]
fn bodies_by_length_chunks_or_close() {
assert_eq!(
get(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello").unwrap(),
(200, b"hello".to_vec())
);
assert_eq!(
get(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n6;x=y\r\n world\r\n0\r\nTrailer: z\r\n\r\n").unwrap(),
(200, b"hello world".to_vec())
);
assert_eq!(
get(b"HTTP/1.1 200 OK\r\ntransfer-encoding: CHUNKED\r\n\r\nA\r\n0123456789\r\n0\r\n\r\n")
.unwrap()
.1,
b"0123456789".to_vec()
);
assert_eq!(
get(b"HTTP/1.0 200 OK\r\n\r\nuntil the end").unwrap(),
(200, b"until the end".to_vec())
);
assert_eq!(
get(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n").unwrap(),
(204, Vec::new())
);
}
#[test]
fn headers_are_found_whatever_their_case_and_trimmed() {
let mut d = Duplex::new(
b"HTTP/1.1 101 Switching Protocols\r\nUPGRADE: websocket \r\nX-A: 1\r\n\r\nFRAMES",
);
let head = read_head(&mut d).unwrap();
assert_eq!(head.status, 101);
assert_eq!(head.header("upgrade"), Some("websocket"));
assert_eq!(head.header("x-a"), Some("1"));
assert_eq!(head.header("missing"), None);
let mut rest = String::new();
d.read_to_string(&mut rest).unwrap();
assert_eq!(
rest, "FRAMES",
"read_head reads nothing past the blank line"
);
}
#[test]
fn malformed_responses_are_errors_not_panics() {
for bad in [
&b""[..],
b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n",
b"HTTP/2 200 OK\r\n\r\n",
b"HTTP/1.1 2000 OK\r\n\r\n",
b"HTTP/1.1 abc OK\r\n\r\n",
b"HTTP/1.1 99 OK\r\n\r\n",
b"HTTP/1.1 200 OK\r\nno colon here\r\n\r\n",
b"HTTP/1.1 200 OK\r\nContent-Length: five\r\n\r\n",
b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\nshort",
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\nzz\r\n",
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhelloXX0\r\n\r\n",
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhel",
b"HTTP/1.1 200 OK\r\n\xff\xfe: x\r\n\r\n",
] {
assert!(get(bad).is_err(), "{:?}", String::from_utf8_lossy(bad));
}
}
#[test]
fn caps_are_checked_before_reading_or_allocating() {
let mut long_head = b"HTTP/1.1 200 OK\r\nX: ".to_vec();
long_head.extend(std::iter::repeat_n(b'a', MAX_HEAD));
long_head.extend(b"\r\n\r\n");
assert!(matches!(get(&long_head), Err(HttpError::TooLarge("head"))));
let huge = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n",
MAX_BODY + 1
);
assert!(
matches!(get(huge.as_bytes()), Err(HttpError::TooLarge("body"))),
"refused from the header alone"
);
let huge_chunk = format!(
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n{:x}\r\n",
MAX_BODY + 1
);
assert!(matches!(
get(huge_chunk.as_bytes()),
Err(HttpError::TooLarge("body"))
));
let overflow = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\nffffffffffffffffffff\r\n";
assert!(get(overflow.as_bytes()).is_err());
let mut to_close = b"HTTP/1.0 200 OK\r\n\r\n".to_vec();
to_close.extend(std::iter::repeat_n(b'b', MAX_BODY + 1));
assert!(matches!(get(&to_close), Err(HttpError::TooLarge("body"))));
}
fn head_with(reset: Option<&str>) -> Head {
let mut headers = vec![("X-Ratelimit-Limit".to_string(), "10".to_string())];
if let Some(r) = reset {
headers.push(("X-Ratelimit-Reset".to_string(), r.to_string()));
}
Head {
status: 429,
headers,
}
}
#[test]
fn a_rate_limit_is_waited_out_within_bounds() {
let now = UNIX_EPOCH + Duration::from_secs(1_800_000_000);
assert_eq!(
rate_limit_wait(&head_with(Some("3")), now),
Duration::from_secs(3)
);
assert_eq!(
rate_limit_wait(&head_with(Some("1800000005")), now),
Duration::from_secs(5),
"a Unix time"
);
assert_eq!(
rate_limit_wait(&head_with(Some("1799999999")), now),
Duration::from_secs(1),
"already past"
);
assert_eq!(
rate_limit_wait(&head_with(Some("0")), now),
Duration::from_secs(1)
);
assert_eq!(
rate_limit_wait(&head_with(Some("999999")), now),
Duration::from_secs(60),
"capped"
);
assert_eq!(
rate_limit_wait(&head_with(None), now),
Duration::from_secs(1)
);
assert_eq!(
rate_limit_wait(&head_with(Some("soon")), now),
Duration::from_secs(1)
);
}
@@ -0,0 +1,171 @@
//! The `gatewayd` program: its usage, what stops it at start, the warning for a secret in a file,
//! and that the token never reaches its output (M4a spec, sections 3, 4 and 10). Do not edit.
#[path = "support/tmp.rs"]
mod tmp;
use std::io::{BufRead, BufReader};
use std::os::unix::fs::PermissionsExt;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use tmp::TempDir;
const KYLE: &str = "k0000000000000000000000000";
const TOKEN: &str = "tok-3f9a1c7e5b2d4f6a8c0e";
fn gatewayd() -> Command {
let mut c = Command::new(env!("CARGO_BIN_EXE_gatewayd"));
c.env_remove("GW_TEST_TOKEN")
.env_remove("CREDENTIALS_DIRECTORY");
c
}
fn closed_url() -> String {
let port = std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port();
format!("http://127.0.0.1:{port}")
}
fn write_config(dir: &TempDir, secret: &str) -> std::path::PathBuf {
let text = format!(
"[mattermost]\nurl = \"{}\"\n[secrets.mattermost_token]\n{secret}\n[allow]\nusers = [\"{KYLE}\"]\n[paths]\nhome = \"{}\"\n",
closed_url(),
dir.path().join("home").display()
);
dir.write("gatewayd.toml", &text)
}
/// Run until `want` appears on standard error or 5 s pass, then kill it; all it printed.
fn stderr_until(mut cmd: Command, want: &str) -> String {
let mut child = cmd
.stderr(Stdio::piped())
.stdout(Stdio::null())
.spawn()
.unwrap();
let mut reader = BufReader::new(child.stderr.take().unwrap());
let until = Instant::now() + Duration::from_secs(5);
let mut all = String::new();
while Instant::now() < until && !all.contains(want) {
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 {
break;
}
all.push_str(&line);
}
let _ = child.kill();
let _ = child.wait();
all
}
#[test]
fn usage() {
for args in [
&[][..],
&["serve"][..],
&["serve", "--config"][..],
&["run", "--config", "x"][..],
] {
let out = gatewayd().args(args).output().unwrap();
assert_eq!(out.status.code(), Some(2), "{args:?}");
assert_eq!(
String::from_utf8_lossy(&out.stderr),
"usage: gatewayd serve --config <path>\n"
);
}
}
#[test]
fn a_bad_config_stops_at_start() {
let dir = TempDir::new("main-config");
let missing = dir.path().join("nope.toml");
let bad = dir.write("bad.toml", "[mattermost]\nurl = \"ftp://x\"\n");
for path in [missing, bad] {
let out = gatewayd()
.arg("serve")
.arg("--config")
.arg(&path)
.output()
.unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(1), "{err}");
assert!(
err.starts_with(&format!("gatewayd: {}: ", path.display())),
"{err}"
);
assert!(
err.ends_with("\nsee docs/runbook.md#gatewayd-start-failed\n"),
"{err}"
);
}
}
#[test]
fn a_missing_secret_stops_at_start() {
let dir = TempDir::new("main-secret");
let config = write_config(&dir, "env = \"GW_TEST_TOKEN\"");
let out = gatewayd()
.arg("serve")
.arg("--config")
.arg(&config)
.output()
.unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(1), "{err}");
assert!(
err.starts_with("gatewayd: secret mattermost_token: "),
"{err}"
);
assert!(
err.ends_with("\nsee docs/runbook.md#secret-unavailable\n"),
"{err}"
);
assert!(
!dir.path().join("home").exists(),
"nothing is made before the secret is read"
);
}
#[test]
fn a_secret_in_a_file_warns_and_the_token_is_never_printed() {
let dir = TempDir::new("main-file");
let secret = dir.write("token", &format!("{TOKEN}\n"));
std::fs::set_permissions(&secret, std::fs::Permissions::from_mode(0o600)).unwrap();
let config = write_config(&dir, &format!("file = \"{}\"", secret.display()));
let mut cmd = gatewayd();
cmd.arg("serve").arg("--config").arg(&config);
let err = stderr_until(cmd, "trying again");
let warning = format!(
"gatewayd: warning: secret mattermost_token is read in plaintext from {}; a systemd credential keeps it encrypted at rest (see docs/runbook.md#secret-in-a-file)\n",
secret.display()
);
assert!(err.starts_with(&warning), "{err}");
assert!(
err.contains("gatewayd: cannot reach http://127.0.0.1:"),
"{err}"
);
assert!(!err.contains(TOKEN), "{err}");
let mode = std::fs::metadata(dir.path().join("home/gateway"))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o700);
}
#[test]
fn a_secret_from_the_environment_has_no_warning() {
let dir = TempDir::new("main-env");
let config = write_config(&dir, "env = \"GW_TEST_TOKEN\"");
let mut cmd = gatewayd();
cmd.arg("serve")
.arg("--config")
.arg(&config)
.env("GW_TEST_TOKEN", TOKEN);
let err = stderr_until(cmd, "trying again");
assert!(err.starts_with("gatewayd: cannot reach "), "{err}");
assert!(!err.contains("warning") && !err.contains(TOKEN), "{err}");
}
@@ -0,0 +1,173 @@
//! Mattermost's JSON: events as the server sends them (v11.11.0 shapes), the typing request, and
//! the `posts?since` list, whose edited, deleted and root-only posts must not be answered (M4a spec,
//! sections 7 and 9). Do not edit.
use gatewayd::mm::{Event, MmError, SINCE_LIMIT, parse_event, since_list, typing};
use serde_json::json;
const KYLE: &str = "k0000000000000000000000000";
const DM: &str = "d0000000000000000000000000";
fn post(id: &str, create_at: i64) -> serde_json::Value {
json!({
"id": id, "create_at": create_at, "update_at": create_at, "edit_at": 0, "delete_at": 0,
"is_pinned": false, "user_id": KYLE, "channel_id": DM, "root_id": "", "original_id": "",
"message": "hello", "type": "", "props": {"from_bot": "true"}, "hashtags": "",
"pending_post_id": "", "reply_count": 0, "metadata": {}
})
}
fn id(n: u32) -> String {
format!("p{n:025}")
}
fn posted(post: &serde_json::Value, channel_type: &str) -> String {
json!({
"event": "posted",
"data": {
"channel_display_name": "@kyle", "channel_name": "x__y", "channel_type": channel_type,
"post": post.to_string(), "sender_name": "@kyle", "set_online": true, "team_id": ""
},
"broadcast": {"omit_users": null, "user_id": "", "channel_id": DM, "team_id": ""},
"seq": 3
})
.to_string()
}
#[test]
fn hello_replies_and_other_events() {
let hello =
json!({"event": "hello", "data": {"server_version": "11.11.0"}, "broadcast": {}, "seq": 0});
assert_eq!(parse_event(&hello.to_string()).unwrap(), Event::Hello);
assert_eq!(
parse_event(r#"{"status":"OK","seq_reply":1}"#).unwrap(),
Event::Other(String::new())
);
let typing = json!({"event": "typing", "data": {"parent_id": ""}, "broadcast": {}, "seq": 4});
assert_eq!(
parse_event(&typing.to_string()).unwrap(),
Event::Other("typing".to_string())
);
}
#[test]
fn a_posted_event_carries_the_post_as_a_string() {
let mut p = post(&id(1), 1_758_650_000_123);
p["root_id"] = json!(id(0));
p["message"] = json!("line one\nline two");
let Event::Posted { post, channel_type } = parse_event(&posted(&p, "D")).unwrap() else {
panic!("not a post")
};
assert_eq!(channel_type, "D");
assert_eq!(
(post.id.as_str(), post.root_id.as_str()),
(id(1).as_str(), id(0).as_str())
);
assert_eq!(
(post.user_id.as_str(), post.channel_id.as_str()),
(KYLE, DM)
);
assert_eq!(
(post.message.as_str(), post.create_at, post.kind.as_str()),
("line one\nline two", 1_758_650_000_123, "")
);
let mut s = self::post(&id(2), 5);
s["type"] = json!("system_join_channel");
let Event::Posted { post, .. } = parse_event(&posted(&s, "O")).unwrap() else {
panic!()
};
assert_eq!(post.kind, "system_join_channel");
}
#[test]
fn bad_posts_are_errors_not_panics() {
let p = post(&id(1), 5);
let as_object =
json!({"event": "posted", "data": {"post": p, "channel_type": "D"}}).to_string();
let mut cases = vec![
as_object,
"not json".to_string(),
"[1,2]".to_string(),
"{\"event\":5}".to_string(),
];
for (field, value) in [
("id", json!("../../etc")),
("user_id", json!("")),
("channel_id", json!("A0000000000000000000000000")),
("root_id", json!("short")),
("create_at", json!("soon")),
] {
let mut bad = post(&id(1), 5);
bad[field] = value;
cases.push(posted(&bad, "D"));
}
let mut missing = post(&id(1), 5);
missing.as_object_mut().unwrap().remove("create_at");
cases.push(posted(&missing, "D"));
for case in cases {
assert!(
matches!(parse_event(&case), Err(MmError::Json(_))),
"{case}"
);
}
}
#[test]
fn the_typing_request() {
let got: serde_json::Value = serde_json::from_str(&typing(7, DM, &id(0))).unwrap();
assert_eq!(
got,
json!({"action": "user_typing", "seq": 7, "data": {"channel_id": DM, "parent_id": id(0)}})
);
}
#[test]
fn since_keeps_new_posts_in_order_and_nothing_else() {
let since = 1000;
let mut edited = post(&id(1), 900);
edited["update_at"] = json!(1500);
let mut deleted = post(&id(2), 1100);
deleted["delete_at"] = json!(1200);
let root_only = post(&id(3), 10);
let later = post(&id(4), 1300);
let earlier = post(&id(5), 1200);
let at_since = post(&id(6), 1000);
let mut posts = serde_json::Map::new();
for p in [&edited, &deleted, &root_only, &later, &earlier, &at_since] {
posts.insert(p["id"].as_str().unwrap().to_string(), p.clone());
}
let order = json!([id(4), id(2), id(1), id(5), id(9), id(6)]);
let body = json!({"order": order, "posts": posts, "next_post_id": "", "prev_post_id": "", "has_next": false});
let got = since_list(body.to_string().as_bytes(), since).unwrap();
let ids: Vec<&str> = got.posts.iter().map(|p| p.id.as_str()).collect();
assert_eq!(ids, [id(5), id(4)]);
assert!(!got.full);
}
#[test]
fn a_full_since_answer_says_so() {
let mut posts = serde_json::Map::new();
let mut order = Vec::new();
for n in 0..SINCE_LIMIT {
let n = u32::try_from(n).unwrap();
posts.insert(id(n), post(&id(n), 2000 + i64::from(n)));
order.push(id(n));
}
let body = json!({"order": order, "posts": posts});
let got = since_list(body.to_string().as_bytes(), 1000).unwrap();
assert!(got.full);
assert_eq!(got.posts.len(), SINCE_LIMIT);
let empty = since_list(br#"{"order":[],"posts":{}}"#, 0).unwrap();
assert!(empty.posts.is_empty() && !empty.full);
}
#[test]
fn a_server_error_body_is_quoted_in_messages() {
let e = MmError::Status(500, "boom\ngatewayd: a forged line".to_string());
let text = e.to_string();
assert!(!text.contains('\n'), "{text}");
assert_eq!(
MmError::Auth(401).to_string(),
"Mattermost refused the token (401)"
);
}
@@ -0,0 +1,242 @@
//! Mattermost's REST calls against a scripted server: the requests on the wire, and what a 401,
//! 403, 429, 5xx or 404 does (M4a spec, sections 5 and 9). Do not edit.
#[path = "support/http_server.rs"]
mod http_server;
#[path = "support/tls_server.rs"]
mod tls_server;
use std::net::SocketAddr;
use std::sync::mpsc::Receiver;
use std::time::{Duration, Instant};
use gatewayd::config::ServerUrl;
use gatewayd::mm::MmError;
use gatewayd::mm::rest::Client;
use gatewayd::net::Connector;
use gatewayd::secrets::Secret;
use http_server::{Request, reply, serve_http};
use serde_json::json;
use tls_server::{fixture, server_config};
const BOT: &str = "b0000000000000000000000000";
const KYLE: &str = "k0000000000000000000000000";
const DM: &str = "d0000000000000000000000000";
const POST: &str = "p0000000000000000000000001";
fn client(addr: SocketAddr, tls: bool) -> Client {
let url = ServerUrl {
tls,
host: "localhost".to_string(),
port: addr.port(),
};
let ca = tls.then(|| fixture("test-ca.pem"));
let connector = Connector::new(url, ca.as_deref()).unwrap();
Client::new(
connector,
Secret::new("TOKEN".to_string()),
Duration::from_secs(5),
)
}
fn me_body() -> String {
json!({"id": BOT, "username": "boxmaker-straylight", "roles": "system_user", "is_bot": true})
.to_string()
}
fn requests(rx: &Receiver<Request>) -> Vec<Request> {
rx.try_iter().collect()
}
#[test]
fn me_plain_and_over_tls() {
for tls in [false, true] {
let config = tls.then(|| server_config("server"));
let (addr, rx) = serve_http(config, |_, _| reply(200, "", &me_body()));
let me = client(addr, tls).me().unwrap();
assert_eq!(
(me.id.as_str(), me.username.as_str()),
(BOT, "boxmaker-straylight")
);
let r = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(
(r.method.as_str(), r.path.as_str()),
("GET", "/api/v4/users/me")
);
assert!(
r.head.contains("Authorization: Bearer TOKEN\r\n"),
"{}",
r.head
);
assert!(
r.head
.contains(&format!("Host: localhost:{}\r\n", addr.port())),
"{}",
r.head
);
}
}
#[test]
fn create_post_sends_the_thread_and_reads_the_post_back() {
let (addr, rx) = serve_http(None, |_, r| {
let mut p = r.json();
p["id"] = json!(POST);
p["user_id"] = json!(BOT);
p["create_at"] = json!(5);
reply(201, "", &p.to_string())
});
let post = client(addr, false)
.create_post(DM, KYLE, "an answer\nin two lines")
.unwrap();
assert_eq!((post.id.as_str(), post.root_id.as_str()), (POST, KYLE));
let r = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert_eq!(
(r.method.as_str(), r.path.as_str()),
("POST", "/api/v4/posts")
);
assert!(
r.head.contains("Content-Type: application/json\r\n"),
"{}",
r.head
);
assert_eq!(
r.json(),
json!({"channel_id": DM, "root_id": KYLE, "message": "an answer\nin two lines"})
);
}
#[test]
fn posts_since_and_the_direct_channel() {
let (addr, rx) = serve_http(None, |_, r| {
if r.path.contains("/posts?since=") {
reply(200, "", r#"{"order":[],"posts":{}}"#)
} else {
reply(201, "", &json!({"id": DM, "type": "D"}).to_string())
}
});
let c = client(addr, false);
assert!(
c.posts_since(DM, 1_758_650_000_000)
.unwrap()
.posts
.is_empty()
);
assert_eq!(c.direct_channel(BOT, KYLE).unwrap(), DM);
let rs = requests(&rx);
assert_eq!(
rs[0].path,
format!("/api/v4/channels/{DM}/posts?since=1758650000000")
);
assert_eq!(
(rs[1].method.as_str(), rs[1].path.as_str()),
("POST", "/api/v4/channels/direct")
);
assert_eq!(rs[1].json(), json!([BOT, KYLE]));
}
#[test]
fn a_channel_that_is_not_an_id_is_never_sent() {
let (addr, rx) = serve_http(None, |_, _| reply(200, "", r#"{"order":[],"posts":{}}"#));
let err = client(addr, false)
.posts_since("../users/me?x=", 0)
.unwrap_err();
assert!(matches!(err, MmError::Json(_)), "{err}");
std::thread::sleep(Duration::from_millis(100));
assert!(requests(&rx).is_empty());
}
#[test]
fn a_refused_token_stops_at_once() {
for status in [401, 403] {
let (addr, rx) = serve_http(None, move |_, _| {
reply(
status,
"",
r#"{"id":"api.context.session_expired.app_error"}"#,
)
});
let err = client(addr, false).me().unwrap_err();
assert!(matches!(err, MmError::Auth(s) if s == status), "{err}");
std::thread::sleep(Duration::from_millis(100));
assert_eq!(requests(&rx).len(), 1, "no retry on {status}");
}
}
#[test]
fn a_server_error_is_tried_twice_more() {
let (addr, rx) = serve_http(None, |n, _| {
if n < 2 {
reply(503, "", "{}")
} else {
reply(200, "", &me_body())
}
});
assert!(client(addr, false).me().is_ok());
assert_eq!(requests(&rx).len(), 3);
let (addr, rx) = serve_http(None, |_, _| reply(502, "", "{}"));
let err = client(addr, false).me().unwrap_err();
assert!(matches!(err, MmError::Status(502, _)), "{err}");
std::thread::sleep(Duration::from_millis(100));
assert_eq!(requests(&rx).len(), 3);
}
#[test]
fn a_rate_limit_is_waited_out() {
let (addr, rx) = serve_http(None, |n, _| {
if n == 0 {
reply(429, "X-Ratelimit-Reset: 1\r\n", "{}")
} else {
reply(200, "", &me_body())
}
});
let started = Instant::now();
assert!(client(addr, false).me().is_ok());
assert!(
started.elapsed() >= Duration::from_millis(900),
"{:?}",
started.elapsed()
);
assert_eq!(requests(&rx).len(), 2);
let (addr, _rx) = serve_http(None, |_, _| reply(429, "X-Ratelimit-Reset: 1\r\n", "{}"));
assert!(matches!(
client(addr, false).me(),
Err(MmError::RateLimited(_))
));
}
#[test]
fn other_statuses_keep_the_start_of_the_body() {
let long = format!("{{\"message\":\"{}\"}}", "x".repeat(1000));
let (addr, _rx) = serve_http(None, move |_, _| reply(404, "", &long));
let Err(MmError::Status(404, body)) = client(addr, false).me() else {
panic!("not a 404")
};
assert_eq!(body.chars().count(), 200);
}
#[test]
fn answers_that_are_not_what_we_expect() {
for body in [
"not json",
r#"{"id":"short","username":"x"}"#,
r#"{"id":"b0000000000000000000000000","username":""}"#,
] {
let (addr, _rx) = serve_http(None, move |_, _| reply(200, "", body));
assert!(
matches!(client(addr, false).me(), Err(MmError::Json(_))),
"{body}"
);
}
}
#[test]
fn nobody_listening_is_a_network_error() {
let port = std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap();
assert!(matches!(client(port, false).me(), Err(MmError::Net(_))));
}
@@ -0,0 +1,120 @@
//! Connections, plain and TLS, against local servers with TEST-ONLY certificates (M4a spec,
//! section 5): the right CA is trusted through `ca_file`; an unknown CA and a wrong name are
//! refused, so verification is on. Do not edit.
#[path = "support/tls_server.rs"]
mod tls_server;
use std::io::{BufRead, BufReader, Write};
use std::time::Duration;
use gatewayd::config::ServerUrl;
use gatewayd::net::{Connector, NetError, Stream};
use tls_server::{echo_line, fixture, serve, server_config};
const T: Duration = Duration::from_secs(5);
fn url(tls: bool, host: &str, port: u16) -> ServerUrl {
ServerUrl {
tls,
host: host.to_string(),
port,
}
}
fn round_trip(mut stream: Stream) -> String {
stream.write_all(b"hello over the wire\n").unwrap();
stream.flush().unwrap();
let mut line = String::new();
BufReader::new(stream).read_line(&mut line).unwrap();
line
}
#[test]
fn plain_tcp() {
let addr = serve(None, echo_line);
let c = Connector::new(url(false, "127.0.0.1", addr.port()), None).unwrap();
let stream = c.connect(T).unwrap();
assert!(matches!(stream, Stream::Plain(_)));
assert_eq!(round_trip(stream), "hello over the wire\n");
}
#[test]
fn tls_to_a_server_signed_by_the_ca_file() {
let addr = serve(Some(server_config("server")), echo_line);
for host in ["localhost", "127.0.0.1"] {
let c =
Connector::new(url(true, host, addr.port()), Some(&fixture("test-ca.pem"))).unwrap();
let stream = c.connect(T).unwrap_or_else(|e| panic!("{host}: {e}"));
assert!(matches!(stream, Stream::Tls(_)));
assert_eq!(round_trip(stream), "hello over the wire\n", "{host}");
}
}
#[test]
fn an_unknown_ca_is_refused_at_connect() {
let addr = serve(Some(server_config("other-server")), echo_line);
let c = Connector::new(
url(true, "localhost", addr.port()),
Some(&fixture("test-ca.pem")),
)
.unwrap();
match c.connect(T) {
Err(NetError::Tls(why)) => assert!(why.to_lowercase().contains("certificate"), "{why}"),
Err(e) => panic!("expected a TLS error, got {e}"),
Ok(_) => panic!("a server signed by an unknown CA was accepted"),
}
}
#[test]
fn a_wrong_name_is_refused_at_connect() {
let addr = serve(Some(server_config("wrong-name")), echo_line);
let c = Connector::new(
url(true, "localhost", addr.port()),
Some(&fixture("test-ca.pem")),
)
.unwrap();
assert!(
matches!(c.connect(T), Err(NetError::Tls(_))),
"a certificate for wrong.example was accepted for localhost"
);
}
#[test]
fn tls_to_a_plain_server_fails_and_does_not_hang() {
let addr = serve(None, |_conn| std::thread::sleep(Duration::from_secs(30)));
let c = Connector::new(
url(true, "localhost", addr.port()),
Some(&fixture("test-ca.pem")),
)
.unwrap();
let started = std::time::Instant::now();
assert!(c.connect(Duration::from_millis(300)).is_err());
assert!(started.elapsed() < Duration::from_secs(3));
}
#[test]
fn a_bad_ca_file_is_an_error_before_any_connection() {
let missing = fixture("no-such.pem");
assert!(matches!(
Connector::new(url(true, "localhost", 1), Some(&missing)),
Err(NetError::Roots(_))
));
let not_pem = fixture("README.md");
assert!(matches!(
Connector::new(url(true, "localhost", 1), Some(&not_pem)),
Err(NetError::Roots(_))
));
// A plain server needs no certificates at all.
assert!(Connector::new(url(false, "localhost", 1), Some(&missing)).is_ok());
}
#[test]
fn nothing_listening_is_a_connect_error() {
let port = {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
l.local_addr().unwrap().port()
};
let c = Connector::new(url(false, "127.0.0.1", port), None).unwrap();
assert!(matches!(c.connect(T), Err(NetError::Connect(_))));
}
@@ -0,0 +1,200 @@
//! The secret store (M4a spec, section 4). The environment is passed in as a function, so no test
//! changes the process's environment. Do not edit.
#[path = "support/tmp.rs"]
mod tmp;
use std::collections::HashMap;
use std::ffi::OsString;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use gatewayd::config::SecretSource;
use gatewayd::secrets::{RUNBOOK, RUNBOOK_FILE, load};
use tmp::TempDir;
const TOKEN: &str = "s3cret-t0ken-value";
fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<OsString> + use<> {
let map: HashMap<String, OsString> = pairs
.iter()
.map(|(k, v)| (k.to_string(), OsString::from(v)))
.collect();
move |k| map.get(k).cloned()
}
fn owner_file(dir: &TempDir, name: &str, text: &str, mode: u32) -> PathBuf {
let path = dir.write(name, text);
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap();
path
}
fn refused(source: &SecretSource, env: &dyn Fn(&str) -> Option<OsString>, word: &str) {
let e = load("mattermost_token", source, env).expect_err(word);
let text = e.to_string();
assert!(text.contains(word), "{word}: {text}");
assert!(text.starts_with("secret mattermost_token: "), "{text}");
assert!(text.ends_with(RUNBOOK), "{text}");
assert!(
!text.contains(TOKEN),
"a refusal never shows the value: {text}"
);
}
#[test]
fn a_systemd_credential() {
let dir = TempDir::new("cred");
dir.write("creds/mattermost-token", &format!("{TOKEN}\n"));
let creds = dir.path().join("creds");
let env = env_of(&[("CREDENTIALS_DIRECTORY", creds.to_str().unwrap())]);
let got = load(
"mattermost_token",
&SecretSource::Credential("mattermost-token".into()),
&env,
)
.unwrap();
assert_eq!(
got.secret.expose(),
TOKEN,
"one trailing newline is removed"
);
assert_eq!(got.warning, None);
}
#[test]
fn a_credential_outside_systemd_or_missing_is_refused() {
let src = SecretSource::Credential("mattermost-token".into());
refused(&src, &env_of(&[]), "CREDENTIALS_DIRECTORY is not set");
let dir = TempDir::new("cred-missing");
refused(
&src,
&env_of(&[("CREDENTIALS_DIRECTORY", dir.path().to_str().unwrap())]),
"cannot read the credential",
);
}
#[test]
fn an_environment_variable() {
let got = load(
"mattermost_token",
&SecretSource::Env("MM".into()),
&env_of(&[("MM", TOKEN)]),
)
.unwrap();
assert_eq!(got.secret.expose(), TOKEN);
assert_eq!(got.warning, None, "only a file warns");
refused(&SecretSource::Env("MM".into()), &env_of(&[]), "is not set");
refused(
&SecretSource::Env("MM".into()),
&env_of(&[("MM", "")]),
"empty",
);
}
#[test]
fn an_owner_only_file_is_read_with_a_warning() {
let dir = TempDir::new("file");
for mode in [0o600, 0o400] {
let path = owner_file(
&dir,
&format!("token-{mode:o}"),
&format!("{TOKEN}\n"),
mode,
);
let got = load(
"mattermost_token",
&SecretSource::File(path.clone()),
&env_of(&[]),
)
.unwrap();
assert_eq!(got.secret.expose(), TOKEN);
let warning = got.warning.expect("a file secret warns");
assert!(
warning.starts_with(
"gatewayd: warning: secret mattermost_token is read in plaintext from "
),
"{warning}"
);
assert!(warning.contains(path.to_str().unwrap()), "{warning}");
assert!(warning.contains(RUNBOOK_FILE), "{warning}");
assert!(!warning.contains(TOKEN));
}
}
#[test]
fn a_file_anyone_else_can_read_or_that_is_not_a_plain_file_is_refused() {
let dir = TempDir::new("file-bad");
for (mode, _) in [
(0o640, "group"),
(0o604, "other"),
(0o644, "both"),
(0o660, "group write"),
] {
let path = owner_file(&dir, &format!("t-{mode:o}"), TOKEN, mode);
refused(
&SecretSource::File(path),
&env_of(&[]),
"only the owner may read it",
);
}
let target = owner_file(&dir, "real", TOKEN, 0o600);
let link = dir.path().join("link");
std::os::unix::fs::symlink(&target, &link).unwrap();
refused(&SecretSource::File(link), &env_of(&[]), "symbolic link");
std::fs::create_dir(dir.path().join("adir")).unwrap();
refused(
&SecretSource::File(dir.path().join("adir")),
&env_of(&[]),
"not a regular file",
);
refused(
&SecretSource::File(dir.path().join("missing")),
&env_of(&[]),
"cannot read",
);
refused(
&SecretSource::File(PathBuf::from("relative/token")),
&env_of(&[]),
"absolute",
);
}
#[test]
fn empty_and_non_utf8_values_are_refused() {
let dir = TempDir::new("value");
refused(
&SecretSource::File(owner_file(&dir, "empty", "", 0o600)),
&env_of(&[]),
"empty",
);
refused(
&SecretSource::File(owner_file(&dir, "nl", "\n", 0o600)),
&env_of(&[]),
"empty",
);
let path = dir.path().join("bin");
std::fs::write(&path, [0xff, 0xfe]).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
refused(&SecretSource::File(path), &env_of(&[]), "not UTF-8");
}
#[test]
fn only_one_trailing_newline_is_removed_and_spaces_stay() {
let dir = TempDir::new("trim");
let path = owner_file(&dir, "t", " a b \n\n", 0o600);
let got = load("x", &SecretSource::File(path), &env_of(&[])).unwrap();
assert_eq!(got.secret.expose(), " a b \n");
}
#[test]
fn a_secret_prints_nothing_of_itself() {
let got = load(
"mattermost_token",
&SecretSource::Env("MM".into()),
&env_of(&[("MM", TOKEN)]),
)
.unwrap();
let shown = format!("{:?} {:?}", got.secret, got);
assert!(!shown.contains(TOKEN), "{shown}");
assert!(shown.contains("Secret(…)"), "{shown}");
}
@@ -0,0 +1,213 @@
//! `gatewayd` end to end, against a fake Mattermost and a fake `loopd`: who is answered, where,
//! and how (M4a spec, sections 7 and 8). Do not edit.
#[path = "support/fake_loop.rs"]
mod fake_loop;
#[path = "support/fake_mm.rs"]
mod fake_mm;
#[path = "support/gateway.rs"]
mod gateway;
#[path = "support/tmp.rs"]
mod tmp;
use std::sync::{Mutex, mpsc};
use std::time::Duration;
use fake_loop::{done, serve_loop};
use fake_mm::{BOT, BOT_NAME, DM, EVE, EVE_DM, FakeMm, KYLE, id, post};
use gateway::{OTHER, SHARED, WAIT, config, loop_dir, read_state, start, up};
use gatewayd::serve::Stop;
use gatewayd::sessions::{BUSY, M4B_COMMAND};
use tmp::TempDir;
#[test]
fn a_direct_message_is_answered_in_its_thread_while_typing() {
let home = TempDir::new("serve-dm");
let (mm, running, turns, mut ws) = up(&home, Duration::from_millis(600));
let p1 = id('p', 1);
ws.posted(&post(&p1, KYLE, DM, "", "hello there", 5), "D");
let turn = turns.recv_timeout(WAIT).unwrap();
assert_eq!(
(turn.session.as_str(), turn.content.as_str(), turn.resume),
(format!("mm-{p1}").as_str(), "hello there", false)
);
let typing = ws.typing_within(Duration::from_millis(500));
assert!(typing.len() >= 2, "{typing:?}");
assert!(
typing.iter().all(|(c, p)| c == DM && *p == p1),
"{typing:?}"
);
let posts = mm.wait_posts(1, WAIT);
assert_eq!(
posts,
[(
DM.to_string(),
p1.clone(),
"answer to hello there".to_string()
)]
);
// Typing sent just before the answer may still be on its way; after that, it stops.
ws.typing_within(Duration::from_millis(300));
assert!(
ws.typing_within(Duration::from_millis(400)).is_empty(),
"typing stops after the answer"
);
let log = running.log();
assert!(
log.iter()
.any(|l| l == &format!("gatewayd: connected to {} as {BOT_NAME}", mm.url())),
"{log:?}"
);
assert!(matches!(running.finish(), Stop::Asked));
}
#[test]
fn anyone_else_gets_nothing_at_all() {
let home = TempDir::new("serve-stranger");
let (mm, running, turns, mut ws) = up(&home, Duration::ZERO);
let p1 = id('p', 1);
ws.posted(&post(&p1, EVE, EVE_DM, "", "secret words", 5), "D");
let mut own = post(&id('p', 2), BOT, DM, "", "my own post", 6);
own["user_id"] = serde_json::json!(BOT);
ws.posted(&own, "D");
let log = running.wait_log("not allowed");
assert!(turns.recv_timeout(Duration::from_millis(300)).is_err());
assert!(ws.typing_within(Duration::from_millis(200)).is_empty());
assert!(mm.posts().is_empty());
assert!(
log.contains(&format!(
"gatewayd: ignored post {p1} from {EVE}: not allowed"
)),
"{log:?}"
);
assert!(!log.iter().any(|l| l.contains("secret words")), "{log:?}");
}
#[test]
fn messages_during_a_turn_go_together_in_the_next() {
let home = TempDir::new("serve-burst");
loop_dir(&home);
let (release_tx, release_rx) = mpsc::channel::<()>();
let release = Mutex::new(release_rx);
let turns = serve_loop(&home.path().join("run/loop/loop.sock"), move |n, turn| {
if n == 0 {
let _ = release.lock().unwrap().recv_timeout(WAIT);
}
vec![done(&format!("answer to {}", turn.content))]
});
let mm = FakeMm::start();
let _running = start(config(&home, &mm.url(), ""));
let mut ws = mm.next_ws(WAIT);
let p1 = id('p', 1);
ws.posted(&post(&p1, KYLE, DM, "", "one", 5), "D");
assert_eq!(turns.recv_timeout(WAIT).unwrap().content, "one");
let saved = read_state(&home);
assert_eq!(
saved["in_flight"],
serde_json::json!([{"session": format!("mm-{p1}"), "channel": DM, "root": p1}])
);
ws.posted(&post(&id('p', 2), KYLE, DM, &p1, "two", 6), "D");
ws.posted(&post(&id('p', 3), KYLE, DM, &p1, "three", 7), "D");
std::thread::sleep(Duration::from_millis(200));
release_tx.send(()).unwrap();
let second = turns.recv_timeout(WAIT).unwrap();
assert_eq!(
(
second.session.as_str(),
second.content.as_str(),
second.resume
),
(format!("mm-{p1}").as_str(), "two\n\nthree", true)
);
let posts = mm.wait_posts(2, WAIT);
let texts: Vec<&str> = posts.iter().map(|(_, _, t)| t.as_str()).collect();
assert_eq!(texts, ["answer to one", "answer to two\n\nthree"]);
}
#[test]
fn a_full_queue_says_busy() {
let home = TempDir::new("serve-busy");
loop_dir(&home);
let (release_tx, release_rx) = mpsc::channel::<()>();
let release = Mutex::new(release_rx);
let _turns = serve_loop(&home.path().join("run/loop/loop.sock"), move |_, _| {
let _ = release.lock().unwrap().recv_timeout(WAIT);
vec![done("ok")]
});
let mm = FakeMm::start();
let _running = start(config(&home, &mm.url(), "queue = 1"));
let mut ws = mm.next_ws(WAIT);
let p1 = id('p', 1);
for (n, text) in ["run", "waits", "too many"].iter().enumerate() {
let n = u32::try_from(n).unwrap();
let root = if n == 0 { String::new() } else { p1.clone() };
ws.posted(
&post(&id('p', n + 1), KYLE, DM, &root, text, i64::from(n) + 5),
"D",
);
}
let posts = mm.wait_posts(1, WAIT);
assert_eq!(posts, [(DM.to_string(), p1, BUSY.to_string())]);
release_tx.send(()).unwrap();
release_tx.send(()).unwrap();
}
#[test]
fn channels_are_answered_only_when_named_or_in_our_thread() {
let home = TempDir::new("serve-channel");
let (mm, _running, turns, mut ws) = up(&home, Duration::ZERO);
let p1 = id('p', 1);
ws.posted(
&post(&id('p', 9), KYLE, SHARED, "", "hello everyone", 4),
"O",
);
ws.posted(
&post(
&id('p', 8),
KYLE,
OTHER,
"",
"@boxmaker-straylight elsewhere",
4,
),
"O",
);
ws.posted(
&post(&p1, KYLE, SHARED, "", "@boxmaker-straylight start", 5),
"O",
);
assert_eq!(
turns.recv_timeout(WAIT).unwrap().content,
"@boxmaker-straylight start"
);
mm.wait_posts(1, WAIT);
ws.posted(
&post(&id('p', 2), KYLE, SHARED, &p1, "@hermes your turn", 6),
"O",
);
ws.posted(&post(&id('p', 3), KYLE, SHARED, &p1, "and more", 7), "O");
let next = turns.recv_timeout(WAIT).unwrap();
assert_eq!((next.content.as_str(), next.resume), ("and more", true));
let posts = mm.wait_posts(2, WAIT);
assert!(
posts.iter().all(|(c, r, _)| c == SHARED && *r == p1),
"{posts:?}"
);
assert!(turns.recv_timeout(Duration::from_millis(200)).is_err());
let saved = read_state(&home);
assert!(saved["channels"].get(OTHER).is_none(), "{saved}");
assert_eq!(saved["threads"], serde_json::json!([p1]));
}
#[test]
fn commands_are_answered_without_a_turn() {
let home = TempDir::new("serve-command");
let (mm, _running, turns, mut ws) = up(&home, Duration::ZERO);
let p1 = id('p', 1);
ws.posted(&post(&p1, KYLE, DM, "", "!approve 3", 5), "D");
assert_eq!(
mm.wait_posts(1, WAIT),
[(DM.to_string(), p1, M4B_COMMAND.to_string())]
);
assert!(turns.recv_timeout(Duration::from_millis(200)).is_err());
}
@@ -0,0 +1,192 @@
//! `gatewayd` end to end across gaps: a restart, a lost connection, an unreachable server, a
//! refused token and a damaged state file (M4a spec, section 9). Do not edit.
#[path = "support/fake_loop.rs"]
mod fake_loop;
#[path = "support/fake_mm.rs"]
mod fake_mm;
#[path = "support/gateway.rs"]
mod gateway;
#[path = "support/tmp.rs"]
mod tmp;
use std::sync::{Mutex, mpsc};
use std::time::Duration;
use fake_loop::{done, serve_loop};
use fake_mm::{DM, FakeMm, KYLE, id, post};
use gateway::{SHARED, WAIT, answering, config, loop_dir, start, up};
use gatewayd::serve::{INTERRUPTED, Stop};
use tmp::TempDir;
#[test]
fn a_restart_reports_the_cut_off_turn_and_catches_up() {
let home = TempDir::new("serve-restart");
let (cut, seen, new1, new2) = (id('r', 1), id('p', 2), id('p', 3), id('p', 4));
let state = serde_json::json!({
"channels": {DM: 1000}, "recent": [seen], "threads": [],
"in_flight": [{"session": format!("mm-{cut}"), "channel": DM, "root": cut}]
});
home.write("gateway/state.json", &state.to_string());
loop_dir(&home);
let turns = answering(&home, Duration::ZERO);
let mm = FakeMm::start();
mm.set_since(
DM,
&[
post(&new2, KYLE, DM, "", "second", 2000),
post(&seen, KYLE, DM, "", "already answered", 1500),
post(&new1, KYLE, DM, "", "first", 1800),
],
);
let _running = start(config(&home, &mm.url(), ""));
let _ws = mm.next_ws(WAIT);
let first = turns.recv_timeout(WAIT).unwrap();
let second = turns.recv_timeout(WAIT).unwrap();
assert_eq!(
(first.content.as_str(), second.content.as_str()),
("first", "second")
);
let posts = mm.wait_posts(3, WAIT);
assert_eq!(posts[0], (DM.to_string(), cut, INTERRUPTED.to_string()));
assert_eq!(posts.len(), 3, "{posts:?}");
std::thread::sleep(Duration::from_millis(100));
let saved: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(home.path().join("gateway/state.json")).unwrap(),
)
.unwrap();
assert_eq!(saved["in_flight"], serde_json::json!([]));
assert_eq!(saved["channels"][DM], 2000);
}
#[test]
fn a_first_start_answers_no_history() {
let home = TempDir::new("serve-first");
loop_dir(&home);
let turns = answering(&home, Duration::ZERO);
let mm = FakeMm::start();
mm.set_since(DM, &[post(&id('p', 1), KYLE, DM, "", "old", 5)]);
let running = start(config(&home, &mm.url(), ""));
let _ws = mm.next_ws(WAIT);
running.wait_log("connected to");
assert!(turns.recv_timeout(Duration::from_millis(300)).is_err());
assert!(
!mm.calls().iter().any(|(_, p)| p.contains("since=")),
"{:?}",
mm.calls()
);
let saved: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(home.path().join("gateway/state.json")).unwrap(),
)
.unwrap();
assert!(
saved["channels"][DM].as_i64().unwrap() > 1_700_000_000_000,
"marked from now"
);
assert!(saved["channels"][SHARED].as_i64().is_some());
}
#[test]
fn a_lost_connection_is_made_again() {
let home = TempDir::new("serve-reconnect");
let (mm, running, turns, ws) = up(&home, Duration::ZERO);
ws.drop_connection();
let mut again = mm.next_ws(WAIT);
let log = running.wait_log("lost the connection");
assert!(
log.iter()
.any(|l| l.ends_with("see docs/runbook.md#mattermost-unreachable")),
"{log:?}"
);
let p1 = id('p', 1);
again.posted(&post(&p1, KYLE, DM, "", "still there?", 5), "D");
assert_eq!(turns.recv_timeout(WAIT).unwrap().content, "still there?");
assert_eq!(mm.wait_posts(1, WAIT).len(), 1);
}
#[test]
fn a_refused_token_stops_gatewayd() {
let home = TempDir::new("serve-auth");
let mm = FakeMm::start();
mm.refuse_token();
let running = start(config(&home, &mm.url(), ""));
let stop = running.join_within();
assert!(matches!(stop, Stop::Auth(401)), "{stop}");
assert_eq!(
stop.to_string(),
"gatewayd: Mattermost refused the token (401)\nsee docs/runbook.md#mattermost-auth-failed"
);
}
#[test]
fn an_unreachable_server_is_tried_again() {
let home = TempDir::new("serve-unreachable");
let port = std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port();
let url = format!("http://127.0.0.1:{port}");
let running = start(config(&home, &url, ""));
std::thread::sleep(Duration::from_millis(300));
let log = running.log();
let tries: Vec<&String> = log
.iter()
.filter(|l| l.starts_with(&format!("gatewayd: cannot reach {url}: ")))
.collect();
assert!(tries.len() >= 2, "{log:?}");
assert!(
tries
.iter()
.all(|l| l
.ends_with("; trying again in 0 s\nsee docs/runbook.md#mattermost-unreachable")),
"{tries:?}"
);
assert!(matches!(running.finish(), Stop::Asked));
}
#[test]
fn a_damaged_state_file_stops_at_once() {
let home = TempDir::new("serve-damaged");
home.write("gateway/state.json", "{not json");
let mm = FakeMm::start();
let running = start(config(&home, &mm.url(), ""));
let stop = running.join_within();
assert!(matches!(stop, Stop::State(_)), "{stop}");
assert!(
stop.to_string()
.ends_with("see docs/runbook.md#gateway-state-damaged"),
"{stop}"
);
assert!(mm.calls().is_empty(), "nothing is asked of Mattermost");
}
#[test]
fn a_reconnect_does_not_interrupt_a_running_turn() {
let home = TempDir::new("serve-reconnect-turn");
loop_dir(&home);
let (release_tx, release_rx) = mpsc::channel::<()>();
let release = Mutex::new(release_rx);
let turns = serve_loop(&home.path().join("run/loop/loop.sock"), move |_, _| {
let _ = release.lock().unwrap().recv_timeout(WAIT);
vec![done("the answer")]
});
let mm = FakeMm::start();
let running = start(config(&home, &mm.url(), ""));
let mut ws = mm.next_ws(WAIT);
let p1 = id('p', 1);
ws.posted(&post(&p1, KYLE, DM, "", "a long one", 5), "D");
turns.recv_timeout(WAIT).unwrap();
ws.drop_connection();
let _again = mm.next_ws(WAIT);
running.wait_log("lost the connection");
std::thread::sleep(Duration::from_millis(100));
release_tx.send(()).unwrap();
let posts = mm.wait_posts(1, WAIT);
std::thread::sleep(Duration::from_millis(200));
assert_eq!(
mm.posts(),
[(DM.to_string(), p1, "the answer".to_string())],
"{posts:?}"
);
}
@@ -0,0 +1,298 @@
//! Which posts become turns, in which session, and how messages wait for a running turn (M4a spec,
//! section 7, including its table of examples). Do not edit.
use gatewayd::mm::Post;
use gatewayd::sessions::{
BUSY, Batch, Ignored, M4B_COMMAND, Message, Pushed, Queues, Route, Router, Thread,
UNKNOWN_COMMAND, named,
};
use proto::SessionId;
const BOT: &str = "b0000000000000000000000000";
const KYLE: &str = "k0000000000000000000000000";
const EVE: &str = "e0000000000000000000000000";
const DM: &str = "d0000000000000000000000000";
const SHARED: &str = "c0000000000000000000000000";
const OTHER: &str = "o0000000000000000000000000";
const ROOT: &str = "r0000000000000000000000000";
const POST: &str = "p0000000000000000000000000";
fn router() -> Router {
Router::new(
BOT,
"boxmaker-straylight",
&[KYLE.to_string()],
&[SHARED.to_string()],
)
}
fn post(channel: &str, root: &str, message: &str) -> Post {
Post {
id: POST.to_string(),
user_id: KYLE.to_string(),
channel_id: channel.to_string(),
root_id: root.to_string(),
message: message.to_string(),
create_at: 5,
delete_at: 0,
kind: String::new(),
}
}
fn known(root: &str) -> bool {
root == ROOT
}
fn unknown(_: &str) -> bool {
false
}
fn queued(route: Route) -> Message {
match route {
Route::Queue(m) => m,
other => panic!("not queued: {other:?}"),
}
}
#[test]
fn the_examples_in_the_spec() {
let r = router();
let cases = [
("", "@boxmaker-straylight summarise the audit log", true),
(ROOT, "and the older files?", true),
(ROOT, "@hermes what do you think?", false),
("", "@boxmaker-straylight @hermes compare notes", true),
("", "@boxmaker-straylightx hello", false),
("", "@channel standup in five", false),
];
for (root, message, yes) in cases {
let got = r.route(&post(SHARED, root, message), "O", &known);
assert_eq!(matches!(got, Route::Queue(_)), yes, "{message}: {got:?}");
if !yes {
assert_eq!(got, Route::Ignore(Ignored::NotForUs), "{message}");
}
}
}
#[test]
fn naming() {
assert_eq!(named("@Boxmaker-Straylight, look"), ["boxmaker-straylight"]);
assert_eq!(named("ask @boxmaker-straylight."), ["boxmaker-straylight"]);
assert_eq!(named("@a.b_c-d... and @e"), ["a.b_c-d", "e"]);
assert_eq!(named("@ alone, @@x, trailing @"), ["x"]);
assert_eq!(named("ünïcødé @ʙob @bob"), ["bob"]);
assert!(named("no names here").is_empty());
let r = router();
for message in [
"hi @BOXMAKER-STRAYLIGHT",
"@boxmaker-straylight.",
"(@boxmaker-straylight)",
] {
assert!(
matches!(
r.route(&post(SHARED, "", message), "P", &unknown),
Route::Queue(_)
),
"{message}"
);
}
}
#[test]
fn replies_in_our_thread_that_name_everyone_are_still_ours() {
let r = router();
for message in ["@here any news?", "@all done", "thanks @channel"] {
assert!(
matches!(
r.route(&post(SHARED, ROOT, message), "O", &known),
Route::Queue(_)
),
"{message}"
);
}
let got = r.route(
&post(
SHARED,
"q0000000000000000000000000",
"a reply in someone else's thread",
),
"O",
&known,
);
assert_eq!(got, Route::Ignore(Ignored::NotForUs));
}
#[test]
fn who_and_where() {
let r = router();
let mut own = post(DM, "", "hello");
own.user_id = BOT.to_string();
assert_eq!(r.route(&own, "D", &unknown), Route::Ignore(Ignored::Own));
let mut system = post(DM, "", "joined");
system.kind = "system_join_channel".to_string();
assert_eq!(
r.route(&system, "D", &unknown),
Route::Ignore(Ignored::System)
);
let mut stranger = post(DM, "", "@boxmaker-straylight hello");
stranger.user_id = EVE.to_string();
assert_eq!(
r.route(&stranger, "D", &unknown),
Route::Ignore(Ignored::NotAllowed)
);
let mut stranger_cmd = post(DM, "", "!approve 1");
stranger_cmd.user_id = EVE.to_string();
assert_eq!(
r.route(&stranger_cmd, "D", &unknown),
Route::Ignore(Ignored::NotAllowed)
);
let naming = "@boxmaker-straylight hello";
assert_eq!(
r.route(&post(OTHER, "", naming), "O", &unknown),
Route::Ignore(Ignored::NotForUs)
);
assert_eq!(
r.route(&post(SHARED, "", naming), "X", &unknown),
Route::Ignore(Ignored::NotForUs)
);
assert!(matches!(
r.route(&post(SHARED, "", naming), "G", &unknown),
Route::Queue(_)
));
assert!(matches!(
r.route(&post(DM, "", "no name needed"), "D", &unknown),
Route::Queue(_)
));
}
#[test]
fn sessions_and_threads() {
let r = router();
let top = queued(r.route(&post(DM, "", "hello"), "D", &unknown));
assert_eq!(top.session.as_str(), format!("mm-{POST}"));
assert_eq!(
top.thread,
Thread {
channel: DM.to_string(),
root: POST.to_string()
}
);
assert!(!top.resume && !top.joins_thread);
assert_eq!(top.text, "hello");
let reply = queued(r.route(&post(DM, ROOT, "more"), "D", &unknown));
assert_eq!(reply.session.as_str(), format!("mm-{ROOT}"));
assert_eq!(reply.thread.root, ROOT);
assert!(reply.resume);
let channel = queued(r.route(&post(SHARED, "", "@boxmaker-straylight hi"), "O", &unknown));
assert!(channel.joins_thread && !channel.resume);
assert_eq!(channel.text, "@boxmaker-straylight hi");
}
#[test]
fn commands() {
let r = router();
let thread = Thread {
channel: DM.to_string(),
root: ROOT.to_string(),
};
let reply = |text: &str| Route::Reply {
thread: thread.clone(),
text: text.to_string(),
};
for (message, answer) in [
("!approve 42", M4B_COMMAND),
("!deny 42 not now", M4B_COMMAND),
("!deny", M4B_COMMAND),
("!approved", UNKNOWN_COMMAND),
("!help", UNKNOWN_COMMAND),
("!", UNKNOWN_COMMAND),
] {
assert_eq!(
r.route(&post(DM, ROOT, message), "D", &unknown),
reply(answer),
"{message}"
);
}
assert_eq!(
queued(r.route(&post(DM, ROOT, "!!approve is a word"), "D", &unknown)).text,
"!approve is a word"
);
assert_eq!(
queued(r.route(&post(DM, ROOT, "!!"), "D", &unknown)).text,
"!"
);
assert_eq!(
queued(r.route(&post(DM, ROOT, " !help"), "D", &unknown)).text,
" !help"
);
}
fn message(root: &str, resume: bool, text: &str) -> Message {
Message {
session: SessionId::new(&format!("mm-{root}")).unwrap(),
thread: Thread {
channel: DM.to_string(),
root: root.to_string(),
},
resume,
text: text.to_string(),
joins_thread: false,
}
}
#[test]
fn messages_wait_for_the_running_turn_and_go_together() {
let mut q = Queues::new(3);
let first = message(ROOT, false, "one");
let Pushed::Start(batch) = q.push(first.clone()) else {
panic!("not started")
};
assert_eq!(
batch,
Batch {
session: first.session.clone(),
thread: first.thread.clone(),
resume: false,
text: "one".to_string()
}
);
assert_eq!(q.push(message(ROOT, true, "two")), Pushed::Waiting);
assert_eq!(q.push(message(ROOT, true, "three\nlines")), Pushed::Waiting);
let other = message(POST, false, "elsewhere");
assert!(
matches!(q.push(other.clone()), Pushed::Start(_)),
"another session starts at once"
);
assert_eq!(q.running(), 2);
let mut roots: Vec<String> = q.threads().into_iter().map(|t| t.root).collect();
roots.sort();
assert_eq!(roots, [POST, ROOT]);
let next = q.finish(&first.session).unwrap();
assert_eq!(
(next.text.as_str(), next.resume),
("two\n\nthree\nlines", true)
);
assert_eq!(q.finish(&first.session), None);
assert_eq!(q.finish(&other.session), None);
assert_eq!(q.running(), 0);
assert!(
matches!(q.push(message(ROOT, true, "later")), Pushed::Start(_)),
"idle again"
);
}
#[test]
fn a_full_queue_drops_the_message() {
let mut q = Queues::new(2);
let m = message(ROOT, false, "run");
assert!(matches!(q.push(m.clone()), Pushed::Start(_)));
assert_eq!(q.push(message(ROOT, true, "a")), Pushed::Waiting);
assert_eq!(q.push(message(ROOT, true, "b")), Pushed::Waiting);
assert_eq!(
q.push(message(ROOT, true, "c")),
Pushed::Full(m.thread.clone())
);
assert_eq!(q.finish(&m.session).unwrap().text, "a\n\nb");
assert!(!BUSY.is_empty());
assert_eq!(q.finish(&SessionId::new("mm-never").unwrap()), None);
}
@@ -0,0 +1,178 @@
//! The state file: a first start, surviving a restart, its limits, and refusing a damaged file
//! instead of guessing (M4a spec, section 9). Do not edit.
#[path = "support/tmp.rs"]
mod tmp;
use std::os::unix::fs::PermissionsExt;
use gatewayd::state::{InFlight, RECENT_KEPT, State, StateError, THREADS_KEPT};
use tmp::TempDir;
const CHAN: &str = "c0000000000000000000000000";
const DM: &str = "d0000000000000000000000000";
fn id(n: usize) -> String {
format!("p{n:025}")
}
fn turn(n: usize) -> InFlight {
InFlight {
session: format!("mm-{}", id(n)),
channel: DM.to_string(),
root: id(n),
}
}
#[test]
fn a_first_start_then_a_restart() {
let dir = TempDir::new("state-restart");
let path = dir.path().join("gateway/state.json");
let mut s = State::load(&path).unwrap();
assert!(!path.exists(), "loading writes nothing");
assert_eq!(
(s.since(CHAN), s.channels().len(), s.seen(&id(1))),
(None, 0, false)
);
s.handled(&id(1), CHAN, 2000).unwrap();
s.handled(&id(2), CHAN, 1500).unwrap();
s.mark(DM, 3000).unwrap();
s.mark(DM, 9000).unwrap();
s.join_thread(&id(1)).unwrap();
s.start_turn(turn(1)).unwrap();
s.start_turn(turn(2)).unwrap();
s.end_turn(&format!("mm-{}", id(2))).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
let dir_mode = std::fs::metadata(path.parent().unwrap())
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!((mode, dir_mode), (0o600, 0o700));
assert!(!dir.path().join("gateway/state.json.tmp").exists());
let mut again = State::load(&path).unwrap();
assert_eq!(again.since(CHAN), Some(2000), "the mark never moves back");
assert_eq!(
again.since(DM),
Some(3000),
"mark only sets a channel without one"
);
assert_eq!(again.channels(), [CHAN, DM]);
assert!(again.seen(&id(1)) && again.seen(&id(2)));
assert!(again.knows_thread(&id(1)) && !again.knows_thread(&id(2)));
assert_eq!(again.take_in_flight().unwrap(), [turn(1)]);
assert!(
State::load(&path)
.unwrap()
.take_in_flight()
.unwrap()
.is_empty(),
"taking is saved"
);
}
#[test]
fn only_the_newest_posts_and_threads_are_kept() {
let dir = TempDir::new("state-limits");
// A full file to start from: posts 0.. and threads 0.., at their limits.
let recent: Vec<String> = (0..RECENT_KEPT).map(id).collect();
let threads: Vec<String> = (0..THREADS_KEPT).map(id).collect();
let full =
serde_json::json!({"channels": {}, "recent": recent, "threads": threads, "in_flight": []});
let path = dir.write("state.json", &full.to_string());
let mut s = State::load(&path).unwrap();
s.handled(&id(RECENT_KEPT), CHAN, 1).unwrap();
s.handled(&id(RECENT_KEPT + 1), CHAN, 1).unwrap();
s.handled(&id(RECENT_KEPT + 1), CHAN, 1).unwrap();
s.join_thread(&id(THREADS_KEPT)).unwrap();
s.join_thread(&id(3)).unwrap();
let s = State::load(&path).unwrap();
assert!(!s.seen(&id(0)) && !s.seen(&id(1)) && s.seen(&id(2)) && s.seen(&id(RECENT_KEPT + 1)));
assert!(!s.knows_thread(&id(0)) && s.knows_thread(&id(1)) && s.knows_thread(&id(THREADS_KEPT)));
let text = std::fs::read_to_string(&path).unwrap();
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(
v["recent"].as_array().unwrap().len(),
RECENT_KEPT,
"a repeat is not stored twice"
);
assert_eq!(v["threads"].as_array().unwrap().len(), THREADS_KEPT);
}
#[test]
fn a_damaged_file_stops_with_its_pointer() {
let dir = TempDir::new("state-damaged");
let good_turn = r#"{"session":"mm-p0000000000000000000000001","channel":"d0000000000000000000000000","root":"p0000000000000000000000001"}"#;
let cases = [
"".to_string(),
"{".to_string(),
"[]".to_string(),
r#"{"channels":{},"recent":[],"threads":[]}"#.to_string(),
r#"{"channels":{},"recent":[],"threads":[],"in_flight":[],"extra":1}"#.to_string(),
r#"{"channels":{"../x":1},"recent":[],"threads":[],"in_flight":[]}"#.to_string(),
r#"{"channels":{},"recent":["short"],"threads":[],"in_flight":[]}"#.to_string(),
r#"{"channels":{},"recent":[],"threads":["P0000000000000000000000000"],"in_flight":[]}"#
.to_string(),
format!(
r#"{{"channels":{{}},"recent":[],"threads":[],"in_flight":[{}]}}"#,
good_turn.replace("mm-", "xx-")
),
format!(
r#"{{"channels":{{}},"recent":[],"threads":[],"in_flight":[{}]}}"#,
good_turn.replace("\"d0", "\"D0")
),
];
for (n, text) in cases.iter().enumerate() {
let path = dir.write(&format!("s{n}.json"), text);
match State::load(&path) {
Err(e @ StateError::Read(..)) => {
let message = e.to_string();
assert!(
message.starts_with(&path.display().to_string()),
"{message}"
);
assert!(
message.ends_with("\nsee docs/runbook.md#gateway-state-damaged"),
"{message}"
);
}
Err(e) => panic!("{text}: {e}"),
Ok(_) => panic!("accepted: {text}"),
}
}
let ok = dir.write(
"ok.json",
&format!(
r#"{{"channels":{{"{CHAN}":5}},"recent":[],"threads":[],"in_flight":[{good_turn}]}}"#
),
);
assert_eq!(State::load(&ok).unwrap().since(CHAN), Some(5));
std::fs::create_dir(dir.path().join("adir")).unwrap();
assert!(matches!(
State::load(&dir.path().join("adir")),
Err(StateError::Read(..))
));
}
#[test]
fn a_failed_write_is_an_error_and_keeps_the_old_file() {
let dir = TempDir::new("state-readonly");
let sub = dir.path().join("gateway");
let path = sub.join("state.json");
let mut s = State::load(&path).unwrap();
s.handled(&id(1), CHAN, 5).unwrap();
let before = std::fs::read(&path).unwrap();
std::fs::set_permissions(&sub, std::fs::Permissions::from_mode(0o500)).unwrap();
let got = s.handled(&id(2), CHAN, 6);
std::fs::set_permissions(&sub, std::fs::Permissions::from_mode(0o700)).unwrap();
let e = got.unwrap_err();
assert!(matches!(e, StateError::Write(..)), "{e}");
assert!(
e.to_string()
.ends_with("see docs/runbook.md#gateway-state-damaged"),
"{e}"
);
assert_eq!(std::fs::read(&path).unwrap(), before);
}
@@ -0,0 +1,101 @@
//! A fake `loopd` on a Unix socket: it records each turn and answers with the frames the test's
//! function gives for it. Do not edit.
#![allow(dead_code)] // each test file uses a different part of this module
use std::io::Write;
use std::os::unix::net::UnixListener;
use std::path::Path;
use std::sync::mpsc;
use proto::{
Envelope, ErrorCode, Message, PROTOCOL_VERSION, Turn, TurnDone, TurnEvent, Usage, WireError,
read_frame, write_frame,
};
const USAGE: Usage = Usage {
cache_n: 0,
prompt_n: 1,
predicted_n: 1,
reasoning_tokens: 0,
thinking_capped: false,
};
/// What the fake sends back for a turn.
pub enum Reply {
Frame(Envelope),
/// Raw bytes, for broken frames.
Bytes(Vec<u8>),
/// Stop answering this connection (the caller sees it close).
Close,
}
pub fn event(e: TurnEvent) -> Reply {
Reply::Frame(Envelope {
v: PROTOCOL_VERSION,
id: 1,
r#final: false,
msg: Message::TurnEvent(e),
})
}
pub fn done(content: &str) -> Reply {
let msg = Message::TurnDone(TurnDone {
content: content.to_string(),
usage: USAGE,
});
Reply::Frame(Envelope {
v: PROTOCOL_VERSION,
id: 1,
r#final: true,
msg,
})
}
pub fn error(code: ErrorCode, detail: &str) -> Reply {
let msg = Message::Error(WireError {
code,
detail: detail.to_string(),
});
Reply::Frame(Envelope {
v: PROTOCOL_VERSION,
id: 1,
r#final: true,
msg,
})
}
/// Listen on `socket`; `script(n, &turn)` gives the replies to the n-th turn (from 0).
pub fn serve_loop<F>(socket: &Path, script: F) -> mpsc::Receiver<Turn>
where
F: Fn(usize, &Turn) -> Vec<Reply> + Send + 'static,
{
let listener = UnixListener::bind(socket).unwrap();
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
for (n, stream) in listener.incoming().enumerate() {
let Ok(mut stream) = stream else { continue };
let Ok(Envelope {
msg: Message::Turn(turn),
..
}) = read_frame(&mut stream)
else {
continue;
};
// Reported before the script runs: a script may wait for the test.
let _ = tx.send(turn.clone());
let replies = script(n, &turn);
for reply in replies {
let ok = match reply {
Reply::Frame(env) => write_frame(&mut stream, &env).is_ok(),
Reply::Bytes(b) => stream.write_all(&b).is_ok(),
Reply::Close => false,
};
if !ok {
break;
}
}
}
});
rx
}
@@ -0,0 +1,331 @@
//! A fake Mattermost on 127.0.0.1, plain TCP: the four REST calls `gatewayd` makes, and the
//! WebSocket, whose events the test sends and whose requests it reads. Do not edit.
#![allow(dead_code)] // each test file uses a different part of this module
use std::collections::HashMap;
use std::io::{Read, Write};
use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream};
use std::sync::{Arc, Mutex, mpsc};
use std::time::{Duration, Instant};
use gatewayd::ws::handshake::accept_for;
use serde_json::{Value, json};
pub const BOT: &str = "b0000000000000000000000000";
pub const BOT_NAME: &str = "boxmaker-straylight";
pub const KYLE: &str = "k0000000000000000000000000";
pub const EVE: &str = "e0000000000000000000000000";
/// The direct channel between the bot and Kyle, and between the bot and anyone else.
pub const DM: &str = "d0000000000000000000000000";
pub const EVE_DM: &str = "f0000000000000000000000000";
#[derive(Default)]
struct Inner {
/// Status for `users/me`: 200 unless a test sets another.
me_status: u16,
/// The body for `channels/<id>/posts?since=`, by channel.
since: HashMap<String, Value>,
/// Every post made: channel, root, message.
posts: Vec<(String, String, String)>,
/// Every REST call: method and path.
calls: Vec<(String, String)>,
}
pub struct FakeMm {
pub addr: SocketAddr,
inner: Arc<Mutex<Inner>>,
sockets: Mutex<mpsc::Receiver<WsPeer>>,
}
/// One WebSocket connection from `gatewayd`.
pub struct WsPeer {
writer: TcpStream,
/// The text of every text frame `gatewayd` sends.
pub texts: mpsc::Receiver<String>,
}
/// A post as Mattermost sends it.
pub fn post(
id: &str,
user: &str,
channel: &str,
root: &str,
message: &str,
create_at: i64,
) -> Value {
json!({
"id": id, "create_at": create_at, "update_at": create_at, "delete_at": 0, "user_id": user,
"channel_id": channel, "root_id": root, "message": message, "type": "", "props": {}
})
}
pub fn id(prefix: char, n: u32) -> String {
format!("{prefix}{n:025}")
}
fn frame(opcode: u8, payload: &[u8]) -> Vec<u8> {
let mut out = vec![0x80 | opcode];
match payload.len() {
n if n < 126 => out.push(n as u8),
n => {
out.push(126);
out.extend_from_slice(&(n as u16).to_be_bytes());
}
}
out.extend_from_slice(payload);
out
}
impl WsPeer {
pub fn event(&mut self, value: &Value) {
let _ = self
.writer
.write_all(&frame(0x1, value.to_string().as_bytes()));
}
pub fn posted(&mut self, post: &Value, channel_type: &str) {
let data = json!({"post": post.to_string(), "channel_type": channel_type, "team_id": ""});
self.event(&json!({"event": "posted", "data": data, "broadcast": {}, "seq": 1}));
}
/// End the connection without a close frame.
pub fn drop_connection(self) {
let _ = self.writer.shutdown(Shutdown::Both);
}
/// The `user_typing` requests received within `wait`, as (channel, parent).
pub fn typing_within(&self, wait: Duration) -> Vec<(String, String)> {
let until = Instant::now() + wait;
let mut got = Vec::new();
while let Ok(text) = self
.texts
.recv_timeout(until.saturating_duration_since(Instant::now()))
{
let v: Value = serde_json::from_str(&text).unwrap();
if v["action"] == "user_typing" {
let data = &v["data"];
got.push((
data["channel_id"].as_str().unwrap().to_string(),
data["parent_id"].as_str().unwrap().to_string(),
));
}
}
got
}
}
fn read_head(stream: &mut TcpStream) -> Option<String> {
let mut head = Vec::new();
let mut byte = [0u8; 1];
while !head.ends_with(b"\r\n\r\n") {
if stream.read(&mut byte).ok()? == 0 {
return None;
}
head.push(byte[0]);
}
String::from_utf8(head).ok()
}
/// Unmask the client's frames and send each text on `tx`, until the connection ends.
fn read_frames(mut stream: TcpStream, tx: mpsc::Sender<String>) {
let mut exact = |n: usize| -> Option<Vec<u8>> {
let mut buf = vec![0u8; n];
stream.read_exact(&mut buf).ok().map(|()| buf)
};
loop {
let Some(head) = exact(2) else { return };
let len = match head[1] & 0x7F {
126 => u16::from_be_bytes(exact(2).unwrap().try_into().unwrap()) as usize,
127 => return,
n => n as usize,
};
let Some(mask) = exact(4) else { return };
let Some(raw) = exact(len) else { return };
let payload: Vec<u8> = raw
.iter()
.zip(mask.iter().cycle())
.map(|(b, m)| b ^ m)
.collect();
if head[0] & 0x0F == 0x1 {
let _ = tx.send(String::from_utf8(payload).unwrap());
}
}
}
impl FakeMm {
pub fn start() -> FakeMm {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let inner = Arc::new(Mutex::new(Inner {
me_status: 200,
..Inner::default()
}));
let (ws_tx, ws_rx) = mpsc::channel();
let shared = Arc::clone(&inner);
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(stream) = stream else { continue };
let (inner, ws_tx) = (Arc::clone(&shared), ws_tx.clone());
std::thread::spawn(move || connection(stream, &inner, &ws_tx));
}
});
FakeMm {
addr,
inner,
sockets: Mutex::new(ws_rx),
}
}
pub fn url(&self) -> String {
format!("http://127.0.0.1:{}", self.addr.port())
}
pub fn refuse_token(&self) {
self.inner.lock().unwrap().me_status = 401;
}
pub fn set_since(&self, channel: &str, posts: &[Value]) {
let order: Vec<Value> = posts.iter().map(|p| p["id"].clone()).collect();
let map: serde_json::Map<String, Value> = posts
.iter()
.map(|p| (p["id"].as_str().unwrap().to_string(), p.clone()))
.collect();
self.inner
.lock()
.unwrap()
.since
.insert(channel.to_string(), json!({"order": order, "posts": map}));
}
/// The next WebSocket `gatewayd` opens, after its hello.
pub fn next_ws(&self, wait: Duration) -> WsPeer {
self.sockets
.lock()
.unwrap()
.recv_timeout(wait)
.expect("no WebSocket connection")
}
pub fn posts(&self) -> Vec<(String, String, String)> {
self.inner.lock().unwrap().posts.clone()
}
/// Wait until at least `n` posts were made, for at most `wait`.
pub fn wait_posts(&self, n: usize, wait: Duration) -> Vec<(String, String, String)> {
let until = Instant::now() + wait;
while self.posts().len() < n && Instant::now() < until {
std::thread::sleep(Duration::from_millis(10));
}
self.posts()
}
pub fn calls(&self) -> Vec<(String, String)> {
self.inner.lock().unwrap().calls.clone()
}
}
fn connection(mut stream: TcpStream, inner: &Mutex<Inner>, ws_tx: &mpsc::Sender<WsPeer>) {
let Some(head) = read_head(&mut stream) else {
return;
};
let mut words = head.split_whitespace();
let (method, path) = (
words.next().unwrap_or("").to_string(),
words.next().unwrap_or("").to_string(),
);
if path == "/api/v4/websocket" {
let key = head
.lines()
.find_map(|l| l.strip_prefix("Sec-WebSocket-Key: "))
.unwrap_or("")
.trim()
.to_string();
let reply = format!(
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {}\r\n\r\n",
accept_for(&key)
);
let _ = stream.write_all(reply.as_bytes());
let _ = stream.write_all(&frame(
0x1,
br#"{"event":"hello","data":{},"broadcast":{},"seq":0}"#,
));
let (tx, texts) = mpsc::channel();
let reader = stream.try_clone().unwrap();
std::thread::spawn(move || read_frames(reader, tx));
let _ = ws_tx.send(WsPeer {
writer: stream,
texts,
});
return;
}
let length = head
.lines()
.find_map(|l| {
l.to_ascii_lowercase()
.strip_prefix("content-length:")
.map(|v| v.trim().parse::<usize>().unwrap_or(0))
})
.unwrap_or(0);
let mut body = vec![0u8; length];
let _ = stream.read_exact(&mut body);
let (status, answer) = rest(inner, &method, &path, &body);
let text = answer.to_string();
let reply = format!(
"HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{text}",
text.len()
);
let _ = stream.write_all(reply.as_bytes());
}
fn rest(inner: &Mutex<Inner>, method: &str, path: &str, body: &[u8]) -> (u16, Value) {
let mut inner = inner.lock().unwrap();
inner.calls.push((method.to_string(), path.to_string()));
match (method, path) {
("GET", "/api/v4/users/me") if inner.me_status == 200 => {
(200, json!({"id": BOT, "username": BOT_NAME}))
}
("GET", "/api/v4/users/me") => (
inner.me_status,
json!({"id": "api.context.session_expired.app_error"}),
),
("POST", "/api/v4/channels/direct") => {
let users: Vec<String> = serde_json::from_slice(body).unwrap();
let channel = if users.iter().any(|u| u == KYLE) {
DM
} else {
EVE_DM
};
(201, json!({"id": channel, "type": "D"}))
}
("POST", "/api/v4/posts") => {
let p: Value = serde_json::from_slice(body).unwrap();
let n = u32::try_from(inner.posts.len()).unwrap();
let (channel, root, message) = (
p["channel_id"].as_str().unwrap(),
p["root_id"].as_str().unwrap(),
p["message"].as_str().unwrap(),
);
inner
.posts
.push((channel.to_string(), root.to_string(), message.to_string()));
(201, post(&id('x', n), BOT, channel, root, message, 1))
}
("GET", p) if p.contains("/posts?since=") => {
let channel = p
.trim_start_matches("/api/v4/channels/")
.split('/')
.next()
.unwrap_or("");
(
200,
inner
.since
.get(channel)
.cloned()
.unwrap_or(json!({"order": [], "posts": {}})),
)
}
_ => (404, json!({"message": "not found"})),
}
}
@@ -0,0 +1,141 @@
//! Running `gatewayd`'s serve loop in a test, against the fake Mattermost and the fake `loopd`.
//! Do not edit.
#![allow(dead_code)] // each test file uses a different part of this module
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, mpsc};
use std::thread::JoinHandle;
use std::time::Duration;
use gatewayd::config::Config;
use gatewayd::secrets::Secret;
use gatewayd::serve::{Stop, Tuning, run};
use proto::Turn;
use crate::fake_loop::{done, serve_loop};
use crate::fake_mm::{FakeMm, KYLE, WsPeer};
use crate::tmp::TempDir;
pub const SHARED: &str = "c0000000000000000000000000";
pub const OTHER: &str = "o0000000000000000000000000";
pub const WAIT: Duration = Duration::from_secs(5);
pub struct Running {
pub stop: Arc<AtomicBool>,
pub log: Arc<Mutex<Vec<String>>>,
pub handle: Option<JoinHandle<Stop>>,
}
impl Running {
pub fn log(&self) -> Vec<String> {
self.log.lock().unwrap().clone()
}
pub fn wait_log(&self, part: &str) -> Vec<String> {
let until = std::time::Instant::now() + WAIT;
while !self.log().iter().any(|l| l.contains(part)) {
assert!(
std::time::Instant::now() < until,
"no log line with {part:?}: {:?}",
self.log()
);
std::thread::sleep(Duration::from_millis(10));
}
self.log()
}
/// The `Stop` `run` returns by itself within 5 s; after that it is stopped, and the test fails.
pub fn join_within(mut self) -> Stop {
let handle = self.handle.take().unwrap();
let until = std::time::Instant::now() + WAIT;
while !handle.is_finished() && std::time::Instant::now() < until {
std::thread::sleep(Duration::from_millis(10));
}
self.stop.store(true, Ordering::SeqCst);
let stop = handle.join().unwrap();
assert!(!matches!(stop, Stop::Asked), "run did not stop by itself");
stop
}
pub fn finish(mut self) -> Stop {
self.stop.store(true, Ordering::SeqCst);
self.handle.take().unwrap().join().unwrap()
}
}
impl Drop for Running {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
}
}
pub fn config(home: &TempDir, url: &str, extra: &str) -> Config {
let text = format!(
r#"
[mattermost]
url = "{url}"
[secrets.mattermost_token]
env = "NOT_READ_BY_RUN"
[allow]
users = ["{KYLE}"]
channels = ["{SHARED}"]
[paths]
home = "{}"
[limits]
typing_every_ms = 100
{extra}
"#,
home.path().display()
);
Config::parse(&text).unwrap()
}
pub fn start(config: Config) -> Running {
let stop = Arc::new(AtomicBool::new(false));
let log = Arc::new(Mutex::new(Vec::new()));
let tuning = Tuning {
backoff: vec![Duration::from_millis(50)],
poll: Duration::from_millis(20),
rest_timeout: WAIT,
};
let (s, l) = (Arc::clone(&stop), Arc::clone(&log));
let handle = std::thread::spawn(move || {
let sink: gatewayd::serve::Log =
Arc::new(move |line: &str| l.lock().unwrap().push(line.to_string()));
run(config, Secret::new("TOKEN".to_string()), tuning, sink, &s)
});
Running {
stop,
log,
handle: Some(handle),
}
}
/// A fake loop that answers every turn with "answer to <content>", after `delay`.
pub fn answering(home: &TempDir, delay: Duration) -> mpsc::Receiver<Turn> {
serve_loop(&home.path().join("run/loop/loop.sock"), move |_, turn| {
std::thread::sleep(delay);
vec![done(&format!("answer to {}", turn.content))]
})
}
pub fn read_state(home: &TempDir) -> serde_json::Value {
let text = std::fs::read_to_string(home.path().join("gateway/state.json")).unwrap();
serde_json::from_str(&text).unwrap()
}
pub fn loop_dir(home: &TempDir) {
std::fs::create_dir_all(home.path().join("run/loop")).unwrap();
}
/// Start with a fake Mattermost and a fake loop; the first WebSocket is returned.
pub fn up(home: &TempDir, delay: Duration) -> (FakeMm, Running, mpsc::Receiver<Turn>, WsPeer) {
loop_dir(home);
let turns = answering(home, delay);
let mm = FakeMm::start();
let running = start(config(home, &mm.url(), ""));
let ws = mm.next_ws(WAIT);
running.wait_log("gatewayd: connected to ");
(mm, running, turns, ws)
}
@@ -0,0 +1,93 @@
//! A scripted HTTP server for tests: it records each request and answers with what the test's
//! function returns for it. Built on `tls_server`. Do not edit.
#![allow(dead_code)] // each test file uses a different part of this module
use std::io::{Read, Write};
use std::net::SocketAddr;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, mpsc};
use rustls::ServerConfig;
use crate::tls_server::serve;
#[derive(Debug, Clone)]
pub struct Request {
pub method: String,
pub path: String,
/// The whole head, for tests that look for a header.
pub head: String,
pub body: Vec<u8>,
}
impl Request {
pub fn json(&self) -> serde_json::Value {
serde_json::from_slice(&self.body).unwrap()
}
}
/// A response with a JSON body.
pub fn reply(status: u16, extra_headers: &str, body: &str) -> String {
format!(
"HTTP/1.1 {status} X\r\nContent-Type: application/json\r\n{extra_headers}Content-Length: {}\r\n\r\n{body}",
body.len()
)
}
fn read_request(conn: &mut dyn Read) -> Option<Request> {
let mut head = Vec::new();
let mut byte = [0u8; 1];
while !head.ends_with(b"\r\n\r\n") {
if conn.read(&mut byte).ok()? == 0 {
return None;
}
head.push(byte[0]);
}
let head = String::from_utf8(head).ok()?;
let mut first = head.split_whitespace();
let method = first.next()?.to_string();
let path = first.next()?.to_string();
let length = head
.lines()
.find_map(|l| {
l.to_ascii_lowercase()
.strip_prefix("content-length:")
.map(|v| v.trim().parse().ok())
})
.flatten()
.unwrap_or(0);
let mut body = vec![0u8; length];
conn.read_exact(&mut body).ok()?;
Some(Request {
method,
path,
head,
body,
})
}
/// Serve requests: `answer(n, &request)` gives the response to the n-th request (from 0). Every
/// request is sent on the returned channel.
pub fn serve_http<F>(
tls: Option<Arc<ServerConfig>>,
answer: F,
) -> (SocketAddr, mpsc::Receiver<Request>)
where
F: Fn(usize, &Request) -> String + Send + Sync + 'static,
{
let (tx, rx) = mpsc::channel();
let tx = Mutex::new(tx);
let count = AtomicUsize::new(0);
let addr = serve(tls, move |mut conn| {
let Some(request) = read_request(&mut conn) else {
return;
};
let n = count.fetch_add(1, Ordering::SeqCst);
let response = answer(n, &request);
let _ = tx.lock().unwrap().send(request);
let _ = conn.write_all(response.as_bytes());
let _ = conn.flush();
});
(addr, rx)
}
@@ -0,0 +1,82 @@
//! Small TCP and TLS servers for tests, using the TEST-ONLY certificates in `fixtures/tls/`.
//! Each serves connections on its own thread with a function of the connection. Do not edit.
#![allow(dead_code)] // each test file uses a different part of this module
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::path::PathBuf;
use std::sync::Arc;
use rustls::pki_types::pem::PemObject;
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use rustls::{ServerConfig, ServerConnection, StreamOwned};
pub fn fixture(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/tls")
.join(name)
}
/// A server certificate (`server`, `wrong-name`, `other-server`) and its key.
pub fn server_config(which: &str) -> Arc<ServerConfig> {
let certs: Vec<CertificateDer<'static>> =
CertificateDer::pem_file_iter(fixture(&format!("{which}.pem")))
.unwrap()
.collect::<Result<_, _>>()
.unwrap();
let key = PrivateKeyDer::from_pem_file(fixture(&format!("{which}.key"))).unwrap();
let provider = Arc::new(rustls::crypto::ring::default_provider());
let config = ServerConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.unwrap()
.with_no_client_auth()
.with_single_cert(certs, key)
.unwrap();
Arc::new(config)
}
/// Anything a test server can serve: plain TCP, or TLS over it.
pub trait Conn: Read + Write + Send {}
impl<T: Read + Write + Send> Conn for T {}
/// Serve every connection on 127.0.0.1 with `handle`, in plain TCP (`tls` None) or TLS.
pub fn serve<F>(tls: Option<Arc<ServerConfig>>, handle: F) -> SocketAddr
where
F: Fn(Box<dyn Conn>) + Send + Sync + 'static,
{
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let handle = Arc::new(handle);
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(stream) = stream else { continue };
let handle = Arc::clone(&handle);
let tls = tls.clone();
std::thread::spawn(move || match tls {
None => handle(Box::new(stream)),
Some(config) => {
let conn = ServerConnection::new(config).unwrap();
let tls_stream: StreamOwned<ServerConnection, TcpStream> =
StreamOwned::new(conn, stream);
handle(Box::new(tls_stream));
}
});
}
});
addr
}
/// A handler that reads one line and writes it back.
pub fn echo_line(mut conn: Box<dyn Conn>) {
let mut line = Vec::new();
let mut byte = [0u8; 1];
while conn.read(&mut byte).map(|n| n == 1).unwrap_or(false) {
line.push(byte[0]);
if byte[0] == b'\n' {
break;
}
}
let _ = conn.write_all(&line);
let _ = conn.flush();
}
@@ -0,0 +1,40 @@
//! Temporary directories for tests. Do not edit.
#![allow(dead_code)] // each test file uses a different part of this module
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
static NEXT: AtomicU32 = AtomicU32::new(0);
pub struct TempDir(PathBuf);
impl TempDir {
pub fn new(tag: &str) -> TempDir {
let n = NEXT.fetch_add(1, Ordering::SeqCst);
let path = std::env::temp_dir().join(format!("gw-{tag}-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).unwrap();
TempDir(path)
}
pub fn path(&self) -> &Path {
&self.0
}
/// Writes `text` to `name` inside the directory and returns the full path.
pub fn write(&self, name: &str, text: &str) -> PathBuf {
let path = self.0.join(name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&path, text).unwrap();
path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
@@ -0,0 +1,132 @@
//! A scripted WebSocket server for tests: it accepts one handshake per connection and then lets
//! the test send raw frames and read the client's. Built on `tls_server`. Do not edit.
#![allow(dead_code)] // each test file uses a different part of this module
use std::io::{Read, Write};
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use gatewayd::ws::handshake::accept_for;
use rustls::ServerConfig;
use crate::tls_server::{Conn, serve};
pub struct Peer {
pub conn: Box<dyn Conn>,
/// The request head the client sent, for tests that check it.
pub request: String,
}
/// A frame from the client: opcode, whether it was masked, and the unmasked payload.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientFrame {
pub opcode: u8,
pub masked: bool,
pub mask: [u8; 4],
pub payload: Vec<u8>,
}
impl Peer {
pub fn send(&mut self, bytes: &[u8]) {
let _ = self.conn.write_all(bytes);
let _ = self.conn.flush();
}
/// An unmasked server frame, FIN set.
pub fn frame(&mut self, opcode: u8, payload: &[u8]) {
let mut out = vec![0x80 | opcode];
if payload.len() < 126 {
out.push(payload.len() as u8);
} else {
out.push(126);
out.extend_from_slice(&(payload.len() as u16).to_be_bytes());
}
out.extend_from_slice(payload);
self.send(&out);
}
pub fn text(&mut self, text: &str) {
self.frame(0x1, text.as_bytes());
}
fn read_exact(&mut self, n: usize) -> Option<Vec<u8>> {
let mut buf = vec![0u8; n];
self.conn.read_exact(&mut buf).ok()?;
Some(buf)
}
/// The next frame from the client, or `None` when it has gone.
pub fn read_frame(&mut self) -> Option<ClientFrame> {
let head = self.read_exact(2)?;
let opcode = head[0] & 0x0F;
let masked = head[1] & 0x80 != 0;
let len = match head[1] & 0x7F {
126 => u16::from_be_bytes(self.read_exact(2)?.try_into().ok()?) as usize,
127 => u64::from_be_bytes(self.read_exact(8)?.try_into().ok()?) as usize,
n => n as usize,
};
let mask: [u8; 4] = if masked {
self.read_exact(4)?.try_into().ok()?
} else {
[0; 4]
};
let raw = self.read_exact(len)?;
let payload = raw
.iter()
.zip(mask.iter().cycle())
.map(|(b, m)| b ^ m)
.collect();
Some(ClientFrame {
opcode,
masked,
mask,
payload,
})
}
pub fn pause(&self, d: Duration) {
std::thread::sleep(d);
}
}
/// Serve WebSocket connections: complete the handshake (or answer `refuse_with` instead), then run
/// `script` on the connection.
pub fn serve_ws<F>(
tls: Option<Arc<ServerConfig>>,
refuse_with: Option<&'static str>,
script: F,
) -> SocketAddr
where
F: Fn(Peer) + Send + Sync + 'static,
{
serve(tls, move |mut conn| {
let mut head = Vec::new();
let mut byte = [0u8; 1];
while !head.ends_with(b"\r\n\r\n") {
if conn.read(&mut byte).map(|n| n == 0).unwrap_or(true) {
return;
}
head.push(byte[0]);
}
let request = String::from_utf8_lossy(&head).into_owned();
if let Some(reply) = refuse_with {
let _ = conn.write_all(reply.as_bytes());
return;
}
let key = request
.lines()
.find_map(|l| l.strip_prefix("Sec-WebSocket-Key: "))
.unwrap_or_default()
.trim()
.to_string();
let reply = format!(
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {}\r\n\r\n",
accept_for(&key)
);
let _ = conn.write_all(reply.as_bytes());
let _ = conn.flush();
script(Peer { conn, request });
})
}
@@ -0,0 +1,241 @@
//! A WebSocket connection against a scripted server, plain and TLS: messages in order, pings both
//! ways, a dead peer, closing, and hostile input (M4a spec, section 6). Do not edit.
#[path = "support/tls_server.rs"]
mod tls_server;
#[path = "support/ws_server.rs"]
mod ws_server;
use std::io::Cursor;
use std::sync::mpsc;
use std::time::{Duration, Instant};
use gatewayd::config::ServerUrl;
use gatewayd::net::Connector;
use gatewayd::ws::WsError;
use gatewayd::ws::conn::{Timing, Ws, host_header};
use tls_server::{fixture, server_config};
use ws_server::{ClientFrame, serve_ws};
const SLOW: Timing = Timing {
ping_every: Duration::from_secs(60),
dead_after: Duration::from_secs(60),
};
/// Plenty of deterministic "random" bytes: the key, then masks.
fn random() -> Box<Cursor<Vec<u8>>> {
Box::new(Cursor::new(
(0..4096u32).map(|i| (i * 37 % 251) as u8).collect(),
))
}
fn open(port: u16, tls: bool, timing: Timing) -> Result<Ws, WsError> {
let url = ServerUrl {
tls,
host: "localhost".to_string(),
port,
};
let ca = tls.then(|| fixture("test-ca.pem"));
let c = Connector::new(url, ca.as_deref()).unwrap();
Ws::open(&c, "TOKEN", timing, random())
}
/// What the server saw, within 5 s: a missing frame fails the test instead of hanging it.
fn got<T>(rx: &mpsc::Receiver<T>) -> T {
rx.recv_timeout(Duration::from_secs(5))
.expect("the server saw nothing within 5 s")
}
/// Poll until a text message or an error, for at most 5 s.
fn next(ws: &mut Ws) -> Result<String, WsError> {
let until = Instant::now() + Duration::from_secs(5);
loop {
if let Some(t) = ws.poll(Duration::from_millis(200))? {
return Ok(t);
}
assert!(Instant::now() < until, "no message within 5 s");
}
}
#[test]
fn messages_arrive_in_order_plain_and_over_tls() {
for tls in [false, true] {
let (tx, rx) = mpsc::channel();
let config = tls.then(|| server_config("server"));
let addr = serve_ws(config, None, move |mut p| {
tx.send(p.request.clone()).unwrap();
p.text("{\"event\":\"hello\"}");
p.send(&[0x01, 0x03, b'o', b'n', b'e']);
p.send(&[0x80, 0x04, b'-', b't', b'w', b'o']);
p.text("three");
p.pause(Duration::from_secs(2));
});
let mut ws = open(addr.port(), tls, SLOW).unwrap();
let request = got(&rx);
assert!(
request.contains("Authorization: Bearer TOKEN\r\n"),
"{request}"
);
assert!(
request.starts_with("GET /api/v4/websocket HTTP/1.1\r\n"),
"{request}"
);
assert!(
request.contains(&format!("Host: localhost:{}\r\n", addr.port())),
"{request}"
);
assert_eq!(next(&mut ws).unwrap(), "{\"event\":\"hello\"}");
assert_eq!(next(&mut ws).unwrap(), "one-two");
assert_eq!(next(&mut ws).unwrap(), "three");
}
}
#[test]
fn a_ping_is_answered_with_the_same_payload() {
let (tx, rx) = mpsc::channel();
let addr = serve_ws(None, None, move |mut p| {
p.frame(0x9, b"are you there");
tx.send(p.read_frame()).unwrap();
p.text("after");
p.pause(Duration::from_secs(2));
});
let mut ws = open(addr.port(), false, SLOW).unwrap();
assert_eq!(next(&mut ws).unwrap(), "after");
let pong: ClientFrame = got(&rx).expect("a pong");
assert_eq!(
(pong.opcode, pong.masked, pong.payload.as_slice()),
(0xA, true, &b"are you there"[..])
);
}
#[test]
fn we_ping_on_schedule_and_every_frame_is_masked_differently() {
let (tx, rx) = mpsc::channel();
let addr = serve_ws(None, None, move |mut p| {
for _ in 0..3 {
tx.send(p.read_frame()).unwrap();
p.frame(0xA, b"");
}
p.pause(Duration::from_secs(2));
});
let timing = Timing {
ping_every: Duration::from_millis(100),
dead_after: Duration::from_secs(5),
};
let mut ws = open(addr.port(), false, timing).unwrap();
ws.send_text("first").unwrap();
let started = Instant::now();
while started.elapsed() < Duration::from_millis(350) {
let _ = ws.poll(Duration::from_millis(50)).unwrap();
}
let frames: Vec<ClientFrame> = (0..3).map(|_| got(&rx).unwrap()).collect();
assert_eq!(
(frames[0].opcode, frames[0].payload.as_slice()),
(0x1, &b"first"[..])
);
assert_eq!(frames[1].opcode, 0x9, "a ping after ping_every");
assert_eq!(frames[2].opcode, 0x9);
assert!(frames.iter().all(|f| f.masked));
assert_ne!(
frames[0].mask, frames[1].mask,
"a fresh mask for every frame"
);
}
#[test]
fn silence_is_a_dead_peer() {
let addr = serve_ws(None, None, |p| p.pause(Duration::from_secs(10)));
let timing = Timing {
ping_every: Duration::from_secs(60),
dead_after: Duration::from_millis(300),
};
let mut ws = open(addr.port(), false, timing).unwrap();
let started = Instant::now();
let err = loop {
match ws.poll(Duration::from_millis(100)) {
Ok(_) => assert!(
started.elapsed() < Duration::from_secs(3),
"never declared dead"
),
Err(e) => break e,
}
};
assert!(matches!(err, WsError::Dead), "{err}");
assert!(started.elapsed() < Duration::from_secs(1));
}
#[test]
fn a_peer_that_trickles_is_alive_and_its_message_arrives() {
let addr = serve_ws(None, None, |mut p| {
for b in [0x81u8, 0x05, b'd', b'r', b'i', b'p', b's'] {
p.send(&[b]);
p.pause(Duration::from_millis(100));
}
p.pause(Duration::from_secs(2));
});
let timing = Timing {
ping_every: Duration::from_secs(60),
dead_after: Duration::from_millis(400),
};
let mut ws = open(addr.port(), false, timing).unwrap();
assert_eq!(next(&mut ws).unwrap(), "drips");
}
#[test]
fn a_close_frame_is_answered_and_ends_the_connection() {
let (tx, rx) = mpsc::channel();
let addr = serve_ws(None, None, move |mut p| {
p.frame(0x8, &[0x03, 0xE8]);
tx.send(p.read_frame()).unwrap();
});
let mut ws = open(addr.port(), false, SLOW).unwrap();
assert!(matches!(next(&mut ws), Err(WsError::Closed)));
let reply = got(&rx).expect("a close in reply");
assert_eq!(
(reply.opcode, reply.payload.as_slice()),
(0x8, &[0x03u8, 0xE8][..])
);
}
#[test]
fn a_dropped_connection_is_closed() {
let addr = serve_ws(None, None, drop);
let mut ws = open(addr.port(), false, SLOW).unwrap();
assert!(matches!(next(&mut ws), Err(WsError::Closed)));
}
#[test]
fn a_hostile_frame_is_an_error_not_a_panic() {
let addr = serve_ws(None, None, |mut p| {
p.send(&[0x81, 0xFF, 0x80, 0, 0, 0, 0, 0, 0, 0]);
p.pause(Duration::from_secs(2));
});
let mut ws = open(addr.port(), false, SLOW).unwrap();
assert!(matches!(next(&mut ws), Err(WsError::Protocol(_))));
}
#[test]
fn a_refused_handshake() {
let addr = serve_ws(
None,
Some("HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"),
|_| {},
);
assert!(matches!(
open(addr.port(), false, SLOW),
Err(WsError::Handshake(_))
));
}
#[test]
fn the_host_header_names_the_port_only_when_it_is_not_the_default() {
let url = |tls, port| ServerUrl {
tls,
host: "chat.example".to_string(),
port,
};
assert_eq!(host_header(&url(true, 443)), "chat.example");
assert_eq!(host_header(&url(false, 80)), "chat.example");
assert_eq!(host_header(&url(true, 80)), "chat.example:80");
assert_eq!(host_header(&url(false, 8065)), "chat.example:8065");
}
@@ -0,0 +1,469 @@
//! WebSocket frames, adversarially (M4a spec, section 6). Everything a server sends is untrusted:
//! each hostile frame must end the connection with an error, never a panic, and a length must be
//! refused before anything waits for or allocates its payload. A seeded property test compares the
//! decoder with a deliberately naive one written here, on valid streams and on random mutations of
//! them, fed in random pieces. The seed is printed on failure. Do not edit.
use gatewayd::ws::WsError;
use gatewayd::ws::frame::{
CLOSE, CONTINUATION, Decoder, Incoming, MAX_MESSAGE, PING, PONG, TEXT, encode,
};
/// How a server frame's length is written: the shortest form, or a longer one on purpose.
#[derive(Clone, Copy)]
enum Len {
Short,
Force16,
Force64,
}
/// A frame as a server sends it (unmasked unless `masked`).
fn frame(fin: bool, rsv: u8, opcode: u8, masked: bool, payload: &[u8], form: Len) -> Vec<u8> {
let mut out = vec![(if fin { 0x80 } else { 0 }) | (rsv << 4) | opcode];
let m = if masked { 0x80 } else { 0 };
let len = payload.len();
match form {
Len::Short if len < 126 => out.push(m | len as u8),
Len::Short if len <= 0xFFFF => {
out.push(m | 126);
out.extend_from_slice(&(len as u16).to_be_bytes());
}
Len::Force16 => {
out.push(m | 126);
out.extend_from_slice(&(len as u16).to_be_bytes());
}
_ => {
out.push(m | 127);
out.extend_from_slice(&(len as u64).to_be_bytes());
}
}
if masked {
out.extend_from_slice(&[1, 2, 3, 4]);
}
out.extend_from_slice(payload);
out
}
fn text(s: &str) -> Vec<u8> {
frame(true, 0, TEXT, false, s.as_bytes(), Len::Short)
}
/// Feed `bytes` in pieces of `step` and collect every message, stopping at the first error.
fn decode(bytes: &[u8], step: usize) -> (Vec<Incoming>, Option<String>) {
let mut d = Decoder::new();
let mut got = Vec::new();
for piece in bytes.chunks(step.max(1)) {
d.feed(piece);
loop {
match d.next_message() {
Ok(Some(m)) => got.push(m),
Ok(None) => break,
Err(e) => return (got, Some(e.to_string())),
}
}
}
(got, None)
}
fn fails(bytes: &[u8], why: &str) {
for step in [1, 2, 3, 7, bytes.len().max(1)] {
let (_, err) = decode(bytes, step);
assert!(err.is_some(), "{why} (fed {step} at a time) was accepted");
}
}
#[test]
fn plain_messages() {
let mut bytes = text("hello");
bytes.extend(frame(true, 0, PING, false, b"p1", Len::Short));
bytes.extend(frame(true, 0, PONG, false, b"", Len::Short));
bytes.extend(frame(
true,
0,
CLOSE,
false,
&[0x03, 0xE8, b'b', b'y', b'e'],
Len::Short,
));
let (got, err) = decode(&bytes, bytes.len());
assert_eq!(err, None);
assert_eq!(
got,
[
Incoming::Text("hello".into()),
Incoming::Ping(b"p1".to_vec()),
Incoming::Pong(Vec::new()),
Incoming::Close(Some(1000), "bye".into()),
]
);
assert_eq!(
decode(&frame(true, 0, CLOSE, false, b"", Len::Short), 1).0,
[Incoming::Close(None, String::new())]
);
}
#[test]
fn fragments_reassemble_with_control_frames_between_and_utf8_split_across_them() {
let snow = "snow ☃ man";
let bytes_of = snow.as_bytes();
let cut = snow.find('☃').unwrap() + 1; // inside the three-byte character
let mut bytes = frame(false, 0, TEXT, false, &bytes_of[..cut], Len::Short);
bytes.extend(frame(true, 0, PING, false, b"mid", Len::Short));
bytes.extend(frame(
false,
0,
CONTINUATION,
false,
&bytes_of[cut..cut + 1],
Len::Short,
));
bytes.extend(frame(
true,
0,
CONTINUATION,
false,
&bytes_of[cut + 1..],
Len::Short,
));
for step in 1..=bytes.len() {
let (got, err) = decode(&bytes, step);
assert_eq!(err, None, "step {step}");
assert_eq!(
got,
[Incoming::Ping(b"mid".to_vec()), Incoming::Text(snow.into())],
"step {step}"
);
}
}
#[test]
fn lengths_in_every_form() {
for len in [0usize, 1, 125, 126, 127, 65_535, 65_536, 100_000] {
let body = "x".repeat(len);
let (got, err) = decode(&text(&body), 4096);
assert_eq!(err, None, "{len}");
assert_eq!(got, [Incoming::Text(body)], "{len}");
}
}
#[test]
fn hostile_frames_end_the_connection() {
for rsv in [1, 2, 4] {
fails(
&frame(true, rsv, TEXT, false, b"x", Len::Short),
"a reserved bit",
);
}
fails(
&frame(true, 0, TEXT, true, b"x", Len::Short),
"a masked frame from the server",
);
for op in [2u8, 3, 7, 11, 15] {
fails(
&frame(true, 0, op, false, b"x", Len::Short),
"an unknown or binary opcode",
);
}
fails(
&frame(true, 0, PING, false, &[0u8; 126], Len::Short),
"a control frame over 125 bytes",
);
fails(
&frame(false, 0, PING, false, b"x", Len::Short),
"a fragmented control frame",
);
fails(
&frame(true, 0, CONTINUATION, false, b"x", Len::Short),
"a continuation with nothing to continue",
);
let mut inside = frame(false, 0, TEXT, false, b"a", Len::Short);
inside.extend(text("b"));
fails(&inside, "a new message inside an unfinished one");
fails(
&frame(true, 0, TEXT, false, b"x", Len::Force16),
"a 16-bit length for 1 byte",
);
fails(
&frame(true, 0, TEXT, false, &[b'y'; 200], Len::Force64),
"a 64-bit length for 200 bytes",
);
fails(
&frame(true, 0, TEXT, false, &[0xff, 0xfe], Len::Short),
"text that is not UTF-8",
);
fails(
&frame(true, 0, CLOSE, false, &[3], Len::Short),
"a close frame of one byte",
);
fails(
&frame(true, 0, CLOSE, false, &[3, 232, 0xff], Len::Short),
"a close reason that is not UTF-8",
);
}
#[test]
fn huge_lengths_are_refused_from_the_header_alone() {
// Only the header is fed: the decoder must refuse without waiting for a payload.
let top_bit = [0x81u8, 127, 0x80, 0, 0, 0, 0, 0, 0, 1];
let mut d = Decoder::new();
d.feed(&top_bit);
assert!(
d.next_message().is_err(),
"a 64-bit length with its top bit set"
);
let too_big = (MAX_MESSAGE as u64) + 1;
let mut head = vec![0x81u8, 127];
head.extend_from_slice(&too_big.to_be_bytes());
let mut d = Decoder::new();
d.feed(&head);
assert!(matches!(d.next_message(), Err(WsError::TooLarge)));
let mut head = vec![0x81u8, 127];
head.extend_from_slice(&0x7FFF_FFFF_FFFF_FFFFu64.to_be_bytes());
let mut d = Decoder::new();
d.feed(&head);
assert!(matches!(d.next_message(), Err(WsError::TooLarge)));
// Across fragments: the sum counts.
let half = MAX_MESSAGE / 2 + 1;
let mut d = Decoder::new();
d.feed(&frame(false, 0, TEXT, false, &vec![b'a'; half], Len::Short));
assert!(matches!(d.next_message(), Ok(None)));
let mut second = vec![0x00u8, 127];
second.extend_from_slice(&(half as u64).to_be_bytes());
d.feed(&second);
assert!(matches!(d.next_message(), Err(WsError::TooLarge)));
// Exactly the limit is fine.
let (got, err) = decode(&text(&"z".repeat(MAX_MESSAGE)), 65_536);
assert_eq!(err, None);
assert_eq!(got.len(), 1);
}
#[test]
fn our_frames_are_masked_and_decode_back() {
let mask = [0x11, 0x22, 0x33, 0x44];
for len in [0usize, 5, 125, 126, 65_535, 65_536] {
let payload: Vec<u8> = (0..len).map(|i| (i % 251) as u8).collect();
let bytes = encode(TEXT, &payload, mask);
assert_eq!(bytes[0], 0x80 | TEXT, "FIN and the opcode");
assert_ne!(bytes[1] & 0x80, 0, "the mask bit");
let (len_field, header) = match bytes[1] & 0x7F {
126 => (u16::from_be_bytes([bytes[2], bytes[3]]) as usize, 4),
127 => (
u64::from_be_bytes(bytes[2..10].try_into().unwrap()) as usize,
10,
),
n => (n as usize, 2),
};
assert_eq!(len_field, len);
let shortest = if len < 126 {
2
} else if len <= 0xFFFF {
4
} else {
10
};
assert_eq!(header, shortest, "the shortest length form");
assert_eq!(&bytes[header..header + 4], &mask);
let unmasked: Vec<u8> = bytes[header + 4..]
.iter()
.zip(mask.iter().cycle())
.map(|(b, m)| b ^ m)
.collect();
assert_eq!(unmasked, payload);
}
assert_eq!(encode(PONG, b"p", mask)[0], 0x80 | PONG);
}
// ---------- the property test ----------
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
let mut x = self.0;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
self.0 = x;
x
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
}
}
/// The naive decoder: the whole buffer at once, the rules written out plainly.
fn naive(bytes: &[u8]) -> (Vec<Incoming>, bool) {
let mut out = Vec::new();
let mut i = 0usize;
let mut partial: Option<Vec<u8>> = None;
while i < bytes.len() {
if bytes.len() - i < 2 {
return (out, false);
}
let (b0, b1) = (bytes[i], bytes[i + 1]);
let (fin, rsv, op, masked, short) = (
b0 >> 7 == 1,
(b0 >> 4) & 7,
b0 & 15,
b1 >> 7 == 1,
(b1 & 127) as usize,
);
if rsv != 0 || masked || ![0, 1, 8, 9, 10].contains(&op) {
return (out, true);
}
let (hl, len) = if short == 126 {
if bytes.len() - i < 4 {
return (out, false);
}
let l = u16::from_be_bytes([bytes[i + 2], bytes[i + 3]]) as usize;
if l < 126 {
return (out, true);
}
(4, l)
} else if short == 127 {
if bytes.len() - i < 10 {
return (out, false);
}
let l = u64::from_be_bytes(bytes[i + 2..i + 10].try_into().unwrap());
if l >> 63 == 1 || l <= 0xFFFF {
return (out, true);
}
(10, l as usize)
} else {
(2, short)
};
let control = op >= 8;
if control && (!fin || len > 125) {
return (out, true);
}
if !control {
if (op == 1 && partial.is_some()) || (op == 0 && partial.is_none()) {
return (out, true);
}
if partial.as_ref().map_or(0, |p| p.len()) + len > MAX_MESSAGE {
return (out, true);
}
}
if bytes.len() - i - hl < len {
return (out, false);
}
let payload = bytes[i + hl..i + hl + len].to_vec();
i += hl + len;
match op {
9 => out.push(Incoming::Ping(payload)),
10 => out.push(Incoming::Pong(payload)),
8 => {
if payload.len() == 1 {
return (out, true);
}
if payload.is_empty() {
out.push(Incoming::Close(None, String::new()));
} else {
match String::from_utf8(payload[2..].to_vec()) {
Ok(r) => out.push(Incoming::Close(
Some(u16::from_be_bytes([payload[0], payload[1]])),
r,
)),
Err(_) => return (out, true),
}
}
}
_ => {
let mut m = if op == 1 {
Vec::new()
} else {
partial.take().unwrap()
};
m.extend_from_slice(&payload);
if fin {
match String::from_utf8(m) {
Ok(t) => out.push(Incoming::Text(t)),
Err(_) => return (out, true),
}
} else {
partial = Some(m);
}
}
}
}
(out, false)
}
/// A random valid stream: text messages split into random fragments, with control frames between.
fn valid_stream(rng: &mut Rng) -> Vec<u8> {
let mut bytes = Vec::new();
for _ in 0..1 + rng.below(6) {
let len = [0, 1, 50, 125, 126, 300, 70_000][rng.below(7)];
let body: String = (0..len)
.map(|k| {
if (k + rng.below(3)).is_multiple_of(29) {
'é'
} else {
'a'
}
})
.collect();
let raw = body.as_bytes();
let parts = 1 + rng.below(3);
let mut cuts: Vec<usize> = (0..parts - 1).map(|_| rng.below(raw.len() + 1)).collect();
cuts.sort();
let mut start = 0;
for (k, cut) in cuts
.iter()
.copied()
.chain(std::iter::once(raw.len()))
.enumerate()
{
let op = if k == 0 { TEXT } else { CONTINUATION };
bytes.extend(frame(
k == parts - 1,
0,
op,
false,
&raw[start..cut],
Len::Short,
));
start = cut;
if rng.below(3) == 0 {
bytes.extend(frame(
true,
0,
PING,
false,
&[rng.below(256) as u8; 3],
Len::Short,
));
}
}
}
bytes
}
#[test]
fn random_streams_agree_with_the_naive_decoder() {
for case in 0..300u64 {
let seed = 0x9E37_79B9_7F4A_7C15 ^ (case * 7919 + 1);
let mut rng = Rng(seed);
let mut bytes = valid_stream(&mut rng);
if case % 2 == 1 {
// Mutate: flip a few random bits, so most streams break somewhere different.
for _ in 0..1 + rng.below(4) {
let at = rng.below(bytes.len());
bytes[at] ^= 1 << rng.below(8);
}
}
let (want, want_err) = naive(&bytes);
let step = 1 + rng.below(4096);
let (got, got_err) = decode(&bytes, step);
assert_eq!(got, want, "seed {seed:#x}, step {step}: messages differ");
assert_eq!(
got_err.is_some(),
want_err,
"seed {seed:#x}, step {step}: {got_err:?} vs naive error {want_err}"
);
}
}
@@ -0,0 +1,200 @@
//! Base64 and the WebSocket opening handshake (RFC 4648; RFC 6455, section 4). Do not edit.
use std::io::{Cursor, Read, Write};
use gatewayd::http::Head;
use gatewayd::ws::WsError;
use gatewayd::ws::handshake::{
accept_for, base64, check_response, handshake, new_key, request_text,
};
#[test]
fn base64_vectors() {
for (input, want) in [
("", ""),
("f", "Zg=="),
("fo", "Zm8="),
("foo", "Zm9v"),
("foob", "Zm9vYg=="),
("fooba", "Zm9vYmE="),
("foobar", "Zm9vYmFy"),
] {
assert_eq!(base64(input.as_bytes()), want, "{input:?}");
}
assert_eq!(base64(&[0xff, 0xfe, 0xfd, 0x00, 0x3f]), "//79AD8=");
assert_eq!(
base64(&(0u8..=15).collect::<Vec<_>>()),
"AAECAwQFBgcICQoLDA0ODw=="
);
}
#[test]
fn the_rfc_example_accept() {
assert_eq!(
accept_for("dGhlIHNhbXBsZSBub25jZQ=="),
"s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
);
}
#[test]
fn a_key_is_sixteen_random_bytes() {
let mut random = Cursor::new((0u8..=15).collect::<Vec<_>>());
assert_eq!(new_key(&mut random).unwrap(), "AAECAwQFBgcICQoLDA0ODw==");
let mut short = Cursor::new(vec![1u8; 15]);
assert!(
new_key(&mut short).is_err(),
"too few random bytes is an error, not a weak key"
);
}
#[test]
fn the_request_is_exactly_this() {
assert_eq!(
request_text("a.example", "/api/v4/websocket", "KEY==", "TOKEN"),
"GET /api/v4/websocket HTTP/1.1\r\nHost: a.example\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\
Sec-WebSocket-Key: KEY==\r\nSec-WebSocket-Version: 13\r\nAuthorization: Bearer TOKEN\r\n\r\n"
);
}
fn head(status: u16, headers: &[(&str, &str)]) -> Head {
Head {
status,
headers: headers
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
}
}
const KEY: &str = "dGhlIHNhbXBsZSBub25jZQ==";
const ACCEPT: &str = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=";
#[test]
fn only_a_proper_upgrade_is_accepted() {
let good = [
("Upgrade", "websocket"),
("Connection", "Upgrade"),
("Sec-WebSocket-Accept", ACCEPT),
];
assert!(check_response(&head(101, &good), KEY).is_ok());
let loose = [
("upgrade", "WebSocket"),
("connection", "keep-alive, Upgrade"),
("sec-websocket-accept", ACCEPT),
];
assert!(
check_response(&head(101, &loose), KEY).is_ok(),
"names and tokens are case-insensitive"
);
let cases: [(u16, &[(&str, &str)]); 7] = [
(200, &good),
(401, &good),
(
101,
&[("Connection", "Upgrade"), ("Sec-WebSocket-Accept", ACCEPT)],
),
(
101,
&[
("Upgrade", "h2c"),
("Connection", "Upgrade"),
("Sec-WebSocket-Accept", ACCEPT),
],
),
(
101,
&[("Upgrade", "websocket"), ("Sec-WebSocket-Accept", ACCEPT)],
),
(101, &[("Upgrade", "websocket"), ("Connection", "Upgrade")]),
(
101,
&[
("Upgrade", "websocket"),
("Connection", "Upgrade"),
("Sec-WebSocket-Accept", "s3pplmbitxaq9kygzzhzrbk+xoo="),
],
),
];
for (status, headers) in cases {
assert!(
matches!(
check_response(&head(status, headers), KEY),
Err(WsError::Handshake(_))
),
"{status} {headers:?}"
);
}
}
/// A server side scripted as bytes; records what the client wrote.
struct Scripted {
input: Cursor<Vec<u8>>,
output: Vec<u8>,
}
impl Read for Scripted {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.input.read(buf)
}
}
impl Write for Scripted {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.output.write(buf)
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[test]
fn a_whole_handshake_leaves_the_first_frame_unread() {
let key_bytes: Vec<u8> = (0u8..=15).collect();
let key = base64(&key_bytes);
let reply = format!(
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {}\r\n\r\n",
accept_for(&key)
);
let mut bytes = reply.into_bytes();
bytes.extend_from_slice(&[0x81, 0x02, b'h', b'i']);
let mut s = Scripted {
input: Cursor::new(bytes),
output: Vec::new(),
};
handshake(
&mut s,
"a.example",
"/api/v4/websocket",
"TOKEN",
&mut Cursor::new(key_bytes),
)
.unwrap();
assert_eq!(
String::from_utf8(s.output).unwrap(),
request_text("a.example", "/api/v4/websocket", &key, "TOKEN")
);
let mut rest = Vec::new();
s.input.read_to_end(&mut rest).unwrap();
assert_eq!(rest, [0x81, 0x02, b'h', b'i']);
}
#[test]
fn a_refused_or_broken_handshake_is_a_handshake_error() {
for reply in [
&b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"[..],
b"HTTP/1.1 101 Switching",
b"not http at all\r\n\r\n",
b"",
] {
let mut s = Scripted {
input: Cursor::new(reply.to_vec()),
output: Vec::new(),
};
let got = handshake(&mut s, "h", "/p", "t", &mut Cursor::new(vec![7u8; 16]));
assert!(
matches!(got, Err(WsError::Handshake(_))),
"{:?}: {got:?}",
String::from_utf8_lossy(reply)
);
}
}
@@ -0,0 +1,65 @@
//! SHA-1 (FIPS 180-4), used only to check the `Sec-WebSocket-Accept` header of a WebSocket
//! handshake (RFC 6455, section 4.2.2). Never use it for anything that needs to resist attack.
/// The digest of `data`.
pub fn sha1(data: &[u8]) -> [u8; 20] {
let mut h = Sha1::new();
h.update(data);
h.finish()
}
/// SHA-1 fed in pieces.
#[derive(Debug, Clone)]
pub struct Sha1 {
state: [u32; 5],
block: [u8; 64],
filled: usize,
length: u64,
}
impl Default for Sha1 {
fn default() -> Self {
Self::new()
}
}
impl Sha1 {
pub fn new() -> Sha1 {
Sha1 {
state: [
0x6745_2301,
0xEFCD_AB89,
0x98BA_DCFE,
0x1032_5476,
0xC3D2_E1F0,
],
block: [0; 64],
filled: 0,
length: 0,
}
}
pub fn update(&mut self, mut data: &[u8]) {
// Add 8 * data.len() to `length` (wrapping; `u64::try_from`, never `as`). Copy bytes into
// `block` from `filled` on; each time it is full (64), `compress` it and set `filled` to 0.
// Use `split_at` and `get_mut(..)`, no indexing that can go out of bounds.
todo!()
}
pub fn finish(mut self) -> [u8; 20] {
// Save `length`. Feed 0x80 then zeros so that 56 bytes of the block are filled (56 -
// filled, or 120 - filled when filled >= 56), then the saved length as 8 big-endian bytes,
// through `update`. `update` adds to `length`: put the saved value back after. Then the
// five state words, big-endian.
todo!()
}
fn compress(&mut self, block: &[u8; 64]) {
// FIPS 180-4, section 6.1.2: w[0..16] are the block as big-endian u32s; w[i] = (w[i-3] ^
// w[i-8] ^ w[i-14] ^ w[i-16]).rotate_left(1) for 16..80. Eighty rounds with f and k by
// range: 0..=19 (b & c) | (!b & d), 0x5A827999; 20..=39 b ^ c ^ d, 0x6ED9EBA1; 40..=59 (b &
// c) | (b & d) | (c & d), 0x8F1BBCDC; 60..=79 b ^ c ^ d, 0xCA62C1D6. All additions
// wrapping. Add a..e into state.
todo!()
}
}
@@ -0,0 +1,78 @@
//! SHA-1 against FIPS 180 and RFC 3174 vectors, and against `sha1sum` for the lengths around the
//! 64-byte block where padding changes shape. Every case is also fed in pieces. Do not edit.
use proto::sha1::{Sha1, sha1};
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn check(data: &[u8], want: &str) {
assert_eq!(hex(&sha1(data)), want, "whole, {} bytes", data.len());
for split in [
0,
1,
data.len() / 2,
data.len().saturating_sub(1),
data.len(),
] {
let split = split.min(data.len());
let mut h = Sha1::new();
h.update(&data[..split]);
h.update(&data[split..]);
assert_eq!(hex(&h.finish()), want, "split at {split} of {}", data.len());
}
let mut h = Sha1::new();
for b in data {
h.update(&[*b]);
}
assert_eq!(hex(&h.finish()), want, "byte by byte, {} bytes", data.len());
}
#[test]
fn standard_vectors() {
check(b"", "da39a3ee5e6b4b0d3255bfef95601890afd80709");
check(b"abc", "a9993e364706816aba3e25717850c26c9cd0d89d");
check(
b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq",
"84983e441c3bd26ebaae4aa1f95129e5e54670f1",
);
check(
b"The quick brown fox jumps over the lazy dog",
"2fd4e1c67a2d28fced849ee1bb76e7391b93eb12",
);
}
#[test]
fn a_million_a() {
let data = vec![b'a'; 1_000_000];
assert_eq!(
hex(&sha1(&data)),
"34aa973cd4c4daa4f61eeb2bdbad27316534016f"
);
}
#[test]
fn lengths_around_the_block_boundary() {
// `printf 'a%.0s' $(seq N) | sha1sum`, N = 55, 56, 63, 64, 65, 119, 120.
let cases = [
(55, "c1c8bbdc22796e28c0e15163d20899b65621d65a"),
(56, "c2db330f6083854c99d4b5bfb6e8f29f201be699"),
(63, "03f09f5b158a7a8cdad920bddc29b81c18a551f5"),
(64, "0098ba824b5c16427bd7a1122a5a442a25ec644d"),
(65, "11655326c708d70319be2610e8a57d9a5b959d3b"),
(119, "ee971065aaa017e0632a8ca6c77bb3bf8b1dfc56"),
(120, "f34c1488385346a55709ba056ddd08280dd4c6d6"),
];
for (n, want) in cases {
check(&vec![b'a'; n], want);
}
}
#[test]
fn the_websocket_handshake_example() {
// RFC 6455, section 1.3: the key and the GUID give this digest (its base64 is
// "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=").
let digest = sha1(b"dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11");
assert_eq!(hex(&digest), "b37a4f2cc0624f1690f64606cf385945b2bec4ea");
}
+32
View File
@@ -0,0 +1,32 @@
# cargo-deny configuration. `make gate` runs bans, licenses and sources offline.
# `make audit` runs advisories, which fetches the RustSec database.
# Only the platforms Boxmaker is built on: straylight and the development machines (Linux), and
# the owner's Mac. Dependencies for other targets (Windows) are not judged.
[graph]
all-features = true
targets = [
"x86_64-unknown-linux-gnu",
"x86_64-unknown-linux-musl",
"aarch64-apple-darwin",
]
[licenses]
allow = ["MIT", "Apache-2.0", "Unicode-3.0", "ISC", "BSD-3-Clause"]
confidence-threshold = 0.9
[licenses.private]
ignore = true
[bans]
multiple-versions = "deny"
wildcards = "deny"
allow-wildcard-paths = true
[sources]
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
[advisories]
yanked = "deny"
+143
View File
@@ -0,0 +1,143 @@
## gatewayd-start-failed
**What you see.** `gatewayd` exits 1 at start, before it connects, with one line naming its config
file or a directory, then this entry.
**Why.** `gatewayd` could not read or parse `gatewayd.toml`, or a value in it is not allowed: a
`url` that is not `http://` or `https://` with a host and an optional port, an id that is not 26
characters of `a-z0-9`, an empty `[allow] users`, or a secret with not exactly one of `credential`,
`env` and `file`. Or it could not create `<home>/gateway/`. It will not guess at a configuration
that decides who it answers.
**Confirm.** The line says which: `<config path>: …` for the file (unknown keys are errors; check
it against `docs/specs/2026-09-23-m4a-gateway.md`, section 3), or `cannot prepare <dir>: …` for the
directory (`ls -ld <dir> "$(dirname <dir>)"`).
**Fix.** Correct the file or the directory's ownership. A user id is shown in Mattermost under the
user's profile, or by `GET /api/v4/users/username/<name>`.
**Check.** `gatewayd serve --config <path>` prints `gatewayd: connected to <url> as <user>`.
## secret-unavailable
**What you see.** `gatewayd` exits 1 at start with `secret <name>: <reason>`, then this entry. The
value is never printed.
**Why.** `gatewayd` does not start without its Mattermost token, and reads it only from the one
place `[secrets.<name>]` names. The reasons: for `credential`, `CREDENTIALS_DIRECTORY` is unset
(not started by systemd with a credential) or the file in it is missing; for `env`, the variable is
unset or empty; for `file`, the path is not absolute, is a symbolic link, is not a regular file, is
not owned by the user `gatewayd` runs as, or has any group or other permission (only 0600 or 0400
are accepted). An empty value is refused in every form.
**Confirm.**
```sh
systemctl --user show -p LoadCredentialEncrypted gatewayd # credential
ls -l <path>; id -u # file: owner and mode
```
**Fix.** For a credential: `systemd-creds --user encrypt --name=<credential> - <path>`, type the
token, then give the unit `LoadCredentialEncrypted=<credential>:<path>`. For a file:
`chmod 600 <path>` and `chown` it to the user `gatewayd` runs as. For an environment variable, set
it in the environment `gatewayd` starts in.
**Check.** `gatewayd` starts and prints `gatewayd: connected to <url> as <user>`.
## secret-in-a-file
**What you see.** At start: `gatewayd: warning: secret <name> is read in plaintext from <path>; a
systemd credential keeps it encrypted at rest`, then this entry. `gatewayd` runs normally.
**Why.** A file holds the token in plaintext: anyone who can read the disk, or a backup of it, can
use it. A credential is encrypted to this machine's TPM and host key. Sometimes a file is right (a
development machine, a system without systemd); the warning is there so the choice is deliberate.
**Confirm.** `[secrets.<name>]` in `gatewayd.toml` has `file = …`.
**Fix.** To keep the file: nothing; the warning stays. To move to a credential, follow the fix in
[secret-unavailable](#secret-unavailable), change the entry to `credential = "<name>"`, restart,
then delete the file and regenerate the token if the file was ever copied elsewhere.
**Check.** The warning is gone at the next start.
## mattermost-unreachable
**What you see.** `gatewayd` prints `gatewayd: cannot reach <url>: <reason>; trying again in <n> s`,
then this entry, once per attempt: after 1, 2, 5 and 10 seconds, then every 30. Posts to Boxmaker go
unanswered meanwhile; they are caught up when the connection returns.
**Why.** The TCP connection, the TLS handshake or the WebSocket upgrade failed, or the server went
silent for `dead_after_ms`. A TLS failure means the certificate did not match the host in `url` or
did not chain to the system's roots or `ca_file`; verification cannot be turned off.
**Confirm.**
```sh
curl -sS <url>/api/v4/system/ping # the server answers
tailscale status # for a tailnet url: the tailnet is up
openssl s_client -connect <host>:443 -servername <host> </dev/null | head
```
**Fix.** Start Mattermost, or the tailnet. For a certificate error, correct `url` to the name on
the certificate, or give the issuing CA in `[mattermost] ca_file`. `gatewayd` keeps trying by
itself; no restart is needed.
**Check.** `gatewayd: connected to <url> as <user>`, then a direct message to Boxmaker is answered.
## mattermost-auth-failed
**What you see.** `gatewayd` exits 1 with `gatewayd: Mattermost refused the token (<status>)`, then
this entry.
**Why.** Mattermost answered 401 or 403: the token is wrong, revoked, or belongs to a deactivated
user. Retrying with the same token cannot help, so `gatewayd` stops instead.
**Confirm.** With the token in `$T` (from the same place `gatewayd` reads it; do not paste it into a
shared shell history):
`curl -sS -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $T" <url>/api/v4/users/me`
prints 401 or 403.
**Fix.** In Mattermost, under the bot's or user's access tokens, create a new token and revoke the
old one. Store it as [secret-unavailable](#secret-unavailable) describes, then start `gatewayd`.
**Check.** `gatewayd: connected to <url> as <user>`.
## gateway-state-damaged
**What you see.** `gatewayd` exits 1 with `<home>/gateway/state.json: <reason>`, then this entry:
at start when the file cannot be read, or while running when it cannot be written
(`…: cannot write: …`).
**Why.** The state file records which posts were handled, which threads Boxmaker is in, and which
turns were in flight. If it cannot be read or kept up to date, `gatewayd` could answer old posts
twice or miss threads, so it does not guess. A missing file is a first start and is fine.
**Confirm.** `ls -l "$BOXMAKER_HOME/gateway/state.json"` and
`python3 -m json.tool "$BOXMAKER_HOME/gateway/state.json" >/dev/null`.
**Fix.** For a write failure, free space or correct the directory's ownership (`df -h`,
`ls -ld "$BOXMAKER_HOME/gateway"`), then start `gatewayd`. If only the file's ownership or mode is
wrong, correct it. If the content is damaged, move it
aside (`mv state.json state.json.damaged`) and start again. That is a first start: posts sent while
`gatewayd` was down are not answered, and threads in channels must name Boxmaker again once.
**Check.** `gatewayd` starts, and `state.json` is rewritten after the next post.
## loop-unavailable
**What you see.** In the Mattermost thread: "Boxmaker's loop is not running
(see docs/runbook.md#loop-unavailable)". The messages that were waiting are dropped.
**Why.** `gatewayd` could not connect to `loop.sock`, or the connection closed before the turn
ended. `gatewayd` does not retry: `loopd` may have finished and logged the turn, and sending it
again would run it twice.
**Confirm.** `ls -l "$BOXMAKER_HOME/run/loop/loop.sock"` (or `[loop] socket`), and whether
`loopd serve` is running. If it stopped, its last lines say why.
**Fix.** Start `loopd serve --config <path>`; if it failed, follow the entry its message names.
Then send the message again in the thread.
**Check.** A direct message to Boxmaker is answered.