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