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>
208 lines
6.4 KiB
Rust
208 lines
6.4 KiB
Rust
//! 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)
|
|
);
|
|
}
|