diff --git a/crates/gatewayd/src/http.rs b/crates/gatewayd/src/http.rs new file mode 100644 index 0000000..8134836 --- /dev/null +++ b/crates/gatewayd/src/http.rs @@ -0,0 +1,357 @@ +//! 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 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. + self.headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.as_str()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Response { + pub head: Head, + pub body: Vec, +} + +/// 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: " HTTP/1.1\r\nHost: \r\n", each header as ": \r\n", + // "Content-Length: \r\n" when there is a body, "Connection: close\r\n\r\n", then the body. + // Flush. + let mut head = Vec::new(); + let body_len = body.map(|b| b.len()).unwrap_or(0); + head.extend_from_slice(format!("{method} {path} HTTP/1.1\r\n").as_bytes()); + head.extend_from_slice(format!("Host: {host}\r\n").as_bytes()); + for (k, v) in headers { + head.extend_from_slice(format!("{k}: {v}\r\n").as_bytes()); + } + if body.is_some() { + head.extend_from_slice(format!("Content-Length: {body_len}\r\n").as_bytes()); + } + head.extend_from_slice(b"Connection: close\r\n\r\n"); + stream.write_all(&head)?; + if let Some(b) = body { + stream.write_all(b)?; + } + stream.flush()?; + Ok(()) +} + +/// 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 { + // `write_request`, then `read_head`, then `read_body`. + write_request(stream, method, host, path, headers, body)?; + let head = read_head(stream)?; + let body = read_body(stream, &head)?; + Ok(Response { head, body }) +} + +/// 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 { + // 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. + let mut raw = Vec::new(); + let mut byte = [0u8; 1]; + loop { + let n = loop { + match stream.read(&mut byte) { + Ok(0) => { + return Err(HttpError::Protocol( + "the head ended before the blank line".to_string(), + )); + } + Ok(n) => break n, + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(e) => return Err(HttpError::Io(e)), + } + }; + debug_assert_eq!(n, 1); + raw.push(byte[0]); + if raw.len() > MAX_HEAD { + return Err(HttpError::TooLarge("head")); + } + if raw.ends_with(b"\r\n\r\n") { + break; + } + } + + let text = std::str::from_utf8(&raw) + .map_err(|_| HttpError::Protocol("the head is not UTF-8".to_string()))?; + + let mut lines = text.split("\r\n"); + let status_line = lines.next().unwrap_or(""); + let (version, code) = parse_status_line(status_line)?; + let _ = version; + + let mut headers = Vec::new(); + for line in lines { + if line.is_empty() { + break; + } + let (name, value) = line + .split_once(':') + .ok_or_else(|| HttpError::Protocol("a header line has no colon".to_string()))?; + if name.is_empty() || name.bytes().any(|b| b == b' ') { + return Err(HttpError::Protocol( + "a header name is empty or has a space".to_string(), + )); + } + headers.push((name.to_string(), value.trim().to_string())); + } + + Ok(Head { + status: code, + headers, + }) +} + +fn parse_status_line(line: &str) -> Result<(&str, u16), HttpError> { + // "HTTP/1.1 200 OK": a 1.1 or 1.0 version, then a 3-digit status in 100..=599, then anything. + let mut parts = line.split_whitespace(); + let version = parts.next().unwrap_or(""); + let code = parts.next().unwrap_or(""); + if version != "HTTP/1.1" && version != "HTTP/1.0" { + return Err(HttpError::Protocol( + "not an HTTP/1.1 status line".to_string(), + )); + } + if code.len() != 3 || !code.bytes().all(|b| b.is_ascii_digit()) { + return Err(HttpError::Protocol( + "the status code is not three digits".to_string(), + )); + } + let code: u16 = code + .parse() + .map_err(|_| HttpError::Protocol("the status code will not parse".to_string()))?; + if !(100..=599).contains(&code) { + return Err(HttpError::Protocol( + "the status code is out of range".to_string(), + )); + } + Ok((version, code)) +} + +/// 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, 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"). + if head + .header("transfer-encoding") + .is_some_and(|v| v.eq_ignore_ascii_case("chunked")) + { + return read_chunked(stream); + } + match head.header("content-length") { + Some(s) => { + let len = parse_content_length(s)?; + if len > MAX_BODY { + return Err(HttpError::TooLarge("body")); + } + let mut buf = vec![0u8; len]; + stream.read_exact(&mut buf)?; + Ok(buf) + } + None => { + let mut buf = Vec::new(); + stream.take((MAX_BODY + 1) as u64).read_to_end(&mut buf)?; + if buf.len() > MAX_BODY { + return Err(HttpError::TooLarge("body")); + } + Ok(buf) + } + } +} + +fn parse_content_length(s: &str) -> Result { + // Its own digits only: a non-number is Protocol, an overflowing one is Protocol too. + if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) { + return Err(HttpError::Protocol( + "the Content-Length is not a number".to_string(), + )); + } + s.parse::() + .map_err(|_| HttpError::Protocol("the Content-Length is too large".to_string())) +} + +fn read_line(stream: &mut dyn Read, cap: usize) -> Result { + // Bytes up to CRLF (dropped), at most `cap` (else TooLarge("chunk header")); end of stream is + // Protocol. + let mut bytes = Vec::new(); + let mut byte = [0u8; 1]; + loop { + let n = loop { + match stream.read(&mut byte) { + Ok(0) => { + return Err(HttpError::Protocol( + "the line ended before its CRLF".to_string(), + )); + } + Ok(n) => break n, + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(e) => return Err(HttpError::Io(e)), + } + }; + debug_assert_eq!(n, 1); + match byte[0] { + b'\n' => { + if bytes.last() == Some(&b'\r') { + bytes.pop(); + } + return String::from_utf8(bytes) + .map_err(|_| HttpError::Protocol("a line is not UTF-8".to_string())); + } + other => { + bytes.push(other); + if bytes.len() > cap { + return Err(HttpError::TooLarge("chunk header")); + } + } + } + } +} + +fn read_chunked(stream: &mut dyn Read) -> Result, 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). + let mut out = Vec::new(); + loop { + let size_line = read_line(stream, 1024)?.trim().to_string(); + let hex = match size_line.split_once(';') { + Some((h, _)) => h.trim(), + None => &size_line, + }; + let size = parse_hex(hex)?; + if size == 0 { + loop { + let trailer = read_line(stream, 8 * 1024)?; + if trailer.is_empty() { + return Ok(out); + } + } + } + if size > MAX_BODY as u128 - out.len() as u128 { + return Err(HttpError::TooLarge("body")); + } + let size = size as usize; + let mut chunk = vec![0u8; size]; + stream.read_exact(&mut chunk)?; + out.extend_from_slice(&chunk); + let terminator = read_line(stream, 2)?; + if !terminator.is_empty() { + return Err(HttpError::Protocol( + "a chunk is not ended by a blank line".to_string(), + )); + } + } +} + +fn parse_hex(s: &str) -> Result { + // A hex chunk size; an overflow past u128 is refused here, so a size larger than the body cap + // still comes back as an error to the caller. + let mut value: u128 = 0; + for b in s.bytes() { + let d = match b { + b'0'..=b'9' => (b - b'0') as u128, + b'a'..=b'f' => (b - b'a' + 10) as u128, + b'A'..=b'F' => (b - b'A' + 10) as u128, + _ => return Err(HttpError::Protocol("a chunk size is not hex".to_string())), + }; + value = value + .checked_mul(16) + .and_then(|v| v.checked_add(d)) + .ok_or_else(|| HttpError::Protocol("a chunk size is too large".to_string()))?; + } + Ok(value) +} + +/// 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. + let reset = match head.header("x-ratelimit-reset") { + Some(v) => v, + None => return Duration::from_secs(1), + }; + let value: u64 = match reset.parse() { + Ok(v) => v, + Err(_) => return Duration::from_secs(1), + }; + let secs = if value > 1_000_000_000 { + match now.duration_since(UNIX_EPOCH) { + Ok(elapsed) => value.saturating_sub(elapsed.as_secs()).max(1), + Err(_) => 1, + } + } else { + value.max(1) + }; + Duration::from_secs(secs.min(MAX_RATE_WAIT.as_secs())) +} diff --git a/crates/gatewayd/src/lib.rs b/crates/gatewayd/src/lib.rs index b16147a..163b307 100644 --- a/crates/gatewayd/src/lib.rs +++ b/crates/gatewayd/src/lib.rs @@ -2,5 +2,6 @@ //! the answers back, over outbound connections only. M4a spec: `docs/specs/2026-09-23-m4a-gateway.md`. pub mod config; +pub mod http; pub mod net; pub mod secrets; diff --git a/crates/gatewayd/tests/http.rs b/crates/gatewayd/tests/http.rs new file mode 100644 index 0000000..e98af41 --- /dev/null +++ b/crates/gatewayd/tests/http.rs @@ -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>, + output: Vec, +} + +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 { + self.input.read(buf) + } +} + +impl Write for Duplex { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.output.write(buf) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +fn get(response: &[u8]) -> Result<(u16, Vec), 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) + ); +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index f85b038..6571b90 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone. | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | |---|---|---|---|---|---|---|---| +| 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 (` HTTP/1.1`, `Host:`, the given headers, `Content-Length: ` 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. | ? | | M4a/04-gatewayd-secrets | 2026-09-23 | done | 1 | pass | none | Filled the copied `crates/gatewayd/src/secrets.rs` skeleton. `value`: `from_utf8` else "the value is not UTF-8", one trailing `\n` stripped with `strip_suffix`, empty refused, raw bytes kept in `Zeroizing` until inside the `Secret`. `check_file` in the given order: not absolute, `symlink_metadata` else "cannot read ", symlink via `file_type().is_symlink()`, not a regular file via inherent `is_file()`, owner uid compared to `/proc/self`'s uid (`MetadataExt`), then `mode & 0o077 != 0` reporting the mode as `{:03o}`. `load` matches the three `SecretSource` forms, reading `CREDENTIALS_DIRECTORY` and the variable through the passed `env` closure (never `std::env`), every failure wrapped in `SecretError` naming the secret and never the value, file secrets setting the exact plaintext warning. Replaced the skeleton's `PermissionsExt` import with `MetadataExt` and used inherent `FileType::is_file`/`is_symlink` (Rust 1.98) so no `FileTypeExt`, `unsafe` or `libc`. All 8 tests in `tests/secrets.rs` pass; `docs/runbook.md` gained the seven gatewayd fail-closed entries (14→21 `## ` lines) and `scripts/check-runbook.sh` exits 0. | ? | | M4a/03-gatewayd-config | 2026-09-23 | done | 2 | fail | none | Filled the copied `crates/gatewayd/src/config.rs` skeleton. `ConfigError::fmt`: `": "` with `path.display()`. `load`: read (else `Read`), `toml::from_str` (else `Parse`), then `problem()` (Some is `Invalid`). `parse_url`: strip `https://`/`http://`, `rsplit_once(':')` for an optional port, `valid_host` (1..=253 bytes of a-z 0-9 . -, not starting/ending with . or -) and `parse_port` (digits, 1..=65535, equal to its own `to_string()`, via `u16::try_from`); every failure returns one `[mattermost] url "" must be...` message. `SecretSpec::source`: count the set fields (else "needs exactly one"), then validate credential (a-z0-9 _ . -), env (A-Z0-9 _) and file (absolute) in turn. `problem` checks url, ca_file, missing token, each secret's `source()`, empty users, ids in users then channels, then limits queue/typing/ping/dead. `valid_id` is 26 bytes of a-z0-9; `loop_socket` falls back to `/run/loop/loop.sock`; `state_path` is `/gateway/state.json`. All 7 config tests pass; `make gate` prints `gate: ok`. | ? |