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 SystemConfiguration proxies key, as macOS's dynamic store and iOS's
35 /// `CFNetworkCopySystemProxySettings()` both spell it.
36 SystemConfiguration(String),
37 /// An Android `ProxyInfo` field, read from `ConnectivityManager.getDefaultProxy()`.
38 ProxyInfo(String),
39}
40
41/// One fail-soft drop with a typed reason and origin.
42///
43/// Construction masks URL-shaped `user:password` in `input`, or withholds the token when a
44/// credential fragment may remain (for example a password that contains whitespace). Derived
45/// [`Debug`] and the text accessors therefore cannot expose a password even when the caller
46/// supplies an unparseable credential-bearing URL.
47#[derive(Debug, Clone, PartialEq, Eq, Hash)]
48#[non_exhaustive]
49pub struct RejectedValue {
50 kind: RejectionKind,
51 source: RejectionSource,
52 redacted_input: String,
53 scheme: Option<Scheme>,
54}
55
56impl RejectedValue {
57 /// Record a rejected raw value, redacting credentials immediately.
58 #[must_use]
59 pub(crate) fn new(
60 kind: RejectionKind,
61 source: RejectionSource,
62 input: impl AsRef<str>,
63 ) -> Self {
64 Self {
65 kind,
66 source,
67 redacted_input: redact_offending_token(input.as_ref()),
68 scheme: None,
69 }
70 }
71
72 /// Name the request scheme this drop took an answer away from.
73 ///
74 /// This specifies which requests lost an answer. A slot the parser recognised names
75 /// its own scheme (`socksProxy` → [`Scheme::Socks`]); a key that decides every request
76 /// (a PAC or WPAD switch) names [`Scheme::All`]; an unrecognised token names nothing,
77 /// because the crate cannot say which requests it would have covered.
78 ///
79 /// `None` is not a safe default. `resolve` answers an unattributed record as if the
80 /// value had never been configured. This is right for the unrecognised token and wrong
81 /// for everything else. The widest drops (PAC/WPAD switches) look most like "no single
82 /// scheme" and least deserve `None`.
83 ///
84 /// It takes the [`Option`] rather than the [`Scheme`] because several callers hold one:
85 /// a helper shared between a per-scheme loop and a whole-configuration key knows which
86 /// it was called for, and would otherwise have to say so with a `match` around the
87 /// construction, which is also what hides the masking from `debug_masking`'s scanner.
88 #[must_use]
89 pub(crate) const fn for_scheme(mut self, scheme: Option<Scheme>) -> Self {
90 self.scheme = scheme;
91 self
92 }
93
94 /// Request scheme this drop took an answer away from, when one is known.
95 ///
96 /// Under the `resolve` feature, `resolve` reports
97 /// [`Error::ProxyEntryUnusable`](crate::Error::ProxyEntryUnusable) rather than
98 /// `ProxyStep::Direct` for such a scheme, so a caller is not told "no proxy" about a
99 /// request the platform would have proxied. Those two names are left unlinked because
100 /// the feature they live behind can be off while this type is still documented.
101 #[must_use]
102 pub const fn affected_scheme(&self) -> Option<Scheme> {
103 self.scheme
104 }
105
106 /// Typed reason the value was dropped.
107 #[must_use]
108 pub const fn kind(&self) -> RejectionKind {
109 self.kind
110 }
111
112 /// Configuration origin of the dropped value.
113 #[must_use]
114 pub const fn source(&self) -> &RejectionSource {
115 &self.source
116 }
117
118 /// Redacted original input.
119 #[must_use]
120 pub fn redacted_input(&self) -> &str {
121 &self.redacted_input
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 #[test]
130 fn construction_redacts_before_debug_or_accessors_can_observe_the_value() {
131 let rejected = RejectedValue::new(
132 RejectionKind::InvalidProxyEndpoint,
133 RejectionSource::EnvironmentVariable("http_proxy".to_owned()),
134 "http://alice:hunter2@bad host:8080",
135 );
136 assert_eq!(rejected.redacted_input(), "http://alice:***@bad host:8080");
137 assert!(!format!("{rejected:?}").contains("hunter2"));
138 }
139
140 // The two inputs leave [`redact_offending_token`] through different branches (the `//`
141 // one masks in place and keeps naming the proxy, the space one is withheld outright)
142 // and this test does not say which is which. It checks the layer: whichever branch
143 // processes a value, `new` has already been through it before any accessor or `Debug`
144 // can observe the field. Which branch each input takes is pinned one layer down, in
145 // `util`'s `redact_offending_token_masks_a_double_slash_inside_a_password` and
146 // `redact_offending_token_withholds_when_a_password_holds_a_boundary`.
147 #[test]
148 fn construction_hides_a_password_that_contains_whitespace_or_double_slash() {
149 for (input, secret) in [
150 ("http://alice:aa//bb@proxy.corp:8080", "aa//bb"),
151 ("http://alice:my pass@proxy.corp:8080", "my pass"),
152 ] {
153 let rejected = RejectedValue::new(
154 RejectionKind::InvalidProxyEndpoint,
155 RejectionSource::ProxyServer,
156 input,
157 );
158 assert!(!rejected.redacted_input().contains(secret), "{rejected:?}");
159 assert!(!format!("{rejected:?}").contains(secret), "{rejected:?}");
160 }
161 }
162}