Skip to main content

go_http/
client.rs

1// SPDX-License-Identifier: Apache-2.0
2
3/// Client, Transport, and RoundTripper — port of Go's net/http client.
4use std::collections::{HashMap, VecDeque};
5use std::io::{self, Read, Write};
6use std::sync::{Arc, Mutex};
7use std::time::Duration;
8
9use go_lib::context::{with_timeout, Context};
10use go_lib::net::TcpStream;
11use url::Url;
12
13use crate::cookie::{Cookie, CookieJar};
14use crate::error::HttpError;
15use crate::header::Header;
16use crate::parse::response::{read_response, ParsedResponse};
17use crate::parse::transfer::Body;
18use crate::request::Request;
19use crate::response::Response;
20
21// ---------------------------------------------------------------------------
22// RoundTripper — port of Go's http.RoundTripper
23// ---------------------------------------------------------------------------
24
25/// The low-level interface for executing a single HTTP request.
26/// Port of Go's `http.RoundTripper`.
27pub trait RoundTripper: Send + Sync {
28    fn round_trip(&self, req: Request) -> Result<Response, HttpError>;
29}
30
31// ---------------------------------------------------------------------------
32// Transport — default RoundTripper with connection pooling
33// ---------------------------------------------------------------------------
34
35/// A connection pool entry: an idle `TcpStream` ready for reuse.
36struct IdleConn {
37    stream: TcpStream,
38}
39
40/// Resolves the proxy to use for a request, or `None` for a direct connection.
41/// Port of Go's `Transport.Proxy func(*Request) (*url.URL, error)`.
42pub type ProxyFn = Arc<dyn Fn(&Request) -> Result<Option<Url>, HttpError> + Send + Sync>;
43
44/// Default `RoundTripper` with per-host idle connection pooling.
45/// Port of Go's `http.Transport`.
46pub struct Transport {
47    pub max_idle_conns_per_host: usize,
48    pub idle_conn_timeout:       Option<Duration>,
49    pub dial_timeout:            Option<Duration>,
50    /// TLS client configuration for HTTPS requests.
51    /// `None` uses the default Mozilla root store (via `webpki-roots`).
52    pub tls_config: Option<Arc<rustls::ClientConfig>>,
53    /// Proxy resolver.  `None` connects directly.  See [`proxy_from_environment`].
54    pub proxy: Option<ProxyFn>,
55    /// Idle connection pool keyed by `"host:port"` (the dial target — the proxy
56    /// when proxying, else the origin).
57    pool: Mutex<HashMap<String, VecDeque<IdleConn>>>,
58}
59
60impl Transport {
61    pub fn new() -> Self {
62        Self {
63            max_idle_conns_per_host: 10,
64            idle_conn_timeout:       Some(Duration::from_secs(90)),
65            dial_timeout:            Some(Duration::from_secs(30)),
66            tls_config:              None,
67            proxy:                   None,
68            pool:                    Mutex::new(HashMap::new()),
69        }
70    }
71
72    /// Acquire an idle connection for `host_port`, or dial a new one.
73    fn acquire(&self, host_port: &str) -> io::Result<TcpStream> {
74        // Try pool first.
75        if let Some(conn) = self
76            .pool
77            .lock()
78            .unwrap()
79            .get_mut(host_port)
80            .and_then(|q| q.pop_front())
81        {
82            return Ok(conn.stream);
83        }
84        // Dial a new connection.
85        TcpStream::connect(host_port)
86    }
87
88    /// Return a connection to the pool for reuse.
89    fn release(&self, host_port: &str, stream: TcpStream) {
90        let mut pool = self.pool.lock().unwrap();
91        let queue = pool.entry(host_port.to_owned()).or_default();
92        if queue.len() < self.max_idle_conns_per_host {
93            queue.push_back(IdleConn { stream });
94        }
95        // If over the limit we simply drop the stream (closes the fd).
96    }
97}
98
99impl Default for Transport {
100    fn default() -> Self {
101        Self::new()
102    }
103}
104
105impl RoundTripper for Transport {
106    fn round_trip(&self, mut req: Request) -> Result<Response, HttpError> {
107        let is_https  = req.url.scheme() == "https";
108        let host      = req.url.host_str().unwrap_or("localhost").to_owned();
109        let port      = req.url.port_or_known_default()
110            .unwrap_or(if is_https { 443 } else { 80 });
111        let target_hp = format!("{host}:{port}");
112
113        // Resolve the proxy (if any) for this request.
114        let proxy_url = match &self.proxy {
115            Some(f) => f(&req)?,
116            None    => None,
117        };
118
119        match proxy_url {
120            None => {
121                if is_https {
122                    self.https_round_trip(req, &host, &target_hp, None)
123                } else {
124                    self.http_round_trip(req, &target_hp, false)
125                }
126            }
127            Some(pu) => {
128                let proxy_hp = proxy_host_port(&pu)?;
129                let auth     = proxy_auth_header(&pu);
130                if is_https {
131                    // Tunnel to the origin through the proxy with CONNECT, then TLS.
132                    self.https_round_trip(req, &host, &target_hp, Some((proxy_hp, auth)))
133                } else {
134                    // Plain HTTP: dial the proxy and send an absolute-form target.
135                    if let Some(a) = auth {
136                        req.header.set("Proxy-Authorization", a);
137                    }
138                    self.http_round_trip(req, &proxy_hp, true)
139                }
140            }
141        }
142    }
143}
144
145impl Transport {
146    /// Plain-HTTP round-trip.  `dial_hp` is the address to connect to (the
147    /// proxy when proxying, else the origin); `absolute` selects absolute-form
148    /// request-target framing for proxied requests.
149    fn http_round_trip(
150        &self,
151        mut req: Request,
152        dial_hp: &str,
153        absolute: bool,
154    ) -> Result<Response, HttpError> {
155        let mut stream = self.acquire(dial_hp).map_err(HttpError::Io)?;
156        send_request(&mut stream, &mut req, absolute)?;
157
158        let mut parsed = read_response(
159            stream.try_clone().map_err(HttpError::Io)?,
160            Some(req.method.as_str()),
161            crate::parse::request::DEFAULT_MAX_HEADER_BYTES,
162        )?;
163
164        let keep_alive = is_keep_alive_parsed(&parsed, req.proto_minor);
165
166        // Buffer the body into memory before releasing the stream to the pool.
167        // The body is backed by a try_clone() of `stream`; releasing `stream`
168        // while an unread network-backed body alias is live would race the next
169        // request's reads on the same socket.  Buffering fully drains the socket
170        // first and replaces the body with an in-memory Cursor.
171        if keep_alive {
172            let bytes = parsed.body.read_to_vec().map_err(|_| HttpError::BodyRead)?;
173            parsed.body = Body::Unbounded(Box::new(io::Cursor::new(bytes)));
174            self.release(dial_hp, stream);
175        }
176
177        Ok(parsed_response_to_response(parsed))
178    }
179
180    /// HTTPS round-trip.  When `proxy` is `Some((proxy_hp, auth))`, a `CONNECT`
181    /// tunnel to `target_hp` is established through the proxy first; otherwise
182    /// `target_hp` is dialled directly.  TLS is then negotiated using `sni_host`.
183    /// TLS connections are not pooled.
184    fn https_round_trip(
185        &self,
186        mut req: Request,
187        sni_host: &str,
188        target_hp: &str,
189        proxy: Option<(String, Option<String>)>,
190    ) -> Result<Response, HttpError> {
191        let stream = match proxy {
192            Some((proxy_hp, auth)) => {
193                let mut s = TcpStream::connect(proxy_hp.as_str()).map_err(HttpError::Io)?;
194                connect_tunnel(&mut s, target_hp, auth.as_deref())?;
195                s
196            }
197            None => TcpStream::connect(target_hp).map_err(HttpError::Io)?,
198        };
199
200        let tls_cfg = match &self.tls_config {
201            Some(c) => Arc::clone(c),
202            None    => crate::tls::default_client_config(),
203        };
204        let server_name = rustls::pki_types::ServerName::try_from(sni_host.to_owned())
205            .map_err(|e| HttpError::Tls(e.to_string()))?;
206        let client_conn = rustls::ClientConnection::new(tls_cfg, server_name)
207            .map_err(|e| HttpError::Tls(e.to_string()))?;
208        let mut tls = rustls::StreamOwned::new(client_conn, stream);
209
210        // Origin-form over the (possibly tunnelled) TLS connection.
211        send_request(&mut tls, &mut req, false)?;
212
213        // Lend `tls` to the response parser via a raw-pointer read wrapper.
214        // Safety: `tls` outlives the parser and the response body within this
215        // call frame; there is no concurrent access.
216        let read_ptr: *mut dyn Read = &mut tls as &mut dyn Read as *mut dyn Read;
217        let parsed = read_response(
218            RawRead(read_ptr),
219            Some(req.method.as_str()),
220            crate::parse::request::DEFAULT_MAX_HEADER_BYTES,
221        )?;
222        Ok(parsed_response_to_response(parsed))
223    }
224}
225
226// ---------------------------------------------------------------------------
227// RawRead — lends a mutable reference as a Send + 'static Read
228// ---------------------------------------------------------------------------
229
230/// Raw-pointer wrapper that gives `read_response` a `Read + Send + 'static`
231/// view of a `TLS StreamOwned` (or any `impl Read`) without moving it.
232///
233/// # Safety
234/// The caller must ensure the pointed-to value lives at least as long as
235/// this `RawRead` is alive and is not concurrently accessed.
236struct RawRead(*mut dyn Read);
237unsafe impl Send for RawRead {}
238impl Read for RawRead {
239    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
240        unsafe { (*self.0).read(buf) }
241    }
242}
243
244// ---------------------------------------------------------------------------
245// Redirect policy — port of Go's Client.CheckRedirect
246// ---------------------------------------------------------------------------
247
248/// What a [`CheckRedirect`] policy decides for the next hop.
249pub enum RedirectPolicy {
250    /// Follow the redirect (subject to the same policy on the following hop).
251    Follow,
252    /// Stop and return the most recent response as-is, without following.
253    /// Equivalent to returning Go's `ErrUseLastResponse`.
254    UseLastResponse,
255}
256
257/// A redirect policy.  Called before each redirect is followed, with the
258/// request about to be made and `via`, the chain of requests already made
259/// (oldest first).  Return [`RedirectPolicy::Follow`] to continue,
260/// [`RedirectPolicy::UseLastResponse`] to stop and return the last response,
261/// or an `Err` to abort the request with that error.
262///
263/// Port of Go's `Client.CheckRedirect func(req *Request, via []*Request) error`.
264pub type CheckRedirect =
265    Arc<dyn Fn(&Request, &[Request]) -> Result<RedirectPolicy, HttpError> + Send + Sync>;
266
267// ---------------------------------------------------------------------------
268// Client
269// ---------------------------------------------------------------------------
270
271/// An HTTP client.  Mirrors Go's `http.Client`.
272pub struct Client {
273    /// Transport used for request execution.
274    pub transport:    Arc<dyn RoundTripper>,
275    /// Per-request timeout; `None` means no timeout.
276    pub timeout:      Option<Duration>,
277    /// Maximum number of redirects to follow when `check_redirect` is `None`
278    /// (default 10, matching Go).
279    pub max_redirects: usize,
280    /// Custom redirect policy.  `None` uses the default (follow up to
281    /// `max_redirects`, then fail with [`HttpError::TooManyRedirects`]).
282    pub check_redirect: Option<CheckRedirect>,
283    /// Optional cookie jar.
284    pub jar: Option<Arc<dyn CookieJar>>,
285}
286
287impl Client {
288    /// Create a client using the default `Transport`.
289    pub fn new() -> Self {
290        Self {
291            transport:      Arc::new(Transport::new()),
292            timeout:        None,
293            max_redirects:  10,
294            check_redirect: None,
295            jar:            None,
296        }
297    }
298
299    // ── Convenience methods ───────────────────────────────────────────────
300
301    /// Issue a GET request.  Port of Go's `(*Client).Get`.
302    pub fn get(&self, url: &str) -> Result<Response, HttpError> {
303        let req = Request::new("GET", url, None)?;
304        self.do_request(req)
305    }
306
307    /// Issue a POST request with the given content type and body.
308    /// Port of Go's `(*Client).Post`.
309    pub fn post(
310        &self,
311        url:          &str,
312        content_type: &str,
313        body:         Body,
314    ) -> Result<Response, HttpError> {
315        let mut req = Request::new("POST", url, Some(body))?;
316        req.header.set("Content-Type", content_type);
317        self.do_request(req)
318    }
319
320    /// Issue a POST request with `application/x-www-form-urlencoded` body.
321    /// Port of Go's `(*Client).PostForm`.
322    pub fn post_form(
323        &self,
324        url:    &str,
325        values: &[(&str, &str)],
326    ) -> Result<Response, HttpError> {
327        let encoded = url_encode(values);
328        let body = Body::Unbounded(Box::new(io::Cursor::new(encoded.into_bytes())));
329        self.post(url, "application/x-www-form-urlencoded", body)
330    }
331
332    /// Issue a HEAD request.  Port of Go's `(*Client).Head`.
333    pub fn head(&self, url: &str) -> Result<Response, HttpError> {
334        let req = Request::new("HEAD", url, None)?;
335        self.do_request(req)
336    }
337
338    // ── Core request execution ────────────────────────────────────────────
339
340    /// Execute `req`, following redirects and attaching cookies.
341    /// Port of Go's `(*Client).Do`.
342    pub fn do_request(&self, req: Request) -> Result<Response, HttpError> {
343        // Effective cancellation context governing *every* redirect hop: the
344        // request's own context (which may already carry a per-request
345        // deadline via `Request::new_with_context`) with the client-wide
346        // `timeout` applied on top.  `_cancel` must stay alive for the whole
347        // call so the deadline timer is not released early.
348        let (_cancel, ctx) = match self.timeout {
349            Some(d) => {
350                let (c, cancel) = with_timeout(req.context(), d);
351                (Some(cancel), c)
352            }
353            None => (None, req.context().clone()),
354        };
355
356        let mut req = req;
357
358        // Attach cookies from the jar for the initial URL.
359        if let Some(jar) = &self.jar {
360            attach_cookies(&mut req, jar.as_ref());
361        }
362
363        // `via` records the requests already made (oldest first), as Go passes
364        // to CheckRedirect.  Entries are body-less snapshots.
365        let mut via: Vec<Request> = Vec::new();
366
367        loop {
368            let method = req.method.clone();
369            let url    = req.url.clone();
370            let via_entry = snapshot_request(&req);
371
372            let mut resp = self.execute_round_trip(req, &ctx)?;
373            via.push(via_entry);
374
375            // Store cookies from the response.
376            if let Some(jar) = &self.jar {
377                store_cookies(&url, &resp.header, jar.as_ref());
378            }
379
380            // ── Redirect handling ─────────────────────────────────────────
381            let status = resp.status;
382            if !is_redirect(status) {
383                return Ok(resp);
384            }
385
386            let location = resp
387                .header
388                .get("Location")
389                .ok_or_else(|| HttpError::InvalidUrl("redirect with no Location".into()))?
390                .to_owned();
391
392            // Resolve the redirect URL against the current one.
393            let new_url = resolve_url(&url, &location)?;
394
395            // POST → GET on 301/302/303 (matching Go semantics).
396            let new_method = match status {
397                301..=303 => {
398                    if method == "POST" { "GET".to_owned() } else { method }
399                }
400                _ => method,
401            };
402
403            // Body is consumed; 307/308 with a body would need a rewindable
404            // body to replay (not yet supported).
405            let mut new_req = Request::new(&new_method, new_url.as_str(), None)?;
406            // Forward safe headers; strip Authorization on cross-origin redirects.
407            forward_headers(&mut new_req.header, &resp.header, same_origin(&url, &new_url));
408            if let Some(jar) = &self.jar {
409                attach_cookies(&mut new_req, jar.as_ref());
410            }
411
412            // ── Apply the redirect policy ──────────────────────────────────
413            match &self.check_redirect {
414                Some(policy) => match policy(&new_req, &via)? {
415                    RedirectPolicy::Follow => {}
416                    RedirectPolicy::UseLastResponse => return Ok(resp),
417                },
418                None => {
419                    if via.len() > self.max_redirects {
420                        return Err(HttpError::TooManyRedirects);
421                    }
422                }
423            }
424
425            // Drain the redirect body so the connection can be reused.
426            let _ = resp.body_bytes();
427
428            req = new_req;
429        }
430    }
431
432    /// Execute one round-trip, honouring the cancellation `ctx`.
433    ///
434    /// When `ctx` carries a deadline the round-trip runs on its own goroutine
435    /// and is raced against `ctx.done()`, so a timeout or cancellation returns
436    /// promptly with [`HttpError::Timeout`] even while the underlying socket
437    /// I/O is still blocked.  (The abandoned goroutine finishes on its own when
438    /// the I/O completes or errors; like Go, we can't forcibly unwind it.)
439    /// Without a deadline the round-trip runs synchronously — no extra goroutine.
440    fn execute_round_trip(&self, req: Request, ctx: &Context) -> Result<Response, HttpError> {
441        if ctx.deadline().is_none() {
442            return self.transport.round_trip(req);
443        }
444        if ctx.is_done() {
445            return Err(HttpError::Timeout);
446        }
447
448        let (tx, rx) = go_lib::chan::chan::<Result<Response, HttpError>>(1);
449        let transport = Arc::clone(&self.transport);
450        go_lib::go!(move || {
451            let result = transport.round_trip(req);
452            // Buffered(1): the send never blocks.  If the receiver already
453            // timed out and is gone, catch_unwind swallows any panic.
454            let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| tx.send(result)));
455        });
456
457        go_lib::select! {
458            recv(ctx.done()) -> _sig => { Err(HttpError::Timeout) }
459            recv(rx) -> result => { result.unwrap_or_else(|| Err(HttpError::Timeout)) }
460        }
461    }
462}
463
464impl Default for Client {
465    fn default() -> Self {
466        Self::new()
467    }
468}
469
470// ---------------------------------------------------------------------------
471// Package-level free functions — port of Go's http.Get / http.Post etc.
472// ---------------------------------------------------------------------------
473
474/// Global default client, mirroring Go's `http.DefaultClient`.
475fn default_client() -> &'static Client {
476    use std::sync::OnceLock;
477    static DEFAULT: OnceLock<Client> = OnceLock::new();
478    DEFAULT.get_or_init(Client::new)
479}
480
481/// Issue a GET using the default client.  Port of Go's `http.Get`.
482pub fn get(url: &str) -> Result<Response, HttpError> {
483    default_client().get(url)
484}
485
486/// Issue a POST using the default client.  Port of Go's `http.Post`.
487pub fn post(url: &str, content_type: &str, body: Body) -> Result<Response, HttpError> {
488    default_client().post(url, content_type, body)
489}
490
491/// Issue a POST form using the default client.  Port of Go's `http.PostForm`.
492pub fn post_form(url: &str, values: &[(&str, &str)]) -> Result<Response, HttpError> {
493    default_client().post_form(url, values)
494}
495
496/// Issue a HEAD using the default client.
497pub fn head(url: &str) -> Result<Response, HttpError> {
498    default_client().head(url)
499}
500
501// ---------------------------------------------------------------------------
502// Internal helpers
503// ---------------------------------------------------------------------------
504
505/// Serialize a `Request` (headers + body) to `w`.  `absolute` selects
506/// absolute-form request-target framing (`GET http://host/path`) for requests
507/// sent to an HTTP proxy.
508fn send_request(w: &mut impl Write, req: &mut Request, absolute: bool) -> Result<(), HttpError> {
509    if absolute {
510        req.write_header_absolute_to(w)?;
511    } else {
512        req.write_header_to(w)?;
513    }
514    // Write body bytes if present (POST/PUT/PATCH).
515    if let Some(body) = req.body.take() {
516        use std::io::Read;
517        let mut body = body;
518        let mut buf = [0u8; 8192];
519        loop {
520            let n = body.read(&mut buf).map_err(|_| HttpError::BodyRead)?;
521            if n == 0 { break; }
522            w.write_all(&buf[..n])?;
523        }
524    }
525    Ok(())
526}
527
528/// True if a `ParsedResponse` should be treated as keep-alive.
529fn is_keep_alive_parsed(resp: &ParsedResponse, req_minor: u8) -> bool {
530    let conn = resp.header.get("Connection").unwrap_or("").to_ascii_lowercase();
531    if conn.contains("close") { return false; }
532    if req_minor == 0 { conn.contains("keep-alive") } else { true }
533}
534
535/// Convert a `ParsedResponse` into the public `Response` type.
536fn parsed_response_to_response(p: ParsedResponse) -> Response {
537    Response {
538        status:            p.status,
539        status_text:       p.status_text,
540        proto:             p.proto,
541        proto_major:       p.proto_major,
542        proto_minor:       p.proto_minor,
543        header:            p.header,
544        body:              match p.body {
545            Body::Empty => None,
546            other       => Some(other),
547        },
548        content_length:    p.content_length,
549        transfer_encoding: p.transfer_encoding,
550        trailer:           Header::new(),
551    }
552}
553
554// ---------------------------------------------------------------------------
555// Proxy helpers
556// ---------------------------------------------------------------------------
557
558/// A [`ProxyFn`] that reads the standard proxy environment variables, mirroring
559/// Go's `http.ProxyFromEnvironment`:
560///
561/// - `HTTPS_PROXY` / `https_proxy` for `https://` requests,
562/// - `HTTP_PROXY`  / `http_proxy`  for `http://`  requests,
563/// - `NO_PROXY`    / `no_proxy`    a comma-separated exclusion list.
564///
565/// `NO_PROXY` entries match by exact host, by `.suffix` (any sub-domain), or
566/// `*` (everything).  The environment is read on each call.  A bare
567/// `host:port` proxy value is treated as an `http://` URL.
568pub fn proxy_from_environment() -> ProxyFn {
569    Arc::new(|req: &Request| -> Result<Option<Url>, HttpError> {
570        let is_https = req.url.scheme() == "https";
571        let host     = req.url.host_str().unwrap_or("");
572
573        // NO_PROXY exclusions.
574        if let Some(no) = env_first(&["NO_PROXY", "no_proxy"])
575            && host_matches_no_proxy(host, &no)
576        {
577            return Ok(None);
578        }
579
580        let names: &[&str] = if is_https {
581            &["HTTPS_PROXY", "https_proxy"]
582        } else {
583            &["HTTP_PROXY", "http_proxy"]
584        };
585        match env_first(names) {
586            None => Ok(None),
587            Some(v) if v.trim().is_empty() => Ok(None),
588            Some(v) => {
589                let raw = v.trim();
590                // Accept bare "host:port" by defaulting to an http:// scheme.
591                let normalized = if raw.contains("://") {
592                    raw.to_owned()
593                } else {
594                    format!("http://{raw}")
595                };
596                let url = Url::parse(&normalized)
597                    .map_err(|e| HttpError::Proxy(format!("bad proxy URL {raw:?}: {e}")))?;
598                Ok(Some(url))
599            }
600        }
601    })
602}
603
604/// Return the first set, non-empty environment variable among `names`.
605fn env_first(names: &[&str]) -> Option<String> {
606    names.iter().find_map(|n| std::env::var(n).ok())
607}
608
609/// Match `host` against a `NO_PROXY` list (comma-separated).
610fn host_matches_no_proxy(host: &str, no_proxy: &str) -> bool {
611    let host = host.trim_start_matches('.').to_ascii_lowercase();
612    for entry in no_proxy.split(',') {
613        let e = entry.trim().to_ascii_lowercase();
614        if e.is_empty() {
615            continue;
616        }
617        if e == "*" {
618            return true;
619        }
620        let suffix = e.trim_start_matches('.');
621        if host == suffix || host.ends_with(&format!(".{suffix}")) {
622            return true;
623        }
624    }
625    false
626}
627
628/// `host:port` to dial for a proxy URL (default port 80).
629fn proxy_host_port(pu: &Url) -> Result<String, HttpError> {
630    let h = pu
631        .host_str()
632        .ok_or_else(|| HttpError::Proxy("proxy URL has no host".into()))?;
633    let p = pu.port_or_known_default().unwrap_or(80);
634    Ok(format!("{h}:{p}"))
635}
636
637/// Build a `Basic` `Proxy-Authorization` value from a proxy URL's userinfo,
638/// or `None` when there is no username.
639fn proxy_auth_header(pu: &Url) -> Option<String> {
640    let user = pu.username();
641    if user.is_empty() {
642        return None;
643    }
644    let pass  = pu.password().unwrap_or("");
645    let creds = format!("{user}:{pass}");
646    let b64   = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, creds);
647    Some(format!("Basic {b64}"))
648}
649
650/// Establish an HTTP `CONNECT` tunnel through a proxy to `target_hp`.
651/// On success the stream is positioned to begin the TLS handshake.
652fn connect_tunnel<S: Read + Write>(
653    stream: &mut S,
654    target_hp: &str,
655    auth: Option<&str>,
656) -> Result<(), HttpError> {
657    let mut req = format!("CONNECT {target_hp} HTTP/1.1\r\nHost: {target_hp}\r\n");
658    if let Some(a) = auth {
659        req.push_str(&format!("Proxy-Authorization: {a}\r\n"));
660    }
661    req.push_str("\r\n");
662    stream.write_all(req.as_bytes()).map_err(HttpError::Io)?;
663
664    let status = read_connect_response(stream)?;
665    if !(200..300).contains(&status) {
666        return Err(HttpError::Proxy(format!(
667            "CONNECT to {target_hp} failed with status {status}"
668        )));
669    }
670    Ok(())
671}
672
673/// Read a proxy `CONNECT` response (status line + headers, no body) and return
674/// its status code.
675fn read_connect_response<R: Read>(stream: &mut R) -> Result<u16, HttpError> {
676    let mut buf  = Vec::with_capacity(128);
677    let mut byte = [0u8; 1];
678    loop {
679        let n = stream.read(&mut byte).map_err(HttpError::Io)?;
680        if n == 0 {
681            return Err(HttpError::Proxy("proxy closed before CONNECT response".into()));
682        }
683        buf.push(byte[0]);
684        if buf.ends_with(b"\r\n\r\n") {
685            break;
686        }
687        if buf.len() > 8192 {
688            return Err(HttpError::Proxy("CONNECT response headers too large".into()));
689        }
690    }
691    let text  = String::from_utf8_lossy(&buf);
692    let first = text.lines().next().unwrap_or("");
693    first
694        .split_whitespace()
695        .nth(1)
696        .and_then(|s| s.parse::<u16>().ok())
697        .ok_or_else(|| HttpError::Proxy(format!("malformed CONNECT status line: {first:?}")))
698}
699
700/// Build a body-less snapshot of `r` for the redirect `via` chain (method,
701/// URL, headers, host).  Infallible: `r` already holds a valid method + URL.
702fn snapshot_request(r: &Request) -> Request {
703    let mut s = Request::new(&r.method, r.url.as_str(), None)
704        .unwrap_or_else(|_| Request::new("GET", "http://invalid.invalid/", None).unwrap());
705    s.header = r.header.clone();
706    s.host   = r.host.clone();
707    s.proto  = r.proto.clone();
708    s
709}
710
711/// Copy safe headers from `src` into `dst`; strip Authorization on
712/// cross-origin redirects.
713fn forward_headers(dst: &mut Header, src: &Header, same_origin: bool) {
714    for (name, values) in src.iter() {
715        // Never forward hop-by-hop headers.
716        let lower = name.to_ascii_lowercase();
717        if matches!(
718            lower.as_str(),
719            "connection" | "keep-alive" | "proxy-authenticate"
720                | "proxy-authorization" | "te" | "trailers"
721                | "transfer-encoding" | "upgrade"
722        ) {
723            continue;
724        }
725        // Strip Authorization on cross-origin redirects (Go behaviour).
726        if !same_origin && lower == "authorization" {
727            continue;
728        }
729        for v in values {
730            dst.add(name, v.as_str());
731        }
732    }
733}
734
735/// True if `status` is a redirect code.
736fn is_redirect(status: u16) -> bool {
737    matches!(status, 301 | 302 | 303 | 307 | 308)
738}
739
740/// Resolve `location` (possibly relative) against `base`.
741fn resolve_url(base: &Url, location: &str) -> Result<Url, HttpError> {
742    if location.starts_with("http://") || location.starts_with("https://") {
743        Url::parse(location).map_err(|e| HttpError::InvalidUrl(e.to_string()))
744    } else {
745        base.join(location).map_err(|e| HttpError::InvalidUrl(e.to_string()))
746    }
747}
748
749/// True if `a` and `b` have the same scheme + host + port.
750fn same_origin(a: &Url, b: &Url) -> bool {
751    a.scheme() == b.scheme()
752        && a.host_str() == b.host_str()
753        && a.port()     == b.port()
754}
755
756/// Attach jar cookies to the request's Cookie header.
757fn attach_cookies(req: &mut Request, jar: &dyn CookieJar) {
758    let cookies = jar.cookies(&req.url);
759    if !cookies.is_empty() {
760        let pairs: Vec<String> = cookies
761            .iter()
762            .map(|c| format!("{}={}", c.name, c.value))
763            .collect();
764        req.header.set("Cookie", pairs.join("; "));
765    }
766}
767
768/// Store Set-Cookie headers from a response into the jar.
769fn store_cookies(url: &Url, header: &Header, jar: &dyn CookieJar) {
770    let cookies: Vec<Cookie> = header
771        .values("Set-Cookie")
772        .iter()
773        .filter_map(|v| {
774            let eq = v.find('=')?;
775            let name  = v[..eq].trim().to_owned();
776            let rest  = &v[eq + 1..];
777            let value = rest.split(';').next().unwrap_or("").trim().to_owned();
778            Some(Cookie::new(name, value))
779        })
780        .collect();
781    if !cookies.is_empty() {
782        jar.set_cookies(url, &cookies);
783    }
784}
785
786/// Percent-encode a form value (spaces → `+`, special chars → `%XX`).
787fn url_encode(values: &[(&str, &str)]) -> String {
788    values
789        .iter()
790        .map(|(k, v)| format!("{}={}", encode_form(k), encode_form(v)))
791        .collect::<Vec<_>>()
792        .join("&")
793}
794
795fn encode_form(s: &str) -> String {
796    let mut out = String::with_capacity(s.len());
797    for b in s.bytes() {
798        match b {
799            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9'
800            | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
801            b' '                         => out.push('+'),
802            _                            => {
803                out.push('%');
804                out.push_str(&format!("{b:02X}"));
805            }
806        }
807    }
808    out
809}
810
811// ---------------------------------------------------------------------------
812// Tests
813// ---------------------------------------------------------------------------
814
815#[cfg(test)]
816mod tests {
817    use super::*;
818
819    #[test]
820    fn url_encode_basic() {
821        let pairs = [("q", "hello world"), ("lang", "rust")];
822        assert_eq!(url_encode(&pairs), "q=hello+world&lang=rust");
823    }
824
825    #[test]
826    fn url_encode_special_chars() {
827        let pairs = [("a", "b&c=d")];
828        assert_eq!(url_encode(&pairs), "a=b%26c%3Dd");
829    }
830
831    #[test]
832    fn resolve_url_absolute() {
833        let base = Url::parse("http://example.com/foo").unwrap();
834        let resolved = resolve_url(&base, "http://other.com/bar").unwrap();
835        assert_eq!(resolved.as_str(), "http://other.com/bar");
836    }
837
838    #[test]
839    fn resolve_url_relative() {
840        let base = Url::parse("http://example.com/a/b").unwrap();
841        let resolved = resolve_url(&base, "/c").unwrap();
842        assert_eq!(resolved.as_str(), "http://example.com/c");
843    }
844
845    #[test]
846    fn is_redirect_codes() {
847        for code in [301u16, 302, 303, 307, 308] {
848            assert!(is_redirect(code), "{code} should be redirect");
849        }
850        for code in [200u16, 404, 500] {
851            assert!(!is_redirect(code), "{code} should not be redirect");
852        }
853    }
854
855    #[test]
856    fn same_origin_check() {
857        let a = Url::parse("http://example.com/foo").unwrap();
858        let b = Url::parse("http://example.com/bar").unwrap();
859        let c = Url::parse("https://example.com/foo").unwrap();
860        let d = Url::parse("http://other.com/foo").unwrap();
861        assert!(same_origin(&a, &b));
862        assert!(!same_origin(&a, &c)); // different scheme
863        assert!(!same_origin(&a, &d)); // different host
864    }
865
866    #[test]
867    fn transport_pool_reuse() {
868        // Verify the pool stores and retrieves entries by key without
869        // requiring a real network connection.
870        // We can't easily test acquire() without a server, but we can
871        // verify the pool's limit enforcement via the internal state.
872        let t = Transport::new();
873        assert_eq!(t.max_idle_conns_per_host, 10);
874        // Pool starts empty.
875        assert!(t.pool.lock().unwrap().is_empty());
876    }
877
878    // client_get_end_to_end is covered by tests/server_client.rs integration
879    // tests (get_basic, multiple_sequential_requests, etc.), which carry
880    // `#[go_lib::main]` so each test body runs as the first goroutine on the
881    // shared process-wide scheduler.
882
883    // ── Proxy helpers ──────────────────────────────────────────────────────
884
885    #[test]
886    fn proxy_auth_header_from_userinfo() {
887        use base64::Engine;
888        let with = Url::parse("http://user:pass@proxy.local:3128").unwrap();
889        let expected = format!(
890            "Basic {}",
891            base64::engine::general_purpose::STANDARD.encode("user:pass")
892        );
893        assert_eq!(proxy_auth_header(&with), Some(expected));
894
895        let without = Url::parse("http://proxy.local:3128").unwrap();
896        assert_eq!(proxy_auth_header(&without), None);
897    }
898
899    #[test]
900    fn proxy_host_port_defaults_port_80() {
901        let u = Url::parse("http://proxy.local").unwrap();
902        assert_eq!(proxy_host_port(&u).unwrap(), "proxy.local:80");
903        let u2 = Url::parse("http://proxy.local:8080").unwrap();
904        assert_eq!(proxy_host_port(&u2).unwrap(), "proxy.local:8080");
905    }
906
907    #[test]
908    fn no_proxy_matching() {
909        assert!(host_matches_no_proxy("example.com", "example.com"));
910        assert!(host_matches_no_proxy("api.example.com", ".example.com"));
911        assert!(host_matches_no_proxy("api.example.com", "example.com"));
912        assert!(host_matches_no_proxy("anything", "*"));
913        assert!(host_matches_no_proxy("b.internal", "foo.com, .internal"));
914        assert!(!host_matches_no_proxy("example.org", "example.com"));
915        assert!(!host_matches_no_proxy("notexample.com", ".example.com"));
916    }
917
918    #[test]
919    fn connect_response_parsing() {
920        use std::io::Cursor;
921        let mut ok = Cursor::new(b"HTTP/1.1 200 Connection established\r\n\r\n".to_vec());
922        assert_eq!(read_connect_response(&mut ok).unwrap(), 200);
923
924        let mut denied = Cursor::new(b"HTTP/1.1 407 Proxy Auth Required\r\n\r\n".to_vec());
925        assert_eq!(read_connect_response(&mut denied).unwrap(), 407);
926    }
927
928    /// In-memory Read+Write used to exercise `connect_tunnel` without a socket.
929    struct MockConn {
930        to_read: std::io::Cursor<Vec<u8>>,
931        written: Vec<u8>,
932    }
933    impl Read for MockConn {
934        fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
935            self.to_read.read(buf)
936        }
937    }
938    impl Write for MockConn {
939        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
940            self.written.extend_from_slice(buf);
941            Ok(buf.len())
942        }
943        fn flush(&mut self) -> io::Result<()> {
944            Ok(())
945        }
946    }
947
948    #[test]
949    fn connect_tunnel_sends_request_and_accepts_200() {
950        let mut conn = MockConn {
951            to_read: std::io::Cursor::new(b"HTTP/1.1 200 OK\r\n\r\n".to_vec()),
952            written: Vec::new(),
953        };
954        connect_tunnel(&mut conn, "example.com:443", Some("Basic Zm9v")).unwrap();
955        let sent = String::from_utf8(conn.written.clone()).unwrap();
956        assert!(sent.starts_with("CONNECT example.com:443 HTTP/1.1\r\n"), "got: {sent:?}");
957        assert!(sent.contains("Host: example.com:443\r\n"));
958        assert!(sent.contains("Proxy-Authorization: Basic Zm9v\r\n"));
959        assert!(sent.ends_with("\r\n\r\n"));
960    }
961
962    #[test]
963    fn connect_tunnel_errors_on_non_2xx() {
964        let mut conn = MockConn {
965            to_read: std::io::Cursor::new(b"HTTP/1.1 403 Forbidden\r\n\r\n".to_vec()),
966            written: Vec::new(),
967        };
968        let err = connect_tunnel(&mut conn, "example.com:443", None).unwrap_err();
969        assert!(matches!(err, HttpError::Proxy(_)), "got: {err:?}");
970    }
971}