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}