http_quik/client/pool.rs
1use bytes::Bytes;
2use cookie_store::CookieStore;
3use std::collections::{HashMap, HashSet};
4use std::net::SocketAddr;
5use std::sync::{Arc, Mutex, RwLock};
6use tokio::net::UdpSocket;
7use tokio::sync::mpsc;
8use url::Url;
9
10use crate::client::connector::{connect, QuikConnection};
11use crate::client::proxy::Proxy;
12use crate::client::quic::QuicSession;
13use crate::client::request::{inject_chrome_headers, RequestContext};
14use crate::client::response::Response;
15use crate::error::{Error, Result};
16use crate::profile::ChromeProfile;
17
18/// Thread-safe registry for dynamically discovered HTTP/3 Alt-Svc advertisements.
19///
20/// Under RFC 9114, origins advertise QUIC support via the `Alt-Svc` HTTP header.
21/// This cache records valid `h3` mappings to preemptively bypass TCP and TLS handshakes
22/// on subsequent requests to the same origin, establishing QUIC connections directly.
23///
24/// The cache employs a reader-writer lock design to eliminate contention on the hot path.
25/// Reads (origin lookups) are wait-free under shared locks, while exclusive locks are
26/// strictly reserved for initial discovery or deterministic eviction during network drops.
27#[derive(Clone)]
28pub struct AltSvcCache {
29 entries: Arc<RwLock<HashMap<String, String>>>,
30}
31
32impl AltSvcCache {
33 /// Instantiates a new thread-safe in-memory cache.
34 pub fn new() -> Self {
35 Self {
36 entries: Arc::new(RwLock::new(HashMap::new())),
37 }
38 }
39
40 /// Retrieves cached H3 signals for a target origin string.
41 pub fn get(&self, origin: &str) -> Option<String> {
42 let guard = self.entries.read().ok()?;
43 guard.get(origin).cloned()
44 }
45
46 /// Stores/updates an Alt-Svc advertisement.
47 pub fn insert(&self, origin: String, alt_svc: String) {
48 if let Ok(mut guard) = self.entries.write() {
49 guard.insert(origin, alt_svc);
50 }
51 }
52
53 /// Evicts an origin from the cache to trigger protocol fallback.
54 ///
55 /// Used defensively when a QUIC handshake fails or a middlebox silently drops
56 /// UDP packets. Eviction ensures that subsequent requests to the affected origin
57 /// are deterministically routed over HTTP/2 and TCP, maintaining connectivity
58 /// on restrictive networks.
59 pub fn remove(&self, origin: &str) {
60 if let Ok(mut guard) = self.entries.write() {
61 guard.remove(origin);
62 }
63 }
64}
65
66/// An abstract representation of an active, multiplexed connection session.
67///
68/// This enum enforces strict transport decoupling. The request runner interacts
69/// exclusively with the polymorphic `send` interface, completely abstracting whether
70/// the underlying byte stream is multiplexed over HTTP/2 (TCP/TLS) or HTTP/3 (UDP/QUIC).
71/// It ensures connection lifecycles and multiplexing limits are handled seamlessly
72/// behind a unified boundary.
73#[derive(Clone)]
74pub enum PooledConnection {
75 /// Persistent HTTP/2 multiplexed TCP/TLS transport.
76 Http2(QuikConnection),
77 /// Stealth HTTP/3 multiplexed UDP/QUIC transport.
78 Http3(QuicSession),
79}
80
81impl PooledConnection {
82 /// Dispatches an HTTP request over the active session.
83 pub async fn send(
84 &mut self,
85 request: http::Request<()>,
86 body: Option<Bytes>,
87 ) -> Result<Response> {
88 match self {
89 PooledConnection::Http2(conn) => conn.send(request, body).await,
90 PooledConnection::Http3(conn) => conn.send(request, body).await,
91 }
92 }
93}
94
95/// Extracts the registrable domain (eTLD+1) from a bare hostname.
96///
97/// For simple TLDs (`.com`, `.net`, `.org`), returns the second-to-last label
98/// plus the TLD. For compound public suffixes (`.co.uk`, `.com.au`, `.co.jp`),
99/// returns the label preceding the compound suffix plus the suffix itself.
100///
101/// Examples:
102/// - `sub.example.com` → `example.com`
103/// - `a.b.example.co.uk` → `example.co.uk`
104/// - `example.com` → `example.com`
105/// - `localhost` → `localhost`
106fn get_registrable_domain(domain: &str) -> &str {
107 // Compound (two-label) public suffixes commonly encountered on WAF-protected targets.
108 // This is not exhaustive but covers the vast majority of production traffic patterns.
109 const COMPOUND_TLDS: &[&str] = &[
110 "co.uk", "co.jp", "co.kr", "co.in", "co.id", "co.nz", "co.za", "co.th",
111 "com.au", "com.br", "com.cn", "com.mx", "com.tw", "com.sg", "com.hk",
112 "com.ar", "com.co", "com.my", "com.ph", "com.pk", "com.tr", "com.ua",
113 "com.vn", "com.ng", "com.eg", "com.sa",
114 "org.uk", "org.au", "net.au", "net.nz",
115 "ac.uk", "gov.uk", "gov.au",
116 "ne.jp", "or.jp",
117 ];
118
119 let parts: Vec<&str> = domain.split('.').collect();
120 if parts.len() <= 2 {
121 // Already at most a TLD + one label (e.g. "example.com" or "localhost").
122 return domain;
123 }
124
125 // Check whether the trailing two labels form a known compound suffix.
126 let last_two = format!("{}.{}", parts[parts.len() - 2], parts[parts.len() - 1]);
127 for &compound in COMPOUND_TLDS {
128 if last_two == compound {
129 if parts.len() >= 3 {
130 // Registrable domain = label before the compound suffix + compound suffix.
131 let reg_label = parts[parts.len() - 3];
132 let start = domain.len() - last_two.len() - 1 - reg_label.len();
133 return &domain[start..];
134 }
135 return domain;
136 }
137 }
138
139 // Simple TLD: registrable domain = second-to-last label + TLD.
140 let tld_len = parts[parts.len() - 1].len();
141 let sld_len = parts[parts.len() - 2].len();
142 let start = domain.len() - tld_len - sld_len - 1;
143 &domain[start..]
144}
145
146type SharedConnection = Arc<tokio::sync::Mutex<Option<PooledConnection>>>;
147type ConnectionPool = Arc<Mutex<HashMap<String, SharedConnection>>>;
148
149/// A stateful HTTP client engine enforcing deterministic Chrome identity parity.
150///
151/// The `Client` is the primary interface for managing cross-origin requests. It maintains
152/// global state across its clones, enabling shared connection pooling and cookie persistence.
153/// Key operational guarantees include:
154///
155/// - **Transport Decoupling**: Transparently routes requests over H2 or H3 based on cache states.
156/// - **Connection Pooling**: Reuses established multiplexed streams isolated by proxy and origin.
157/// - **Automated State Tracking**: Synchronizes cookies, redirects, and client-hints seamlessly.
158#[derive(Clone)]
159pub struct Client {
160 /// A synchronized hash map of active connections, strictly keyed by protocol, proxy, and origin.
161 pool: ConnectionPool,
162 /// The canonical identity profile governing TLS handshakes, H2 parameters, and HTTP metadata.
163 profile: ChromeProfile,
164 /// An optional proxy route applied uniformly to all outbound connections from this client.
165 proxy: Option<Proxy>,
166 /// A synchronized cookie jar enforcing RFC 6265 storage and cross-request persistence.
167 pub cookie_store: Arc<RwLock<CookieStore>>,
168 /// A cache tracking origins that explicitly solicited dynamic client hints (e.g. `Accept-CH`).
169 pub hint_cache: Arc<RwLock<HashSet<String>>>,
170 /// Thread-safe registry mapping origins to discovered `Alt-Svc` UDP/QUIC endpoints.
171 pub alt_svc_cache: AltSvcCache,
172 /// A synchronized cache of TLS sessions for resumption, keyed by origin/host.
173 pub tls_session_cache: Arc<Mutex<HashMap<String, boring::ssl::SslSession>>>,
174}
175
176impl Default for Client {
177 fn default() -> Self {
178 Self::new()
179 }
180}
181
182impl Client {
183 /// Creates a new `Client` with a Chrome profile auto-matched to the host OS.
184 ///
185 /// By default, this constructor initializes a ClientBuilder and compiles the default
186 /// profile to the active host platform (e.g. `chrome_148` on Linux/macOS/Windows).
187 /// If builder initialization fails, it statefully falls back to the static `chrome_148`
188 /// auto-profile to guarantee uninterrupted transport-level compliance.
189 pub fn new() -> Self {
190 Self::builder().build().unwrap_or_else(|_| Client {
191 pool: Arc::new(Mutex::new(HashMap::new())),
192 profile: crate::profile::chrome_148::profile_auto(),
193 proxy: None,
194 cookie_store: Arc::new(RwLock::new(CookieStore::default())),
195 hint_cache: Arc::new(RwLock::new(HashSet::new())),
196 alt_svc_cache: AltSvcCache::new(),
197 tls_session_cache: Arc::new(Mutex::new(HashMap::new())),
198 })
199 }
200
201 /// Returns a [`ClientBuilder`] to configure a specialized `Client` instance.
202 pub fn builder() -> ClientBuilder {
203 ClientBuilder::default()
204 }
205
206 /// Executes a GET request and follows redirects stealthily.
207 pub async fn get(&self, url: &str) -> Result<Response> {
208 self.execute_with_redirects("GET", url, None, RequestContext::Navigate)
209 .await
210 }
211
212 /// Executes a POST request and follows redirects stealthily.
213 pub async fn post(&self, url: &str, body: Bytes) -> Result<Response> {
214 self.execute_with_redirects("POST", url, Some(body), RequestContext::Navigate)
215 .await
216 }
217
218 /// Executes the primary request lifecycle, including automated redirect evaluation
219 /// and dual-stack protocol fallback.
220 ///
221 /// ### Connection Acquisition and Fallback Topology
222 /// 1. **Routing Phase**: Evaluates `AltSvcCache` to select the target transport (H2 vs H3).
223 /// 2. **Lock Serialization**: Acquires an origin-specific async mutex to prevent connection
224 /// storming when multiple tasks simultaneously fault on a new origin.
225 /// 3. **Graceful Degradation**: If an active HTTP/3 dial fails or a request drops mid-flight
226 /// due to UDP restrictions, the engine instantly evicts the Alt-Svc mapping and
227 /// transparently fails over to HTTP/2 over TCP with zero user-visible latency.
228 ///
229 /// Implements a strict limit of 10 redirects to prevent cyclical loops, applying
230 /// RFC 7231 method rotation and Chrome-parity cross-site referer truncation on each hop.
231 async fn execute_with_redirects(
232 &self,
233 initial_method: &str,
234 initial_url: &str,
235 initial_body: Option<Bytes>,
236 context: RequestContext,
237 ) -> Result<Response> {
238 let mut current_url_str = initial_url.to_string();
239 let mut current_method = initial_method.to_string();
240 let mut current_body = initial_body;
241 let mut previous_url_str: Option<String> = None;
242
243 let mut sec_fetch_site = "none".to_string();
244 let mut is_cross_site = false;
245
246 for hop in 0..10 {
247 let parsed_url =
248 Url::parse(¤t_url_str).map_err(|e| Error::InvalidUrl(e.to_string()))?;
249 let authority = parsed_url
250 .host_str()
251 .ok_or_else(|| Error::InvalidUrl("missing host".to_string()))?;
252 let port = parsed_url.port().unwrap_or_else(|| {
253 if parsed_url.scheme() == "http" {
254 80
255 } else {
256 443
257 }
258 });
259
260 // Isolate connection pools by proxy to prevent credential leakage or route mismatches.
261 let proxy_prefix = self
262 .proxy
263 .as_ref()
264 .map(|p| match p {
265 Proxy::Http(a) => format!("http://{}@", a),
266 Proxy::Socks5(a) => format!("socks5://{}@", a),
267 })
268 .unwrap_or_default();
269
270 // Differentiate H2 and H3 keys to isolate TCP and UDP multiplexers.
271 let origin_key = format!("{}:{}", authority, port);
272 let mut has_alt_svc = self.alt_svc_cache.get(&origin_key).is_some();
273 let transport_proto = if has_alt_svc { "h3" } else { "h2" };
274 let pool_key = format!("{}{}:{}#{}", proxy_prefix, authority, port, transport_proto);
275
276 // Extract cookies matched to the target domain.
277 let cookie_header = {
278 let store = self
279 .cookie_store
280 .read()
281 .map_err(|_| Error::Connect(std::io::Error::other("cookie store poisoned")))?;
282 let cookies: Vec<_> = store
283 .matches(&parsed_url)
284 .iter()
285 .map(|c| format!("{}={}", c.name(), c.value()))
286 .collect();
287 if cookies.is_empty() {
288 None
289 } else {
290 Some(cookies.join("; "))
291 }
292 };
293
294 // Injects Chrome-identical headers.
295 let is_initial = hop == 0;
296 let accept_ch = {
297 let cache = self.hint_cache.read().unwrap();
298 cache.contains(&parsed_url.origin().ascii_serialization())
299 };
300
301 // Strict-origin-when-cross-origin referer propagation.
302 let referer_to_send = previous_url_str.as_ref().map(|prev| {
303 if is_cross_site {
304 if let Ok(prev_url) = Url::parse(prev) {
305 return prev_url.origin().ascii_serialization() + "/";
306 }
307 }
308 prev.clone()
309 });
310
311 // Use an async Mutex per pool key to serialize connection establishment.
312 // This prevents connection storms when making parallel requests to a new origin.
313 let conn_mutex = {
314 let mut pool = self.pool.lock().map_err(|_| {
315 Error::Connect(std::io::Error::other("connection pool poisoned"))
316 })?;
317 pool.entry(pool_key.clone())
318 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(None)))
319 .clone()
320 };
321
322 let mut pooled_client = loop {
323 let conn_opt = {
324 let guard = conn_mutex.lock().await;
325 guard.as_ref().cloned()
326 };
327
328 if let Some(c) = conn_opt {
329 match c {
330 PooledConnection::Http2(mut conn) => {
331 // Rebuild the TCP stream if the socket was closed or encountered a TLS error.
332 match conn.h2.ready().await {
333 Ok(h2) => {
334 conn.h2 = h2;
335 break PooledConnection::Http2(conn);
336 }
337 Err(_) => {
338 let mut guard = conn_mutex.lock().await;
339 *guard = None;
340 }
341 }
342 }
343 PooledConnection::Http3(conn) => {
344 // HTTP/3 runs continuously via the background UDP worker task.
345 // Handshake and channel timeouts are handled internally by the driver.
346 break PooledConnection::Http3(conn);
347 }
348 }
349 } else {
350 let mut guard = conn_mutex.lock().await;
351 if guard.is_none() {
352 // Dial either UDP/QUIC (H3) or TCP/TLS (H2) based on the target protocols.
353 match self.dial(authority, port, has_alt_svc, &self.profile).await {
354 Ok(new_conn) => {
355 *guard = Some(new_conn.clone());
356 break new_conn;
357 }
358 Err(e) => {
359 if has_alt_svc {
360 // Fallback: UDP dial blocked. Evict from cache and retry over H2.
361 tracing::warn!("HTTP/3 dial to {} failed ({:?}); falling back to HTTP/2/TCP.", origin_key, e);
362 self.alt_svc_cache.remove(&origin_key);
363 has_alt_svc = false;
364
365 // Build H2 pool key and resolve.
366 let h2_pool_key =
367 format!("{}{}:{}#h2", proxy_prefix, authority, port);
368 let h2_conn_mutex = {
369 let mut pool = self.pool.lock().map_err(|_| {
370 Error::Connect(std::io::Error::other(
371 "connection pool poisoned",
372 ))
373 })?;
374 pool.entry(h2_pool_key)
375 .or_insert_with(|| {
376 Arc::new(tokio::sync::Mutex::new(None))
377 })
378 .clone()
379 };
380
381 let mut h2_guard = h2_conn_mutex.lock().await;
382 if h2_guard.is_none() {
383 let h2_conn = self
384 .dial(authority, port, false, &self.profile)
385 .await?;
386 *h2_guard = Some(h2_conn.clone());
387 break h2_conn;
388 } else {
389 break h2_guard.as_ref().unwrap().clone();
390 }
391 } else {
392 return Err(e);
393 }
394 }
395 }
396 }
397 }
398 };
399
400 // Build request dynamically for outbound session sending.
401 let mut request = http::Request::builder()
402 .method(current_method.as_str())
403 .uri(parsed_url.as_str())
404 .body(())
405 .map_err(|e| Error::InvalidUrl(e.to_string()))?;
406
407 if let Some(c) = cookie_header.as_deref() {
408 if let Ok(val) = http::header::HeaderValue::from_str(c) {
409 request.headers_mut().insert("cookie", val);
410 }
411 }
412
413 if current_method == "POST" || current_method == "PUT" || current_method == "PATCH" {
414 if let Ok(val) =
415 http::header::HeaderValue::from_str(&parsed_url.origin().ascii_serialization())
416 {
417 request.headers_mut().insert("origin", val);
418 }
419 }
420
421 inject_chrome_headers(
422 request.headers_mut(),
423 &self.profile,
424 &sec_fetch_site,
425 is_initial,
426 context,
427 accept_ch,
428 referer_to_send.as_deref(),
429 );
430
431 // Transmit request. If H3 fails mid-flight (e.g. silent UDP drop), evict and retry over H2.
432 let mut response = match pooled_client.send(request, current_body.clone()).await {
433 Ok(resp) => resp,
434 Err(e) => {
435 if let PooledConnection::Http3(_) = pooled_client {
436 tracing::warn!("HTTP/3 request transmission failed ({:?}); falling back to HTTP/2/TCP.", e);
437 self.alt_svc_cache.remove(&origin_key);
438
439 // Check for an existing H2 connection to preserve multiplexing and avoid handshakes.
440 let h2_pool_key = format!("{}{}:{}#h2", proxy_prefix, authority, port);
441 let h2_conn_mutex = {
442 let mut pool = self.pool.lock().map_err(|_| {
443 Error::Connect(std::io::Error::other("connection pool poisoned"))
444 })?;
445 pool.entry(h2_pool_key)
446 .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(None)))
447 .clone()
448 };
449
450 let mut h2_guard = h2_conn_mutex.lock().await;
451 let h2_conn = if let Some(PooledConnection::Http2(mut conn)) =
452 h2_guard.as_ref().cloned()
453 {
454 match conn.h2.ready().await {
455 Ok(h2) => {
456 conn.h2 = h2;
457 *h2_guard = Some(PooledConnection::Http2(conn.clone()));
458 PooledConnection::Http2(conn)
459 }
460 Err(_) => {
461 let new_conn =
462 self.dial(authority, port, false, &self.profile).await?;
463 *h2_guard = Some(new_conn.clone());
464 new_conn
465 }
466 }
467 } else {
468 let new_conn = self.dial(authority, port, false, &self.profile).await?;
469 *h2_guard = Some(new_conn.clone());
470 new_conn
471 };
472
473 // Rebuild request for H2 transmission.
474 let mut fallback_request = http::Request::builder()
475 .method(current_method.as_str())
476 .uri(parsed_url.as_str())
477 .body(())
478 .map_err(|e| Error::InvalidUrl(e.to_string()))?;
479
480 if let Some(c) = cookie_header.as_deref() {
481 if let Ok(val) = http::header::HeaderValue::from_str(c) {
482 fallback_request.headers_mut().insert("cookie", val);
483 }
484 }
485 if current_method == "POST"
486 || current_method == "PUT"
487 || current_method == "PATCH"
488 {
489 if let Ok(val) = http::header::HeaderValue::from_str(
490 &parsed_url.origin().ascii_serialization(),
491 ) {
492 fallback_request.headers_mut().insert("origin", val);
493 }
494 }
495
496 inject_chrome_headers(
497 fallback_request.headers_mut(),
498 &self.profile,
499 &sec_fetch_site,
500 is_initial,
501 context,
502 accept_ch,
503 referer_to_send.as_deref(),
504 );
505
506 let mut h2_pooled = h2_conn;
507 h2_pooled
508 .send(fallback_request, current_body.clone())
509 .await?
510 } else {
511 return Err(e);
512 }
513 }
514 };
515
516 // Store cookie, hints, and Alt-Svc headers from response.
517 self.store_cookies(&response, &parsed_url);
518 self.store_hints(&response, &parsed_url);
519 self.store_alt_svc(&response, &parsed_url);
520
521 let status = response.status();
522 if status.is_redirection() {
523 if let Some(location) = response.headers().get("location") {
524 let loc_str = location.to_str().unwrap_or("");
525 let next_url = parsed_url
526 .join(loc_str)
527 .map_err(|e| Error::InvalidUrl(e.to_string()))?;
528
529 // Rotate method to GET on 301/302/303 per RFC 7231 §6.4.
530 if status == http::StatusCode::MOVED_PERMANENTLY
531 || status == http::StatusCode::FOUND
532 || status == http::StatusCode::SEE_OTHER
533 {
534 current_method = "GET".to_string();
535 current_body = None;
536 }
537
538 if !is_cross_site {
539 if next_url.origin() == parsed_url.origin() {
540 sec_fetch_site = "same-origin".to_string();
541 } else {
542 // Compare registrable domains (eTLD+1) to correctly identify
543 // same-site subdomain transitions (e.g. www.example.com → api.example.com)
544 // without false cross-site classification.
545 let is_same_site = match (next_url.domain(), parsed_url.domain()) {
546 (Some(a), Some(b)) => {
547 get_registrable_domain(a) == get_registrable_domain(b)
548 }
549 _ => false,
550 };
551 if is_same_site {
552 sec_fetch_site = "same-site".to_string();
553 } else {
554 sec_fetch_site = "cross-site".to_string();
555 is_cross_site = true;
556 }
557 }
558 }
559
560 previous_url_str = Some(current_url_str);
561 current_url_str = next_url.to_string();
562 continue;
563 }
564 }
565
566 response.set_url(current_url_str);
567 return Ok(response);
568 }
569
570 Err(Error::Connect(std::io::Error::other(
571 "Redirect limit exceeded (max 10)",
572 )))
573 }
574
575 /// Initializes a network socket and negotiates the underlying protocol stream.
576 ///
577 /// ### Protocol Dispatch
578 /// - **HTTP/3 (`dial_h3 = true`)**: Resolves the target via DNS, binds an ephemeral
579 /// IPv4/IPv6 wildcard UDP socket, and delegates stream handling to a background
580 /// `QuicSession` worker. Emits Chrome's zero-length connection ID signature.
581 /// - **HTTP/2 (`dial_h3 = false`)**: Establishes a standard TCP connection, negotiating
582 /// TLS 1.3 with ALPN strictly constrained to `h2` and HTTP/1.1 fallbacks.
583 async fn dial(
584 &self,
585 authority: &str,
586 port: u16,
587 dial_h3: bool,
588 profile: &ChromeProfile,
589 ) -> Result<PooledConnection> {
590 if dial_h3 {
591 let addr_str = format!("{}:{}", authority, port);
592 // Asynchronous, non-blocking DNS resolution.
593 let addr = tokio::net::lookup_host(&addr_str).await?.next().ok_or_else(|| {
594 std::io::Error::new(std::io::ErrorKind::NotFound, "could not resolve host")
595 })?;
596
597 // Setup dual-stack loopback or wildcard listener.
598 let local_addr: SocketAddr = if addr.is_ipv6() {
599 "[::]:0".parse().unwrap()
600 } else {
601 "0.0.0.0:0".parse().unwrap()
602 };
603
604 let socket = UdpSocket::bind(local_addr).await?;
605 socket.connect(addr).await?;
606
607 let mut config = crate::client::quic::configure_chrome_quic_transport()?;
608 if !profile.tls.verify_peer {
609 config.verify_peer(false);
610 }
611
612 // Bind zero-length CID to match Chrome wire identity.
613 let scid = quiche::ConnectionId::from_ref(&[]);
614 let conn = quiche::connect(Some(authority), &scid, local_addr, addr, &mut config)
615 .map_err(|e| Error::Connect(std::io::Error::other(e.to_string())))?;
616
617 let (cmd_tx, cmd_rx) = mpsc::channel(100);
618 let socket_arc = Arc::new(socket);
619
620 tokio::spawn(crate::client::quic::run_quic_driver(
621 socket_arc, conn, addr, cmd_rx,
622 ));
623
624 Ok(PooledConnection::Http3(QuicSession {
625 tx: cmd_tx,
626 profile: profile.clone(),
627 }))
628 } else {
629 let addr_str = format!("{}:{}", authority, port);
630 // Asynchronous, non-blocking DNS resolution.
631 let addr = tokio::net::lookup_host(&addr_str).await?.next().ok_or_else(|| {
632 std::io::Error::new(std::io::ErrorKind::NotFound, "could not resolve host")
633 })?;
634
635 // Lookup cached TLS session for resumption if available.
636 let cached_session = {
637 let cache = self.tls_session_cache.lock().map_err(|_| {
638 Error::Connect(std::io::Error::other("TLS session cache poisoned"))
639 })?;
640 cache.get(authority).cloned()
641 };
642
643 let conn = connect(authority, port, addr, profile, self.proxy.as_ref(), cached_session).await?;
644
645 // If a new session ticket was negotiated, cache it for future resumptions.
646 if let Some(ref sess) = conn.session {
647 if let Ok(mut cache) = self.tls_session_cache.lock() {
648 cache.insert(authority.to_string(), sess.clone());
649 }
650 }
651
652 Ok(PooledConnection::Http2(conn))
653 }
654 }
655
656 /// Persists `Set-Cookie` headers from a response into the synchronized cookie store.
657 fn store_cookies(&self, resp: &Response, url: &Url) {
658 if let Ok(mut store) = self.cookie_store.write() {
659 for v in resp.headers().get_all("set-cookie").iter() {
660 if let Ok(cookie_str) = v.to_str() {
661 let _ = store.parse(cookie_str, url);
662 }
663 }
664 }
665 }
666
667 /// Caches `Accept-CH` headers explicitly requested by the server.
668 fn store_hints(&self, resp: &Response, url: &Url) {
669 if let Some(accept_ch) = resp.headers().get("accept-ch") {
670 if let Ok(ch_str) = accept_ch.to_str() {
671 if ch_str.to_lowercase().contains("sec-ch-ua-platform-version") {
672 if let Ok(mut cache) = self.hint_cache.write() {
673 cache.insert(url.origin().ascii_serialization());
674 }
675 }
676 }
677 }
678 }
679
680 /// Caches server Alt-Svc headers.
681 fn store_alt_svc(&self, resp: &Response, url: &Url) {
682 if let Some(alt_svc) = resp.headers().get("alt-svc") {
683 if let Ok(alt_str) = alt_svc.to_str() {
684 if alt_str.contains("h3") {
685 let origin_key = format!(
686 "{}:{}",
687 url.host_str().unwrap_or(""),
688 url.port().unwrap_or(443)
689 );
690 self.alt_svc_cache.insert(origin_key, alt_str.to_string());
691 }
692 }
693 }
694 }
695}
696
697/// A builder pattern for instantiating a custom [`Client`] with specific overrides.
698///
699/// Provides a declarative interface to override the default Chrome profile, configure
700/// outbound proxy routes, or inject a pre-populated synchronized cookie store.
701#[derive(Default)]
702pub struct ClientBuilder {
703 profile: Option<ChromeProfile>,
704 proxy: Option<Proxy>,
705 cookie_store: Option<Arc<RwLock<CookieStore>>>,
706 danger_accept_invalid_certs: bool,
707}
708
709impl ClientBuilder {
710 /// Bypasses TLS certificate validation.
711 ///
712 /// Disables peer verification in BoringSSL. This is strictly intended for debugging
713 /// environments or corporate proxies and undermines transport layer security.
714 pub fn danger_accept_invalid_certs(mut self, accept: bool) -> Self {
715 self.danger_accept_invalid_certs = accept;
716 self
717 }
718
719 /// Sets the Chrome identity profile.
720 pub fn profile(mut self, profile: ChromeProfile) -> Self {
721 self.profile = Some(profile);
722 self
723 }
724
725 /// Configures an outbound proxy.
726 pub fn proxy(mut self, proxy: Proxy) -> Self {
727 self.proxy = Some(proxy);
728 self
729 }
730
731 /// Provides a pre-existing synchronized cookie store.
732 pub fn cookie_store(mut self, store: Arc<RwLock<CookieStore>>) -> Self {
733 self.cookie_store = Some(store);
734 self
735 }
736
737 /// Finalizes the configuration and constructs a `Client`.
738 ///
739 /// If no explicit `ChromeProfile` was supplied, this method defaults to the
740 /// modern, high-fidelity `chrome_148` profile matched statefully to the host operating system
741 /// to preserve p0f network characteristics.
742 pub fn build(self) -> Result<Client> {
743 let mut profile = self
744 .profile
745 .unwrap_or_else(crate::profile::chrome_148::profile_auto);
746
747 if self.danger_accept_invalid_certs {
748 profile.tls.verify_peer = false;
749 }
750
751 Ok(Client {
752 pool: Arc::new(Mutex::new(HashMap::new())),
753 profile,
754 proxy: self.proxy,
755 cookie_store: self
756 .cookie_store
757 .unwrap_or_else(|| Arc::new(RwLock::new(CookieStore::default()))),
758 hint_cache: Arc::new(RwLock::new(HashSet::new())),
759 alt_svc_cache: AltSvcCache::new(),
760 tls_session_cache: Arc::new(Mutex::new(HashMap::new())),
761 })
762 }
763}