diff --git a/crates/net/src/http.rs b/crates/net/src/http.rs new file mode 100644 index 0000000..754999b --- /dev/null +++ b/crates/net/src/http.rs @@ -0,0 +1,1161 @@ +//! 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()); + } +} diff --git a/crates/net/src/lib.rs b/crates/net/src/lib.rs index b737548..e935e31 100644 --- a/crates/net/src/lib.rs +++ b/crates/net/src/lib.rs @@ -1,5 +1,6 @@ //! TCP, DNS, pure-Rust TLS 1.3, HTTP/1.1, HTTP/2. pub mod dns; +pub mod http; pub mod tcp; pub mod tls;