Skip to main content

rightkit_http/
client.rs

1use std::fmt;
2use std::io::{self, Read};
3use std::sync::atomic::AtomicBool;
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6
7use serde::de::DeserializeOwned;
8use serde::Serialize;
9
10use crate::cancel::{is_cancelled_io, is_set, sleep_cancellable, CancelToken, CheckedReader};
11use crate::error::{HttpError, Phase, MAX_ERROR_BODY_BYTES};
12use crate::netpolicy::{denial_in_chain, normalize_host, AddressPolicy, VettingResolver, ViaProxy};
13use crate::redirect;
14use crate::retry::{parse_retry_after, RetryPolicy};
15use crate::sse::SseReader;
16use crate::urlredact::{redact_url, redact_urls_in_text, url_secret_parts};
17
18pub(crate) const REDACTED: &str = "[redacted]";
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Method {
22    Get,
23    Post,
24    Put,
25    Patch,
26    Delete,
27    Head,
28}
29
30impl Method {
31    pub fn as_str(self) -> &'static str {
32        match self {
33            Method::Get => "GET",
34            Method::Post => "POST",
35            Method::Put => "PUT",
36            Method::Patch => "PATCH",
37            Method::Delete => "DELETE",
38            Method::Head => "HEAD",
39        }
40    }
41    /// RFC 9110 idempotent methods (safe to retry after an ambiguous failure).
42    pub fn is_idempotent(self) -> bool {
43        !matches!(self, Method::Post | Method::Patch)
44    }
45}
46
47/// One retry decision, reported to [`ClientConfig::on_retry`]. `url` and `reason`
48/// have configured and per-request secret values replaced by `[redacted]`.
49#[derive(Debug, Clone)]
50pub struct RetryEvent {
51    pub url: String,
52    /// Retry number about to run (1 = first retry).
53    pub retry: u32,
54    pub reason: String,
55    pub delay: Duration,
56}
57
58type RetryObserver = Arc<dyn Fn(&RetryEvent) + Send + Sync>;
59
60/// How requests reach the network.
61///
62/// **Threat model.** A direct connection is the strongest case: this crate resolves the
63/// name, checks every address and connects to exactly those addresses. Through a proxy it
64/// cannot: the proxy resolves the destination name itself, so the address a connection
65/// reaches cannot be checked or pinned here. The guarantee through a proxy is therefore
66/// name- and literal-based, and weaker than a direct connection. `ProxyMode::None` stays
67/// the default for that reason.
68///
69/// With a proxy:
70///
71/// - The proxy endpoint is trusted (it is user or administrator configuration, often on a
72///   private address such as `http://10.0.0.5:3128`). It gets no address-class denial.
73///   Only `http` and `https` proxies are accepted.
74/// - Every destination is still vetted: IP literals (and `localhost`) by class, hostnames
75///   by the name rules (`localhost`, `*.localhost`, `*.local`, `*.internal` and
76///   single-label names are refused unless an `allow_if` rule lists the host), cloud
77///   metadata names, and the first-party host pin. `deny_ranges` and `deny_if` apply to
78///   literals only, because a hostname's address is not known. A hostname that resolves to
79///   a private address at the proxy is not caught.
80/// - Every redirect hop is vetted the same way.
81/// - Hosts that match `NO_PROXY` connect directly and get the full direct-connection check.
82/// - [`ClientConfig::proxy_bypass_first_party`] skips the proxy for a first-party client.
83///
84/// The proxy is trusted: it sees destinations and can forward anywhere. Credentials in a
85/// proxy URL never appear in `Debug` output or errors.
86#[derive(Clone, Default)]
87pub enum ProxyMode {
88    /// No proxy. Environment proxy variables are ignored. This is the default.
89    #[default]
90    None,
91    /// `ALL_PROXY`, `HTTPS_PROXY`, `HTTP_PROXY` (first set, either case) and `NO_PROXY`.
92    Environment,
93    /// A proxy URL such as `http://proxy.corp:3128` or `socks5://127.0.0.1:1080`.
94    Explicit(String),
95    /// The proxy the operating system is configured with for the current user.
96    ///
97    /// Windows: the static proxy and bypass list of the system proxy settings, followed
98    /// exactly or not at all. One `http` or `https` proxy for both schemes with a bypass list
99    /// of plain hosts (`host`, `*.example.com`) is used. Anything else (different routes per
100    /// scheme, `<local>`, a scheme-limited or inner-wildcard bypass entry, a proxy
101    /// auto-config script, automatic detection) is
102    /// [`HttpError::SystemProxyUnsupported`](crate::HttpError::SystemProxyUnsupported) and
103    /// nothing is sent; catch it and use the mode the app used before. When nothing is
104    /// configured, and on other systems, this is [`ProxyMode::Environment`].
105    System,
106}
107
108impl fmt::Debug for ProxyMode {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        match self {
111            ProxyMode::None => f.write_str("None"),
112            ProxyMode::Environment => f.write_str("Environment"),
113            ProxyMode::Explicit(url) => write!(f, "Explicit({})", redact_url(url)),
114            ProxyMode::System => f.write_str("System"),
115        }
116    }
117}
118
119/// Which certificate roots verify a server's TLS certificate.
120///
121/// Verification is always on; there is no option that turns it off. `Platform` hands the
122/// decision to the operating system (macOS Security framework, Windows CryptoAPI, the
123/// system roots on Linux) through `rustls-platform-verifier` in both clients, so roots an
124/// enterprise or an inspection proxy installed on the machine are honoured, as curl and
125/// browsers honour them.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
127pub enum TrustRoots {
128    /// The operating system's trust store. This is the default. When the store cannot be
129    /// used the client fails closed: the handshake (blocking) or `AsyncClient::new` (async)
130    /// returns an error.
131    #[default]
132    Platform,
133    /// Mozilla's root certificates compiled into the crate (`webpki-roots`), ignoring the
134    /// machine's store. Use it when the machine's roots must not be trusted, or when
135    /// results must not depend on the machine. A host behind TLS inspection will fail.
136    Bundled,
137}
138
139/// ureq TLS settings for a trust choice. The provider and the root source are named
140/// explicitly, so no other crate's additive features change what is trusted.
141fn blocking_tls_config(trust: TrustRoots) -> ureq::tls::TlsConfig {
142    let roots = match trust {
143        TrustRoots::Platform => ureq::tls::RootCerts::PlatformVerifier,
144        TrustRoots::Bundled => {
145            let extra = crate::trust::test_roots();
146            if extra.is_empty() {
147                ureq::tls::RootCerts::WebPki
148            } else {
149                ureq::tls::RootCerts::Specific(std::sync::Arc::new(
150                    extra
151                        .iter()
152                        .map(|d| ureq::tls::Certificate::from_der(d).to_owned())
153                        .collect(),
154                ))
155            }
156        }
157    };
158    ureq::tls::TlsConfig::builder()
159        .provider(ureq::tls::TlsProvider::Rustls)
160        .root_certs(roots)
161        .build()
162}
163
164impl TrustRoots {
165    /// `"platform"` or `"bundled"`: the verifier a client built from this setting uses.
166    pub fn verifier_kind(self) -> &'static str {
167        match self {
168            TrustRoots::Platform => "platform",
169            TrustRoots::Bundled => "bundled",
170        }
171    }
172}
173
174/// Proxy settings resolved from a [`ProxyMode`].
175pub(crate) struct ResolvedProxy {
176    pub(crate) host: String,
177    pub(crate) port: u16,
178    pub(crate) ureq: ureq::Proxy,
179    /// The proxy URL as configured. It may carry credentials: never print it.
180    #[cfg_attr(not(feature = "async"), allow(dead_code))]
181    pub(crate) url: String,
182    /// `NO_PROXY`-style entries as configured (the matcher lives in `ureq`).
183    #[allow(dead_code)]
184    pub(crate) no_proxy: Vec<String>,
185    /// True when the proxy came from the environment or the operating system's settings.
186    pub(crate) from_env: bool,
187}
188
189/// What a client knows about the proxy it uses, for diagnostics. Credentials are never part
190/// of it.
191#[derive(Clone, Debug, PartialEq, Eq)]
192pub struct ProxyDiagnostic {
193    /// `scheme://host:port` of the proxy, without credentials, path or query.
194    pub endpoint: String,
195    /// True when the proxy was taken from the environment or from the operating system's
196    /// settings ([`ProxyMode::System`]), false for `ProxyMode::Explicit`.
197    pub from_environment: bool,
198    /// The proxy endpoint is exempt from the destination class check because the caller or
199    /// the environment configured it. Destinations are still checked: literals by class,
200    /// names by the name rules, and `NO_PROXY` hosts fully.
201    pub endpoint_exempt_from_class_check: bool,
202}
203
204impl ProxyDiagnostic {
205    pub(crate) fn of(p: &ResolvedProxy) -> Self {
206        Self {
207            endpoint: format!("{}://{}:{}", p.scheme(), p.host, p.port),
208            from_environment: p.from_env,
209            endpoint_exempt_from_class_check: true,
210        }
211    }
212}
213
214impl ResolvedProxy {
215    fn scheme(&self) -> &'static str {
216        if self.ureq.protocol() == ureq::ProxyProtocol::Https {
217            "https"
218        } else {
219            "http"
220        }
221    }
222}
223
224const PROXY_ENV: [&str; 6] = [
225    "ALL_PROXY",
226    "all_proxy",
227    "HTTPS_PROXY",
228    "https_proxy",
229    "HTTP_PROXY",
230    "http_proxy",
231];
232
233/// Build an `http`/`https` proxy that resolves destination names itself.
234fn build_proxy(url: &str, no_proxy: &[String]) -> Result<ResolvedProxy, HttpError> {
235    let invalid = || HttpError::InvalidRequest("invalid proxy URL".into());
236    let base = ureq::Proxy::new(url).map_err(|_| invalid())?;
237    if !matches!(
238        base.protocol(),
239        ureq::ProxyProtocol::Http | ureq::ProxyProtocol::Https
240    ) {
241        return Err(invalid());
242    }
243    let mut builder = ureq::Proxy::builder(base.protocol())
244        .host(base.host())
245        .port(base.port())
246        .resolve_target(false);
247    if let Some(user) = base.username() {
248        builder = builder.username(user);
249    }
250    if let Some(pass) = base.password() {
251        builder = builder.password(pass);
252    }
253    for expr in no_proxy {
254        builder = builder.no_proxy(expr);
255    }
256    let ureq = builder.build().map_err(|_| invalid())?;
257    Ok(ResolvedProxy {
258        host: normalize_host(ureq.host()),
259        port: ureq.port(),
260        ureq,
261        url: url.to_string(),
262        no_proxy: no_proxy.to_vec(),
263        from_env: false,
264    })
265}
266
267/// Proxy from environment-style variables read through `get`. Empty values are unset.
268pub(crate) fn proxy_from_lookup(
269    get: &dyn Fn(&str) -> Option<String>,
270) -> Result<Option<ResolvedProxy>, HttpError> {
271    let no_proxy: Vec<String> = ["NO_PROXY", "no_proxy"]
272        .iter()
273        .find_map(|name| get(name).filter(|v| !v.is_empty()))
274        .map(|v| {
275            v.split(',')
276                .map(|e| e.trim().to_string())
277                .filter(|e| !e.is_empty())
278                .collect()
279        })
280        .unwrap_or_default();
281    for name in PROXY_ENV {
282        if let Some(url) = get(name).filter(|v| !v.trim().is_empty()) {
283            if let Ok(mut proxy) = build_proxy(url.trim(), &no_proxy) {
284                proxy.from_env = true;
285                return Ok(Some(proxy));
286            }
287        }
288    }
289    Ok(None)
290}
291
292impl ClientConfig {
293    /// The proxy this config uses, or `None` when it connects directly.
294    pub(crate) fn resolved_proxy(&self) -> Result<Option<ResolvedProxy>, HttpError> {
295        if self.proxy_bypass_first_party && self.address_policy.is_first_party() {
296            return Ok(None);
297        }
298        match &self.proxy {
299            ProxyMode::None => Ok(None),
300            ProxyMode::Explicit(url) => build_proxy(url, &[]).map(Some),
301            ProxyMode::Environment => proxy_from_lookup(&|name| std::env::var(name).ok()),
302            ProxyMode::System => {
303                match proxy_from_system(&crate::system_proxy::system_proxy_settings())? {
304                    Some(proxy) => Ok(Some(proxy)),
305                    None => proxy_from_lookup(&|name| std::env::var(name).ok()),
306                }
307            }
308        }
309    }
310}
311
312/// The proxy the operating system's static settings name, treated like an environment proxy.
313/// Settings that name no usable proxy, or one this crate cannot build, give `Ok(None)`.
314///
315/// Settings this client cannot follow exactly (see [`crate::system_proxy`]) are an error that
316/// names the first such element. A client uses one proxy for every request and a bypass list
317/// of plain hosts, so following them approximately would send traffic to a proxy, or around
318/// it, against what the user configured. The client then fails closed, as for any invalid
319/// proxy setting.
320pub(crate) fn proxy_from_system(
321    settings: &crate::system_proxy::SystemProxySettings,
322) -> Result<Option<ResolvedProxy>, HttpError> {
323    use crate::system_proxy::SystemProxyChoice;
324    match crate::system_proxy::proxy_choice(settings) {
325        SystemProxyChoice::None => Ok(None),
326        SystemProxyChoice::Single { url, no_proxy } => {
327            Ok(build_proxy(&url, &no_proxy).ok().map(|mut proxy| {
328                proxy.from_env = true;
329                proxy
330            }))
331        }
332        SystemProxyChoice::Unsupported(element) => {
333            Err(HttpError::SystemProxyUnsupported { element })
334        }
335    }
336}
337
338#[derive(Clone)]
339pub struct ClientConfig {
340    pub user_agent: String,
341    /// Connection-phase deadline; `None` disables this per-phase deadline.
342    pub connect_timeout: Option<Duration>,
343    /// Whole-request deadline for buffered calls (headers + body).
344    pub request_timeout: Duration,
345    /// Time to receive response headers; for streams this is the first-byte
346    /// (first-token) deadline. `None` disables this per-phase deadline.
347    pub response_timeout: Option<Duration>,
348    /// Total time to receive the body, starting after response headers arrive.
349    /// The budget is not restarted per read. `None` disables this phase deadline.
350    pub recv_body_timeout: Option<Duration>,
351    /// Optional total lifetime cap for a stream body.
352    pub stream_timeout: Option<Duration>,
353    /// Redirects followed (0 = never; credentials are never replayed to a new host).
354    pub max_redirects: u32,
355    /// Error when a nonzero redirect limit is exceeded (default true, like ureq).
356    /// False returns the final 3xx response. With `max_redirects = 0`, redirects
357    /// are never followed and the 3xx response is always returned.
358    pub max_redirects_will_error: bool,
359    pub max_response_bytes: usize,
360    pub retry: RetryPolicy,
361    pub on_retry: Option<RetryObserver>,
362    /// Secret strings scrubbed from every error message and retry event. Add
363    /// values with [`ClientConfig::redact_values`].
364    pub redact_values: Vec<String>,
365    /// Outbound address policy. The default is public-web policy: loopback, private,
366    /// link-local, CGNAT, multicast and reserved destinations are denied on the first
367    /// request and on every redirect hop. See [`crate::netpolicy`].
368    pub address_policy: AddressPolicy,
369    /// Proxy use. The default is [`ProxyMode::None`]: no proxy, and environment proxy
370    /// variables are ignored. See [`ProxyMode`] for the threat model.
371    pub proxy: ProxyMode,
372    /// When the address policy is first-party, connect to the first-party hosts directly
373    /// even if `proxy` is set. Default `false`: first-party calls use the proxy too.
374    pub proxy_bypass_first_party: bool,
375    /// Certificate roots used to verify servers. The default is [`TrustRoots::Platform`]
376    /// (the OS trust store); [`TrustRoots::Bundled`] restores the Mozilla set used before
377    /// 0.3.0. Verification cannot be disabled.
378    pub trust: TrustRoots,
379    /// Refuse any URL that is not `https`, on the first request and on every redirect hop,
380    /// with [`HttpError::HttpsRequired`]. Default `false`, which keeps the earlier
381    /// behaviour (plain `http` allowed; an HTTPS to HTTP redirect is still refused by the
382    /// address policy unless it opts in).
383    pub https_only: bool,
384}
385
386impl Default for ClientConfig {
387    fn default() -> Self {
388        Self {
389            user_agent: concat!("rightkit-http/", env!("CARGO_PKG_VERSION")).to_string(),
390            connect_timeout: Some(Duration::from_secs(10)),
391            request_timeout: Duration::from_secs(60),
392            response_timeout: Some(Duration::from_secs(60)),
393            recv_body_timeout: None,
394            stream_timeout: None,
395            max_redirects: 0,
396            max_redirects_will_error: true,
397            max_response_bytes: 32 * 1024 * 1024,
398            retry: RetryPolicy::default(),
399            on_retry: None,
400            redact_values: Vec::new(),
401            address_policy: AddressPolicy::default(),
402            proxy: ProxyMode::None,
403            proxy_bypass_first_party: false,
404            trust: TrustRoots::Platform,
405            https_only: false,
406        }
407    }
408}
409
410impl ClientConfig {
411    /// Default settings with a first-party policy: only `hosts` (exact match), only over
412    /// HTTPS, resolving to public addresses unless the policy opts a host out with
413    /// `AddressPolicy::allow_private_resolution`. Use it for license checks and model downloads against known hosts.
414    pub fn first_party(hosts: Vec<String>) -> Self {
415        Self::default().address_policy(AddressPolicy::first_party(hosts))
416    }
417
418    /// Settings that match a plain `ureq` agent, for callers moving off direct ureq: environment
419    /// proxy variables honoured ([`ProxyMode::Environment`]), Mozilla roots ([`TrustRoots::Bundled`]),
420    /// 10 redirects, no retries ([`RetryPolicy::none`]) and the given user agent. Every other field
421    /// keeps its [`Default`] value. It does NOT enable private-address resolution: the address policy
422    /// stays public-web unless set explicitly.
423    pub fn legacy_ureq_parity(user_agent: impl Into<String>) -> Self {
424        Self {
425            user_agent: user_agent.into(),
426            proxy: ProxyMode::Environment,
427            trust: TrustRoots::Bundled,
428            max_redirects: 10,
429            retry: RetryPolicy::none(),
430            ..Self::default()
431        }
432    }
433
434    /// Replace the outbound address policy.
435    pub fn address_policy(mut self, policy: AddressPolicy) -> Self {
436        self.address_policy = policy;
437        self
438    }
439
440    /// Set the proxy mode; see [`ProxyMode`].
441    pub fn proxy(mut self, mode: ProxyMode) -> Self {
442        self.proxy = mode;
443        self
444    }
445
446    /// Choose the certificate roots; see [`TrustRoots`].
447    pub fn trust(mut self, trust: TrustRoots) -> Self {
448        self.trust = trust;
449        self
450    }
451
452    /// Refuse non-`https` URLs and redirect hops; see [`ClientConfig::https_only`].
453    pub fn https_only(mut self, https_only: bool) -> Self {
454        self.https_only = https_only;
455        self
456    }
457
458    /// Let a first-party client reach its hosts without the proxy; see
459    /// [`ClientConfig::proxy_bypass_first_party`].
460    pub fn proxy_bypass_first_party(mut self, bypass: bool) -> Self {
461        self.proxy_bypass_first_party = bypass;
462        self
463    }
464
465    /// Add secret strings to scrub from error text (`[redacted]`). Empty strings are ignored.
466    pub fn redact_values(mut self, values: Vec<String>) -> Self {
467        self.redact_values
468            .extend(values.into_iter().filter(|v| !v.is_empty()));
469        self
470    }
471}
472
473#[derive(Clone)]
474pub struct Request {
475    pub method: Method,
476    pub url: String,
477    pub headers: Vec<(String, String)>,
478    pub body: Vec<u8>,
479    /// Per-attempt total timeout (buffered calls and stream headers).
480    pub timeout: Option<Duration>,
481    pub retry: Option<RetryPolicy>,
482    /// Allow retrying a POST/PATCH after HTTP-status failures (429, 5xx, ...). Default
483    /// `false` since 0.3.0: a replayed POST (a single-use OAuth code exchange, a payment) can
484    /// be harmful. Transport errors on non-idempotent methods retry only in the connect
485    /// phase regardless. Set `true` per request for an idempotent POST; see the README for
486    /// how this interacts with `RetryPolicy::none()`.
487    pub retry_unsafe_statuses: bool,
488    pub(crate) max_response_bytes: Option<usize>,
489    pub(crate) deadline: Option<Duration>,
490    pub(crate) cancel: Option<CancelToken>,
491    pub(crate) secret_values: Vec<String>,
492    pub(crate) headers_only: bool,
493}
494
495impl fmt::Debug for Request {
496    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
497        let headers: Vec<(String, String)> = self
498            .headers
499            .iter()
500            .map(|(k, v)| {
501                let shown = if redirect::is_secret_header_name(k) {
502                    REDACTED.to_string()
503                } else {
504                    redact_str(&redact_urls_in_text(v), &sorted_values(&self.secret_values))
505                };
506                (k.clone(), shown)
507            })
508            .collect();
509        f.debug_struct("Request")
510            .field("method", &self.method)
511            .field(
512                "url",
513                &redact_str(&redact_url(&self.url), &self.secret_values),
514            )
515            .field("headers", &headers)
516            .field("body_len", &self.body.len())
517            .field("timeout", &self.timeout)
518            .field("retry", &self.retry)
519            .field("retry_unsafe_statuses", &self.retry_unsafe_statuses)
520            .field("max_response_bytes", &self.max_response_bytes)
521            .field("deadline", &self.deadline)
522            .field("cancel", &self.cancel.is_some())
523            .field("headers_only", &self.headers_only)
524            .finish()
525    }
526}
527
528impl Request {
529    pub fn new(method: Method, url: impl Into<String>) -> Self {
530        Self {
531            method,
532            url: url.into(),
533            headers: Vec::new(),
534            body: Vec::new(),
535            timeout: None,
536            retry: None,
537            retry_unsafe_statuses: false,
538            max_response_bytes: None,
539            deadline: None,
540            cancel: None,
541            secret_values: Vec::new(),
542            headers_only: false,
543        }
544    }
545    pub fn get(url: impl Into<String>) -> Self {
546        Self::new(Method::Get, url)
547    }
548    pub fn post(url: impl Into<String>) -> Self {
549        Self::new(Method::Post, url)
550    }
551    pub fn put(url: impl Into<String>) -> Self {
552        Self::new(Method::Put, url)
553    }
554    pub fn delete(url: impl Into<String>) -> Self {
555        Self::new(Method::Delete, url)
556    }
557    /// Append a header. Use [`Request::set_header`] to replace an existing one.
558    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
559        self.headers.push((name.into(), value.into()));
560        self
561    }
562    /// Set a header, replacing every existing header with the same name
563    /// (ASCII case-insensitive). The first occurrence keeps its position.
564    pub fn set_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
565        let name = name.into();
566        let value = value.into();
567        let position = self
568            .headers
569            .iter()
570            .position(|(k, _)| k.eq_ignore_ascii_case(&name));
571        self.headers.retain(|(k, _)| !k.eq_ignore_ascii_case(&name));
572        match position {
573            Some(i) => self.headers.insert(i, (name, value)),
574            None => self.headers.push((name, value)),
575        }
576        self
577    }
578    /// Set a header whose value is a secret. The value is sent on the wire but
579    /// never appears in error text, retry events, or `Debug` output.
580    pub fn secret_header(self, name: impl Into<String>, value: impl Into<String>) -> Self {
581        let value = value.into();
582        let mut req = self;
583        if !value.is_empty() {
584            req.secret_values.push(value.clone());
585        }
586        req.set_header(name, value)
587    }
588    /// `Authorization: Bearer <token>`. The token is treated as a secret.
589    pub fn bearer(mut self, token: &str) -> Self {
590        if !token.is_empty() {
591            self.secret_values.push(token.to_string());
592        }
593        self.header("Authorization", format!("Bearer {token}"))
594    }
595    /// Register a secret that is not a header, for example a form field or a value inside a
596    /// body. It is scrubbed from error text, retry events and `Debug` output exactly like a
597    /// [`Request::secret_header`] value. Empty values are ignored.
598    pub fn redact_value(mut self, value: impl Into<String>) -> Self {
599        let value = value.into();
600        if !value.is_empty() {
601            self.secret_values.push(value);
602        }
603        self
604    }
605    /// [`Request::form`] that also registers the value of every pair whose name is in
606    /// `secret_names` (compared ignoring ASCII case) as a secret, so a token or client secret
607    /// sent in the body is scrubbed from errors like a secret header.
608    pub fn secret_form(mut self, pairs: &[(&str, &str)], secret_names: &[&str]) -> Self {
609        for (name, value) in pairs {
610            if secret_names.iter().any(|s| s.eq_ignore_ascii_case(name)) && !value.is_empty() {
611                self.secret_values.push((*value).to_string());
612            }
613        }
614        self.form(pairs)
615    }
616    /// Replace every secret value registered on this request (not the client's configured
617    /// ones) in `text` with `[redacted]`. For text the caller took from a response, such as
618    /// an error field a provider echoed back, where no HTTP error carries it for scrubbing.
619    pub fn scrub(&self, text: &str) -> String {
620        redact_str(text, &sorted_values(&self.secret_values))
621    }
622    pub fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
623        self.body = body.into();
624        self
625    }
626    pub fn json<T: Serialize>(mut self, value: &T) -> Result<Self, HttpError> {
627        self.body = serde_json::to_vec(value).map_err(json_error)?;
628        self.headers
629            .push(("Content-Type".into(), "application/json".into()));
630        Ok(self)
631    }
632    /// `application/x-www-form-urlencoded` body.
633    pub fn form(mut self, pairs: &[(&str, &str)]) -> Self {
634        self.body = form_encode(pairs).into_bytes();
635        self.headers.push((
636            "Content-Type".into(),
637            "application/x-www-form-urlencoded".into(),
638        ));
639        self
640    }
641    /// Per-attempt total timeout. Each retry attempt gets this budget; use
642    /// [`Request::deadline`] to bound all attempts together.
643    pub fn timeout(mut self, d: Duration) -> Self {
644        self.timeout = Some(d);
645        self
646    }
647    /// Overall budget for the call: all attempts and the retry sleeps between
648    /// them. When the next sleep would pass the deadline, the last outcome is
649    /// returned instead of sleeping. The per-attempt timeout is unchanged.
650    pub fn deadline(mut self, total: Duration) -> Self {
651        self.deadline = Some(total);
652        self
653    }
654    pub fn retry(mut self, policy: RetryPolicy) -> Self {
655        self.retry = Some(policy);
656        self
657    }
658    /// Allow retrying POST/PATCH after HTTP-status failures (`true`) or not (`false`, the default).
659    pub fn retry_unsafe_statuses(mut self, allow: bool) -> Self {
660        self.retry_unsafe_statuses = allow;
661        self
662    }
663    /// Per-request response body limit, overriding `ClientConfig::max_response_bytes`.
664    pub fn max_response_bytes(mut self, limit: usize) -> Self {
665        self.max_response_bytes = Some(limit);
666        self
667    }
668    /// Return only the status and headers: `send`, `send_any` and `Client::send_headers` do
669    /// not read the response body. It is dropped unread, which closes that connection.
670    /// Any status is returned by `send_any` and `send_headers`, including 3xx when redirects
671    /// are off and 4xx/5xx after retries; `send` still turns non-2xx into an error.
672    pub fn headers_only(mut self) -> Self {
673        self.headers_only = true;
674        self
675    }
676    /// Cancel this call through `token`. Checked between attempts, during retry
677    /// sleeps and before every body read; a cancelled call returns [`HttpError::Cancelled`].
678    pub fn cancel(mut self, token: CancelToken) -> Self {
679        self.cancel = Some(token);
680        self
681    }
682}
683
684/// Percent-encode for `application/x-www-form-urlencoded` / URL query values.
685pub fn percent_encode(input: &str) -> String {
686    let mut out = String::with_capacity(input.len());
687    for b in input.bytes() {
688        match b {
689            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
690                out.push(b as char)
691            }
692            b' ' => out.push('+'),
693            _ => out.push_str(&format!("%{b:02X}")),
694        }
695    }
696    out
697}
698
699pub fn percent_decode(input: &str) -> String {
700    let bytes = input.as_bytes();
701    let mut out = Vec::with_capacity(bytes.len());
702    let mut i = 0;
703    while i < bytes.len() {
704        match bytes[i] {
705            b'+' => out.push(b' '),
706            b'%' if i + 2 < bytes.len() => {
707                let hi = (bytes[i + 1] as char).to_digit(16);
708                let lo = (bytes[i + 2] as char).to_digit(16);
709                match (hi, lo) {
710                    (Some(h), Some(l)) => {
711                        out.push((h * 16 + l) as u8);
712                        i += 2;
713                    }
714                    _ => out.push(b'%'),
715                }
716            }
717            b => out.push(b),
718        }
719        i += 1;
720    }
721    String::from_utf8_lossy(&out).into_owned()
722}
723
724pub fn form_encode(pairs: &[(&str, &str)]) -> String {
725    pairs
726        .iter()
727        .map(|(k, v)| format!("{}={}", percent_encode(k), percent_encode(v)))
728        .collect::<Vec<_>>()
729        .join("&")
730}
731
732#[derive(Clone)]
733pub struct Response {
734    pub status: u16,
735    /// Lower-cased header names.
736    pub headers: Vec<(String, String)>,
737    pub body: Vec<u8>,
738    pub attempts: u32,
739    /// Secrets to scrub from `Debug` output: the client's `redact_values` and the
740    /// request's secret values and URL secrets.
741    pub(crate) redact: Vec<String>,
742}
743
744impl Response {
745    pub fn header(&self, name: &str) -> Option<&str> {
746        header_of(&self.headers, name)
747    }
748    pub fn text(&self) -> String {
749        String::from_utf8_lossy(&self.body).into_owned()
750    }
751    /// Decode the body. The error names the failure category and position only: serde's
752    /// own text can echo fragments of the body, which may be secret.
753    pub fn json<T: DeserializeOwned>(&self) -> Result<T, HttpError> {
754        serde_json::from_slice(&self.body).map_err(json_error)
755    }
756}
757
758/// A [`HttpError::Json`] that never quotes the input.
759pub(crate) fn json_error(e: serde_json::Error) -> HttpError {
760    HttpError::Json(format!(
761        "{:?} error at line {} column {}",
762        e.classify(),
763        e.line(),
764        e.column()
765    ))
766}
767
768impl fmt::Debug for Response {
769    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
770        f.debug_struct("Response")
771            .field("status", &self.status)
772            .field("headers", &redact_headers_with(&self.headers, &self.redact))
773            .field("body_len", &self.body.len())
774            .field("attempts", &self.attempts)
775            .finish()
776    }
777}
778
779/// Status line and headers of a response whose body was not read; see
780/// [`Client::send_headers`].
781#[derive(Clone)]
782pub struct ResponseHead {
783    pub status: u16,
784    /// Lower-cased header names.
785    pub headers: Vec<(String, String)>,
786    /// URL of the response after the redirects that were followed. It is the caller's own
787    /// URL data and keeps any query string; `Debug` redacts it.
788    pub final_url: String,
789    /// Redirects followed to reach `final_url`.
790    pub hops: u32,
791    /// HTTP requests made, counting retries (not redirect hops).
792    pub attempts: u32,
793    /// Secrets to scrub from `Debug` output; see [`Response`].
794    pub(crate) redact: Vec<String>,
795}
796
797impl ResponseHead {
798    pub fn header(&self, name: &str) -> Option<&str> {
799        header_of(&self.headers, name)
800    }
801}
802
803impl fmt::Debug for ResponseHead {
804    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
805        f.debug_struct("ResponseHead")
806            .field("status", &self.status)
807            .field("headers", &redact_headers_with(&self.headers, &self.redact))
808            .field(
809                "final_url",
810                &redact_str(&redact_url(&self.final_url), &self.redact),
811            )
812            .field("hops", &self.hops)
813            .field("attempts", &self.attempts)
814            .finish()
815    }
816}
817
818pub struct StreamResponse {
819    pub status: u16,
820    pub headers: Vec<(String, String)>,
821    pub attempts: u32,
822    reader: Box<dyn Read + Send>,
823}
824
825impl StreamResponse {
826    pub fn header(&self, name: &str) -> Option<&str> {
827        header_of(&self.headers, name)
828    }
829    pub fn into_reader(self) -> Box<dyn Read + Send> {
830        self.reader
831    }
832    /// Decode the body as Server-Sent Events.
833    pub fn sse(self) -> SseReader<Box<dyn Read + Send>> {
834        SseReader::new(self.reader)
835    }
836}
837
838pub(crate) fn header_of<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
839    headers
840        .iter()
841        .find(|(k, _)| k.eq_ignore_ascii_case(name))
842        .map(|(_, v)| v.as_str())
843}
844
845#[derive(Clone)]
846pub struct Client {
847    agent: ureq::Agent,
848    config: ClientConfig,
849    /// The proxy in use, for the `NO_PROXY` decision and destination vetting.
850    proxy: Option<ureq::Proxy>,
851    proxy_diagnostic: Option<ProxyDiagnostic>,
852    /// Set when the proxy settings were invalid; every request then fails closed.
853    config_error: Option<String>,
854    /// The invalid setting is a system proxy configuration this client cannot follow:
855    /// requests fail with the typed [`HttpError::SystemProxyUnsupported`] naming this
856    /// element, instead of a generic message.
857    system_proxy_unsupported: Option<String>,
858}
859
860pub(crate) struct Raw {
861    pub(crate) status: u16,
862    pub(crate) headers: Vec<(String, String)>,
863    pub(crate) body: ureq::Body,
864    pub(crate) attempts: u32,
865    /// URL of this response after followed redirects.
866    pub(crate) final_url: String,
867    /// Redirects followed.
868    pub(crate) hops: u32,
869}
870
871impl Client {
872    /// Build a client. Invalid proxy settings do not panic: every request then returns
873    /// [`HttpError::InvalidRequest`]. Use [`Client::try_new`] to see the error up front.
874    pub fn new(config: ClientConfig) -> Self {
875        match Self::try_new(config.clone()) {
876            Ok(client) => client,
877            Err(e) => {
878                let mut direct = config;
879                direct.proxy = ProxyMode::None;
880                let mut client = Self::build(direct, None);
881                if let HttpError::SystemProxyUnsupported { element } = &e {
882                    client.system_proxy_unsupported = Some(element.clone());
883                }
884                client.config_error = Some(e.to_string());
885                client
886            }
887        }
888    }
889
890    /// Build a client, failing when the proxy settings are invalid.
891    pub fn try_new(config: ClientConfig) -> Result<Self, HttpError> {
892        let proxy = config.resolved_proxy()?;
893        Ok(Self::build(config, proxy))
894    }
895
896    fn build(config: ClientConfig, proxy: Option<ResolvedProxy>) -> Self {
897        // Redirects are followed by this crate, hop by hop, so the address policy and
898        // header stripping run on every hop. ureq must never follow one itself.
899        let builder = ureq::Agent::config_builder()
900            .http_status_as_error(false)
901            .max_redirects(0)
902            .user_agent(config.user_agent.clone())
903            .timeout_connect(config.connect_timeout)
904            .timeout_resolve(config.connect_timeout)
905            .tls_config(blocking_tls_config(config.trust));
906        let diagnostic = proxy.as_ref().map(ProxyDiagnostic::of);
907        let (builder, endpoint, proxy) = match proxy {
908            Some(p) => (
909                builder.proxy(Some(p.ureq.clone())),
910                Some((p.host, p.port)),
911                Some(p.ureq),
912            ),
913            None => (builder.proxy(None), None, None),
914        };
915        // The vetting resolver returns only checked addresses, and ureq's TCP connector
916        // connects to exactly those (`ConnectionDetails::addrs`). Destinations that go
917        // through a proxy are vetted by name and literal in `once_hop` instead.
918        let agent = ureq::Agent::with_parts(
919            builder.build(),
920            ureq::unversioned::transport::DefaultConnector::default(),
921            VettingResolver {
922                policy: Arc::new(config.address_policy.clone()),
923                proxy: endpoint,
924                lookup_limit: config.connect_timeout.unwrap_or(Duration::from_secs(10)),
925            },
926        );
927        Self {
928            agent,
929            config,
930            proxy,
931            proxy_diagnostic: diagnostic,
932            config_error: None,
933            system_proxy_unsupported: None,
934        }
935    }
936
937    /// Which certificate verifier this client was built with: `"platform"` (the OS verifier)
938    /// or `"bundled"` (Mozilla roots). It follows [`ClientConfig::trust`].
939    pub fn verifier_kind(&self) -> &'static str {
940        self.config.trust.verifier_kind()
941    }
942
943    /// The proxy this client uses, if any, without credentials. See [`ProxyDiagnostic`].
944    pub fn proxy_diagnostic(&self) -> Option<&ProxyDiagnostic> {
945        self.proxy_diagnostic.as_ref()
946    }
947
948    /// Client that may reach only `hosts` (exact match), only over HTTPS; see
949    /// [`ClientConfig::first_party`].
950    pub fn first_party(hosts: Vec<String>) -> Self {
951        Self::new(ClientConfig::first_party(hosts))
952    }
953
954    pub fn config(&self) -> &ClientConfig {
955        &self.config
956    }
957
958    /// Buffered request; non-2xx becomes [`HttpError::Status`].
959    pub fn send(&self, req: &Request) -> Result<Response, HttpError> {
960        let resp = self.send_any(req)?;
961        if (200..300).contains(&resp.status) {
962            Ok(resp)
963        } else {
964            let values = self.redaction_values(req);
965            Err(scrub_error(
966                status_error(resp.status, &resp.headers, resp.body, resp.attempts),
967                &values,
968            ))
969        }
970    }
971
972    /// Buffered request returning any final status (retryable statuses are still retried).
973    pub fn send_any(&self, req: &Request) -> Result<Response, HttpError> {
974        let timeout = req.timeout.unwrap_or(self.config.request_timeout);
975        let cancel = request_cancel(req);
976        let mut raw = self.execute(req, Some(timeout), cancel)?;
977        let values = self.redaction_values(req);
978        if req.headers_only {
979            // Dropping the body unread closes the connection.
980            return Ok(Response {
981                status: raw.status,
982                headers: raw.headers,
983                body: Vec::new(),
984                attempts: raw.attempts,
985                redact: values,
986            });
987        }
988        let limit = req
989            .max_response_bytes
990            .unwrap_or(self.config.max_response_bytes);
991        let mut body_reader = raw.body.as_reader();
992        let mut reader = CheckedReader {
993            inner: &mut body_reader,
994            cancel,
995        };
996        let body = read_limited(&mut reader, limit).map_err(|e| scrub_error(e, &values))?;
997        Ok(Response {
998            status: raw.status,
999            headers: raw.headers,
1000            body,
1001            attempts: raw.attempts,
1002            redact: values,
1003        })
1004    }
1005
1006    /// Status and headers only, for any status, without reading the body. Redirects, retries,
1007    /// the address policy and cancellation behave as for [`Client::send_any`]; the body is
1008    /// dropped unread, which closes that connection. `final_url` and `hops` report where
1009    /// redirects led.
1010    pub fn send_headers(&self, req: &Request) -> Result<ResponseHead, HttpError> {
1011        let timeout = req.timeout.unwrap_or(self.config.request_timeout);
1012        let raw = self.execute(req, Some(timeout), request_cancel(req))?;
1013        let Raw {
1014            status,
1015            headers,
1016            body,
1017            attempts,
1018            final_url,
1019            hops,
1020            ..
1021        } = raw;
1022        drop(body);
1023        Ok(ResponseHead {
1024            status,
1025            headers,
1026            final_url,
1027            hops,
1028            attempts,
1029            redact: self.redaction_values(req),
1030        })
1031    }
1032
1033    /// Streaming request returning any final status with its headers and streaming body
1034    /// (retryable statuses are still retried per policy). The caller inspects `status` and
1035    /// `headers` (for example `401` plus `www-authenticate`) and bounds the body. Timeout and
1036    /// cancellation behave as for [`Client::stream`].
1037    pub fn stream_any(&self, req: &Request) -> Result<StreamResponse, HttpError> {
1038        let total = req.timeout.or(self.config.stream_timeout);
1039        let raw = self.execute(req, total, request_cancel(req))?;
1040        let values = self.redaction_values(req);
1041        Ok(Self::wrap_stream(raw, req, values))
1042    }
1043
1044    fn wrap_stream(raw: Raw, req: &Request, values: Vec<String>) -> StreamResponse {
1045        let guarded = GuardReader {
1046            inner: Box::new(raw.body.into_reader()),
1047            cancel: req.cancel.clone(),
1048            values,
1049        };
1050        StreamResponse {
1051            status: raw.status,
1052            headers: raw.headers,
1053            attempts: raw.attempts,
1054            reader: Box::new(guarded),
1055        }
1056    }
1057
1058    /// Streaming request; non-2xx becomes [`HttpError::Status`] (use [`Client::stream_any`]
1059    /// to receive such a response with its headers). Retries happen
1060    /// only before the response headers arrive (never mid-stream). Uses
1061    /// `Request::timeout` as the total stream budget when set, otherwise
1062    /// `ClientConfig::stream_timeout`.
1063    pub fn stream(&self, req: &Request) -> Result<StreamResponse, HttpError> {
1064        let cancel = request_cancel(req);
1065        let total = req.timeout.or(self.config.stream_timeout);
1066        let mut raw = self.execute(req, total, cancel)?;
1067        let values = self.redaction_values(req);
1068        if !(200..300).contains(&raw.status) {
1069            let mut body_reader = raw.body.as_reader();
1070            let mut reader = CheckedReader {
1071                inner: &mut body_reader,
1072                cancel,
1073            };
1074            let body = read_limited(&mut reader, MAX_ERROR_BODY_BYTES).unwrap_or_default();
1075            if is_set(cancel) {
1076                return Err(HttpError::Cancelled);
1077            }
1078            return Err(scrub_error(
1079                status_error(raw.status, &raw.headers, body, raw.attempts),
1080                &values,
1081            ));
1082        }
1083        Ok(Self::wrap_stream(raw, req, values))
1084    }
1085
1086    pub fn get_json<T: DeserializeOwned>(&self, url: &str) -> Result<T, HttpError> {
1087        let req = Request::get(url).header("Accept", "application/json");
1088        let values = self.redaction_values(&req);
1089        self.send(&req)?.json().map_err(|e| scrub_error(e, &values))
1090    }
1091
1092    /// Configured secrets plus this request's secret values, longest first.
1093    pub(crate) fn redaction_values(&self, req: &Request) -> Vec<String> {
1094        redaction_values_for(&self.config, req)
1095    }
1096
1097    /// Run the retry loop. `total` bounds each attempt; `cancel` is checked between
1098    /// attempts and during retry sleeps. Errors are scrubbed of secret values.
1099    fn execute(
1100        &self,
1101        req: &Request,
1102        total: Option<Duration>,
1103        cancel: Option<&AtomicBool>,
1104    ) -> Result<Raw, HttpError> {
1105        let values = self.redaction_values(req);
1106        self.execute_inner(req, total, cancel, &values)
1107            .map_err(|e| scrub_error(e, &values))
1108    }
1109
1110    fn execute_inner(
1111        &self,
1112        req: &Request,
1113        total: Option<Duration>,
1114        cancel: Option<&AtomicBool>,
1115        values: &[String],
1116    ) -> Result<Raw, HttpError> {
1117        let policy = req
1118            .retry
1119            .clone()
1120            .unwrap_or_else(|| self.config.retry.clone());
1121        let max = policy.max_attempts.max(1);
1122        let deadline = req.deadline.map(|d| Instant::now() + d);
1123        let mut attempt = 0u32;
1124        loop {
1125            if is_set(cancel) {
1126                return Err(HttpError::Cancelled);
1127            }
1128            let attempt_total = match deadline {
1129                None => total,
1130                Some(dl) => {
1131                    let remaining = dl.saturating_duration_since(Instant::now());
1132                    if remaining.is_zero() {
1133                        return Err(HttpError::timeout(
1134                            format!("request deadline exceeded after {attempt} attempt(s)"),
1135                            Phase::Headers,
1136                        ));
1137                    }
1138                    Some(total.map_or(remaining, |t| t.min(remaining)))
1139                }
1140            };
1141            attempt += 1;
1142            let (reason, delay) = match self.once(req, attempt_total, values) {
1143                Ok(raw) => {
1144                    let retryable = policy.is_retryable_status(raw.status)
1145                        && (req.method.is_idempotent() || req.retry_unsafe_statuses);
1146                    let retry_after =
1147                        header_of(&raw.headers, "retry-after").and_then(parse_retry_after);
1148                    if !retryable || attempt >= max {
1149                        return Ok(Raw {
1150                            attempts: attempt,
1151                            ..raw
1152                        });
1153                    }
1154                    let delay = policy.delay(attempt, retry_after);
1155                    if deadline_passes(deadline, delay) {
1156                        return Ok(Raw {
1157                            attempts: attempt,
1158                            ..raw
1159                        });
1160                    }
1161                    // Drain so the connection is released.
1162                    let mut body = raw.body;
1163                    let mut reader = body.as_reader();
1164                    let mut checked = CheckedReader {
1165                        inner: &mut reader,
1166                        cancel,
1167                    };
1168                    let _ = read_limited(&mut checked, MAX_ERROR_BODY_BYTES);
1169                    if is_set(cancel) {
1170                        return Err(HttpError::Cancelled);
1171                    }
1172                    (format!("HTTP {}", raw.status), delay)
1173                }
1174                Err(err) => {
1175                    let retryable = match &err {
1176                        HttpError::Transport { connect_phase, .. } => {
1177                            *connect_phase || req.method.is_idempotent()
1178                        }
1179                        HttpError::Timeout { .. } => req.method.is_idempotent(),
1180                        _ => false,
1181                    };
1182                    if !retryable || attempt >= max {
1183                        return Err(err);
1184                    }
1185                    let delay = policy.delay(attempt, None);
1186                    if deadline_passes(deadline, delay) {
1187                        return Err(err);
1188                    }
1189                    (err.to_string(), delay)
1190                }
1191            };
1192            if let Some(obs) = &self.config.on_retry {
1193                obs(&RetryEvent {
1194                    url: redact_str(&redact_url(&req.url), values),
1195                    retry: attempt,
1196                    reason: redact_str(&redact_urls_in_text(&reason), values),
1197                    delay,
1198                });
1199            }
1200            // Never sleep past the whole-request deadline: the drain above can have used it up.
1201            // The next loop pass then reports the elapsed deadline instead of a late retry.
1202            let delay = match deadline {
1203                Some(dl) => delay.min(dl.saturating_duration_since(Instant::now())),
1204                None => delay,
1205            };
1206            if !sleep_cancellable(delay, cancel) {
1207                return Err(HttpError::Cancelled);
1208            }
1209        }
1210    }
1211
1212    /// One attempt: the request and, up to `max_redirects`, its redirect hops. Every hop
1213    /// goes through the same address policy.
1214    fn once(
1215        &self,
1216        req: &Request,
1217        total: Option<Duration>,
1218        secrets: &[String],
1219    ) -> Result<Raw, HttpError> {
1220        let started = Instant::now();
1221        let mut method = req.method;
1222        let mut url = req.url.clone();
1223        let mut headers = req.headers.clone();
1224        let mut body = req.body.clone();
1225        let mut hops = 0u32;
1226        loop {
1227            let remaining = match total {
1228                None => None,
1229                Some(t) => {
1230                    let left = t.saturating_sub(started.elapsed());
1231                    if left.is_zero() {
1232                        let phase = if hops > 0 {
1233                            Phase::Redirect
1234                        } else {
1235                            Phase::Headers
1236                        };
1237                        return Err(HttpError::timeout("request", phase));
1238                    }
1239                    Some(left)
1240                }
1241            };
1242            let raw = match self.once_hop(method, &url, &headers, &body, remaining, hops > 0) {
1243                Ok(raw) => raw,
1244                Err(e) if hops > 0 => return Err(e.into_redirect_phase()),
1245                Err(e) => return Err(e),
1246            };
1247            if self.config.max_redirects == 0 || !redirect::is_redirect_status(raw.status) {
1248                return Ok(Raw {
1249                    final_url: url,
1250                    hops,
1251                    ..raw
1252                });
1253            }
1254            let Some(location) = header_of(&raw.headers, "location") else {
1255                return Ok(Raw {
1256                    final_url: url,
1257                    hops,
1258                    ..raw
1259                });
1260            };
1261            let Some(next_method) = redirect::redirect_method(raw.status, method) else {
1262                return Ok(Raw {
1263                    final_url: url,
1264                    hops,
1265                    ..raw
1266                });
1267            };
1268            if hops >= self.config.max_redirects {
1269                if self.config.max_redirects_will_error {
1270                    return Err(HttpError::transport("too many redirects", Phase::Redirect));
1271                }
1272                return Ok(Raw {
1273                    final_url: url,
1274                    hops,
1275                    ..raw
1276                });
1277            }
1278            hops += 1;
1279            let next_url = redirect::resolve_location(&url, location).ok_or_else(|| {
1280                HttpError::InvalidRequest("redirect with an unusable Location".into())
1281            })?;
1282            if let (Some(from), Some(to), Some(host)) = (
1283                redirect::scheme_of(&url),
1284                redirect::scheme_of(&next_url),
1285                redirect::host_of(&next_url),
1286            ) {
1287                // `https_only` first: its error names the rule the caller set, where the
1288                // address policy's downgrade refusal (always on) would hide it.
1289                redirect::require_https(self.config.https_only, &to, &host, true)?;
1290                self.config
1291                    .address_policy
1292                    .check_redirect_scheme(&from, &to, &host)
1293                    .map_err(|d| HttpError::from(d).into_redirect_phase())?;
1294            }
1295            if redirect::origin(&url) != redirect::origin(&next_url) {
1296                redirect::strip_cross_origin(&mut headers, secrets);
1297            }
1298            if next_method != method {
1299                body.clear();
1300                redirect::strip_body_headers(&mut headers);
1301            }
1302            method = next_method;
1303            url = next_url;
1304            // `raw` (and its connection) is dropped here before the next hop.
1305        }
1306    }
1307
1308    fn once_hop(
1309        &self,
1310        method: Method,
1311        url: &str,
1312        headers: &[(String, String)],
1313        body: &[u8],
1314        total: Option<Duration>,
1315        redirect_hop: bool,
1316    ) -> Result<Raw, HttpError> {
1317        let mut builder = ureq::http::Request::builder()
1318            .method(method.as_str())
1319            .uri(url);
1320        for (k, v) in headers {
1321            builder = builder.header(k.as_str(), v.as_str());
1322        }
1323        // ureq frames a `Vec` body, even an empty one, with `Content-Length: 0`, which GET,
1324        // HEAD and DELETE must not carry. `()` has no body and no body headers. POST, PUT and
1325        // PATCH always send their body, so an empty one keeps `Content-Length: 0`.
1326        let bodiless =
1327            body.is_empty() && !matches!(method, Method::Post | Method::Put | Method::Patch);
1328        if bodiless {
1329            let request = builder
1330                .body(())
1331                .map_err(|_| HttpError::InvalidRequest("request could not be built".into()))?;
1332            self.run_hop(request, total, redirect_hop)
1333        } else {
1334            let request = builder
1335                .body(body.to_vec())
1336                .map_err(|_| HttpError::InvalidRequest("request could not be built".into()))?;
1337            self.run_hop(request, total, redirect_hop)
1338        }
1339    }
1340
1341    /// One hop with a built request: the policy checks, the proxy scope and the ureq call.
1342    fn run_hop<S: ureq::AsSendBody>(
1343        &self,
1344        request: ureq::http::Request<S>,
1345        total: Option<Duration>,
1346        redirect_hop: bool,
1347    ) -> Result<Raw, HttpError> {
1348        if let Some(element) = &self.system_proxy_unsupported {
1349            return Err(HttpError::SystemProxyUnsupported {
1350                element: element.clone(),
1351            });
1352        }
1353        if let Some(reason) = &self.config_error {
1354            return Err(HttpError::InvalidRequest(reason.clone()));
1355        }
1356        // Every hop, not only the first, so a path that skips the redirect loop still
1357        // cannot reach plain http.
1358        redirect::require_https(
1359            self.config.https_only,
1360            request.uri().scheme_str().unwrap_or(""),
1361            request.uri().host().unwrap_or(""),
1362            redirect_hop,
1363        )?;
1364        let via_proxy = self.vet_proxied_destination(request.uri())?;
1365        let _scope = ViaProxy::enter(via_proxy);
1366        let configured = self
1367            .agent
1368            .configure_request(request)
1369            .timeout_global(total)
1370            .timeout_recv_response(self.config.response_timeout)
1371            .timeout_recv_body(self.config.recv_body_timeout)
1372            .build();
1373        let response = self.agent.run(configured).map_err(map_ureq_error)?;
1374        let status = response.status().as_u16();
1375        let headers = response
1376            .headers()
1377            .iter()
1378            .map(|(k, v)| {
1379                (
1380                    k.as_str().to_ascii_lowercase(),
1381                    String::from_utf8_lossy(v.as_bytes()).into_owned(),
1382                )
1383            })
1384            .collect();
1385        Ok(Raw {
1386            status,
1387            headers,
1388            body: response.into_body(),
1389            attempts: 1,
1390            final_url: String::new(),
1391            hops: 0,
1392        })
1393    }
1394}
1395
1396impl Client {
1397    /// Through a proxy the destination's address cannot be checked, so vet its name and
1398    /// literal form first. Hosts that bypass the proxy (`NO_PROXY`) are vetted by the
1399    /// resolver like direct connections.
1400    /// Returns whether the request goes through the proxy.
1401    fn vet_proxied_destination(&self, uri: &ureq::http::Uri) -> Result<bool, HttpError> {
1402        let Some(proxy) = &self.proxy else {
1403            return Ok(false);
1404        };
1405        if proxy.is_no_proxy(uri) {
1406            return Ok(false);
1407        }
1408        let policy = &self.config.address_policy;
1409        let scheme = uri.scheme_str().unwrap_or("").to_ascii_lowercase();
1410        let raw_host = uri.host().unwrap_or("");
1411        policy.admit(&scheme, raw_host)?;
1412        policy.vet_via_proxy(raw_host)?;
1413        Ok(true)
1414    }
1415}
1416
1417fn sorted_values(values: &[String]) -> Vec<String> {
1418    let mut v: Vec<String> = values.iter().filter(|v| !v.is_empty()).cloned().collect();
1419    v.sort_by_key(|s| std::cmp::Reverse(s.len()));
1420    v
1421}
1422
1423/// Header list safe to print: credential-named headers (including cookies) are replaced,
1424/// URL-bearing headers lose their query and userinfo, and any `scheme://` text in other
1425/// values is redacted the same way.
1426pub(crate) fn redact_headers_for_debug(headers: &[(String, String)]) -> Vec<(String, String)> {
1427    headers
1428        .iter()
1429        .map(|(k, v)| {
1430            let shown = if redirect::is_secret_header_name(k) {
1431                REDACTED.to_string()
1432            } else if matches!(
1433                k.to_ascii_lowercase().as_str(),
1434                "location" | "content-location" | "refresh" | "link" | "referer"
1435            ) {
1436                redact_urls_in_text(&redact_url(v))
1437            } else {
1438                redact_urls_in_text(v)
1439            };
1440            (k.clone(), shown)
1441        })
1442        .collect()
1443}
1444
1445/// [`redact_headers_for_debug`] plus every recorded secret value scrubbed from what is left.
1446pub(crate) fn redact_headers_with(
1447    headers: &[(String, String)],
1448    values: &[String],
1449) -> Vec<(String, String)> {
1450    redact_headers_for_debug(headers)
1451        .into_iter()
1452        .map(|(k, v)| (k, redact_str(&v, values)))
1453        .collect()
1454}
1455
1456/// Configured secrets, the request's secret values, and the query and userinfo of its URL,
1457/// longest first. Every error text and retry event is scrubbed of these.
1458pub(crate) fn redaction_values_for(config: &ClientConfig, req: &Request) -> Vec<String> {
1459    let mut values: Vec<String> = config
1460        .redact_values
1461        .iter()
1462        .chain(req.secret_values.iter())
1463        .filter(|v| !v.is_empty())
1464        .cloned()
1465        .chain(url_secret_parts(&req.url))
1466        .collect();
1467    values.sort_by_key(|v| std::cmp::Reverse(v.len()));
1468    values.dedup();
1469    values
1470}
1471
1472fn request_cancel(req: &Request) -> Option<&AtomicBool> {
1473    req.cancel.as_ref().map(CancelToken::as_atomic)
1474}
1475
1476fn deadline_passes(deadline: Option<Instant>, delay: Duration) -> bool {
1477    deadline.is_some_and(|dl| Instant::now() + delay >= dl)
1478}
1479
1480/// Reader for stream bodies: honours cancellation per read and scrubs secret
1481/// values from I/O error text.
1482struct GuardReader {
1483    inner: Box<dyn Read + Send>,
1484    cancel: Option<CancelToken>,
1485    values: Vec<String>,
1486}
1487
1488impl Read for GuardReader {
1489    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
1490        if self.cancel.as_ref().is_some_and(|c| c.is_cancelled()) {
1491            return Err(crate::cancel::cancelled_io());
1492        }
1493        self.inner.read(buf).map_err(|e| {
1494            if is_cancelled_io(&e) {
1495                return e;
1496            }
1497            let text = e.to_string();
1498            let scrubbed = redact_str(&redact_urls_in_text(&text), &self.values);
1499            if scrubbed == text {
1500                e
1501            } else {
1502                io::Error::new(e.kind(), scrubbed)
1503            }
1504        })
1505    }
1506}
1507
1508pub(crate) fn status_error(
1509    status: u16,
1510    headers: &[(String, String)],
1511    body: Vec<u8>,
1512    attempts: u32,
1513) -> HttpError {
1514    let mut body = body;
1515    body.truncate(MAX_ERROR_BODY_BYTES);
1516    HttpError::Status {
1517        status,
1518        body: String::from_utf8_lossy(&body).into_owned(),
1519        retry_after: header_of(headers, "retry-after").and_then(parse_retry_after),
1520        attempts,
1521    }
1522}
1523
1524/// Replace every occurrence of each secret value with `[redacted]`.
1525pub(crate) fn redact_str(text: &str, values: &[String]) -> String {
1526    let mut out = text.to_string();
1527    for value in values {
1528        if !value.is_empty() && out.contains(value.as_str()) {
1529            out = out.replace(value.as_str(), REDACTED);
1530        }
1531    }
1532    out
1533}
1534
1535/// Scrub secret values from the text of any error variant.
1536pub(crate) fn scrub_error(err: HttpError, values: &[String]) -> HttpError {
1537    // Free-text messages lose secret values and any URL query or userinfo. A `Status`
1538    // body is the server's response and only loses secret values.
1539    let r = |s: String| redact_str(&redact_urls_in_text(&s), values);
1540    match err {
1541        HttpError::InvalidRequest(m) => HttpError::InvalidRequest(r(m)),
1542        HttpError::Transport {
1543            message,
1544            connect_phase,
1545            phase,
1546        } => HttpError::Transport {
1547            message: r(message),
1548            connect_phase,
1549            phase,
1550        },
1551        HttpError::Timeout { message, phase } => HttpError::Timeout {
1552            message: r(message),
1553            phase,
1554        },
1555        HttpError::Status {
1556            status,
1557            body,
1558            retry_after,
1559            attempts,
1560        } => HttpError::Status {
1561            status,
1562            body: redact_str(&body, values),
1563            retry_after,
1564            attempts,
1565        },
1566        HttpError::BodyTooLarge { limit } => HttpError::BodyTooLarge { limit },
1567        HttpError::Json(m) => HttpError::Json(r(m)),
1568        HttpError::Stream(m) => HttpError::Stream(r(m)),
1569        HttpError::Cancelled => HttpError::Cancelled,
1570        HttpError::SystemProxyUnsupported { element } => HttpError::SystemProxyUnsupported {
1571            element: element.clone(),
1572        },
1573        HttpError::HttpsRequired {
1574            host,
1575            scheme,
1576            redirect,
1577        } => HttpError::HttpsRequired {
1578            host: r(host),
1579            scheme,
1580            redirect,
1581        },
1582        HttpError::AddressDenied {
1583            host,
1584            addr,
1585            class,
1586            reason,
1587        } => HttpError::AddressDenied {
1588            host: r(host),
1589            addr,
1590            class,
1591            reason: r(reason),
1592        },
1593    }
1594}
1595
1596pub(crate) fn read_limited(reader: &mut dyn Read, limit: usize) -> Result<Vec<u8>, HttpError> {
1597    let mut out = Vec::new();
1598    reader
1599        .take(limit as u64 + 1)
1600        .read_to_end(&mut out)
1601        .map_err(|e| {
1602            if is_cancelled_io(&e) {
1603                return HttpError::Cancelled;
1604            }
1605            match e.kind() {
1606                std::io::ErrorKind::TimedOut => HttpError::timeout(e.to_string(), Phase::Body),
1607                // Body readers wrap ureq errors (including phase timeouts) in
1608                // io::ErrorKind::Other. Unwrap them before applying our taxonomy.
1609                _ => map_ureq_error_in(e.into(), Phase::Body),
1610            }
1611        })?;
1612    if out.len() > limit {
1613        return Err(HttpError::BodyTooLarge { limit });
1614    }
1615    Ok(out)
1616}
1617
1618/// Map a ureq error raised while connecting or waiting for headers.
1619pub(crate) fn map_ureq_error(err: ureq::Error) -> HttpError {
1620    map_ureq_error_in(err, Phase::Headers)
1621}
1622
1623/// Phase of a ureq timeout, falling back to `default` for the global and per-call budgets.
1624fn timeout_phase(t: &ureq::Timeout, default: Phase) -> Phase {
1625    use ureq::Timeout as T;
1626    match t {
1627        T::Resolve | T::Connect => Phase::Connect,
1628        T::SendRequest | T::SendBody | T::RecvResponse => Phase::Headers,
1629        T::RecvBody => Phase::Body,
1630        _ => default,
1631    }
1632}
1633
1634/// Map a ureq error. `default` is the phase for failures that name none (socket errors,
1635/// global timeouts): `Headers` before the response arrives, `Body` while reading it.
1636pub(crate) fn map_ureq_error_in(err: ureq::Error, default: Phase) -> HttpError {
1637    use ureq::Error as E;
1638    // The vetting resolver refused the destination.
1639    if let E::Io(io) = &err {
1640        if let Some(inner) = io.get_ref() {
1641            if let Some(denial) = denial_in_chain(inner) {
1642                return denial.into();
1643            }
1644        }
1645    }
1646    match err {
1647        E::Timeout(t) => HttpError::timeout(format!("{t:?}"), timeout_phase(&t, default)),
1648        E::TooManyRedirects => HttpError::transport("too many redirects", Phase::Redirect),
1649        E::HostNotFound => HttpError::transport("host not found", Phase::Connect),
1650        E::ConnectionFailed => HttpError::transport("connection failed", Phase::Connect),
1651        E::Io(io)
1652            if matches!(
1653                io.kind(),
1654                std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound
1655            ) =>
1656        {
1657            HttpError::transport(io.to_string(), Phase::Connect)
1658        }
1659        E::Io(io) => HttpError::transport(io.to_string(), default),
1660        E::BadUri(m) => HttpError::InvalidRequest(m),
1661        E::Http(e) => HttpError::InvalidRequest(e.to_string()),
1662        other => HttpError::transport(other.to_string(), default),
1663    }
1664}
1665
1666#[cfg(test)]
1667mod tests {
1668    use super::*;
1669
1670    #[test]
1671    fn legacy_ureq_parity_sets_only_the_ureq_fields() {
1672        let cfg = ClientConfig::legacy_ureq_parity("heardright/1.0");
1673        let base = ClientConfig::default();
1674        assert_eq!(cfg.user_agent, "heardright/1.0");
1675        assert!(matches!(cfg.proxy, ProxyMode::Environment));
1676        assert_eq!(cfg.trust, TrustRoots::Bundled);
1677        assert_eq!(cfg.max_redirects, 10);
1678        assert_eq!(cfg.retry, RetryPolicy::none());
1679        // Private-address resolution stays off: the policy is the public-web default.
1680        assert_eq!(
1681            format!("{:?}", cfg.address_policy),
1682            format!("{:?}", base.address_policy)
1683        );
1684        // Every other field keeps its default.
1685        assert_eq!(cfg.connect_timeout, base.connect_timeout);
1686        assert_eq!(cfg.request_timeout, base.request_timeout);
1687        assert_eq!(cfg.response_timeout, base.response_timeout);
1688        assert_eq!(cfg.recv_body_timeout, base.recv_body_timeout);
1689        assert_eq!(cfg.stream_timeout, base.stream_timeout);
1690        assert_eq!(cfg.max_redirects_will_error, base.max_redirects_will_error);
1691        assert_eq!(cfg.max_response_bytes, base.max_response_bytes);
1692        assert!(cfg.on_retry.is_none());
1693        assert!(cfg.redact_values.is_empty());
1694        assert_eq!(cfg.proxy_bypass_first_party, base.proxy_bypass_first_party);
1695        assert_eq!(cfg.https_only, base.https_only);
1696    }
1697
1698    #[test]
1699    fn put_and_delete_set_their_methods() {
1700        assert_eq!(Request::put("http://x/").method, Method::Put);
1701        assert_eq!(Request::delete("http://x/").method, Method::Delete);
1702        assert!(Request::put("http://x/").body.is_empty());
1703    }
1704
1705    #[test]
1706    fn set_header_replaces_case_insensitively_in_place() {
1707        let req = Request::post("http://x/")
1708            .header("X-A", "1")
1709            .header("Accept", "text/plain")
1710            .header("x-a", "2")
1711            .set_header("X-A", "3");
1712        assert_eq!(
1713            req.headers,
1714            vec![
1715                ("X-A".to_string(), "3".to_string()),
1716                ("Accept".to_string(), "text/plain".to_string()),
1717            ]
1718        );
1719        let req = req.set_header("content-type", "text/csv");
1720        assert_eq!(
1721            req.headers.last(),
1722            Some(&("content-type".to_string(), "text/csv".to_string()))
1723        );
1724    }
1725
1726    #[test]
1727    fn redaction_replaces_values_and_longest_first_scrub() {
1728        let values = vec!["secret-token-123".to_string(), "secret".to_string()];
1729        assert_eq!(
1730            redact_str("bad secret-token-123 and secret", &values),
1731            "bad [redacted] and [redacted]"
1732        );
1733    }
1734
1735    #[test]
1736    fn secret_header_hidden_in_debug_but_sent() {
1737        let req = Request::get("http://x/").secret_header("X-Key", "hunter2-value");
1738        assert!(req.headers.iter().any(|(_, v)| v == "hunter2-value"));
1739        let debug = format!("{req:?}");
1740        assert!(!debug.contains("hunter2-value"), "{debug}");
1741        assert!(debug.contains(REDACTED));
1742    }
1743
1744    #[test]
1745    fn scrub_error_covers_text_variants() {
1746        let values = vec!["tok-9".to_string()];
1747        match scrub_error(
1748            HttpError::Status {
1749                status: 401,
1750                body: "invalid tok-9".into(),
1751                retry_after: None,
1752                attempts: 1,
1753            },
1754            &values,
1755        ) {
1756            HttpError::Status { body, .. } => assert_eq!(body, "invalid [redacted]"),
1757            other => panic!("unexpected {other:?}"),
1758        }
1759    }
1760
1761    fn env_of(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<String> {
1762        move |name| {
1763            pairs
1764                .iter()
1765                .find(|(k, _)| *k == name)
1766                .map(|(_, v)| v.to_string())
1767        }
1768    }
1769
1770    fn uri(s: &str) -> ureq::http::Uri {
1771        s.parse().unwrap()
1772    }
1773
1774    #[test]
1775    fn environment_proxy_uses_the_first_set_variable_and_skips_empty_ones() {
1776        let env = env_of(&[
1777            ("ALL_PROXY", ""),
1778            ("HTTPS_PROXY", "http://secure.proxy:3128"),
1779            ("HTTP_PROXY", "http://plain.proxy:8080"),
1780            ("NO_PROXY", "a.test, .b.test ,"),
1781        ]);
1782        let p = proxy_from_lookup(&env).unwrap().unwrap();
1783        assert_eq!(p.host, "secure.proxy");
1784        assert_eq!(p.port, 3128);
1785        assert_eq!(
1786            p.no_proxy,
1787            vec!["a.test".to_string(), ".b.test".to_string()]
1788        );
1789        assert!(p.ureq.is_no_proxy(&uri("http://a.test/")));
1790        assert!(p.ureq.is_no_proxy(&uri("http://x.b.test/")));
1791        assert!(!p.ureq.is_no_proxy(&uri("http://c.test/")));
1792    }
1793
1794    #[test]
1795    fn environment_proxy_order_and_case() {
1796        let env = env_of(&[
1797            ("http_proxy", "http://lower.proxy:1"),
1798            ("ALL_PROXY", "http://all.proxy:2"),
1799        ]);
1800        assert_eq!(proxy_from_lookup(&env).unwrap().unwrap().host, "all.proxy");
1801        let env = env_of(&[("http_proxy", "http://lower.proxy:1")]);
1802        assert_eq!(
1803            proxy_from_lookup(&env).unwrap().unwrap().host,
1804            "lower.proxy"
1805        );
1806        let env = env_of(&[("no_proxy", "z.test"), ("https_proxy", "http://p.proxy:9")]);
1807        assert_eq!(
1808            proxy_from_lookup(&env).unwrap().unwrap().no_proxy,
1809            vec!["z.test".to_string()]
1810        );
1811    }
1812
1813    #[test]
1814    fn environment_without_a_usable_proxy_is_none() {
1815        assert!(proxy_from_lookup(&env_of(&[])).unwrap().is_none());
1816        // An unsupported scheme is skipped in favour of the next variable.
1817        let env = env_of(&[
1818            ("ALL_PROXY", "ftp://nope.proxy:21"),
1819            ("HTTP_PROXY", "http://ok.proxy:80"),
1820        ]);
1821        assert_eq!(proxy_from_lookup(&env).unwrap().unwrap().host, "ok.proxy");
1822        assert!(
1823            proxy_from_lookup(&env_of(&[("ALL_PROXY", "ftp://nope.proxy:21")]))
1824                .unwrap()
1825                .is_none()
1826        );
1827    }
1828
1829    #[test]
1830    fn explicit_proxy_lets_the_proxy_resolve_and_keeps_credentials() {
1831        let p = build_proxy("http://user:pass@Proxy.Corp:3128", &[]).unwrap();
1832        assert_eq!(p.host, "proxy.corp");
1833        assert_eq!(p.port, 3128);
1834        assert!(!p.ureq.resolve_target());
1835        assert_eq!(p.ureq.username(), Some("user"));
1836        assert_eq!(p.ureq.password(), Some("pass"));
1837        assert!(build_proxy("https://10.0.0.5:3128", &[]).is_ok());
1838        assert!(build_proxy("socks5://127.0.0.1:1080", &[]).is_err());
1839        assert!(build_proxy("ftp://x:1", &[]).is_err());
1840        assert!(build_proxy("not a url", &[]).is_err());
1841    }
1842
1843    #[test]
1844    fn proxy_mode_debug_never_shows_credentials() {
1845        let shown = format!(
1846            "{:?}",
1847            ProxyMode::Explicit("http://user:pw-9@proxy:3128/x?k=v".into())
1848        );
1849        assert!(
1850            !shown.contains("pw-9") && !shown.contains("user:") && !shown.contains("k=v"),
1851            "{shown}"
1852        );
1853        assert_eq!(format!("{:?}", ProxyMode::None), "None");
1854        assert_eq!(format!("{:?}", ProxyMode::Environment), "Environment");
1855        assert_eq!(format!("{:?}", ProxyMode::System), "System");
1856    }
1857
1858    #[test]
1859    fn system_settings_resolve_like_an_environment_proxy() {
1860        use crate::system_proxy::SystemProxySettings;
1861        let proxy = proxy_from_system(&SystemProxySettings {
1862            proxy: Some("http=web.corp:8080;https=web.corp:8080".into()),
1863            bypass: Some("*.corp.example;intranet".into()),
1864            ..SystemProxySettings::default()
1865        })
1866        .unwrap()
1867        .expect("a static system proxy resolves");
1868        assert_eq!((proxy.host.as_str(), proxy.port), ("web.corp", 8080));
1869        assert_eq!(
1870            proxy.no_proxy,
1871            vec![".corp.example".to_string(), "intranet".to_string()]
1872        );
1873        assert!(ProxyDiagnostic::of(&proxy).from_environment);
1874        // Nothing configured: the caller falls back to the environment.
1875        assert!(proxy_from_system(&SystemProxySettings::default())
1876            .unwrap()
1877            .is_none());
1878    }
1879
1880    #[test]
1881    fn system_settings_this_client_cannot_follow_exactly_fail_closed() {
1882        use crate::system_proxy::SystemProxySettings;
1883        let static_proxy = |proxy: &str, bypass: Option<&str>| SystemProxySettings {
1884            proxy: Some(proxy.into()),
1885            bypass: bypass.map(str::to_string),
1886            ..SystemProxySettings::default()
1887        };
1888        // Nothing is approximated with one proxy: plain HTTP is never sent to the HTTPS
1889        // endpoint, nothing goes around the proxy, and no bypass entry is dropped while the
1890        // proxy stays in use (fifth read, finding 11).
1891        let rows = [
1892            (
1893                static_proxy("http=web.corp:8080;https=secure.corp:8443", None),
1894                "HTTP and HTTPS are given different proxy routes",
1895            ),
1896            (
1897                static_proxy("https=secure.corp:8443", None),
1898                "HTTP and HTTPS are given different proxy routes",
1899            ),
1900            (
1901                static_proxy("http=web.corp:8080", None),
1902                "HTTP and HTTPS are given different proxy routes",
1903            ),
1904            (
1905                static_proxy("web.corp:8080", Some("http://private.example")),
1906                "the bypass entry `http://private.example` cannot be followed exactly",
1907            ),
1908            (
1909                static_proxy("web.corp:8080", Some("*.corp.example;https://only.example")),
1910                "the bypass entry `https://only.example` cannot be followed exactly",
1911            ),
1912            (
1913                static_proxy("web.corp:8080", Some("<local>")),
1914                "the bypass entry `<local>` cannot be followed exactly",
1915            ),
1916            (
1917                static_proxy("web.corp:8080", Some("10.*")),
1918                "the bypass entry `10.*` cannot be followed exactly",
1919            ),
1920            (
1921                SystemProxySettings {
1922                    auto_config: true,
1923                    ..SystemProxySettings::default()
1924                },
1925                "a proxy auto-config script is set",
1926            ),
1927            (
1928                SystemProxySettings {
1929                    auto_detect: true,
1930                    ..SystemProxySettings::default()
1931                },
1932                "\"Automatically detect settings\" (proxy auto-detect) is on",
1933            ),
1934        ];
1935        for (settings, expected) in rows {
1936            let error = proxy_from_system(&settings)
1937                .err()
1938                .unwrap_or_else(|| panic!("{expected}: must be refused"));
1939            assert!(
1940                matches!(&error, HttpError::SystemProxyUnsupported { element } if element == expected),
1941                "{expected}: {error}"
1942            );
1943            assert_eq!(
1944                error.to_string(),
1945                format!(
1946                    "the Windows proxy settings cannot be followed exactly by this version \
1947                     ({expected}); nothing was sent"
1948                )
1949            );
1950            assert!(matches!(error.class(), crate::error::ErrorClass::Invalid));
1951        }
1952    }
1953
1954    #[test]
1955    fn first_party_bypass_drops_the_proxy_only_when_asked() {
1956        let base = ClientConfig::first_party(vec!["api.example.com".into()])
1957            .proxy(ProxyMode::Explicit("http://proxy:3128".into()));
1958        assert!(base.resolved_proxy().unwrap().is_some());
1959        assert!(base
1960            .clone()
1961            .proxy_bypass_first_party(true)
1962            .resolved_proxy()
1963            .unwrap()
1964            .is_none());
1965        // The flag means nothing for a public-web client.
1966        let public = ClientConfig::default()
1967            .proxy(ProxyMode::Explicit("http://proxy:3128".into()))
1968            .proxy_bypass_first_party(true);
1969        assert!(public.resolved_proxy().unwrap().is_some());
1970        assert!(ClientConfig::default().resolved_proxy().unwrap().is_none());
1971    }
1972
1973    #[test]
1974    fn invalid_explicit_proxy_fails_closed() {
1975        let config = ClientConfig::default().proxy(ProxyMode::Explicit("ftp://nope:1".into()));
1976        assert!(matches!(
1977            Client::try_new(config.clone()),
1978            Err(HttpError::InvalidRequest(_))
1979        ));
1980        let err = Client::new(config)
1981            .send(&Request::get("http://127.0.0.1:9/"))
1982            .unwrap_err();
1983        assert!(matches!(err, HttpError::InvalidRequest(_)), "{err:?}");
1984    }
1985}