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
@@ -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)
);
}