gatewayd: ws frames, a strict decoder and a masked encoder

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-23 20:50:00 -07:00
parent 02681069b6
commit f649ca9e3d
4 changed files with 727 additions and 0 deletions
+1
View File
@@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone.
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|---|---|---|---|---|---|---|---|
| M4a/08-gatewayd-ws-frames | 2026-09-23 | done | 1 | pass | none | Copied `tests/ws_frame.rs` and the `src/ws/frame.rs` skeleton, added `pub mod frame;` (before `handshake`, alphabetical). Filled the seven functions the comments specified verbatim: `check_first_bytes` (reserved bits `b0 & 0x70`, mask `b1 & 0x80`, opcode `matches!(b0 & 0x0F, CONTINUATION | TEXT | CLOSE | PING | PONG)`); `check_control` (not fin, then >125); `length` (match on `short`: 0..=125, 126 reading `buf.get(2..4)` into `u16::from_be_bytes` with `len < 126` refused, 127 reading `buf.get(2..10)` into `u64::from_be_bytes` with top-bit and `<= 0xFFFF` refused, `usize::try_from(len).unwrap_or(usize::MAX)`); `check_data` (TEXT while partial, CONTINUATION while none, `payload_len > MAX_MESSAGE.saturating_sub(so_far)`); `data_frame` (empty Vec for TEXT else `partial.take().unwrap_or_default()`, append, defer when not fin, `String::from_utf8` at fin); `close` (slice-pattern match, `u16::from_be_bytes` code, UTF-8 reason); `encode` (FIN+opcode, three length branches with mask bit, XOR with `mask.iter().cycle()`). All string literals got `.to_string()` for the `Protocol(String)` variant, matching http.rs. `length`'s match needed a defensive `_` arm (`128..=u8::MAX` unreachable since `short = b1 & 0x7F`) so the codec stays exhaustive without a panic. All 7 tests in `tests/ws_frame.rs` pass including the 300-seed property test against the naive decoder; `make gate` prints `gate: ok` first run. | ? |
| M4a/07-gatewayd-ws-handshake | 2026-09-23 | done | 1 | pass | none | Filled the copied `crates/gatewayd/src/ws/handshake.rs` skeleton. `base64`: 3-byte chunks to 4 chars over ALPHABET with `=` padding, reading each byte via `first`/`get(..).copied().unwrap_or(0)` (no indexing) and masking to 0..=63 before the alphabet index; `accept_for`: `base64(sha1(key + GUID))` building `key+GUID` into one Vec; `new_key`: `read_exact` 16 bytes (too few is an io error) then base64; `check_response` in the task's order — status 101 (`"status <n>"`), `Upgrade` == `websocket` (ASCII case-insensitive), `Connection` with a comma-split token == `upgrade` (case-insensitive), then `Sec-WebSocket-Accept` exactly == `accept_for(key)`; `handshake`: `new_key`, write `request_text` + flush, `read_head` (mapped to `Handshake`), `check_response`, reading nothing past the head. All 7 tests in `tests/ws_handshake.rs` pass including the RFC 6455 accept vector and the first-frame-left-unread handshake; `make gate` prints `gate: ok` first run. | ? |
| M4a/06-gatewayd-http | 2026-09-23 | done | 1 | pass | none | Filled the copied `crates/gatewayd/src/http.rs` skeleton. `Head::header`: first name match, ASCII case-insensitive. `write_request`: builds the head into one buffer in the exact order (`<method> <path> HTTP/1.1`, `Host:`, the given headers, `Content-Length: <n>` only when there is a body, `Connection: close`), writes it then the body, flushes. `request`: `write_request`, `read_head`, `read_body`. `read_head`: reads one byte at a time, retrying `Interrupted`, returning `Protocol` on EOF and `TooLarge("head")` once past `MAX_HEAD`, stopping exactly at `\r\n\r\n`; parses UTF-8, a `HTTP/1.1`/`HTTP/1.0` status line with a 3-digit code in 100..=599 (`split_whitespace`, so `2000`/`abc`/`99`/`HTTP/2` all fail), then header lines `name: value` (non-empty name without a space, value trimmed) until the first blank line. `read_body`: chunked via `read_chunked` when `Transfer-Encoding: chunked` (any case), else `Content-Length` parsed as its own digits (else `Protocol`, over `MAX_BODY` is `TooLarge("body")`, then `read_exact`), else read to the end through `take(MAX_BODY + 1)`. `read_chunked`: hex size before any `;` (1024-byte cap), size 0 reads 8 KiB trailer lines until an empty one, otherwise the size must fit in `MAX_BODY - already_read` (else `TooLarge`) followed by exactly a blank line; `parse_hex` uses `checked_mul`/`checked_add` so an overflow past u128 is `Protocol`. `rate_limit_wait`: `X-Ratelimit-Reset` as u64, above 1_000_000_000 a Unix time (`saturating_sub` elapsed since epoch, at least 1 s) else seconds (at least 1), missing or non-numeric 1 s, capped at `MAX_RATE_WAIT`. All 6 tests in `tests/http.rs` pass; `make gate` prints `gate: ok` first run. | ? |
| M4a/05-gatewayd-net | 2026-09-23 | done | 1 | pass | none | Filled the copied `crates/gatewayd/src/net.rs` skeleton. `Stream::tcp`: match on the variant, `s` for Plain, `s.get_ref()` for Tls. `set_read_timeout` and `Read`/`Write`/`flush` forward to the inner stream per variant. `Connector::new`: for `server.tls` true, `Arc::new(client_config(ca_file)?)` (a bad `ca_file` or empty host certs is `Roots`, before any connection); for false, `None`. `Connector::server` returns `&self.server`. The written `connect` resolves the host, tries each address, sets read/write timeouts + nodelay, and for TLS runs `complete_io` in a loop so a bad cert fails at connect. All 7 tests in `tests/net.rs` pass (plain TCP; TLS via `ca_file`; unknown CA and wrong name refused at connect; TLS to a plain server fails without hanging; bad `ca_file` refused before connecting; nothing listening); `make gate` prints `gate: ok` first run. Added `rustls` to `[dev-dependencies]` for the test TLS server. | ? |