proxy_watch/diagnostic.rs
1//! Structured, redaction-safe records for fail-soft configuration drops.
2
3use crate::Scheme;
4use crate::util::redact_offending_token;
5
6/// Why one configuration value was ignored.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
8#[non_exhaustive]
9pub enum RejectionKind {
10 /// A proxy endpoint or URL could not be parsed.
11 InvalidProxyEndpoint,
12 /// A bypass-list entry could not be parsed.
13 InvalidBypassPattern,
14 /// A named proxy scheme was not recognised.
15 UnknownProxyScheme,
16 /// The source expressed a setting this crate cannot model.
17 UnsupportedMapping,
18}
19
20/// Where a rejected value was read from.
21#[derive(Debug, Clone, PartialEq, Eq, Hash)]
22#[non_exhaustive]
23pub enum RejectionSource {
24 /// A Windows `ProxyServer`-style token.
25 ProxyServer,
26 /// A bypass / `no_proxy` / `ProxyOverride` token.
27 BypassList,
28 /// A process environment variable.
29 EnvironmentVariable(String),
30 /// A KDE `kioslaverc` key.
31 Kioslaverc(String),
32 /// A GNOME GSettings key.
33 GSettings(String),
34 /// A macOS SystemConfiguration key.
35 SystemConfiguration(String),
36}
37
38/// One fail-soft drop with a typed reason and origin.
39///
40/// Construction masks URL-shaped `user:password` in `input`, or withholds the token when a
41/// credential fragment may remain (for example a password that contains whitespace). Derived
42/// [`Debug`] and the text accessors therefore cannot expose a password even when the caller
43/// supplies an unparseable credential-bearing URL.
44#[derive(Debug, Clone, PartialEq, Eq, Hash)]
45#[non_exhaustive]
46pub struct RejectedValue {
47 kind: RejectionKind,
48 source: RejectionSource,
49 redacted_input: String,
50 scheme: Option<Scheme>,
51}
52
53impl RejectedValue {
54 /// Record a rejected raw value, redacting credentials immediately.
55 #[must_use]
56 pub(crate) fn new(
57 kind: RejectionKind,
58 source: RejectionSource,
59 input: impl AsRef<str>,
60 ) -> Self {
61 Self {
62 kind,
63 source,
64 redacted_input: redact_offending_token(input.as_ref()),
65 scheme: None,
66 }
67 }
68
69 /// Name the request scheme this drop took an answer away from.
70 ///
71 /// The question is *which requests lost an answer*, not which key was read. A slot the
72 /// parser recognised names its own scheme (`socksProxy` → [`Scheme::Socks`]); a key that
73 /// decides every request — a PAC or WPAD switch — names [`Scheme::All`]; only a token
74 /// whose key was not recognised at all names nothing, because the crate cannot say which
75 /// requests it would have covered.
76 ///
77 /// `None` is not a safe default. `resolve` cannot find an unattributed record, so it
78 /// answers as if the value had never been configured — which is right for the
79 /// unrecognised token and wrong for everything else. The widest drops are the ones that
80 /// look most like "no single scheme" and least deserve it.
81 ///
82 /// It takes the [`Option`] rather than the [`Scheme`] because several callers hold one:
83 /// a helper shared between a per-scheme loop and a whole-configuration key knows which
84 /// it was called for, and would otherwise have to say so with a `match` around the
85 /// construction — which is also what hides the masking from `debug_masking`'s scanner.
86 #[must_use]
87 pub(crate) const fn for_scheme(mut self, scheme: Option<Scheme>) -> Self {
88 self.scheme = scheme;
89 self
90 }
91
92 /// Request scheme this drop took an answer away from, when one is known.
93 ///
94 /// Under the `resolve` feature, `resolve` reports
95 /// [`Error::ProxyEntryUnusable`](crate::Error::ProxyEntryUnusable) rather than
96 /// `ProxyStep::Direct` for such a scheme, so a caller is not told "no proxy" about a
97 /// request the platform would have proxied. Those two names are left unlinked because
98 /// the feature they live behind can be off while this type is still documented.
99 #[must_use]
100 pub const fn affected_scheme(&self) -> Option<Scheme> {
101 self.scheme
102 }
103
104 /// Typed reason the value was dropped.
105 #[must_use]
106 pub const fn kind(&self) -> RejectionKind {
107 self.kind
108 }
109
110 /// Configuration origin of the dropped value.
111 #[must_use]
112 pub const fn source(&self) -> &RejectionSource {
113 &self.source
114 }
115
116 /// Redacted original input.
117 #[must_use]
118 pub fn redacted_input(&self) -> &str {
119 &self.redacted_input
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 #[test]
128 fn construction_redacts_before_debug_or_accessors_can_observe_the_value() {
129 let rejected = RejectedValue::new(
130 RejectionKind::InvalidProxyEndpoint,
131 RejectionSource::EnvironmentVariable("http_proxy".to_owned()),
132 "http://alice:hunter2@bad host:8080",
133 );
134 assert_eq!(rejected.redacted_input(), "http://alice:***@bad host:8080");
135 assert!(!format!("{rejected:?}").contains("hunter2"));
136 }
137
138 // The two inputs leave [`redact_offending_token`] by different doors — the `//` one
139 // masks in place and keeps naming the proxy, the space one is withheld outright — and
140 // this test deliberately does not say which is which. What it owns is the layer: that
141 // whichever door a value leaves by, `new` has already been through it before any
142 // accessor or `Debug` can observe the field. Which door each input takes is pinned one
143 // layer down, in `util`'s `redact_offending_token_masks_a_double_slash_inside_a_password`
144 // and `redact_offending_token_withholds_when_a_password_holds_a_boundary`.
145 #[test]
146 fn construction_hides_a_password_that_contains_whitespace_or_double_slash() {
147 for (input, secret) in [
148 ("http://alice:aa//bb@proxy.corp:8080", "aa//bb"),
149 ("http://alice:my pass@proxy.corp:8080", "my pass"),
150 ] {
151 let rejected = RejectedValue::new(
152 RejectionKind::InvalidProxyEndpoint,
153 RejectionSource::ProxyServer,
154 input,
155 );
156 assert!(!rejected.redacted_input().contains(secret), "{rejected:?}");
157 assert!(!format!("{rejected:?}").contains(secret), "{rejected:?}");
158 }
159 }
160}