gatewayd: http, requests over a stream with size caps and rate-limit waits
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -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<std::io::Error> 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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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: "<method> <path> HTTP/1.1\r\nHost: <host>\r\n", each header as "<k>: <v>\r\n",
|
||||||
|
// "Content-Length: <n>\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<Response, HttpError> {
|
||||||
|
// `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<Head, HttpError> {
|
||||||
|
// 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<Vec<u8>, 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<usize, HttpError> {
|
||||||
|
// 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::<usize>()
|
||||||
|
.map_err(|_| HttpError::Protocol("the Content-Length is too large".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_line(stream: &mut dyn Read, cap: usize) -> Result<String, HttpError> {
|
||||||
|
// 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<Vec<u8>, 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<u128, HttpError> {
|
||||||
|
// 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()))
|
||||||
|
}
|
||||||
@@ -2,5 +2,6 @@
|
|||||||
//! 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;
|
pub mod config;
|
||||||
|
pub mod http;
|
||||||
pub mod net;
|
pub mod net;
|
||||||
pub mod secrets;
|
pub mod secrets;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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/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. | ? |
|
| 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 <path>", 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/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 <path>", 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`: `"<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/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`. | ? |
|
||||||
|
|||||||
Reference in New Issue
Block a user