we (web engine): Experimental web browser project to understand the limits of Claude
at main 1399 lines 42 kB view raw
1//! High-level HTTP client with connection pooling and HTTP/2 support. 2//! 3//! Brings together TCP, TLS 1.3, DNS, URL parsing, HTTP/1.1, and HTTP/2 4//! into a single `HttpClient` that can fetch HTTP and HTTPS URLs. 5//! When connecting over TLS, ALPN negotiation automatically selects HTTP/2 6//! if the server supports it, falling back to HTTP/1.1 otherwise. 7 8use std::collections::HashMap; 9use std::fmt; 10use std::io; 11use std::time::{Duration, Instant}; 12 13use we_url::Url; 14 15use crate::cookie::{CookieJar, RequestContext}; 16use crate::http::{self, Headers, HttpResponse, Method}; 17use crate::http2::connection::Http2Connection; 18use crate::http2::frame::Http2Error; 19use crate::tcp::{self, TcpConnection}; 20use crate::tls::handshake::{self, HandshakeError, TlsStream}; 21 22// --------------------------------------------------------------------------- 23// Constants 24// --------------------------------------------------------------------------- 25 26const DEFAULT_MAX_REDIRECTS: u32 = 20; 27const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(30); 28const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(30); 29const DEFAULT_MAX_IDLE_TIME: Duration = Duration::from_secs(60); 30const DEFAULT_MAX_PER_HOST: usize = 6; 31const READ_BUF_SIZE: usize = 8192; 32 33// --------------------------------------------------------------------------- 34// Error type 35// --------------------------------------------------------------------------- 36 37/// Errors that can occur during an HTTP client operation. 38#[derive(Debug)] 39pub enum ClientError { 40 /// URL is invalid or missing required components. 41 InvalidUrl(String), 42 /// Unsupported URL scheme. 43 UnsupportedScheme(String), 44 /// TCP connection error. 45 Tcp(tcp::NetError), 46 /// TLS handshake error. 47 Tls(HandshakeError), 48 /// HTTP parsing error. 49 Http(http::HttpError), 50 /// Too many redirects. 51 TooManyRedirects, 52 /// Redirect loop detected. 53 RedirectLoop, 54 /// HTTP/2 protocol error. 55 Http2(Http2Error), 56 /// Connection was closed unexpectedly. 57 ConnectionClosed, 58 /// I/O error. 59 Io(io::Error), 60} 61 62impl fmt::Display for ClientError { 63 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 64 match self { 65 Self::InvalidUrl(s) => write!(f, "invalid URL: {s}"), 66 Self::UnsupportedScheme(s) => write!(f, "unsupported scheme: {s}"), 67 Self::Tcp(e) => write!(f, "TCP error: {e}"), 68 Self::Tls(e) => write!(f, "TLS error: {e}"), 69 Self::Http(e) => write!(f, "HTTP error: {e}"), 70 Self::TooManyRedirects => write!(f, "too many redirects"), 71 Self::RedirectLoop => write!(f, "redirect loop detected"), 72 Self::Http2(e) => write!(f, "HTTP/2 error: {e}"), 73 Self::ConnectionClosed => write!(f, "connection closed"), 74 Self::Io(e) => write!(f, "I/O error: {e}"), 75 } 76 } 77} 78 79impl From<tcp::NetError> for ClientError { 80 fn from(e: tcp::NetError) -> Self { 81 Self::Tcp(e) 82 } 83} 84 85impl From<HandshakeError> for ClientError { 86 fn from(e: HandshakeError) -> Self { 87 Self::Tls(e) 88 } 89} 90 91impl From<http::HttpError> for ClientError { 92 fn from(e: http::HttpError) -> Self { 93 Self::Http(e) 94 } 95} 96 97impl From<io::Error> for ClientError { 98 fn from(e: io::Error) -> Self { 99 Self::Io(e) 100 } 101} 102 103impl From<Http2Error> for ClientError { 104 fn from(e: Http2Error) -> Self { 105 Self::Http2(e) 106 } 107} 108 109pub type Result<T> = std::result::Result<T, ClientError>; 110 111// --------------------------------------------------------------------------- 112// Connection abstraction 113// --------------------------------------------------------------------------- 114 115/// A connection that can be either plain TCP or TLS-encrypted. 116enum Connection { 117 Plain(TcpConnection), 118 Tls(TlsStream<TcpConnection>), 119} 120 121impl Connection { 122 fn read(&mut self, buf: &mut [u8]) -> Result<usize> { 123 match self { 124 Self::Plain(tcp) => tcp.read(buf).map_err(ClientError::Tcp), 125 Self::Tls(tls) => tls.read(buf).map_err(ClientError::Tls), 126 } 127 } 128 129 fn write_all(&mut self, data: &[u8]) -> Result<()> { 130 match self { 131 Self::Plain(tcp) => tcp.write_all(data).map_err(ClientError::Tcp), 132 Self::Tls(tls) => tls.write_all(data).map_err(ClientError::Tls), 133 } 134 } 135 136 fn flush(&mut self) -> Result<()> { 137 match self { 138 Self::Plain(tcp) => tcp.flush().map_err(ClientError::Tcp), 139 Self::Tls(_) => Ok(()), // TLS writes are flushed per record 140 } 141 } 142 143 fn set_read_timeout(&self, duration: Option<Duration>) -> Result<()> { 144 match self { 145 Self::Plain(tcp) => tcp.set_read_timeout(duration).map_err(ClientError::Tcp), 146 Self::Tls(tls) => tls 147 .stream() 148 .set_read_timeout(duration) 149 .map_err(ClientError::Tcp), 150 } 151 } 152} 153 154// --------------------------------------------------------------------------- 155// Connection pool 156// --------------------------------------------------------------------------- 157 158/// Key for pooling connections by origin. 159#[derive(Hash, Eq, PartialEq, Clone, Debug)] 160struct ConnectionKey { 161 host: String, 162 port: u16, 163 is_tls: bool, 164} 165 166/// A pooled connection with its idle timestamp. 167struct PooledConnection { 168 conn: Connection, 169 idle_since: Instant, 170} 171 172/// Pool of idle HTTP connections for reuse. 173struct ConnectionPool { 174 connections: HashMap<ConnectionKey, Vec<PooledConnection>>, 175 max_idle_time: Duration, 176 max_per_host: usize, 177} 178 179impl ConnectionPool { 180 fn new(max_idle_time: Duration, max_per_host: usize) -> Self { 181 Self { 182 connections: HashMap::new(), 183 max_idle_time, 184 max_per_host, 185 } 186 } 187 188 /// Take an idle connection for the given key, if one is available. 189 fn take(&mut self, key: &ConnectionKey) -> Option<Connection> { 190 let entries = self.connections.get_mut(key)?; 191 let now = Instant::now(); 192 193 // Remove expired connections 194 entries.retain(|pc| now.duration_since(pc.idle_since) < self.max_idle_time); 195 196 // Take the most recently idled connection 197 entries.pop().map(|pc| pc.conn) 198 } 199 200 /// Return a connection to the pool. 201 fn put(&mut self, key: ConnectionKey, conn: Connection) { 202 let entries = self.connections.entry(key).or_default(); 203 204 // Evict oldest if at capacity 205 if entries.len() >= self.max_per_host { 206 entries.remove(0); 207 } 208 209 entries.push(PooledConnection { 210 conn, 211 idle_since: Instant::now(), 212 }); 213 } 214} 215 216// --------------------------------------------------------------------------- 217// HttpClient 218// --------------------------------------------------------------------------- 219 220/// Key for HTTP/2 connection pooling (one multiplexed connection per origin). 221#[derive(Hash, Eq, PartialEq, Clone, Debug)] 222struct H2ConnectionKey { 223 host: String, 224 port: u16, 225} 226 227/// High-level HTTP client with connection pooling, HTTP/2 support, redirect following, and cookie jar. 228pub struct HttpClient { 229 pool: ConnectionPool, 230 h2_connections: HashMap<H2ConnectionKey, Http2Connection<TlsStream<TcpConnection>>>, 231 max_redirects: u32, 232 connect_timeout: Duration, 233 read_timeout: Duration, 234 cookie_jar: CookieJar, 235} 236 237impl HttpClient { 238 /// Create a new HTTP client with default settings. 239 pub fn new() -> Self { 240 Self { 241 pool: ConnectionPool::new(DEFAULT_MAX_IDLE_TIME, DEFAULT_MAX_PER_HOST), 242 h2_connections: HashMap::new(), 243 max_redirects: DEFAULT_MAX_REDIRECTS, 244 connect_timeout: DEFAULT_CONNECT_TIMEOUT, 245 read_timeout: DEFAULT_READ_TIMEOUT, 246 cookie_jar: CookieJar::new(), 247 } 248 } 249 250 /// Estimated heap bytes used by the HTTP client's connection pool and buffers. 251 pub fn memory_usage(&self) -> usize { 252 use std::mem::size_of; 253 let pool_conns: usize = self 254 .pool 255 .connections 256 .iter() 257 .map(|(k, v)| k.host.capacity() + v.capacity() * size_of::<PooledConnection>()) 258 .sum(); 259 let pool_overhead = self.pool.connections.capacity() 260 * (size_of::<ConnectionKey>() + size_of::<Vec<PooledConnection>>() + 8); 261 let h2_conns = self.h2_connections.capacity() * (size_of::<H2ConnectionKey>() + 256); // estimate for Http2Connection 262 let h2_keys: usize = self.h2_connections.keys().map(|k| k.host.capacity()).sum(); 263 pool_conns + pool_overhead + h2_conns + h2_keys 264 } 265 266 /// Number of pooled idle HTTP/1.1 connections. 267 pub fn pooled_connection_count(&self) -> usize { 268 self.pool.connections.values().map(|v| v.len()).sum() 269 } 270 271 /// Number of active HTTP/2 connections. 272 pub fn h2_connection_count(&self) -> usize { 273 self.h2_connections.len() 274 } 275 276 /// Get a reference to the cookie jar. 277 pub fn cookie_jar(&self) -> &CookieJar { 278 &self.cookie_jar 279 } 280 281 /// Get a mutable reference to the cookie jar. 282 pub fn cookie_jar_mut(&mut self) -> &mut CookieJar { 283 &mut self.cookie_jar 284 } 285 286 /// Set the maximum number of redirects to follow. 287 pub fn set_max_redirects(&mut self, max: u32) { 288 self.max_redirects = max; 289 } 290 291 /// Set the connection timeout. 292 pub fn set_connect_timeout(&mut self, timeout: Duration) { 293 self.connect_timeout = timeout; 294 } 295 296 /// Set the read timeout. 297 pub fn set_read_timeout(&mut self, timeout: Duration) { 298 self.read_timeout = timeout; 299 } 300 301 /// Perform an HTTP GET request. 302 pub fn get(&mut self, url: &Url) -> Result<HttpResponse> { 303 self.request(Method::Get, url, &Headers::new(), None) 304 } 305 306 /// Perform an HTTP POST request. 307 pub fn post(&mut self, url: &Url, body: &[u8], content_type: &str) -> Result<HttpResponse> { 308 let mut headers = Headers::new(); 309 headers.add("Content-Type", content_type); 310 self.request(Method::Post, url, &headers, Some(body)) 311 } 312 313 /// Perform an HTTP request with full control over method, headers, and body. 314 /// 315 /// Follows redirects (301, 302, 303, 307, 308) up to `max_redirects`. 316 /// 317 /// - 301/302/303: method changes to GET, body is dropped 318 /// - 307/308: original method and body are preserved 319 /// - Cross-origin redirects strip the `Authorization` header 320 /// - Redirect loops are detected and produce an error 321 pub fn request( 322 &mut self, 323 method: Method, 324 url: &Url, 325 headers: &Headers, 326 body: Option<&[u8]>, 327 ) -> Result<HttpResponse> { 328 let mut current_url = url.clone(); 329 let mut current_method = method; 330 let mut current_body = body.map(|b| b.to_vec()); 331 let mut current_headers = headers.clone(); 332 let mut redirects = 0u32; 333 let original_origin = url.origin(); 334 let mut visited_urls = vec![current_url.serialize()]; 335 336 loop { 337 let resp = self.execute_request( 338 current_method, 339 &current_url, 340 &current_headers, 341 current_body.as_deref(), 342 )?; 343 344 // Check for redirects 345 if is_redirect_status(resp.status_code) { 346 redirects += 1; 347 if redirects > self.max_redirects { 348 return Err(ClientError::TooManyRedirects); 349 } 350 351 if let Some(location) = resp.headers.get("Location") { 352 // Resolve relative URLs against current URL 353 let next_url = Url::parse_with_base(location, &current_url) 354 .or_else(|_| Url::parse(location)) 355 .map_err(|_| { 356 ClientError::InvalidUrl(format!( 357 "invalid redirect location: {location}" 358 )) 359 })?; 360 361 // Detect redirect loops 362 let next_serialized = next_url.serialize(); 363 if visited_urls.contains(&next_serialized) { 364 return Err(ClientError::RedirectLoop); 365 } 366 visited_urls.push(next_serialized); 367 368 // Handle method changes per HTTP spec 369 current_method = redirect_method(resp.status_code, current_method); 370 if !redirect_preserves_body(resp.status_code) { 371 current_body = None; 372 } 373 374 // Strip Authorization header on cross-origin redirects 375 if !next_url.origin().same_origin(&original_origin) { 376 current_headers.remove("Authorization"); 377 } 378 379 current_url = next_url; 380 continue; 381 } 382 } 383 384 // Attach redirect metadata to the response 385 let mut resp = resp; 386 if redirects > 0 { 387 resp.redirected = true; 388 resp.final_url = Some(current_url.serialize()); 389 } 390 391 return Ok(resp); 392 } 393 } 394 395 /// Execute a single HTTP request (no redirect following). 396 fn execute_request( 397 &mut self, 398 method: Method, 399 url: &Url, 400 headers: &Headers, 401 body: Option<&[u8]>, 402 ) -> Result<HttpResponse> { 403 let scheme = url.scheme(); 404 let is_tls = match scheme { 405 "https" => true, 406 "http" => false, 407 other => return Err(ClientError::UnsupportedScheme(other.to_string())), 408 }; 409 410 let host = url 411 .host_str() 412 .ok_or_else(|| ClientError::InvalidUrl("missing host".to_string()))?; 413 414 let port = url 415 .port_or_default() 416 .ok_or_else(|| ClientError::InvalidUrl("cannot determine port".to_string()))?; 417 418 let path = request_path(url); 419 420 // Build request headers, attaching cookies from the jar. 421 let mut merged_headers = Headers::new(); 422 for (name, value) in headers.iter() { 423 merged_headers.add(name, value); 424 } 425 if !merged_headers.contains("Cookie") { 426 if let Some(cookie_val) = self 427 .cookie_jar 428 .cookie_header_value(url, RequestContext::SameSite) 429 { 430 merged_headers.add("Cookie", &cookie_val); 431 } 432 } 433 434 // Try HTTP/2 for TLS connections 435 if is_tls { 436 let h2_key = H2ConnectionKey { 437 host: host.clone(), 438 port, 439 }; 440 441 // Check if we have an existing HTTP/2 connection for this origin 442 if self.h2_connections.contains_key(&h2_key) { 443 let response = self.execute_h2_request( 444 &h2_key, 445 method, 446 &path, 447 &host, 448 &merged_headers, 449 body, 450 url, 451 ); 452 453 match response { 454 Ok(resp) => return Ok(resp), 455 Err(ClientError::Http2(_)) => { 456 // HTTP/2 connection failed — remove it and fall through to new connection 457 self.h2_connections.remove(&h2_key); 458 } 459 Err(e) => return Err(e), 460 } 461 } 462 463 // Try to establish a new connection with ALPN 464 let tcp = TcpConnection::connect_timeout(&host, port, self.connect_timeout)?; 465 let (tls, alpn) = handshake::connect_with_alpn(tcp, &host, &["h2", "http/1.1"])?; 466 467 if alpn.as_deref() == Some("h2") { 468 // HTTP/2 negotiated — create HTTP/2 connection 469 let h2_conn = Http2Connection::new(tls)?; 470 self.h2_connections.insert(h2_key.clone(), h2_conn); 471 472 return self.execute_h2_request( 473 &h2_key, 474 method, 475 &path, 476 &host, 477 &merged_headers, 478 body, 479 url, 480 ); 481 } 482 483 // HTTP/1.1 over TLS — use normal path 484 let conn = Connection::Tls(tls); 485 return self.execute_h1_request(conn, method, &path, &host, &merged_headers, body, url); 486 } 487 488 // Plain HTTP (no TLS) — always HTTP/1.1 489 let key = ConnectionKey { 490 host: host.clone(), 491 port, 492 is_tls: false, 493 }; 494 495 let conn = match self.pool.take(&key) { 496 Some(conn) => conn, 497 None => { 498 let tcp = TcpConnection::connect_timeout(&host, port, self.connect_timeout)?; 499 Connection::Plain(tcp) 500 } 501 }; 502 503 self.execute_h1_request(conn, method, &path, &host, &merged_headers, body, url) 504 } 505 506 /// Execute an HTTP/1.1 request over the given connection. 507 #[allow(clippy::too_many_arguments)] 508 fn execute_h1_request( 509 &mut self, 510 mut conn: Connection, 511 method: Method, 512 path: &str, 513 host: &str, 514 headers: &Headers, 515 body: Option<&[u8]>, 516 url: &Url, 517 ) -> Result<HttpResponse> { 518 conn.set_read_timeout(Some(self.read_timeout))?; 519 520 let request_bytes = http::serialize_request(method, path, host, headers, body); 521 conn.write_all(&request_bytes)?; 522 conn.flush()?; 523 524 let response = read_response(&mut conn)?; 525 526 // Store Set-Cookie headers 527 let set_cookies: Vec<String> = response 528 .headers 529 .get_all("Set-Cookie") 530 .into_iter() 531 .map(|s| s.to_string()) 532 .collect(); 533 for header in &set_cookies { 534 self.cookie_jar.store_from_header(header, url); 535 } 536 537 // Return connection to pool if keep-alive 538 if !response.connection_close() { 539 // Determine the connection key for pooling 540 let is_tls = matches!(conn, Connection::Tls(_)); 541 let port = url 542 .port_or_default() 543 .unwrap_or(if is_tls { 443 } else { 80 }); 544 let key = ConnectionKey { 545 host: host.to_string(), 546 port, 547 is_tls, 548 }; 549 self.pool.put(key, conn); 550 } 551 552 Ok(response) 553 } 554 555 /// Execute a request over an existing HTTP/2 connection. 556 #[allow(clippy::too_many_arguments)] 557 fn execute_h2_request( 558 &mut self, 559 h2_key: &H2ConnectionKey, 560 method: Method, 561 path: &str, 562 authority: &str, 563 headers: &Headers, 564 body: Option<&[u8]>, 565 url: &Url, 566 ) -> Result<HttpResponse> { 567 let extra_headers: Vec<(String, String)> = headers 568 .iter() 569 .filter(|(name, _)| { 570 // Skip pseudo-headers and host (authority is used instead) 571 let lower = name.to_ascii_lowercase(); 572 lower != "host" && lower != "connection" && lower != "transfer-encoding" 573 }) 574 .map(|(name, value)| (name.to_ascii_lowercase(), value.to_string())) 575 .collect(); 576 577 let h2_conn = self.h2_connections.get_mut(h2_key).unwrap(); 578 579 let stream_id = 580 h2_conn.send_request(method.as_str(), path, authority, &extra_headers, body)?; 581 582 let (resp_headers, resp_body, status_code) = h2_conn.read_response(stream_id)?; 583 584 // Convert HTTP/2 response to HttpResponse 585 let mut response_headers = Headers::new(); 586 for (name, value) in &resp_headers { 587 let name_str = String::from_utf8_lossy(name); 588 let value_str = String::from_utf8_lossy(value); 589 if !name_str.starts_with(':') { 590 response_headers.add(&name_str, &value_str); 591 } 592 } 593 594 // Store Set-Cookie headers 595 let set_cookies: Vec<String> = response_headers 596 .get_all("Set-Cookie") 597 .into_iter() 598 .map(|s| s.to_string()) 599 .collect(); 600 for header in &set_cookies { 601 self.cookie_jar.store_from_header(header, url); 602 } 603 604 Ok(HttpResponse { 605 version: "HTTP/2".to_string(), 606 status_code, 607 reason: reason_phrase(status_code).to_string(), 608 headers: response_headers, 609 body: resp_body, 610 redirected: false, 611 final_url: None, 612 }) 613 } 614} 615 616impl Default for HttpClient { 617 fn default() -> Self { 618 Self::new() 619 } 620} 621 622// --------------------------------------------------------------------------- 623// Helpers 624// --------------------------------------------------------------------------- 625 626/// Build the request path from a URL (path + query). 627fn request_path(url: &Url) -> String { 628 let path = url.path(); 629 let path = if path.is_empty() { "/" } else { &path }; 630 match url.query() { 631 Some(q) => format!("{path}?{q}"), 632 None => path.to_string(), 633 } 634} 635 636/// Read a complete HTTP response from a connection. 637/// 638/// Reads the header section first, then determines the body length from 639/// headers and reads the appropriate amount of body data. 640fn read_response(conn: &mut Connection) -> Result<HttpResponse> { 641 let mut buf = Vec::with_capacity(READ_BUF_SIZE); 642 let mut temp = [0u8; READ_BUF_SIZE]; 643 644 // Phase 1: Read until we have the complete header section (\r\n\r\n) 645 let header_end = loop { 646 let n = conn.read(&mut temp)?; 647 if n == 0 { 648 if buf.is_empty() { 649 return Err(ClientError::ConnectionClosed); 650 } 651 break find_header_end(&buf); 652 } 653 buf.extend_from_slice(&temp[..n]); 654 if let Some(pos) = find_header_end(&buf) { 655 break Some(pos); 656 } 657 }; 658 659 let header_end = header_end.ok_or(ClientError::Http(http::HttpError::Incomplete))?; 660 let body_start = header_end + 4; // skip \r\n\r\n 661 662 // Quick-parse headers to determine body strategy 663 let header_str = std::str::from_utf8(&buf[..header_end]).map_err(|_| { 664 ClientError::Http(http::HttpError::Parse( 665 "invalid UTF-8 in headers".to_string(), 666 )) 667 })?; 668 669 let status_code = parse_status_code(header_str)?; 670 let body_strategy = determine_body_strategy(header_str, status_code); 671 672 // Phase 2: Read body according to strategy 673 match body_strategy { 674 BodyStrategy::NoBody => { 675 // Truncate buffer to just headers + \r\n\r\n 676 buf.truncate(body_start); 677 } 678 BodyStrategy::ContentLength(len) => { 679 let total_needed = body_start + len; 680 while buf.len() < total_needed { 681 let n = conn.read(&mut temp)?; 682 if n == 0 { 683 break; 684 } 685 buf.extend_from_slice(&temp[..n]); 686 } 687 } 688 BodyStrategy::Chunked => { 689 // Read until we find the terminating 0-length chunk 690 while !has_chunked_terminator(&buf[body_start..]) { 691 let n = conn.read(&mut temp)?; 692 if n == 0 { 693 break; 694 } 695 buf.extend_from_slice(&temp[..n]); 696 } 697 } 698 BodyStrategy::ReadUntilClose => { 699 // Read until EOF 700 loop { 701 let n = conn.read(&mut temp)?; 702 if n == 0 { 703 break; 704 } 705 buf.extend_from_slice(&temp[..n]); 706 } 707 } 708 } 709 710 // Parse the complete response 711 http::parse_response(&buf).map_err(ClientError::Http) 712} 713 714/// Find the end of the HTTP header section (\r\n\r\n). 715/// Returns the position of the first \r in the \r\n\r\n sequence. 716fn find_header_end(data: &[u8]) -> Option<usize> { 717 data.windows(4).position(|w| w == b"\r\n\r\n") 718} 719 720/// Extract status code from the first line of headers. 721fn parse_status_code(headers: &str) -> Result<u16> { 722 let first_line = headers.lines().next().unwrap_or(""); 723 let mut parts = first_line.splitn(3, ' '); 724 let _version = parts.next(); 725 let code_str = parts.next().unwrap_or(""); 726 code_str.parse().map_err(|_| { 727 ClientError::Http(http::HttpError::MalformedStatusLine(first_line.to_string())) 728 }) 729} 730 731/// Strategy for reading the response body. 732enum BodyStrategy { 733 NoBody, 734 ContentLength(usize), 735 Chunked, 736 ReadUntilClose, 737} 738 739/// Extract the value for a header name (case-insensitive match). 740fn header_value<'a>(line: &'a str, name: &str) -> Option<&'a str> { 741 let colon = line.find(':')?; 742 if line[..colon].eq_ignore_ascii_case(name) { 743 Some(line[colon + 1..].trim()) 744 } else { 745 None 746 } 747} 748 749/// Determine how to read the body from headers. 750fn determine_body_strategy(headers: &str, status_code: u16) -> BodyStrategy { 751 // 1xx, 204, 304 have no body 752 if status_code < 200 || status_code == 204 || status_code == 304 { 753 return BodyStrategy::NoBody; 754 } 755 756 // Check for Transfer-Encoding: chunked 757 for line in headers.split("\r\n").skip(1) { 758 if let Some(val) = header_value(line, "transfer-encoding") { 759 if val.eq_ignore_ascii_case("chunked") { 760 return BodyStrategy::Chunked; 761 } 762 } 763 } 764 765 // Check for Content-Length 766 for line in headers.split("\r\n").skip(1) { 767 if let Some(val) = header_value(line, "content-length") { 768 if let Ok(len) = val.parse::<usize>() { 769 return BodyStrategy::ContentLength(len); 770 } 771 } 772 } 773 774 BodyStrategy::ReadUntilClose 775} 776 777/// Standard HTTP reason phrase for a status code. 778fn reason_phrase(status: u16) -> &'static str { 779 match status { 780 200 => "OK", 781 201 => "Created", 782 204 => "No Content", 783 301 => "Moved Permanently", 784 302 => "Found", 785 303 => "See Other", 786 304 => "Not Modified", 787 307 => "Temporary Redirect", 788 308 => "Permanent Redirect", 789 400 => "Bad Request", 790 401 => "Unauthorized", 791 403 => "Forbidden", 792 404 => "Not Found", 793 405 => "Method Not Allowed", 794 500 => "Internal Server Error", 795 502 => "Bad Gateway", 796 503 => "Service Unavailable", 797 _ => "", 798 } 799} 800 801/// Determine whether a status code is a redirect that should be followed. 802fn is_redirect_status(status: u16) -> bool { 803 matches!(status, 301 | 302 | 303 | 307 | 308) 804} 805 806/// Determine the method for the next request after a redirect. 807/// 808/// Per HTTP spec: 809/// - 301/302/303: change to GET (drop body) 810/// - 307/308: preserve original method 811fn redirect_method(status: u16, original_method: Method) -> Method { 812 match status { 813 301..=303 => Method::Get, 814 _ => original_method, 815 } 816} 817 818/// Determine whether body should be preserved after a redirect. 819/// 820/// Body is dropped for 301/302/303, preserved for 307/308. 821fn redirect_preserves_body(status: u16) -> bool { 822 matches!(status, 307 | 308) 823} 824 825/// Check if chunked body data contains the terminating `0\r\n\r\n`. 826fn has_chunked_terminator(data: &[u8]) -> bool { 827 // Look for \r\n0\r\n\r\n (the final chunk after some data) or 0\r\n\r\n at start 828 data.windows(5).any(|w| w == b"0\r\n\r\n") 829} 830 831// --------------------------------------------------------------------------- 832// Tests 833// --------------------------------------------------------------------------- 834 835#[cfg(test)] 836mod tests { 837 use super::*; 838 839 // -- ClientError Display tests -- 840 841 #[test] 842 fn error_display_invalid_url() { 843 let e = ClientError::InvalidUrl("bad".to_string()); 844 assert_eq!(e.to_string(), "invalid URL: bad"); 845 } 846 847 #[test] 848 fn error_display_unsupported_scheme() { 849 let e = ClientError::UnsupportedScheme("ftp".to_string()); 850 assert_eq!(e.to_string(), "unsupported scheme: ftp"); 851 } 852 853 #[test] 854 fn error_display_too_many_redirects() { 855 let e = ClientError::TooManyRedirects; 856 assert_eq!(e.to_string(), "too many redirects"); 857 } 858 859 #[test] 860 fn error_display_connection_closed() { 861 let e = ClientError::ConnectionClosed; 862 assert_eq!(e.to_string(), "connection closed"); 863 } 864 865 // -- HttpClient configuration tests -- 866 867 #[test] 868 fn client_default() { 869 let client = HttpClient::default(); 870 assert_eq!(client.max_redirects, DEFAULT_MAX_REDIRECTS); 871 assert_eq!(client.connect_timeout, DEFAULT_CONNECT_TIMEOUT); 872 assert_eq!(client.read_timeout, DEFAULT_READ_TIMEOUT); 873 } 874 875 #[test] 876 fn client_set_max_redirects() { 877 let mut client = HttpClient::new(); 878 client.set_max_redirects(5); 879 assert_eq!(client.max_redirects, 5); 880 } 881 882 #[test] 883 fn client_set_connect_timeout() { 884 let mut client = HttpClient::new(); 885 client.set_connect_timeout(Duration::from_secs(10)); 886 assert_eq!(client.connect_timeout, Duration::from_secs(10)); 887 } 888 889 #[test] 890 fn client_set_read_timeout() { 891 let mut client = HttpClient::new(); 892 client.set_read_timeout(Duration::from_secs(5)); 893 assert_eq!(client.read_timeout, Duration::from_secs(5)); 894 } 895 896 // -- ConnectionPool tests -- 897 898 #[test] 899 fn pool_take_empty() { 900 let mut pool = ConnectionPool::new(Duration::from_secs(60), 6); 901 let key = ConnectionKey { 902 host: "example.com".to_string(), 903 port: 80, 904 is_tls: false, 905 }; 906 assert!(pool.take(&key).is_none()); 907 } 908 909 #[test] 910 fn pool_connections_map_starts_empty() { 911 let pool = ConnectionPool::new(Duration::from_secs(60), 6); 912 assert!(pool.connections.is_empty()); 913 } 914 915 // -- request_path tests -- 916 917 #[test] 918 fn request_path_simple() { 919 let url = Url::parse("http://example.com/path").unwrap(); 920 assert_eq!(request_path(&url), "/path"); 921 } 922 923 #[test] 924 fn request_path_with_query() { 925 let url = Url::parse("http://example.com/path?key=value").unwrap(); 926 assert_eq!(request_path(&url), "/path?key=value"); 927 } 928 929 #[test] 930 fn request_path_root() { 931 let url = Url::parse("http://example.com").unwrap(); 932 assert_eq!(request_path(&url), "/"); 933 } 934 935 #[test] 936 fn request_path_deep() { 937 let url = Url::parse("http://example.com/a/b/c").unwrap(); 938 assert_eq!(request_path(&url), "/a/b/c"); 939 } 940 941 // -- find_header_end tests -- 942 943 #[test] 944 fn find_header_end_found() { 945 let data = b"HTTP/1.1 200 OK\r\nHost: x\r\n\r\nbody"; 946 assert_eq!(find_header_end(data), Some(24)); 947 } 948 949 #[test] 950 fn find_header_end_not_found() { 951 let data = b"HTTP/1.1 200 OK\r\nHost: x\r\n"; 952 assert_eq!(find_header_end(data), None); 953 } 954 955 #[test] 956 fn find_header_end_empty() { 957 assert_eq!(find_header_end(b""), None); 958 } 959 960 #[test] 961 fn find_header_end_minimal() { 962 let data = b"\r\n\r\n"; 963 assert_eq!(find_header_end(data), Some(0)); 964 } 965 966 // -- parse_status_code tests -- 967 968 #[test] 969 fn parse_status_code_200() { 970 assert_eq!(parse_status_code("HTTP/1.1 200 OK").unwrap(), 200); 971 } 972 973 #[test] 974 fn parse_status_code_404() { 975 assert_eq!(parse_status_code("HTTP/1.1 404 Not Found").unwrap(), 404); 976 } 977 978 #[test] 979 fn parse_status_code_301() { 980 assert_eq!( 981 parse_status_code("HTTP/1.1 301 Moved Permanently").unwrap(), 982 301 983 ); 984 } 985 986 #[test] 987 fn parse_status_code_invalid() { 988 assert!(parse_status_code("INVALID").is_err()); 989 } 990 991 // -- determine_body_strategy tests -- 992 993 #[test] 994 fn strategy_no_body_204() { 995 let headers = "HTTP/1.1 204 No Content\r\nConnection: keep-alive"; 996 assert!(matches!( 997 determine_body_strategy(headers, 204), 998 BodyStrategy::NoBody 999 )); 1000 } 1001 1002 #[test] 1003 fn strategy_no_body_304() { 1004 let headers = "HTTP/1.1 304 Not Modified\r\nETag: \"abc\""; 1005 assert!(matches!( 1006 determine_body_strategy(headers, 304), 1007 BodyStrategy::NoBody 1008 )); 1009 } 1010 1011 #[test] 1012 fn strategy_no_body_1xx() { 1013 let headers = "HTTP/1.1 100 Continue"; 1014 assert!(matches!( 1015 determine_body_strategy(headers, 100), 1016 BodyStrategy::NoBody 1017 )); 1018 } 1019 1020 #[test] 1021 fn strategy_content_length() { 1022 let headers = "HTTP/1.1 200 OK\r\nContent-Length: 42"; 1023 match determine_body_strategy(headers, 200) { 1024 BodyStrategy::ContentLength(42) => {} 1025 _ => panic!("expected ContentLength(42)"), 1026 } 1027 } 1028 1029 #[test] 1030 fn strategy_chunked() { 1031 let headers = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked"; 1032 assert!(matches!( 1033 determine_body_strategy(headers, 200), 1034 BodyStrategy::Chunked 1035 )); 1036 } 1037 1038 #[test] 1039 fn strategy_read_until_close() { 1040 let headers = "HTTP/1.1 200 OK\r\nConnection: close"; 1041 assert!(matches!( 1042 determine_body_strategy(headers, 200), 1043 BodyStrategy::ReadUntilClose 1044 )); 1045 } 1046 1047 // -- has_chunked_terminator tests -- 1048 1049 #[test] 1050 fn chunked_terminator_present() { 1051 assert!(has_chunked_terminator(b"5\r\nHello\r\n0\r\n\r\n")); 1052 } 1053 1054 #[test] 1055 fn chunked_terminator_at_start() { 1056 assert!(has_chunked_terminator(b"0\r\n\r\n")); 1057 } 1058 1059 #[test] 1060 fn chunked_terminator_missing() { 1061 assert!(!has_chunked_terminator(b"5\r\nHello\r\n")); 1062 } 1063 1064 #[test] 1065 fn chunked_terminator_empty() { 1066 assert!(!has_chunked_terminator(b"")); 1067 } 1068 1069 // -- ConnectionKey equality tests -- 1070 1071 #[test] 1072 fn connection_key_equal() { 1073 let a = ConnectionKey { 1074 host: "example.com".to_string(), 1075 port: 443, 1076 is_tls: true, 1077 }; 1078 let b = ConnectionKey { 1079 host: "example.com".to_string(), 1080 port: 443, 1081 is_tls: true, 1082 }; 1083 assert_eq!(a, b); 1084 } 1085 1086 #[test] 1087 fn connection_key_different_host() { 1088 let a = ConnectionKey { 1089 host: "a.com".to_string(), 1090 port: 443, 1091 is_tls: true, 1092 }; 1093 let b = ConnectionKey { 1094 host: "b.com".to_string(), 1095 port: 443, 1096 is_tls: true, 1097 }; 1098 assert_ne!(a, b); 1099 } 1100 1101 #[test] 1102 fn connection_key_different_port() { 1103 let a = ConnectionKey { 1104 host: "example.com".to_string(), 1105 port: 80, 1106 is_tls: false, 1107 }; 1108 let b = ConnectionKey { 1109 host: "example.com".to_string(), 1110 port: 8080, 1111 is_tls: false, 1112 }; 1113 assert_ne!(a, b); 1114 } 1115 1116 #[test] 1117 fn connection_key_different_tls() { 1118 let a = ConnectionKey { 1119 host: "example.com".to_string(), 1120 port: 443, 1121 is_tls: true, 1122 }; 1123 let b = ConnectionKey { 1124 host: "example.com".to_string(), 1125 port: 443, 1126 is_tls: false, 1127 }; 1128 assert_ne!(a, b); 1129 } 1130 1131 // -- Header parsing strategy with case variations -- 1132 1133 #[test] 1134 fn strategy_content_length_lowercase() { 1135 let headers = "HTTP/1.1 200 OK\r\ncontent-length: 10"; 1136 match determine_body_strategy(headers, 200) { 1137 BodyStrategy::ContentLength(10) => {} 1138 _ => panic!("expected ContentLength(10)"), 1139 } 1140 } 1141 1142 #[test] 1143 fn strategy_chunked_lowercase() { 1144 let headers = "HTTP/1.1 200 OK\r\ntransfer-encoding: chunked"; 1145 assert!(matches!( 1146 determine_body_strategy(headers, 200), 1147 BodyStrategy::Chunked 1148 )); 1149 } 1150 1151 #[test] 1152 fn strategy_chunked_uppercase_value() { 1153 let headers = "HTTP/1.1 200 OK\r\nTransfer-Encoding: CHUNKED"; 1154 assert!(matches!( 1155 determine_body_strategy(headers, 200), 1156 BodyStrategy::Chunked 1157 )); 1158 } 1159 1160 #[test] 1161 fn strategy_content_length_mixed_case() { 1162 let headers = "HTTP/1.1 200 OK\r\nCONTENT-LENGTH: 99"; 1163 match determine_body_strategy(headers, 200) { 1164 BodyStrategy::ContentLength(99) => {} 1165 _ => panic!("expected ContentLength(99)"), 1166 } 1167 } 1168 1169 #[test] 1170 fn strategy_chunked_mixed_case_name() { 1171 let headers = "HTTP/1.1 200 OK\r\nTRANSFER-ENCODING: chunked"; 1172 assert!(matches!( 1173 determine_body_strategy(headers, 200), 1174 BodyStrategy::Chunked 1175 )); 1176 } 1177 1178 // -- URL scheme handling -- 1179 1180 #[test] 1181 fn unsupported_scheme_error() { 1182 let mut client = HttpClient::new(); 1183 let url = Url::parse("ftp://example.com/file").unwrap(); 1184 let result = client.get(&url); 1185 assert!(matches!(result, Err(ClientError::UnsupportedScheme(_)))); 1186 } 1187 1188 // -- Redirect-related tests -- 1189 1190 #[test] 1191 fn default_max_redirects_is_20() { 1192 let client = HttpClient::new(); 1193 assert_eq!(client.max_redirects, 20); 1194 } 1195 1196 #[test] 1197 fn error_display_redirect_loop() { 1198 let e = ClientError::RedirectLoop; 1199 assert_eq!(e.to_string(), "redirect loop detected"); 1200 } 1201 1202 #[test] 1203 fn reason_phrase_303() { 1204 assert_eq!(reason_phrase(303), "See Other"); 1205 } 1206 1207 #[test] 1208 fn reason_phrase_301() { 1209 assert_eq!(reason_phrase(301), "Moved Permanently"); 1210 } 1211 1212 #[test] 1213 fn reason_phrase_302() { 1214 assert_eq!(reason_phrase(302), "Found"); 1215 } 1216 1217 #[test] 1218 fn reason_phrase_307() { 1219 assert_eq!(reason_phrase(307), "Temporary Redirect"); 1220 } 1221 1222 #[test] 1223 fn reason_phrase_308() { 1224 assert_eq!(reason_phrase(308), "Permanent Redirect"); 1225 } 1226 1227 // -- is_redirect_status tests -- 1228 1229 #[test] 1230 fn redirect_status_301() { 1231 assert!(is_redirect_status(301)); 1232 } 1233 1234 #[test] 1235 fn redirect_status_302() { 1236 assert!(is_redirect_status(302)); 1237 } 1238 1239 #[test] 1240 fn redirect_status_303() { 1241 assert!(is_redirect_status(303)); 1242 } 1243 1244 #[test] 1245 fn redirect_status_307() { 1246 assert!(is_redirect_status(307)); 1247 } 1248 1249 #[test] 1250 fn redirect_status_308() { 1251 assert!(is_redirect_status(308)); 1252 } 1253 1254 #[test] 1255 fn redirect_status_200_is_not_redirect() { 1256 assert!(!is_redirect_status(200)); 1257 } 1258 1259 #[test] 1260 fn redirect_status_404_is_not_redirect() { 1261 assert!(!is_redirect_status(404)); 1262 } 1263 1264 #[test] 1265 fn redirect_status_304_is_not_redirect() { 1266 assert!(!is_redirect_status(304)); 1267 } 1268 1269 // -- redirect_method tests -- 1270 1271 #[test] 1272 fn redirect_301_changes_post_to_get() { 1273 assert_eq!(redirect_method(301, Method::Post), Method::Get); 1274 } 1275 1276 #[test] 1277 fn redirect_302_changes_post_to_get() { 1278 assert_eq!(redirect_method(302, Method::Post), Method::Get); 1279 } 1280 1281 #[test] 1282 fn redirect_303_changes_post_to_get() { 1283 assert_eq!(redirect_method(303, Method::Post), Method::Get); 1284 } 1285 1286 #[test] 1287 fn redirect_303_changes_put_to_get() { 1288 assert_eq!(redirect_method(303, Method::Put), Method::Get); 1289 } 1290 1291 #[test] 1292 fn redirect_303_changes_delete_to_get() { 1293 assert_eq!(redirect_method(303, Method::Delete), Method::Get); 1294 } 1295 1296 #[test] 1297 fn redirect_307_preserves_post() { 1298 assert_eq!(redirect_method(307, Method::Post), Method::Post); 1299 } 1300 1301 #[test] 1302 fn redirect_307_preserves_put() { 1303 assert_eq!(redirect_method(307, Method::Put), Method::Put); 1304 } 1305 1306 #[test] 1307 fn redirect_308_preserves_post() { 1308 assert_eq!(redirect_method(308, Method::Post), Method::Post); 1309 } 1310 1311 #[test] 1312 fn redirect_308_preserves_delete() { 1313 assert_eq!(redirect_method(308, Method::Delete), Method::Delete); 1314 } 1315 1316 #[test] 1317 fn redirect_301_get_stays_get() { 1318 assert_eq!(redirect_method(301, Method::Get), Method::Get); 1319 } 1320 1321 // -- redirect_preserves_body tests -- 1322 1323 #[test] 1324 fn redirect_301_drops_body() { 1325 assert!(!redirect_preserves_body(301)); 1326 } 1327 1328 #[test] 1329 fn redirect_302_drops_body() { 1330 assert!(!redirect_preserves_body(302)); 1331 } 1332 1333 #[test] 1334 fn redirect_303_drops_body() { 1335 assert!(!redirect_preserves_body(303)); 1336 } 1337 1338 #[test] 1339 fn redirect_307_preserves_body() { 1340 assert!(redirect_preserves_body(307)); 1341 } 1342 1343 #[test] 1344 fn redirect_308_preserves_body() { 1345 assert!(redirect_preserves_body(308)); 1346 } 1347 1348 // -- Cross-origin header stripping -- 1349 1350 #[test] 1351 fn cross_origin_strips_authorization() { 1352 let url_a = Url::parse("https://example.com/page").unwrap(); 1353 let url_b = Url::parse("https://other.com/page").unwrap(); 1354 assert!(!url_b.origin().same_origin(&url_a.origin())); 1355 1356 let mut headers = Headers::new(); 1357 headers.add("Authorization", "Bearer token123"); 1358 headers.add("Accept", "text/html"); 1359 1360 // Simulate cross-origin redirect stripping 1361 if !url_b.origin().same_origin(&url_a.origin()) { 1362 headers.remove("Authorization"); 1363 } 1364 1365 assert!(headers.get("Authorization").is_none()); 1366 assert_eq!(headers.get("Accept"), Some("text/html")); 1367 } 1368 1369 #[test] 1370 fn same_origin_preserves_authorization() { 1371 let url_a = Url::parse("https://example.com/page1").unwrap(); 1372 let url_b = Url::parse("https://example.com/page2").unwrap(); 1373 assert!(url_b.origin().same_origin(&url_a.origin())); 1374 1375 let mut headers = Headers::new(); 1376 headers.add("Authorization", "Bearer token123"); 1377 1378 if !url_b.origin().same_origin(&url_a.origin()) { 1379 headers.remove("Authorization"); 1380 } 1381 1382 assert_eq!(headers.get("Authorization"), Some("Bearer token123")); 1383 } 1384 1385 // -- Redirect loop detection -- 1386 1387 #[test] 1388 fn redirect_loop_detected_in_visited_urls() { 1389 let mut visited = vec!["https://example.com/a".to_string()]; 1390 let next = "https://example.com/a".to_string(); 1391 assert!(visited.contains(&next)); 1392 1393 // Unique URL is not a loop 1394 let next2 = "https://example.com/b".to_string(); 1395 assert!(!visited.contains(&next2)); 1396 visited.push(next2); 1397 assert_eq!(visited.len(), 2); 1398 } 1399}