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:
Claude
2026-06-12 04:56:09 +00:00
parent 42a2464743
commit 4482f47265
3 changed files with 342 additions and 20 deletions
+40 -12
View File
@@ -4,14 +4,13 @@
//! copy, battle-tested). Body framing, keep-alive logic and response writing
//! are ours.
//!
//! v1 body framing:
//! Body framing:
//! - `Content-Length: N` — read exactly N bytes.
//! - No body header — empty body.
//! - `Transfer-Encoding: chunked` — deferred. Returns ParseError::Unsupported
//! and the connection actor responds 411 Length Required + close.
//!
//! Keep it stupid simple. Chunked decoding lands when something actually
//! requests it.
//! - `Transfer-Encoding: chunked` (HTTP/1.1) — flagged in `ParsedHead`;
//! the connection actor decodes incrementally (`read_chunked_body`).
//! Chunked + Content-Length together, or chunked on HTTP/1.0, is
//! Malformed (request-smuggling ambiguity; RFC 7230 §3.3.3).
use crate::conn::{Body, Conn, HeaderMap, HttpVersion, Method, RespBody};
@@ -30,8 +29,9 @@ pub enum ParseError {
TooManyHeaders,
/// `Content-Length` header could not be parsed as an integer.
BadContentLength,
/// A wire feature we haven't implemented yet (e.g. chunked encoding).
/// Connection actor responds 411 + close.
/// A wire feature we haven't implemented. Currently unconstructed
/// (chunked decoding landed in v0.3); kept for future unsupported
/// framings. Connection actor responds 411 + close.
Unsupported,
}
@@ -50,6 +50,10 @@ pub struct ParsedHead {
pub version: HttpVersion,
pub headers: HeaderMap,
pub content_length: Option<usize>,
/// `Transfer-Encoding: chunked` — the body is chunked-framed and the
/// connection actor decodes it (`read_chunked_body`). Mutually
/// exclusive with `content_length` (rejected as Malformed).
pub chunked: bool,
pub keep_alive: bool,
pub expect_100: bool,
}
@@ -130,7 +134,13 @@ pub fn parse_head(buf: &[u8], max_headers: usize) -> Result<ParsedHead, ParseErr
}
if chunked {
return Err(ParseError::Unsupported);
// Transfer-Encoding is an HTTP/1.1 mechanism; a 1.0 request
// carrying it is malformed. And a request carrying BOTH a
// Content-Length and TE: chunked is the classic request-smuggling
// ambiguity — RFC 7230 §3.3.3 lets a server reject it, and we do.
if version == HttpVersion::Http10 || content_length.is_some() {
return Err(ParseError::Malformed);
}
}
// Keep-alive logic, RFC 7230 §6.3:
@@ -149,6 +159,7 @@ pub fn parse_head(buf: &[u8], max_headers: usize) -> Result<ParsedHead, ParseErr
version,
headers,
content_length,
chunked,
keep_alive,
expect_100,
})
@@ -357,11 +368,28 @@ mod tests {
}
#[test]
fn parse_chunked_unsupported() {
fn parse_chunked_is_flagged() {
let req = b"POST /a HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n";
let head = parse_head(req, 64).unwrap();
assert!(head.chunked);
assert_eq!(head.content_length, None);
}
#[test]
fn parse_chunked_plus_content_length_is_malformed() {
let req = b"POST /a HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\nTransfer-Encoding: chunked\r\n\r\n";
match parse_head(req, 64) {
Err(ParseError::Unsupported) => {}
_ => panic!("expected Unsupported for chunked"),
Err(ParseError::Malformed) => {}
_ => panic!("expected Malformed for CL + chunked"),
}
}
#[test]
fn parse_chunked_on_http10_is_malformed() {
let req = b"POST /a HTTP/1.0\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n";
match parse_head(req, 64) {
Err(ParseError::Malformed) => {}
_ => panic!("expected Malformed for chunked on 1.0"),
}
}