Skip to main content

proxy_watch/
env.rs

1//! The environment variable snapshot type.
2
3use std::collections::HashMap;
4use std::fmt;
5use std::time::SystemTime;
6
7use crate::bypass::BypassRules;
8use crate::config::{ProxyConfig, ProxyConfigSource};
9use crate::diagnostic::{RejectedValue, RejectionKind, RejectionSource};
10use crate::endpoint::{ProxyEndpoint, ProxyEntry, Scheme};
11use crate::error::Error;
12use crate::mode::ProxyMode;
13use crate::parse;
14
15/// The variable whose presence *with a method in it* marks a CGI environment (see
16/// [`Error::CgiHttpProxy`]).
17pub const CGI_MARKER_VAR: &str = "REQUEST_METHOD";
18
19// Lowercase variable name for each scheme, in the order they are looked up.
20const SCHEME_VARS: [(Scheme, &str); 4] = [
21    (Scheme::Http, "http_proxy"),
22    (Scheme::Https, "https_proxy"),
23    (Scheme::Ftp, "ftp_proxy"),
24    (Scheme::All, "all_proxy"),
25];
26
27const NO_PROXY_VAR: &str = "no_proxy";
28
29/// Env snapshot of `*_proxy` / `no_proxy` (not a [`Stream`](crate::Stream);
30/// [`ProxyWatcher`](crate::ProxyWatcher) does not merge these in — except where an OS
31/// setting names them, which KDE's `ProxyType=4` does).
32///
33/// Lowercase beats uppercase; empty value → [`ProxyEntry::Disabled`]; port explicit, else
34/// the `scheme://` default, else 80 (so `all_proxy=socks5://h` is 1080, `http_proxy=h` is
35/// 80) — the rule [`ProxyEndpoint::parse`] states. On Windows only,
36/// any other letter case the variable was actually set in (`Http_Proxy`) is read too, after
37/// both conventional spellings. `no_proxy` via [`parse::no_proxy`]. Bad `*_proxy` →
38/// [`rejected`](Self::rejected). A **non-empty** `REQUEST_METHOD` + `http_proxy` →
39/// [`Error::CgiHttpProxy`]. Merge with a watcher is caller-defined precedence.
40#[derive(Clone)]
41pub struct ProxyEnv {
42    per_scheme: HashMap<Scheme, ProxyEntry>,
43    bypass: BypassRules,
44    rejected: Vec<RejectedValue>,
45    captured_at: SystemTime,
46}
47
48impl ProxyEnv {
49    /// Read the snapshot from the current process environment.
50    ///
51    /// [`std::env::vars`] panics on a variable whose name or value is not valid Unicode,
52    /// so this reads [`std::env::vars_os`] instead: one unrelated variable elsewhere in
53    /// the process must not be able to take the whole snapshot down. What that costs is
54    /// split by half: a variable whose *name* is not valid Unicode is none of the ones
55    /// read here, so it is dropped, while a mangled *value* is kept and refused — it
56    /// lands in [`rejected`](Self::rejected) rather than reading as unset.
57    ///
58    /// # Errors
59    ///
60    /// [`Error::CgiHttpProxy`] in a CGI environment carrying an `http_proxy`
61    /// variable. A malformed value is no longer one of these, but the two kinds are
62    /// recorded apart: a dropped `*_proxy` endpoint lands in
63    /// [`rejected`](Self::rejected), a dropped `no_proxy` entry in
64    /// [`bypass()`](Self::bypass)`.rejected`. Reading only the first and finding it
65    /// empty says nothing about the second.
66    pub fn from_env() -> Result<Self, Error> {
67        Self::from_vars(std::env::vars_os().filter_map(readable_var))
68    }
69
70    /// Explicit map for tests (avoids mutating the process-global env).
71    ///
72    /// ```
73    /// # use proxy_watch::{ProxyEnv, Scheme};
74    /// let env = ProxyEnv::from_vars([
75    ///     ("HTTP_PROXY", "http://upper:8080"),
76    ///     ("http_proxy", "http://lower:8080"),
77    ///     ("no_proxy", "*.internal"),
78    /// ])
79    /// .unwrap();
80    /// // Lowercase wins.
81    /// let endpoint = env.endpoint_for(Scheme::Http).unwrap();
82    /// assert_eq!(endpoint.host.to_string(), "lower");
83    /// assert!(env.bypass().matches_authority("api.internal"));
84    /// ```
85    ///
86    /// # Errors
87    ///
88    /// [`Error::CgiHttpProxy`] when a non-empty `REQUEST_METHOD` and an `http_proxy` are
89    /// both present. Malformed values are recorded instead of returned; see
90    /// [`from_env`](Self::from_env) for which of the two lists each kind reaches.
91    pub fn from_vars<I, K, V>(vars: I) -> Result<Self, Error>
92    where
93        I: IntoIterator<Item = (K, V)>,
94        K: AsRef<str>,
95        V: AsRef<str>,
96    {
97        let map: HashMap<String, String> = vars
98            .into_iter()
99            .map(|(k, v)| (k.as_ref().to_owned(), v.as_ref().to_owned()))
100            .collect();
101
102        // CGI marker: exact + Windows any-case, and it has to carry a method. Presence
103        // alone is not the test — Go reads it as `os.Getenv("REQUEST_METHOD") != ""`
104        // (`httpproxy.FromEnvironment`), and RFC 3875 §4.1.12 has no empty production for
105        // it (`method = "GET" | "POST" | "HEAD" | extension-method`), so no conforming
106        // CGI server ever sets it empty. Refuse any-case `http_proxy` (`min` for a stable
107        // name).
108        let in_cgi = map
109            .get(CGI_MARKER_VAR)
110            .or_else(|| any_case_on_windows(&map, CGI_MARKER_VAR).map(|(_, value)| value))
111            .is_some_and(|method| !method.is_empty());
112        if in_cgi
113            && let Some(variable) = map
114                .keys()
115                .filter(|k| k.eq_ignore_ascii_case(SCHEME_VARS[0].1))
116                .min()
117        {
118            return Err(Error::CgiHttpProxy {
119                variable: variable.clone(),
120            });
121        }
122
123        let mut per_scheme = HashMap::new();
124        let mut rejected = Vec::new();
125        for (scheme, name) in SCHEME_VARS {
126            let Some(value) = lookup(&map, name) else {
127                continue;
128            };
129            let trimmed = value.trim();
130            if trimmed.is_empty() {
131                per_scheme.insert(scheme, ProxyEntry::Disabled);
132                continue;
133            }
134            match ProxyEndpoint::parse(trimmed, 80) {
135                Ok(endpoint) => {
136                    per_scheme.insert(scheme, ProxyEntry::Use(endpoint));
137                }
138                // The `WARN` compiles to nothing without the `tracing` feature, which is
139                // what leaves `err` unused there; the `rejected` entry below is what
140                // carries the drop either way.
141                #[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
142                Err(err) => {
143                    crate::trace::warning!(
144                        variable = name,
145                        error = %crate::trace::SafeError(&err),
146                        "skipping an unparseable *_proxy value"
147                    );
148                    rejected.push(
149                        RejectedValue::new(
150                            RejectionKind::InvalidProxyEndpoint,
151                            RejectionSource::EnvironmentVariable(name.to_owned()),
152                            trimmed,
153                        )
154                        .for_scheme(Some(scheme)),
155                    );
156                }
157            }
158        }
159
160        let bypass = match lookup(&map, NO_PROXY_VAR) {
161            Some(value) => parse::no_proxy(value),
162            None => BypassRules::new(),
163        };
164
165        Ok(Self {
166            per_scheme,
167            bypass,
168            rejected,
169            captured_at: SystemTime::now(),
170        })
171    }
172
173    /// The parsed per-scheme entries. Never [`Unusable`](ProxyEntry::Unusable): a drop stays
174    /// on [`rejected`](Self::rejected) alone until [`to_mode`](Self::to_mode) files it into
175    /// the [`ProxyMode`]'s map, so a snapshot and the mode built from it differ here.
176    #[must_use]
177    pub fn per_scheme(&self) -> &HashMap<Scheme, ProxyEntry> {
178        &self.per_scheme
179    }
180
181    /// The parsed `no_proxy` rules.
182    #[must_use]
183    pub fn bypass(&self) -> &BypassRules {
184        &self.bypass
185    }
186
187    /// Redacted `*_proxy` values [`ProxyEndpoint::parse`] rejected (fail-open drop,
188    /// not whole-snapshot failure). Threaded into [`ProxyMode`] via [`to_mode`](Self::to_mode).
189    /// Not the `no_proxy` drops: those are exclusions rather than endpoints and stay on
190    /// [`bypass()`](Self::bypass)`.rejected`. [`to_mode`](Self::to_mode) carries them
191    /// across inside the [`BypassRules`], but only on the branch that returns a
192    /// [`Manual`](ProxyMode::Manual): a snapshot holding nothing but a `no_proxy` is
193    /// [`Direct`](ProxyMode::Direct), and an exclusion list with no proxy to be excluded
194    /// from does not outlive the conversion.
195    #[must_use]
196    pub fn rejected(&self) -> &[RejectedValue] {
197        &self.rejected
198    }
199
200    /// When the snapshot was taken. Excluded from equality comparisons.
201    #[must_use]
202    pub fn captured_at(&self) -> SystemTime {
203        self.captured_at
204    }
205
206    /// No `*_proxy` set (malformed-but-present still counts as set via [`rejected`](Self::rejected)).
207    #[must_use]
208    pub fn is_empty(&self) -> bool {
209        self.per_scheme.is_empty() && self.rejected.is_empty()
210    }
211
212    /// Whether these variables *specify* a configuration — something the environment asked
213    /// for, as opposed to something it merely mentioned.
214    ///
215    /// This neither implies [`is_empty`](Self::is_empty) nor follows from it, which is why
216    /// both exist. `is_empty` answers "was any scheme variable set", counting a value that
217    /// failed to parse and ignoring `no_proxy`; this one ignores the failures and counts
218    /// `no_proxy`:
219    ///
220    /// | environment | `is_empty` | `is_configured` |
221    /// | --- | --- | --- |
222    /// | nothing set | `true` | `false` |
223    /// | `http_proxy=http://p:8080` | `false` | `true` |
224    /// | `http_proxy=` — a deliberate direct for http | `false` | `true` |
225    /// | `no_proxy=.corp.example` alone | `true` | `true` |
226    /// | every scheme variable malformed | `false` | `false` |
227    ///
228    /// The last two rows are the ones with consequences, and both follow Chromium's
229    /// `net/proxy_resolution/proxy_config_service_linux.cc`. A `no_proxy` on its own is a
230    /// configuration there — "having no rules specified only means the user explicitly asks
231    /// for direct connections" — and a value that fails to parse is logged and then treated
232    /// exactly as if the variable were unset. A malformed value is a diagnostic, kept in
233    /// [`rejected`](Self::rejected); reading it as an instruction would let a typo in
234    /// `http_proxy` mask a working OS proxy.
235    ///
236    /// [`ProxyConfig::with_env`](crate::ProxyConfig::with_env) is what acts on the
237    /// distinction. [`to_mode`](Self::to_mode) does not: a `no_proxy` with no proxy to
238    /// exclude from is still [`Direct`](ProxyMode::Direct), which is the same answer
239    /// Chromium's config produces once it has no proxy servers in it.
240    #[must_use]
241    pub fn is_configured(&self) -> bool {
242        !self.per_scheme.is_empty() || !self.bypass.is_empty()
243    }
244
245    /// The endpoint for `scheme`, under the same [`Scheme::All`] fallback as
246    /// [`ProxyMode::entry_for`](crate::ProxyMode::entry_for): `All` itself has none, and a
247    /// [`Disabled`](ProxyEntry::Disabled) entry answers `None` instead of falling through.
248    #[must_use]
249    pub fn endpoint_for(&self, scheme: Scheme) -> Option<&ProxyEndpoint> {
250        // Written the same way as [`ProxyMode::entry_for`], which is what the doc above
251        // claims, and for the `All` case for the reason given there. `entry_for`'s one further
252        // rule — step over a drop and let a later slot answer — has nothing to act on in this
253        // map, which never holds one; the two stay the same lookup as long as that holds.
254        self.per_scheme
255            .get(&scheme)
256            .or_else(|| self.per_scheme.get(&Scheme::All))?
257            .endpoint()
258    }
259
260    /// Into [`ProxyMode`]: empty → [`Direct`](ProxyMode::Direct); else Manual (keeping
261    /// [`rejected`](Self::rejected) even when every scheme value failed to parse).
262    #[must_use]
263    pub fn to_mode(&self) -> ProxyMode {
264        // `is_empty` rather than its expression again: "empty" here is that method's
265        // question, and two copies of it can only ever drift apart.
266        if self.is_empty() {
267            ProxyMode::Direct
268        } else {
269            ProxyMode::manual(self.per_scheme.clone(), self.bypass.clone())
270                .with_rejected(self.rejected.clone())
271        }
272    }
273
274    /// Convert the snapshot into a [`ProxyConfig`] attributed to
275    /// [`ProxyConfigSource::Env`], carrying this snapshot's
276    /// [`captured_at`](Self::captured_at) rather than the time of the conversion.
277    #[must_use]
278    pub fn to_config(&self) -> ProxyConfig {
279        let mut config = ProxyConfig::from_source(ProxyConfigSource::Env, self.to_mode());
280        // `from_source` stamps now, which is the wrong instant: the variables were read when
281        // this snapshot was taken. A snapshot held and converted once per request would
282        // otherwise hand back a `ProxyConfig` claiming to be fresh every time.
283        config.captured_at = self.captured_at;
284        config
285    }
286}
287
288// Not a derive, only so that `per_scheme` prints in a fixed order — a `HashMap` seeds its
289// iteration order per instance, so a derive would render the same snapshot differently on
290// each run. [`ProxyMode`]'s own `Debug` says the rest.
291impl fmt::Debug for ProxyEnv {
292    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293        f.debug_struct("ProxyEnv")
294            .field(
295                "per_scheme",
296                &self
297                    .per_scheme
298                    .iter()
299                    .collect::<std::collections::BTreeMap<_, _>>(),
300            )
301            .field("bypass", &self.bypass)
302            .field("rejected", &self.rejected)
303            .field("captured_at", &self.captured_at)
304            .finish()
305    }
306}
307
308impl PartialEq for ProxyEnv {
309    // Compares the parsed values only, ignoring `captured_at` (consistent with
310    // [`ProxyConfig`]).
311    fn eq(&self, other: &Self) -> bool {
312        self.per_scheme == other.per_scheme
313            && self.bypass == other.bypass
314            && self.rejected == other.rejected
315    }
316}
317
318impl Eq for ProxyEnv {}
319
320// One environment variable as [`ProxyEnv::from_vars`] needs to see it.
321//
322// The two halves are not the same question. A *name* that is not valid Unicode cannot be
323// any of the variables in [`SCHEME_VARS`] or [`NO_PROXY_VAR`], which are ASCII, so it is
324// one of the unrelated variables sharing the process environment and dropping it changes
325// no answer. A *value* is where `into_string().ok()` would be the misreading this crate
326// keeps finding: it cannot tell "unset" from "set to bytes that are not UTF-8", and those
327// are opposite answers here — a dropped `http_proxy` leaves the snapshot saying nobody
328// configured a proxy at all, with nothing in [`ProxyEnv::rejected`] to say otherwise. So
329// the value is converted the lossy way instead, which is what
330// [`sys::linux::desktop::text_if_set`](crate::sys) and `kde`'s `ProxyType = 4` lookup do
331// for the same reason: the replacement characters are what
332// [`ProxyEndpoint::parse`] refuses the address on, so the variable is recorded rather than
333// vanishing. That refusal reads the whole authority and not only the host — see the check
334// itself for why a password in the authority is part of what it refuses on.
335//
336// Taking the pair rather than reading the environment so the split can be tested without
337// a process-global variable.
338fn readable_var(
339    (name, value): (std::ffi::OsString, std::ffi::OsString),
340) -> Option<(String, String)> {
341    Some((
342        name.into_string().ok()?,
343        value.to_string_lossy().into_owned(),
344    ))
345}
346
347// Lowercase name first, uppercase name second — then, on Windows only, whatever other
348// letter case the variable was actually set in.
349fn lookup<'a>(map: &'a HashMap<String, String>, lower: &str) -> Option<&'a str> {
350    map.get(lower)
351        .or_else(|| map.get(&lower.to_ascii_uppercase()))
352        .or_else(|| any_case_on_windows(map, lower).map(|(_, value)| value))
353        .map(String::as_str)
354}
355
356// The entry whose key equals `name` ignoring case — on Windows only, where that is the
357// same variable rather than a different one.
358fn any_case_on_windows<'a>(
359    map: &'a HashMap<String, String>,
360    name: &str,
361) -> Option<(&'a String, &'a String)> {
362    if !cfg!(windows) {
363        return None;
364    }
365    map.iter()
366        .filter(|(key, _)| key.eq_ignore_ascii_case(name))
367        .min_by_key(|(key, _)| *key)
368}
369
370// The tests below need an `OsString` that is not valid Unicode, and only Windows and Unix can
371// build one; anywhere else they would run on an ordinary string and prove nothing. The gate
372// is on the module and not on each test because an empty `mod tests` still carries its
373// `use super::*`, and on `wasm32-unknown-unknown` — the one target that reaches `sys::stub` —
374// that unused import is an error under `-D warnings`.
375#[cfg(all(test, any(windows, unix)))]
376mod tests {
377    use super::*;
378
379    // The impl above is hand-written for the whole struct, and its comment names only the
380    // scheme order as the reason, so this test is the only thing holding the field list.
381    // `captured_at` is the field `PartialEq` refuses to compare and `to_config` carries over
382    // on purpose — it is how old the reading is — so a snapshot printed without it has no
383    // age, and two taken minutes apart render identically.
384    //
385    // Exact string, so a label, a field order or the scheme order cannot change unseen. The
386    // entries, the bypass rules and the timestamp keep their own renderings, which this impl
387    // does not own, so the expectation defers to them rather than copying them out.
388    #[test]
389    fn the_env_debug_lists_every_field_and_sorts_the_schemes() {
390        let env = ProxyEnv::from_vars([
391            ("https_proxy", "http://secure.corp:8443"),
392            ("http_proxy", "http://plain.corp:8080"),
393            ("no_proxy", ".example.com"),
394        ])
395        .expect("no REQUEST_METHOD is set here");
396        assert_eq!(
397            format!("{env:?}"),
398            format!(
399                "ProxyEnv {{ per_scheme: {{Http: {:?}, Https: {:?}}}, bypass: {:?}, \
400                 rejected: [], captured_at: {:?} }}",
401                env.per_scheme[&Scheme::Http],
402                env.per_scheme[&Scheme::Https],
403                env.bypass,
404                env.captured_at
405            )
406        );
407    }
408
409    // A variable set to bytes with no UTF-8 reading is *set*, and the answer this crate
410    // exists to avoid is "nobody configured a proxy" when somebody did. Read with
411    // `into_string().ok()` the variable arrived as absent, so a mangled `http_proxy` was
412    // indistinguishable from an unset one — not even a `rejected` entry to look at.
413    #[test]
414    fn an_http_proxy_that_is_not_unicode_is_refused_rather_than_dropped() {
415        #[cfg(windows)]
416        // A lone UTF-16 surrogate has no UTF-8 representation.
417        let raw = {
418            use std::os::windows::ffi::OsStringExt;
419            std::ffi::OsString::from_wide(&[0xD800])
420        };
421        #[cfg(unix)]
422        // 0xFF is not a valid UTF-8 lead byte.
423        let raw = {
424            use std::os::unix::ffi::OsStringExt;
425            std::ffi::OsString::from_vec(vec![0xFF])
426        };
427
428        let (name, value) = readable_var((std::ffi::OsString::from("http_proxy"), raw))
429            .expect("a variable whose name is ASCII stays in the snapshot");
430        let env = ProxyEnv::from_vars([(name, value)]).expect("no REQUEST_METHOD is set here");
431        assert!(
432            env.endpoint_for(Scheme::Http).is_none(),
433            "replacement characters are not an address to send traffic to"
434        );
435        assert_eq!(
436            env.rejected().len(),
437            1,
438            "the variable is set, so its drop has to be visible: {:?}",
439            env.rejected()
440        );
441    }
442
443    // The same promise, at the place it is hardest to keep. The test above puts the
444    // undecodable byte where the whole value is the host, and a host never survives one. Put
445    // it in the password instead and the address around it is well formed, so without the
446    // authority-wide refusal in `ProxyEndpoint::parse` this reads `Ok`: the snapshot names
447    // `proxy.corp:8080` with `rejected` empty and a secret the reader invented attached to it.
448    #[test]
449    fn a_password_that_is_not_unicode_is_refused_like_a_host_that_is_not() {
450        #[cfg(windows)]
451        let raw = {
452            use std::os::windows::ffi::OsStringExt;
453            let mut units: Vec<u16> = "http://alice:".encode_utf16().collect();
454            units.push(0xD800);
455            units.extend("@proxy.corp:8080".encode_utf16());
456            std::ffi::OsString::from_wide(&units)
457        };
458        #[cfg(unix)]
459        let raw = {
460            use std::os::unix::ffi::OsStringExt;
461            let mut bytes = b"http://alice:".to_vec();
462            bytes.push(0xFF);
463            bytes.extend_from_slice(b"@proxy.corp:8080");
464            std::ffi::OsString::from_vec(bytes)
465        };
466
467        let (name, value) = readable_var((std::ffi::OsString::from("http_proxy"), raw))
468            .expect("a variable whose name is ASCII stays in the snapshot");
469        // Not vacuous: everything but the password is a perfectly ordinary address.
470        assert!(value.contains("@proxy.corp:8080"), "{value}");
471
472        let env = ProxyEnv::from_vars([(name, value)]).expect("no REQUEST_METHOD is set here");
473        assert!(
474            env.endpoint_for(Scheme::Http).is_none(),
475            "a credential the reader had to invent is not one to offer a proxy"
476        );
477        assert_eq!(
478            env.rejected().len(),
479            1,
480            "the variable is set, so its drop has to be visible: {:?}",
481            env.rejected()
482        );
483    }
484
485    // The other half: a name that cannot be one of the ASCII variables this crate reads is
486    // dropped, and dropping it has to stay silent — every process carries some of these.
487    #[test]
488    fn a_variable_whose_name_is_not_unicode_is_dropped_without_a_trace() {
489        #[cfg(windows)]
490        let raw = {
491            use std::os::windows::ffi::OsStringExt;
492            std::ffi::OsString::from_wide(&[0xD800])
493        };
494        #[cfg(unix)]
495        let raw = {
496            use std::os::unix::ffi::OsStringExt;
497            std::ffi::OsString::from_vec(vec![0xFF])
498        };
499
500        assert_eq!(
501            readable_var((raw, std::ffi::OsString::from("http://p:8080"))),
502            None
503        );
504    }
505}