gatewayd: config, gatewayd.toml into a checked Config

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-23 19:27:43 -07:00
parent 64b34f48d6
commit ede25312b3
5 changed files with 653 additions and 0 deletions
+364
View File
@@ -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<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()`.
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<Config, toml::de::Error> {
// `toml::from_str`.
toml::from_str(text)
}
pub fn load(path: &Path) -> Result<Config, ConfigError> {
// 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<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.
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<ServerUrl, String> {
// `parse_url` of the url.
parse_url(&self.mattermost.url)
}
pub fn token_source(&self) -> Result<SecretSource, String> {
// 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 <home>/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 {
// <home>/gateway/state.json.
self.paths.home.join("gateway/state.json")
}
}
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.
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<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.
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<u16> {
if p.is_empty() || !p.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let n = p.parse::<u32>().ok()?;
if !(1..=65535).contains(&n) || n.to_string() != p {
return None;
}
u16::try_from(n).ok()
}
+2
View File
@@ -1,2 +1,4 @@
//! `gatewayd`: the Mattermost channel. It carries the owner's messages to `loopd` as turns and posts //! `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`. //! the answers back, over outbound connections only. M4a spec: `docs/specs/2026-09-23-m4a-gateway.md`.
pub mod config;
+246
View File
@@ -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}");
}
}
+40
View File
@@ -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);
}
}
+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 | | 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`: `"<path>: <why>"` 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 "<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 `<home>/run/loop/loop.sock`; `state_path` is `<home>/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/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`. | ? | | 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. | ? | | 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. | ? |