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. A target with no backend has none at all; on Linux neither desktop
91    /// store answered, which both desktop features being off guarantees and which a build
92    /// with one of them on reaches when the schema and `kioslaverc` are missing.
93    ///
94    /// Not the answer inside a sandbox: that route is chosen before either store is
95    /// consulted and reports [`Sandboxed`](Self::Sandboxed), including when the feature that
96    /// would carry the portal fallback is off.
97    #[error("watching the system proxy configuration is not supported on this platform")]
98    Unsupported,
99
100    /// `resolve()` needs PAC/WPAD evaluation (`resolve_with_pac` / `pac` feature).
101    /// An error, not silent Direct.
102    ///
103    /// `resolve_with_pac` answers with it too, for
104    /// [`WpadAutoDetect`](crate::ProxyMode::WpadAutoDetect) handed no script body: this
105    /// crate runs no discovery itself, so the fix there is to supply the script, or to hand
106    /// discovery to the OS through `WinHttpPacResolver` / `CfNetworkPacResolver` built
107    /// `with_wpad(true)`, which answer `WpadAutoDetect` with this error until they are.
108    /// `mode` is `"wpad"` in every case and does not tell them apart.
109    #[error("resolving this URL requires evaluating a proxy auto-config script ({mode})")]
110    PacNotSupported {
111        /// Auto-config mode in effect.
112        mode: &'static str,
113    },
114
115    /// The system configured a proxy for this scheme, the value could not be used, and no
116    /// other entry covers it.
117    ///
118    /// Returning `ProxyStep::Direct` here would assert something the platform does not do:
119    /// KDE expands a `[$e]` value from the session environment and proxies the request, and
120    /// this crate does not expand it. Only a scheme with no catch-all reaches this; a live
121    /// `socksProxy` covers the drop first, as Chromium's `fallback_proxies` does. Where
122    /// this reports, Chromium goes direct instead: its `ProxyList::AddProxyChain` drops a
123    /// malformed entry with "Silently discard malformed inputs", leaving
124    /// `MapUrlSchemeToProxyList` to answer `nullptr`.
125    // `{scheme}` names the slot, so the second half says "this request" rather than
126    // repeating it: for a `ws`/`wss` URL the slot is `Https` or `Socks`, and "nothing else
127    // covers https" would describe a request that was not made.
128    #[error("the configured {scheme} proxy could not be used and nothing else covers this request")]
129    ProxyEntryUnusable {
130        /// Which requests lost an answer, read off the drop's own attribution. In every
131        /// mode this crate builds, the attribution and the slot are the same value because
132        /// each backend files its record against the slot it dropped, so the slot decides
133        /// coverage rather than the request's own scheme. A lost catch-all reports
134        /// [`Scheme::All`] whatever was asked for, and a `ws`/`wss` request reports
135        /// whichever of the chain it lost. A caller who hands
136        /// [`ProxyMode::manual`](crate::ProxyMode::manual) a record attributed to one
137        /// scheme under the key of another gets the attribution back: nothing normalises a
138        /// caller-built mismatch, and the same value is what
139        /// [`ProxyMode::rejected`](crate::ProxyMode::rejected) hands back.
140        scheme: Scheme,
141        /// First drop naming that scheme; masked at construction. The rest stay reachable
142        /// through [`ProxyMode::rejected`](crate::ProxyMode::rejected) on the mode this
143        /// error was resolved against, which is the caller's own `config.effective`
144        /// wherever the caller supplied it. This field survives when a resolver builds a
145        /// mode of its own that goes out of scope with the call: `WinHttpPacResolver`
146        /// answers a WPAD miss by re-reading the registry into a `Manual`, while the caller
147        /// still holds `WpadAutoDetect`, which has no list.
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    /// Every PAC evaluation slot stayed taken for the whole of `PacPolicy::timeout`, so the
177    /// script never started. The PAC engines together run at most `limit` evaluation threads
178    /// per process, and a thread holds its slot until its script ends, past the timeout that
179    /// abandoned it. `pac-quickjs` interrupts a script at the deadline, and holds a slot past
180    /// it only while a host function such as `dnsResolve` has yet to return.
181    #[error("no PAC evaluation slot came free within {timeout:?} ({limit} still running)")]
182    PacSaturated {
183        /// Budget spent waiting.
184        timeout: std::time::Duration,
185        /// Evaluation threads this process allows at once.
186        limit: usize,
187    },
188
189    /// `FindProxyForURL` returned nothing usable (malformed candidates skipped first).
190    /// `result` masked at construction.
191    #[error("PAC script returned no usable proxy candidate: {result:?}")]
192    PacInvalidResult {
193        /// Raw return string (masked).
194        result: String,
195    },
196
197    /// `pac` on but no engine (`pac-quickjs` or a custom `pac::PacEvaluator`).
198    #[error(
199        "no PAC JavaScript engine is enabled; build with `pac-quickjs` (not on Android or iOS), \
200         or attach a native resolver through `pac::PacResolver`"
201    )]
202    PacEngineUnavailable,
203}
204
205impl Error {
206    // Mask `user:password` in `input` once so Display/Debug stay safe downstream, with
207    // or without the `@` that would make it recognisable, since `input` is by definition
208    // a token that failed to parse.
209    pub(crate) fn proxy_server(input: impl AsRef<str>, reason: impl Into<String>) -> Self {
210        Error::InvalidProxyServer {
211            input: redact_offending_token(input.as_ref()),
212            reason: reason.into(),
213        }
214    }
215
216    // Same masking as [`Self::proxy_server`] (malformed bypasses can still look like userinfo).
217    pub(crate) fn bypass(input: impl AsRef<str>, reason: impl Into<String>) -> Self {
218        Error::InvalidBypassPattern {
219            input: redact_offending_token(input.as_ref()),
220            reason: reason.into(),
221        }
222    }
223
224    // Mask raw text that failed `Url::parse` (cannot re-parse to strip userinfo).
225    #[cfg_attr(
226        not(any(target_os = "linux", target_os = "macos", windows)),
227        allow(dead_code)
228    )]
229    pub(crate) fn invalid_proxy_url(input: impl AsRef<str>, source: url::ParseError) -> Self {
230        Error::InvalidProxyUrl {
231            input: redact_offending_token(input.as_ref()),
232            source,
233        }
234    }
235
236    // No `allow(dead_code)` for a target with no backend, unlike its neighbour above:
237    // `watch::ThreadGuard`'s `Drop` builds this error when a backend thread panics, and a
238    // `Drop` impl is live everywhere.
239    pub(crate) fn io(context: impl Into<String>, source: std::io::Error) -> Self {
240        Error::Io {
241            context: context.into(),
242            source,
243        }
244    }
245
246    // Mask + sanitize, in that order; see
247    // [`crate::util::redact_and_sanitize_untrusted`] (PAC return is attacker-chosen).
248    #[cfg_attr(not(feature = "pac"), allow(dead_code))]
249    pub(crate) fn pac_invalid_result(result: impl AsRef<str>) -> Self {
250        Error::PacInvalidResult {
251            result: crate::util::redact_and_sanitize_untrusted(result.as_ref()),
252        }
253    }
254
255    // Mask + sanitize engine/`throw` text. `pac/quickjs.rs` hands over what the engine said;
256    // `pac/winhttp.rs` hands over a sentence it wrote itself and still comes through here,
257    // so the variant is never built any other way.
258    #[cfg_attr(
259        not(any(
260            pac_quickjs,
261            feature = "pac-subprocess",
262            all(windows, feature = "pac-windows-native")
263        )),
264        allow(dead_code)
265    )]
266    pub(crate) fn pac_evaluation(reason: impl AsRef<str>) -> Self {
267        Error::PacEvaluation {
268            reason: crate::util::redact_and_sanitize_untrusted(reason.as_ref()),
269        }
270    }
271}
272
273impl std::fmt::Debug for Error {
274    // See enum docs: only [`Error::PacFetchRequired`]'s URL needs special casing.
275    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
276        match self {
277            Error::InvalidProxyServer { input, reason } => f
278                .debug_struct("InvalidProxyServer")
279                .field("input", input)
280                .field("reason", reason)
281                .finish(),
282            Error::InvalidBypassPattern { input, reason } => f
283                .debug_struct("InvalidBypassPattern")
284                .field("input", input)
285                .field("reason", reason)
286                .finish(),
287            Error::InvalidProxyUrl { input, source } => f
288                .debug_struct("InvalidProxyUrl")
289                .field("input", input)
290                .field("source", source)
291                .finish(),
292            Error::UnsupportedProxyScheme(scheme) => f
293                .debug_tuple("UnsupportedProxyScheme")
294                .field(scheme)
295                .finish(),
296            Error::CgiHttpProxy { variable } => f
297                .debug_struct("CgiHttpProxy")
298                .field("variable", variable)
299                .finish(),
300            Error::Io { context, source } => f
301                .debug_struct("Io")
302                .field("context", context)
303                .field("source", source)
304                .finish(),
305            Error::Sandboxed { sandbox, reason } => f
306                .debug_struct("Sandboxed")
307                .field("sandbox", sandbox)
308                .field("reason", reason)
309                .finish(),
310            Error::Unsupported => write!(f, "Unsupported"),
311            Error::PacNotSupported { mode } => f
312                .debug_struct("PacNotSupported")
313                .field("mode", mode)
314                .finish(),
315            Error::ProxyEntryUnusable { scheme, rejected } => f
316                .debug_struct("ProxyEntryUnusable")
317                .field("scheme", scheme)
318                .field("rejected", rejected)
319                .finish(),
320            Error::PacFetchRequired { url } => f
321                .debug_struct("PacFetchRequired")
322                .field("url", &format_args!("{}", redact_userinfo(url.as_str())))
323                .finish(),
324            Error::PacEvaluation { reason } => f
325                .debug_struct("PacEvaluation")
326                .field("reason", reason)
327                .finish(),
328            Error::PacTimeout { timeout } => f
329                .debug_struct("PacTimeout")
330                .field("timeout", timeout)
331                .finish(),
332            Error::PacSaturated { timeout, limit } => f
333                .debug_struct("PacSaturated")
334                .field("timeout", timeout)
335                .field("limit", limit)
336                .finish(),
337            Error::PacInvalidResult { result } => f
338                .debug_struct("PacInvalidResult")
339                .field("result", result)
340                .finish(),
341            Error::PacEngineUnavailable => write!(f, "PacEngineUnavailable"),
342        }
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    const SECRET: &str = "hunter2";
351
352    #[test]
353    fn proxy_server_masks_the_password_in_display_and_debug() {
354        let input = format!("http://alice:{SECRET}@proxy.corp:99999");
355        let error = Error::proxy_server(&input, "port out of range");
356
357        let display = error.to_string();
358        let debug = format!("{error:?}");
359        assert!(!display.contains(SECRET), "{display}");
360        assert!(!debug.contains(SECRET), "{debug}");
361        // Not vacuous: the rest of the input is still there.
362        assert!(display.contains("proxy.corp"), "{display}");
363        assert!(debug.contains("proxy.corp"), "{debug}");
364    }
365
366    #[test]
367    fn invalid_proxy_url_masks_the_password_in_display_and_debug() {
368        let input = format!("http://alice:{SECRET}@proxy.corp:99999");
369        let error = Error::invalid_proxy_url(&input, url::ParseError::EmptyHost);
370
371        let display = error.to_string();
372        let debug = format!("{error:?}");
373        assert!(!display.contains(SECRET), "{display}");
374        assert!(!debug.contains(SECRET), "{debug}");
375        assert!(display.contains("proxy.corp"), "{display}");
376        assert!(debug.contains("proxy.corp"), "{debug}");
377    }
378
379    // The payload looks like a bare scheme name, but
380    // [`ProxyEndpoint::parse`](crate::ProxyEndpoint::parse) splits on the first `"://"`
381    // *before* it looks for userinfo, so credentials written ahead of the scheme end up
382    // inside it.
383    #[test]
384    fn an_unsupported_scheme_masks_credentials_written_ahead_of_it() {
385        let input = format!("alice:{SECRET}@http://proxy.corp:8080");
386        let error = crate::ProxyEndpoint::parse(&input, 80).unwrap_err();
387
388        assert!(
389            matches!(error, Error::UnsupportedProxyScheme(_)),
390            "fixture precondition: expected an unsupported-scheme error, got {error:?}"
391        );
392        let display = error.to_string();
393        let debug = format!("{error:?}");
394        assert!(!display.contains(SECRET), "{display}");
395        assert!(!debug.contains(SECRET), "{debug}");
396        // `SafeError` only exists with the `tracing` feature, so this rendering is checked
397        // conditionally: an unconditional reference stops `cargo test
398        // --no-default-features` from compiling at all.
399        #[cfg(feature = "tracing")]
400        {
401            let traced = crate::trace::SafeError(&error).to_string();
402            assert!(!traced.contains(SECRET), "{traced}");
403        }
404        // Not vacuous: the user name and the mangled scheme still identify the mistake.
405        assert!(display.contains("alice"), "{display}");
406    }
407
408    // The `url` field stays a genuine, fetchable `url::Url`, so this variant masks at
409    // *display* time rather than at construction. Both rendering paths must still come
410    // out clean.
411    #[test]
412    fn pac_fetch_required_masks_the_password_in_display_and_debug() {
413        let url = url::Url::parse(&format!("https://alice:{SECRET}@wpad.corp/proxy.pac")).unwrap();
414        let error = Error::PacFetchRequired { url: url.clone() };
415
416        let display = error.to_string();
417        let debug = format!("{error:?}");
418        assert!(!display.contains(SECRET), "{display}");
419        assert!(!debug.contains(SECRET), "{debug}");
420        assert!(display.contains("wpad.corp"), "{display}");
421        assert!(debug.contains("wpad.corp"), "{debug}");
422
423        // The field itself is untouched: the caller still needs the real URL to fetch.
424        if let Error::PacFetchRequired { url: preserved } = &error {
425            assert_eq!(preserved, &url);
426            assert_eq!(preserved.password(), Some(SECRET));
427        } else {
428            unreachable!();
429        }
430    }
431
432    #[test]
433    fn pac_invalid_result_masks_the_password_in_display_and_debug() {
434        let error = Error::pac_invalid_result(format!("PROXY alice:{SECRET}@proxy.corp:8080"));
435
436        let display = error.to_string();
437        let debug = format!("{error:?}");
438        assert!(!display.contains(SECRET), "{display}");
439        assert!(!debug.contains(SECRET), "{debug}");
440        assert!(display.contains("proxy.corp"), "{display}");
441        assert!(debug.contains("proxy.corp"), "{debug}");
442    }
443
444    // A `FindProxyForURL` that returns an unusable string chooses that string outright,
445    // so it is at least as attacker-controlled as an engine message quoting a `throw`.
446    #[test]
447    fn pac_invalid_result_strips_control_characters_and_truncates() {
448        let error = Error::pac_invalid_result("BOGUS\nWARN a fake second log line\r\nthird");
449
450        let display = error.to_string();
451        let debug = format!("{error:?}");
452        assert!(!display.contains('\n'), "{display}");
453        assert!(!display.contains('\r'), "{display}");
454        assert!(!debug.contains('\n'), "{debug}");
455        assert!(!debug.contains('\r'), "{debug}");
456        assert!(
457            display.contains("BOGUS.WARN a fake second log line..third"),
458            "{display}"
459        );
460
461        let long = Error::pac_invalid_result("y".repeat(4096));
462        assert!(long.to_string().len() < 512, "{}", long.to_string().len());
463        assert!(long.to_string().contains('…'), "{long}");
464    }
465
466    // `Error::proxy_server`/`Error::bypass` mask only their `input` field; `reason` is
467    // trusted. `crate::util::split_host_port`'s `Err` tests that trust: on malformed
468    // input it hands back the tail of the string it was splitting, which, because
469    // `ProxyEndpoint::parse` cuts the authority off at the first `/`/`?`/`#` *before* it
470    // looks for `@`, can be a stranded password fragment rather than a port. This drives
471    // the real helper the way `endpoint.rs` does, not a made-up reason string.
472    #[test]
473    fn proxy_server_reason_does_not_leak_a_password_stranded_past_a_slash() {
474        let reason = crate::util::split_host_port(&format!("bob:{SECRET}"))
475            .expect_err("a non-numeric \"port\" must fail to parse");
476        let error = Error::proxy_server(format!("http://bob:{SECRET}/x@proxy.corp:8080"), reason);
477
478        let display = error.to_string();
479        let debug = format!("{error:?}");
480        assert!(!display.contains(SECRET), "{display}");
481        assert!(!debug.contains(SECRET), "{debug}");
482        // Not vacuous: the rest of the address is still there, in both `input` (already
483        // masked) and `reason` (now safe by construction rather than by luck).
484        assert!(display.contains("proxy.corp"), "{display}");
485        assert!(debug.contains("proxy.corp"), "{debug}");
486    }
487
488    // `HostPattern::parse` never processes `@` (a bypass entry is not supposed to be a
489    // URL), so `bypass.rs` rejects any `@`-bearing entry outright, with a reason that
490    // names the mistake instead of repeating it.
491    #[test]
492    fn bypass_reason_does_not_leak_a_password_before_an_at_sign() {
493        let error = crate::bypass::HostPattern::parse(&format!("alice:{SECRET}@proxy.corp"))
494            .expect_err("an '@'-bearing entry must be rejected, not silently misparsed");
495
496        let display = error.to_string();
497        let debug = format!("{error:?}");
498        assert!(!display.contains(SECRET), "{display}");
499        assert!(!debug.contains(SECRET), "{debug}");
500        assert!(display.contains("proxy.corp"), "{display}");
501        assert!(debug.contains("proxy.corp"), "{debug}");
502    }
503
504    // A syntax error repeats the malformed PAC URL literal the script embedded, so the
505    // engine's message can quote a `user:password@` fragment.
506    #[test]
507    fn pac_evaluation_masks_the_password_in_display_and_debug() {
508        let error = Error::pac_evaluation(format!(
509            "the PAC script failed to load: unexpected token in string literal \
510             \"http://alice:{SECRET}@proxy.corp/x.pac\""
511        ));
512
513        let display = error.to_string();
514        let debug = format!("{error:?}");
515        assert!(!display.contains(SECRET), "{display}");
516        assert!(!debug.contains(SECRET), "{debug}");
517        assert!(display.contains("proxy.corp"), "{display}");
518        assert!(debug.contains("proxy.corp"), "{debug}");
519    }
520
521    // A PAC script's own `throw` reaches `reason` verbatim via the engine's message, so a
522    // script author chooses its bytes. Newlines must not survive into `Display`/`Debug`,
523    // or a script could forge extra log lines wherever a caller prints the error.
524    #[test]
525    fn pac_evaluation_strips_control_characters_in_display_and_debug() {
526        let error = Error::pac_evaluation(
527            "FindProxyForURL failed: Error: forged\nWARN a fake second log line\r\nthird",
528        );
529
530        let display = error.to_string();
531        let debug = format!("{error:?}");
532        assert!(!display.contains('\n'), "{display}");
533        assert!(!display.contains('\r'), "{display}");
534        assert!(!debug.contains('\n'), "{debug}");
535        assert!(!debug.contains('\r'), "{debug}");
536        // Not vacuous: the message survives, control characters swapped for `.`.
537        assert!(
538            display.contains("forged.WARN a fake second log line..third"),
539            "{display}"
540        );
541    }
542
543    // A pathological script (or engine) message must not make this error unbounded, even
544    // without the `tracing` feature.
545    #[test]
546    fn pac_evaluation_truncates_a_long_reason_in_display_and_debug() {
547        let error = Error::pac_evaluation(format!("FindProxyForURL failed: {}", "y".repeat(4096)));
548
549        let display = error.to_string();
550        let debug = format!("{error:?}");
551        assert!(display.len() < 512, "{}", display.len());
552        assert!(debug.len() < 512, "{}", debug.len());
553        assert!(display.contains('…'), "{display}");
554        assert!(debug.contains('…'), "{debug}");
555    }
556
557    // One row per variant. Only [`Error::PacFetchRequired`] needs the hand-written impl at
558    // all; the other arms are what `derive(Debug)` would have written, copied out by hand
559    // because a hand-written impl cannot delegate to a derive for the rest. This test is
560    // the only thing that notices a copy drifting: `Sandboxed` printed without its `reason`
561    // (the field that says *why* the sandbox left nothing readable, and the whole content
562    // of that error), and `Unsupported` printed as `PacEngineUnavailable`, which leaves two
563    // distinct failures indistinguishable wherever a caller logs `{:?}`.
564    //
565    // Exact strings, so that a label, an order or a name cannot change unseen. Where a
566    // field has a `Debug` of its own the expectation defers to it rather than copying it
567    // out, because this impl does not control that rendering.
568    #[test]
569    fn every_variant_debug_names_itself_and_keeps_its_fields() {
570        let rejected = RejectedValue::new(
571            crate::RejectionKind::UnsupportedMapping,
572            crate::RejectionSource::ProxyServer,
573            "socks",
574        );
575        for (error, expected) in [
576            (
577                Error::proxy_server("proxy.corp", "no port"),
578                r#"InvalidProxyServer { input: "proxy.corp", reason: "no port" }"#.to_owned(),
579            ),
580            (
581                Error::bypass("*.corp", "empty label"),
582                r#"InvalidBypassPattern { input: "*.corp", reason: "empty label" }"#.to_owned(),
583            ),
584            (
585                Error::invalid_proxy_url("not-a-url", url::ParseError::RelativeUrlWithoutBase),
586                format!(
587                    r#"InvalidProxyUrl {{ input: "not-a-url", source: {:?} }}"#,
588                    url::ParseError::RelativeUrlWithoutBase
589                ),
590            ),
591            (
592                Error::UnsupportedProxyScheme("gopher".to_owned()),
593                r#"UnsupportedProxyScheme("gopher")"#.to_owned(),
594            ),
595            (
596                Error::CgiHttpProxy {
597                    variable: "http_proxy".to_owned(),
598                },
599                r#"CgiHttpProxy { variable: "http_proxy" }"#.to_owned(),
600            ),
601            (
602                Error::io(
603                    "reading dconf",
604                    std::io::Error::other("dconf exited nonzero"),
605                ),
606                format!(
607                    r#"Io {{ context: "reading dconf", source: {:?} }}"#,
608                    std::io::Error::other("dconf exited nonzero")
609                ),
610            ),
611            (
612                Error::Sandboxed {
613                    sandbox: "flatpak".to_owned(),
614                    reason: "dconf is not on the bus".to_owned(),
615                },
616                r#"Sandboxed { sandbox: "flatpak", reason: "dconf is not on the bus" }"#.to_owned(),
617            ),
618            (Error::Unsupported, "Unsupported".to_owned()),
619            (
620                Error::PacNotSupported { mode: "wpad" },
621                r#"PacNotSupported { mode: "wpad" }"#.to_owned(),
622            ),
623            (
624                Error::ProxyEntryUnusable {
625                    scheme: Scheme::Https,
626                    rejected: rejected.clone(),
627                },
628                format!(
629                    "ProxyEntryUnusable {{ scheme: {:?}, rejected: {rejected:?} }}",
630                    Scheme::Https
631                ),
632            ),
633            (
634                // No credentials, so that the row pins the rendering and not the masking,
635                // which `pac_fetch_required_masks_the_password_in_display_and_debug` owns.
636                Error::PacFetchRequired {
637                    url: url::Url::parse("https://wpad.corp/proxy.pac").unwrap(),
638                },
639                "PacFetchRequired { url: https://wpad.corp/proxy.pac }".to_owned(),
640            ),
641            (
642                Error::pac_evaluation("the engine refused the script"),
643                r#"PacEvaluation { reason: "the engine refused the script" }"#.to_owned(),
644            ),
645            (
646                Error::PacTimeout {
647                    timeout: std::time::Duration::from_millis(1500),
648                },
649                "PacTimeout { timeout: 1.5s }".to_owned(),
650            ),
651            (
652                Error::PacSaturated {
653                    timeout: std::time::Duration::from_millis(1500),
654                    limit: 4,
655                },
656                "PacSaturated { timeout: 1.5s, limit: 4 }".to_owned(),
657            ),
658            (
659                Error::pac_invalid_result("BOGUS"),
660                r#"PacInvalidResult { result: "BOGUS" }"#.to_owned(),
661            ),
662            (
663                Error::PacEngineUnavailable,
664                "PacEngineUnavailable".to_owned(),
665            ),
666        ] {
667            assert_eq!(format!("{error:?}"), expected);
668        }
669    }
670}