we (web engine): Experimental web browser project to understand the limits of Claude

Implement HTTP/1.1 parser: request/response, headers, chunked encoding

HTTP/1.1 message parser (RFC 7230, 7231) for the net crate:
- Request serialization (GET, POST, HEAD, PUT, DELETE, OPTIONS, PATCH)
- Response parsing: status line, headers, body
- Headers collection with case-insensitive name lookup
- Content-Length body reading
- Chunked Transfer-Encoding decoding (RFC 7230 §4.1)
- Content-Type parsing with charset extraction
- Proper handling of 1xx/204/304 no-body responses

55 tests covering all acceptance criteria.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+1162
+1161
crates/net/src/http.rs
··· 1 + //! HTTP/1.1 message parser and request serializer (RFC 7230, 7231). 2 + //! 3 + //! Pure parsing — no I/O. Provides: 4 + //! - Request serialization (GET, POST, etc.) 5 + //! - Response parsing: status line, headers, body 6 + //! - Chunked transfer-encoding decoding 7 + //! - Content-Length body reading 8 + //! - Case-insensitive header collection 9 + //! - Content-Type parsing (MIME type + charset) 10 + 11 + use std::fmt; 12 + 13 + // --------------------------------------------------------------------------- 14 + // Constants 15 + // --------------------------------------------------------------------------- 16 + 17 + const HTTP_VERSION: &str = "HTTP/1.1"; 18 + const CRLF: &str = "\r\n"; 19 + const USER_AGENT: &str = "we-browser/0.1"; 20 + 21 + // --------------------------------------------------------------------------- 22 + // HTTP Method 23 + // --------------------------------------------------------------------------- 24 + 25 + /// HTTP request methods. 26 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 27 + pub enum Method { 28 + Get, 29 + Post, 30 + Head, 31 + Put, 32 + Delete, 33 + Options, 34 + Patch, 35 + } 36 + 37 + impl Method { 38 + /// Return the method name as a string. 39 + pub fn as_str(&self) -> &'static str { 40 + match self { 41 + Self::Get => "GET", 42 + Self::Post => "POST", 43 + Self::Head => "HEAD", 44 + Self::Put => "PUT", 45 + Self::Delete => "DELETE", 46 + Self::Options => "OPTIONS", 47 + Self::Patch => "PATCH", 48 + } 49 + } 50 + } 51 + 52 + impl fmt::Display for Method { 53 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 54 + f.write_str(self.as_str()) 55 + } 56 + } 57 + 58 + // --------------------------------------------------------------------------- 59 + // Headers 60 + // --------------------------------------------------------------------------- 61 + 62 + /// A collection of HTTP headers with case-insensitive name lookup. 63 + #[derive(Debug, Clone)] 64 + pub struct Headers { 65 + /// Stored as (original_name, value) pairs. 66 + entries: Vec<(String, String)>, 67 + } 68 + 69 + impl Headers { 70 + /// Create an empty header collection. 71 + pub fn new() -> Self { 72 + Self { 73 + entries: Vec::new(), 74 + } 75 + } 76 + 77 + /// Add a header. Does not replace existing headers with the same name. 78 + pub fn add(&mut self, name: &str, value: &str) { 79 + self.entries 80 + .push((name.to_string(), value.trim().to_string())); 81 + } 82 + 83 + /// Set a header, replacing any existing header with the same name. 84 + pub fn set(&mut self, name: &str, value: &str) { 85 + let lower = name.to_ascii_lowercase(); 86 + self.entries 87 + .retain(|(n, _)| n.to_ascii_lowercase() != lower); 88 + self.entries 89 + .push((name.to_string(), value.trim().to_string())); 90 + } 91 + 92 + /// Get the first value for a header name (case-insensitive). 93 + pub fn get(&self, name: &str) -> Option<&str> { 94 + let lower = name.to_ascii_lowercase(); 95 + self.entries 96 + .iter() 97 + .find(|(n, _)| n.to_ascii_lowercase() == lower) 98 + .map(|(_, v)| v.as_str()) 99 + } 100 + 101 + /// Get all values for a header name (case-insensitive). 102 + pub fn get_all(&self, name: &str) -> Vec<&str> { 103 + let lower = name.to_ascii_lowercase(); 104 + self.entries 105 + .iter() 106 + .filter(|(n, _)| n.to_ascii_lowercase() == lower) 107 + .map(|(_, v)| v.as_str()) 108 + .collect() 109 + } 110 + 111 + /// Check if a header exists (case-insensitive). 112 + pub fn contains(&self, name: &str) -> bool { 113 + let lower = name.to_ascii_lowercase(); 114 + self.entries 115 + .iter() 116 + .any(|(n, _)| n.to_ascii_lowercase() == lower) 117 + } 118 + 119 + /// Remove all headers with the given name (case-insensitive). 120 + pub fn remove(&mut self, name: &str) { 121 + let lower = name.to_ascii_lowercase(); 122 + self.entries 123 + .retain(|(n, _)| n.to_ascii_lowercase() != lower); 124 + } 125 + 126 + /// Return an iterator over all (name, value) pairs. 127 + pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> { 128 + self.entries.iter().map(|(n, v)| (n.as_str(), v.as_str())) 129 + } 130 + 131 + /// Return the number of header entries. 132 + pub fn len(&self) -> usize { 133 + self.entries.len() 134 + } 135 + 136 + /// Check if the collection is empty. 137 + pub fn is_empty(&self) -> bool { 138 + self.entries.is_empty() 139 + } 140 + } 141 + 142 + impl Default for Headers { 143 + fn default() -> Self { 144 + Self::new() 145 + } 146 + } 147 + 148 + // --------------------------------------------------------------------------- 149 + // Error types 150 + // --------------------------------------------------------------------------- 151 + 152 + /// HTTP parsing errors. 153 + #[derive(Debug)] 154 + pub enum HttpError { 155 + /// Response status line is malformed. 156 + MalformedStatusLine(String), 157 + /// Header line is malformed. 158 + MalformedHeader(String), 159 + /// Chunked encoding is malformed. 160 + MalformedChunkedEncoding(String), 161 + /// Content-Length value is invalid. 162 + InvalidContentLength(String), 163 + /// Response is incomplete (not enough data). 164 + Incomplete, 165 + /// A generic parse error. 166 + Parse(String), 167 + } 168 + 169 + impl fmt::Display for HttpError { 170 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 171 + match self { 172 + Self::MalformedStatusLine(s) => write!(f, "malformed status line: {s}"), 173 + Self::MalformedHeader(s) => write!(f, "malformed header: {s}"), 174 + Self::MalformedChunkedEncoding(s) => write!(f, "malformed chunked encoding: {s}"), 175 + Self::InvalidContentLength(s) => write!(f, "invalid Content-Length: {s}"), 176 + Self::Incomplete => write!(f, "incomplete HTTP response"), 177 + Self::Parse(s) => write!(f, "HTTP parse error: {s}"), 178 + } 179 + } 180 + } 181 + 182 + pub type Result<T> = std::result::Result<T, HttpError>; 183 + 184 + // --------------------------------------------------------------------------- 185 + // Content-Type 186 + // --------------------------------------------------------------------------- 187 + 188 + /// Parsed Content-Type header value. 189 + #[derive(Debug, Clone, PartialEq, Eq)] 190 + pub struct ContentType { 191 + /// The MIME type (e.g. "text/html"). 192 + pub mime_type: String, 193 + /// The charset parameter, if present (e.g. "utf-8"). 194 + pub charset: Option<String>, 195 + } 196 + 197 + impl ContentType { 198 + /// Parse a Content-Type header value. 199 + /// 200 + /// Handles formats like: 201 + /// - `text/html` 202 + /// - `text/html; charset=utf-8` 203 + /// - `text/html; charset="UTF-8"` 204 + pub fn parse(value: &str) -> Self { 205 + let mut parts = value.splitn(2, ';'); 206 + let mime_type = parts.next().unwrap_or("").trim().to_ascii_lowercase(); 207 + 208 + let charset = parts.next().and_then(|params| { 209 + // Look for charset= parameter 210 + for param in params.split(';') { 211 + let param = param.trim(); 212 + if let Some(val) = param 213 + .strip_prefix("charset=") 214 + .or_else(|| param.strip_prefix("CHARSET=")) 215 + .or_else(|| { 216 + // Case-insensitive match 217 + let lower = param.to_ascii_lowercase(); 218 + if lower.starts_with("charset=") { 219 + Some(&param["charset=".len()..]) 220 + } else { 221 + None 222 + } 223 + }) 224 + { 225 + let val = val.trim(); 226 + // Remove surrounding quotes if present 227 + let val = if val.starts_with('"') && val.ends_with('"') && val.len() >= 2 { 228 + &val[1..val.len() - 1] 229 + } else { 230 + val 231 + }; 232 + return Some(val.to_ascii_lowercase()); 233 + } 234 + } 235 + None 236 + }); 237 + 238 + ContentType { mime_type, charset } 239 + } 240 + } 241 + 242 + // --------------------------------------------------------------------------- 243 + // HTTP Request serialization 244 + // --------------------------------------------------------------------------- 245 + 246 + /// Serialize an HTTP/1.1 request to bytes. 247 + /// 248 + /// The caller provides the method, path, host, optional extra headers, and 249 + /// optional body. Required headers (Host, User-Agent, Connection) are added 250 + /// automatically if not present in the provided headers. 251 + pub fn serialize_request( 252 + method: Method, 253 + path: &str, 254 + host: &str, 255 + headers: &Headers, 256 + body: Option<&[u8]>, 257 + ) -> Vec<u8> { 258 + let mut buf = Vec::with_capacity(256); 259 + 260 + // Request line 261 + buf.extend_from_slice(method.as_str().as_bytes()); 262 + buf.push(b' '); 263 + buf.extend_from_slice(path.as_bytes()); 264 + buf.push(b' '); 265 + buf.extend_from_slice(HTTP_VERSION.as_bytes()); 266 + buf.extend_from_slice(CRLF.as_bytes()); 267 + 268 + // Host header (required) 269 + if !headers.contains("Host") { 270 + buf.extend_from_slice(b"Host: "); 271 + buf.extend_from_slice(host.as_bytes()); 272 + buf.extend_from_slice(CRLF.as_bytes()); 273 + } 274 + 275 + // User-Agent 276 + if !headers.contains("User-Agent") { 277 + buf.extend_from_slice(b"User-Agent: "); 278 + buf.extend_from_slice(USER_AGENT.as_bytes()); 279 + buf.extend_from_slice(CRLF.as_bytes()); 280 + } 281 + 282 + // Accept 283 + if !headers.contains("Accept") { 284 + buf.extend_from_slice(b"Accept: */*"); 285 + buf.extend_from_slice(CRLF.as_bytes()); 286 + } 287 + 288 + // Connection 289 + if !headers.contains("Connection") { 290 + buf.extend_from_slice(b"Connection: keep-alive"); 291 + buf.extend_from_slice(CRLF.as_bytes()); 292 + } 293 + 294 + // Content-Length for requests with a body 295 + if let Some(body_data) = body { 296 + if !headers.contains("Content-Length") { 297 + let len_str = body_data.len().to_string(); 298 + buf.extend_from_slice(b"Content-Length: "); 299 + buf.extend_from_slice(len_str.as_bytes()); 300 + buf.extend_from_slice(CRLF.as_bytes()); 301 + } 302 + } 303 + 304 + // User-provided headers 305 + for (name, value) in headers.iter() { 306 + buf.extend_from_slice(name.as_bytes()); 307 + buf.extend_from_slice(b": "); 308 + buf.extend_from_slice(value.as_bytes()); 309 + buf.extend_from_slice(CRLF.as_bytes()); 310 + } 311 + 312 + // End of headers 313 + buf.extend_from_slice(CRLF.as_bytes()); 314 + 315 + // Body 316 + if let Some(body_data) = body { 317 + buf.extend_from_slice(body_data); 318 + } 319 + 320 + buf 321 + } 322 + 323 + // --------------------------------------------------------------------------- 324 + // HTTP Response 325 + // --------------------------------------------------------------------------- 326 + 327 + /// A parsed HTTP/1.1 response. 328 + #[derive(Debug, Clone)] 329 + pub struct HttpResponse { 330 + /// HTTP version string (e.g. "HTTP/1.1"). 331 + pub version: String, 332 + /// Status code (e.g. 200, 404). 333 + pub status_code: u16, 334 + /// Reason phrase (e.g. "OK", "Not Found"). 335 + pub reason: String, 336 + /// Response headers. 337 + pub headers: Headers, 338 + /// Response body bytes. 339 + pub body: Vec<u8>, 340 + } 341 + 342 + impl HttpResponse { 343 + /// Get the Content-Type header, parsed. 344 + pub fn content_type(&self) -> Option<ContentType> { 345 + self.headers.get("Content-Type").map(ContentType::parse) 346 + } 347 + 348 + /// Check if the response indicates the connection should be closed. 349 + pub fn connection_close(&self) -> bool { 350 + self.headers 351 + .get("Connection") 352 + .map(|v| v.eq_ignore_ascii_case("close")) 353 + .unwrap_or(false) 354 + } 355 + 356 + /// Check if the response has no body (per RFC 7230). 357 + /// 1xx, 204, and 304 responses have no message body. 358 + pub fn has_no_body(&self) -> bool { 359 + self.status_code < 200 || self.status_code == 204 || self.status_code == 304 360 + } 361 + } 362 + 363 + // --------------------------------------------------------------------------- 364 + // Response parsing 365 + // --------------------------------------------------------------------------- 366 + 367 + /// Parse an HTTP/1.1 response from raw bytes. 368 + /// 369 + /// Parses the status line, headers, and body (handling both Content-Length 370 + /// and chunked Transfer-Encoding). 371 + pub fn parse_response(data: &[u8]) -> Result<HttpResponse> { 372 + let (header_section, body_start) = split_header_body(data)?; 373 + let header_str = std::str::from_utf8(header_section) 374 + .map_err(|_| HttpError::Parse("invalid UTF-8 in headers".to_string()))?; 375 + 376 + let mut lines = header_str.split("\r\n"); 377 + 378 + // Parse status line 379 + let status_line = lines.next().ok_or(HttpError::Incomplete)?; 380 + let (version, status_code, reason) = parse_status_line(status_line)?; 381 + 382 + // Parse headers 383 + let mut headers = Headers::new(); 384 + for line in lines { 385 + if line.is_empty() { 386 + break; 387 + } 388 + let (name, value) = parse_header_line(line)?; 389 + headers.add(name, value); 390 + } 391 + 392 + let body_data = &data[body_start..]; 393 + 394 + // Determine body based on status code 395 + let body = if status_code < 200 || status_code == 204 || status_code == 304 { 396 + Vec::new() 397 + } else { 398 + decode_body(body_data, &headers)? 399 + }; 400 + 401 + Ok(HttpResponse { 402 + version: version.to_string(), 403 + status_code, 404 + reason: reason.to_string(), 405 + headers, 406 + body, 407 + }) 408 + } 409 + 410 + /// Split the header section from the body, returning (header_bytes, body_start_offset). 411 + fn split_header_body(data: &[u8]) -> Result<(&[u8], usize)> { 412 + // Look for \r\n\r\n 413 + for i in 0..data.len().saturating_sub(3) { 414 + if &data[i..i + 4] == b"\r\n\r\n" { 415 + return Ok((&data[..i], i + 4)); 416 + } 417 + } 418 + Err(HttpError::Incomplete) 419 + } 420 + 421 + /// Parse a status line like "HTTP/1.1 200 OK". 422 + fn parse_status_line(line: &str) -> Result<(&str, u16, &str)> { 423 + // Split: version SP status-code SP reason-phrase 424 + let mut parts = line.splitn(3, ' '); 425 + 426 + let version = parts 427 + .next() 428 + .ok_or_else(|| HttpError::MalformedStatusLine(line.to_string()))?; 429 + 430 + if !version.starts_with("HTTP/") { 431 + return Err(HttpError::MalformedStatusLine(line.to_string())); 432 + } 433 + 434 + let status_str = parts 435 + .next() 436 + .ok_or_else(|| HttpError::MalformedStatusLine(line.to_string()))?; 437 + 438 + let status_code: u16 = status_str 439 + .parse() 440 + .map_err(|_| HttpError::MalformedStatusLine(line.to_string()))?; 441 + 442 + // Reason phrase may be empty (but usually isn't) 443 + let reason = parts.next().unwrap_or(""); 444 + 445 + Ok((version, status_code, reason)) 446 + } 447 + 448 + /// Parse a single header line like "Content-Type: text/html". 449 + fn parse_header_line(line: &str) -> Result<(&str, &str)> { 450 + let colon_pos = line 451 + .find(':') 452 + .ok_or_else(|| HttpError::MalformedHeader(line.to_string()))?; 453 + 454 + let name = &line[..colon_pos]; 455 + let value = line[colon_pos + 1..].trim(); 456 + 457 + // Header field names must not contain whitespace 458 + if name.contains(' ') || name.contains('\t') || name.is_empty() { 459 + return Err(HttpError::MalformedHeader(line.to_string())); 460 + } 461 + 462 + Ok((name, value)) 463 + } 464 + 465 + /// Decode the response body based on Transfer-Encoding and Content-Length headers. 466 + fn decode_body(body_data: &[u8], headers: &Headers) -> Result<Vec<u8>> { 467 + // Check for chunked transfer encoding 468 + if let Some(te) = headers.get("Transfer-Encoding") { 469 + if te.eq_ignore_ascii_case("chunked") { 470 + return decode_chunked(body_data); 471 + } 472 + } 473 + 474 + // Check for Content-Length 475 + if let Some(cl) = headers.get("Content-Length") { 476 + let length: usize = cl 477 + .trim() 478 + .parse() 479 + .map_err(|_| HttpError::InvalidContentLength(cl.to_string()))?; 480 + 481 + if body_data.len() < length { 482 + return Err(HttpError::Incomplete); 483 + } 484 + 485 + return Ok(body_data[..length].to_vec()); 486 + } 487 + 488 + // No Content-Length and not chunked: read all available data 489 + // (connection close signals end of body) 490 + Ok(body_data.to_vec()) 491 + } 492 + 493 + /// Decode a chunked transfer-encoded body. 494 + /// 495 + /// Format per RFC 7230 §4.1: 496 + /// ```text 497 + /// chunk-size (hex) CRLF 498 + /// chunk-data CRLF 499 + /// ... 500 + /// 0 CRLF 501 + /// CRLF 502 + /// ``` 503 + pub fn decode_chunked(data: &[u8]) -> Result<Vec<u8>> { 504 + let mut result = Vec::new(); 505 + let mut pos = 0; 506 + 507 + loop { 508 + // Find the end of the chunk-size line 509 + let line_end = find_crlf(data, pos).ok_or_else(|| { 510 + HttpError::MalformedChunkedEncoding("missing CRLF after chunk size".to_string()) 511 + })?; 512 + 513 + let size_line = std::str::from_utf8(&data[pos..line_end]).map_err(|_| { 514 + HttpError::MalformedChunkedEncoding("invalid UTF-8 in chunk size".to_string()) 515 + })?; 516 + 517 + // Chunk size may be followed by chunk-extensions (semicolon-delimited); 518 + // we only care about the hex size before any extension. 519 + let size_str = size_line.split(';').next().unwrap_or("").trim(); 520 + 521 + let chunk_size = usize::from_str_radix(size_str, 16).map_err(|_| { 522 + HttpError::MalformedChunkedEncoding(format!("invalid chunk size: {size_str}")) 523 + })?; 524 + 525 + pos = line_end + 2; // skip past CRLF 526 + 527 + if chunk_size == 0 { 528 + // Last chunk. Optional trailers follow, then final CRLF. 529 + break; 530 + } 531 + 532 + // Read chunk data 533 + if pos + chunk_size > data.len() { 534 + return Err(HttpError::MalformedChunkedEncoding( 535 + "chunk data truncated".to_string(), 536 + )); 537 + } 538 + 539 + result.extend_from_slice(&data[pos..pos + chunk_size]); 540 + pos += chunk_size; 541 + 542 + // Skip trailing CRLF after chunk data 543 + if pos + 2 > data.len() || data[pos] != b'\r' || data[pos + 1] != b'\n' { 544 + return Err(HttpError::MalformedChunkedEncoding( 545 + "missing CRLF after chunk data".to_string(), 546 + )); 547 + } 548 + pos += 2; 549 + } 550 + 551 + Ok(result) 552 + } 553 + 554 + /// Find the position of `\r\n` starting from `offset`. 555 + fn find_crlf(data: &[u8], offset: usize) -> Option<usize> { 556 + (offset..data.len().saturating_sub(1)).find(|&i| data[i] == b'\r' && data[i + 1] == b'\n') 557 + } 558 + 559 + // --------------------------------------------------------------------------- 560 + // Tests 561 + // --------------------------------------------------------------------------- 562 + 563 + #[cfg(test)] 564 + mod tests { 565 + use super::*; 566 + 567 + // -- Method tests -- 568 + 569 + #[test] 570 + fn method_as_str() { 571 + assert_eq!(Method::Get.as_str(), "GET"); 572 + assert_eq!(Method::Post.as_str(), "POST"); 573 + assert_eq!(Method::Head.as_str(), "HEAD"); 574 + assert_eq!(Method::Put.as_str(), "PUT"); 575 + assert_eq!(Method::Delete.as_str(), "DELETE"); 576 + assert_eq!(Method::Options.as_str(), "OPTIONS"); 577 + assert_eq!(Method::Patch.as_str(), "PATCH"); 578 + } 579 + 580 + #[test] 581 + fn method_display() { 582 + assert_eq!(format!("{}", Method::Get), "GET"); 583 + assert_eq!(format!("{}", Method::Post), "POST"); 584 + } 585 + 586 + // -- Headers tests -- 587 + 588 + #[test] 589 + fn headers_add_and_get() { 590 + let mut h = Headers::new(); 591 + h.add("Content-Type", "text/html"); 592 + assert_eq!(h.get("Content-Type"), Some("text/html")); 593 + assert_eq!(h.get("content-type"), Some("text/html")); 594 + assert_eq!(h.get("CONTENT-TYPE"), Some("text/html")); 595 + } 596 + 597 + #[test] 598 + fn headers_set_replaces() { 599 + let mut h = Headers::new(); 600 + h.add("X-Foo", "bar"); 601 + h.set("X-Foo", "baz"); 602 + assert_eq!(h.get("X-Foo"), Some("baz")); 603 + assert_eq!(h.len(), 1); 604 + } 605 + 606 + #[test] 607 + fn headers_get_all() { 608 + let mut h = Headers::new(); 609 + h.add("Set-Cookie", "a=1"); 610 + h.add("Set-Cookie", "b=2"); 611 + let vals = h.get_all("Set-Cookie"); 612 + assert_eq!(vals, vec!["a=1", "b=2"]); 613 + } 614 + 615 + #[test] 616 + fn headers_contains() { 617 + let mut h = Headers::new(); 618 + h.add("Host", "example.com"); 619 + assert!(h.contains("Host")); 620 + assert!(h.contains("host")); 621 + assert!(!h.contains("Accept")); 622 + } 623 + 624 + #[test] 625 + fn headers_remove() { 626 + let mut h = Headers::new(); 627 + h.add("X-Foo", "bar"); 628 + h.add("X-Foo", "baz"); 629 + h.add("X-Other", "val"); 630 + h.remove("X-Foo"); 631 + assert!(!h.contains("X-Foo")); 632 + assert_eq!(h.len(), 1); 633 + } 634 + 635 + #[test] 636 + fn headers_is_empty() { 637 + let h = Headers::new(); 638 + assert!(h.is_empty()); 639 + } 640 + 641 + #[test] 642 + fn headers_iter() { 643 + let mut h = Headers::new(); 644 + h.add("A", "1"); 645 + h.add("B", "2"); 646 + let pairs: Vec<_> = h.iter().collect(); 647 + assert_eq!(pairs, vec![("A", "1"), ("B", "2")]); 648 + } 649 + 650 + #[test] 651 + fn headers_missing_returns_none() { 652 + let h = Headers::new(); 653 + assert_eq!(h.get("NonExistent"), None); 654 + } 655 + 656 + #[test] 657 + fn headers_get_all_empty() { 658 + let h = Headers::new(); 659 + let vals = h.get_all("X-Missing"); 660 + assert!(vals.is_empty()); 661 + } 662 + 663 + #[test] 664 + fn headers_trims_values() { 665 + let mut h = Headers::new(); 666 + h.add("X-Foo", " bar "); 667 + assert_eq!(h.get("X-Foo"), Some("bar")); 668 + } 669 + 670 + // -- Content-Type parsing tests -- 671 + 672 + #[test] 673 + fn content_type_simple() { 674 + let ct = ContentType::parse("text/html"); 675 + assert_eq!(ct.mime_type, "text/html"); 676 + assert_eq!(ct.charset, None); 677 + } 678 + 679 + #[test] 680 + fn content_type_with_charset() { 681 + let ct = ContentType::parse("text/html; charset=utf-8"); 682 + assert_eq!(ct.mime_type, "text/html"); 683 + assert_eq!(ct.charset, Some("utf-8".to_string())); 684 + } 685 + 686 + #[test] 687 + fn content_type_charset_quoted() { 688 + let ct = ContentType::parse("text/html; charset=\"UTF-8\""); 689 + assert_eq!(ct.mime_type, "text/html"); 690 + assert_eq!(ct.charset, Some("utf-8".to_string())); 691 + } 692 + 693 + #[test] 694 + fn content_type_case_insensitive() { 695 + let ct = ContentType::parse("Text/HTML; Charset=ISO-8859-1"); 696 + assert_eq!(ct.mime_type, "text/html"); 697 + assert_eq!(ct.charset, Some("iso-8859-1".to_string())); 698 + } 699 + 700 + #[test] 701 + fn content_type_application_json() { 702 + let ct = ContentType::parse("application/json"); 703 + assert_eq!(ct.mime_type, "application/json"); 704 + assert_eq!(ct.charset, None); 705 + } 706 + 707 + #[test] 708 + fn content_type_extra_params() { 709 + let ct = ContentType::parse("text/html; charset=utf-8; boundary=something"); 710 + assert_eq!(ct.mime_type, "text/html"); 711 + assert_eq!(ct.charset, Some("utf-8".to_string())); 712 + } 713 + 714 + // -- Request serialization tests -- 715 + 716 + #[test] 717 + fn serialize_get_request() { 718 + let headers = Headers::new(); 719 + let req = serialize_request(Method::Get, "/", "example.com", &headers, None); 720 + let req_str = String::from_utf8(req).unwrap(); 721 + 722 + assert!(req_str.starts_with("GET / HTTP/1.1\r\n")); 723 + assert!(req_str.contains("Host: example.com\r\n")); 724 + assert!(req_str.contains("User-Agent: we-browser/0.1\r\n")); 725 + assert!(req_str.contains("Accept: */*\r\n")); 726 + assert!(req_str.contains("Connection: keep-alive\r\n")); 727 + assert!(req_str.ends_with("\r\n\r\n")); 728 + } 729 + 730 + #[test] 731 + fn serialize_post_request_with_body() { 732 + let mut headers = Headers::new(); 733 + headers.add("Content-Type", "application/json"); 734 + let body = b"{\"key\": \"value\"}"; 735 + let req = serialize_request( 736 + Method::Post, 737 + "/api", 738 + "api.example.com", 739 + &headers, 740 + Some(body), 741 + ); 742 + let req_str = String::from_utf8(req).unwrap(); 743 + 744 + assert!(req_str.starts_with("POST /api HTTP/1.1\r\n")); 745 + assert!(req_str.contains("Host: api.example.com\r\n")); 746 + assert!(req_str.contains("Content-Length: 16\r\n")); 747 + assert!(req_str.contains("Content-Type: application/json\r\n")); 748 + assert!(req_str.ends_with("{\"key\": \"value\"}")); 749 + } 750 + 751 + #[test] 752 + fn serialize_request_with_path() { 753 + let headers = Headers::new(); 754 + let req = serialize_request( 755 + Method::Get, 756 + "/path/to/resource?query=1", 757 + "example.com", 758 + &headers, 759 + None, 760 + ); 761 + let req_str = String::from_utf8(req).unwrap(); 762 + assert!(req_str.starts_with("GET /path/to/resource?query=1 HTTP/1.1\r\n")); 763 + } 764 + 765 + #[test] 766 + fn serialize_request_custom_headers_override() { 767 + let mut headers = Headers::new(); 768 + headers.add("Host", "custom.example.com"); 769 + headers.add("User-Agent", "custom-agent/1.0"); 770 + headers.add("Accept", "text/html"); 771 + headers.add("Connection", "close"); 772 + 773 + let req = serialize_request(Method::Get, "/", "example.com", &headers, None); 774 + let req_str = String::from_utf8(req).unwrap(); 775 + 776 + // Should use custom headers, not add defaults 777 + assert!(req_str.contains("Host: custom.example.com\r\n")); 778 + assert!(req_str.contains("User-Agent: custom-agent/1.0\r\n")); 779 + assert!(!req_str.contains("we-browser")); 780 + } 781 + 782 + #[test] 783 + fn serialize_head_request() { 784 + let headers = Headers::new(); 785 + let req = serialize_request(Method::Head, "/", "example.com", &headers, None); 786 + let req_str = String::from_utf8(req).unwrap(); 787 + assert!(req_str.starts_with("HEAD / HTTP/1.1\r\n")); 788 + } 789 + 790 + #[test] 791 + fn serialize_post_empty_body() { 792 + let mut headers = Headers::new(); 793 + headers.add("Content-Type", "text/plain"); 794 + let req = serialize_request(Method::Post, "/submit", "example.com", &headers, Some(b"")); 795 + let req_str = String::from_utf8(req).unwrap(); 796 + assert!(req_str.contains("Content-Length: 0\r\n")); 797 + } 798 + 799 + // -- Response parsing tests -- 800 + 801 + fn make_response(status: u16, reason: &str, headers: &[(&str, &str)], body: &[u8]) -> Vec<u8> { 802 + let mut buf = Vec::new(); 803 + buf.extend_from_slice(format!("HTTP/1.1 {status} {reason}\r\n").as_bytes()); 804 + for &(name, value) in headers { 805 + buf.extend_from_slice(format!("{name}: {value}\r\n").as_bytes()); 806 + } 807 + // Add Content-Length if not already present and not chunked 808 + let has_cl = headers 809 + .iter() 810 + .any(|(n, _)| n.eq_ignore_ascii_case("Content-Length")); 811 + let has_te = headers.iter().any(|(n, v)| { 812 + n.eq_ignore_ascii_case("Transfer-Encoding") && v.eq_ignore_ascii_case("chunked") 813 + }); 814 + if !has_cl && !has_te && !body.is_empty() { 815 + buf.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes()); 816 + } 817 + buf.extend_from_slice(b"\r\n"); 818 + buf.extend_from_slice(body); 819 + buf 820 + } 821 + 822 + #[test] 823 + fn parse_simple_200() { 824 + let data = make_response( 825 + 200, 826 + "OK", 827 + &[("Content-Type", "text/html")], 828 + b"<html></html>", 829 + ); 830 + let resp = parse_response(&data).unwrap(); 831 + assert_eq!(resp.version, "HTTP/1.1"); 832 + assert_eq!(resp.status_code, 200); 833 + assert_eq!(resp.reason, "OK"); 834 + assert_eq!(resp.headers.get("Content-Type"), Some("text/html")); 835 + assert_eq!(resp.body, b"<html></html>"); 836 + } 837 + 838 + #[test] 839 + fn parse_404_not_found() { 840 + let data = make_response(404, "Not Found", &[], b"not found"); 841 + let resp = parse_response(&data).unwrap(); 842 + assert_eq!(resp.status_code, 404); 843 + assert_eq!(resp.reason, "Not Found"); 844 + assert_eq!(resp.body, b"not found"); 845 + } 846 + 847 + #[test] 848 + fn parse_204_no_content() { 849 + let data = b"HTTP/1.1 204 No Content\r\nConnection: keep-alive\r\n\r\n"; 850 + let resp = parse_response(data).unwrap(); 851 + assert_eq!(resp.status_code, 204); 852 + assert!(resp.body.is_empty()); 853 + assert!(resp.has_no_body()); 854 + } 855 + 856 + #[test] 857 + fn parse_304_not_modified() { 858 + let data = b"HTTP/1.1 304 Not Modified\r\nETag: \"abc\"\r\n\r\n"; 859 + let resp = parse_response(data).unwrap(); 860 + assert_eq!(resp.status_code, 304); 861 + assert!(resp.body.is_empty()); 862 + assert!(resp.has_no_body()); 863 + } 864 + 865 + #[test] 866 + fn parse_100_continue() { 867 + let data = b"HTTP/1.1 100 Continue\r\n\r\n"; 868 + let resp = parse_response(data).unwrap(); 869 + assert_eq!(resp.status_code, 100); 870 + assert!(resp.body.is_empty()); 871 + assert!(resp.has_no_body()); 872 + } 873 + 874 + #[test] 875 + fn parse_content_length_body() { 876 + let body = b"Hello, World!"; 877 + let data = make_response(200, "OK", &[("Content-Length", "13")], body); 878 + let resp = parse_response(&data).unwrap(); 879 + assert_eq!(resp.body, body); 880 + } 881 + 882 + #[test] 883 + fn parse_zero_content_length() { 884 + let data = make_response(200, "OK", &[("Content-Length", "0")], b""); 885 + let resp = parse_response(&data).unwrap(); 886 + assert!(resp.body.is_empty()); 887 + } 888 + 889 + #[test] 890 + fn parse_chunked_body() { 891 + let mut data = Vec::new(); 892 + data.extend_from_slice(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"); 893 + data.extend_from_slice(b"5\r\nHello\r\n"); 894 + data.extend_from_slice(b"7\r\n, World\r\n"); 895 + data.extend_from_slice(b"0\r\n\r\n"); 896 + 897 + let resp = parse_response(&data).unwrap(); 898 + assert_eq!(resp.body, b"Hello, World"); 899 + } 900 + 901 + #[test] 902 + fn parse_chunked_with_extensions() { 903 + let mut data = Vec::new(); 904 + data.extend_from_slice(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"); 905 + data.extend_from_slice(b"5;ext=val\r\nHello\r\n"); 906 + data.extend_from_slice(b"0\r\n\r\n"); 907 + 908 + let resp = parse_response(&data).unwrap(); 909 + assert_eq!(resp.body, b"Hello"); 910 + } 911 + 912 + #[test] 913 + fn parse_multiple_headers_same_name() { 914 + let data = 915 + b"HTTP/1.1 200 OK\r\nSet-Cookie: a=1\r\nSet-Cookie: b=2\r\nContent-Length: 0\r\n\r\n"; 916 + let resp = parse_response(data).unwrap(); 917 + let cookies = resp.headers.get_all("Set-Cookie"); 918 + assert_eq!(cookies, vec!["a=1", "b=2"]); 919 + } 920 + 921 + #[test] 922 + fn parse_case_insensitive_headers() { 923 + let data = make_response(200, "OK", &[("content-type", "text/plain")], b"ok"); 924 + let resp = parse_response(&data).unwrap(); 925 + assert_eq!(resp.headers.get("Content-Type"), Some("text/plain")); 926 + assert_eq!(resp.headers.get("CONTENT-TYPE"), Some("text/plain")); 927 + } 928 + 929 + #[test] 930 + fn parse_connection_close() { 931 + let data = make_response(200, "OK", &[("Connection", "close")], b"bye"); 932 + let resp = parse_response(&data).unwrap(); 933 + assert!(resp.connection_close()); 934 + } 935 + 936 + #[test] 937 + fn parse_connection_keep_alive() { 938 + let data = make_response(200, "OK", &[("Connection", "keep-alive")], b"hi"); 939 + let resp = parse_response(&data).unwrap(); 940 + assert!(!resp.connection_close()); 941 + } 942 + 943 + #[test] 944 + fn parse_content_type_method() { 945 + let data = make_response( 946 + 200, 947 + "OK", 948 + &[("Content-Type", "text/html; charset=utf-8")], 949 + b"<html></html>", 950 + ); 951 + let resp = parse_response(&data).unwrap(); 952 + let ct = resp.content_type().unwrap(); 953 + assert_eq!(ct.mime_type, "text/html"); 954 + assert_eq!(ct.charset, Some("utf-8".to_string())); 955 + } 956 + 957 + #[test] 958 + fn parse_no_content_type() { 959 + let data = make_response(200, "OK", &[], b"data"); 960 + let resp = parse_response(&data).unwrap(); 961 + assert!(resp.content_type().is_none()); 962 + } 963 + 964 + // -- Status line edge cases -- 965 + 966 + #[test] 967 + fn parse_status_line_no_reason() { 968 + // Some servers send status lines without a reason phrase 969 + let data = b"HTTP/1.1 200 \r\nContent-Length: 0\r\n\r\n"; 970 + let resp = parse_response(data).unwrap(); 971 + assert_eq!(resp.status_code, 200); 972 + } 973 + 974 + #[test] 975 + fn parse_http10_response() { 976 + let data = b"HTTP/1.0 200 OK\r\nContent-Length: 2\r\n\r\nhi"; 977 + let resp = parse_response(data).unwrap(); 978 + assert_eq!(resp.version, "HTTP/1.0"); 979 + assert_eq!(resp.status_code, 200); 980 + assert_eq!(resp.body, b"hi"); 981 + } 982 + 983 + // -- Error cases -- 984 + 985 + #[test] 986 + fn parse_malformed_status_line() { 987 + let data = b"INVALID\r\n\r\n"; 988 + let result = parse_response(data); 989 + assert!(result.is_err()); 990 + } 991 + 992 + #[test] 993 + fn parse_malformed_status_code() { 994 + let data = b"HTTP/1.1 abc OK\r\n\r\n"; 995 + let result = parse_response(data); 996 + assert!(result.is_err()); 997 + } 998 + 999 + #[test] 1000 + fn parse_incomplete_response() { 1001 + // No \r\n\r\n terminator 1002 + let data = b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n"; 1003 + let result = parse_response(data); 1004 + assert!(matches!(result, Err(HttpError::Incomplete))); 1005 + } 1006 + 1007 + #[test] 1008 + fn parse_invalid_content_length() { 1009 + let data = b"HTTP/1.1 200 OK\r\nContent-Length: abc\r\n\r\ndata"; 1010 + let result = parse_response(data); 1011 + assert!(matches!(result, Err(HttpError::InvalidContentLength(_)))); 1012 + } 1013 + 1014 + #[test] 1015 + fn parse_malformed_header() { 1016 + let data = b"HTTP/1.1 200 OK\r\n bad header\r\n\r\n"; 1017 + let result = parse_response(data); 1018 + assert!(matches!(result, Err(HttpError::MalformedHeader(_)))); 1019 + } 1020 + 1021 + // -- Chunked encoding edge cases -- 1022 + 1023 + #[test] 1024 + fn decode_chunked_empty() { 1025 + let data = b"0\r\n\r\n"; 1026 + let result = decode_chunked(data).unwrap(); 1027 + assert!(result.is_empty()); 1028 + } 1029 + 1030 + #[test] 1031 + fn decode_chunked_single_chunk() { 1032 + let data = b"a\r\n0123456789\r\n0\r\n\r\n"; 1033 + let result = decode_chunked(data).unwrap(); 1034 + assert_eq!(result, b"0123456789"); 1035 + } 1036 + 1037 + #[test] 1038 + fn decode_chunked_multiple_chunks() { 1039 + let data = b"3\r\nabc\r\n3\r\ndef\r\n0\r\n\r\n"; 1040 + let result = decode_chunked(data).unwrap(); 1041 + assert_eq!(result, b"abcdef"); 1042 + } 1043 + 1044 + #[test] 1045 + fn decode_chunked_hex_upper() { 1046 + let data = b"A\r\n0123456789\r\n0\r\n\r\n"; 1047 + let result = decode_chunked(data).unwrap(); 1048 + assert_eq!(result, b"0123456789"); 1049 + } 1050 + 1051 + #[test] 1052 + fn decode_chunked_invalid_size() { 1053 + let data = b"xyz\r\ndata\r\n0\r\n\r\n"; 1054 + let result = decode_chunked(data); 1055 + assert!(result.is_err()); 1056 + } 1057 + 1058 + #[test] 1059 + fn decode_chunked_truncated() { 1060 + // Chunk says 10 bytes, but only 3 are provided 1061 + let data = b"a\r\nabc"; 1062 + let result = decode_chunked(data); 1063 + assert!(result.is_err()); 1064 + } 1065 + 1066 + // -- Error display tests -- 1067 + 1068 + #[test] 1069 + fn error_display_malformed_status() { 1070 + let e = HttpError::MalformedStatusLine("bad".to_string()); 1071 + assert_eq!(e.to_string(), "malformed status line: bad"); 1072 + } 1073 + 1074 + #[test] 1075 + fn error_display_malformed_header() { 1076 + let e = HttpError::MalformedHeader("no colon".to_string()); 1077 + assert_eq!(e.to_string(), "malformed header: no colon"); 1078 + } 1079 + 1080 + #[test] 1081 + fn error_display_malformed_chunked() { 1082 + let e = HttpError::MalformedChunkedEncoding("bad chunk".to_string()); 1083 + assert_eq!(e.to_string(), "malformed chunked encoding: bad chunk"); 1084 + } 1085 + 1086 + #[test] 1087 + fn error_display_invalid_content_length() { 1088 + let e = HttpError::InvalidContentLength("abc".to_string()); 1089 + assert_eq!(e.to_string(), "invalid Content-Length: abc"); 1090 + } 1091 + 1092 + #[test] 1093 + fn error_display_incomplete() { 1094 + let e = HttpError::Incomplete; 1095 + assert_eq!(e.to_string(), "incomplete HTTP response"); 1096 + } 1097 + 1098 + #[test] 1099 + fn error_display_parse() { 1100 + let e = HttpError::Parse("something went wrong".to_string()); 1101 + assert_eq!(e.to_string(), "HTTP parse error: something went wrong"); 1102 + } 1103 + 1104 + // -- Body without Content-Length or chunked -- 1105 + 1106 + #[test] 1107 + fn parse_body_no_content_length_no_chunked() { 1108 + // When neither Content-Length nor chunked is present, read all remaining data 1109 + let data = b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\nall the data"; 1110 + let resp = parse_response(data).unwrap(); 1111 + assert_eq!(resp.body, b"all the data"); 1112 + } 1113 + 1114 + // -- Large chunk size -- 1115 + 1116 + #[test] 1117 + fn decode_chunked_large_hex() { 1118 + // Use hex ff (255) 1119 + let mut data = Vec::new(); 1120 + data.extend_from_slice(b"ff\r\n"); 1121 + data.extend_from_slice(&[b'X'; 255]); 1122 + data.extend_from_slice(b"\r\n0\r\n\r\n"); 1123 + let result = decode_chunked(&data).unwrap(); 1124 + assert_eq!(result.len(), 255); 1125 + assert!(result.iter().all(|&b| b == b'X')); 1126 + } 1127 + 1128 + // -- HttpResponse methods -- 1129 + 1130 + #[test] 1131 + fn response_has_no_body_1xx() { 1132 + let resp = HttpResponse { 1133 + version: "HTTP/1.1".to_string(), 1134 + status_code: 101, 1135 + reason: "Switching Protocols".to_string(), 1136 + headers: Headers::new(), 1137 + body: Vec::new(), 1138 + }; 1139 + assert!(resp.has_no_body()); 1140 + } 1141 + 1142 + #[test] 1143 + fn response_has_body_200() { 1144 + let resp = HttpResponse { 1145 + version: "HTTP/1.1".to_string(), 1146 + status_code: 200, 1147 + reason: "OK".to_string(), 1148 + headers: Headers::new(), 1149 + body: Vec::new(), 1150 + }; 1151 + assert!(!resp.has_no_body()); 1152 + } 1153 + 1154 + // -- Default trait -- 1155 + 1156 + #[test] 1157 + fn headers_default() { 1158 + let h = Headers::default(); 1159 + assert!(h.is_empty()); 1160 + } 1161 + }
+1
crates/net/src/lib.rs
··· 1 1 //! TCP, DNS, pure-Rust TLS 1.3, HTTP/1.1, HTTP/2. 2 2 3 3 pub mod dns; 4 + pub mod http; 4 5 pub mod tcp; 5 6 pub mod tls;