//! HTTP/1.1 wire protocol — request parsing and response serialisation. //! //! Parsing uses `httparse` for the request line + headers (zero-alloc, zero- //! copy, battle-tested). Body framing, keep-alive logic and response writing //! are ours. //! //! Body framing: //! - `Content-Length: N` — read exactly N bytes. //! - No body header — empty body. //! - `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}; // --------------------------------------------------------------------------- // ParseError // --------------------------------------------------------------------------- #[derive(Debug)] pub enum ParseError { /// Request bytes are well-formed but incomplete; need more from the /// socket. Incomplete, /// Request is malformed; respond 400 and close. Malformed, /// Header count exceeded the configured max; respond 431 and close. TooManyHeaders, /// `Content-Length` header could not be parsed as an integer. BadContentLength, /// 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, } // --------------------------------------------------------------------------- // ParsedHead — what `parse_head` returns on success. // --------------------------------------------------------------------------- // // `head_len` is how many bytes from the start of the buffer the head // occupied — the body starts at `&buf[head_len..]`. pub struct ParsedHead { pub head_len: usize, pub method: Method, pub path: String, pub query: Option, pub version: HttpVersion, pub headers: HeaderMap, pub content_length: Option, /// `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, } /// Try to parse a request head from `buf`. Returns `Incomplete` if more /// bytes are needed; the caller should read more and retry with the *same* /// buffer. pub fn parse_head(buf: &[u8], max_headers: usize) -> Result { // httparse needs a header array; size it from config. 64 is the spec // default. Stack allocation; if a request has more than max_headers // they get TooManyHeaders. let mut header_buf = vec![httparse::EMPTY_HEADER; max_headers]; let mut req = httparse::Request::new(&mut header_buf); let head_len = match req.parse(buf) { Ok(httparse::Status::Complete(n)) => n, Ok(httparse::Status::Partial) => return Err(ParseError::Incomplete), Err(httparse::Error::TooManyHeaders) => return Err(ParseError::TooManyHeaders), Err(_) => return Err(ParseError::Malformed), }; let method_str = req.method.ok_or(ParseError::Malformed)?; let raw_path = req.path.ok_or(ParseError::Malformed)?; let v = req.version.ok_or(ParseError::Malformed)?; let version = match v { 0 => HttpVersion::Http10, 1 => HttpVersion::Http11, _ => return Err(ParseError::Malformed), }; // Split path and query. We don't percent-decode the path here — the // spec calls for it, but for v1 raw is fine; user code can decode if // they need to. Same with the query. let (path, query) = match raw_path.split_once('?') { Some((p, q)) => (p.to_string(), Some(q.to_string())), None => (raw_path.to_string(), None), }; // Walk headers, building HeaderMap and pulling out the few we need // ourselves (Content-Length, Connection, Transfer-Encoding, Expect). let mut headers = HeaderMap::with_capacity(req.headers.len()); let mut content_length = None; let mut connection_hdr = None; let mut chunked = false; let mut expect_100 = false; let mut host_count = 0usize; let mut host_ok = true; let mut cl_count = 0usize; for h in req.headers.iter() { let name_lower = h.name.to_ascii_lowercase(); let value = std::str::from_utf8(h.value).map_err(|_| ParseError::Malformed)?; match name_lower.as_str() { "content-length" => { // Count occurrences; duplicates (even equal) are rejected // post-loop. A single value must be one decimal integer — // a comma-list ("5, 5") or non-numeric fails parse here. cl_count += 1; content_length = Some( value.trim() .parse::() .map_err(|_| ParseError::BadContentLength)? ); } "transfer-encoding" => { // We only care whether it includes "chunked". Multiple codings // can appear; chunked is the only one we'd need to decode. if value.to_ascii_lowercase().split(',').any(|t| t.trim() == "chunked") { chunked = true; } } "connection" => { connection_hdr = Some(value.to_ascii_lowercase()); } "expect" if value.eq_ignore_ascii_case("100-continue") => { expect_100 = true; } "host" => { // Presence/uniqueness enforced post-loop; validity here. host_count += 1; if !valid_host(value) { host_ok = false; } } _ => {} } headers.append(&name_lower, value.to_string()); } // Host (RFC 9112 §3.2): an HTTP/1.1 request MUST carry exactly one valid // Host; a missing, duplicate, or malformed Host is a 400. HTTP/1.0 may // omit Host, but a duplicate or invalid one is still rejected on any // version (ambiguous / malformed authority). if host_count > 1 || !host_ok { return Err(ParseError::Malformed); } if version == HttpVersion::Http11 && host_count == 0 { return Err(ParseError::Malformed); } // Content-Length (RFC 9112 §6.3): more than one Content-Length is an // unrecoverable framing ambiguity (CL.CL request smuggling). We are // strict — reject any duplicate, not only differing values. if cl_count > 1 { return Err(ParseError::BadContentLength); } if chunked { // 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: // HTTP/1.1: keep-alive by default; "Connection: close" overrides. // HTTP/1.0: close by default; "Connection: keep-alive" overrides. let keep_alive = match version { HttpVersion::Http11 => connection_hdr.as_deref() != Some("close"), HttpVersion::Http10 => connection_hdr.as_deref() == Some("keep-alive"), }; Ok(ParsedHead { head_len, method: Method::parse(method_str), path, query, version, headers, content_length, chunked, keep_alive, expect_100, }) } /// Conservative RFC 3986 check for a `Host` field-value: non-empty and every /// byte drawn from the `host[:port]` productions (reg-name / IP-literal /// brackets / port colon). This is charset-level, not full structural /// validation (no bracket matching, no pct-encoding well-formedness) — enough /// to reject the smuggling-relevant garbage (whitespace, controls, `@`, `/`, /// `?`, `#`) while accepting every legitimate host. Tighter structural checks /// (bracketed IPv6, single port colon) are a possible follow-up. fn valid_host(value: &str) -> bool { !value.is_empty() && value.bytes().all(|b| { b.is_ascii_alphanumeric() || matches!( b, // unreserved punctuation b'-' | b'.' | b'_' | b'~' // sub-delims | b'!' | b'$' | b'&' | b'\'' | b'(' | b')' | b'*' | b'+' | b',' | b';' | b'=' // pct-encoded lead | b'%' // IP-literal brackets + port separator | b'[' | b']' | b':' ) }) } // --------------------------------------------------------------------------- // Conn assembly // --------------------------------------------------------------------------- pub fn build_conn(head: ParsedHead, body: Body) -> Conn { let mut c = Conn::new(); c.method = head.method; c.path = head.path; c.query = head.query; c.version = head.version; c.headers = head.headers; c.body = body; c } // --------------------------------------------------------------------------- // Response serialisation // --------------------------------------------------------------------------- // // We emit: // \r\n //
: \r\n // ... // \r\n // // // Headers we always inject (unless the user already set them): // - Content-Length: from resp_body.len_hint() // - Connection: close (if keep-alive is off this request) // - Date: skipped in v1; not required by the standard and adds complexity. pub fn serialise_response(conn: &Conn, keep_alive: bool) -> Vec { // Pre-size: status line ~30 + headers ~50/each + body. Good enough. let body_len = conn.resp_body.len_hint(); let is_stream = matches!(conn.resp_body, RespBody::Stream(_)); let mut out = Vec::with_capacity(64 + conn.resp_headers.len() * 40 + body_len); let status = conn.status.unwrap_or(200); let reason = reason_phrase(status); // Status line. out.extend_from_slice(conn.version.as_str().as_bytes()); out.push(b' '); out.extend_from_slice(status.to_string().as_bytes()); out.push(b' '); out.extend_from_slice(reason.as_bytes()); out.extend_from_slice(b"\r\n"); // User headers — written first so subsequent injection can skip them. // For Stream bodies WE own the framing: a user `content-length` or // `transfer-encoding` is dropped rather than emitted (the combination // of content-length + chunked is a smuggling vector, and a stream has // no length to promise anyway). let mut wrote_content_length = false; let mut wrote_connection = false; for (name, value) in conn.resp_headers.iter() { match name { "content-length" if is_stream => continue, "transfer-encoding" if is_stream => continue, "content-length" => wrote_content_length = true, "connection" => wrote_connection = true, _ => {} } out.extend_from_slice(name.as_bytes()); out.extend_from_slice(b": "); out.extend_from_slice(value.as_bytes()); out.extend_from_slice(b"\r\n"); } if is_stream { // HTTP/1.1: chunked framing, connection reusable afterwards. // HTTP/1.0: no chunked TE exists; the body is raw bytes delimited // by EOF — the caller passes keep_alive = false and we emit // `connection: close` below. if conn.version == HttpVersion::Http11 { out.extend_from_slice(b"transfer-encoding: chunked\r\n"); } } else if !wrote_content_length && !(100..200).contains(&status) { // 1xx responses have no body by definition (RFC 7230 §3.3.2) — // injecting `content-length: 0` on the 101 upgrade response is a // protocol violation some clients reject. out.extend_from_slice(b"content-length: "); out.extend_from_slice(body_len.to_string().as_bytes()); out.extend_from_slice(b"\r\n"); } if !wrote_connection { if !keep_alive { out.extend_from_slice(b"connection: close\r\n"); } else if conn.version == HttpVersion::Http10 { // HTTP/1.0 defaults to close: a connection we intend to keep // open MUST be advertised back, or a spec-following client // waits for an EOF that never comes (`ab -k` deadlocked on // exactly this). 1.1 keep-alive is the default and stays // implicit. out.extend_from_slice(b"connection: keep-alive\r\n"); } } out.extend_from_slice(b"\r\n"); // Fixed bodies are written inline with the head; a Stream body is // pumped by the connection actor after this head goes on the wire. match &conn.resp_body { RespBody::Empty => {} RespBody::Bytes(b) => out.extend_from_slice(b), RespBody::Stream(_) => {} } out } /// HTTP/1.1 reason phrases. Required by RFC 7230 §3.1.2 — clients may /// display them. pub fn reason_phrase(status: u16) -> &'static str { match status { 200 => "OK", 201 => "Created", 202 => "Accepted", 204 => "No Content", 301 => "Moved Permanently", 302 => "Found", 303 => "See Other", 304 => "Not Modified", 307 => "Temporary Redirect", 308 => "Permanent Redirect", 400 => "Bad Request", 401 => "Unauthorized", 403 => "Forbidden", 404 => "Not Found", 405 => "Method Not Allowed", 409 => "Conflict", 411 => "Length Required", 413 => "Payload Too Large", 414 => "URI Too Long", 415 => "Unsupported Media Type", 422 => "Unprocessable Entity", 429 => "Too Many Requests", 431 => "Request Header Fields Too Large", 500 => "Internal Server Error", 501 => "Not Implemented", 502 => "Bad Gateway", 503 => "Service Unavailable", _ => "OK", // best-effort fallback; spec allows any phrase } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; #[test] fn parse_simple_get() { let req = b"GET /hello HTTP/1.1\r\nHost: x\r\n\r\n"; let head = parse_head(req, 64).unwrap(); assert_eq!(head.method, Method::Get); assert_eq!(head.path, "/hello"); assert_eq!(head.version, HttpVersion::Http11); assert!(head.keep_alive); assert_eq!(head.content_length, None); } #[test] fn parse_with_query() { let req = b"GET /users?id=42&active=true HTTP/1.1\r\nHost: x\r\n\r\n"; let head = parse_head(req, 64).unwrap(); assert_eq!(head.path, "/users"); assert_eq!(head.query.as_deref(), Some("id=42&active=true")); } #[test] fn parse_post_with_content_length() { let req = b"POST /a HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\n\r\nhello"; let head = parse_head(req, 64).unwrap(); assert_eq!(head.content_length, Some(5)); // head_len is bytes before the body; the remainder is the body. assert_eq!(&req[head.head_len..], b"hello"); } #[test] fn parse_connection_close() { let req = b"GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"; let head = parse_head(req, 64).unwrap(); assert!(!head.keep_alive); } #[test] fn parse_http10_default_close() { let req = b"GET / HTTP/1.0\r\nHost: x\r\n\r\n"; let head = parse_head(req, 64).unwrap(); assert!(!head.keep_alive); } #[test] fn parse_http10_keepalive_opt_in() { let req = b"GET / HTTP/1.0\r\nHost: x\r\nConnection: keep-alive\r\n\r\n"; let head = parse_head(req, 64).unwrap(); assert!(head.keep_alive); } #[test] fn parse_partial() { let req = b"GET /hello HTTP/1.1\r\nHost: x\r\n"; // no terminator yet match parse_head(req, 64) { Err(ParseError::Incomplete) => {} _ => panic!("expected Incomplete"), } } #[test] 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::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"), } } // --- Host (RFC 9112 §3.2) ------------------------------------------- #[test] fn parse_missing_host_http11_is_malformed() { let req = b"GET / HTTP/1.1\r\n\r\n"; match parse_head(req, 64) { Err(ParseError::Malformed) => {} _ => panic!("expected Malformed for missing Host on 1.1"), } } #[test] fn parse_missing_host_http10_is_allowed() { // Host is optional in HTTP/1.0. let req = b"GET / HTTP/1.0\r\n\r\n"; assert!(parse_head(req, 64).is_ok(), "1.0 may omit Host"); } #[test] fn parse_duplicate_host_is_malformed() { let req = b"GET / HTTP/1.1\r\nHost: a\r\nHost: b\r\n\r\n"; match parse_head(req, 64) { Err(ParseError::Malformed) => {} _ => panic!("expected Malformed for duplicate Host"), } } #[test] fn parse_invalid_host_value_is_malformed() { // Embedded whitespace — invalid in an RFC 3986 authority. let req = b"GET / HTTP/1.1\r\nHost: bad host\r\n\r\n"; match parse_head(req, 64) { Err(ParseError::Malformed) => {} _ => panic!("expected Malformed for invalid Host"), } } #[test] fn parse_valid_hosts_accepted() { // Positive controls: reg-name, reg-name:port, and IPv6-literal:port. for req in [ b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n".as_slice(), b"GET / HTTP/1.1\r\nHost: example.com:8080\r\n\r\n".as_slice(), b"GET / HTTP/1.1\r\nHost: [::1]:443\r\n\r\n".as_slice(), ] { assert!(parse_head(req, 64).is_ok(), "should accept a valid Host"); } } // --- Content-Length (RFC 9112 §6.3) --------------------------------- #[test] fn parse_conflicting_content_length_is_rejected() { // Two differing Content-Length values — classic CL.CL smuggling. let req = b"POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\nContent-Length: 7\r\n\r\nhello!!"; match parse_head(req, 64) { Err(ParseError::BadContentLength) => {} _ => panic!("expected BadContentLength for conflicting CL"), } } #[test] fn parse_duplicate_equal_content_length_is_rejected() { // Strict: even identical duplicates are rejected. let req = b"POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\nContent-Length: 5\r\n\r\nhello"; match parse_head(req, 64) { Err(ParseError::BadContentLength) => {} _ => panic!("expected BadContentLength for duplicate CL"), } } #[test] fn parse_single_content_length_still_ok() { // Regression: the ordinary single-CL path is unchanged. let req = b"POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\n\r\nhello"; let head = parse_head(req, 64).unwrap(); assert_eq!(head.content_length, Some(5)); } #[test] fn serialise_basic_200() { let conn = Conn::new().put_status(200).put_body("hi"); let bytes = serialise_response(&conn, true); let s = std::str::from_utf8(&bytes).unwrap(); assert!(s.starts_with("HTTP/1.1 200 OK\r\n")); assert!(s.contains("content-length: 2")); assert!(s.ends_with("\r\n\r\nhi")); } #[test] fn serialise_connection_close_when_not_keepalive() { let conn = Conn::new().put_status(204); let bytes = serialise_response(&conn, false); let s = std::str::from_utf8(&bytes).unwrap(); assert!(s.contains("connection: close")); } #[test] fn serialise_stream_http11_is_chunked_no_content_length() { let (_tx, rx) = smarm::channel::>(); let conn = Conn::new().put_status(200).put_body(RespBody::from(rx)); let bytes = serialise_response(&conn, true); let s = std::str::from_utf8(&bytes).unwrap(); assert!(s.contains("transfer-encoding: chunked")); assert!(!s.contains("content-length")); assert!(s.ends_with("\r\n\r\n")); // head only, no body bytes } #[test] fn serialise_stream_strips_user_framing_headers() { let (_tx, rx) = smarm::channel::>(); let conn = Conn::new().put_status(200) .put_header("content-length", "999") .put_header("transfer-encoding", "gzip") .put_body(RespBody::from(rx)); let bytes = serialise_response(&conn, true); let s = std::str::from_utf8(&bytes).unwrap(); assert!(!s.contains("content-length")); assert!(!s.contains("gzip")); assert!(s.contains("transfer-encoding: chunked")); } #[test] fn serialise_stream_http10_no_te_and_closes() { let (_tx, rx) = smarm::channel::>(); let mut conn = Conn::new().put_status(200).put_body(RespBody::from(rx)); conn.version = HttpVersion::Http10; // The conn actor forces keep_alive=false for a 1.0 stream. let bytes = serialise_response(&conn, false); let s = std::str::from_utf8(&bytes).unwrap(); assert!(!s.contains("transfer-encoding")); assert!(!s.contains("content-length")); assert!(s.contains("connection: close")); } #[test] fn serialise_http10_keepalive_is_echoed() { let mut conn = Conn::new().put_status(200).put_body("hi"); conn.version = HttpVersion::Http10; let bytes = serialise_response(&conn, true); let s = std::str::from_utf8(&bytes).unwrap(); assert!(s.contains("connection: keep-alive"), "head: {s}"); } #[test] fn serialise_http11_keepalive_stays_implicit() { let conn = Conn::new().put_status(200).put_body("hi"); let bytes = serialise_response(&conn, true); let s = std::str::from_utf8(&bytes).unwrap(); assert!(!s.contains("connection:"), "head: {s}"); } #[test] fn serialise_user_content_length_is_respected() { let conn = Conn::new().put_status(200) .put_header("content-length", "999") .put_body("hi"); // mismatch on purpose — user wins let bytes = serialise_response(&conn, true); let s = std::str::from_utf8(&bytes).unwrap(); assert!(s.contains("content-length: 999")); assert!(!s.contains("content-length: 2")); } }