feat(parser,conn): chunked request bodies (v0.3 chunk 2)
parser: Transfer-Encoding: chunked no longer 411s — it sets ParsedHead.chunked and the conn actor decodes. Two hard rejections at parse time, both Malformed/400: chunked together with Content-Length (the request-smuggling ambiguity; RFC 7230 §3.3.3 permits rejection) and chunked on HTTP/1.0 (TE is a 1.1 mechanism). ParseError::Unsupported is now unconstructed; kept for future framings. conn_actor: read_chunked_body decodes incrementally from buf[head_len..], reading on the SAME request deadline the head came in under (a stalled chunked body dies at request_timeout exactly like a stalled CL body). Chunk extensions are ignored; trailers are consumed and discarded. Bounds: decoded size capped at max_body_bytes -> 413 the moment the cap would be crossed (the CL pre-check can't see a chunked body's size up front); size lines capped at 128 bytes and the trailer section at 8 KiB -> 400, so framing spam can't grow the read buffer unboundedly. Returns (decoded, raw_consumed_past_head): the keep-alive drain at the loop bottom now drops head_len + RAW framing length (not decoded length), so pipelined requests behind a chunked body land exactly — covered by the trailers+pipelining test. Tests: 3 parser unit (flagging, CL+TE reject, 1.0 reject; the old chunked-is-Unsupported test replaced) + 6 integration (decode with extensions, trailers + pipelined next request, 413 over decoded cap, 400 malformed size line, 400 CL+TE on the wire, stalled chunked body killed at request_timeout with silent close).
This commit is contained in:
@@ -772,3 +772,136 @@ fn stalled_reader_killed_at_write_timeout() {
|
||||
.recv_timeout(Duration::from_secs(10))
|
||||
.expect("producer never observed conn death: write_timeout not enforced");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chunked request bodies (v0.3 chunk 2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn echo_pipeline() -> Pipeline {
|
||||
Pipeline::new().plug(
|
||||
Router::new()
|
||||
.get("/", |c: Conn, _n: Next| c.put_status(200).put_body("hello"))
|
||||
.post("/echo", |c: Conn, _n: Next| {
|
||||
let body = c.body.as_bytes().to_vec();
|
||||
c.put_status(200).put_body(body)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// A chunked request body (with chunk extensions, which must be ignored)
|
||||
/// is decoded before the pipeline runs; the handler sees the joined bytes.
|
||||
#[test]
|
||||
fn chunked_request_body_is_decoded() {
|
||||
let port = spawn_server(echo_pipeline());
|
||||
let resp = send_request(
|
||||
port,
|
||||
b"POST /echo HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n\
|
||||
5\r\nhello\r\n6;ext=1\r\n world\r\n0\r\n\r\n",
|
||||
);
|
||||
assert_eq!(http_status(&resp), 200);
|
||||
assert_eq!(http_body(&resp), b"hello world");
|
||||
}
|
||||
|
||||
/// Trailer headers after the 0-chunk are consumed and discarded; the
|
||||
/// connection stays usable (keep-alive across a chunked request, with the
|
||||
/// next request pipelined in the same packet — exercises the raw-consumed
|
||||
/// accounting).
|
||||
#[test]
|
||||
fn chunked_request_trailers_and_pipelining() {
|
||||
let port = spawn_server(echo_pipeline());
|
||||
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
s.write_all(
|
||||
b"POST /echo HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n\
|
||||
3\r\nabc\r\n0\r\nx-trailer: ignored\r\n\r\n\
|
||||
GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut resp = Vec::new();
|
||||
s.read_to_end(&mut resp).unwrap();
|
||||
let text = String::from_utf8_lossy(&resp);
|
||||
// Two responses back to back: the echo, then hello.
|
||||
assert!(text.contains("abc"), "echo body missing: {text}");
|
||||
let second = &text[text.find("abc").unwrap()..];
|
||||
assert!(second.contains("hello"), "pipelined second response missing: {text}");
|
||||
}
|
||||
|
||||
/// A chunked body whose decoded size exceeds max_body_bytes is rejected
|
||||
/// with 413 the moment the limit would be crossed.
|
||||
#[test]
|
||||
fn chunked_request_over_limit_413() {
|
||||
let port = free_port();
|
||||
let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
|
||||
let pipe = echo_pipeline();
|
||||
std::thread::spawn(move || {
|
||||
let cfg = Config {
|
||||
listener_pool: 2,
|
||||
scheduler_threads: Some(2),
|
||||
max_body_bytes: 8, // tiny
|
||||
..Config::new(addr)
|
||||
};
|
||||
serve_with(cfg, pipe).unwrap();
|
||||
});
|
||||
for _ in 0..50 {
|
||||
if TcpStream::connect(addr).is_ok() { break; }
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
let mut s = TcpStream::connect(addr).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
s.write_all(
|
||||
b"POST /echo HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n\
|
||||
9\r\n123456789\r\n0\r\n\r\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut resp = Vec::new();
|
||||
s.read_to_end(&mut resp).unwrap();
|
||||
assert_eq!(http_status(&resp), 413);
|
||||
}
|
||||
|
||||
/// Garbage where a chunk-size line should be is 400.
|
||||
#[test]
|
||||
fn chunked_request_malformed_size_400() {
|
||||
let port = spawn_server(echo_pipeline());
|
||||
let resp = send_request(
|
||||
port,
|
||||
b"POST /echo HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n\
|
||||
zz\r\nhello\r\n0\r\n\r\n",
|
||||
);
|
||||
assert_eq!(http_status(&resp), 400);
|
||||
}
|
||||
|
||||
/// Content-Length + Transfer-Encoding: chunked together is the smuggling
|
||||
/// ambiguity: rejected 400 at parse time.
|
||||
#[test]
|
||||
fn chunked_plus_content_length_400() {
|
||||
let port = spawn_server(echo_pipeline());
|
||||
let resp = send_request(
|
||||
port,
|
||||
b"POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\nTransfer-Encoding: chunked\r\n\r\n\
|
||||
5\r\nhello\r\n0\r\n\r\n",
|
||||
);
|
||||
assert_eq!(http_status(&resp), 400);
|
||||
}
|
||||
|
||||
/// A chunked body that stalls mid-stream is killed by the request
|
||||
/// deadline: the connection just closes (no response owed mid-body).
|
||||
#[test]
|
||||
fn chunked_request_stall_killed_at_request_timeout() {
|
||||
let port = spawn_server_with_timeouts(
|
||||
echo_pipeline(),
|
||||
Duration::from_secs(30),
|
||||
Duration::from_millis(400), // request_timeout
|
||||
);
|
||||
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
// Head + first chunk, then stall forever before the 0-chunk.
|
||||
s.write_all(
|
||||
b"POST /echo HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n",
|
||||
)
|
||||
.unwrap();
|
||||
let start = std::time::Instant::now();
|
||||
let mut resp = Vec::new();
|
||||
s.read_to_end(&mut resp).unwrap(); // server closes; EOF
|
||||
assert!(resp.is_empty(), "expected silent close, got: {:?}", String::from_utf8_lossy(&resp));
|
||||
assert!(start.elapsed() < Duration::from_secs(3), "close took too long");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user