380 lines
13 KiB
Rust
380 lines
13 KiB
Rust
//! 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.
|
|
//!
|
|
//! v1 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.
|
|
|
|
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 yet (e.g. chunked encoding).
|
|
/// 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<String>,
|
|
pub version: HttpVersion,
|
|
pub headers: HeaderMap,
|
|
pub content_length: Option<usize>,
|
|
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<ParsedHead, ParseError> {
|
|
// 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;
|
|
|
|
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" => {
|
|
content_length = Some(
|
|
value.trim()
|
|
.parse::<usize>()
|
|
.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;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
headers.append(&name_lower, value.to_string());
|
|
}
|
|
|
|
if chunked {
|
|
return Err(ParseError::Unsupported);
|
|
}
|
|
|
|
// 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,
|
|
keep_alive,
|
|
expect_100,
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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:
|
|
// <version> <status> <reason>\r\n
|
|
// <header>: <value>\r\n
|
|
// ...
|
|
// \r\n
|
|
// <body bytes>
|
|
//
|
|
// 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<u8> {
|
|
// Pre-size: status line ~30 + headers ~50/each + body. Good enough.
|
|
let body_len = conn.resp_body.len_hint();
|
|
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.
|
|
let mut wrote_content_length = false;
|
|
let mut wrote_connection = false;
|
|
|
|
for (name, value) in conn.resp_headers.iter() {
|
|
match name {
|
|
"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 !wrote_content_length {
|
|
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 && !keep_alive {
|
|
out.extend_from_slice(b"connection: close\r\n");
|
|
}
|
|
|
|
out.extend_from_slice(b"\r\n");
|
|
|
|
// Body.
|
|
match &conn.resp_body {
|
|
RespBody::Empty => {}
|
|
RespBody::Bytes(b) => out.extend_from_slice(b),
|
|
}
|
|
|
|
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_unsupported() {
|
|
let req = b"POST /a HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n";
|
|
match parse_head(req, 64) {
|
|
Err(ParseError::Unsupported) => {}
|
|
_ => panic!("expected Unsupported for chunked"),
|
|
}
|
|
}
|
|
|
|
#[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_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"));
|
|
}
|
|
}
|