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