proxy_watch/config.rs
1//! Snapshot type: [`ProxyConfig`] and provenance labels.
2
3use std::time::SystemTime;
4
5use crate::env::ProxyEnv;
6use crate::mode::ProxyMode;
7
8/// Where a [`ProxyMode`] came from. [`ProxyConfig`] keeps all of them for explanation.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
10#[non_exhaustive]
11pub enum ProxyConfigSource {
12 /// The per-user WinINet settings, read through
13 /// `WinHttpGetIEProxyConfigForCurrentUser` — which answers for the *active* connection,
14 /// so a VPN or dial-up connectoid carrying its own proxy is what this reports while it
15 /// is up. When that call fails the backend falls back to the plain
16 /// `HKCU\…\Internet Settings` values, which are the LAN connection's: the label is
17 /// unchanged, the store behind it is not, and the two disagree exactly when a
18 /// connectoid was in charge. This label alone does not say which of them answered.
19 ///
20 /// [`ProxyConfig::fallbacks`] says it for the failures where it is a loss. When the
21 /// call reports that no Internet Explorer proxy settings exist — the documented
22 /// `ERROR_FILE_NOT_FOUND` — the plain values are read from the same account's own
23 /// hive, so they are not a substitute for the answer but the answer itself, from a
24 /// store documented to hold the same settings, and nothing is recorded — except under
25 /// `ProxySettingsPerUser = 0`, where Windows answers from the per-machine `Connections`
26 /// blob instead and these per-user values are a store it is not reading. When it fails
27 /// any other way, settings may have existed and gone unread, so this source appears in
28 /// that list: the read did not learn the value it came for.
29 Registry,
30 /// Group policy `HKLM\Software\Policies\…\Internet Settings`. Recorded but never
31 /// [`effective`](ProxyConfig::effective), because Windows does not read the values this
32 /// crate reads from that key: no administrative template shipped with Windows writes
33 /// `ProxyServer`, `ProxyEnable`, `AutoConfigURL` or `ProxyOverride` there
34 /// (`C:\Windows\PolicyDefinitions\inetres.admx` defines `ProxySettingsPerUser` and
35 /// nothing else under it), and `WinHttpGetIEProxyConfigForCurrentUser` answers the same
36 /// with those values present as with the key empty. An administrator who wrote one by
37 /// hand meant something by it, so it is reported from
38 /// [`sources`](ProxyConfig::sources); acting on it would route through a proxy nothing
39 /// else on the machine uses.
40 GroupPolicy,
41 /// WinHTTP machine defaults (`netsh winhttp set proxy`). Recorded but never
42 /// [`effective`](ProxyConfig::effective): Microsoft scopes this store to service and
43 /// middle-tier processes rather than ranking it against the per-user settings, so the
44 /// crate does not rank it either. A consumer that *is* a service reads it from
45 /// [`sources`](ProxyConfig::sources).
46 WinHttpDefault,
47 /// macOS `Setup:/Network/Global/Proxies` (configured; loses to `State:`, but is the
48 /// effective mode when there is no `State:` scope to lose to).
49 SystemConfigurationSetup,
50 /// macOS `State:/Network/Global/Proxies` (in effect; wins when both exist).
51 SystemConfigurationState,
52 /// GNOME `org.gnome.system.proxy`.
53 GSettings,
54 /// KDE `kioslaverc`.
55 Kioslaverc,
56 /// The process environment, read under the variable names a `kioslaverc` with
57 /// `ProxyType = 4` chose: `httpProxy=MY_HTTP_VAR` names the *variable*, not the proxy.
58 ///
59 /// Separate from [`Env`](Self::Env) because only the store is shared — the naming rule
60 /// is the file's, so the two can be read on one machine and disagree. Merged under a
61 /// single label they would both land in [`sources`](ProxyConfig::sources) and
62 /// [`ProxyConfig::source`] would answer with whichever came first, silently.
63 ///
64 /// The precedence slot is still `kioslaverc`'s: a `ProxyType = 4` file occupies the
65 /// position any other `ProxyType` would, because the rule keys off the store that was
66 /// read and not off the label it hands back.
67 KioslavercEnv,
68 /// XDG portal resolver. It answers already-resolved lookups, so a
69 /// [`ProxyMode::Manual`] from this source always carries an
70 /// empty [`BypassRules`](crate::BypassRules) — the portal applied the bypass itself
71 /// and never discloses it.
72 ///
73 /// The lookup names a fixed reserved probe host and not the destination you are asking
74 /// about, because the portal resolves per destination and a snapshot has none to give
75 /// it. A host-side PAC that branches on the host name therefore answers about the probe:
76 /// a [`ProxyMode::Direct`] from this source says the portal had no proxy *for the
77 /// probe*, which is a weaker claim than the same value read out of a settings store.
78 Portal,
79 /// Process environment, under the `*_proxy` convention this crate reads directly
80 /// ([`ProxyEnv`](crate::ProxyEnv)). A `kioslaverc` that names its own variables
81 /// reports [`KioslavercEnv`](Self::KioslavercEnv) instead.
82 Env,
83}
84
85/// Point-in-time system proxy snapshot.
86///
87/// Backend-built: `sources` descending precedence, `effective` = first (or Direct).
88/// [`ProxyConfig::from_ordered_sources`] enforces that invariant; [`ProxyConfig::new`]
89/// remains available for caller-defined resolved configurations. [`PartialEq`] ignores
90/// `captured_at`.
91///
92/// ```
93/// # use proxy_watch::{ProxyConfig, ProxyConfigSource, ProxyMode};
94/// # use std::time::SystemTime;
95/// let a = ProxyConfig::from_source(ProxyConfigSource::Registry, ProxyMode::Direct);
96/// let mut b = a.clone();
97/// b.captured_at = SystemTime::UNIX_EPOCH;
98/// assert_eq!(a, b);
99/// ```
100#[derive(Debug, Clone)]
101#[non_exhaustive]
102pub struct ProxyConfig {
103 /// Resolved mode after platform precedence.
104 pub effective: ProxyMode,
105 /// Descending precedence; backend snapshots: first wins (or Direct if empty).
106 pub sources: Vec<(ProxyConfigSource, ProxyMode)>,
107 /// Sources that were consulted, could not be read, and were left out of `sources`
108 /// rather than failing the whole read.
109 ///
110 /// A source absent from both lists was not configured; a source listed here is one
111 /// the machine may well be configured with, whose value this read did not learn. That
112 /// is the difference [`sources`](Self::sources) alone cannot express, and until this
113 /// field existed the only record of it was a log line — so a consumer built without
114 /// the `tracing` feature had none at all.
115 ///
116 /// Backend snapshots only: nothing here is derived from the modes, so
117 /// [`ProxyConfig::new`] and the constructors below leave it empty and
118 /// [`ProxyConfig::with_fallbacks`] is what fills it in.
119 ///
120 /// Unlike `captured_at` this **is** compared by [`PartialEq`], which is what makes a
121 /// watcher deliver the snapshot where a degradation appears or clears. Excluding it
122 /// would be worse than merely quiet: the watcher's equality skip keeps the snapshot it
123 /// already holds, so a degradation that healed would be reported for the rest of the
124 /// watcher's life.
125 pub fallbacks: Vec<ProxyConfigSource>,
126 /// Capture time; excluded from equality.
127 pub captured_at: SystemTime,
128}
129
130impl ProxyConfig {
131 /// Build a snapshot stamped with now.
132 #[must_use]
133 pub fn new(effective: ProxyMode, sources: Vec<(ProxyConfigSource, ProxyMode)>) -> Self {
134 Self {
135 effective,
136 sources,
137 fallbacks: Vec::new(),
138 captured_at: SystemTime::now(),
139 }
140 }
141
142 /// Record the sources this read could not learn the value of.
143 ///
144 /// See [`fallbacks`](Self::fallbacks). Takes the whole list rather than appending, so
145 /// a backend that assembles one alongside its `sources` hands it over in one place.
146 #[must_use]
147 pub fn with_fallbacks(mut self, fallbacks: Vec<ProxyConfigSource>) -> Self {
148 self.fallbacks = fallbacks;
149 self
150 }
151
152 /// Build a snapshot from sources already ordered by descending precedence.
153 ///
154 /// The first mode becomes [`effective`](Self::effective); an empty list becomes
155 /// [`ProxyMode::Direct`]. The source order and every losing source are preserved.
156 #[must_use]
157 pub fn from_ordered_sources(sources: Vec<(ProxyConfigSource, ProxyMode)>) -> Self {
158 let effective = sources
159 .first()
160 .map_or(ProxyMode::Direct, |(_, mode)| mode.clone());
161 Self::new(effective, sources)
162 }
163
164 /// Snapshot whose effective value comes from exactly one source.
165 #[must_use]
166 pub fn from_source(source: ProxyConfigSource, mode: ProxyMode) -> Self {
167 Self::from_ordered_sources(vec![(source, mode)])
168 }
169
170 /// "No proxy" with no sources.
171 #[must_use]
172 pub fn direct() -> Self {
173 Self::from_ordered_sources(Vec::new())
174 }
175
176 /// Mode for a specific source, if consulted.
177 #[must_use]
178 pub fn source(&self, source: ProxyConfigSource) -> Option<&ProxyMode> {
179 self.sources
180 .iter()
181 .find(|(s, _)| *s == source)
182 .map(|(_, mode)| mode)
183 }
184
185 /// Fold the process environment into this snapshot as one more source.
186 ///
187 /// The environment enters *whole*: one entry in [`sources`](Self::sources), ranked
188 /// against the OS sources rather than merged into them slot by slot. Setting only
189 /// `http_proxy` therefore does not leave the OS's https proxy in place — the winning
190 /// source answers for every scheme, and an environment with no https entry resolves
191 /// https to direct. `all_proxy` is how the environment covers the schemes it did not
192 /// name.
193 ///
194 /// What the fold does depends on the environment's shape.
195 /// [`ProxyEnv::is_configured`] separates the first shape from the other two; what
196 /// separates those is whether anything was *dropped*, which [`ProxyEnv::rejected`] and
197 /// [`ProxyEnv::bypass`]'s own rejections answer.
198 ///
199 /// - **Configured** — it takes the rank `precedence` asks for. A `no_proxy` with no proxy
200 /// variable beside it is this shape, and [`ProxyEnv::to_mode`] turns it into
201 /// [`Direct`](ProxyMode::Direct): under [`BeforeSystem`](EnvPrecedence::BeforeSystem) it
202 /// does not *add* a bypass to the OS proxy, it outranks that proxy and every host
203 /// resolves direct. The error is toward bypassing more than the caller listed, never
204 /// toward proxying a host they asked to exclude.
205 /// - **Present but specifying nothing** — appended to `sources` and never made
206 /// [`effective`](Self::effective), so a typo cannot mask the OS. Where every `*_proxy`
207 /// value was malformed, [`ProxyEnv::to_mode`] answers `Manual` and the drops come back
208 /// out of [`source`](Self::source). Where instead a `no_proxy` lost every entry it held,
209 /// `to_mode` answers [`Direct`](ProxyMode::Direct), which has nowhere to hold an
210 /// exclusion list: the entry records only *that* the environment was there and lost
211 /// something, and the text of the drop stays on [`ProxyEnv::bypass`]`().rejected`.
212 /// - **Specifying nothing and dropping nothing** — `self` is returned untouched. Usually
213 /// that means the variables are unset, but a `no_proxy` that parses to no rules and no
214 /// rejections (`no_proxy=`, `no_proxy=","`) lands here too: it was set, and the
215 /// snapshot keeps no evidence that it was.
216 ///
217 /// Being configured and having dropped something are not exclusive. `no_proxy=.corp`
218 /// beside an `http_proxy` that does not parse is configured — the bypass list is the
219 /// configuration — so it takes the rank, and the dropped scheme rides in with it: `http`
220 /// then answers [`Error::ProxyEntryUnusable`](crate::Error::ProxyEntryUnusable) instead
221 /// of falling through to the OS proxy or to direct. Silently sending the scheme whose
222 /// value was typed wrong straight out is the failure this crate exists to make visible.
223 ///
224 /// Fold an environment in once. A second `with_env` can leave a second
225 /// [`Env`](ProxyConfigSource::Env) entry in `sources`, and [`source`](Self::source)
226 /// answers with whichever is first — which the second environment's shape decides as much
227 /// as the precedence does.
228 ///
229 /// [`captured_at`](Self::captured_at) becomes the older of the two reads, except in the
230 /// third shape, where nothing is folded in: the result is only as fresh as its stalest
231 /// half. `effective` is otherwise left alone, including where that leaves it disagreeing
232 /// with `sources[0]`.
233 ///
234 /// ```
235 /// # use proxy_watch::{EnvPrecedence, ProxyConfig, ProxyConfigSource, ProxyEnv, ProxyMode};
236 /// let env = ProxyEnv::from_vars([("http_proxy", "http://env.corp:3128")]).unwrap();
237 /// let os = ProxyConfig::from_source(ProxyConfigSource::Registry, ProxyMode::Direct);
238 ///
239 /// let merged = os.with_env(&env, EnvPrecedence::BeforeSystem);
240 /// assert!(matches!(merged.effective, ProxyMode::Manual { .. }));
241 /// assert_eq!(merged.sources.len(), 2);
242 /// assert!(merged.source(ProxyConfigSource::Registry).is_some());
243 /// ```
244 #[must_use]
245 pub fn with_env(mut self, env: &ProxyEnv, precedence: EnvPrecedence) -> Self {
246 // Left out entirely when the environment specifies nothing *and* recorded no drop.
247 // The two halves of that do not buy the same thing. A dropped *scheme* value comes
248 // back out: `to_mode` answers `Manual` for it and carries `rejected` across. A
249 // dropped `no_proxy` entry does not — `to_mode` answers `Direct`, which has nowhere
250 // to hold a bypass list, and [`ProxyEnv::rejected`] says so in as many words. For
251 // that half the source records only that the environment was there and lost
252 // something; the text of the drop stays on the `ProxyEnv` the caller still holds.
253 // Not the same as "the process set none of the variables" either: `no_proxy=` parses
254 // to no rules and no rejections, so it is set and still lands in this return.
255 if !env.is_configured() && env.rejected().is_empty() && env.bypass().rejected.is_empty() {
256 return self;
257 }
258 // `AfterSystem` is a rank, not a veto: with nothing for the environment to come
259 // after, it is the answer rather than nothing at all. "Nothing" is both halves below,
260 // and neither is the obvious one.
261 //
262 // What that rank comes after is the *OS* settings, which is not the same as a
263 // non-empty `sources`: the `else` below writes an `Env` entry for an environment
264 // that specified nothing, and a record kept so that a drop is not silent must not
265 // become the thing the next fold has to lose to. Testing the label instead is exact
266 // rather than approximate — no OS reader writes `Env`, KDE's `ProxyType = 4` being
267 // `KioslavercEnv` precisely so that it does not. A malformed-only environment takes
268 // the rank under neither half — see [`ProxyEnv::is_configured`].
269 //
270 // The labels alone are not enough either. [`ProxyConfig::new`] can hand over a
271 // resolved `effective` with no provenance behind it, and taking the rank off the
272 // labels would overwrite the one value that caller did supply. Snapshots this crate
273 // reads always leave `Direct` there when no OS source came back, so `is_direct`
274 // answers for configurations assembled by hand — and for one an earlier `with_env`
275 // already settled.
276 let wins = env.is_configured()
277 && match precedence {
278 EnvPrecedence::BeforeSystem => true,
279 EnvPrecedence::AfterSystem => {
280 self.effective.is_direct()
281 && self
282 .sources
283 .iter()
284 .all(|(source, _)| *source == ProxyConfigSource::Env)
285 }
286 };
287 let mode = env.to_mode();
288 if wins {
289 self.effective = mode.clone();
290 self.sources.insert(0, (ProxyConfigSource::Env, mode));
291 } else {
292 // `effective` stays where it was. A snapshot with no OS source at all, folded
293 // with an all-malformed environment, therefore stays `Direct` while the entry
294 // this push adds — the only one there — is a `Manual` carrying `Unusable` in
295 // every slot the drops named. The disagreement is the point: an environment that
296 // specified nothing must not take a working connection down, and recomputing
297 // would also overwrite an `effective` a caller of [`ProxyConfig::new`] chose
298 // deliberately.
299 self.sources.push((ProxyConfigSource::Env, mode));
300 }
301 // The same correction [`ProxyEnv::to_config`] makes, for the same reason.
302 self.captured_at = self.captured_at.min(env.captured_at());
303 self
304 }
305}
306
307/// Where the process environment ranks against the OS settings in
308/// [`ProxyConfig::with_env`].
309///
310/// A third ranking, "ignore the environment", is deliberately not a variant here: a caller
311/// who wants the environment ignored does not call `with_env`.
312#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
313#[non_exhaustive]
314pub enum EnvPrecedence {
315 /// The environment outranks every OS source. What a consumer layering a `*_proxy`
316 /// reader over the system settings gets by default — Go's `golang.org/x/net/http/
317 /// httpproxy` reads the variables and nothing else, so a caller who consults it first
318 /// has already chosen this.
319 BeforeSystem,
320 /// The OS settings outrank the environment, which answers only when they produced no
321 /// configuration at all. Not per scheme: one OS source is enough to settle every scheme,
322 /// including the ones it says nothing about — [`ProxyConfig::with_env`] ranks sources,
323 /// it does not fill slots. Chromium's
324 /// `net/proxy_resolution/proxy_config_service_linux.cc` does this in the strong form —
325 /// once the desktop settings have produced a configuration it never looks at the
326 /// variables, a desktop mode of "none" counts as one, and reading `mode` always finds a
327 /// value, so a stock desktop nobody has ever configured still counts.
328 ///
329 /// This crate is deliberately a shade weaker on GNOME: it asks who wrote that `mode`, and
330 /// only one somebody actually set — the user's dconf layer, or an administrator's profile
331 /// — becomes a source. A machine where nobody has opened the proxy settings therefore
332 /// reaches this rank with an empty `sources`, and the environment answers. "Nobody
333 /// configured anything" and "somebody chose direct" are different answers, and only the
334 /// second should outrank a `*_proxy` an operator set on purpose.
335 ///
336 /// On Windows that leaves this a rank the environment never takes: `read` always reports
337 /// a `Registry` source, `ProxyEnable = 0` included, so there is never a snapshot for the
338 /// environment to answer for. macOS is nearly as closed: a scope the reader could
339 /// interpret becomes a source whatever it says, [`Direct`](ProxyMode::Direct) included, so
340 /// the environment answers only where *neither* `State:` nor `Setup:` came back — a
341 /// missing key, a NULL, or a value that is not a dictionary — and not merely where the
342 /// scopes name no proxy. Linux is where this rank earns its keep: desktop stores that
343 /// exist and are unset produce a snapshot with no source at all. So does the degraded case
344 /// beside it — the store this session's desktop would normally use compiled out of the
345 /// build while the other one is unset — where the empty `sources` means "never consulted"
346 /// rather than "found nothing", which only [`fallbacks`](ProxyConfig::fallbacks) records.
347 /// A machine with no desktop store *at all* is not this case: `read` fails with
348 /// [`Error::Unsupported`](crate::Error::Unsupported), so there is no snapshot to fold an
349 /// environment into.
350 ///
351 /// A rank the environment never takes is still not the "ignore" this enum leaves out,
352 /// which is a caller not folding at all. What a rank settles is `effective`; the rest of
353 /// the fold happens either way, so the environment lands in
354 /// [`sources`](ProxyConfig::sources) and [`captured_at`](ProxyConfig::captured_at) drops
355 /// to the older of the two reads even where this one can never win.
356 AfterSystem,
357}
358
359impl Default for ProxyConfig {
360 fn default() -> Self {
361 Self::direct()
362 }
363}
364
365impl PartialEq for ProxyConfig {
366 // Everything but `captured_at` — see type docs, and `fallbacks` for why that field is
367 // on this side of the line rather than beside the timestamp.
368 fn eq(&self, other: &Self) -> bool {
369 self.effective == other.effective
370 && self.sources == other.sources
371 && self.fallbacks == other.fallbacks
372 }
373}
374
375impl Eq for ProxyConfig {}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380
381 #[test]
382 fn ordered_sources_derive_the_effective_mode_without_dropping_provenance() {
383 let sources = vec![
384 (ProxyConfigSource::GroupPolicy, ProxyMode::Direct),
385 (ProxyConfigSource::Registry, ProxyMode::WpadAutoDetect),
386 ];
387 let config = ProxyConfig::from_ordered_sources(sources.clone());
388 assert_eq!(config.effective, ProxyMode::Direct);
389 assert_eq!(config.sources, sources);
390 }
391
392 // The distinction the field exists to carry, stated as the one place it has to hold:
393 // two snapshots that agree on every mode are still not the same answer when one of
394 // them was assembled without a source it could not read. Dropping `fallbacks` from
395 // `PartialEq` fails this, and with it the watcher's ability to ever report that a
396 // degradation cleared.
397 #[test]
398 fn a_source_that_could_not_be_read_is_not_the_same_snapshot_as_one_that_was_absent() {
399 let complete = ProxyConfig::from_source(ProxyConfigSource::Registry, ProxyMode::Direct);
400 let degraded = complete
401 .clone()
402 .with_fallbacks(vec![ProxyConfigSource::GroupPolicy]);
403 assert_eq!(complete.effective, degraded.effective);
404 assert_eq!(complete.sources, degraded.sources);
405 assert_ne!(complete, degraded);
406 }
407
408 #[test]
409 fn no_ordered_sources_means_direct() {
410 let config = ProxyConfig::from_ordered_sources(Vec::new());
411 assert_eq!(config.effective, ProxyMode::Direct);
412 assert!(config.sources.is_empty());
413 }
414}