Something went wrong. Try again.
we (web engine): Experimental web browser project to understand the limits of Claude
Something went wrong. Try again.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162//! HTTP/1.1 message parser and request serializer (RFC 7230, 7231).//!//! Pure parsing — no I/O. Provides://! - Request serialization (GET, POST, etc.)//! - Response parsing: status line, headers, body//! - Chunked transfer-encoding decoding//! - Content-Length body reading//! - Case-insensitive header collection//! - Content-Type parsing (MIME type + charset)
use std::fmt;
// ---------------------------------------------------------------------------// Constants// ---------------------------------------------------------------------------
const HTTP_VERSION: &str = "HTTP/1.1";const CRLF: &str = "\r\n";const USER_AGENT: &str = "we-browser/0.1";
// ---------------------------------------------------------------------------// HTTP Method// ---------------------------------------------------------------------------
/// HTTP request methods.#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub enum Method { Get, Post, Head, Put, Delete, Options, Patch,}
impl Method { /// Return the method name as a string. pub fn as_str(&self) -> &'static str { match self { Self::Get => "GET", Self::Post => "POST", Self::Head => "HEAD", Self::Put => "PUT", Self::Delete => "DELETE", Self::Options => "OPTIONS", Self::Patch => "PATCH", } }}
impl fmt::Display for Method { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) }}
// ---------------------------------------------------------------------------// Headers// ---------------------------------------------------------------------------
/// A collection of HTTP headers with case-insensitive name lookup.#[derive(Debug, Clone)]pub struct Headers { /// Stored as (original_name, value) pairs. entries: Vec<(String, String)>,}
impl Headers { /// Create an empty header collection. pub fn new() -> Self { Self { entries: Vec::new(), } }
/// Add a header. Does not replace existing headers with the same name. pub fn add(&mut self, name: &str, value: &str) { self.entries .push((name.to_string(), value.trim().to_string())); }
/// Set a header, replacing any existing header with the same name. pub fn set(&mut self, name: &str, value: &str) { let lower = name.to_ascii_lowercase(); self.entries .retain(|(n, _)| n.to_ascii_lowercase() != lower); self.entries .push((name.to_string(), value.trim().to_string())); }
/// Get the first value for a header name (case-insensitive). pub fn get(&self, name: &str) -> Option<&str> { let lower = name.to_ascii_lowercase(); self.entries .iter() .find(|(n, _)| n.to_ascii_lowercase() == lower) .map(|(_, v)| v.as_str()) }
/// Get all values for a header name (case-insensitive). pub fn get_all(&self, name: &str) -> Vec<&str> { let lower = name.to_ascii_lowercase(); self.entries .iter() .filter(|(n, _)| n.to_ascii_lowercase() == lower) .map(|(_, v)| v.as_str()) .collect() }
/// Check if a header exists (case-insensitive). pub fn contains(&self, name: &str) -> bool { let lower = name.to_ascii_lowercase(); self.entries .iter() .any(|(n, _)| n.to_ascii_lowercase() == lower) }
/// Remove all headers with the given name (case-insensitive). pub fn remove(&mut self, name: &str) { let lower = name.to_ascii_lowercase(); self.entries .retain(|(n, _)| n.to_ascii_lowercase() != lower); }
/// Return an iterator over all (name, value) pairs. pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> { self.entries.iter().map(|(n, v)| (n.as_str(), v.as_str())) }
/// Return the number of header entries. pub fn len(&self) -> usize { self.entries.len() }
/// Check if the collection is empty. pub fn is_empty(&self) -> bool { self.entries.is_empty() }}
impl Default for Headers { fn default() -> Self { Self::new() }}
// ---------------------------------------------------------------------------// Error types// ---------------------------------------------------------------------------
/// HTTP parsing errors.#[derive(Debug)]pub enum HttpError { /// Response status line is malformed. MalformedStatusLine(String), /// Header line is malformed. MalformedHeader(String), /// Chunked encoding is malformed. MalformedChunkedEncoding(String), /// Content-Length value is invalid. InvalidContentLength(String), /// Response is incomplete (not enough data). Incomplete, /// A generic parse error. Parse(String),}
impl fmt::Display for HttpError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::MalformedStatusLine(s) => write!(f, "malformed status line: {s}"), Self::MalformedHeader(s) => write!(f, "malformed header: {s}"), Self::MalformedChunkedEncoding(s) => write!(f, "malformed chunked encoding: {s}"), Self::InvalidContentLength(s) => write!(f, "invalid Content-Length: {s}"), Self::Incomplete => write!(f, "incomplete HTTP response"), Self::Parse(s) => write!(f, "HTTP parse error: {s}"), } }}
pub type Result<T> = std::result::Result<T, HttpError>;
// ---------------------------------------------------------------------------// Content-Type// ---------------------------------------------------------------------------
/// Parsed Content-Type header value.#[derive(Debug, Clone, PartialEq, Eq)]pub struct ContentType { /// The MIME type (e.g. "text/html"). pub mime_type: String, /// The charset parameter, if present (e.g. "utf-8"). pub charset: Option<String>,}
impl ContentType { /// Parse a Content-Type header value. /// /// Handles formats like: /// - `text/html` /// - `text/html; charset=utf-8` /// - `text/html; charset="UTF-8"` pub fn parse(value: &str) -> Self { let mut parts = value.splitn(2, ';'); let mime_type = parts.next().unwrap_or("").trim().to_ascii_lowercase();
let charset = parts.next().and_then(|params| { // Look for charset= parameter for param in params.split(';') { let param = param.trim(); if let Some(val) = param .strip_prefix("charset=") .or_else(|| param.strip_prefix("CHARSET=")) .or_else(|| { // Case-insensitive match let lower = param.to_ascii_lowercase(); if lower.starts_with("charset=") { Some(¶m["charset=".len()..]) } else { None } }) { let val = val.trim(); // Remove surrounding quotes if present let val = if val.starts_with('"') && val.ends_with('"') && val.len() >= 2 { &val[1..val.len() - 1] } else { val }; return Some(val.to_ascii_lowercase()); } } None });
ContentType { mime_type, charset } }}
// ---------------------------------------------------------------------------// HTTP Request serialization// ---------------------------------------------------------------------------
/// Serialize an HTTP/1.1 request to bytes.////// The caller provides the method, path, host, optional extra headers, and/// optional body. Required headers (Host, User-Agent, Connection) are added/// automatically if not present in the provided headers.pub fn serialize_request( method: Method, path: &str, host: &str, headers: &Headers, body: Option<&[u8]>,) -> Vec<u8> { let mut buf = Vec::with_capacity(256);
// Request line buf.extend_from_slice(method.as_str().as_bytes()); buf.push(b' '); buf.extend_from_slice(path.as_bytes()); buf.push(b' '); buf.extend_from_slice(HTTP_VERSION.as_bytes()); buf.extend_from_slice(CRLF.as_bytes());
// Host header (required) if !headers.contains("Host") { buf.extend_from_slice(b"Host: "); buf.extend_from_slice(host.as_bytes()); buf.extend_from_slice(CRLF.as_bytes()); }
// User-Agent if !headers.contains("User-Agent") { buf.extend_from_slice(b"User-Agent: "); buf.extend_from_slice(USER_AGENT.as_bytes()); buf.extend_from_slice(CRLF.as_bytes()); }
// Accept if !headers.contains("Accept") { buf.extend_from_slice(b"Accept: */*"); buf.extend_from_slice(CRLF.as_bytes()); }
// Connection if !headers.contains("Connection") { buf.extend_from_slice(b"Connection: keep-alive"); buf.extend_from_slice(CRLF.as_bytes()); }
// Content-Length for requests with a body if let Some(body_data) = body { if !headers.contains("Content-Length") { let len_str = body_data.len().to_string(); buf.extend_from_slice(b"Content-Length: "); buf.extend_from_slice(len_str.as_bytes()); buf.extend_from_slice(CRLF.as_bytes()); } }
// User-provided headers for (name, value) in headers.iter() { buf.extend_from_slice(name.as_bytes()); buf.extend_from_slice(b": "); buf.extend_from_slice(value.as_bytes()); buf.extend_from_slice(CRLF.as_bytes()); }
// End of headers buf.extend_from_slice(CRLF.as_bytes());
// Body if let Some(body_data) = body { buf.extend_from_slice(body_data); }
buf}
// ---------------------------------------------------------------------------// HTTP Response// ---------------------------------------------------------------------------
/// A parsed HTTP/1.1 response.#[derive(Debug, Clone)]pub struct HttpResponse { /// HTTP version string (e.g. "HTTP/1.1"). pub version: String, /// Status code (e.g. 200, 404). pub status_code: u16, /// Reason phrase (e.g. "OK", "Not Found"). pub reason: String, /// Response headers. pub headers: Headers, /// Response body bytes. pub body: Vec<u8>,}
impl HttpResponse { /// Get the Content-Type header, parsed. pub fn content_type(&self) -> Option<ContentType> { self.headers.get("Content-Type").map(ContentType::parse) }
/// Check if the response indicates the connection should be closed. pub fn connection_close(&self) -> bool { self.headers .get("Connection") .map(|v| v.eq_ignore_ascii_case("close")) .unwrap_or(false) }
/// Check if the response has no body (per RFC 7230). /// 1xx, 204, and 304 responses have no message body. pub fn has_no_body(&self) -> bool { self.status_code < 200 || self.status_code == 204 || self.status_code == 304 }}
// ---------------------------------------------------------------------------// Response parsing// ---------------------------------------------------------------------------
/// Parse an HTTP/1.1 response from raw bytes.////// Parses the status line, headers, and body (handling both Content-Length/// and chunked Transfer-Encoding).pub fn parse_response(data: &[u8]) -> Result<HttpResponse> { let (header_section, body_start) = split_header_body(data)?; let header_str = std::str::from_utf8(header_section) .map_err(|_| HttpError::Parse("invalid UTF-8 in headers".to_string()))?;
let mut lines = header_str.split("\r\n");
// Parse status line let status_line = lines.next().ok_or(HttpError::Incomplete)?; let (version, status_code, reason) = parse_status_line(status_line)?;
// Parse headers let mut headers = Headers::new(); for line in lines { if line.is_empty() { break; } let (name, value) = parse_header_line(line)?; headers.add(name, value); }
let body_data = &data[body_start..];
// Determine body based on status code let body = if status_code < 200 || status_code == 204 || status_code == 304 { Vec::new() } else { decode_body(body_data, &headers)? };
Ok(HttpResponse { version: version.to_string(), status_code, reason: reason.to_string(), headers, body, })}
/// Split the header section from the body, returning (header_bytes, body_start_offset).fn split_header_body(data: &[u8]) -> Result<(&[u8], usize)> { // Look for \r\n\r\n for i in 0..data.len().saturating_sub(3) { if &data[i..i + 4] == b"\r\n\r\n" { return Ok((&data[..i], i + 4)); } } Err(HttpError::Incomplete)}
/// Parse a status line like "HTTP/1.1 200 OK".fn parse_status_line(line: &str) -> Result<(&str, u16, &str)> { // Split: version SP status-code SP reason-phrase let mut parts = line.splitn(3, ' ');
let version = parts .next() .ok_or_else(|| HttpError::MalformedStatusLine(line.to_string()))?;
if !version.starts_with("HTTP/") { return Err(HttpError::MalformedStatusLine(line.to_string())); }
let status_str = parts .next() .ok_or_else(|| HttpError::MalformedStatusLine(line.to_string()))?;
let status_code: u16 = status_str .parse() .map_err(|_| HttpError::MalformedStatusLine(line.to_string()))?;
// Reason phrase may be empty (but usually isn't) let reason = parts.next().unwrap_or("");
Ok((version, status_code, reason))}
/// Parse a single header line like "Content-Type: text/html".fn parse_header_line(line: &str) -> Result<(&str, &str)> { let colon_pos = line .find(':') .ok_or_else(|| HttpError::MalformedHeader(line.to_string()))?;
let name = &line[..colon_pos]; let value = line[colon_pos + 1..].trim();
// Header field names must not contain whitespace if name.contains(' ') || name.contains('\t') || name.is_empty() { return Err(HttpError::MalformedHeader(line.to_string())); }
Ok((name, value))}
/// Decode the response body based on Transfer-Encoding and Content-Length headers.fn decode_body(body_data: &[u8], headers: &Headers) -> Result<Vec<u8>> { // Check for chunked transfer encoding if let Some(te) = headers.get("Transfer-Encoding") { if te.eq_ignore_ascii_case("chunked") { return decode_chunked(body_data); } }
// Check for Content-Length if let Some(cl) = headers.get("Content-Length") { let length: usize = cl .trim() .parse() .map_err(|_| HttpError::InvalidContentLength(cl.to_string()))?;
if body_data.len() < length { return Err(HttpError::Incomplete); }
return Ok(body_data[..length].to_vec()); }
// No Content-Length and not chunked: read all available data // (connection close signals end of body) Ok(body_data.to_vec())}
/// Decode a chunked transfer-encoded body.////// Format per RFC 7230 §4.1:/// ```text/// chunk-size (hex) CRLF/// chunk-data CRLF/// .../// 0 CRLF/// CRLF/// ```pub fn decode_chunked(data: &[u8]) -> Result<Vec<u8>> { let mut result = Vec::new(); let mut pos = 0;
loop { // Find the end of the chunk-size line let line_end = find_crlf(data, pos).ok_or_else(|| { HttpError::MalformedChunkedEncoding("missing CRLF after chunk size".to_string()) })?;
let size_line = std::str::from_utf8(&data[pos..line_end]).map_err(|_| { HttpError::MalformedChunkedEncoding("invalid UTF-8 in chunk size".to_string()) })?;
// Chunk size may be followed by chunk-extensions (semicolon-delimited); // we only care about the hex size before any extension. let size_str = size_line.split(';').next().unwrap_or("").trim();
let chunk_size = usize::from_str_radix(size_str, 16).map_err(|_| { HttpError::MalformedChunkedEncoding(format!("invalid chunk size: {size_str}")) })?;
pos = line_end + 2; // skip past CRLF
if chunk_size == 0 { // Last chunk. Optional trailers follow, then final CRLF. break; }
// Read chunk data if pos + chunk_size > data.len() { return Err(HttpError::MalformedChunkedEncoding( "chunk data truncated".to_string(), )); }
result.extend_from_slice(&data[pos..pos + chunk_size]); pos += chunk_size;
// Skip trailing CRLF after chunk data if pos + 2 > data.len() || data[pos] != b'\r' || data[pos + 1] != b'\n' { return Err(HttpError::MalformedChunkedEncoding( "missing CRLF after chunk data".to_string(), )); } pos += 2; }
Ok(result)}
/// Find the position of `\r\n` starting from `offset`.fn find_crlf(data: &[u8], offset: usize) -> Option<usize> { (offset..data.len().saturating_sub(1)).find(|&i| data[i] == b'\r' && data[i + 1] == b'\n')}
// ---------------------------------------------------------------------------// Tests// ---------------------------------------------------------------------------
#[cfg(test)]mod tests { use super::*;
// -- Method tests --
#[test] fn method_as_str() { assert_eq!(Method::Get.as_str(), "GET"); assert_eq!(Method::Post.as_str(), "POST"); assert_eq!(Method::Head.as_str(), "HEAD"); assert_eq!(Method::Put.as_str(), "PUT"); assert_eq!(Method::Delete.as_str(), "DELETE"); assert_eq!(Method::Options.as_str(), "OPTIONS"); assert_eq!(Method::Patch.as_str(), "PATCH"); }
#[test] fn method_display() { assert_eq!(format!("{}", Method::Get), "GET"); assert_eq!(format!("{}", Method::Post), "POST"); }
// -- Headers tests --
#[test] fn headers_add_and_get() { let mut h = Headers::new(); h.add("Content-Type", "text/html"); assert_eq!(h.get("Content-Type"), Some("text/html")); assert_eq!(h.get("content-type"), Some("text/html")); assert_eq!(h.get("CONTENT-TYPE"), Some("text/html")); }
#[test] fn headers_set_replaces() { let mut h = Headers::new(); h.add("X-Foo", "bar"); h.set("X-Foo", "baz"); assert_eq!(h.get("X-Foo"), Some("baz")); assert_eq!(h.len(), 1); }
#[test] fn headers_get_all() { let mut h = Headers::new(); h.add("Set-Cookie", "a=1"); h.add("Set-Cookie", "b=2"); let vals = h.get_all("Set-Cookie"); assert_eq!(vals, vec!["a=1", "b=2"]); }
#[test] fn headers_contains() { let mut h = Headers::new(); h.add("Host", "example.com"); assert!(h.contains("Host")); assert!(h.contains("host")); assert!(!h.contains("Accept")); }
#[test] fn headers_remove() { let mut h = Headers::new(); h.add("X-Foo", "bar"); h.add("X-Foo", "baz"); h.add("X-Other", "val"); h.remove("X-Foo"); assert!(!h.contains("X-Foo")); assert_eq!(h.len(), 1); }
#[test] fn headers_is_empty() { let h = Headers::new(); assert!(h.is_empty()); }
#[test] fn headers_iter() { let mut h = Headers::new(); h.add("A", "1"); h.add("B", "2"); let pairs: Vec<_> = h.iter().collect(); assert_eq!(pairs, vec![("A", "1"), ("B", "2")]); }
#[test] fn headers_missing_returns_none() { let h = Headers::new(); assert_eq!(h.get("NonExistent"), None); }
#[test] fn headers_get_all_empty() { let h = Headers::new(); let vals = h.get_all("X-Missing"); assert!(vals.is_empty()); }
#[test] fn headers_trims_values() { let mut h = Headers::new(); h.add("X-Foo", " bar "); assert_eq!(h.get("X-Foo"), Some("bar")); }
// -- Content-Type parsing tests --
#[test] fn content_type_simple() { let ct = ContentType::parse("text/html"); assert_eq!(ct.mime_type, "text/html"); assert_eq!(ct.charset, None); }
#[test] fn content_type_with_charset() { let ct = ContentType::parse("text/html; charset=utf-8"); assert_eq!(ct.mime_type, "text/html"); assert_eq!(ct.charset, Some("utf-8".to_string())); }
#[test] fn content_type_charset_quoted() { let ct = ContentType::parse("text/html; charset=\"UTF-8\""); assert_eq!(ct.mime_type, "text/html"); assert_eq!(ct.charset, Some("utf-8".to_string())); }
#[test] fn content_type_case_insensitive() { let ct = ContentType::parse("Text/HTML; Charset=ISO-8859-1"); assert_eq!(ct.mime_type, "text/html"); assert_eq!(ct.charset, Some("iso-8859-1".to_string())); }
#[test] fn content_type_application_json() { let ct = ContentType::parse("application/json"); assert_eq!(ct.mime_type, "application/json"); assert_eq!(ct.charset, None); }
#[test] fn content_type_extra_params() { let ct = ContentType::parse("text/html; charset=utf-8; boundary=something"); assert_eq!(ct.mime_type, "text/html"); assert_eq!(ct.charset, Some("utf-8".to_string())); }
// -- Request serialization tests --
#[test] fn serialize_get_request() { let headers = Headers::new(); let req = serialize_request(Method::Get, "/", "example.com", &headers, None); let req_str = String::from_utf8(req).unwrap();
assert!(req_str.starts_with("GET / HTTP/1.1\r\n")); assert!(req_str.contains("Host: example.com\r\n")); assert!(req_str.contains("User-Agent: we-browser/0.1\r\n")); assert!(req_str.contains("Accept: */*\r\n")); assert!(req_str.contains("Connection: keep-alive\r\n")); assert!(req_str.ends_with("\r\n\r\n")); }
#[test] fn serialize_post_request_with_body() { let mut headers = Headers::new(); headers.add("Content-Type", "application/json"); let body = b"{\"key\": \"value\"}"; let req = serialize_request( Method::Post, "/api", "api.example.com", &headers, Some(body), ); let req_str = String::from_utf8(req).unwrap();
assert!(req_str.starts_with("POST /api HTTP/1.1\r\n")); assert!(req_str.contains("Host: api.example.com\r\n")); assert!(req_str.contains("Content-Length: 16\r\n")); assert!(req_str.contains("Content-Type: application/json\r\n")); assert!(req_str.ends_with("{\"key\": \"value\"}")); }
#[test] fn serialize_request_with_path() { let headers = Headers::new(); let req = serialize_request( Method::Get, "/path/to/resource?query=1", "example.com", &headers, None, ); let req_str = String::from_utf8(req).unwrap(); assert!(req_str.starts_with("GET /path/to/resource?query=1 HTTP/1.1\r\n")); }
#[test] fn serialize_request_custom_headers_override() { let mut headers = Headers::new(); headers.add("Host", "custom.example.com"); headers.add("User-Agent", "custom-agent/1.0"); headers.add("Accept", "text/html"); headers.add("Connection", "close");
let req = serialize_request(Method::Get, "/", "example.com", &headers, None); let req_str = String::from_utf8(req).unwrap();
// Should use custom headers, not add defaults assert!(req_str.contains("Host: custom.example.com\r\n")); assert!(req_str.contains("User-Agent: custom-agent/1.0\r\n")); assert!(!req_str.contains("we-browser")); }
#[test] fn serialize_head_request() { let headers = Headers::new(); let req = serialize_request(Method::Head, "/", "example.com", &headers, None); let req_str = String::from_utf8(req).unwrap(); assert!(req_str.starts_with("HEAD / HTTP/1.1\r\n")); }
#[test] fn serialize_post_empty_body() { let mut headers = Headers::new(); headers.add("Content-Type", "text/plain"); let req = serialize_request(Method::Post, "/submit", "example.com", &headers, Some(b"")); let req_str = String::from_utf8(req).unwrap(); assert!(req_str.contains("Content-Length: 0\r\n")); }
// -- Response parsing tests --
fn make_response(status: u16, reason: &str, headers: &[(&str, &str)], body: &[u8]) -> Vec<u8> { let mut buf = Vec::new(); buf.extend_from_slice(format!("HTTP/1.1 {status} {reason}\r\n").as_bytes()); for &(name, value) in headers { buf.extend_from_slice(format!("{name}: {value}\r\n").as_bytes()); } // Add Content-Length if not already present and not chunked let has_cl = headers .iter() .any(|(n, _)| n.eq_ignore_ascii_case("Content-Length")); let has_te = headers.iter().any(|(n, v)| { n.eq_ignore_ascii_case("Transfer-Encoding") && v.eq_ignore_ascii_case("chunked") }); if !has_cl && !has_te && !body.is_empty() { buf.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes()); } buf.extend_from_slice(b"\r\n"); buf.extend_from_slice(body); buf }
#[test] fn parse_simple_200() { let data = make_response( 200, "OK", &[("Content-Type", "text/html")], b"<html></html>", ); let resp = parse_response(&data).unwrap(); assert_eq!(resp.version, "HTTP/1.1"); assert_eq!(resp.status_code, 200); assert_eq!(resp.reason, "OK"); assert_eq!(resp.headers.get("Content-Type"), Some("text/html")); assert_eq!(resp.body, b"<html></html>"); }
#[test] fn parse_404_not_found() { let data = make_response(404, "Not Found", &[], b"not found"); let resp = parse_response(&data).unwrap(); assert_eq!(resp.status_code, 404); assert_eq!(resp.reason, "Not Found"); assert_eq!(resp.body, b"not found"); }
#[test] fn parse_204_no_content() { let data = b"HTTP/1.1 204 No Content\r\nConnection: keep-alive\r\n\r\n"; let resp = parse_response(data).unwrap(); assert_eq!(resp.status_code, 204); assert!(resp.body.is_empty()); assert!(resp.has_no_body()); }
#[test] fn parse_304_not_modified() { let data = b"HTTP/1.1 304 Not Modified\r\nETag: \"abc\"\r\n\r\n"; let resp = parse_response(data).unwrap(); assert_eq!(resp.status_code, 304); assert!(resp.body.is_empty()); assert!(resp.has_no_body()); }
#[test] fn parse_100_continue() { let data = b"HTTP/1.1 100 Continue\r\n\r\n"; let resp = parse_response(data).unwrap(); assert_eq!(resp.status_code, 100); assert!(resp.body.is_empty()); assert!(resp.has_no_body()); }
#[test] fn parse_content_length_body() { let body = b"Hello, World!"; let data = make_response(200, "OK", &[("Content-Length", "13")], body); let resp = parse_response(&data).unwrap(); assert_eq!(resp.body, body); }
#[test] fn parse_zero_content_length() { let data = make_response(200, "OK", &[("Content-Length", "0")], b""); let resp = parse_response(&data).unwrap(); assert!(resp.body.is_empty()); }
#[test] fn parse_chunked_body() { let mut data = Vec::new(); data.extend_from_slice(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"); data.extend_from_slice(b"5\r\nHello\r\n"); data.extend_from_slice(b"7\r\n, World\r\n"); data.extend_from_slice(b"0\r\n\r\n");
let resp = parse_response(&data).unwrap(); assert_eq!(resp.body, b"Hello, World"); }
#[test] fn parse_chunked_with_extensions() { let mut data = Vec::new(); data.extend_from_slice(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"); data.extend_from_slice(b"5;ext=val\r\nHello\r\n"); data.extend_from_slice(b"0\r\n\r\n");
let resp = parse_response(&data).unwrap(); assert_eq!(resp.body, b"Hello"); }
#[test] fn parse_multiple_headers_same_name() { let data = b"HTTP/1.1 200 OK\r\nSet-Cookie: a=1\r\nSet-Cookie: b=2\r\nContent-Length: 0\r\n\r\n"; let resp = parse_response(data).unwrap(); let cookies = resp.headers.get_all("Set-Cookie"); assert_eq!(cookies, vec!["a=1", "b=2"]); }
#[test] fn parse_case_insensitive_headers() { let data = make_response(200, "OK", &[("content-type", "text/plain")], b"ok"); let resp = parse_response(&data).unwrap(); assert_eq!(resp.headers.get("Content-Type"), Some("text/plain")); assert_eq!(resp.headers.get("CONTENT-TYPE"), Some("text/plain")); }
#[test] fn parse_connection_close() { let data = make_response(200, "OK", &[("Connection", "close")], b"bye"); let resp = parse_response(&data).unwrap(); assert!(resp.connection_close()); }
#[test] fn parse_connection_keep_alive() { let data = make_response(200, "OK", &[("Connection", "keep-alive")], b"hi"); let resp = parse_response(&data).unwrap(); assert!(!resp.connection_close()); }
#[test] fn parse_content_type_method() { let data = make_response( 200, "OK", &[("Content-Type", "text/html; charset=utf-8")], b"<html></html>", ); let resp = parse_response(&data).unwrap(); let ct = resp.content_type().unwrap(); assert_eq!(ct.mime_type, "text/html"); assert_eq!(ct.charset, Some("utf-8".to_string())); }
#[test] fn parse_no_content_type() { let data = make_response(200, "OK", &[], b"data"); let resp = parse_response(&data).unwrap(); assert!(resp.content_type().is_none()); }
// -- Status line edge cases --
#[test] fn parse_status_line_no_reason() { // Some servers send status lines without a reason phrase let data = b"HTTP/1.1 200 \r\nContent-Length: 0\r\n\r\n"; let resp = parse_response(data).unwrap(); assert_eq!(resp.status_code, 200); }
#[test] fn parse_http10_response() { let data = b"HTTP/1.0 200 OK\r\nContent-Length: 2\r\n\r\nhi"; let resp = parse_response(data).unwrap(); assert_eq!(resp.version, "HTTP/1.0"); assert_eq!(resp.status_code, 200); assert_eq!(resp.body, b"hi"); }
// -- Error cases --
#[test] fn parse_malformed_status_line() { let data = b"INVALID\r\n\r\n"; let result = parse_response(data); assert!(result.is_err()); }
#[test] fn parse_malformed_status_code() { let data = b"HTTP/1.1 abc OK\r\n\r\n"; let result = parse_response(data); assert!(result.is_err()); }
#[test] fn parse_incomplete_response() { // No \r\n\r\n terminator let data = b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n"; let result = parse_response(data); assert!(matches!(result, Err(HttpError::Incomplete))); }
#[test] fn parse_invalid_content_length() { let data = b"HTTP/1.1 200 OK\r\nContent-Length: abc\r\n\r\ndata"; let result = parse_response(data); assert!(matches!(result, Err(HttpError::InvalidContentLength(_)))); }
#[test] fn parse_malformed_header() { let data = b"HTTP/1.1 200 OK\r\n bad header\r\n\r\n"; let result = parse_response(data); assert!(matches!(result, Err(HttpError::MalformedHeader(_)))); }
// -- Chunked encoding edge cases --
#[test] fn decode_chunked_empty() { let data = b"0\r\n\r\n"; let result = decode_chunked(data).unwrap(); assert!(result.is_empty()); }
#[test] fn decode_chunked_single_chunk() { let data = b"a\r\n0123456789\r\n0\r\n\r\n"; let result = decode_chunked(data).unwrap(); assert_eq!(result, b"0123456789"); }
#[test] fn decode_chunked_multiple_chunks() { let data = b"3\r\nabc\r\n3\r\ndef\r\n0\r\n\r\n"; let result = decode_chunked(data).unwrap(); assert_eq!(result, b"abcdef"); }
#[test] fn decode_chunked_hex_upper() { let data = b"A\r\n0123456789\r\n0\r\n\r\n"; let result = decode_chunked(data).unwrap(); assert_eq!(result, b"0123456789"); }
#[test] fn decode_chunked_invalid_size() { let data = b"xyz\r\ndata\r\n0\r\n\r\n"; let result = decode_chunked(data); assert!(result.is_err()); }
#[test] fn decode_chunked_truncated() { // Chunk says 10 bytes, but only 3 are provided let data = b"a\r\nabc"; let result = decode_chunked(data); assert!(result.is_err()); }
// -- Error display tests --
#[test] fn error_display_malformed_status() { let e = HttpError::MalformedStatusLine("bad".to_string()); assert_eq!(e.to_string(), "malformed status line: bad"); }
#[test] fn error_display_malformed_header() { let e = HttpError::MalformedHeader("no colon".to_string()); assert_eq!(e.to_string(), "malformed header: no colon"); }
#[test] fn error_display_malformed_chunked() { let e = HttpError::MalformedChunkedEncoding("bad chunk".to_string()); assert_eq!(e.to_string(), "malformed chunked encoding: bad chunk"); }
#[test] fn error_display_invalid_content_length() { let e = HttpError::InvalidContentLength("abc".to_string()); assert_eq!(e.to_string(), "invalid Content-Length: abc"); }
#[test] fn error_display_incomplete() { let e = HttpError::Incomplete; assert_eq!(e.to_string(), "incomplete HTTP response"); }
#[test] fn error_display_parse() { let e = HttpError::Parse("something went wrong".to_string()); assert_eq!(e.to_string(), "HTTP parse error: something went wrong"); }
// -- Body without Content-Length or chunked --
#[test] fn parse_body_no_content_length_no_chunked() { // When neither Content-Length nor chunked is present, read all remaining data let data = b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\nall the data"; let resp = parse_response(data).unwrap(); assert_eq!(resp.body, b"all the data"); }
// -- Large chunk size --
#[test] fn decode_chunked_large_hex() { // Use hex ff (255) let mut data = Vec::new(); data.extend_from_slice(b"ff\r\n"); data.extend_from_slice(&[b'X'; 255]); data.extend_from_slice(b"\r\n0\r\n\r\n"); let result = decode_chunked(&data).unwrap(); assert_eq!(result.len(), 255); assert!(result.iter().all(|&b| b == b'X')); }
// -- HttpResponse methods --
#[test] fn response_has_no_body_1xx() { let resp = HttpResponse { version: "HTTP/1.1".to_string(), status_code: 101, reason: "Switching Protocols".to_string(), headers: Headers::new(), body: Vec::new(), }; assert!(resp.has_no_body()); }
#[test] fn response_has_body_200() { let resp = HttpResponse { version: "HTTP/1.1".to_string(), status_code: 200, reason: "OK".to_string(), headers: Headers::new(), body: Vec::new(), }; assert!(!resp.has_no_body()); }
// -- Default trait --
#[test] fn headers_default() { let h = Headers::default(); assert!(h.is_empty()); }}