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

Configure Feed

Select the types of activity you want to include in your feed.

Implement HTTP/1.1 client with connection pooling

High-level HTTP client in the net crate bringing together TCP, TLS 1.3,
DNS, URL parsing, and HTTP message handling:

- HttpClient with get(), post(), request() methods
- Scheme handling: http:// (plain TCP) and https:// (TLS)
- Connection pooling by (host, port, is_tls) with max idle time and
max connections per host
- Redirect following (301, 302, 307, 308) with configurable max
- Configurable connect and read timeouts
- Streaming response reader: header detection, Content-Length,
chunked Transfer-Encoding, and read-until-close strategies
- std::io::Read and Write trait impls for TcpConnection

30+ unit tests covering error types, configuration, connection pool,
URL path building, header parsing, body strategy selection, and
chunked terminator detection.

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

authored by

Pierre Le Fevre
Claude Opus 4.6
and committed by tangled.org d9195fac c7955003

+875
+858
crates/net/src/client.rs
··· 1 + //! High-level HTTP/1.1 client with connection pooling. 2 + //! 3 + //! Brings together TCP, TLS 1.3, DNS, URL parsing, and HTTP message 4 + //! parsing into a single `HttpClient` that can fetch HTTP and HTTPS URLs. 5 + 6 + use std::collections::HashMap; 7 + use std::fmt; 8 + use std::io; 9 + use std::time::{Duration, Instant}; 10 + 11 + use we_url::Url; 12 + 13 + use crate::http::{self, Headers, HttpResponse, Method}; 14 + use crate::tcp::{self, TcpConnection}; 15 + use crate::tls::handshake::{self, HandshakeError, TlsStream}; 16 + 17 + // --------------------------------------------------------------------------- 18 + // Constants 19 + // --------------------------------------------------------------------------- 20 + 21 + const DEFAULT_MAX_REDIRECTS: u32 = 10; 22 + const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(30); 23 + const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(30); 24 + const DEFAULT_MAX_IDLE_TIME: Duration = Duration::from_secs(60); 25 + const DEFAULT_MAX_PER_HOST: usize = 6; 26 + const READ_BUF_SIZE: usize = 8192; 27 + 28 + // --------------------------------------------------------------------------- 29 + // Error type 30 + // --------------------------------------------------------------------------- 31 + 32 + /// Errors that can occur during an HTTP client operation. 33 + #[derive(Debug)] 34 + pub enum ClientError { 35 + /// URL is invalid or missing required components. 36 + InvalidUrl(String), 37 + /// Unsupported URL scheme. 38 + UnsupportedScheme(String), 39 + /// TCP connection error. 40 + Tcp(tcp::NetError), 41 + /// TLS handshake error. 42 + Tls(HandshakeError), 43 + /// HTTP parsing error. 44 + Http(http::HttpError), 45 + /// Too many redirects. 46 + TooManyRedirects, 47 + /// Connection was closed unexpectedly. 48 + ConnectionClosed, 49 + /// I/O error. 50 + Io(io::Error), 51 + } 52 + 53 + impl fmt::Display for ClientError { 54 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 55 + match self { 56 + Self::InvalidUrl(s) => write!(f, "invalid URL: {s}"), 57 + Self::UnsupportedScheme(s) => write!(f, "unsupported scheme: {s}"), 58 + Self::Tcp(e) => write!(f, "TCP error: {e}"), 59 + Self::Tls(e) => write!(f, "TLS error: {e}"), 60 + Self::Http(e) => write!(f, "HTTP error: {e}"), 61 + Self::TooManyRedirects => write!(f, "too many redirects"), 62 + Self::ConnectionClosed => write!(f, "connection closed"), 63 + Self::Io(e) => write!(f, "I/O error: {e}"), 64 + } 65 + } 66 + } 67 + 68 + impl From<tcp::NetError> for ClientError { 69 + fn from(e: tcp::NetError) -> Self { 70 + Self::Tcp(e) 71 + } 72 + } 73 + 74 + impl From<HandshakeError> for ClientError { 75 + fn from(e: HandshakeError) -> Self { 76 + Self::Tls(e) 77 + } 78 + } 79 + 80 + impl From<http::HttpError> for ClientError { 81 + fn from(e: http::HttpError) -> Self { 82 + Self::Http(e) 83 + } 84 + } 85 + 86 + impl From<io::Error> for ClientError { 87 + fn from(e: io::Error) -> Self { 88 + Self::Io(e) 89 + } 90 + } 91 + 92 + pub type Result<T> = std::result::Result<T, ClientError>; 93 + 94 + // --------------------------------------------------------------------------- 95 + // Connection abstraction 96 + // --------------------------------------------------------------------------- 97 + 98 + /// A connection that can be either plain TCP or TLS-encrypted. 99 + enum Connection { 100 + Plain(TcpConnection), 101 + Tls(TlsStream<TcpConnection>), 102 + } 103 + 104 + impl Connection { 105 + fn read(&mut self, buf: &mut [u8]) -> Result<usize> { 106 + match self { 107 + Self::Plain(tcp) => tcp.read(buf).map_err(ClientError::Tcp), 108 + Self::Tls(tls) => tls.read(buf).map_err(ClientError::Tls), 109 + } 110 + } 111 + 112 + fn write_all(&mut self, data: &[u8]) -> Result<()> { 113 + match self { 114 + Self::Plain(tcp) => tcp.write_all(data).map_err(ClientError::Tcp), 115 + Self::Tls(tls) => tls.write_all(data).map_err(ClientError::Tls), 116 + } 117 + } 118 + 119 + fn flush(&mut self) -> Result<()> { 120 + match self { 121 + Self::Plain(tcp) => tcp.flush().map_err(ClientError::Tcp), 122 + Self::Tls(_) => Ok(()), // TLS writes are flushed per record 123 + } 124 + } 125 + 126 + fn set_read_timeout(&self, duration: Option<Duration>) -> Result<()> { 127 + match self { 128 + Self::Plain(tcp) => tcp.set_read_timeout(duration).map_err(ClientError::Tcp), 129 + Self::Tls(tls) => tls 130 + .stream() 131 + .set_read_timeout(duration) 132 + .map_err(ClientError::Tcp), 133 + } 134 + } 135 + } 136 + 137 + // --------------------------------------------------------------------------- 138 + // Connection pool 139 + // --------------------------------------------------------------------------- 140 + 141 + /// Key for pooling connections by origin. 142 + #[derive(Hash, Eq, PartialEq, Clone, Debug)] 143 + struct ConnectionKey { 144 + host: String, 145 + port: u16, 146 + is_tls: bool, 147 + } 148 + 149 + /// A pooled connection with its idle timestamp. 150 + struct PooledConnection { 151 + conn: Connection, 152 + idle_since: Instant, 153 + } 154 + 155 + /// Pool of idle HTTP connections for reuse. 156 + struct ConnectionPool { 157 + connections: HashMap<ConnectionKey, Vec<PooledConnection>>, 158 + max_idle_time: Duration, 159 + max_per_host: usize, 160 + } 161 + 162 + impl ConnectionPool { 163 + fn new(max_idle_time: Duration, max_per_host: usize) -> Self { 164 + Self { 165 + connections: HashMap::new(), 166 + max_idle_time, 167 + max_per_host, 168 + } 169 + } 170 + 171 + /// Take an idle connection for the given key, if one is available. 172 + fn take(&mut self, key: &ConnectionKey) -> Option<Connection> { 173 + let entries = self.connections.get_mut(key)?; 174 + let now = Instant::now(); 175 + 176 + // Remove expired connections 177 + entries.retain(|pc| now.duration_since(pc.idle_since) < self.max_idle_time); 178 + 179 + // Take the most recently idled connection 180 + entries.pop().map(|pc| pc.conn) 181 + } 182 + 183 + /// Return a connection to the pool. 184 + fn put(&mut self, key: ConnectionKey, conn: Connection) { 185 + let entries = self.connections.entry(key).or_default(); 186 + 187 + // Evict oldest if at capacity 188 + if entries.len() >= self.max_per_host { 189 + entries.remove(0); 190 + } 191 + 192 + entries.push(PooledConnection { 193 + conn, 194 + idle_since: Instant::now(), 195 + }); 196 + } 197 + } 198 + 199 + // --------------------------------------------------------------------------- 200 + // HttpClient 201 + // --------------------------------------------------------------------------- 202 + 203 + /// High-level HTTP/1.1 client with connection pooling and redirect following. 204 + pub struct HttpClient { 205 + pool: ConnectionPool, 206 + max_redirects: u32, 207 + connect_timeout: Duration, 208 + read_timeout: Duration, 209 + } 210 + 211 + impl HttpClient { 212 + /// Create a new HTTP client with default settings. 213 + pub fn new() -> Self { 214 + Self { 215 + pool: ConnectionPool::new(DEFAULT_MAX_IDLE_TIME, DEFAULT_MAX_PER_HOST), 216 + max_redirects: DEFAULT_MAX_REDIRECTS, 217 + connect_timeout: DEFAULT_CONNECT_TIMEOUT, 218 + read_timeout: DEFAULT_READ_TIMEOUT, 219 + } 220 + } 221 + 222 + /// Set the maximum number of redirects to follow. 223 + pub fn set_max_redirects(&mut self, max: u32) { 224 + self.max_redirects = max; 225 + } 226 + 227 + /// Set the connection timeout. 228 + pub fn set_connect_timeout(&mut self, timeout: Duration) { 229 + self.connect_timeout = timeout; 230 + } 231 + 232 + /// Set the read timeout. 233 + pub fn set_read_timeout(&mut self, timeout: Duration) { 234 + self.read_timeout = timeout; 235 + } 236 + 237 + /// Perform an HTTP GET request. 238 + pub fn get(&mut self, url: &Url) -> Result<HttpResponse> { 239 + self.request(Method::Get, url, &Headers::new(), None) 240 + } 241 + 242 + /// Perform an HTTP POST request. 243 + pub fn post(&mut self, url: &Url, body: &[u8], content_type: &str) -> Result<HttpResponse> { 244 + let mut headers = Headers::new(); 245 + headers.add("Content-Type", content_type); 246 + self.request(Method::Post, url, &headers, Some(body)) 247 + } 248 + 249 + /// Perform an HTTP request with full control over method, headers, and body. 250 + /// 251 + /// Follows redirects (301, 302, 307, 308) up to `max_redirects`. 252 + pub fn request( 253 + &mut self, 254 + method: Method, 255 + url: &Url, 256 + headers: &Headers, 257 + body: Option<&[u8]>, 258 + ) -> Result<HttpResponse> { 259 + let mut current_url = url.clone(); 260 + let mut redirects = 0; 261 + 262 + loop { 263 + let resp = self.execute_request(method, &current_url, headers, body)?; 264 + 265 + // Check for redirects 266 + if matches!(resp.status_code, 301 | 302 | 307 | 308) { 267 + redirects += 1; 268 + if redirects > self.max_redirects { 269 + return Err(ClientError::TooManyRedirects); 270 + } 271 + 272 + if let Some(location) = resp.headers.get("Location") { 273 + // Resolve relative URLs against current URL 274 + current_url = Url::parse_with_base(location, &current_url) 275 + .or_else(|_| Url::parse(location)) 276 + .map_err(|_| { 277 + ClientError::InvalidUrl(format!( 278 + "invalid redirect location: {location}" 279 + )) 280 + })?; 281 + continue; 282 + } 283 + } 284 + 285 + return Ok(resp); 286 + } 287 + } 288 + 289 + /// Execute a single HTTP request (no redirect following). 290 + fn execute_request( 291 + &mut self, 292 + method: Method, 293 + url: &Url, 294 + headers: &Headers, 295 + body: Option<&[u8]>, 296 + ) -> Result<HttpResponse> { 297 + let scheme = url.scheme(); 298 + let is_tls = match scheme { 299 + "https" => true, 300 + "http" => false, 301 + other => return Err(ClientError::UnsupportedScheme(other.to_string())), 302 + }; 303 + 304 + let host = url 305 + .host_str() 306 + .ok_or_else(|| ClientError::InvalidUrl("missing host".to_string()))?; 307 + 308 + let port = url 309 + .port_or_default() 310 + .ok_or_else(|| ClientError::InvalidUrl("cannot determine port".to_string()))?; 311 + 312 + let path = request_path(url); 313 + 314 + let key = ConnectionKey { 315 + host: host.clone(), 316 + port, 317 + is_tls, 318 + }; 319 + 320 + // Try to reuse a pooled connection, fall back to new connection 321 + let mut conn = match self.pool.take(&key) { 322 + Some(conn) => conn, 323 + None => self.connect(&host, port, is_tls)?, 324 + }; 325 + 326 + conn.set_read_timeout(Some(self.read_timeout))?; 327 + 328 + // Serialize and send request 329 + let request_bytes = http::serialize_request(method, &path, &host, headers, body); 330 + conn.write_all(&request_bytes)?; 331 + conn.flush()?; 332 + 333 + // Read and parse response 334 + let response = read_response(&mut conn)?; 335 + 336 + // Return connection to pool if keep-alive 337 + if !response.connection_close() { 338 + self.pool.put(key, conn); 339 + } 340 + 341 + Ok(response) 342 + } 343 + 344 + /// Establish a new connection (plain TCP or TLS). 345 + fn connect(&self, host: &str, port: u16, is_tls: bool) -> Result<Connection> { 346 + let tcp = TcpConnection::connect_timeout(host, port, self.connect_timeout)?; 347 + 348 + if is_tls { 349 + let tls = handshake::connect(tcp, host)?; 350 + Ok(Connection::Tls(tls)) 351 + } else { 352 + Ok(Connection::Plain(tcp)) 353 + } 354 + } 355 + } 356 + 357 + impl Default for HttpClient { 358 + fn default() -> Self { 359 + Self::new() 360 + } 361 + } 362 + 363 + // --------------------------------------------------------------------------- 364 + // Helpers 365 + // --------------------------------------------------------------------------- 366 + 367 + /// Build the request path from a URL (path + query). 368 + fn request_path(url: &Url) -> String { 369 + let path = url.path(); 370 + let path = if path.is_empty() { "/" } else { &path }; 371 + match url.query() { 372 + Some(q) => format!("{path}?{q}"), 373 + None => path.to_string(), 374 + } 375 + } 376 + 377 + /// Read a complete HTTP response from a connection. 378 + /// 379 + /// Reads the header section first, then determines the body length from 380 + /// headers and reads the appropriate amount of body data. 381 + fn read_response(conn: &mut Connection) -> Result<HttpResponse> { 382 + let mut buf = Vec::with_capacity(READ_BUF_SIZE); 383 + let mut temp = [0u8; READ_BUF_SIZE]; 384 + 385 + // Phase 1: Read until we have the complete header section (\r\n\r\n) 386 + let header_end = loop { 387 + let n = conn.read(&mut temp)?; 388 + if n == 0 { 389 + if buf.is_empty() { 390 + return Err(ClientError::ConnectionClosed); 391 + } 392 + break find_header_end(&buf); 393 + } 394 + buf.extend_from_slice(&temp[..n]); 395 + if let Some(pos) = find_header_end(&buf) { 396 + break Some(pos); 397 + } 398 + }; 399 + 400 + let header_end = header_end.ok_or(ClientError::Http(http::HttpError::Incomplete))?; 401 + let body_start = header_end + 4; // skip \r\n\r\n 402 + 403 + // Quick-parse headers to determine body strategy 404 + let header_str = std::str::from_utf8(&buf[..header_end]).map_err(|_| { 405 + ClientError::Http(http::HttpError::Parse( 406 + "invalid UTF-8 in headers".to_string(), 407 + )) 408 + })?; 409 + 410 + let status_code = parse_status_code(header_str)?; 411 + let body_strategy = determine_body_strategy(header_str, status_code); 412 + 413 + // Phase 2: Read body according to strategy 414 + match body_strategy { 415 + BodyStrategy::NoBody => { 416 + // Truncate buffer to just headers + \r\n\r\n 417 + buf.truncate(body_start); 418 + } 419 + BodyStrategy::ContentLength(len) => { 420 + let total_needed = body_start + len; 421 + while buf.len() < total_needed { 422 + let n = conn.read(&mut temp)?; 423 + if n == 0 { 424 + break; 425 + } 426 + buf.extend_from_slice(&temp[..n]); 427 + } 428 + } 429 + BodyStrategy::Chunked => { 430 + // Read until we find the terminating 0-length chunk 431 + while !has_chunked_terminator(&buf[body_start..]) { 432 + let n = conn.read(&mut temp)?; 433 + if n == 0 { 434 + break; 435 + } 436 + buf.extend_from_slice(&temp[..n]); 437 + } 438 + } 439 + BodyStrategy::ReadUntilClose => { 440 + // Read until EOF 441 + loop { 442 + let n = conn.read(&mut temp)?; 443 + if n == 0 { 444 + break; 445 + } 446 + buf.extend_from_slice(&temp[..n]); 447 + } 448 + } 449 + } 450 + 451 + // Parse the complete response 452 + http::parse_response(&buf).map_err(ClientError::Http) 453 + } 454 + 455 + /// Find the end of the HTTP header section (\r\n\r\n). 456 + /// Returns the position of the first \r in the \r\n\r\n sequence. 457 + fn find_header_end(data: &[u8]) -> Option<usize> { 458 + data.windows(4).position(|w| w == b"\r\n\r\n") 459 + } 460 + 461 + /// Extract status code from the first line of headers. 462 + fn parse_status_code(headers: &str) -> Result<u16> { 463 + let first_line = headers.lines().next().unwrap_or(""); 464 + let mut parts = first_line.splitn(3, ' '); 465 + let _version = parts.next(); 466 + let code_str = parts.next().unwrap_or(""); 467 + code_str.parse().map_err(|_| { 468 + ClientError::Http(http::HttpError::MalformedStatusLine(first_line.to_string())) 469 + }) 470 + } 471 + 472 + /// Strategy for reading the response body. 473 + enum BodyStrategy { 474 + NoBody, 475 + ContentLength(usize), 476 + Chunked, 477 + ReadUntilClose, 478 + } 479 + 480 + /// Determine how to read the body from headers. 481 + fn determine_body_strategy(headers: &str, status_code: u16) -> BodyStrategy { 482 + // 1xx, 204, 304 have no body 483 + if status_code < 200 || status_code == 204 || status_code == 304 { 484 + return BodyStrategy::NoBody; 485 + } 486 + 487 + // Check for Transfer-Encoding: chunked 488 + for line in headers.split("\r\n").skip(1) { 489 + if let Some(val) = line 490 + .strip_prefix("Transfer-Encoding:") 491 + .or_else(|| line.strip_prefix("transfer-encoding:")) 492 + { 493 + if val.trim().eq_ignore_ascii_case("chunked") { 494 + return BodyStrategy::Chunked; 495 + } 496 + } 497 + } 498 + 499 + // Check for Content-Length 500 + for line in headers.split("\r\n").skip(1) { 501 + if let Some(val) = line 502 + .strip_prefix("Content-Length:") 503 + .or_else(|| line.strip_prefix("content-length:")) 504 + { 505 + if let Ok(len) = val.trim().parse::<usize>() { 506 + return BodyStrategy::ContentLength(len); 507 + } 508 + } 509 + } 510 + 511 + BodyStrategy::ReadUntilClose 512 + } 513 + 514 + /// Check if chunked body data contains the terminating `0\r\n\r\n`. 515 + fn has_chunked_terminator(data: &[u8]) -> bool { 516 + // Look for \r\n0\r\n\r\n (the final chunk after some data) or 0\r\n\r\n at start 517 + data.windows(5).any(|w| w == b"0\r\n\r\n") 518 + } 519 + 520 + // --------------------------------------------------------------------------- 521 + // Tests 522 + // --------------------------------------------------------------------------- 523 + 524 + #[cfg(test)] 525 + mod tests { 526 + use super::*; 527 + 528 + // -- ClientError Display tests -- 529 + 530 + #[test] 531 + fn error_display_invalid_url() { 532 + let e = ClientError::InvalidUrl("bad".to_string()); 533 + assert_eq!(e.to_string(), "invalid URL: bad"); 534 + } 535 + 536 + #[test] 537 + fn error_display_unsupported_scheme() { 538 + let e = ClientError::UnsupportedScheme("ftp".to_string()); 539 + assert_eq!(e.to_string(), "unsupported scheme: ftp"); 540 + } 541 + 542 + #[test] 543 + fn error_display_too_many_redirects() { 544 + let e = ClientError::TooManyRedirects; 545 + assert_eq!(e.to_string(), "too many redirects"); 546 + } 547 + 548 + #[test] 549 + fn error_display_connection_closed() { 550 + let e = ClientError::ConnectionClosed; 551 + assert_eq!(e.to_string(), "connection closed"); 552 + } 553 + 554 + // -- HttpClient configuration tests -- 555 + 556 + #[test] 557 + fn client_default() { 558 + let client = HttpClient::default(); 559 + assert_eq!(client.max_redirects, DEFAULT_MAX_REDIRECTS); 560 + assert_eq!(client.connect_timeout, DEFAULT_CONNECT_TIMEOUT); 561 + assert_eq!(client.read_timeout, DEFAULT_READ_TIMEOUT); 562 + } 563 + 564 + #[test] 565 + fn client_set_max_redirects() { 566 + let mut client = HttpClient::new(); 567 + client.set_max_redirects(5); 568 + assert_eq!(client.max_redirects, 5); 569 + } 570 + 571 + #[test] 572 + fn client_set_connect_timeout() { 573 + let mut client = HttpClient::new(); 574 + client.set_connect_timeout(Duration::from_secs(10)); 575 + assert_eq!(client.connect_timeout, Duration::from_secs(10)); 576 + } 577 + 578 + #[test] 579 + fn client_set_read_timeout() { 580 + let mut client = HttpClient::new(); 581 + client.set_read_timeout(Duration::from_secs(5)); 582 + assert_eq!(client.read_timeout, Duration::from_secs(5)); 583 + } 584 + 585 + // -- ConnectionPool tests -- 586 + 587 + #[test] 588 + fn pool_take_empty() { 589 + let mut pool = ConnectionPool::new(Duration::from_secs(60), 6); 590 + let key = ConnectionKey { 591 + host: "example.com".to_string(), 592 + port: 80, 593 + is_tls: false, 594 + }; 595 + assert!(pool.take(&key).is_none()); 596 + } 597 + 598 + #[test] 599 + fn pool_connections_map_starts_empty() { 600 + let pool = ConnectionPool::new(Duration::from_secs(60), 6); 601 + assert!(pool.connections.is_empty()); 602 + } 603 + 604 + // -- request_path tests -- 605 + 606 + #[test] 607 + fn request_path_simple() { 608 + let url = Url::parse("http://example.com/path").unwrap(); 609 + assert_eq!(request_path(&url), "/path"); 610 + } 611 + 612 + #[test] 613 + fn request_path_with_query() { 614 + let url = Url::parse("http://example.com/path?key=value").unwrap(); 615 + assert_eq!(request_path(&url), "/path?key=value"); 616 + } 617 + 618 + #[test] 619 + fn request_path_root() { 620 + let url = Url::parse("http://example.com").unwrap(); 621 + assert_eq!(request_path(&url), "/"); 622 + } 623 + 624 + #[test] 625 + fn request_path_deep() { 626 + let url = Url::parse("http://example.com/a/b/c").unwrap(); 627 + assert_eq!(request_path(&url), "/a/b/c"); 628 + } 629 + 630 + // -- find_header_end tests -- 631 + 632 + #[test] 633 + fn find_header_end_found() { 634 + let data = b"HTTP/1.1 200 OK\r\nHost: x\r\n\r\nbody"; 635 + assert_eq!(find_header_end(data), Some(24)); 636 + } 637 + 638 + #[test] 639 + fn find_header_end_not_found() { 640 + let data = b"HTTP/1.1 200 OK\r\nHost: x\r\n"; 641 + assert_eq!(find_header_end(data), None); 642 + } 643 + 644 + #[test] 645 + fn find_header_end_empty() { 646 + assert_eq!(find_header_end(b""), None); 647 + } 648 + 649 + #[test] 650 + fn find_header_end_minimal() { 651 + let data = b"\r\n\r\n"; 652 + assert_eq!(find_header_end(data), Some(0)); 653 + } 654 + 655 + // -- parse_status_code tests -- 656 + 657 + #[test] 658 + fn parse_status_code_200() { 659 + assert_eq!(parse_status_code("HTTP/1.1 200 OK").unwrap(), 200); 660 + } 661 + 662 + #[test] 663 + fn parse_status_code_404() { 664 + assert_eq!(parse_status_code("HTTP/1.1 404 Not Found").unwrap(), 404); 665 + } 666 + 667 + #[test] 668 + fn parse_status_code_301() { 669 + assert_eq!( 670 + parse_status_code("HTTP/1.1 301 Moved Permanently").unwrap(), 671 + 301 672 + ); 673 + } 674 + 675 + #[test] 676 + fn parse_status_code_invalid() { 677 + assert!(parse_status_code("INVALID").is_err()); 678 + } 679 + 680 + // -- determine_body_strategy tests -- 681 + 682 + #[test] 683 + fn strategy_no_body_204() { 684 + let headers = "HTTP/1.1 204 No Content\r\nConnection: keep-alive"; 685 + assert!(matches!( 686 + determine_body_strategy(headers, 204), 687 + BodyStrategy::NoBody 688 + )); 689 + } 690 + 691 + #[test] 692 + fn strategy_no_body_304() { 693 + let headers = "HTTP/1.1 304 Not Modified\r\nETag: \"abc\""; 694 + assert!(matches!( 695 + determine_body_strategy(headers, 304), 696 + BodyStrategy::NoBody 697 + )); 698 + } 699 + 700 + #[test] 701 + fn strategy_no_body_1xx() { 702 + let headers = "HTTP/1.1 100 Continue"; 703 + assert!(matches!( 704 + determine_body_strategy(headers, 100), 705 + BodyStrategy::NoBody 706 + )); 707 + } 708 + 709 + #[test] 710 + fn strategy_content_length() { 711 + let headers = "HTTP/1.1 200 OK\r\nContent-Length: 42"; 712 + match determine_body_strategy(headers, 200) { 713 + BodyStrategy::ContentLength(42) => {} 714 + _ => panic!("expected ContentLength(42)"), 715 + } 716 + } 717 + 718 + #[test] 719 + fn strategy_chunked() { 720 + let headers = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked"; 721 + assert!(matches!( 722 + determine_body_strategy(headers, 200), 723 + BodyStrategy::Chunked 724 + )); 725 + } 726 + 727 + #[test] 728 + fn strategy_read_until_close() { 729 + let headers = "HTTP/1.1 200 OK\r\nConnection: close"; 730 + assert!(matches!( 731 + determine_body_strategy(headers, 200), 732 + BodyStrategy::ReadUntilClose 733 + )); 734 + } 735 + 736 + // -- has_chunked_terminator tests -- 737 + 738 + #[test] 739 + fn chunked_terminator_present() { 740 + assert!(has_chunked_terminator(b"5\r\nHello\r\n0\r\n\r\n")); 741 + } 742 + 743 + #[test] 744 + fn chunked_terminator_at_start() { 745 + assert!(has_chunked_terminator(b"0\r\n\r\n")); 746 + } 747 + 748 + #[test] 749 + fn chunked_terminator_missing() { 750 + assert!(!has_chunked_terminator(b"5\r\nHello\r\n")); 751 + } 752 + 753 + #[test] 754 + fn chunked_terminator_empty() { 755 + assert!(!has_chunked_terminator(b"")); 756 + } 757 + 758 + // -- ConnectionKey equality tests -- 759 + 760 + #[test] 761 + fn connection_key_equal() { 762 + let a = ConnectionKey { 763 + host: "example.com".to_string(), 764 + port: 443, 765 + is_tls: true, 766 + }; 767 + let b = ConnectionKey { 768 + host: "example.com".to_string(), 769 + port: 443, 770 + is_tls: true, 771 + }; 772 + assert_eq!(a, b); 773 + } 774 + 775 + #[test] 776 + fn connection_key_different_host() { 777 + let a = ConnectionKey { 778 + host: "a.com".to_string(), 779 + port: 443, 780 + is_tls: true, 781 + }; 782 + let b = ConnectionKey { 783 + host: "b.com".to_string(), 784 + port: 443, 785 + is_tls: true, 786 + }; 787 + assert_ne!(a, b); 788 + } 789 + 790 + #[test] 791 + fn connection_key_different_port() { 792 + let a = ConnectionKey { 793 + host: "example.com".to_string(), 794 + port: 80, 795 + is_tls: false, 796 + }; 797 + let b = ConnectionKey { 798 + host: "example.com".to_string(), 799 + port: 8080, 800 + is_tls: false, 801 + }; 802 + assert_ne!(a, b); 803 + } 804 + 805 + #[test] 806 + fn connection_key_different_tls() { 807 + let a = ConnectionKey { 808 + host: "example.com".to_string(), 809 + port: 443, 810 + is_tls: true, 811 + }; 812 + let b = ConnectionKey { 813 + host: "example.com".to_string(), 814 + port: 443, 815 + is_tls: false, 816 + }; 817 + assert_ne!(a, b); 818 + } 819 + 820 + // -- Header parsing strategy with case variations -- 821 + 822 + #[test] 823 + fn strategy_content_length_lowercase() { 824 + let headers = "HTTP/1.1 200 OK\r\ncontent-length: 10"; 825 + match determine_body_strategy(headers, 200) { 826 + BodyStrategy::ContentLength(10) => {} 827 + _ => panic!("expected ContentLength(10)"), 828 + } 829 + } 830 + 831 + #[test] 832 + fn strategy_chunked_lowercase() { 833 + let headers = "HTTP/1.1 200 OK\r\ntransfer-encoding: chunked"; 834 + assert!(matches!( 835 + determine_body_strategy(headers, 200), 836 + BodyStrategy::Chunked 837 + )); 838 + } 839 + 840 + #[test] 841 + fn strategy_chunked_uppercase_value() { 842 + let headers = "HTTP/1.1 200 OK\r\nTransfer-Encoding: CHUNKED"; 843 + assert!(matches!( 844 + determine_body_strategy(headers, 200), 845 + BodyStrategy::Chunked 846 + )); 847 + } 848 + 849 + // -- URL scheme handling -- 850 + 851 + #[test] 852 + fn unsupported_scheme_error() { 853 + let mut client = HttpClient::new(); 854 + let url = Url::parse("ftp://example.com/file").unwrap(); 855 + let result = client.get(&url); 856 + assert!(matches!(result, Err(ClientError::UnsupportedScheme(_)))); 857 + } 858 + }
+1
crates/net/src/lib.rs
··· 1 1 //! TCP, DNS, pure-Rust TLS 1.3, HTTP/1.1, HTTP/2. 2 2 3 + pub mod client; 3 4 pub mod dns; 4 5 pub mod http; 5 6 pub mod tcp;
+16
crates/net/src/tcp.rs
··· 174 174 } 175 175 } 176 176 177 + impl Read for TcpConnection { 178 + fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { 179 + self.stream.read(buf) 180 + } 181 + } 182 + 183 + impl Write for TcpConnection { 184 + fn write(&mut self, buf: &[u8]) -> io::Result<usize> { 185 + self.stream.write(buf) 186 + } 187 + 188 + fn flush(&mut self) -> io::Result<()> { 189 + self.stream.flush() 190 + } 191 + } 192 + 177 193 impl fmt::Debug for TcpConnection { 178 194 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 179 195 f.debug_struct("TcpConnection")