Skip to main content

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}