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}