use std::time::Duration;
use hyper::StatusCode;
use mini_serve::{body, body_bytes, handler, RouteBuilder};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
const LIMIT: usize = 256;
async fn echo_length_app() -> u16 {
let app = RouteBuilder::stateless()
.with_max_body_size(LIMIT)
.post(
"/ingest",
handler(|req, _state| async move {
let bytes = body_bytes(req).await?;
let mut resp = hyper::Response::new(body(
format!("len={}", bytes.len()).into(),
));
*resp.status_mut() = StatusCode::OK;
Ok(resp)
}),
)
.seal();
app.bind_ephemeral().await.unwrap()
}
#[tokio::test]
async fn a_body_within_the_limit_is_read() {
let port = echo_length_app().await;
let text = "a".repeat(32);
let response = reqwest::Client::new()
.post(format!("http://127.0.0.1:{port}/ingest"))
.body(text)
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.text().await.unwrap();
assert_eq!(body, "len=32", "the handler should see the whole body");
}
#[tokio::test]
async fn an_oversized_content_length_is_rejected() {
let port = echo_length_app().await;
let text = "a".repeat(LIMIT * 4);
let response = reqwest::Client::new()
.post(format!("http://127.0.0.1:{port}/ingest"))
.json(&serde_json::json!({ "text": text }))
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test]
async fn an_oversized_content_length_is_refused_before_the_body_is_sent() {
let port = echo_length_app().await;
let mut stream = TcpStream::connect(format!("127.0.0.1:{port}")).await.unwrap();
stream
.write_all(
format!(
"POST /ingest HTTP/1.1\r\n\
Host: localhost\r\n\
Content-Type: application/json\r\n\
Content-Length: {}\r\n\r\n",
LIMIT * 4
)
.as_bytes(),
)
.await
.unwrap();
let mut buf = vec![0u8; 1024];
let read = tokio::time::timeout(Duration::from_secs(2), stream.read(&mut buf))
.await
.expect("the server waited for a body instead of refusing on the header alone");
let n = read.unwrap();
let response = String::from_utf8_lossy(&buf[..n]);
assert!(
response.starts_with("HTTP/1.1 413"),
"got: {}",
response.lines().next().unwrap_or("<nothing>")
);
}
#[tokio::test]
async fn a_chunked_body_that_overruns_the_limit_is_rejected() {
let port = echo_length_app().await;
let mut stream = TcpStream::connect(format!("127.0.0.1:{port}")).await.unwrap();
stream
.write_all(
b"POST /ingest HTTP/1.1\r\n\
Host: localhost\r\n\
Content-Type: application/json\r\n\
Transfer-Encoding: chunked\r\n\r\n",
)
.await
.unwrap();
let chunk = "a".repeat(64);
for _ in 0..16 {
let framed = format!("{:x}\r\n{}\r\n", chunk.len(), chunk);
if stream.write_all(framed.as_bytes()).await.is_err() {
break;
}
}
let _ = stream.write_all(b"0\r\n\r\n").await;
let mut response = Vec::new();
stream.read_to_end(&mut response).await.unwrap();
let response = String::from_utf8_lossy(&response);
assert!(
response.starts_with("HTTP/1.1 413"),
"an unbounded chunked body must be refused, got: {}",
response.lines().next().unwrap_or("<nothing>")
);
}
#[tokio::test]
async fn a_understated_content_length_does_not_bypass_the_limit() {
let port = echo_length_app().await;
let mut stream = TcpStream::connect(format!("127.0.0.1:{port}")).await.unwrap();
let body = format!("{{\"text\":\"{}\"}}", "a".repeat(LIMIT * 4));
stream
.write_all(
format!(
"POST /ingest HTTP/1.1\r\n\
Host: localhost\r\n\
Content-Type: application/json\r\n\
Content-Length: {}\r\n\r\n",
LIMIT / 2
)
.as_bytes(),
)
.await
.unwrap();
let _ = stream.write_all(body.as_bytes()).await;
let mut response = Vec::new();
let _ = stream.read_to_end(&mut response).await;
let response = String::from_utf8_lossy(&response);
let status = response.lines().next().unwrap_or("<nothing>");
let served_len: usize = response
.split_once("len=")
.map(|(_, rest)| rest.chars().take_while(char::is_ascii_digit).collect::<String>())
.and_then(|digits| digits.parse().ok())
.unwrap_or(usize::MAX);
assert!(
status.starts_with("HTTP/1.1 4") || served_len <= LIMIT,
"an understated Content-Length let {served_len} bytes past a {LIMIT}-byte \
ceiling: {status}"
);
}
#[tokio::test]
async fn the_configured_limit_is_the_one_enforced() {
let generous = LIMIT * 8;
let app = RouteBuilder::stateless()
.with_max_body_size(generous)
.post(
"/ingest",
handler(|req, _state| async move {
let bytes = body_bytes(req).await?;
let mut resp = hyper::Response::new(body(
format!("len={}", bytes.len()).into(),
));
*resp.status_mut() = StatusCode::OK;
Ok(resp)
}),
)
.seal();
let port = app.bind_ephemeral().await.unwrap();
let text = "a".repeat(LIMIT * 2);
let response = reqwest::Client::new()
.post(format!("http://127.0.0.1:{port}/ingest"))
.json(&serde_json::json!({ "text": text }))
.send()
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::OK,
"a body under the configured ceiling must be served"
);
}