Skip to main content

proxy_watch/
error.rs

1//! Crate-wide error type.
2
3use thiserror::Error;
4
5use crate::util::{redact_offending_token, redact_userinfo};
6use crate::{RejectedValue, Scheme};
7
8/// Errors from reading OS proxy configuration, and from routing a URL through what was read.
9///
10/// `#[non_exhaustive]`. No `#[from] io::Error` — I/O is always wrapped with context.
11/// Hand-written [`Debug`]: [`PacFetchRequired`](Error::PacFetchRequired)'s [`url::Url`]
12/// would print credentials; other payloads are masked at construction or safe.
13#[derive(Error)]
14#[non_exhaustive]
15pub enum Error {
16    /// A Windows `ProxyServer` style specification could not be parsed.
17    #[error("invalid proxy server specification {input:?}: {reason}")]
18    InvalidProxyServer {
19        /// Offending token, masked — and withheld outright when it may be a credential.
20        input: String,
21        /// Why parsing failed.
22        reason: String,
23    },
24
25    /// A single bypass / `no_proxy` / `ProxyOverride` entry could not be parsed.
26    #[error("invalid bypass pattern {input:?}: {reason}")]
27    InvalidBypassPattern {
28        /// Offending entry, masked like [`InvalidProxyServer`](Error::InvalidProxyServer)'s.
29        input: String,
30        /// Why parsing failed.
31        reason: String,
32    },
33
34    /// An `AutoConfigURL`-style setting read from the OS is malformed.
35    ///
36    /// Not the environment: `*_proxy` values go through
37    /// [`ProxyEndpoint::parse`](crate::ProxyEndpoint::parse), which answers with
38    /// [`InvalidProxyServer`](Error::InvalidProxyServer) instead.
39    #[error("invalid proxy URL {input:?}: {source}")]
40    InvalidProxyUrl {
41        /// Offending input, masked like [`InvalidProxyServer`](Error::InvalidProxyServer)'s.
42        input: String,
43        /// Underlying parse error.
44        #[source]
45        source: url::ParseError,
46    },
47
48    /// The proxy URL carried a scheme this crate does not understand.
49    ///
50    /// Recognised schemes are listed on [`ProxyScheme`](crate::ProxyScheme).
51    #[error("unsupported proxy scheme {0:?}")]
52    UnsupportedProxyScheme(String),
53
54    /// `http_proxy` under CGI: a non-empty `REQUEST_METHOD` ⇒ forged `Proxy:` header
55    /// (httpoxy, CVE-2016-5385). Refused like Go's httpproxy; not applied to
56    /// `https_proxy`/`no_proxy`, whose names no request header can produce.
57    ///
58    /// KDE's `ProxyType = 4` raises the same error on a wider rule, because there the
59    /// *file* names the variable each slot reads: any name a request header could have set
60    /// — RFC 3875 §4.1.18's `HTTP_` prefix — is refused whichever slot named it, including
61    /// the one holding the bypass list. `variable` is that name as written.
62    #[error("refusing to use {variable} value in CGI environment")]
63    CgiHttpProxy {
64        /// Variable name as set in the environment.
65        variable: String,
66    },
67
68    /// An I/O failure, annotated with the operation that caused it.
69    ///
70    /// OS error codes are carried as [`std::io::Error`], built by `from_raw_os_error`.
71    #[error("{context}")]
72    Io {
73        /// Operation in progress when the failure occurred.
74        context: String,
75        /// Underlying I/O error.
76        #[source]
77        source: std::io::Error,
78    },
79
80    /// Linux sandbox: desktop settings unreadable (Flatpak without dconf → keyfile
81    /// defaults). Portal fallback when possible; else this error — not a false Direct.
82    #[error("running inside a {sandbox} sandbox: {reason}")]
83    Sandboxed {
84        /// Detected sandbox name.
85        sandbox: String,
86        /// Why no source could be read.
87        reason: String,
88    },
89
90    /// No store to read. Off Windows/macOS/Linux there is no backend at all; on Linux it is
91    /// a reading and not a property of the build — *neither* desktop store answered, which
92    /// both desktop features being off guarantees and which a build with one of them on also
93    /// reaches whenever the schema and `kioslaverc` are both missing from the machine.
94    ///
95    /// Not the answer inside a sandbox: that route is chosen before either store is
96    /// consulted and reports [`Sandboxed`](Self::Sandboxed), including when the feature that
97    /// would carry the portal fallback is off.
98    #[error("watching the system proxy configuration is not supported on this platform")]
99    Unsupported,
100
101    /// `resolve()` needs PAC/WPAD evaluation (`resolve_with_pac` / `pac` feature).
102    /// Deliberately an error, not silent Direct.
103    ///
104    /// `resolve_with_pac` answers with it too, for
105    /// [`WpadAutoDetect`](crate::ProxyMode::WpadAutoDetect) handed no script body:
106    /// discovery is a non-goal, so the fix there is to supply the script rather than to
107    /// change entry point. `mode` is `"wpad"` either way and does not tell the two apart.
108    #[error("resolving this URL requires evaluating a proxy auto-config script ({mode})")]
109    PacNotSupported {
110        /// Auto-config mode in effect.
111        mode: &'static str,
112    },
113
114    /// The system configured a proxy for this scheme, the value could not be used, and no
115    /// other entry covers it.
116    ///
117    /// Returning `ProxyStep::Direct` here would assert something
118    /// the platform does not do: KDE expands a `[$e]` value from the session environment and
119    /// proxies the request, and this crate deliberately does not expand it. Only a scheme
120    /// with no catch-all reaches this — a live `socksProxy` covers the drop first, exactly as
121    /// Chromium's `fallback_proxies` does. Where this reports, Chromium goes direct instead:
122    /// its `ProxyList::AddProxyChain` drops a malformed entry with "Silently discard
123    /// malformed inputs", leaving `MapUrlSchemeToProxyList` to answer `nullptr`.
124    // `{scheme}` names the slot, so the second half says "this request" rather than repeating
125    // it: for a `ws`/`wss` URL the slot is `Https` or `Socks`, and "nothing else covers https"
126    // would describe a request nobody made.
127    #[error("the configured {scheme} proxy could not be used and nothing else covers this request")]
128    ProxyEntryUnusable {
129        /// Which requests lost an answer — read off the drop's own attribution, not off the
130        /// slot it was found under. In every mode this crate builds the two are the same
131        /// value, because each backend files its record against the slot it dropped, so this
132        /// is the slot, and the slot is what decides coverage rather than the request's own
133        /// scheme: a lost catch-all reports [`Scheme::All`] whatever was asked for, and a
134        /// `ws`/`wss` request reports whichever of the chain it lost. A caller who hands
135        /// [`ProxyMode::manual`](crate::ProxyMode::manual) a record attributed to one scheme
136        /// under the key of another gets the attribution back rather than the key. Nothing
137        /// normalises the pair: the record is the caller's, and the same value is what
138        /// [`ProxyMode::rejected`](crate::ProxyMode::rejected) hands back.
139        scheme: Scheme,
140        /// First drop naming that scheme; masked at construction. The rest stay reachable
141        /// through [`ProxyMode::rejected`](crate::ProxyMode::rejected) *on the mode this
142        /// error was resolved against*, which is the caller's own `config.effective`
143        /// wherever the caller supplied it. Where a resolver builds a mode of its own it is
144        /// not: `WinHttpPacResolver::resolve_config` answers a WPAD miss by re-reading the
145        /// registry into a `Manual` and resolving against that, and the mode goes out of
146        /// scope with the call while the caller still holds `WpadAutoDetect`, which has no
147        /// list. This field is what survives that.
148        rejected: RejectedValue,
149    },
150
151    /// `resolve_with_pac` + [`ProxyMode::Pac`](crate::ProxyMode::Pac) without script body.
152    /// Fetch is caller's job; `url` stored verbatim, masked only in `{}`/`{:?}`.
153    #[error(
154        "the PAC script at {} must be fetched by the caller before it can be evaluated",
155        redact_userinfo(.url.as_str())
156    )]
157    PacFetchRequired {
158        /// Script URL.
159        url: url::Url,
160    },
161
162    /// PAC run failed (syntax, throw, limits). `reason` masked/sanitized at construction.
163    #[error("PAC evaluation failed: {reason}")]
164    PacEvaluation {
165        /// Engine message (sanitized).
166        reason: String,
167    },
168
169    /// Evaluating a PAC script overran `PacPolicy::timeout`.
170    #[error("PAC evaluation exceeded its {timeout:?} budget")]
171    PacTimeout {
172        /// Budget that was exceeded.
173        timeout: std::time::Duration,
174    },
175
176    /// `FindProxyForURL` returned nothing usable (malformed candidates skipped first).
177    /// `result` masked at construction.
178    #[error("PAC script returned no usable proxy candidate: {result:?}")]
179    PacInvalidResult {
180        /// Raw return string (masked).
181        result: String,
182    },
183
184    /// `pac` on but no engine (`pac-boa` or a custom `pac::PacEvaluator`).
185    ///
186    /// Not linked: the item exists only with the `pac` feature on, and this variant does not.
187    #[error("no PAC JavaScript engine is enabled; build with the `pac-boa` feature")]
188    PacEngineUnavailable,
189}
190
191impl Error {
192    // Mask `user:password` in `input` once so Display/Debug stay safe downstream — with
193    // or without the `@` that would make it recognisable, since `input` is by definition
194    // a token that failed to parse.
195    pub(crate) fn proxy_server(input: impl AsRef<str>, reason: impl Into<String>) -> Self {
196        Error::InvalidProxyServer {
197            input: redact_offending_token(input.as_ref()),
198            reason: reason.into(),
199        }
200    }
201
202    // Same masking as [`Self::proxy_server`] (malformed bypasses can still look like userinfo).
203    pub(crate) fn bypass(input: impl AsRef<str>, reason: impl Into<String>) -> Self {
204        Error::InvalidBypassPattern {
205            input: redact_offending_token(input.as_ref()),
206            reason: reason.into(),
207        }
208    }
209
210    // Mask raw text that failed `Url::parse` (cannot re-parse to strip userinfo).
211    #[cfg_attr(
212        not(any(target_os = "linux", target_os = "macos", windows)),
213        allow(dead_code)
214    )]
215    pub(crate) fn invalid_proxy_url(input: impl AsRef<str>, source: url::ParseError) -> Self {
216        Error::InvalidProxyUrl {
217            input: redact_offending_token(input.as_ref()),
218            source,
219        }
220    }
221
222    // No `allow(dead_code)` for a target with no backend, unlike its neighbour above:
223    // `watch::ThreadGuard`'s `Drop` builds this error when a backend thread panics, and a
224    // `Drop` impl is live everywhere.
225    pub(crate) fn io(context: impl Into<String>, source: std::io::Error) -> Self {
226        Error::Io {
227            context: context.into(),
228            source,
229        }
230    }
231
232    // Mask + sanitize, in that order — see
233    // [`crate::util::redact_and_sanitize_untrusted`] (PAC return is attacker-chosen).
234    #[cfg_attr(not(feature = "pac"), allow(dead_code))]
235    pub(crate) fn pac_invalid_result(result: impl AsRef<str>) -> Self {
236        Error::PacInvalidResult {
237            result: crate::util::redact_and_sanitize_untrusted(result.as_ref()),
238        }
239    }
240
241    // Mask + sanitize engine/`throw` text. `pac/boa.rs` hands over what the engine said;
242    // `pac/winhttp.rs` hands over a sentence it wrote itself and still comes through here,
243    // so the variant is never built any other way.
244    #[cfg_attr(
245        not(any(feature = "pac-boa", all(windows, feature = "pac-windows-native"))),
246        allow(dead_code)
247    )]
248    pub(crate) fn pac_evaluation(reason: impl AsRef<str>) -> Self {
249        Error::PacEvaluation {
250            reason: crate::util::redact_and_sanitize_untrusted(reason.as_ref()),
251        }
252    }
253}
254
255impl std::fmt::Debug for Error {
256    // See enum docs: only [`Error::PacFetchRequired`]'s URL needs special casing.
257    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
258        match self {
259            Error::InvalidProxyServer { input, reason } => f
260                .debug_struct("InvalidProxyServer")
261                .field("input", input)
262                .field("reason", reason)
263                .finish(),
264            Error::InvalidBypassPattern { input, reason } => f
265                .debug_struct("InvalidBypassPattern")
266                .field("input", input)
267                .field("reason", reason)
268                .finish(),
269            Error::InvalidProxyUrl { input, source } => f
270                .debug_struct("InvalidProxyUrl")
271                .field("input", input)
272                .field("source", source)
273                .finish(),
274            Error::UnsupportedProxyScheme(scheme) => f
275                .debug_tuple("UnsupportedProxyScheme")
276                .field(scheme)
277                .finish(),
278            Error::CgiHttpProxy { variable } => f
279                .debug_struct("CgiHttpProxy")
280                .field("variable", variable)
281                .finish(),
282            Error::Io { context, source } => f
283                .debug_struct("Io")
284                .field("context", context)
285                .field("source", source)
286                .finish(),
287            Error::Sandboxed { sandbox, reason } => f
288                .debug_struct("Sandboxed")
289                .field("sandbox", sandbox)
290                .field("reason", reason)
291                .finish(),
292            Error::Unsupported => write!(f, "Unsupported"),
293            Error::PacNotSupported { mode } => f
294                .debug_struct("PacNotSupported")
295                .field("mode", mode)
296                .finish(),
297            Error::ProxyEntryUnusable { scheme, rejected } => f
298                .debug_struct("ProxyEntryUnusable")
299                .field("scheme", scheme)
300                .field("rejected", rejected)
301                .finish(),
302            Error::PacFetchRequired { url } => f
303                .debug_struct("PacFetchRequired")
304                .field("url", &format_args!("{}", redact_userinfo(url.as_str())))
305                .finish(),
306            Error::PacEvaluation { reason } => f
307                .debug_struct("PacEvaluation")
308                .field("reason", reason)
309                .finish(),
310            Error::PacTimeout { timeout } => f
311                .debug_struct("PacTimeout")
312                .field("timeout", timeout)
313                .finish(),
314            Error::PacInvalidResult { result } => f
315                .debug_struct("PacInvalidResult")
316                .field("result", result)
317                .finish(),
318            Error::PacEngineUnavailable => write!(f, "PacEngineUnavailable"),
319        }
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    const SECRET: &str = "hunter2";
328
329    #[test]
330    fn proxy_server_masks_the_password_in_display_and_debug() {
331        let input = format!("http://alice:{SECRET}@proxy.corp:99999");
332        let error = Error::proxy_server(&input, "port out of range");
333
334        let display = error.to_string();
335        let debug = format!("{error:?}");
336        assert!(!display.contains(SECRET), "{display}");
337        assert!(!debug.contains(SECRET), "{debug}");
338        // Not vacuous: the rest of the input is still there.
339        assert!(display.contains("proxy.corp"), "{display}");
340        assert!(debug.contains("proxy.corp"), "{debug}");
341    }
342
343    #[test]
344    fn invalid_proxy_url_masks_the_password_in_display_and_debug() {
345        let input = format!("http://alice:{SECRET}@proxy.corp:99999");
346        let error = Error::invalid_proxy_url(&input, url::ParseError::EmptyHost);
347
348        let display = error.to_string();
349        let debug = format!("{error:?}");
350        assert!(!display.contains(SECRET), "{display}");
351        assert!(!debug.contains(SECRET), "{debug}");
352        assert!(display.contains("proxy.corp"), "{display}");
353        assert!(debug.contains("proxy.corp"), "{debug}");
354    }
355
356    // The payload looks like a bare scheme name, but
357    // [`ProxyEndpoint::parse`](crate::ProxyEndpoint::parse) splits on the first `"://"`
358    // *before* it looks for userinfo, so credentials written ahead of the scheme end up
359    // inside it.
360    #[test]
361    fn an_unsupported_scheme_masks_credentials_written_ahead_of_it() {
362        let input = format!("alice:{SECRET}@http://proxy.corp:8080");
363        let error = crate::ProxyEndpoint::parse(&input, 80).unwrap_err();
364
365        assert!(
366            matches!(error, Error::UnsupportedProxyScheme(_)),
367            "fixture precondition: expected an unsupported-scheme error, got {error:?}"
368        );
369        let display = error.to_string();
370        let debug = format!("{error:?}");
371        assert!(!display.contains(SECRET), "{display}");
372        assert!(!debug.contains(SECRET), "{debug}");
373        // `SafeError` only exists with the `tracing` feature, so this rendering is checked
374        // conditionally: an unconditional reference stops `cargo test
375        // --no-default-features` from compiling at all.
376        #[cfg(feature = "tracing")]
377        {
378            let traced = crate::trace::SafeError(&error).to_string();
379            assert!(!traced.contains(SECRET), "{traced}");
380        }
381        // Not vacuous: the user name and the mangled scheme still identify the mistake.
382        assert!(display.contains("alice"), "{display}");
383    }
384
385    // The `url` field stays a genuine, fetchable `url::Url`, so this variant masks at
386    // *display* time rather than at construction. Both rendering paths must still come
387    // out clean.
388    #[test]
389    fn pac_fetch_required_masks_the_password_in_display_and_debug() {
390        let url = url::Url::parse(&format!("https://alice:{SECRET}@wpad.corp/proxy.pac")).unwrap();
391        let error = Error::PacFetchRequired { url: url.clone() };
392
393        let display = error.to_string();
394        let debug = format!("{error:?}");
395        assert!(!display.contains(SECRET), "{display}");
396        assert!(!debug.contains(SECRET), "{debug}");
397        assert!(display.contains("wpad.corp"), "{display}");
398        assert!(debug.contains("wpad.corp"), "{debug}");
399
400        // The field itself is untouched: the caller still needs the real URL to fetch.
401        if let Error::PacFetchRequired { url: preserved } = &error {
402            assert_eq!(preserved, &url);
403            assert_eq!(preserved.password(), Some(SECRET));
404        } else {
405            unreachable!();
406        }
407    }
408
409    #[test]
410    fn pac_invalid_result_masks_the_password_in_display_and_debug() {
411        let error = Error::pac_invalid_result(format!("PROXY alice:{SECRET}@proxy.corp:8080"));
412
413        let display = error.to_string();
414        let debug = format!("{error:?}");
415        assert!(!display.contains(SECRET), "{display}");
416        assert!(!debug.contains(SECRET), "{debug}");
417        assert!(display.contains("proxy.corp"), "{display}");
418        assert!(debug.contains("proxy.corp"), "{debug}");
419    }
420
421    // A `FindProxyForURL` that returns an unusable string chooses that string outright,
422    // so it is at least as attacker-controlled as an engine message quoting a `throw`.
423    #[test]
424    fn pac_invalid_result_strips_control_characters_and_truncates() {
425        let error = Error::pac_invalid_result("BOGUS\nWARN a fake second log line\r\nthird");
426
427        let display = error.to_string();
428        let debug = format!("{error:?}");
429        assert!(!display.contains('\n'), "{display}");
430        assert!(!display.contains('\r'), "{display}");
431        assert!(!debug.contains('\n'), "{debug}");
432        assert!(!debug.contains('\r'), "{debug}");
433        assert!(
434            display.contains("BOGUS.WARN a fake second log line..third"),
435            "{display}"
436        );
437
438        let long = Error::pac_invalid_result("y".repeat(4096));
439        assert!(long.to_string().len() < 512, "{}", long.to_string().len());
440        assert!(long.to_string().contains('…'), "{long}");
441    }
442
443    // `Error::proxy_server`/`Error::bypass` mask only their `input` field; `reason` is
444    // trusted. `crate::util::split_host_port`'s `Err` tests that trust: on malformed
445    // input it hands back the tail of the string it was splitting, which — because
446    // `ProxyEndpoint::parse` cuts the authority off at the first `/`/`?`/`#` *before* it
447    // looks for `@` — can be a stranded password fragment rather than a port. This drives
448    // the real helper the way `endpoint.rs` does, not a made-up reason string.
449    #[test]
450    fn proxy_server_reason_does_not_leak_a_password_stranded_past_a_slash() {
451        let reason = crate::util::split_host_port(&format!("bob:{SECRET}"))
452            .expect_err("a non-numeric \"port\" must fail to parse");
453        let error = Error::proxy_server(format!("http://bob:{SECRET}/x@proxy.corp:8080"), reason);
454
455        let display = error.to_string();
456        let debug = format!("{error:?}");
457        assert!(!display.contains(SECRET), "{display}");
458        assert!(!debug.contains(SECRET), "{debug}");
459        // Not vacuous: the rest of the address is still there, in both `input` (already
460        // masked) and `reason` (now safe by construction rather than by luck).
461        assert!(display.contains("proxy.corp"), "{display}");
462        assert!(debug.contains("proxy.corp"), "{debug}");
463    }
464
465    // `HostPattern::parse` never processes `@` — a bypass entry is not supposed to be a
466    // URL — so `bypass.rs` rejects any `@`-bearing entry outright, with a reason that
467    // names the mistake instead of repeating it.
468    #[test]
469    fn bypass_reason_does_not_leak_a_password_before_an_at_sign() {
470        let error = crate::bypass::HostPattern::parse(&format!("alice:{SECRET}@proxy.corp"))
471            .expect_err("an '@'-bearing entry must be rejected, not silently misparsed");
472
473        let display = error.to_string();
474        let debug = format!("{error:?}");
475        assert!(!display.contains(SECRET), "{display}");
476        assert!(!debug.contains(SECRET), "{debug}");
477        assert!(display.contains("proxy.corp"), "{display}");
478        assert!(debug.contains("proxy.corp"), "{debug}");
479    }
480
481    // A syntax error repeats the malformed PAC URL literal the script embedded, so the
482    // engine's message can quote a `user:password@` fragment.
483    #[test]
484    fn pac_evaluation_masks_the_password_in_display_and_debug() {
485        let error = Error::pac_evaluation(format!(
486            "the PAC script failed to load: unexpected token in string literal \
487             \"http://alice:{SECRET}@proxy.corp/x.pac\""
488        ));
489
490        let display = error.to_string();
491        let debug = format!("{error:?}");
492        assert!(!display.contains(SECRET), "{display}");
493        assert!(!debug.contains(SECRET), "{debug}");
494        assert!(display.contains("proxy.corp"), "{display}");
495        assert!(debug.contains("proxy.corp"), "{debug}");
496    }
497
498    // A PAC script's own `throw` reaches `reason` verbatim via the engine's message, so a
499    // script author chooses its bytes. Newlines must not survive into `Display`/`Debug`,
500    // or a script could forge extra log lines wherever a caller prints the error.
501    #[test]
502    fn pac_evaluation_strips_control_characters_in_display_and_debug() {
503        let error = Error::pac_evaluation(
504            "FindProxyForURL failed: Error: forged\nWARN a fake second log line\r\nthird",
505        );
506
507        let display = error.to_string();
508        let debug = format!("{error:?}");
509        assert!(!display.contains('\n'), "{display}");
510        assert!(!display.contains('\r'), "{display}");
511        assert!(!debug.contains('\n'), "{debug}");
512        assert!(!debug.contains('\r'), "{debug}");
513        // Not vacuous: the message survives, control characters swapped for `.`.
514        assert!(
515            display.contains("forged.WARN a fake second log line..third"),
516            "{display}"
517        );
518    }
519
520    // A pathological script (or engine) message must not make this error unbounded, even
521    // without the `tracing` feature.
522    #[test]
523    fn pac_evaluation_truncates_a_long_reason_in_display_and_debug() {
524        let error = Error::pac_evaluation(format!("FindProxyForURL failed: {}", "y".repeat(4096)));
525
526        let display = error.to_string();
527        let debug = format!("{error:?}");
528        assert!(display.len() < 512, "{}", display.len());
529        assert!(debug.len() < 512, "{}", debug.len());
530        assert!(display.contains('…'), "{display}");
531        assert!(debug.contains('…'), "{debug}");
532    }
533
534    // One row per variant. Only [`Error::PacFetchRequired`] needs the hand-written impl at
535    // all; the other arms are what `derive(Debug)` would have written, copied out by hand
536    // because a hand-written impl cannot delegate to a derive for the rest. This test is the
537    // only thing that notices a copy drifting: `Sandboxed` printed without its `reason` — the
538    // field that says *why* the sandbox left nothing readable, and the whole content of that
539    // error — and `Unsupported` printed as `PacEngineUnavailable`, which leaves two distinct
540    // failures indistinguishable wherever a caller logs `{:?}`.
541    //
542    // Exact strings, so that a label, an order or a name cannot change unseen. Where a field
543    // has a `Debug` of its own the expectation defers to it rather than copying it out,
544    // which is the one thing this impl does not own.
545    #[test]
546    fn every_variant_debug_names_itself_and_keeps_its_fields() {
547        let rejected = RejectedValue::new(
548            crate::RejectionKind::UnsupportedMapping,
549            crate::RejectionSource::ProxyServer,
550            "socks",
551        );
552        for (error, expected) in [
553            (
554                Error::proxy_server("proxy.corp", "no port"),
555                r#"InvalidProxyServer { input: "proxy.corp", reason: "no port" }"#.to_owned(),
556            ),
557            (
558                Error::bypass("*.corp", "empty label"),
559                r#"InvalidBypassPattern { input: "*.corp", reason: "empty label" }"#.to_owned(),
560            ),
561            (
562                Error::invalid_proxy_url("not-a-url", url::ParseError::RelativeUrlWithoutBase),
563                format!(
564                    r#"InvalidProxyUrl {{ input: "not-a-url", source: {:?} }}"#,
565                    url::ParseError::RelativeUrlWithoutBase
566                ),
567            ),
568            (
569                Error::UnsupportedProxyScheme("gopher".to_owned()),
570                r#"UnsupportedProxyScheme("gopher")"#.to_owned(),
571            ),
572            (
573                Error::CgiHttpProxy {
574                    variable: "http_proxy".to_owned(),
575                },
576                r#"CgiHttpProxy { variable: "http_proxy" }"#.to_owned(),
577            ),
578            (
579                Error::io(
580                    "reading dconf",
581                    std::io::Error::other("dconf exited nonzero"),
582                ),
583                format!(
584                    r#"Io {{ context: "reading dconf", source: {:?} }}"#,
585                    std::io::Error::other("dconf exited nonzero")
586                ),
587            ),
588            (
589                Error::Sandboxed {
590                    sandbox: "flatpak".to_owned(),
591                    reason: "dconf is not on the bus".to_owned(),
592                },
593                r#"Sandboxed { sandbox: "flatpak", reason: "dconf is not on the bus" }"#.to_owned(),
594            ),
595            (Error::Unsupported, "Unsupported".to_owned()),
596            (
597                Error::PacNotSupported { mode: "wpad" },
598                r#"PacNotSupported { mode: "wpad" }"#.to_owned(),
599            ),
600            (
601                Error::ProxyEntryUnusable {
602                    scheme: Scheme::Https,
603                    rejected: rejected.clone(),
604                },
605                format!(
606                    "ProxyEntryUnusable {{ scheme: {:?}, rejected: {rejected:?} }}",
607                    Scheme::Https
608                ),
609            ),
610            (
611                // No credentials, so that the row pins the rendering and not the masking —
612                // which `pac_fetch_required_masks_the_password_in_display_and_debug` owns.
613                Error::PacFetchRequired {
614                    url: url::Url::parse("https://wpad.corp/proxy.pac").unwrap(),
615                },
616                "PacFetchRequired { url: https://wpad.corp/proxy.pac }".to_owned(),
617            ),
618            (
619                Error::pac_evaluation("the engine refused the script"),
620                r#"PacEvaluation { reason: "the engine refused the script" }"#.to_owned(),
621            ),
622            (
623                Error::PacTimeout {
624                    timeout: std::time::Duration::from_millis(1500),
625                },
626                "PacTimeout { timeout: 1.5s }".to_owned(),
627            ),
628            (
629                Error::pac_invalid_result("BOGUS"),
630                r#"PacInvalidResult { result: "BOGUS" }"#.to_owned(),
631            ),
632            (
633                Error::PacEngineUnavailable,
634                "PacEngineUnavailable".to_owned(),
635            ),
636        ] {
637            assert_eq!(format!("{error:?}"), expected);
638        }
639    }
640}