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 hive.
23    /// They are the answer itself, from a store documented to hold the same settings, and
24    /// nothing is recorded, except under `ProxySettingsPerUser = 0`. Under that setting,
25    /// Windows answers from the per-machine `Connections` blob instead and these per-user
26    /// values are a store it is not reading. When it fails any other way, settings may have
27    /// existed and gone unread, so this source appears in that list: the read did not learn
28    /// 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 may have meant it, so it is reported from
38    /// [`sources`](ProxyConfig::sources); acting on it would route through a proxy no other
39    /// component 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
70    /// [`BypassRules`](crate::BypassRules) with no entries and no implicit set
71    /// ([`ImplicitBypass::Empty`](crate::ImplicitBypass::Empty)): the portal applied the
72    /// bypass itself and never discloses it.
73    ///
74    /// The lookup names a fixed reserved probe host and not the destination you are asking
75    /// about, because the portal resolves per destination and a snapshot has none to give
76    /// it. A host-side PAC that branches on the host name therefore answers about the probe:
77    /// a [`ProxyMode::Direct`] from this source says the portal had no proxy *for the
78    /// probe*, which is a weaker claim than the same value read out of a settings store.
79    Portal,
80    /// Process environment, under the `*_proxy` convention this crate reads directly
81    /// ([`ProxyEnv`](crate::ProxyEnv)). A `kioslaverc` that names its own variables
82    /// reports [`KioslavercEnv`](Self::KioslavercEnv) instead.
83    Env,
84    /// Android's `ConnectivityManager.getDefaultProxy()`: the proxy of the default network,
85    /// or the global one when set, as the Java framework hands it to every app. It is what
86    /// `java.net.ProxySelector.getDefault()` routes by, so a bypass list is read with that
87    /// selector's rules rather than Chromium's.
88    ConnectivityManager,
89    /// iOS's `CFNetworkCopySystemProxySettings()`: the proxy of the active network, keyed
90    /// like macOS's [`SystemConfigurationState`](Self::SystemConfigurationState).
91    CfNetworkSystemSettings,
92}
93
94/// Point-in-time system proxy snapshot.
95///
96/// Backend-built: `sources` descending precedence, `effective` = first (or Direct).
97/// [`ProxyConfig::from_ordered_sources`] enforces that invariant; [`ProxyConfig::new`]
98/// remains available for caller-defined resolved configurations. [`PartialEq`] ignores
99/// `captured_at`.
100///
101/// ```
102/// # use proxy_watch::{ProxyConfig, ProxyConfigSource, ProxyMode};
103/// # use std::time::SystemTime;
104/// let a = ProxyConfig::from_source(ProxyConfigSource::Registry, ProxyMode::Direct);
105/// let mut b = a.clone();
106/// b.captured_at = SystemTime::UNIX_EPOCH;
107/// assert_eq!(a, b);
108/// ```
109#[derive(Debug, Clone)]
110#[non_exhaustive]
111pub struct ProxyConfig {
112    /// Resolved mode after platform precedence.
113    pub effective: ProxyMode,
114    /// Descending precedence; backend snapshots: first wins (or Direct if empty).
115    pub sources: Vec<(ProxyConfigSource, ProxyMode)>,
116    /// Sources that were consulted, could not be read, and were left out of `sources`
117    /// rather than failing the whole read.
118    ///
119    /// A source absent from both lists was not configured; a source listed here is one
120    /// the machine may well be configured with, whose value this read did not learn.
121    /// [`sources`](Self::sources) alone cannot express that. The log line that also records
122    /// it reaches only a consumer built with the `tracing` feature.
123    ///
124    /// Backend snapshots only: nothing here is derived from the modes, so
125    /// [`ProxyConfig::new`] and the constructors below leave it empty and
126    /// [`ProxyConfig::with_fallbacks`] is what fills it in.
127    ///
128    /// Unlike `captured_at` this **is** compared by [`PartialEq`], so a watcher delivers
129    /// the snapshot where a degradation appears or clears. Excluded, it would leave the
130    /// watcher's equality skip holding the snapshot it already has, so a degradation that
131    /// healed would be reported for the rest of the watcher's life.
132    pub fallbacks: Vec<ProxyConfigSource>,
133    /// Capture time; excluded from equality.
134    pub captured_at: SystemTime,
135}
136
137impl ProxyConfig {
138    /// Build a snapshot stamped with now.
139    #[must_use]
140    pub fn new(effective: ProxyMode, sources: Vec<(ProxyConfigSource, ProxyMode)>) -> Self {
141        Self {
142            effective,
143            sources,
144            fallbacks: Vec::new(),
145            captured_at: SystemTime::now(),
146        }
147    }
148
149    /// Record the sources this read could not learn the value of.
150    ///
151    /// See [`fallbacks`](Self::fallbacks). Takes the whole list rather than appending, so
152    /// a backend that assembles one alongside its `sources` hands it over in one place.
153    #[must_use]
154    pub fn with_fallbacks(mut self, fallbacks: Vec<ProxyConfigSource>) -> Self {
155        self.fallbacks = fallbacks;
156        self
157    }
158
159    /// Build a snapshot from sources already ordered by descending precedence.
160    ///
161    /// The first mode becomes [`effective`](Self::effective); an empty list becomes
162    /// [`ProxyMode::Direct`]. The source order and every losing source are preserved.
163    #[must_use]
164    pub fn from_ordered_sources(sources: Vec<(ProxyConfigSource, ProxyMode)>) -> Self {
165        let effective = sources
166            .first()
167            .map_or(ProxyMode::Direct, |(_, mode)| mode.clone());
168        Self::new(effective, sources)
169    }
170
171    /// Snapshot whose effective value comes from exactly one source.
172    #[must_use]
173    pub fn from_source(source: ProxyConfigSource, mode: ProxyMode) -> Self {
174        Self::from_ordered_sources(vec![(source, mode)])
175    }
176
177    /// "No proxy" with no sources.
178    #[must_use]
179    pub fn direct() -> Self {
180        Self::from_ordered_sources(Vec::new())
181    }
182
183    /// Mode for a specific source, if consulted.
184    #[must_use]
185    pub fn source(&self, source: ProxyConfigSource) -> Option<&ProxyMode> {
186        self.sources
187            .iter()
188            .find(|(s, _)| *s == source)
189            .map(|(_, mode)| mode)
190    }
191
192    /// Fold the process environment into this snapshot as one more source.
193    ///
194    /// The environment enters *whole*: one entry in [`sources`](Self::sources), ranked
195    /// against the OS sources rather than merged into them slot by slot. Setting only
196    /// `http_proxy` therefore does not leave the OS's https proxy in place; the winning
197    /// source answers for every scheme, and an environment with no https entry resolves
198    /// https to direct. `all_proxy` is how the environment covers the schemes it did not
199    /// name.
200    ///
201    /// The fold depends on the environment's shape. [`ProxyEnv::is_configured`] separates
202    /// the first shape from the other two; whether anything was *dropped* separates those,
203    /// and [`ProxyEnv::rejected`] and [`ProxyEnv::bypass`]'s own rejections answer that.
204    ///
205    /// - **Configured**: it takes the rank `precedence` asks for. A `no_proxy` with no
206    ///   proxy variable beside it is this shape, and [`ProxyEnv::to_mode`] turns it into
207    ///   [`Direct`](ProxyMode::Direct): under [`BeforeSystem`](EnvPrecedence::BeforeSystem) it
208    ///   does not *add* a bypass to the OS proxy, it outranks that proxy and every host
209    ///   resolves direct. The result errs toward bypassing more than the caller listed,
210    ///   never toward proxying a host they asked to exclude.
211    /// - **Present but specifying nothing**: appended to `sources` and never made
212    ///   [`effective`](Self::effective), so a typo cannot mask the OS. Where every `*_proxy`
213    ///   value was malformed, [`ProxyEnv::to_mode`] answers `Manual` and the drops come back
214    ///   out of [`source`](Self::source). Where instead a `no_proxy` lost every entry it held,
215    ///   `to_mode` answers [`Direct`](ProxyMode::Direct), which has nowhere to hold an
216    ///   exclusion list: the entry records only *that* the environment was there and lost
217    ///   something, and the text of the drop stays on [`ProxyEnv::bypass`]`().rejected`.
218    /// - **Specifying nothing and dropping nothing**: `self` is returned untouched. Usually
219    ///   that means the variables are unset, but a `no_proxy` that parses to no rules and no
220    ///   rejections (`no_proxy=`, `no_proxy=","`) lands here too: it was set, and the
221    ///   snapshot keeps no evidence that it was.
222    ///
223    /// Being configured and having dropped something are not exclusive. `no_proxy=.corp`
224    /// beside an `http_proxy` that does not parse is configured (the bypass list is the
225    /// configuration), so it takes the rank, and the dropped scheme rides in with it:
226    /// `http` then answers [`Error::ProxyEntryUnusable`](crate::Error::ProxyEntryUnusable)
227    /// instead of falling through to the OS proxy or to direct.
228    ///
229    /// Fold an environment in once. A second `with_env` can leave a second
230    /// [`Env`](ProxyConfigSource::Env) entry in `sources`, and [`source`](Self::source)
231    /// answers with whichever is first, which the second environment's shape decides as
232    /// much as the precedence does.
233    ///
234    /// [`captured_at`](Self::captured_at) becomes the older of the two reads, except in the
235    /// third shape, where nothing is folded in. `effective` is otherwise left alone,
236    /// including where that leaves it disagreeing with `sources[0]`.
237    ///
238    /// ```
239    /// # use proxy_watch::{EnvPrecedence, ProxyConfig, ProxyConfigSource, ProxyEnv, ProxyMode};
240    /// let env = ProxyEnv::from_vars([("http_proxy", "http://env.corp:3128")]).unwrap();
241    /// let os = ProxyConfig::from_source(ProxyConfigSource::Registry, ProxyMode::Direct);
242    ///
243    /// let merged = os.with_env(&env, EnvPrecedence::BeforeSystem);
244    /// assert!(matches!(merged.effective, ProxyMode::Manual { .. }));
245    /// assert_eq!(merged.sources.len(), 2);
246    /// assert!(merged.source(ProxyConfigSource::Registry).is_some());
247    /// ```
248    #[must_use]
249    pub fn with_env(mut self, env: &ProxyEnv, precedence: EnvPrecedence) -> Self {
250        // Left out only when the environment specifies nothing and dropped nothing, so a
251        // drop still leaves an `Env` record. `no_proxy=` parses to neither and is left out.
252        if !env.is_configured() && env.rejected().is_empty() && env.bypass().rejected.is_empty() {
253            return self;
254        }
255        // `AfterSystem` takes over only when no OS source came back and `effective` is
256        // still `Direct`. An earlier `Env` record is not an OS source (KDE's
257        // environment-backed mode is labelled `KioslavercEnv`, so it is one), and the
258        // `is_direct` test keeps an `effective` that a caller of [`ProxyConfig::new`]
259        // supplied without sources.
260        let wins = env.is_configured()
261            && match precedence {
262                EnvPrecedence::BeforeSystem => true,
263                EnvPrecedence::AfterSystem => {
264                    self.effective.is_direct()
265                        && self
266                            .sources
267                            .iter()
268                            .all(|(source, _)| *source == ProxyConfigSource::Env)
269                }
270            };
271        let mode = env.to_mode();
272        if wins {
273            self.effective = mode.clone();
274            self.sources.insert(0, (ProxyConfigSource::Env, mode));
275        } else {
276            // Malformed-only environment entries remain visible without replacing the
277            // effective mode chosen by the OS or by `ProxyConfig::new`.
278            self.sources.push((ProxyConfigSource::Env, mode));
279        }
280        // The same correction [`ProxyEnv::to_config`] makes, for the same reason.
281        self.captured_at = self.captured_at.min(env.captured_at());
282        self
283    }
284}
285
286/// Where the process environment ranks against the OS settings in
287/// [`ProxyConfig::with_env`].
288///
289/// A third ranking, "ignore the environment", is not a variant: a caller who wants the
290/// environment ignored does not call `with_env`.
291#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
292#[non_exhaustive]
293pub enum EnvPrecedence {
294    /// The environment outranks every OS source. What a consumer layering a `*_proxy`
295    /// reader over the system settings gets by default: Go's `golang.org/x/net/http/
296    /// httpproxy` reads the variables and nothing else, so a caller who consults it first
297    /// has already chosen this.
298    BeforeSystem,
299    /// The OS settings outrank the environment, which answers only when they produced no
300    /// configuration at all. Not per scheme: one OS source is enough to settle every
301    /// scheme, including the ones it says nothing about; [`ProxyConfig::with_env`] ranks
302    /// sources, it does not fill slots. Chromium's
303    /// `net/proxy_resolution/proxy_config_service_linux.cc` does this in the strong form:
304    /// once the desktop settings have produced a configuration it never looks at the
305    /// variables, a desktop mode of "none" counts as one, and reading `mode` always finds a
306    /// value, so a stock desktop with no proxy configured still counts.
307    ///
308    /// This crate is a shade weaker on GNOME: it asks who wrote that `mode`, and only one
309    /// that was set (in the user's dconf layer, or an administrator's profile) becomes a
310    /// source. A machine whose proxy settings were never touched therefore reaches this
311    /// rank with an empty `sources`, and the environment answers. An unset `mode` and a
312    /// `mode` set to direct are different answers, and only the second should outrank a
313    /// `*_proxy` an operator set.
314    ///
315    /// On Windows that leaves this a rank the environment never takes: `read` always
316    /// reports a `Registry` source, `ProxyEnable = 0` included, so there is never a
317    /// snapshot for the environment to answer for. macOS is nearly as closed: a scope the
318    /// reader could interpret becomes a source whatever it says,
319    /// [`Direct`](ProxyMode::Direct) included, so the environment answers only where
320    /// *neither* `State:` nor `Setup:` came back (a missing key, a NULL, or a value that is
321    /// not a dictionary); scopes that name no proxy do not qualify. Linux is where this
322    /// rank applies: desktop stores that exist and are unset produce a snapshot with no
323    /// source at all. So does the degraded case beside it (the store this session's desktop
324    /// would normally use compiled out of the build while the other one is unset), where
325    /// the empty `sources` means "never consulted" rather than "found nothing", which only
326    /// [`fallbacks`](ProxyConfig::fallbacks) records.
327    /// A machine with no desktop store *at all* is not this case: `read` fails with
328    /// [`Error::Unsupported`](crate::Error::Unsupported), so there is no snapshot to fold an
329    /// environment into.
330    ///
331    /// Even a rank the environment never takes differs from the "ignore" this enum leaves
332    /// out, which is a caller not folding at all: a rank settles only `effective`, and the
333    /// rest of the fold happens either way, so the environment lands in
334    /// [`sources`](ProxyConfig::sources) and [`captured_at`](ProxyConfig::captured_at) drops
335    /// to the older of the two reads even where this one can never win.
336    AfterSystem,
337}
338
339impl Default for ProxyConfig {
340    fn default() -> Self {
341        Self::direct()
342    }
343}
344
345impl PartialEq for ProxyConfig {
346    // Everything but `captured_at`; see type docs, and `fallbacks` for why that field is
347    // on this side of the line rather than beside the timestamp.
348    fn eq(&self, other: &Self) -> bool {
349        self.effective == other.effective
350            && self.sources == other.sources
351            && self.fallbacks == other.fallbacks
352    }
353}
354
355impl Eq for ProxyConfig {}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    #[test]
362    fn ordered_sources_derive_the_effective_mode_without_dropping_provenance() {
363        let sources = vec![
364            (ProxyConfigSource::GroupPolicy, ProxyMode::Direct),
365            (ProxyConfigSource::Registry, ProxyMode::WpadAutoDetect),
366        ];
367        let config = ProxyConfig::from_ordered_sources(sources.clone());
368        assert_eq!(config.effective, ProxyMode::Direct);
369        assert_eq!(config.sources, sources);
370    }
371
372    // Snapshots that agree on every mode still differ when one lost a source. Without
373    // `fallbacks` in `PartialEq`, the watcher never reports that a degradation cleared.
374    #[test]
375    fn a_source_that_could_not_be_read_is_not_the_same_snapshot_as_one_that_was_absent() {
376        let complete = ProxyConfig::from_source(ProxyConfigSource::Registry, ProxyMode::Direct);
377        let degraded = complete
378            .clone()
379            .with_fallbacks(vec![ProxyConfigSource::GroupPolicy]);
380        assert_eq!(complete.effective, degraded.effective);
381        assert_eq!(complete.sources, degraded.sources);
382        assert_ne!(complete, degraded);
383    }
384
385    #[test]
386    fn no_ordered_sources_means_direct() {
387        let config = ProxyConfig::from_ordered_sources(Vec::new());
388        assert_eq!(config.effective, ProxyMode::Direct);
389        assert!(config.sources.is_empty());
390    }
391}