Skip to main content

rightkit_http/
client.rs

1use std::fmt;
2use std::io::{self, Read};
3use std::sync::atomic::AtomicBool;
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6
7use serde::de::DeserializeOwned;
8use serde::Serialize;
9
10use crate::cancel::{is_cancelled_io, is_set, sleep_cancellable, CancelToken, CheckedReader};
11use crate::error::{HttpError, Phase, MAX_ERROR_BODY_BYTES};
12use crate::netpolicy::{denial_in_chain, normalize_host, AddressPolicy, VettingResolver, ViaProxy};
13use crate::redirect;
14use crate::retry::{parse_retry_after, RetryPolicy};
15use crate::sse::SseReader;
16use crate::urlredact::{redact_url, redact_urls_in_text, url_secret_parts};
17
18pub(crate) const REDACTED: &str = "[redacted]";
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Method {
22    Get,
23    Post,
24    Put,
25    Patch,
26    Delete,
27    Head,
28}
29
30impl Method {
31    pub fn as_str(self) -> &'static str {
32        match self {
33            Method::Get => "GET",
34            Method::Post => "POST",
35            Method::Put => "PUT",
36            Method::Patch => "PATCH",
37            Method::Delete => "DELETE",
38            Method::Head => "HEAD",
39        }
40    }
41    /// RFC 9110 idempotent methods (safe to retry after an ambiguous failure).
42    pub fn is_idempotent(self) -> bool {
43        !matches!(self, Method::Post | Method::Patch)
44    }
45}
46
47/// One retry decision, reported to [`ClientConfig::on_retry`]. `url` and `reason`
48/// have configured and per-request secret values replaced by `[redacted]`.
49#[derive(Debug, Clone)]
50pub struct RetryEvent {
51    pub url: String,
52    /// Retry number about to run (1 = first retry).
53    pub retry: u32,
54    pub reason: String,
55    pub delay: Duration,
56}
57
58type RetryObserver = Arc<dyn Fn(&RetryEvent) + Send + Sync>;
59
60/// One attempt about to start, passed to [`ClientConfig::on_attempt`]. `url` has configured
61/// and per-request secret values replaced by `[redacted]` and any query or userinfo removed.
62#[derive(Debug, Clone)]
63pub struct AttemptEvent {
64    pub url: String,
65    pub method: Method,
66    /// Attempt number about to run (1 = the first try, 2 = the first retry).
67    pub attempt: u32,
68    /// Most attempts the policy allows for this call.
69    pub max_attempts: u32,
70}
71
72/// Gate run before every attempt. `Err(reason)` ends the call without sending that attempt.
73type AttemptHook = Arc<dyn Fn(&AttemptEvent) -> Result<(), String> + Send + Sync>;
74
75/// How requests reach the network.
76///
77/// **Threat model.** A direct connection is the strongest case: this crate resolves the
78/// name, checks every address and connects to exactly those addresses. Through a proxy it
79/// cannot: the proxy resolves the destination name itself, so the address a connection
80/// reaches cannot be checked or pinned here. The guarantee through a proxy is therefore
81/// name- and literal-based, and weaker than a direct connection. `ProxyMode::None` stays
82/// the default for that reason.
83///
84/// With a proxy:
85///
86/// - The proxy endpoint is trusted (it is user or administrator configuration, often on a
87///   private address such as `http://10.0.0.5:3128`). It gets no address-class denial.
88///   Only `http` and `https` proxies are accepted.
89/// - Every destination is still vetted: IP literals (and `localhost`) by class, hostnames
90///   by the name rules (`localhost`, `*.localhost`, `*.local`, `*.internal` and
91///   single-label names are refused unless an `allow_if` rule lists the host), cloud
92///   metadata names, and the first-party host pin. `deny_ranges` and `deny_if` apply to
93///   literals only, because a hostname's address is not known. A hostname that resolves to
94///   a private address at the proxy is not caught.
95/// - Every redirect hop is vetted the same way.
96/// - Hosts that match `NO_PROXY` connect directly and get the full direct-connection check.
97/// - [`ClientConfig::proxy_bypass_first_party`] skips the proxy for a first-party client.
98///
99/// The proxy is trusted: it sees destinations and can forward anywhere. Credentials in a
100/// proxy URL never appear in `Debug` output or errors.
101#[derive(Clone, Default)]
102pub enum ProxyMode {
103    /// No proxy. Environment proxy variables are ignored. This is the default.
104    #[default]
105    None,
106    /// `ALL_PROXY`, `HTTPS_PROXY`, `HTTP_PROXY` (first set, either case) and `NO_PROXY`.
107    Environment,
108    /// A proxy URL such as `http://proxy.corp:3128` or `socks5://127.0.0.1:1080`.
109    Explicit(String),
110    /// The proxy the operating system is configured with for the current user.
111    ///
112    /// Windows: the static proxy and bypass list of the system proxy settings, resolved per
113    /// request URL (since 0.3.2): the scheme's own endpoint (`http=a:80;https=b:443`, a
114    /// scheme with none connects directly) and the bypass list evaluated for that URL's host,
115    /// including `<local>`, wildcards anywhere, `.suffix` and scheme-limited entries. This
116    /// applies to every hop, so a redirect to another host is routed on its own. Nothing is
117    /// refused for being per-scheme or per-host. A proxy auto-config script is still
118    /// [`HttpError::SystemProxyUnsupported`](crate::HttpError::SystemProxyUnsupported) and
119    /// nothing is sent (use [`ProxyMode::SystemForUrl`] for a PAC script). With one endpoint
120    /// for both schemes and a bypass list of plain hosts the client holds that one proxy, as
121    /// before; `proxy_diagnostic` is `None` when the choice is made per request.
122    /// "Automatically detect settings" (the Windows default) with no static
123    /// proxy and no PAC script is not an error: the environment proxy variables are used
124    /// when set and a direct connection otherwise, reported through `Client::proxy_mode_used`
125    /// and `AsyncClient::proxy_mode_used` ([`ProxyModeUsed::SystemFallbackEnvironment`],
126    /// [`ProxyModeUsed::SystemFallbackDirect`]). With a static proxy the detection flag is
127    /// ignored. When nothing is configured, and on other systems, this is
128    /// [`ProxyMode::Environment`]. Android is one of those other systems: the proxy set in
129    /// Android's Wi-Fi settings (`ConnectivityManager`) is not read, and a process on Android
130    /// normally has no proxy environment variables, so this is a direct connection unless the
131    /// app sets them. An app that must honour the device proxy passes it as
132    /// [`ProxyMode::Explicit`].
133    System,
134    /// The proxy the operating system routes this URL through, resolved per URL when the
135    /// client is built (since 0.3.2).
136    ///
137    /// Windows: uses WinHTTP's own resolver (`WinHttpGetProxyForUrl`) for the PAC script
138    /// and/or "Automatically detect settings" (WPAD), and otherwise the static proxy with its
139    /// bypass list evaluated exactly for this URL (`<local>`, wildcards, scheme-limited
140    /// entries, different proxies per scheme). Nothing is refused for being per-URL. The
141    /// result is one proxy (or none) for the client, so build one client per target host:
142    /// a redirect to another host reuses the decision made for this URL. A PAC script that
143    /// is set but cannot be evaluated is
144    /// [`HttpError::SystemProxyUnsupported`](crate::HttpError::SystemProxyUnsupported); a
145    /// failed automatic detection alone (no WPAD on the network) falls back to the static
146    /// proxy, then the environment proxy variables, then a direct connection. The URL must be
147    /// `http` or `https`, otherwise the client fails closed with
148    /// [`HttpError::InvalidRequest`](crate::HttpError::InvalidRequest). Reported through
149    /// `proxy_mode_used` as [`ProxyModeUsed::SystemForUrl`] (or a fallback). Other systems:
150    /// as [`ProxyMode::Environment`].
151    SystemForUrl(String),
152}
153
154impl fmt::Debug for ProxyMode {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        match self {
157            ProxyMode::None => f.write_str("None"),
158            ProxyMode::Environment => f.write_str("Environment"),
159            ProxyMode::Explicit(url) => write!(f, "Explicit({})", redact_url(url)),
160            ProxyMode::System => f.write_str("System"),
161            ProxyMode::SystemForUrl(url) => write!(f, "SystemForUrl({})", redact_url(url)),
162        }
163    }
164}
165
166/// Which certificate roots verify a server's TLS certificate.
167///
168/// Verification is always on; there is no option that turns it off. `Platform` hands the
169/// decision to the operating system (macOS Security framework, Windows CryptoAPI, the
170/// system roots on Linux) through `rustls-platform-verifier` in both clients, so roots an
171/// enterprise or an inspection proxy installed on the machine are honoured, as curl and
172/// browsers honour them.
173///
174/// **Android.** `Platform` goes through `rustls-platform-verifier`, which on Android calls the
175/// system `TrustManager` over JNI and needs the verifier's Android component and a one-time
176/// initialisation with the app's `JavaVM` and `Context` (the `rightkit-android` bridge's job).
177/// This crate does not do that initialisation. Until it has run, `Platform` fails closed as
178/// described below; it never falls back to another root set. An Android caller that has not
179/// wired the verifier selects `Bundled`, which needs no JNI. Both clients are built with rustls
180/// and `ring` only: there is no OpenSSL or `native-tls` to cross-build. Neither path has been
181/// run on a device.
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
183pub enum TrustRoots {
184    /// The operating system's trust store. This is the default. When the store cannot be
185    /// used the client fails closed: the handshake (blocking) or `AsyncClient::new` (async)
186    /// returns an error.
187    #[default]
188    Platform,
189    /// Mozilla's root certificates compiled into the crate (`webpki-roots`), ignoring the
190    /// machine's store. Use it when the machine's roots must not be trusted, or when
191    /// results must not depend on the machine. A host behind TLS inspection will fail.
192    Bundled,
193}
194
195/// ureq TLS settings for a trust choice. The provider and the root source are named
196/// explicitly, so no other crate's additive features change what is trusted.
197fn blocking_tls_config(trust: TrustRoots) -> ureq::tls::TlsConfig {
198    let roots = match trust {
199        TrustRoots::Platform => ureq::tls::RootCerts::PlatformVerifier,
200        TrustRoots::Bundled => {
201            let extra = crate::trust::test_roots();
202            if extra.is_empty() {
203                ureq::tls::RootCerts::WebPki
204            } else {
205                ureq::tls::RootCerts::Specific(std::sync::Arc::new(
206                    extra
207                        .iter()
208                        .map(|d| ureq::tls::Certificate::from_der(d).to_owned())
209                        .collect(),
210                ))
211            }
212        }
213    };
214    ureq::tls::TlsConfig::builder()
215        .provider(ureq::tls::TlsProvider::Rustls)
216        .root_certs(roots)
217        .build()
218}
219
220impl TrustRoots {
221    /// `"platform"` or `"bundled"`: the verifier a client built from this setting uses.
222    pub fn verifier_kind(self) -> &'static str {
223        match self {
224            TrustRoots::Platform => "platform",
225            TrustRoots::Bundled => "bundled",
226        }
227    }
228}
229
230/// Proxy settings resolved from a [`ProxyMode`].
231pub(crate) struct ResolvedProxy {
232    pub(crate) host: String,
233    pub(crate) port: u16,
234    pub(crate) ureq: ureq::Proxy,
235    /// The proxy URL as configured. It may carry credentials: never print it.
236    #[cfg_attr(not(feature = "async"), allow(dead_code))]
237    pub(crate) url: String,
238    /// `NO_PROXY`-style entries as configured (the matcher lives in `ureq`).
239    #[allow(dead_code)]
240    pub(crate) no_proxy: Vec<String>,
241    /// True when the proxy came from the environment or the operating system's settings.
242    pub(crate) from_env: bool,
243}
244
245/// Which proxy source a client actually ended up with, as opposed to the [`ProxyMode`] it was
246/// asked for. Differs from the request only for [`ProxyMode::System`] when the operating
247/// system configures automatic detection and nothing else.
248#[derive(Clone, Debug, PartialEq, Eq)]
249pub enum ProxyModeUsed {
250    /// Connects directly (`ProxyMode::None`, a first-party bypass, or `System`/`Environment`
251    /// with nothing configured anywhere).
252    Direct,
253    /// The environment proxy variables (`ProxyMode::Environment`, or `System` with no
254    /// operating-system proxy configured).
255    Environment,
256    /// The proxy of `ProxyMode::Explicit`.
257    Explicit,
258    /// The operating system's static proxy, followed exactly.
259    System,
260    /// `System` was requested and the operating system configures automatic detection only
261    /// (no static proxy, no PAC script); the environment proxy variables are used.
262    SystemFallbackEnvironment { reason: String },
263    /// As above, but no environment proxy is set: connections are direct.
264    SystemFallbackDirect { reason: String },
265    /// `ProxyMode::SystemForUrl`: the route the operating system gave the URL, through its
266    /// automatic/PAC resolver or its static proxy settings (a proxy, or direct).
267    SystemForUrl,
268}
269
270/// What a client knows about the proxy it uses, for diagnostics. Credentials are never part
271/// of it.
272#[derive(Clone, Debug, PartialEq, Eq)]
273pub struct ProxyDiagnostic {
274    /// `scheme://host:port` of the proxy, without credentials, path or query.
275    pub endpoint: String,
276    /// True when the proxy was taken from the environment or from the operating system's
277    /// settings ([`ProxyMode::System`]), false for `ProxyMode::Explicit`.
278    pub from_environment: bool,
279    /// The proxy endpoint is exempt from the destination class check because the caller or
280    /// the environment configured it. Destinations are still checked: literals by class,
281    /// names by the name rules, and `NO_PROXY` hosts fully.
282    pub endpoint_exempt_from_class_check: bool,
283}
284
285impl ProxyDiagnostic {
286    pub(crate) fn of(p: &ResolvedProxy) -> Self {
287        Self {
288            endpoint: format!("{}://{}:{}", p.scheme(), p.host, p.port),
289            from_environment: p.from_env,
290            endpoint_exempt_from_class_check: true,
291        }
292    }
293}
294
295impl ResolvedProxy {
296    fn scheme(&self) -> &'static str {
297        if self.ureq.protocol() == ureq::ProxyProtocol::Https {
298            "https"
299        } else {
300            "http"
301        }
302    }
303}
304
305const PROXY_ENV: [&str; 6] = [
306    "ALL_PROXY",
307    "all_proxy",
308    "HTTPS_PROXY",
309    "https_proxy",
310    "HTTP_PROXY",
311    "http_proxy",
312];
313
314/// Build an `http`/`https` proxy that resolves destination names itself.
315fn build_proxy(url: &str, no_proxy: &[String]) -> Result<ResolvedProxy, HttpError> {
316    let invalid = || HttpError::InvalidRequest("invalid proxy URL".into());
317    let base = ureq::Proxy::new(url).map_err(|_| invalid())?;
318    if !matches!(
319        base.protocol(),
320        ureq::ProxyProtocol::Http | ureq::ProxyProtocol::Https
321    ) {
322        return Err(invalid());
323    }
324    let mut builder = ureq::Proxy::builder(base.protocol())
325        .host(base.host())
326        .port(base.port())
327        .resolve_target(false);
328    if let Some(user) = base.username() {
329        builder = builder.username(user);
330    }
331    if let Some(pass) = base.password() {
332        builder = builder.password(pass);
333    }
334    for expr in no_proxy {
335        builder = builder.no_proxy(expr);
336    }
337    let ureq = builder.build().map_err(|_| invalid())?;
338    Ok(ResolvedProxy {
339        host: normalize_host(ureq.host()),
340        port: ureq.port(),
341        ureq,
342        url: url.to_string(),
343        no_proxy: no_proxy.to_vec(),
344        from_env: false,
345    })
346}
347
348/// How a client reaches the network: one proxy decision for the whole client, or a decision
349/// per request URL (`ProxyMode::System` with a static configuration that differs by scheme or
350/// has a bypass list a by-host `NO_PROXY` cannot express).
351pub(crate) enum Routing {
352    Fixed(Option<ResolvedProxy>),
353    PerRequest(Arc<SystemRoutes>),
354}
355
356/// The operating system's static proxy settings, answered per request URL. The proxies are
357/// built once; each request only evaluates the bypass list and picks its scheme's proxy, which
358/// is a string match, so nothing is cached.
359pub(crate) struct SystemRoutes {
360    settings: crate::system_proxy::SystemProxySettings,
361    http: Option<ResolvedProxy>,
362    https: Option<ResolvedProxy>,
363}
364
365impl SystemRoutes {
366    /// Build the proxy for each scheme named by `settings`. A proxy that cannot be built is
367    /// an invalid setting: the client fails closed, it does not go direct.
368    pub(crate) fn new(
369        settings: &crate::system_proxy::SystemProxySettings,
370    ) -> Result<Self, HttpError> {
371        let raw = settings.proxy.as_deref().unwrap_or_default().trim();
372        let (http, https) = crate::system_proxy::endpoints_by_scheme(raw);
373        let build = |url: Option<String>| -> Result<Option<ResolvedProxy>, HttpError> {
374            url.map(|url| {
375                build_proxy(&url, &[]).map(|mut proxy| {
376                    proxy.from_env = true;
377                    proxy
378                })
379            })
380            .transpose()
381        };
382        Ok(Self {
383            settings: settings.clone(),
384            http: build(http)?,
385            https: build(https)?,
386        })
387    }
388
389    /// Where `scheme://host` goes: the bypass list first, then the scheme's own endpoint.
390    pub(crate) fn route(&self, scheme: &str, host: &str) -> crate::system_proxy::UrlRoute {
391        crate::system_proxy::static_route_for_url(&self.settings, scheme, host)
392            .unwrap_or(crate::system_proxy::UrlRoute::Direct)
393    }
394
395    /// The proxy `scheme://host` goes through, `None` when it connects directly.
396    pub(crate) fn proxy_for(&self, scheme: &str, host: &str) -> Option<&ResolvedProxy> {
397        match self.route(scheme, host) {
398            crate::system_proxy::UrlRoute::Direct => None,
399            crate::system_proxy::UrlRoute::Proxy(_) => {
400                if scheme == "https" {
401                    self.https.as_ref()
402                } else {
403                    self.http.as_ref()
404                }
405            }
406        }
407    }
408
409    /// Every proxy endpoint `(host, port)` this configuration can use.
410    pub(crate) fn endpoints(&self) -> Vec<(String, u16)> {
411        [&self.http, &self.https]
412            .into_iter()
413            .flatten()
414            .map(|p| (p.host.clone(), p.port))
415            .collect()
416    }
417
418    /// The distinct proxies, for a client that prepares one transport per proxy.
419    #[cfg(feature = "async")]
420    pub(crate) fn http(&self) -> Option<&ResolvedProxy> {
421        self.http.as_ref()
422    }
423
424    #[cfg(feature = "async")]
425    pub(crate) fn https(&self) -> Option<&ResolvedProxy> {
426        self.https.as_ref()
427    }
428}
429
430/// Lowercase scheme and host (no brackets, no trailing dot) of a request URI, as proxy
431/// routing needs them.
432pub(crate) fn uri_scheme_and_host(uri: &ureq::http::Uri) -> (String, String) {
433    (
434        uri.scheme_str().unwrap_or("").to_ascii_lowercase(),
435        normalize_host(uri.host().unwrap_or("")),
436    )
437}
438
439/// Proxy from environment-style variables read through `get`. Empty values are unset.
440pub(crate) fn proxy_from_lookup(
441    get: &dyn Fn(&str) -> Option<String>,
442) -> Result<Option<ResolvedProxy>, HttpError> {
443    let no_proxy: Vec<String> = ["NO_PROXY", "no_proxy"]
444        .iter()
445        .find_map(|name| get(name).filter(|v| !v.is_empty()))
446        .map(|v| {
447            v.split(',')
448                .map(|e| e.trim().to_string())
449                .filter(|e| !e.is_empty())
450                .collect()
451        })
452        .unwrap_or_default();
453    for name in PROXY_ENV {
454        if let Some(url) = get(name).filter(|v| !v.trim().is_empty()) {
455            if let Ok(mut proxy) = build_proxy(url.trim(), &no_proxy) {
456                proxy.from_env = true;
457                return Ok(Some(proxy));
458            }
459        }
460    }
461    Ok(None)
462}
463
464impl ClientConfig {
465    /// How this config reaches the network and which source that came from. Same as
466    /// [`ClientConfig::resolved_proxy_with_mode`] except that `ProxyMode::System` with a
467    /// static configuration that needs a decision per URL is [`Routing::PerRequest`].
468    pub(crate) fn resolved_routing_with_mode(&self) -> Result<(Routing, ProxyModeUsed), HttpError> {
469        let bypassed = self.proxy_bypass_first_party && self.address_policy.is_first_party();
470        if matches!(self.proxy, ProxyMode::System) && !bypassed {
471            let env = |name: &str| std::env::var(name).ok();
472            return resolve_system_routing(&crate::system_proxy::system_proxy_settings(), &env);
473        }
474        self.resolved_proxy_with_mode()
475            .map(|(proxy, used)| (Routing::Fixed(proxy), used))
476    }
477
478    /// The proxy this config uses, or `None` when it connects directly.
479    #[allow(dead_code)]
480    pub(crate) fn resolved_proxy(&self) -> Result<Option<ResolvedProxy>, HttpError> {
481        self.resolved_proxy_with_mode().map(|(proxy, _)| proxy)
482    }
483
484    /// The proxy this config uses and which source it came from.
485    pub(crate) fn resolved_proxy_with_mode(
486        &self,
487    ) -> Result<(Option<ResolvedProxy>, ProxyModeUsed), HttpError> {
488        if self.proxy_bypass_first_party && self.address_policy.is_first_party() {
489            return Ok((None, ProxyModeUsed::Direct));
490        }
491        let env = |name: &str| std::env::var(name).ok();
492        match &self.proxy {
493            ProxyMode::None => Ok((None, ProxyModeUsed::Direct)),
494            ProxyMode::Explicit(url) => {
495                build_proxy(url, &[]).map(|p| (Some(p), ProxyModeUsed::Explicit))
496            }
497            ProxyMode::Environment => {
498                let proxy = proxy_from_lookup(&env)?;
499                let used = if proxy.is_some() {
500                    ProxyModeUsed::Environment
501                } else {
502                    ProxyModeUsed::Direct
503                };
504                Ok((proxy, used))
505            }
506            ProxyMode::System => {
507                resolve_system(&crate::system_proxy::system_proxy_settings(), &env)
508            }
509            ProxyMode::SystemForUrl(url) => resolve_system_for_url(
510                &crate::system_proxy::system_proxy_settings(),
511                url,
512                &crate::system_proxy::winhttp_route_for_url,
513                &env,
514            ),
515        }
516    }
517}
518
519/// `ProxyMode::SystemForUrl` for `settings`. `os` is the automatic/PAC resolver (WinHTTP on
520/// Windows), `None` when it failed; it is an input so tests need no network.
521pub(crate) fn resolve_system_for_url(
522    settings: &crate::system_proxy::SystemProxySettings,
523    url: &str,
524    os: &dyn Fn(
525        &str,
526        &crate::system_proxy::SystemProxySettings,
527    ) -> Option<crate::system_proxy::UrlRoute>,
528    get: &dyn Fn(&str) -> Option<String>,
529) -> Result<(Option<ResolvedProxy>, ProxyModeUsed), HttpError> {
530    use crate::system_proxy::UrlRoute;
531    let Some((scheme, host)) = crate::system_proxy::scheme_and_host(url) else {
532        return Err(HttpError::InvalidRequest(
533            "invalid URL for system proxy resolution".into(),
534        ));
535    };
536    let from_route =
537        |route: UrlRoute| -> Result<(Option<ResolvedProxy>, ProxyModeUsed), HttpError> {
538            match route {
539                UrlRoute::Direct => Ok((None, ProxyModeUsed::SystemForUrl)),
540                UrlRoute::Proxy(proxy_url) => {
541                    let mut proxy = build_proxy(&proxy_url, &[])?;
542                    proxy.from_env = true;
543                    Ok((Some(proxy), ProxyModeUsed::SystemForUrl))
544                }
545            }
546        };
547    let automatic = settings.auto_config || settings.auto_detect;
548    if automatic {
549        if let Some(route) = os(url, settings) {
550            return from_route(route);
551        }
552        // A PAC script the user set that cannot be evaluated is not guessed around.
553        if settings.auto_config {
554            return Err(HttpError::SystemProxyUnsupported {
555                element: "the proxy auto-config script could not be evaluated for this URL".into(),
556            });
557        }
558    }
559    if let Some(route) = crate::system_proxy::static_route_for_url(settings, &scheme, &host) {
560        return from_route(route);
561    }
562    let proxy = proxy_from_lookup(get)?;
563    let used = match (settings.auto_detect, proxy.is_some()) {
564        (true, true) => ProxyModeUsed::SystemFallbackEnvironment {
565            reason: AUTO_DETECT_FAILED_REASON.to_string(),
566        },
567        (true, false) => ProxyModeUsed::SystemFallbackDirect {
568            reason: AUTO_DETECT_FAILED_REASON.to_string(),
569        },
570        (false, true) => ProxyModeUsed::Environment,
571        (false, false) => ProxyModeUsed::Direct,
572    };
573    Ok((proxy, used))
574}
575
576/// Reason reported when `SystemForUrl` found no proxy through automatic detection.
577pub(crate) const AUTO_DETECT_FAILED_REASON: &str =
578    "automatic detection found no proxy for this URL; no static proxy or PAC configured";
579
580/// Reason reported when the only thing the system configures is automatic detection.
581pub(crate) const AUTO_DETECT_ONLY_REASON: &str =
582    "automatic detection only; no static proxy or PAC configured";
583
584/// `ProxyMode::System` for `settings`, as a client-wide proxy or, when the static
585/// configuration differs by scheme or has a bypass list a by-host list cannot express, a
586/// decision per request URL. No registry or network access: both are inputs.
587pub(crate) fn resolve_system_routing(
588    settings: &crate::system_proxy::SystemProxySettings,
589    get: &dyn Fn(&str) -> Option<String>,
590) -> Result<(Routing, ProxyModeUsed), HttpError> {
591    if matches!(
592        crate::system_proxy::proxy_choice(settings),
593        crate::system_proxy::SystemProxyChoice::PerRequest
594    ) {
595        return Ok((
596            Routing::PerRequest(Arc::new(SystemRoutes::new(settings)?)),
597            ProxyModeUsed::System,
598        ));
599    }
600    resolve_system(settings, get).map(|(proxy, used)| (Routing::Fixed(proxy), used))
601}
602
603/// `ProxyMode::System` for `settings`, when one proxy serves the whole client.
604///
605/// A static proxy that can be followed exactly is used (the auto-detect flag is ignored when
606/// one is set). Automatic detection with no static proxy and no PAC script is not an
607/// error: the environment proxy is used when set, a direct connection otherwise, and the
608/// result says so. Anything the user configured that this client cannot follow exactly (a
609/// PAC script, different routes per scheme, `<local>` or another unexpressible bypass entry
610/// next to a static proxy) is still [`HttpError::SystemProxyUnsupported`]. No registry or
611/// network access: `settings` and the environment lookup are inputs.
612pub(crate) fn resolve_system(
613    settings: &crate::system_proxy::SystemProxySettings,
614    get: &dyn Fn(&str) -> Option<String>,
615) -> Result<(Option<ResolvedProxy>, ProxyModeUsed), HttpError> {
616    let env_or_direct = |used_env: ProxyModeUsed,
617                         used_direct: ProxyModeUsed|
618     -> Result<(Option<ResolvedProxy>, ProxyModeUsed), HttpError> {
619        let proxy = proxy_from_lookup(get)?;
620        let used = if proxy.is_some() {
621            used_env
622        } else {
623            used_direct
624        };
625        Ok((proxy, used))
626    };
627    if matches!(
628        crate::system_proxy::proxy_choice(settings),
629        crate::system_proxy::SystemProxyChoice::AutoDetectOnly
630    ) {
631        let reason = AUTO_DETECT_ONLY_REASON.to_string();
632        return env_or_direct(
633            ProxyModeUsed::SystemFallbackEnvironment {
634                reason: reason.clone(),
635            },
636            ProxyModeUsed::SystemFallbackDirect { reason },
637        );
638    }
639    match proxy_from_system(settings)? {
640        Some(proxy) => Ok((Some(proxy), ProxyModeUsed::System)),
641        None => env_or_direct(ProxyModeUsed::Environment, ProxyModeUsed::Direct),
642    }
643}
644
645/// The proxy the operating system's static settings name, treated like an environment proxy.
646/// Settings that name no usable proxy, or one this crate cannot build, give `Ok(None)`.
647///
648/// Settings this client cannot follow exactly (see [`crate::system_proxy`]) are an error that
649/// names the first such element. A client uses one proxy for every request and a bypass list
650/// of plain hosts, so following them approximately would send traffic to a proxy, or around
651/// it, against what the user configured. The client then fails closed, as for any invalid
652/// proxy setting. Automatic detection with nothing else configured is `Ok(None)` here;
653/// [`resolve_system`] tells it apart and reports it.
654pub(crate) fn proxy_from_system(
655    settings: &crate::system_proxy::SystemProxySettings,
656) -> Result<Option<ResolvedProxy>, HttpError> {
657    use crate::system_proxy::SystemProxyChoice;
658    match crate::system_proxy::proxy_choice(settings) {
659        SystemProxyChoice::None | SystemProxyChoice::AutoDetectOnly => Ok(None),
660        SystemProxyChoice::Single { url, no_proxy } => {
661            Ok(build_proxy(&url, &no_proxy).ok().map(|mut proxy| {
662                proxy.from_env = true;
663                proxy
664            }))
665        }
666        // The clients take this case through `resolve_system_routing`, per request URL.
667        SystemProxyChoice::PerRequest => Err(HttpError::SystemProxyUnsupported {
668            element: "the proxy settings differ by scheme or host and need per-request routing"
669                .into(),
670        }),
671        SystemProxyChoice::Unsupported(element) => {
672            Err(HttpError::SystemProxyUnsupported { element })
673        }
674    }
675}
676
677#[derive(Clone)]
678pub struct ClientConfig {
679    pub user_agent: String,
680    /// Connection-phase deadline; `None` disables this per-phase deadline.
681    pub connect_timeout: Option<Duration>,
682    /// Whole-request deadline for buffered calls (headers + body).
683    pub request_timeout: Duration,
684    /// Time to receive response headers; for streams this is the first-byte
685    /// (first-token) deadline. `None` disables this per-phase deadline.
686    pub response_timeout: Option<Duration>,
687    /// Total time to receive the body, starting after response headers arrive.
688    /// The budget is not restarted per read. `None` disables this phase deadline.
689    pub recv_body_timeout: Option<Duration>,
690    /// Optional total lifetime cap for a stream body.
691    pub stream_timeout: Option<Duration>,
692    /// Idle (stall) limit for streamed bodies ([`Client::stream`], [`Client::stream_any`]): a read
693    /// that receives no bytes for this long fails with an `io::ErrorKind::TimedOut` error,
694    /// whatever the header, `recv_body_timeout` and `stream_timeout` budgets say. The clock
695    /// restarts on every chunk, so a slow but steady multi-gigabyte download survives. `None`
696    /// (default) disables it. The blocking client costs one helper thread per stream while set;
697    /// the async client's stream costs one timer. Buffered calls (`send`) are not affected; use
698    /// `recv_body_timeout` there. Applies to the streamed body of both clients.
699    pub idle_timeout: Option<Duration>,
700    /// Redirects followed (0 = never; credentials are never replayed to a new host).
701    pub max_redirects: u32,
702    /// Error when a nonzero redirect limit is exceeded (default true, like ureq).
703    /// False returns the final 3xx response. With `max_redirects = 0`, redirects
704    /// are never followed and the 3xx response is always returned.
705    pub max_redirects_will_error: bool,
706    pub max_response_bytes: usize,
707    pub retry: RetryPolicy,
708    pub on_retry: Option<RetryObserver>,
709    /// Hook that runs before every attempt, including the first, beneath the retry loop,
710    /// so budget and spend gates re-enter on each try (a retry is a new spend). `Err(reason)`
711    /// stops the call with `HttpError::InvalidRequest("attempt N refused: reason")`, which is
712    /// never retried; nothing is sent for that attempt. It runs on the calling thread (async:
713    /// on the polling task), so it should not block. `None` by default.
714    pub on_attempt: Option<AttemptHook>,
715    /// Secret strings scrubbed from every error message and retry event. Add
716    /// values with [`ClientConfig::redact_values`].
717    pub redact_values: Vec<String>,
718    /// Outbound address policy. The default is public-web policy: loopback, private,
719    /// link-local, CGNAT, multicast and reserved destinations are denied on the first
720    /// request and on every redirect hop. See [`crate::netpolicy`].
721    pub address_policy: AddressPolicy,
722    /// Proxy use. The default is [`ProxyMode::None`]: no proxy, and environment proxy
723    /// variables are ignored. See [`ProxyMode`] for the threat model.
724    pub proxy: ProxyMode,
725    /// When the address policy is first-party, connect to the first-party hosts directly
726    /// even if `proxy` is set. Default `false`: first-party calls use the proxy too.
727    pub proxy_bypass_first_party: bool,
728    /// Certificate roots used to verify servers. The default is [`TrustRoots::Platform`]
729    /// (the OS trust store); [`TrustRoots::Bundled`] restores the Mozilla set used before
730    /// 0.3.0. Verification cannot be disabled.
731    pub trust: TrustRoots,
732    /// Refuse any URL that is not `https`, on the first request and on every redirect hop,
733    /// with [`HttpError::HttpsRequired`]. Default `false`, which keeps the earlier
734    /// behaviour (plain `http` allowed; an HTTPS to HTTP redirect is still refused by the
735    /// address policy unless it opts in).
736    pub https_only: bool,
737}
738
739impl Default for ClientConfig {
740    fn default() -> Self {
741        Self {
742            user_agent: concat!("rightkit-http/", env!("CARGO_PKG_VERSION")).to_string(),
743            connect_timeout: Some(Duration::from_secs(10)),
744            request_timeout: Duration::from_secs(60),
745            response_timeout: Some(Duration::from_secs(60)),
746            recv_body_timeout: None,
747            stream_timeout: None,
748            idle_timeout: None,
749            max_redirects: 0,
750            max_redirects_will_error: true,
751            max_response_bytes: 32 * 1024 * 1024,
752            retry: RetryPolicy::default(),
753            on_retry: None,
754            on_attempt: None,
755            redact_values: Vec::new(),
756            address_policy: AddressPolicy::default(),
757            proxy: ProxyMode::None,
758            proxy_bypass_first_party: false,
759            trust: TrustRoots::Platform,
760            https_only: false,
761        }
762    }
763}
764
765impl ClientConfig {
766    /// Default settings with a first-party policy: only `hosts` (exact match), only over
767    /// HTTPS, resolving to public addresses unless the policy opts a host out with
768    /// `AddressPolicy::allow_private_resolution`. Use it for license checks and model downloads against known hosts.
769    pub fn first_party(hosts: Vec<String>) -> Self {
770        Self::default().address_policy(AddressPolicy::first_party(hosts))
771    }
772
773    /// Settings that match a plain `ureq` agent, for callers moving off direct ureq: environment
774    /// proxy variables honoured ([`ProxyMode::Environment`]), Mozilla roots ([`TrustRoots::Bundled`]),
775    /// 10 redirects, no retries ([`RetryPolicy::none`]) and the given user agent. Every other field
776    /// keeps its [`Default`] value. It does NOT enable private-address resolution: the address policy
777    /// stays public-web unless set explicitly.
778    pub fn legacy_ureq_parity(user_agent: impl Into<String>) -> Self {
779        Self {
780            user_agent: user_agent.into(),
781            proxy: ProxyMode::Environment,
782            trust: TrustRoots::Bundled,
783            max_redirects: 10,
784            retry: RetryPolicy::none(),
785            ..Self::default()
786        }
787    }
788
789    /// Set the per-attempt hook; see [`ClientConfig::on_attempt`].
790    pub fn on_attempt(
791        mut self,
792        hook: impl Fn(&AttemptEvent) -> Result<(), String> + Send + Sync + 'static,
793    ) -> Self {
794        self.on_attempt = Some(Arc::new(hook));
795        self
796    }
797
798    /// Replace the outbound address policy.
799    pub fn address_policy(mut self, policy: AddressPolicy) -> Self {
800        self.address_policy = policy;
801        self
802    }
803
804    /// Set the proxy mode; see [`ProxyMode`].
805    pub fn proxy(mut self, mode: ProxyMode) -> Self {
806        self.proxy = mode;
807        self
808    }
809
810    /// Choose the certificate roots; see [`TrustRoots`].
811    pub fn trust(mut self, trust: TrustRoots) -> Self {
812        self.trust = trust;
813        self
814    }
815
816    /// Set the idle (no bytes received) limit for streamed bodies; see
817    /// [`ClientConfig::idle_timeout`].
818    pub fn idle_timeout(mut self, idle: Option<Duration>) -> Self {
819        self.idle_timeout = idle;
820        self
821    }
822
823    /// Refuse non-`https` URLs and redirect hops; see [`ClientConfig::https_only`].
824    pub fn https_only(mut self, https_only: bool) -> Self {
825        self.https_only = https_only;
826        self
827    }
828
829    /// Let a first-party client reach its hosts without the proxy; see
830    /// [`ClientConfig::proxy_bypass_first_party`].
831    pub fn proxy_bypass_first_party(mut self, bypass: bool) -> Self {
832        self.proxy_bypass_first_party = bypass;
833        self
834    }
835
836    /// Add secret strings to scrub from error text (`[redacted]`). Empty strings are ignored.
837    pub fn redact_values(mut self, values: Vec<String>) -> Self {
838        self.redact_values
839            .extend(values.into_iter().filter(|v| !v.is_empty()));
840        self
841    }
842}
843
844#[derive(Clone)]
845pub struct Request {
846    pub method: Method,
847    pub url: String,
848    pub headers: Vec<(String, String)>,
849    pub body: Vec<u8>,
850    /// Per-attempt total timeout (buffered calls and stream headers).
851    pub timeout: Option<Duration>,
852    pub retry: Option<RetryPolicy>,
853    /// Allow retrying a POST/PATCH after HTTP-status failures (429, 5xx, ...). Default
854    /// `false` since 0.3.0: a replayed POST (a single-use OAuth code exchange, a payment) can
855    /// be harmful. Transport errors on non-idempotent methods retry only in the connect
856    /// phase regardless. Set `true` per request for an idempotent POST; see the README for
857    /// how this interacts with `RetryPolicy::none()`.
858    pub retry_unsafe_statuses: bool,
859    pub(crate) max_response_bytes: Option<usize>,
860    pub(crate) deadline: Option<Duration>,
861    pub(crate) cancel: Option<CancelToken>,
862    pub(crate) secret_values: Vec<String>,
863    pub(crate) headers_only: bool,
864}
865
866impl fmt::Debug for Request {
867    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
868        let headers: Vec<(String, String)> = self
869            .headers
870            .iter()
871            .map(|(k, v)| {
872                let shown = if redirect::is_secret_header_name(k) {
873                    REDACTED.to_string()
874                } else {
875                    redact_str(&redact_urls_in_text(v), &sorted_values(&self.secret_values))
876                };
877                (k.clone(), shown)
878            })
879            .collect();
880        f.debug_struct("Request")
881            .field("method", &self.method)
882            .field(
883                "url",
884                &redact_str(&redact_url(&self.url), &self.secret_values),
885            )
886            .field("headers", &headers)
887            .field("body_len", &self.body.len())
888            .field("timeout", &self.timeout)
889            .field("retry", &self.retry)
890            .field("retry_unsafe_statuses", &self.retry_unsafe_statuses)
891            .field("max_response_bytes", &self.max_response_bytes)
892            .field("deadline", &self.deadline)
893            .field("cancel", &self.cancel.is_some())
894            .field("headers_only", &self.headers_only)
895            .finish()
896    }
897}
898
899impl Request {
900    pub fn new(method: Method, url: impl Into<String>) -> Self {
901        Self {
902            method,
903            url: url.into(),
904            headers: Vec::new(),
905            body: Vec::new(),
906            timeout: None,
907            retry: None,
908            retry_unsafe_statuses: false,
909            max_response_bytes: None,
910            deadline: None,
911            cancel: None,
912            secret_values: Vec::new(),
913            headers_only: false,
914        }
915    }
916    pub fn get(url: impl Into<String>) -> Self {
917        Self::new(Method::Get, url)
918    }
919    pub fn post(url: impl Into<String>) -> Self {
920        Self::new(Method::Post, url)
921    }
922    pub fn put(url: impl Into<String>) -> Self {
923        Self::new(Method::Put, url)
924    }
925    pub fn delete(url: impl Into<String>) -> Self {
926        Self::new(Method::Delete, url)
927    }
928    /// Append a header. Use [`Request::set_header`] to replace an existing one.
929    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
930        self.headers.push((name.into(), value.into()));
931        self
932    }
933    /// Set a header, replacing every existing header with the same name
934    /// (ASCII case-insensitive). The first occurrence keeps its position.
935    pub fn set_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
936        let name = name.into();
937        let value = value.into();
938        let position = self
939            .headers
940            .iter()
941            .position(|(k, _)| k.eq_ignore_ascii_case(&name));
942        self.headers.retain(|(k, _)| !k.eq_ignore_ascii_case(&name));
943        match position {
944            Some(i) => self.headers.insert(i, (name, value)),
945            None => self.headers.push((name, value)),
946        }
947        self
948    }
949    /// Set a header whose value is a secret. The value is sent on the wire but
950    /// never appears in error text, retry events, or `Debug` output.
951    pub fn secret_header(self, name: impl Into<String>, value: impl Into<String>) -> Self {
952        let value = value.into();
953        let mut req = self;
954        if !value.is_empty() {
955            req.secret_values.push(value.clone());
956        }
957        req.set_header(name, value)
958    }
959    /// `Authorization: Bearer <token>`. The token is treated as a secret.
960    pub fn bearer(mut self, token: &str) -> Self {
961        if !token.is_empty() {
962            self.secret_values.push(token.to_string());
963        }
964        self.header("Authorization", format!("Bearer {token}"))
965    }
966    /// Register a secret that is not a header, for example a form field or a value inside a
967    /// body. It is scrubbed from error text, retry events and `Debug` output exactly like a
968    /// [`Request::secret_header`] value. Empty values are ignored.
969    pub fn redact_value(mut self, value: impl Into<String>) -> Self {
970        let value = value.into();
971        if !value.is_empty() {
972            self.secret_values.push(value);
973        }
974        self
975    }
976    /// [`Request::form`] that also registers the value of every pair whose name is in
977    /// `secret_names` (compared ignoring ASCII case) as a secret, so a token or client secret
978    /// sent in the body is scrubbed from errors like a secret header.
979    pub fn secret_form(mut self, pairs: &[(&str, &str)], secret_names: &[&str]) -> Self {
980        for (name, value) in pairs {
981            if secret_names.iter().any(|s| s.eq_ignore_ascii_case(name)) && !value.is_empty() {
982                self.secret_values.push((*value).to_string());
983            }
984        }
985        self.form(pairs)
986    }
987    /// Replace every secret value registered on this request (not the client's configured
988    /// ones) in `text` with `[redacted]`. For text the caller took from a response, such as
989    /// an error field a provider echoed back, where no HTTP error carries it for scrubbing.
990    pub fn scrub(&self, text: &str) -> String {
991        redact_str(text, &sorted_values(&self.secret_values))
992    }
993    pub fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
994        self.body = body.into();
995        self
996    }
997    pub fn json<T: Serialize>(mut self, value: &T) -> Result<Self, HttpError> {
998        self.body = serde_json::to_vec(value).map_err(json_error)?;
999        self.headers
1000            .push(("Content-Type".into(), "application/json".into()));
1001        Ok(self)
1002    }
1003    /// `application/x-www-form-urlencoded` body.
1004    pub fn form(mut self, pairs: &[(&str, &str)]) -> Self {
1005        self.body = form_encode(pairs).into_bytes();
1006        self.headers.push((
1007            "Content-Type".into(),
1008            "application/x-www-form-urlencoded".into(),
1009        ));
1010        self
1011    }
1012    /// Per-attempt total timeout. Each retry attempt gets this budget; use
1013    /// [`Request::deadline`] to bound all attempts together.
1014    pub fn timeout(mut self, d: Duration) -> Self {
1015        self.timeout = Some(d);
1016        self
1017    }
1018    /// Overall budget for the call: all attempts and the retry sleeps between
1019    /// them. When the next sleep would pass the deadline, the last outcome is
1020    /// returned instead of sleeping. The per-attempt timeout is unchanged.
1021    pub fn deadline(mut self, total: Duration) -> Self {
1022        self.deadline = Some(total);
1023        self
1024    }
1025    pub fn retry(mut self, policy: RetryPolicy) -> Self {
1026        self.retry = Some(policy);
1027        self
1028    }
1029    /// Allow retrying POST/PATCH after HTTP-status failures (`true`) or not (`false`, the default).
1030    pub fn retry_unsafe_statuses(mut self, allow: bool) -> Self {
1031        self.retry_unsafe_statuses = allow;
1032        self
1033    }
1034    /// Per-request response body limit, overriding `ClientConfig::max_response_bytes`.
1035    pub fn max_response_bytes(mut self, limit: usize) -> Self {
1036        self.max_response_bytes = Some(limit);
1037        self
1038    }
1039    /// Return only the status and headers: `send`, `send_any` and `Client::send_headers` do
1040    /// not read the response body. It is dropped unread, which closes that connection.
1041    /// Any status is returned by `send_any` and `send_headers`, including 3xx when redirects
1042    /// are off and 4xx/5xx after retries; `send` still turns non-2xx into an error.
1043    pub fn headers_only(mut self) -> Self {
1044        self.headers_only = true;
1045        self
1046    }
1047    /// Cancel this call through `token`. Checked between attempts, during retry
1048    /// sleeps and before every body read; a cancelled call returns [`HttpError::Cancelled`].
1049    pub fn cancel(mut self, token: CancelToken) -> Self {
1050        self.cancel = Some(token);
1051        self
1052    }
1053}
1054
1055/// Percent-encode for `application/x-www-form-urlencoded` / URL query values.
1056pub fn percent_encode(input: &str) -> String {
1057    let mut out = String::with_capacity(input.len());
1058    for b in input.bytes() {
1059        match b {
1060            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1061                out.push(b as char)
1062            }
1063            b' ' => out.push('+'),
1064            _ => out.push_str(&format!("%{b:02X}")),
1065        }
1066    }
1067    out
1068}
1069
1070pub fn percent_decode(input: &str) -> String {
1071    let bytes = input.as_bytes();
1072    let mut out = Vec::with_capacity(bytes.len());
1073    let mut i = 0;
1074    while i < bytes.len() {
1075        match bytes[i] {
1076            b'+' => out.push(b' '),
1077            b'%' if i + 2 < bytes.len() => {
1078                let hi = (bytes[i + 1] as char).to_digit(16);
1079                let lo = (bytes[i + 2] as char).to_digit(16);
1080                match (hi, lo) {
1081                    (Some(h), Some(l)) => {
1082                        out.push((h * 16 + l) as u8);
1083                        i += 2;
1084                    }
1085                    _ => out.push(b'%'),
1086                }
1087            }
1088            b => out.push(b),
1089        }
1090        i += 1;
1091    }
1092    String::from_utf8_lossy(&out).into_owned()
1093}
1094
1095/// RFC 3986 percent-encoding for one URL path segment or query value. Unreserved bytes
1096/// (`A-Z a-z 0-9 - . _ ~`) are kept, a space becomes `%20` (never `+`), and every other byte
1097/// becomes `%XX`. Use [`percent_encode`] only for `application/x-www-form-urlencoded` bodies.
1098pub fn percent_encode_component(input: &str) -> String {
1099    let mut out = String::with_capacity(input.len());
1100    for b in input.bytes() {
1101        match b {
1102            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1103                out.push(b as char)
1104            }
1105            _ => out.push_str(&format!("%{b:02X}")),
1106        }
1107    }
1108    out
1109}
1110
1111/// Inverse of [`percent_encode_component`]: decodes `%XX` escapes and leaves `+` as written.
1112/// An escape that is not two hex digits is kept as written; invalid UTF-8 is replaced.
1113pub fn percent_decode_component(input: &str) -> String {
1114    let bytes = input.as_bytes();
1115    let mut out = Vec::with_capacity(bytes.len());
1116    let mut i = 0;
1117    while i < bytes.len() {
1118        if bytes[i] == b'%' && i + 2 < bytes.len() {
1119            let hi = (bytes[i + 1] as char).to_digit(16);
1120            let lo = (bytes[i + 2] as char).to_digit(16);
1121            if let (Some(h), Some(l)) = (hi, lo) {
1122                out.push((h * 16 + l) as u8);
1123                i += 3;
1124                continue;
1125            }
1126        }
1127        out.push(bytes[i]);
1128        i += 1;
1129    }
1130    String::from_utf8_lossy(&out).into_owned()
1131}
1132
1133pub fn form_encode(pairs: &[(&str, &str)]) -> String {
1134    pairs
1135        .iter()
1136        .map(|(k, v)| format!("{}={}", percent_encode(k), percent_encode(v)))
1137        .collect::<Vec<_>>()
1138        .join("&")
1139}
1140
1141#[derive(Clone)]
1142pub struct Response {
1143    pub status: u16,
1144    /// Lower-cased header names.
1145    pub headers: Vec<(String, String)>,
1146    pub body: Vec<u8>,
1147    pub attempts: u32,
1148    /// URL of the response after the redirects that were followed (the request URL when none).
1149    pub(crate) final_url: String,
1150    /// Secrets to scrub from `Debug` output: the client's `redact_values` and the
1151    /// request's secret values and URL secrets.
1152    pub(crate) redact: Vec<String>,
1153}
1154
1155impl Response {
1156    pub fn header(&self, name: &str) -> Option<&str> {
1157        header_of(&self.headers, name)
1158    }
1159    /// Every value of a repeated header, in order (for example several `Set-Cookie` lines).
1160    /// [`Response::header`] returns only the first.
1161    pub fn header_all<'a>(&'a self, name: &str) -> impl Iterator<Item = &'a str> + 'a {
1162        header_all_of(&self.headers, name)
1163    }
1164    /// The URL after the followed redirects, as the caller's own URL data (it keeps any query
1165    /// string; `Debug` redacts it).
1166    pub fn final_url(&self) -> &str {
1167        &self.final_url
1168    }
1169    /// The numeric or HTTP-date `Retry-After` delay, when the response carries one.
1170    pub fn retry_after(&self) -> Option<Duration> {
1171        self.header("retry-after").and_then(parse_retry_after)
1172    }
1173    pub fn text(&self) -> String {
1174        String::from_utf8_lossy(&self.body).into_owned()
1175    }
1176    /// Decode the body. The error names the failure category and position only: serde's
1177    /// own text can echo fragments of the body, which may be secret.
1178    pub fn json<T: DeserializeOwned>(&self) -> Result<T, HttpError> {
1179        serde_json::from_slice(&self.body).map_err(json_error)
1180    }
1181}
1182
1183/// A [`HttpError::Json`] that never quotes the input.
1184pub(crate) fn json_error(e: serde_json::Error) -> HttpError {
1185    HttpError::Json(format!(
1186        "{:?} error at line {} column {}",
1187        e.classify(),
1188        e.line(),
1189        e.column()
1190    ))
1191}
1192
1193impl fmt::Debug for Response {
1194    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1195        f.debug_struct("Response")
1196            .field("status", &self.status)
1197            .field("headers", &redact_headers_with(&self.headers, &self.redact))
1198            .field("body_len", &self.body.len())
1199            .field("attempts", &self.attempts)
1200            .finish()
1201    }
1202}
1203
1204/// Status line and headers of a response whose body was not read; see
1205/// [`Client::send_headers`].
1206#[derive(Clone)]
1207pub struct ResponseHead {
1208    pub status: u16,
1209    /// Lower-cased header names.
1210    pub headers: Vec<(String, String)>,
1211    /// URL of the response after the redirects that were followed. It is the caller's own
1212    /// URL data and keeps any query string; `Debug` redacts it.
1213    pub final_url: String,
1214    /// Redirects followed to reach `final_url`.
1215    pub hops: u32,
1216    /// HTTP requests made, counting retries (not redirect hops).
1217    pub attempts: u32,
1218    /// Secrets to scrub from `Debug` output; see [`Response`].
1219    pub(crate) redact: Vec<String>,
1220}
1221
1222impl ResponseHead {
1223    pub fn header(&self, name: &str) -> Option<&str> {
1224        header_of(&self.headers, name)
1225    }
1226    /// Every value of a repeated header, in order; see [`Response::header_all`].
1227    pub fn header_all<'a>(&'a self, name: &str) -> impl Iterator<Item = &'a str> + 'a {
1228        header_all_of(&self.headers, name)
1229    }
1230}
1231
1232impl fmt::Debug for ResponseHead {
1233    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1234        f.debug_struct("ResponseHead")
1235            .field("status", &self.status)
1236            .field("headers", &redact_headers_with(&self.headers, &self.redact))
1237            .field(
1238                "final_url",
1239                &redact_str(&redact_url(&self.final_url), &self.redact),
1240            )
1241            .field("hops", &self.hops)
1242            .field("attempts", &self.attempts)
1243            .finish()
1244    }
1245}
1246
1247pub struct StreamResponse {
1248    pub status: u16,
1249    pub headers: Vec<(String, String)>,
1250    pub attempts: u32,
1251    reader: Box<dyn Read + Send>,
1252}
1253
1254impl StreamResponse {
1255    pub fn header(&self, name: &str) -> Option<&str> {
1256        header_of(&self.headers, name)
1257    }
1258    /// Every value of a repeated header, in order; see [`Response::header_all`].
1259    pub fn header_all<'a>(&'a self, name: &str) -> impl Iterator<Item = &'a str> + 'a {
1260        header_all_of(&self.headers, name)
1261    }
1262    /// Copy the body into `writer`, at most `max_bytes` in total, calling `progress` with the
1263    /// running byte count after each chunk. A body over the cap fails with
1264    /// [`HttpError::BodyTooLarge`]; bytes already written stay written. Returns the total.
1265    pub fn copy_to<W: io::Write>(
1266        self,
1267        writer: &mut W,
1268        max_bytes: u64,
1269        mut progress: impl FnMut(u64),
1270    ) -> Result<u64, HttpError> {
1271        let mut reader = self.reader;
1272        let mut buf = vec![0u8; 64 * 1024];
1273        let mut total: u64 = 0;
1274        loop {
1275            let n = match reader.read(&mut buf) {
1276                Ok(0) => break,
1277                Ok(n) => n,
1278                Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
1279                Err(e) if is_cancelled_io(&e) => return Err(HttpError::Cancelled),
1280                Err(e) if e.kind() == io::ErrorKind::TimedOut => {
1281                    return Err(HttpError::timeout(e.to_string(), Phase::Body))
1282                }
1283                Err(e) => return Err(HttpError::transport(e.to_string(), Phase::Body)),
1284            };
1285            total = total.saturating_add(n as u64);
1286            if total > max_bytes {
1287                return Err(HttpError::BodyTooLarge {
1288                    limit: max_bytes as usize,
1289                });
1290            }
1291            writer
1292                .write_all(&buf[..n])
1293                .map_err(|e| HttpError::Stream(format!("write failed: {e}")))?;
1294            progress(total);
1295        }
1296        Ok(total)
1297    }
1298    pub fn into_reader(self) -> Box<dyn Read + Send> {
1299        self.reader
1300    }
1301    /// Decode the body as Server-Sent Events.
1302    pub fn sse(self) -> SseReader<Box<dyn Read + Send>> {
1303        SseReader::new(self.reader)
1304    }
1305}
1306
1307pub(crate) fn header_of<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
1308    headers
1309        .iter()
1310        .find(|(k, _)| k.eq_ignore_ascii_case(name))
1311        .map(|(_, v)| v.as_str())
1312}
1313
1314/// Every value of `name`, in order. The name is copied so the iterator borrows only `headers`.
1315pub(crate) fn header_all_of<'a>(
1316    headers: &'a [(String, String)],
1317    name: &str,
1318) -> impl Iterator<Item = &'a str> + 'a {
1319    let name = name.to_string();
1320    headers
1321        .iter()
1322        .filter(move |(k, _)| k.eq_ignore_ascii_case(&name))
1323        .map(|(_, v)| v.as_str())
1324}
1325
1326#[derive(Clone)]
1327pub struct Client {
1328    agent: ureq::Agent,
1329    config: ClientConfig,
1330    /// The proxy in use, for the `NO_PROXY` decision and destination vetting.
1331    proxy: Option<ureq::Proxy>,
1332    /// `ProxyMode::System` decided per request URL; `proxy` is `None` then.
1333    routes: Option<Arc<SystemRoutes>>,
1334    proxy_diagnostic: Option<ProxyDiagnostic>,
1335    /// Which proxy source was actually used.
1336    proxy_mode_used: ProxyModeUsed,
1337    /// Set when the proxy settings were invalid; every request then fails closed.
1338    config_error: Option<String>,
1339    /// `false` sends `Accept-Encoding: identity`; see [`Client::compression`].
1340    compression: bool,
1341    /// The invalid setting is a system proxy configuration this client cannot follow:
1342    /// requests fail with the typed [`HttpError::SystemProxyUnsupported`] naming this
1343    /// element, instead of a generic message.
1344    system_proxy_unsupported: Option<String>,
1345}
1346
1347pub(crate) struct Raw {
1348    pub(crate) status: u16,
1349    pub(crate) headers: Vec<(String, String)>,
1350    pub(crate) body: ureq::Body,
1351    pub(crate) attempts: u32,
1352    /// URL of this response after followed redirects.
1353    pub(crate) final_url: String,
1354    /// Redirects followed.
1355    pub(crate) hops: u32,
1356}
1357
1358impl Client {
1359    /// Build a client. Invalid proxy settings do not panic: every request then returns
1360    /// [`HttpError::InvalidRequest`]. Use [`Client::try_new`] to see the error up front.
1361    pub fn new(config: ClientConfig) -> Self {
1362        match Self::try_new(config.clone()) {
1363            Ok(client) => client,
1364            Err(e) => {
1365                let mut direct = config;
1366                direct.proxy = ProxyMode::None;
1367                let mut client = Self::build(direct, Routing::Fixed(None), ProxyModeUsed::Direct);
1368                if let HttpError::SystemProxyUnsupported { element } = &e {
1369                    client.system_proxy_unsupported = Some(element.clone());
1370                }
1371                client.config_error = Some(e.to_string());
1372                client
1373            }
1374        }
1375    }
1376
1377    /// Build a client, failing when the proxy settings are invalid.
1378    pub fn try_new(config: ClientConfig) -> Result<Self, HttpError> {
1379        let (routing, used) = config.resolved_routing_with_mode()?;
1380        Ok(Self::build(config, routing, used))
1381    }
1382
1383    fn build(config: ClientConfig, routing: Routing, used: ProxyModeUsed) -> Self {
1384        let (proxy, routes) = match routing {
1385            Routing::Fixed(proxy) => (proxy, None),
1386            Routing::PerRequest(routes) => (None, Some(routes)),
1387        };
1388        // Redirects are followed by this crate, hop by hop, so the address policy and
1389        // header stripping run on every hop. ureq must never follow one itself.
1390        let builder = ureq::Agent::config_builder()
1391            .http_status_as_error(false)
1392            .max_redirects(0)
1393            .user_agent(config.user_agent.clone())
1394            .timeout_connect(config.connect_timeout)
1395            .timeout_resolve(config.connect_timeout)
1396            .tls_config(blocking_tls_config(config.trust));
1397        let diagnostic = proxy.as_ref().map(ProxyDiagnostic::of);
1398        let (builder, endpoint, proxy) = match proxy {
1399            Some(p) => (
1400                builder.proxy(Some(p.ureq.clone())),
1401                Some((p.host, p.port)),
1402                Some(p.ureq),
1403            ),
1404            None => (builder.proxy(None), None, None),
1405        };
1406        // Every proxy a request can be routed through is configuration, not input.
1407        let mut proxies: Vec<(String, u16)> = endpoint.into_iter().collect();
1408        if let Some(routes) = &routes {
1409            proxies.extend(routes.endpoints());
1410        }
1411        // The vetting resolver returns only checked addresses, and ureq's TCP connector
1412        // connects to exactly those (`ConnectionDetails::addrs`). Destinations that go
1413        // through a proxy are vetted by name and literal in `once_hop` instead.
1414        let agent = ureq::Agent::with_parts(
1415            builder.build(),
1416            ureq::unversioned::transport::Connector::chain(
1417                ureq::unversioned::transport::Connector::chain(
1418                    ConnectStart,
1419                    ureq::unversioned::transport::DefaultConnector::default(),
1420                ),
1421                ConnectDone,
1422            ),
1423            VettingResolver {
1424                policy: Arc::new(config.address_policy.clone()),
1425                proxies,
1426                lookup_limit: config.connect_timeout.unwrap_or(Duration::from_secs(10)),
1427            },
1428        );
1429        Self {
1430            agent,
1431            config,
1432            proxy,
1433            routes,
1434            proxy_diagnostic: diagnostic,
1435            proxy_mode_used: used,
1436            config_error: None,
1437            compression: true,
1438            system_proxy_unsupported: None,
1439        }
1440    }
1441
1442    /// Ask for compressed responses (default true). `false` sends `Accept-Encoding: identity`
1443    /// on every request that sets no `Accept-Encoding` of its own, so an SSE or provider stream
1444    /// is not buffered or re-encoded by an intermediary. Kept on the client, not on
1445    /// [`ClientConfig`], so existing `ClientConfig` struct literals keep compiling.
1446    pub fn compression(mut self, on: bool) -> Self {
1447        self.compression = on;
1448        self
1449    }
1450
1451    /// Which certificate verifier this client was built with: `"platform"` (the OS verifier)
1452    /// or `"bundled"` (Mozilla roots). It follows [`ClientConfig::trust`].
1453    pub fn verifier_kind(&self) -> &'static str {
1454        self.config.trust.verifier_kind()
1455    }
1456
1457    /// The proxy this client uses, if any, without credentials. See [`ProxyDiagnostic`].
1458    /// `None` also when `ProxyMode::System` is decided per request URL, because there is no
1459    /// single proxy to report.
1460    pub fn proxy_diagnostic(&self) -> Option<&ProxyDiagnostic> {
1461        self.proxy_diagnostic.as_ref()
1462    }
1463
1464    /// Which proxy source this client actually uses. For [`ProxyMode::System`] it says
1465    /// whether the operating system's proxy was followed or a fallback was taken, and why.
1466    pub fn proxy_mode_used(&self) -> &ProxyModeUsed {
1467        &self.proxy_mode_used
1468    }
1469
1470    /// Client that may reach only `hosts` (exact match), only over HTTPS; see
1471    /// [`ClientConfig::first_party`].
1472    pub fn first_party(hosts: Vec<String>) -> Self {
1473        Self::new(ClientConfig::first_party(hosts))
1474    }
1475
1476    pub fn config(&self) -> &ClientConfig {
1477        &self.config
1478    }
1479
1480    /// Buffered request; non-2xx becomes [`HttpError::Status`].
1481    pub fn send(&self, req: &Request) -> Result<Response, HttpError> {
1482        self.send_checked(req, None)
1483    }
1484
1485    /// [`Client::send`] cancelled through a borrowed flag, for callers that own an
1486    /// `AtomicBool` and cannot hand out an `Arc` ([`CancelToken::from_flag`]). The flag is
1487    /// checked at the same points as a token: between attempts, during retry sleeps and
1488    /// before every body read (it still cannot interrupt a socket call already waiting for
1489    /// headers; `response_timeout` bounds that). It replaces any [`Request::cancel`] token
1490    /// on this call. Buffered calls only: a stream outlives the borrow, so `stream` and
1491    /// `stream_any` keep taking a [`CancelToken`].
1492    pub fn send_with_flag(&self, req: &Request, flag: &AtomicBool) -> Result<Response, HttpError> {
1493        self.send_checked(req, Some(flag))
1494    }
1495
1496    /// [`Client::send_any`] with a borrowed cancel flag; see [`Client::send_with_flag`].
1497    pub fn send_any_with_flag(
1498        &self,
1499        req: &Request,
1500        flag: &AtomicBool,
1501    ) -> Result<Response, HttpError> {
1502        self.send_any_checked(req, Some(flag))
1503    }
1504
1505    fn send_checked(
1506        &self,
1507        req: &Request,
1508        flag: Option<&AtomicBool>,
1509    ) -> Result<Response, HttpError> {
1510        let resp = self.send_any_checked(req, flag)?;
1511        if (200..300).contains(&resp.status) {
1512            Ok(resp)
1513        } else {
1514            let values = self.redaction_values(req);
1515            Err(scrub_error(
1516                status_error(resp.status, &resp.headers, resp.body, resp.attempts),
1517                &values,
1518            ))
1519        }
1520    }
1521
1522    /// Buffered request returning any final status (retryable statuses are still retried).
1523    pub fn send_any(&self, req: &Request) -> Result<Response, HttpError> {
1524        self.send_any_checked(req, None)
1525    }
1526
1527    fn send_any_checked(
1528        &self,
1529        req: &Request,
1530        flag: Option<&AtomicBool>,
1531    ) -> Result<Response, HttpError> {
1532        let timeout = req.timeout.unwrap_or(self.config.request_timeout);
1533        let cancel = flag.or_else(|| request_cancel(req));
1534        let mut raw = self.execute(req, Some(timeout), cancel)?;
1535        let values = self.redaction_values(req);
1536        if req.headers_only {
1537            // Dropping the body unread closes the connection.
1538            return Ok(Response {
1539                status: raw.status,
1540                headers: raw.headers,
1541                body: Vec::new(),
1542                attempts: raw.attempts,
1543                final_url: raw.final_url,
1544                redact: values,
1545            });
1546        }
1547        let limit = req
1548            .max_response_bytes
1549            .unwrap_or(self.config.max_response_bytes);
1550        let mut body_reader = raw.body.as_reader();
1551        let mut reader = CheckedReader {
1552            inner: &mut body_reader,
1553            cancel,
1554        };
1555        let body = read_limited(&mut reader, limit).map_err(|e| scrub_error(e, &values))?;
1556        Ok(Response {
1557            status: raw.status,
1558            headers: raw.headers,
1559            body,
1560            attempts: raw.attempts,
1561            final_url: raw.final_url,
1562            redact: values,
1563        })
1564    }
1565
1566    /// Status and headers only, for any status, without reading the body. Redirects, retries,
1567    /// the address policy and cancellation behave as for [`Client::send_any`]; the body is
1568    /// dropped unread, which closes that connection. `final_url` and `hops` report where
1569    /// redirects led.
1570    pub fn send_headers(&self, req: &Request) -> Result<ResponseHead, HttpError> {
1571        let timeout = req.timeout.unwrap_or(self.config.request_timeout);
1572        let raw = self.execute(req, Some(timeout), request_cancel(req))?;
1573        let Raw {
1574            status,
1575            headers,
1576            body,
1577            attempts,
1578            final_url,
1579            hops,
1580            ..
1581        } = raw;
1582        drop(body);
1583        Ok(ResponseHead {
1584            status,
1585            headers,
1586            final_url,
1587            hops,
1588            attempts,
1589            redact: self.redaction_values(req),
1590        })
1591    }
1592
1593    /// Streaming request returning any final status with its headers and streaming body
1594    /// (retryable statuses are still retried per policy). The caller inspects `status` and
1595    /// `headers` (for example `401` plus `www-authenticate`) and bounds the body. Timeout and
1596    /// cancellation behave as for [`Client::stream`].
1597    pub fn stream_any(&self, req: &Request) -> Result<StreamResponse, HttpError> {
1598        let total = req.timeout.or(self.config.stream_timeout);
1599        let raw = self.execute(req, total, request_cancel(req))?;
1600        let values = self.redaction_values(req);
1601        Ok(self.wrap_stream(raw, req, values))
1602    }
1603
1604    fn wrap_stream(&self, raw: Raw, req: &Request, values: Vec<String>) -> StreamResponse {
1605        let body: Box<dyn Read + Send> = match self.config.idle_timeout {
1606            Some(idle) => Box::new(crate::idle::IdleReader::new(
1607                Box::new(raw.body.into_reader()),
1608                idle,
1609                req.cancel.clone(),
1610            )),
1611            None => Box::new(raw.body.into_reader()),
1612        };
1613        let guarded = GuardReader {
1614            inner: body,
1615            cancel: req.cancel.clone(),
1616            values,
1617        };
1618        StreamResponse {
1619            status: raw.status,
1620            headers: raw.headers,
1621            attempts: raw.attempts,
1622            reader: Box::new(guarded),
1623        }
1624    }
1625
1626    /// Streaming request; non-2xx becomes [`HttpError::Status`] (use [`Client::stream_any`]
1627    /// to receive such a response with its headers). Retries happen
1628    /// only before the response headers arrive (never mid-stream). Uses
1629    /// `Request::timeout` as the total stream budget when set, otherwise
1630    /// `ClientConfig::stream_timeout`.
1631    pub fn stream(&self, req: &Request) -> Result<StreamResponse, HttpError> {
1632        let cancel = request_cancel(req);
1633        let total = req.timeout.or(self.config.stream_timeout);
1634        let mut raw = self.execute(req, total, cancel)?;
1635        let values = self.redaction_values(req);
1636        if !(200..300).contains(&raw.status) {
1637            let mut body_reader = raw.body.as_reader();
1638            let mut reader = CheckedReader {
1639                inner: &mut body_reader,
1640                cancel,
1641            };
1642            let body = read_limited(&mut reader, MAX_ERROR_BODY_BYTES).unwrap_or_default();
1643            if is_set(cancel) {
1644                return Err(HttpError::Cancelled);
1645            }
1646            return Err(scrub_error(
1647                status_error(raw.status, &raw.headers, body, raw.attempts),
1648                &values,
1649            ));
1650        }
1651        Ok(self.wrap_stream(raw, req, values))
1652    }
1653
1654    pub fn get_json<T: DeserializeOwned>(&self, url: &str) -> Result<T, HttpError> {
1655        let req = Request::get(url).header("Accept", "application/json");
1656        let values = self.redaction_values(&req);
1657        self.send(&req)?.json().map_err(|e| scrub_error(e, &values))
1658    }
1659
1660    /// Configured secrets plus this request's secret values, longest first.
1661    pub(crate) fn redaction_values(&self, req: &Request) -> Vec<String> {
1662        redaction_values_for(&self.config, req)
1663    }
1664
1665    /// Run the retry loop. `total` bounds each attempt; `cancel` is checked between
1666    /// attempts and during retry sleeps. Errors are scrubbed of secret values.
1667    fn execute(
1668        &self,
1669        req: &Request,
1670        total: Option<Duration>,
1671        cancel: Option<&AtomicBool>,
1672    ) -> Result<Raw, HttpError> {
1673        let values = self.redaction_values(req);
1674        self.execute_inner(req, total, cancel, &values)
1675            .map_err(|e| scrub_error(e, &values))
1676    }
1677
1678    fn execute_inner(
1679        &self,
1680        req: &Request,
1681        total: Option<Duration>,
1682        cancel: Option<&AtomicBool>,
1683        values: &[String],
1684    ) -> Result<Raw, HttpError> {
1685        let policy = req
1686            .retry
1687            .clone()
1688            .unwrap_or_else(|| self.config.retry.clone());
1689        let max = policy.max_attempts.max(1);
1690        let deadline = req.deadline.map(|d| Instant::now() + d);
1691        let mut attempt = 0u32;
1692        loop {
1693            if is_set(cancel) {
1694                return Err(HttpError::Cancelled);
1695            }
1696            let attempt_total = match deadline {
1697                None => total,
1698                Some(dl) => {
1699                    let remaining = dl.saturating_duration_since(Instant::now());
1700                    if remaining.is_zero() {
1701                        return Err(HttpError::timeout(
1702                            format!("request deadline exceeded after {attempt} attempt(s)"),
1703                            Phase::Headers,
1704                        ));
1705                    }
1706                    Some(total.map_or(remaining, |t| t.min(remaining)))
1707                }
1708            };
1709            attempt += 1;
1710            if let Some(hook) = &self.config.on_attempt {
1711                hook(&AttemptEvent {
1712                    url: redact_str(&redact_url(&req.url), values),
1713                    method: req.method,
1714                    attempt,
1715                    max_attempts: max,
1716                })
1717                .map_err(|reason| {
1718                    HttpError::InvalidRequest(format!("attempt {attempt} refused: {reason}"))
1719                })?;
1720            }
1721            let (reason, delay) = match self.once(req, attempt_total, values) {
1722                Ok(raw) => {
1723                    let retryable = policy.is_retryable_status(raw.status)
1724                        && (req.method.is_idempotent() || req.retry_unsafe_statuses);
1725                    let retry_after =
1726                        header_of(&raw.headers, "retry-after").and_then(parse_retry_after);
1727                    if !retryable || attempt >= max || policy.defers(retry_after) {
1728                        return Ok(Raw {
1729                            attempts: attempt,
1730                            ..raw
1731                        });
1732                    }
1733                    let delay = policy.delay(attempt, retry_after);
1734                    if deadline_passes(deadline, delay) {
1735                        return Ok(Raw {
1736                            attempts: attempt,
1737                            ..raw
1738                        });
1739                    }
1740                    // Drain so the connection is released.
1741                    let mut body = raw.body;
1742                    let mut reader = body.as_reader();
1743                    let mut checked = CheckedReader {
1744                        inner: &mut reader,
1745                        cancel,
1746                    };
1747                    let _ = read_limited(&mut checked, MAX_ERROR_BODY_BYTES);
1748                    if is_set(cancel) {
1749                        return Err(HttpError::Cancelled);
1750                    }
1751                    (format!("HTTP {}", raw.status), delay)
1752                }
1753                Err(err) => {
1754                    let retryable = match &err {
1755                        HttpError::Transport { connect_phase, .. } => {
1756                            *connect_phase || req.method.is_idempotent()
1757                        }
1758                        HttpError::Timeout { .. } => req.method.is_idempotent(),
1759                        _ => false,
1760                    };
1761                    if !retryable || attempt >= max {
1762                        return Err(err);
1763                    }
1764                    let delay = policy.delay(attempt, None);
1765                    if deadline_passes(deadline, delay) {
1766                        return Err(err);
1767                    }
1768                    (err.to_string(), delay)
1769                }
1770            };
1771            if let Some(obs) = &self.config.on_retry {
1772                obs(&RetryEvent {
1773                    url: redact_str(&redact_url(&req.url), values),
1774                    retry: attempt,
1775                    reason: redact_str(&redact_urls_in_text(&reason), values),
1776                    delay,
1777                });
1778            }
1779            // Never sleep past the whole-request deadline: the drain above can have used it up.
1780            // The next loop pass then reports the elapsed deadline instead of a late retry.
1781            let delay = match deadline {
1782                Some(dl) => delay.min(dl.saturating_duration_since(Instant::now())),
1783                None => delay,
1784            };
1785            if !sleep_cancellable(delay, cancel) {
1786                return Err(HttpError::Cancelled);
1787            }
1788        }
1789    }
1790
1791    /// One attempt: the request and, up to `max_redirects`, its redirect hops. Every hop
1792    /// goes through the same address policy.
1793    fn once(
1794        &self,
1795        req: &Request,
1796        total: Option<Duration>,
1797        secrets: &[String],
1798    ) -> Result<Raw, HttpError> {
1799        let started = Instant::now();
1800        let mut method = req.method;
1801        let mut url = req.url.clone();
1802        let mut headers = req.headers.clone();
1803        if !self.compression
1804            && !headers
1805                .iter()
1806                .any(|(k, _)| k.eq_ignore_ascii_case("accept-encoding"))
1807        {
1808            headers.push(("Accept-Encoding".to_string(), "identity".to_string()));
1809        }
1810        let mut body = req.body.clone();
1811        let mut hops = 0u32;
1812        loop {
1813            let remaining = match total {
1814                None => None,
1815                Some(t) => {
1816                    let left = t.saturating_sub(started.elapsed());
1817                    if left.is_zero() {
1818                        let phase = if hops > 0 {
1819                            Phase::Redirect
1820                        } else {
1821                            Phase::Headers
1822                        };
1823                        return Err(HttpError::timeout("request", phase));
1824                    }
1825                    Some(left)
1826                }
1827            };
1828            let raw = match self.once_hop(method, &url, &headers, &body, remaining, hops > 0) {
1829                Ok(raw) => raw,
1830                Err(e) if hops > 0 => return Err(e.into_redirect_phase()),
1831                Err(e) => return Err(e),
1832            };
1833            if self.config.max_redirects == 0 || !redirect::is_redirect_status(raw.status) {
1834                return Ok(Raw {
1835                    final_url: url,
1836                    hops,
1837                    ..raw
1838                });
1839            }
1840            let Some(location) = header_of(&raw.headers, "location") else {
1841                return Ok(Raw {
1842                    final_url: url,
1843                    hops,
1844                    ..raw
1845                });
1846            };
1847            let Some(next_method) = redirect::redirect_method(raw.status, method) else {
1848                return Ok(Raw {
1849                    final_url: url,
1850                    hops,
1851                    ..raw
1852                });
1853            };
1854            if hops >= self.config.max_redirects {
1855                if self.config.max_redirects_will_error {
1856                    return Err(HttpError::transport("too many redirects", Phase::Redirect));
1857                }
1858                return Ok(Raw {
1859                    final_url: url,
1860                    hops,
1861                    ..raw
1862                });
1863            }
1864            hops += 1;
1865            let next_url = redirect::resolve_location(&url, location).ok_or_else(|| {
1866                HttpError::InvalidRequest("redirect with an unusable Location".into())
1867            })?;
1868            if let (Some(from), Some(to), Some(host)) = (
1869                redirect::scheme_of(&url),
1870                redirect::scheme_of(&next_url),
1871                redirect::host_of(&next_url),
1872            ) {
1873                // `https_only` first: its error names the rule the caller set, where the
1874                // address policy's downgrade refusal (always on) would hide it.
1875                redirect::require_https(self.config.https_only, &to, &host, true)?;
1876                self.config
1877                    .address_policy
1878                    .check_redirect_scheme(&from, &to, &host)
1879                    .map_err(|d| HttpError::from(d).into_redirect_phase())?;
1880            }
1881            if redirect::origin(&url) != redirect::origin(&next_url) {
1882                redirect::strip_cross_origin(&mut headers, secrets);
1883            }
1884            if next_method != method {
1885                body.clear();
1886                redirect::strip_body_headers(&mut headers);
1887            }
1888            method = next_method;
1889            url = next_url;
1890            // `raw` (and its connection) is dropped here before the next hop.
1891        }
1892    }
1893
1894    fn once_hop(
1895        &self,
1896        method: Method,
1897        url: &str,
1898        headers: &[(String, String)],
1899        body: &[u8],
1900        total: Option<Duration>,
1901        redirect_hop: bool,
1902    ) -> Result<Raw, HttpError> {
1903        let mut builder = ureq::http::Request::builder()
1904            .method(method.as_str())
1905            .uri(url);
1906        for (k, v) in headers {
1907            builder = builder.header(k.as_str(), v.as_str());
1908        }
1909        // ureq frames a `Vec` body, even an empty one, with `Content-Length: 0`, which GET,
1910        // HEAD and DELETE must not carry. `()` has no body and no body headers. POST, PUT and
1911        // PATCH always send their body, so an empty one keeps `Content-Length: 0`.
1912        let bodiless =
1913            body.is_empty() && !matches!(method, Method::Post | Method::Put | Method::Patch);
1914        if bodiless {
1915            let request = builder
1916                .body(())
1917                .map_err(|_| HttpError::InvalidRequest("request could not be built".into()))?;
1918            self.run_hop(request, total, redirect_hop)
1919        } else {
1920            let request = builder
1921                .body(body.to_vec())
1922                .map_err(|_| HttpError::InvalidRequest("request could not be built".into()))?;
1923            self.run_hop(request, total, redirect_hop)
1924        }
1925    }
1926
1927    /// One hop with a built request: the policy checks, the proxy scope and the ureq call.
1928    fn run_hop<S: ureq::AsSendBody>(
1929        &self,
1930        request: ureq::http::Request<S>,
1931        total: Option<Duration>,
1932        redirect_hop: bool,
1933    ) -> Result<Raw, HttpError> {
1934        if let Some(element) = &self.system_proxy_unsupported {
1935            return Err(HttpError::SystemProxyUnsupported {
1936                element: element.clone(),
1937            });
1938        }
1939        if let Some(reason) = &self.config_error {
1940            return Err(HttpError::InvalidRequest(reason.clone()));
1941        }
1942        // Every hop, not only the first, so a path that skips the redirect loop still
1943        // cannot reach plain http.
1944        redirect::require_https(
1945            self.config.https_only,
1946            request.uri().scheme_str().unwrap_or(""),
1947            request.uri().host().unwrap_or(""),
1948            redirect_hop,
1949        )?;
1950        let (via_proxy, hop_proxy) = self.vet_proxied_destination(request.uri())?;
1951        let _scope = ViaProxy::enter(via_proxy);
1952        let mut configured = self.agent.configure_request(request);
1953        if let Some(proxy) = hop_proxy {
1954            // The per-request system route: this hop's own proxy, not the agent's.
1955            configured = configured.proxy(Some(proxy));
1956        }
1957        let configured = configured
1958            .timeout_global(total)
1959            .timeout_recv_response(self.config.response_timeout)
1960            .timeout_recv_body(self.config.recv_body_timeout)
1961            .build();
1962        CONNECT_STATE.with(|c| c.set(ConnectState::Idle));
1963        let response = self.agent.run(configured).map_err(|err| {
1964            // A fresh connection was being made and never finished: nothing was sent, so a
1965            // reset (or any other failure that names no phase) belongs to the connect phase.
1966            // A reused pooled connection never starts a connect, so its failures keep the
1967            // Headers default (the request may already have reached the server).
1968            let default = match CONNECT_STATE.with(|c| c.get()) {
1969                ConnectState::Started => Phase::Connect,
1970                _ => Phase::Headers,
1971            };
1972            map_ureq_error_in(err, default)
1973        })?;
1974        let status = response.status().as_u16();
1975        let headers = response
1976            .headers()
1977            .iter()
1978            .map(|(k, v)| {
1979                (
1980                    k.as_str().to_ascii_lowercase(),
1981                    String::from_utf8_lossy(v.as_bytes()).into_owned(),
1982                )
1983            })
1984            .collect();
1985        Ok(Raw {
1986            status,
1987            headers,
1988            body: response.into_body(),
1989            attempts: 1,
1990            final_url: String::new(),
1991            hops: 0,
1992        })
1993    }
1994}
1995
1996impl Client {
1997    /// Through a proxy the destination's address cannot be checked, so vet its name and
1998    /// literal form first. Hosts that bypass the proxy (`NO_PROXY`) are vetted by the
1999    /// resolver like direct connections.
2000    /// Returns whether the request goes through a proxy and, for a per-request system route,
2001    /// that proxy (the agent itself carries none then).
2002    fn vet_proxied_destination(
2003        &self,
2004        uri: &ureq::http::Uri,
2005    ) -> Result<(bool, Option<ureq::Proxy>), HttpError> {
2006        let policy = &self.config.address_policy;
2007        if let Some(routes) = &self.routes {
2008            let (scheme, host) = uri_scheme_and_host(uri);
2009            let Some(proxy) = routes.proxy_for(&scheme, &host) else {
2010                // Bypassed or no endpoint for this scheme: a direct connection, vetted by
2011                // the resolver like any other.
2012                return Ok((false, None));
2013            };
2014            let raw_host = uri.host().unwrap_or("");
2015            policy.admit(&scheme, raw_host)?;
2016            policy.vet_via_proxy(raw_host)?;
2017            return Ok((true, Some(proxy.ureq.clone())));
2018        }
2019        let Some(proxy) = &self.proxy else {
2020            return Ok((false, None));
2021        };
2022        if proxy.is_no_proxy(uri) {
2023            return Ok((false, None));
2024        }
2025        let scheme = uri.scheme_str().unwrap_or("").to_ascii_lowercase();
2026        let raw_host = uri.host().unwrap_or("");
2027        policy.admit(&scheme, raw_host)?;
2028        policy.vet_via_proxy(raw_host)?;
2029        Ok((true, None))
2030    }
2031}
2032
2033fn sorted_values(values: &[String]) -> Vec<String> {
2034    let mut v: Vec<String> = values.iter().filter(|v| !v.is_empty()).cloned().collect();
2035    v.sort_by_key(|s| std::cmp::Reverse(s.len()));
2036    v
2037}
2038
2039/// Header list safe to print: credential-named headers (including cookies) are replaced,
2040/// URL-bearing headers lose their query and userinfo, and any `scheme://` text in other
2041/// values is redacted the same way.
2042pub(crate) fn redact_headers_for_debug(headers: &[(String, String)]) -> Vec<(String, String)> {
2043    headers
2044        .iter()
2045        .map(|(k, v)| {
2046            let shown = if redirect::is_secret_header_name(k) {
2047                REDACTED.to_string()
2048            } else if matches!(
2049                k.to_ascii_lowercase().as_str(),
2050                "location" | "content-location" | "refresh" | "link" | "referer"
2051            ) {
2052                redact_urls_in_text(&redact_url(v))
2053            } else {
2054                redact_urls_in_text(v)
2055            };
2056            (k.clone(), shown)
2057        })
2058        .collect()
2059}
2060
2061/// [`redact_headers_for_debug`] plus every recorded secret value scrubbed from what is left.
2062pub(crate) fn redact_headers_with(
2063    headers: &[(String, String)],
2064    values: &[String],
2065) -> Vec<(String, String)> {
2066    redact_headers_for_debug(headers)
2067        .into_iter()
2068        .map(|(k, v)| (k, redact_str(&v, values)))
2069        .collect()
2070}
2071
2072/// Configured secrets, the request's secret values, and the query and userinfo of its URL,
2073/// longest first. Every error text and retry event is scrubbed of these.
2074pub(crate) fn redaction_values_for(config: &ClientConfig, req: &Request) -> Vec<String> {
2075    let mut values: Vec<String> = config
2076        .redact_values
2077        .iter()
2078        .chain(req.secret_values.iter())
2079        .filter(|v| !v.is_empty())
2080        .cloned()
2081        .chain(url_secret_parts(&req.url))
2082        .collect();
2083    values.sort_by_key(|v| std::cmp::Reverse(v.len()));
2084    values.dedup();
2085    values
2086}
2087
2088fn request_cancel(req: &Request) -> Option<&AtomicBool> {
2089    req.cancel.as_ref().map(CancelToken::as_atomic)
2090}
2091
2092fn deadline_passes(deadline: Option<Instant>, delay: Duration) -> bool {
2093    deadline.is_some_and(|dl| Instant::now() + delay >= dl)
2094}
2095
2096/// Reader for stream bodies: honours cancellation per read and scrubs secret
2097/// values from I/O error text.
2098struct GuardReader {
2099    inner: Box<dyn Read + Send>,
2100    cancel: Option<CancelToken>,
2101    values: Vec<String>,
2102}
2103
2104impl Read for GuardReader {
2105    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
2106        if self.cancel.as_ref().is_some_and(|c| c.is_cancelled()) {
2107            return Err(crate::cancel::cancelled_io());
2108        }
2109        self.inner.read(buf).map_err(|e| {
2110            if is_cancelled_io(&e) {
2111                return e;
2112            }
2113            let text = e.to_string();
2114            let scrubbed = redact_str(&redact_urls_in_text(&text), &self.values);
2115            if scrubbed == text {
2116                e
2117            } else {
2118                io::Error::new(e.kind(), scrubbed)
2119            }
2120        })
2121    }
2122}
2123
2124#[derive(Clone, Copy, PartialEq, Eq)]
2125enum ConnectState {
2126    /// No connect has started in this hop (or a pooled connection is being reused).
2127    Idle,
2128    /// A new connection (TCP, proxy tunnel, TLS) is being made and is not finished.
2129    Started,
2130    /// The connection is ready; anything after this may have reached the server.
2131    Done,
2132}
2133
2134thread_local! {
2135    /// Connect progress of the blocking call running on this thread (ureq runs on the caller's
2136    /// thread), so run_hop can tell a failure before the connection existed from one after.
2137    static CONNECT_STATE: std::cell::Cell<ConnectState> = const { std::cell::Cell::new(ConnectState::Idle) };
2138}
2139
2140/// First link of the connector chain: marks that a new connection is being made.
2141#[derive(Debug)]
2142struct ConnectStart;
2143
2144impl ureq::unversioned::transport::Connector<()> for ConnectStart {
2145    type Out = ();
2146    fn connect(
2147        &self,
2148        _details: &ureq::unversioned::transport::ConnectionDetails,
2149        chained: Option<()>,
2150    ) -> Result<Option<()>, ureq::Error> {
2151        CONNECT_STATE.with(|c| c.set(ConnectState::Started));
2152        Ok(chained)
2153    }
2154}
2155
2156/// Last link: the connection (including any proxy tunnel and TLS) is ready.
2157#[derive(Debug)]
2158struct ConnectDone;
2159
2160impl ureq::unversioned::transport::Connector<Box<dyn ureq::unversioned::transport::Transport>>
2161    for ConnectDone
2162{
2163    type Out = Box<dyn ureq::unversioned::transport::Transport>;
2164    fn connect(
2165        &self,
2166        _details: &ureq::unversioned::transport::ConnectionDetails,
2167        chained: Option<Self::Out>,
2168    ) -> Result<Option<Self::Out>, ureq::Error> {
2169        CONNECT_STATE.with(|c| c.set(ConnectState::Done));
2170        Ok(chained)
2171    }
2172}
2173
2174pub(crate) fn status_error(
2175    status: u16,
2176    headers: &[(String, String)],
2177    body: Vec<u8>,
2178    attempts: u32,
2179) -> HttpError {
2180    let mut body = body;
2181    body.truncate(MAX_ERROR_BODY_BYTES);
2182    HttpError::Status {
2183        status,
2184        body: String::from_utf8_lossy(&body).into_owned(),
2185        retry_after: header_of(headers, "retry-after").and_then(parse_retry_after),
2186        attempts,
2187    }
2188}
2189
2190/// Replace every occurrence of each secret value with `[redacted]`.
2191pub(crate) fn redact_str(text: &str, values: &[String]) -> String {
2192    let mut out = text.to_string();
2193    for value in values {
2194        if !value.is_empty() && out.contains(value.as_str()) {
2195            out = out.replace(value.as_str(), REDACTED);
2196        }
2197    }
2198    out
2199}
2200
2201/// Scrub secret values from the text of any error variant.
2202pub(crate) fn scrub_error(err: HttpError, values: &[String]) -> HttpError {
2203    // Free-text messages lose secret values and any URL query or userinfo. A `Status`
2204    // body is the server's response and only loses secret values.
2205    let r = |s: String| redact_str(&redact_urls_in_text(&s), values);
2206    match err {
2207        HttpError::InvalidRequest(m) => HttpError::InvalidRequest(r(m)),
2208        HttpError::Transport {
2209            message,
2210            connect_phase,
2211            phase,
2212        } => HttpError::Transport {
2213            message: r(message),
2214            connect_phase,
2215            phase,
2216        },
2217        HttpError::Timeout { message, phase } => HttpError::Timeout {
2218            message: r(message),
2219            phase,
2220        },
2221        HttpError::Status {
2222            status,
2223            body,
2224            retry_after,
2225            attempts,
2226        } => HttpError::Status {
2227            status,
2228            body: redact_str(&body, values),
2229            retry_after,
2230            attempts,
2231        },
2232        HttpError::BodyTooLarge { limit } => HttpError::BodyTooLarge { limit },
2233        HttpError::Json(m) => HttpError::Json(r(m)),
2234        HttpError::Stream(m) => HttpError::Stream(r(m)),
2235        HttpError::Cancelled => HttpError::Cancelled,
2236        HttpError::SystemProxyUnsupported { element } => HttpError::SystemProxyUnsupported {
2237            element: element.clone(),
2238        },
2239        HttpError::HttpsRequired {
2240            host,
2241            scheme,
2242            redirect,
2243        } => HttpError::HttpsRequired {
2244            host: r(host),
2245            scheme,
2246            redirect,
2247        },
2248        HttpError::AddressDenied {
2249            host,
2250            addr,
2251            class,
2252            reason,
2253        } => HttpError::AddressDenied {
2254            host: r(host),
2255            addr,
2256            class,
2257            reason: r(reason),
2258        },
2259    }
2260}
2261
2262pub(crate) fn read_limited(reader: &mut dyn Read, limit: usize) -> Result<Vec<u8>, HttpError> {
2263    let mut out = Vec::new();
2264    reader
2265        .take(limit as u64 + 1)
2266        .read_to_end(&mut out)
2267        .map_err(|e| {
2268            if is_cancelled_io(&e) {
2269                return HttpError::Cancelled;
2270            }
2271            match e.kind() {
2272                std::io::ErrorKind::TimedOut => HttpError::timeout(e.to_string(), Phase::Body),
2273                // Body readers wrap ureq errors (including phase timeouts) in
2274                // io::ErrorKind::Other. Unwrap them before applying our taxonomy.
2275                _ => map_ureq_error_in(e.into(), Phase::Body),
2276            }
2277        })?;
2278    if out.len() > limit {
2279        return Err(HttpError::BodyTooLarge { limit });
2280    }
2281    Ok(out)
2282}
2283
2284/// Phase of a ureq timeout, falling back to `default` for the global and per-call budgets.
2285fn timeout_phase(t: &ureq::Timeout, default: Phase) -> Phase {
2286    use ureq::Timeout as T;
2287    match t {
2288        T::Resolve | T::Connect => Phase::Connect,
2289        T::SendRequest | T::SendBody | T::RecvResponse => Phase::Headers,
2290        T::RecvBody => Phase::Body,
2291        _ => default,
2292    }
2293}
2294
2295/// Map a ureq error. `default` is the phase for failures that name none (socket errors,
2296/// global timeouts): `Headers` before the response arrives, `Body` while reading it.
2297pub(crate) fn map_ureq_error_in(err: ureq::Error, default: Phase) -> HttpError {
2298    use ureq::Error as E;
2299    // The vetting resolver refused the destination.
2300    if let E::Io(io) = &err {
2301        if let Some(inner) = io.get_ref() {
2302            if let Some(denial) = denial_in_chain(inner) {
2303                return denial.into();
2304            }
2305        }
2306    }
2307    match err {
2308        E::Timeout(t) => HttpError::timeout(format!("{t:?}"), timeout_phase(&t, default)),
2309        E::TooManyRedirects => HttpError::transport("too many redirects", Phase::Redirect),
2310        E::HostNotFound => HttpError::transport("host not found", Phase::Connect),
2311        E::ConnectionFailed => HttpError::transport("connection failed", Phase::Connect),
2312        E::Io(io)
2313            if matches!(
2314                io.kind(),
2315                std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound
2316            ) =>
2317        {
2318            HttpError::transport(io.to_string(), Phase::Connect)
2319        }
2320        E::Io(io) => HttpError::transport(io.to_string(), default),
2321        E::BadUri(m) => HttpError::InvalidRequest(m),
2322        E::Http(e) => HttpError::InvalidRequest(e.to_string()),
2323        other => HttpError::transport(other.to_string(), default),
2324    }
2325}
2326
2327#[cfg(test)]
2328mod tests {
2329    use super::*;
2330
2331    #[test]
2332    fn legacy_ureq_parity_sets_only_the_ureq_fields() {
2333        let cfg = ClientConfig::legacy_ureq_parity("heardright/1.0");
2334        let base = ClientConfig::default();
2335        assert_eq!(cfg.user_agent, "heardright/1.0");
2336        assert!(matches!(cfg.proxy, ProxyMode::Environment));
2337        assert_eq!(cfg.trust, TrustRoots::Bundled);
2338        assert_eq!(cfg.max_redirects, 10);
2339        assert_eq!(cfg.retry, RetryPolicy::none());
2340        // Private-address resolution stays off: the policy is the public-web default.
2341        assert_eq!(
2342            format!("{:?}", cfg.address_policy),
2343            format!("{:?}", base.address_policy)
2344        );
2345        // Every other field keeps its default.
2346        assert_eq!(cfg.connect_timeout, base.connect_timeout);
2347        assert_eq!(cfg.request_timeout, base.request_timeout);
2348        assert_eq!(cfg.response_timeout, base.response_timeout);
2349        assert_eq!(cfg.recv_body_timeout, base.recv_body_timeout);
2350        assert_eq!(cfg.stream_timeout, base.stream_timeout);
2351        assert_eq!(cfg.idle_timeout, base.idle_timeout);
2352        assert_eq!(cfg.max_redirects_will_error, base.max_redirects_will_error);
2353        assert_eq!(cfg.max_response_bytes, base.max_response_bytes);
2354        assert!(cfg.on_retry.is_none());
2355        assert!(cfg.on_attempt.is_none());
2356        assert!(cfg.redact_values.is_empty());
2357        assert_eq!(cfg.proxy_bypass_first_party, base.proxy_bypass_first_party);
2358        assert_eq!(cfg.https_only, base.https_only);
2359    }
2360
2361    #[test]
2362    fn put_and_delete_set_their_methods() {
2363        assert_eq!(Request::put("http://x/").method, Method::Put);
2364        assert_eq!(Request::delete("http://x/").method, Method::Delete);
2365        assert!(Request::put("http://x/").body.is_empty());
2366    }
2367
2368    #[test]
2369    fn set_header_replaces_case_insensitively_in_place() {
2370        let req = Request::post("http://x/")
2371            .header("X-A", "1")
2372            .header("Accept", "text/plain")
2373            .header("x-a", "2")
2374            .set_header("X-A", "3");
2375        assert_eq!(
2376            req.headers,
2377            vec![
2378                ("X-A".to_string(), "3".to_string()),
2379                ("Accept".to_string(), "text/plain".to_string()),
2380            ]
2381        );
2382        let req = req.set_header("content-type", "text/csv");
2383        assert_eq!(
2384            req.headers.last(),
2385            Some(&("content-type".to_string(), "text/csv".to_string()))
2386        );
2387    }
2388
2389    #[test]
2390    fn redaction_replaces_values_and_longest_first_scrub() {
2391        let values = vec!["secret-token-123".to_string(), "secret".to_string()];
2392        assert_eq!(
2393            redact_str("bad secret-token-123 and secret", &values),
2394            "bad [redacted] and [redacted]"
2395        );
2396    }
2397
2398    #[test]
2399    fn secret_header_hidden_in_debug_but_sent() {
2400        let req = Request::get("http://x/").secret_header("X-Key", "hunter2-value");
2401        assert!(req.headers.iter().any(|(_, v)| v == "hunter2-value"));
2402        let debug = format!("{req:?}");
2403        assert!(!debug.contains("hunter2-value"), "{debug}");
2404        assert!(debug.contains(REDACTED));
2405    }
2406
2407    #[test]
2408    fn scrub_error_covers_text_variants() {
2409        let values = vec!["tok-9".to_string()];
2410        match scrub_error(
2411            HttpError::Status {
2412                status: 401,
2413                body: "invalid tok-9".into(),
2414                retry_after: None,
2415                attempts: 1,
2416            },
2417            &values,
2418        ) {
2419            HttpError::Status { body, .. } => assert_eq!(body, "invalid [redacted]"),
2420            other => panic!("unexpected {other:?}"),
2421        }
2422    }
2423
2424    fn env_of(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<String> {
2425        move |name| {
2426            pairs
2427                .iter()
2428                .find(|(k, _)| *k == name)
2429                .map(|(_, v)| v.to_string())
2430        }
2431    }
2432
2433    fn uri(s: &str) -> ureq::http::Uri {
2434        s.parse().unwrap()
2435    }
2436
2437    #[test]
2438    fn environment_proxy_uses_the_first_set_variable_and_skips_empty_ones() {
2439        let env = env_of(&[
2440            ("ALL_PROXY", ""),
2441            ("HTTPS_PROXY", "http://secure.proxy:3128"),
2442            ("HTTP_PROXY", "http://plain.proxy:8080"),
2443            ("NO_PROXY", "a.test, .b.test ,"),
2444        ]);
2445        let p = proxy_from_lookup(&env).unwrap().unwrap();
2446        assert_eq!(p.host, "secure.proxy");
2447        assert_eq!(p.port, 3128);
2448        assert_eq!(
2449            p.no_proxy,
2450            vec!["a.test".to_string(), ".b.test".to_string()]
2451        );
2452        assert!(p.ureq.is_no_proxy(&uri("http://a.test/")));
2453        assert!(p.ureq.is_no_proxy(&uri("http://x.b.test/")));
2454        assert!(!p.ureq.is_no_proxy(&uri("http://c.test/")));
2455    }
2456
2457    #[test]
2458    fn environment_proxy_order_and_case() {
2459        let env = env_of(&[
2460            ("http_proxy", "http://lower.proxy:1"),
2461            ("ALL_PROXY", "http://all.proxy:2"),
2462        ]);
2463        assert_eq!(proxy_from_lookup(&env).unwrap().unwrap().host, "all.proxy");
2464        let env = env_of(&[("http_proxy", "http://lower.proxy:1")]);
2465        assert_eq!(
2466            proxy_from_lookup(&env).unwrap().unwrap().host,
2467            "lower.proxy"
2468        );
2469        let env = env_of(&[("no_proxy", "z.test"), ("https_proxy", "http://p.proxy:9")]);
2470        assert_eq!(
2471            proxy_from_lookup(&env).unwrap().unwrap().no_proxy,
2472            vec!["z.test".to_string()]
2473        );
2474    }
2475
2476    #[test]
2477    fn environment_without_a_usable_proxy_is_none() {
2478        assert!(proxy_from_lookup(&env_of(&[])).unwrap().is_none());
2479        // An unsupported scheme is skipped in favour of the next variable.
2480        let env = env_of(&[
2481            ("ALL_PROXY", "ftp://nope.proxy:21"),
2482            ("HTTP_PROXY", "http://ok.proxy:80"),
2483        ]);
2484        assert_eq!(proxy_from_lookup(&env).unwrap().unwrap().host, "ok.proxy");
2485        assert!(
2486            proxy_from_lookup(&env_of(&[("ALL_PROXY", "ftp://nope.proxy:21")]))
2487                .unwrap()
2488                .is_none()
2489        );
2490    }
2491
2492    #[test]
2493    fn explicit_proxy_lets_the_proxy_resolve_and_keeps_credentials() {
2494        let p = build_proxy("http://user:pass@Proxy.Corp:3128", &[]).unwrap();
2495        assert_eq!(p.host, "proxy.corp");
2496        assert_eq!(p.port, 3128);
2497        assert!(!p.ureq.resolve_target());
2498        assert_eq!(p.ureq.username(), Some("user"));
2499        assert_eq!(p.ureq.password(), Some("pass"));
2500        assert!(build_proxy("https://10.0.0.5:3128", &[]).is_ok());
2501        assert!(build_proxy("socks5://127.0.0.1:1080", &[]).is_err());
2502        assert!(build_proxy("ftp://x:1", &[]).is_err());
2503        assert!(build_proxy("not a url", &[]).is_err());
2504    }
2505
2506    #[test]
2507    fn proxy_mode_debug_never_shows_credentials() {
2508        let shown = format!(
2509            "{:?}",
2510            ProxyMode::Explicit("http://user:pw-9@proxy:3128/x?k=v".into())
2511        );
2512        assert!(
2513            !shown.contains("pw-9") && !shown.contains("user:") && !shown.contains("k=v"),
2514            "{shown}"
2515        );
2516        assert_eq!(format!("{:?}", ProxyMode::None), "None");
2517        assert_eq!(format!("{:?}", ProxyMode::Environment), "Environment");
2518        assert_eq!(format!("{:?}", ProxyMode::System), "System");
2519    }
2520
2521    #[test]
2522    fn system_settings_resolve_like_an_environment_proxy() {
2523        use crate::system_proxy::SystemProxySettings;
2524        let proxy = proxy_from_system(&SystemProxySettings {
2525            proxy: Some("http=web.corp:8080;https=web.corp:8080".into()),
2526            bypass: Some("*.corp.example;intranet".into()),
2527            ..SystemProxySettings::default()
2528        })
2529        .unwrap()
2530        .expect("a static system proxy resolves");
2531        assert_eq!((proxy.host.as_str(), proxy.port), ("web.corp", 8080));
2532        assert_eq!(
2533            proxy.no_proxy,
2534            vec![".corp.example".to_string(), "intranet".to_string()]
2535        );
2536        assert!(ProxyDiagnostic::of(&proxy).from_environment);
2537        // Nothing configured: the caller falls back to the environment.
2538        assert!(proxy_from_system(&SystemProxySettings::default())
2539            .unwrap()
2540            .is_none());
2541    }
2542
2543    #[test]
2544    fn system_settings_a_pac_script_fails_closed_and_split_configs_need_per_request_routing() {
2545        use crate::system_proxy::SystemProxySettings;
2546        // A PAC script is refused: `proxy_from_system` never approximates it.
2547        let pac = SystemProxySettings {
2548            auto_config: true,
2549            ..SystemProxySettings::default()
2550        };
2551        let expected = "a proxy auto-config script is set";
2552        let error = proxy_from_system(&pac)
2553            .err()
2554            .expect("a PAC script is refused");
2555        assert!(
2556            matches!(&error, HttpError::SystemProxyUnsupported { element } if element == expected),
2557            "{error}"
2558        );
2559        assert_eq!(
2560            error.to_string(),
2561            format!(
2562                "the Windows proxy settings cannot be followed exactly by this version \
2563                 ({expected}); nothing was sent"
2564            )
2565        );
2566        assert!(matches!(error.class(), crate::error::ErrorClass::Invalid));
2567        // Split configurations are not refused any more: the clients take them through
2568        // `resolve_system_routing` (see the per-request tests below).
2569        for (proxy, bypass) in [
2570            ("http=web.corp:8080;https=secure.corp:8443", None),
2571            ("https=secure.corp:8443", None),
2572            ("web.corp:8080", Some("<local>")),
2573            ("web.corp:8080", Some("https://only.example")),
2574        ] {
2575            let settings = SystemProxySettings {
2576                proxy: Some(proxy.into()),
2577                bypass: bypass.map(str::to_string),
2578                ..SystemProxySettings::default()
2579            };
2580            let no_env = |_: &str| None::<String>;
2581            let (routing, used) = resolve_system_routing(&settings, &no_env).unwrap();
2582            assert!(
2583                matches!(routing, Routing::PerRequest(_)),
2584                "{proxy} {bypass:?}"
2585            );
2586            assert_eq!(used, ProxyModeUsed::System);
2587        }
2588    }
2589
2590    #[test]
2591    fn system_mode_with_automatic_detection_only_goes_direct_or_environment() {
2592        use crate::system_proxy::SystemProxySettings;
2593        let no_env = |_: &str| None::<String>;
2594        let env_proxy =
2595            |name: &str| (name == "HTTPS_PROXY").then(|| "http://env.corp:3128".to_string());
2596        // The Windows default: "Automatically detect settings" on, nothing else.
2597        let detect = SystemProxySettings {
2598            auto_detect: true,
2599            ..SystemProxySettings::default()
2600        };
2601        let reason = "automatic detection only; no static proxy or PAC configured";
2602        let (proxy, used) = resolve_system(&detect, &no_env).unwrap();
2603        assert!(proxy.is_none());
2604        assert_eq!(
2605            used,
2606            ProxyModeUsed::SystemFallbackDirect {
2607                reason: reason.to_string()
2608            }
2609        );
2610        let (proxy, used) = resolve_system(&detect, &env_proxy).unwrap();
2611        let proxy = proxy.expect("the environment proxy is used");
2612        assert_eq!((proxy.host.as_str(), proxy.port), ("env.corp", 3128));
2613        assert!(proxy.from_env);
2614        assert_eq!(
2615            used,
2616            ProxyModeUsed::SystemFallbackEnvironment {
2617                reason: reason.to_string()
2618            }
2619        );
2620        // A bypass list alone does not change that.
2621        let with_bypass = SystemProxySettings {
2622            bypass: Some("<local>".into()),
2623            ..detect
2624        };
2625        let (proxy, used) = resolve_system(&with_bypass, &no_env).unwrap();
2626        assert!(proxy.is_none());
2627        assert!(matches!(used, ProxyModeUsed::SystemFallbackDirect { .. }));
2628    }
2629
2630    #[test]
2631    fn system_mode_with_automatic_detection_and_a_static_proxy_follows_the_proxy() {
2632        use crate::system_proxy::SystemProxySettings;
2633        let no_env = |_: &str| None::<String>;
2634        let env_proxy =
2635            |name: &str| (name == "HTTPS_PROXY").then(|| "http://env.corp:3128".to_string());
2636        let settings = SystemProxySettings {
2637            proxy: Some("web.corp:8080".into()),
2638            bypass: Some("localhost".into()),
2639            auto_detect: true,
2640            ..SystemProxySettings::default()
2641        };
2642        for get in [&no_env as &dyn Fn(&str) -> Option<String>, &env_proxy] {
2643            let (proxy, used) = resolve_system(&settings, get).unwrap();
2644            let proxy = proxy.expect("the static proxy is used");
2645            assert_eq!((proxy.host.as_str(), proxy.port), ("web.corp", 8080));
2646            assert_eq!(used, ProxyModeUsed::System);
2647        }
2648    }
2649
2650    #[test]
2651    fn system_mode_still_refuses_a_pac_script() {
2652        use crate::system_proxy::SystemProxySettings;
2653        let no_env = |_: &str| None::<String>;
2654        let env_proxy =
2655            |name: &str| (name == "HTTPS_PROXY").then(|| "http://env.corp:3128".to_string());
2656        let static_proxy = |proxy: &str, bypass: Option<&str>| SystemProxySettings {
2657            proxy: Some(proxy.into()),
2658            bypass: bypass.map(str::to_string),
2659            ..SystemProxySettings::default()
2660        };
2661        let rows: Vec<(SystemProxySettings, &str)> = vec![
2662            (
2663                SystemProxySettings {
2664                    auto_config: true,
2665                    ..SystemProxySettings::default()
2666                },
2667                "a proxy auto-config script is set",
2668            ),
2669            (
2670                SystemProxySettings {
2671                    auto_config: true,
2672                    auto_detect: true,
2673                    ..static_proxy("web.corp:8080", None)
2674                },
2675                "a proxy auto-config script is set",
2676            ),
2677        ];
2678        // Whatever the environment says: the user configured a proxy that cannot be
2679        // followed, so nothing is built and nothing is sent.
2680        for (settings, reason) in rows {
2681            for get in [&no_env as &dyn Fn(&str) -> Option<String>, &env_proxy] {
2682                let error = resolve_system(&settings, get)
2683                    .err()
2684                    .unwrap_or_else(|| panic!("{reason}: must be refused"));
2685                assert!(
2686                    matches!(&error, HttpError::SystemProxyUnsupported { element } if element == reason),
2687                    "{reason}: {error}"
2688                );
2689            }
2690        }
2691    }
2692
2693    #[test]
2694    fn system_mode_reports_what_it_followed() {
2695        use crate::system_proxy::SystemProxySettings;
2696        let no_env = |_: &str| None::<String>;
2697        let env_proxy =
2698            |name: &str| (name == "HTTP_PROXY").then(|| "http://env.corp:3128".to_string());
2699        // A proxy that can be followed exactly is used, whatever the environment says.
2700        let followed = SystemProxySettings {
2701            proxy: Some("web.corp:8080".into()),
2702            bypass: Some("localhost;*.corp.example".into()),
2703            ..SystemProxySettings::default()
2704        };
2705        let (proxy, used) = resolve_system(&followed, &no_env).unwrap();
2706        let proxy = proxy.expect("the system proxy is used");
2707        assert_eq!((proxy.host.as_str(), proxy.port), ("web.corp", 8080));
2708        assert_eq!(used, ProxyModeUsed::System);
2709        let (proxy, used) = resolve_system(&followed, &env_proxy).unwrap();
2710        assert_eq!(proxy.expect("the system proxy wins").host, "web.corp");
2711        assert_eq!(used, ProxyModeUsed::System);
2712        // Nothing configured: the environment proxy when set, direct otherwise, and neither
2713        // is a fallback.
2714        let none = SystemProxySettings::default();
2715        let (proxy, used) = resolve_system(&none, &no_env).unwrap();
2716        assert!(proxy.is_none());
2717        assert_eq!(used, ProxyModeUsed::Direct);
2718        let (proxy, used) = resolve_system(&none, &env_proxy).unwrap();
2719        assert!(proxy.is_some());
2720        assert_eq!(used, ProxyModeUsed::Environment);
2721    }
2722
2723    #[test]
2724    fn system_for_url_follows_the_resolver_then_the_static_settings() {
2725        use crate::system_proxy::{SystemProxySettings, UrlRoute};
2726        let no_env = |_: &str| None::<String>;
2727        let url = "https://updates.example.com/feed.json";
2728        let never = |_: &str, _: &SystemProxySettings| None::<UrlRoute>;
2729        let pac_says =
2730            |route: UrlRoute| move |_: &str, _: &SystemProxySettings| Some(route.clone());
2731
2732        // A PAC script (or detection) that resolves wins over the static proxy.
2733        let pac = SystemProxySettings {
2734            auto_config: true,
2735            auto_config_url: Some("http://wpad.corp/proxy.pac".into()),
2736            proxy: Some("static.corp:8080".into()),
2737            ..SystemProxySettings::default()
2738        };
2739        let os = pac_says(UrlRoute::Proxy("http://pac.corp:3128".into()));
2740        let (proxy, used) = resolve_system_for_url(&pac, url, &os, &no_env).unwrap();
2741        assert_eq!(proxy.expect("PAC proxy").host, "pac.corp");
2742        assert_eq!(used, ProxyModeUsed::SystemForUrl);
2743        let os = pac_says(UrlRoute::Direct);
2744        let (proxy, used) = resolve_system_for_url(&pac, url, &os, &no_env).unwrap();
2745        assert!(proxy.is_none());
2746        assert_eq!(used, ProxyModeUsed::SystemForUrl);
2747
2748        // A PAC script that cannot be evaluated fails closed.
2749        let err = resolve_system_for_url(&pac, url, &never, &no_env)
2750            .err()
2751            .unwrap();
2752        assert!(
2753            matches!(err, HttpError::SystemProxyUnsupported { .. }),
2754            "{err:?}"
2755        );
2756
2757        // Detection that finds nothing falls back to the static proxy, per URL.
2758        let detect = SystemProxySettings {
2759            auto_detect: true,
2760            proxy: Some("http=web.corp:8080;https=secure.corp:8443".into()),
2761            bypass: Some("<local>;*.corp.example;http://plain.example".into()),
2762            ..SystemProxySettings::default()
2763        };
2764        let routed = |target: &str| {
2765            resolve_system_for_url(&detect, target, &never, &no_env)
2766                .unwrap()
2767                .0
2768                .map(|p| p.host)
2769        };
2770        assert_eq!(
2771            routed("https://api.example.com/").as_deref(),
2772            Some("secure.corp")
2773        );
2774        assert_eq!(
2775            routed("http://api.example.com/").as_deref(),
2776            Some("web.corp")
2777        );
2778        assert_eq!(routed("https://intranet/").as_deref(), None);
2779        assert_eq!(routed("https://a.corp.example/").as_deref(), None);
2780        assert_eq!(routed("http://plain.example/").as_deref(), None);
2781        assert_eq!(
2782            routed("https://plain.example/").as_deref(),
2783            Some("secure.corp")
2784        );
2785
2786        // Detection alone, nothing found: environment, then direct, and it says so.
2787        let only = SystemProxySettings {
2788            auto_detect: true,
2789            ..SystemProxySettings::default()
2790        };
2791        let (proxy, used) = resolve_system_for_url(&only, url, &never, &no_env).unwrap();
2792        assert!(proxy.is_none());
2793        assert!(matches!(used, ProxyModeUsed::SystemFallbackDirect { .. }));
2794        let env_proxy =
2795            |name: &str| (name == "HTTPS_PROXY").then(|| "http://env.corp:3128".to_string());
2796        let (proxy, used) = resolve_system_for_url(&only, url, &never, &env_proxy).unwrap();
2797        assert_eq!(proxy.expect("environment proxy").host, "env.corp");
2798        assert!(matches!(
2799            used,
2800            ProxyModeUsed::SystemFallbackEnvironment { .. }
2801        ));
2802
2803        // A URL this mode cannot route fails closed.
2804        for bad in ["not a url", "ftp://host/file", "/relative"] {
2805            let err = resolve_system_for_url(&only, bad, &never, &no_env)
2806                .err()
2807                .unwrap();
2808            assert!(
2809                matches!(err, HttpError::InvalidRequest(_)),
2810                "{bad}: {err:?}"
2811            );
2812        }
2813    }
2814
2815    #[test]
2816    fn system_for_url_debug_hides_credentials() {
2817        let shown = format!(
2818            "{:?}",
2819            ProxyMode::SystemForUrl("https://u:pw-9@host/p?k=v".into())
2820        );
2821        assert!(!shown.contains("pw-9") && !shown.contains("k=v"), "{shown}");
2822    }
2823
2824    #[test]
2825    fn first_party_bypass_drops_the_proxy_only_when_asked() {
2826        let base = ClientConfig::first_party(vec!["api.example.com".into()])
2827            .proxy(ProxyMode::Explicit("http://proxy:3128".into()));
2828        assert!(base.resolved_proxy().unwrap().is_some());
2829        assert!(base
2830            .clone()
2831            .proxy_bypass_first_party(true)
2832            .resolved_proxy()
2833            .unwrap()
2834            .is_none());
2835        // The flag means nothing for a public-web client.
2836        let public = ClientConfig::default()
2837            .proxy(ProxyMode::Explicit("http://proxy:3128".into()))
2838            .proxy_bypass_first_party(true);
2839        assert!(public.resolved_proxy().unwrap().is_some());
2840        assert!(ClientConfig::default().resolved_proxy().unwrap().is_none());
2841    }
2842
2843    #[test]
2844    fn invalid_explicit_proxy_fails_closed() {
2845        let config = ClientConfig::default().proxy(ProxyMode::Explicit("ftp://nope:1".into()));
2846        assert!(matches!(
2847            Client::try_new(config.clone()),
2848            Err(HttpError::InvalidRequest(_))
2849        ));
2850        let err = Client::new(config)
2851            .send(&Request::get("http://127.0.0.1:9/"))
2852            .unwrap_err();
2853        assert!(matches!(err, HttpError::InvalidRequest(_)), "{err:?}");
2854    }
2855
2856    // ---- ProxyMode::System decided per request URL (rightkit#216) ----
2857
2858    fn split_settings() -> crate::system_proxy::SystemProxySettings {
2859        crate::system_proxy::SystemProxySettings {
2860            proxy: Some("http=plain.corp:8080;https=secure.corp:8443".into()),
2861            bypass: Some("<local>;*.corp.example;https://only-tls.example".into()),
2862            ..crate::system_proxy::SystemProxySettings::default()
2863        }
2864    }
2865
2866    #[test]
2867    fn per_scheme_proxies_are_chosen_per_request_url() {
2868        let routes = SystemRoutes::new(&split_settings()).unwrap();
2869        let at = |scheme: &str, host: &str| {
2870            routes
2871                .proxy_for(scheme, host)
2872                .map(|p| (p.host.clone(), p.port))
2873        };
2874        assert_eq!(
2875            at("http", "api.example.com"),
2876            Some(("plain.corp".into(), 8080))
2877        );
2878        assert_eq!(
2879            at("https", "api.example.com"),
2880            Some(("secure.corp".into(), 8443))
2881        );
2882        let mut endpoints = routes.endpoints();
2883        endpoints.sort();
2884        assert_eq!(
2885            endpoints,
2886            vec![
2887                ("plain.corp".to_string(), 8080),
2888                ("secure.corp".to_string(), 8443)
2889            ]
2890        );
2891    }
2892
2893    #[test]
2894    fn a_scheme_limited_bypass_entry_bypasses_that_scheme_only() {
2895        let routes = SystemRoutes::new(&split_settings()).unwrap();
2896        assert!(routes.proxy_for("https", "only-tls.example").is_none());
2897        assert_eq!(
2898            routes.proxy_for("http", "only-tls.example").map(|p| p.port),
2899            Some(8080)
2900        );
2901    }
2902
2903    #[test]
2904    fn local_and_wildcard_bypass_entries_go_direct_on_both_schemes() {
2905        let routes = SystemRoutes::new(&split_settings()).unwrap();
2906        for scheme in ["http", "https"] {
2907            assert!(
2908                routes.proxy_for(scheme, "intranet").is_none(),
2909                "{scheme} <local>"
2910            );
2911            assert!(
2912                routes.proxy_for(scheme, "a.corp.example").is_none(),
2913                "{scheme} *.corp"
2914            );
2915            assert!(
2916                routes.proxy_for(scheme, "intranet.other.com").is_some(),
2917                "{scheme}"
2918            );
2919        }
2920    }
2921
2922    #[test]
2923    fn a_scheme_without_an_endpoint_is_direct() {
2924        let settings = crate::system_proxy::SystemProxySettings {
2925            proxy: Some("https=secure.corp:8443".into()),
2926            ..crate::system_proxy::SystemProxySettings::default()
2927        };
2928        let routes = SystemRoutes::new(&settings).unwrap();
2929        assert!(routes.proxy_for("http", "x.example.com").is_none());
2930        assert!(routes.proxy_for("https", "x.example.com").is_some());
2931    }
2932
2933    /// A listener that records each request line and answers 502, so nothing goes further.
2934    fn recording_proxy() -> (u16, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
2935        use std::io::{BufRead, BufReader, Write};
2936        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2937        let port = listener.local_addr().unwrap().port();
2938        let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2939        let log = seen.clone();
2940        std::thread::spawn(move || {
2941            for stream in listener.incoming() {
2942                let Ok(mut stream) = stream else { break };
2943                let mut first = String::new();
2944                let mut reader = BufReader::new(stream.try_clone().unwrap());
2945                let _ = reader.read_line(&mut first);
2946                log.lock().unwrap().push(first.trim().to_string());
2947                let _ = stream.write_all(
2948                    b"HTTP/1.1 502 Bad Gateway\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
2949                );
2950            }
2951        });
2952        (port, seen)
2953    }
2954
2955    #[test]
2956    fn the_blocking_client_sends_each_request_to_its_own_scheme_proxy() {
2957        let (http_port, http_seen) = recording_proxy();
2958        let (https_port, https_seen) = recording_proxy();
2959        let settings = crate::system_proxy::SystemProxySettings {
2960            proxy: Some(format!(
2961                "http=127.0.0.1:{http_port};https=127.0.0.1:{https_port}"
2962            )),
2963            bypass: Some("<local>;https://tls-only.example".into()),
2964            ..crate::system_proxy::SystemProxySettings::default()
2965        };
2966        let config = ClientConfig {
2967            address_policy: AddressPolicy::default().with_resolver(|_| vec![]),
2968            retry: RetryPolicy::none(),
2969            request_timeout: Duration::from_secs(5),
2970            response_timeout: Some(Duration::from_secs(5)),
2971            connect_timeout: Some(Duration::from_secs(3)),
2972            ..ClientConfig::default()
2973        };
2974        let client = Client::build(
2975            config,
2976            Routing::PerRequest(Arc::new(SystemRoutes::new(&settings).unwrap())),
2977            ProxyModeUsed::System,
2978        );
2979        assert!(client.proxy_diagnostic().is_none());
2980        let go = |url: &str| {
2981            let _ = client.send(&Request::get(url));
2982        };
2983        go("http://app.example.com/");
2984        go("https://app.example.com/");
2985        go("http://tls-only.example/");
2986        // Direct: nothing reaches either proxy.
2987        go("https://tls-only.example/");
2988        go("http://intranet/");
2989        let http_seen = http_seen.lock().unwrap().clone();
2990        let https_seen = https_seen.lock().unwrap().clone();
2991        assert_eq!(
2992            http_seen,
2993            vec![
2994                "CONNECT app.example.com:80 HTTP/1.1".to_string(),
2995                "CONNECT tls-only.example:80 HTTP/1.1".to_string(),
2996            ]
2997        );
2998        assert_eq!(
2999            https_seen,
3000            vec!["CONNECT app.example.com:443 HTTP/1.1".to_string()]
3001        );
3002    }
3003
3004    #[test]
3005    fn component_encoding_and_repeated_headers() {
3006        assert_eq!(percent_encode_component("a b/c+~"), "a%20b%2Fc%2B~");
3007        assert_eq!(percent_decode_component("a%20b%2Fc+d%zz"), "a b/c+d%zz");
3008        let r = Response {
3009            status: 200,
3010            headers: vec![
3011                ("set-cookie".into(), "a=1".into()),
3012                ("set-cookie".into(), "b=2".into()),
3013            ],
3014            body: Vec::new(),
3015            attempts: 1,
3016            final_url: "https://x.test/".into(),
3017            redact: Vec::new(),
3018        };
3019        assert_eq!(
3020            r.header_all("Set-Cookie").collect::<Vec<_>>(),
3021            vec!["a=1", "b=2"]
3022        );
3023        assert_eq!(r.header("set-cookie"), Some("a=1"));
3024        assert_eq!(r.final_url(), "https://x.test/");
3025    }
3026}