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