//! 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 { 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 = std::result::Result; // --------------------------------------------------------------------------- // 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, } 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 { 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, } impl HttpResponse { /// Get the Content-Type header, parsed. pub fn content_type(&self) -> Option { 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 { 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> { // 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> { 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 { (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 { 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"", ); 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""); } #[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"", ); 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()); } }