The content-length arm ran content_length = Some(parse) per header, so a second Content-Length silently overwrote the first with no conflict check (CL.CL request smuggling; h1spec #21 -> 404 instead of 400). Count occurrences and reject any duplicate post-loop, strictly (even equal values), reusing BadContentLength (400). A single value is still required to be one decimal integer, so a comma-list or non-numeric keeps failing at parse as before. Tests: differing dup, equal dup, single-CL regression.
627 lines
23 KiB
Rust
627 lines
23 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.
|
|
//!
|
|
//! 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<String>,
|
|
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,
|
|
}
|
|
|
|
/// 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;
|
|
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::<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;
|
|
}
|
|
"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:
|
|
// <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 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::<Vec<u8>>();
|
|
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::<Vec<u8>>();
|
|
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::<Vec<u8>>();
|
|
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"));
|
|
}
|
|
}
|