diff --git a/crates/gatewayd/src/config.rs b/crates/gatewayd/src/config.rs new file mode 100644 index 0000000..04b9c38 --- /dev/null +++ b/crates/gatewayd/src/config.rs @@ -0,0 +1,364 @@ +//! `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, + 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, +} + +/// 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, + #[serde(default)] + pub env: Option, + #[serde(default)] + pub file: Option, +} + +/// 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, + #[serde(default)] + pub channels: Vec, +} + +#[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: ": ", with `path.display()`. + match self { + ConfigError::Read(path, e) => write!(f, "{}: {}", path.display(), e), + ConfigError::Parse(path, e) => write!(f, "{}: {}", path.display(), e), + ConfigError::Invalid(path, why) => write!(f, "{}: {}", path.display(), why), + } + } +} + +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 { + // `toml::from_str`. + toml::from_str(text) + } + + pub fn load(path: &Path) -> Result { + // Read the file (else Read), parse (else Parse), then `problem()` (Some(why) is Invalid). + let text = + std::fs::read_to_string(path).map_err(|e| ConfigError::Read(path.to_path_buf(), e))?; + let config = Config::parse(&text).map_err(|e| ConfigError::Parse(path.to_path_buf(), e))?; + match config.problem() { + Some(why) => Err(ConfigError::Invalid(path.to_path_buf(), why)), + None => Ok(config), + } + } + + /// The first thing wrong with the values, or `None`. + pub fn problem(&self) -> Option { + // 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. + if let Err(why) = parse_url(&self.mattermost.url) { + return Some(why); + } + if let Some(ca_file) = &self.mattermost.ca_file + && !ca_file.is_absolute() + { + return Some(format!( + "[mattermost] ca_file {:?} must be an absolute path", + ca_file.display() + )); + } + if !self.secrets.contains_key(MATTERMOST_TOKEN) { + return Some("[secrets.mattermost_token] is missing".to_string()); + } + for (name, spec) in &self.secrets { + if let Err(why) = spec.source() { + return Some(format!("[secrets.{}] {}", name, why)); + } + } + if self.allow.users.is_empty() { + return Some( + "[allow] users is empty: a gateway that answers nobody is a mistake".to_string(), + ); + } + for id in self.allow.users.iter().chain(self.allow.channels.iter()) { + if !valid_id(id) { + return Some(format!( + "[allow] {:?} is not a Mattermost id (26 characters of a-z and 0-9)", + id + )); + } + } + if self.limits.queue == 0 { + return Some("[limits] queue must be at least 1".to_string()); + } + for (name, value) in [ + ("typing_every_ms", self.limits.typing_every_ms), + ("ping_every_ms", self.limits.ping_every_ms), + ("dead_after_ms", self.limits.dead_after_ms), + ] { + if value == 0 { + return Some(format!("[limits] {} must be at least 1", name)); + } + } + None + } + + /// The server's address, from `url`. Call only on a checked `Config`. + pub fn server(&self) -> Result { + // `parse_url` of the url. + parse_url(&self.mattermost.url) + } + + pub fn token_source(&self) -> Result { + // The `source()` of [secrets.mattermost_token], or an error if it is missing. + match self.secrets.get(MATTERMOST_TOKEN) { + Some(spec) => spec.source(), + None => Err("[secrets.mattermost_token] is missing".to_string()), + } + } + + pub fn loop_socket(&self) -> PathBuf { + // [loop] socket, or /run/loop/loop.sock when it is empty. + if self.loop_.socket.as_os_str().is_empty() { + return self.paths.home.join("run/loop/loop.sock"); + } + self.loop_.socket.clone() + } + + pub fn state_path(&self) -> PathBuf { + // /gateway/state.json. + self.paths.home.join("gateway/state.json") + } +} + +impl SecretSpec { + /// Exactly one source, well formed. + pub fn source(&self) -> Result { + // 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. + let mut count = 0; + if self.credential.is_some() { + count += 1; + } + if self.env.is_some() { + count += 1; + } + if self.file.is_some() { + count += 1; + } + if count != 1 { + return Err("needs exactly one of credential, env and file".to_string()); + } + if let Some(credential) = &self.credential { + if credential.is_empty() + || !credential.bytes().all(|b| { + b.is_ascii_alphabetic() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-') + }) + { + return Err(format!( + "credential {:?} is not a credential name (letters, digits, _ . -)", + credential + )); + } + return Ok(SecretSource::Credential(credential.clone())); + } + if let Some(env) = &self.env { + if env.is_empty() + || !env + .bytes() + .all(|b| matches!(b, b'A'..=b'Z' | b'0'..=b'9' | b'_')) + { + return Err(format!( + "env {:?} is not a variable name (A-Z, 0-9, _)", + env + )); + } + return Ok(SecretSource::Env(env.clone())); + } + // `count == 1` and the two above are `None`, so this is `Some`. + let file = match &self.file { + Some(file) => file, + None => return Err("needs exactly one of credential, env and file".to_string()), + }; + if !file.is_absolute() { + return Err(format!( + "file {:?} must be an absolute path", + file.display() + )); + } + Ok(SecretSource::File(file.clone())) + } +} + +/// A Mattermost id: 26 characters of `a-z0-9`. +pub fn valid_id(id: &str) -> bool { + // 26 bytes, each a-z or 0-9. + id.len() == 26 + && id + .bytes() + .all(|b| b.is_ascii_digit() || b.is_ascii_lowercase()) +} + +/// `http://host[:port]` or `https://host[:port]`, nothing else. +pub fn parse_url(url: &str) -> Result { + // 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. + let (tls, rest) = match url.strip_prefix("https://") { + Some(rest) => (true, rest), + None => match url.strip_prefix("http://") { + Some(rest) => (false, rest), + None => return Err(url_error(url)), + }, + }; + let (host, port) = match rest.rsplit_once(':') { + Some((h, p)) => { + if !valid_host(h) { + return Err(url_error(url)); + } + let port = match parse_port(p) { + Some(port) => port, + None => return Err(url_error(url)), + }; + (h.to_string(), port) + } + None => { + if !valid_host(rest) { + return Err(url_error(url)); + } + (rest.to_string(), if tls { 443 } else { 80 }) + } + }; + Ok(ServerUrl { tls, host, port }) +} + +fn url_error(url: &str) -> String { + format!( + "[mattermost] url {:?} must be http:// or https://, a host, an optional port, and nothing else", + url + ) +} + +fn valid_host(host: &str) -> bool { + let bytes = host.as_bytes(); + let len = bytes.len(); + if len == 0 || len > 253 { + return false; + } + if bytes[0] == b'.' || bytes[0] == b'-' || bytes[len - 1] == b'.' || bytes[len - 1] == b'-' { + return false; + } + bytes + .iter() + .all(|&b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'.' || b == b'-') +} + +fn parse_port(p: &str) -> Option { + if p.is_empty() || !p.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let n = p.parse::().ok()?; + if !(1..=65535).contains(&n) || n.to_string() != p { + return None; + } + u16::try_from(n).ok() +} diff --git a/crates/gatewayd/src/lib.rs b/crates/gatewayd/src/lib.rs index ac2b739..47fd6e5 100644 --- a/crates/gatewayd/src/lib.rs +++ b/crates/gatewayd/src/lib.rs @@ -1,2 +1,4 @@ //! `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`. + +pub mod config; diff --git a/crates/gatewayd/tests/config.rs b/crates/gatewayd/tests/config.rs new file mode 100644 index 0000000..12b6fdc --- /dev/null +++ b/crates/gatewayd/tests/config.rs @@ -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 { + 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}"); + } +} diff --git a/crates/gatewayd/tests/support/tmp.rs b/crates/gatewayd/tests/support/tmp.rs new file mode 100644 index 0000000..dabbd99 --- /dev/null +++ b/crates/gatewayd/tests/support/tmp.rs @@ -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); + } +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index c9d4f62..68d5b36 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/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`. | ? | | M4a/02-gatewayd-deps | 2026-09-23 | done | 1 | pass | none | Added the dependencies gatewayd needs for TLS to Mattermost and nothing that uses them yet. Added `rustls` (0.23.45, `default-features = false` with `ring`/`std`/`tls12`), `rustls-native-certs` (0.8.4) and `zeroize` (1.9.0) to `[workspace.dependencies]` in the root `Cargo.toml`, the three plus `serde`/`serde_json`/`toml` to `crates/gatewayd/Cargo.toml`, copied `deny.toml` and the whole `crates/gatewayd/tests/fixtures/tls/` directory (9 files), replaced the one-line doc comment in `lib.rs` with the M4a spec doc, and in `docs/dependencies.md` added `gatewayd` to the `serde`/`serde_json`/`toml` rows and appended the `rustls`/`rustls-native-certs`/`zeroize` rows. `cargo build -p gatewayd` succeeded offline (all crates already in the local cache). cargo-deny reported `bans ok, licenses ok, sources ok`. `make gate` printed `gate: ok` on the first run. | ? | | M4a/01-proto-sha1 | 2026-09-23 | done | 2 | fail | none | Wrote `crates/proto/src/sha1.rs`: `sha1` (new/update/finish), `Sha1 { state, block, filled, length }` with `length` counting bits. `compress`: `w: [u32; 80]` via `as_chunks::<4>()` + `from_be_bytes`, `w[i] = (w[i-3]^w[i-8]^w[i-14]^w[i-16]).rotate_left(1)`, eighty wrapping rounds with f/k by range, state added with `wrapping_add`. `update`: `wrapping_add(8u64.wrapping_mul(data.len() as u64))`, `split_at`/`get_mut(..).copy_from_slice`, copy the block out (`let block = self.block`) before `compress` so the mutable receiver and shared slice do not clash. `finish`: builds a 128-byte pad (`0x80`, zeros, 8 big-endian length bytes) sized `56-filled` or `120-filled` plus the 8 length bytes, feeds it through `update`, restores `length`, then the five words big-endian. One logic bug caught by the empty-string vector: the `w[i]` expansion rotated only `w[i-16]` instead of the whole XOR, fixed with parentheses. First gate failed on clippy `needless_range_loop` for the 0..80 round loop; switched to `w.iter().enumerate()` with a bound `&ww`. All 4 sha1 tests pass; `make gate` prints `gate: ok`. | ? | | M3b/17-toolkit-nits | 2026-09-23 | done | 1 | pass | none | Three small fixes. `fetch.rs`: replaced `std::thread::spawn` with a `Builder::new().spawn` match that kills and waits on a spawn error and returns `Outcome::tool_error("http_fetch: cannot start a thread: {e}")`. `input.rs` and `files.rs`: replaced `MAX_INPUT as u64 + 1` / `MAX_READ as u64 + 1` with `u64::try_from(MAX_*).map_or(u64::MAX, |n| n.saturating_add(1))`. `main.rs` `parse_egress_proxy`: the first check is now `args.len() != 5 || args.first()?.as_bytes() != b"egress-proxy"` so a longer list goes to the tool form (exit 2). Copied `tests/egress_form.rs`; the one test failed before the fix and passed in 0.02s after. `grep "thread::spawn\| as u64" crates/toolkit/src/` prints nothing. `make gate` prints `gate: ok` first run. | ? |