system_proxy/
env.rs

1// Copyright (c) 2022 Sebastian Wiesner <sebastian@swsnr.de>
2//
3// This Source Code Form is subject to the terms of the Mozilla Public
4// License, v. 2.0. If a copy of the MPL was not distributed with this
5// file, You can obtain one at https://mozilla.org/MPL/2.0/.
6
7//! Resolve proxies via environment variables.
8//!
9//! This module provides means to get proxy settings from the environment as understood by the
10//! [curl](https://curl.se/) tool.
11//!
12//! The [`EnvProxies`] struct extracts the HTTP and HTTPS proxies as well as no-proxy rules from
13//! the curl environment variables (see [`EnvProxies::from_curl_env`]).  The latter part is
14//! available separately via [`NoProxyRules`].
15//!
16//! Note that the precise meaning of no-proxy rules in the relevant environment variables varies
17//! wildly between different implementations.  This module tries to follow curl as closely as
18//! possible for maximum compatibility, and thus does not support more advanced no-proxy rules,
19//! e.g. based on IP subnet masks.
20
21use std::ops::Not;
22
23use url::{Host, Url};
24
25/// A trait which represents a rule for when to skip a proxy.
26pub trait NoProxy {
27    /// Whether *not* to use a proxy for the given `url`.
28    ///
29    /// Return `true` if a direct connection should be used for `url`, or `false` if `url` should
30    /// use a proxy.
31    fn no_proxy_for(&self, url: &Url) -> bool;
32
33    /// Whether to use a proxy for the given `url`.
34    ///
35    /// Return `true` if a proxy should be used for `url` or `false` if a direct connection should
36    /// be used.
37    fn proxy_allowed_for(&self, url: &Url) -> bool {
38        self.no_proxy_for(url).not()
39    }
40}
41
42/// A single rule for when not to use a proxy.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum NoProxyRule {
45    /// Match the given hostname exactly.
46    MatchExact(String),
47    /// Match a domain and all its subdomains.
48    MatchSubdomain(String),
49}
50
51static_assertions::assert_impl_all!(NoProxyRule: Send, Sync);
52
53impl NoProxy for NoProxyRule {
54    fn no_proxy_for(&self, url: &Url) -> bool {
55        match self {
56            Self::MatchExact(host) => match url.host() {
57                Some(Host::Domain(domain)) => domain == host,
58                Some(Host::Ipv4(ipv4)) => &ipv4.to_string() == host,
59                Some(Host::Ipv6(ipv6)) => &ipv6.to_string() == host,
60                None => false,
61            },
62            Self::MatchSubdomain(subdomain) => match url.host() {
63                Some(Host::Domain(domain)) => {
64                    domain.ends_with(subdomain) || domain == &subdomain[1..]
65                }
66                _ => false,
67            },
68        }
69    }
70}
71
72/// Combine multiple rules for when not to use a proxy.
73#[derive(Debug, Clone, Eq, PartialEq)]
74pub enum NoProxyRules {
75    /// Do not use a proxy for all hosts.
76    All,
77    /// Do not use a proxy if any of the given rules matches.
78    ///
79    /// If the list of rules is empty, always use a proxy.
80    Rules(Vec<NoProxyRule>),
81}
82
83static_assertions::assert_impl_all!(NoProxyRules: Send, Sync);
84
85fn lookup(var: &str) -> Option<String> {
86    std::env::var_os(var).and_then(|v| {
87        v.to_str().map(ToOwned::to_owned).or_else(|| {
88            log::warn!("Variable ${} does not contain valid unicode, skipping", var);
89            None
90        })
91    })
92}
93
94impl NoProxyRules {
95    /// Create no proxy rules.
96    pub fn new(rules: Vec<NoProxyRule>) -> Self {
97        Self::Rules(rules)
98    }
99
100    /// Use a proxy for all URLs.
101    pub fn none() -> Self {
102        NoProxyRules::Rules(Vec::new())
103    }
104
105    /// Never use a proxy for any URL.
106    pub fn all() -> Self {
107        Self::All
108    }
109
110    /// Parse a curl no proxy rule from `value`.
111    ///
112    /// See [`Self::from_curl_env()`] for the details of the format.
113    pub fn parse_curl_env<S: AsRef<str>>(value: S) -> Self {
114        let value = value.as_ref().trim();
115        if value == "*" {
116            Self::all()
117        } else {
118            let rules = value
119                .split(',')
120                .map(|r| r.trim())
121                .filter(|r| !r.is_empty())
122                .map(|rule| {
123                    if rule.starts_with('.') {
124                        NoProxyRule::MatchSubdomain(rule.to_string())
125                    } else {
126                        NoProxyRule::MatchExact(rule.to_string())
127                    }
128                })
129                .collect::<Vec<_>>();
130            Self::new(rules)
131        }
132    }
133
134    /// Lookup no proxy rules in Curl environment variables `$no_proxy` and `$NO_PROXY`.
135    ///
136    /// `$no_proxy` and `$NO_PROXY` either contain a single wildcard `*` or a comma separated list
137    /// of hostnames.  In the first case the proxy is disabled for all URLs, in the second case it
138    /// is disabled if it matches any hostname in the list.
139    ///
140    /// If a hostname starts with `.` it matches the host itself as well as all of its subdomains;
141    /// otherwise it must match the host exactly.  IPv4 and IPv6 addresses can be used as well, but
142    /// are compared as strings, i.e. no wildcards and no subnet specifications.  In other words
143    /// neither `192.168.1.*` nor `192.168.1.0/24` will work; there's _no way_ to disable the proxy
144    /// for an IP address range.  This limitation is inherted from curl.
145    ///
146    /// All extra whitespace in rules or around the value is ignored.
147    ///
148    /// The lowercase `$no_proxy` takes precedence over `$NO_PROXY` if both are defined.
149    ///
150    /// Return the rules extracted from either variable, or `None` if both variables are unset.
151    pub fn from_curl_env() -> Option<Self> {
152        lookup("no_proxy")
153            .or_else(|| lookup("NO_PROXY"))
154            .map(Self::parse_curl_env)
155    }
156}
157
158impl NoProxy for NoProxyRules {
159    fn no_proxy_for(&self, url: &Url) -> bool {
160        match self {
161            NoProxyRules::All => true,
162            NoProxyRules::Rules(ref rules) => rules.iter().any(|rule| rule.no_proxy_for(url)),
163        }
164    }
165}
166
167impl From<Vec<NoProxyRule>> for NoProxyRules {
168    fn from(rules: Vec<NoProxyRule>) -> Self {
169        Self::new(rules)
170    }
171}
172
173impl From<NoProxyRule> for NoProxyRules {
174    fn from(rule: NoProxyRule) -> Self {
175        Self::new(vec![rule])
176    }
177}
178
179impl Default for NoProxyRules {
180    /// Empty no proxy rules, i.e. always use a proxy.
181    fn default() -> Self {
182        NoProxyRules::none()
183    }
184}
185
186/// Proxies extracted from the environment.
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct EnvProxies {
189    /// The proxy to use for `http:` URLs.
190    ///
191    /// `None` if no HTTP proxy was set in the environment.
192    pub http: Option<Url>,
193    /// The proxy to use for `https:` URLs.
194    ///
195    /// `None` if no HTTPS proxy was set in the environment.
196    pub https: Option<Url>,
197    /// When not to use a proxy.
198    ///
199    /// `None` if no such rules where present in the environment.
200    pub no_proxy_rules: Option<NoProxyRules>,
201}
202
203fn lookup_url(var: &str) -> Option<Url> {
204    lookup(var).as_ref().and_then(|s| match Url::parse(s) {
205        Ok(url) => Some(url),
206        Err(error) => {
207            log::warn!(
208                "Failed to parse value of ${} as URL, skipping: {}",
209                var,
210                error
211            );
212            None
213        }
214    })
215}
216
217impl EnvProxies {
218    /// No HTTP and HTTPS proxies in the environment.
219    pub fn unset() -> Self {
220        Self {
221            http: None,
222            https: None,
223            no_proxy_rules: None,
224        }
225    }
226
227    /// Get proxies defined in the curl environment.
228    ///
229    /// Get the proxy to use for http and https URLs from `$http_proxy` and `$https_proxy`
230    /// respectively.  If one variable is not defined look at the uppercase variants instead;
231    /// unlike curl this function also uses `$HTTP_PROXY` as fallback.
232    ///
233    /// IP addresses are matched as if they were host names, i.e. as strings.  IPv6 addresses
234    /// should be given without enclosing brackets.
235    ///
236    /// If either of these proxies is set also look take no proxy rules from the curl environemnt
237    /// with [`NoProxyRules::from_curl_env()`]
238    ///
239    /// If none of these variables is defined return [`EnvProxies::unset()`].
240    ///
241    /// See [`curl(1)`](https://curl.se/docs/manpage.html) for details of curl's proxy settings.
242    pub fn from_curl_env() -> Self {
243        Self {
244            http: lookup_url("http_proxy").or_else(|| lookup_url("HTTP_PROXY")),
245            https: lookup_url("https_proxy").or_else(|| lookup_url("HTTPS_PROXY")),
246            no_proxy_rules: NoProxyRules::from_curl_env(),
247        }
248    }
249
250    /// Whether no proxies were set in the environment.
251    ///
252    /// Returns `true` if all of `$http_proxy` and `$https_proxy` as well as their uppercase
253    /// variants were not set in the environment.
254    pub fn is_unset(&self) -> bool {
255        self.http.is_none() && self.https.is_none()
256    }
257
258    /// Lookup a proxy server for the given `url`.
259    pub fn lookup(&self, url: &Url) -> Option<&Url> {
260        let rules = self.no_proxy_rules.as_ref();
261        let proxy = match url.scheme() {
262            "http" => self.http.as_ref(),
263            "https" => self.https.as_ref(),
264            _ => None,
265        };
266        if proxy.is_some() && rules.map_or(true, |r| r.proxy_allowed_for(url)) {
267            proxy
268        } else {
269            None
270        }
271    }
272}
273
274/// Get proxies from curl environment.
275///
276/// See [`EnvProxies::from_curl_env`].
277pub fn from_curl_env() -> EnvProxies {
278    EnvProxies::from_curl_env()
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use pretty_assertions::assert_eq;
285
286    #[test]
287    fn noproxy_rule_subdomain() {
288        let rule = NoProxyRule::MatchSubdomain(".example.com".to_string());
289        assert!(rule.no_proxy_for(&Url::parse("http://example.com/foo").unwrap()));
290        assert!(rule.no_proxy_for(&Url::parse("http://example.com/bar").unwrap()));
291        assert!(rule.no_proxy_for(&Url::parse("http://foo.example.com/foo").unwrap()));
292        assert!(!rule.no_proxy_for(&Url::parse("http://barexample.com/foo").unwrap()));
293    }
294
295    #[test]
296    fn noproxy_rule_exact_hostname() {
297        let rule = NoProxyRule::MatchExact("example.com".to_string());
298        assert!(rule.no_proxy_for(&Url::parse("http://example.com/foo").unwrap()));
299        assert!(rule.no_proxy_for(&Url::parse("http://example.com/bar").unwrap()));
300        assert!(!rule.no_proxy_for(&Url::parse("http://foo.example.com/foo").unwrap()));
301        assert!(!rule.no_proxy_for(&Url::parse("http://barexample.com/foo").unwrap()));
302    }
303
304    #[test]
305    fn noproxy_rule_exact_ipv4() {
306        let rule = NoProxyRule::MatchExact("192.168.100.12".to_string());
307        assert!(rule.no_proxy_for(&Url::parse("http://192.168.100.12/foo").unwrap()));
308        assert!(!rule.no_proxy_for(&Url::parse("http://192.168.100.122/foo").unwrap()));
309    }
310
311    #[test]
312    fn noproxy_rule_exact_ipv6() {
313        let rule = NoProxyRule::MatchExact("fe80::2ead:fea3:1423:6637".to_string());
314        assert!(rule.no_proxy_for(&Url::parse("http://[fe80::2ead:fea3:1423:6637]/foo").unwrap()));
315        assert!(!rule.no_proxy_for(&Url::parse("http://[fe80::2ead:fea3:1423:6638]/foo").unwrap()));
316    }
317
318    #[test]
319    fn noproxy_rules_all_matches() {
320        let samples = vec![
321            "http://[fe80::2ead:fea3:1423:6637]/foo",
322            "http://192.168.100.12/foo",
323            "http://foo.example.com/foo",
324            "http:///foo",
325        ];
326        for url in samples {
327            assert!(
328                NoProxyRules::All.no_proxy_for(&Url::parse(url).unwrap()),
329                "URL: {}",
330                url
331            );
332        }
333    }
334
335    #[test]
336    fn noproxy_rules_none_matches() {
337        let samples = vec![
338            "http://[fe80::2ead:fea3:1423:6637]/foo",
339            "http://192.168.100.12/foo",
340            "http://foo.example.com/foo",
341            "http:///foo",
342        ];
343        for url in samples {
344            assert!(
345                !NoProxyRules::Rules(Vec::new()).no_proxy_for(&Url::parse(url).unwrap()),
346                "URL: {}",
347                url
348            );
349        }
350    }
351
352    #[test]
353    fn noproxy_rules_matches() {
354        let rules = NoProxyRules::Rules(vec![
355            NoProxyRule::MatchSubdomain(".example.com".to_string()),
356            NoProxyRule::MatchExact("192.168.12.100".to_string()),
357        ]);
358
359        assert!(rules.no_proxy_for(&Url::parse("http://example.com").unwrap()));
360        assert!(rules.no_proxy_for(&Url::parse("http://foo.example.com").unwrap()));
361        assert!(rules.no_proxy_for(&Url::parse("http://192.168.12.100/foo").unwrap()));
362
363        assert!(!rules.no_proxy_for(&Url::parse("http://192.168.12.101/foo").unwrap()));
364        assert!(!rules.no_proxy_for(&Url::parse("http://192.168.12/foo").unwrap()));
365        assert!(!rules.no_proxy_for(&Url::parse("http://fooexample.com/foo").unwrap()));
366        assert!(!rules.no_proxy_for(&Url::parse("http://github.com/swsnr").unwrap()));
367    }
368
369    #[test]
370    fn from_curl_env_no_env() {
371        temp_env::with_vars_unset(
372            vec![
373                "http_proxy",
374                "https_proxy",
375                "no_proxy",
376                "HTTP_PROXY",
377                "HTTPS_PROXY",
378                "NO_PROXY",
379            ],
380            || {
381                assert_eq!(
382                    EnvProxies::from_curl_env(),
383                    EnvProxies {
384                        http: None,
385                        https: None,
386                        no_proxy_rules: None
387                    }
388                )
389            },
390        )
391    }
392
393    #[test]
394    fn from_curl_env_lowercase() {
395        temp_env::with_vars(
396            vec![
397                ("http_proxy", Some("http://thehttpproxy:1234")),
398                ("https_proxy", Some("http://thehttpsproxy:1234")),
399                ("no_proxy", Some("example.com")),
400            ],
401            || {
402                assert_eq!(
403                    EnvProxies::from_curl_env(),
404                    EnvProxies {
405                        http: Some(Url::parse("http://thehttpproxy:1234").unwrap()),
406                        https: Some(Url::parse("http://thehttpsproxy:1234").unwrap()),
407                        no_proxy_rules: Some(
408                            NoProxyRule::MatchExact("example.com".to_string()).into()
409                        )
410                    }
411                )
412            },
413        )
414    }
415
416    #[test]
417    fn from_curl_env_uppercase() {
418        temp_env::with_vars(
419            vec![
420                ("http_proxy", None),
421                ("https_proxy", None),
422                ("no_proxy", None),
423                ("HTTP_PROXY", Some("http://thehttpproxy:1234")),
424                ("HTTPS_PROXY", Some("http://thehttpsproxy:1234")),
425                ("NO_PROXY", Some("example.com")),
426            ],
427            || {
428                assert_eq!(
429                    EnvProxies::from_curl_env(),
430                    EnvProxies {
431                        http: Some(Url::parse("http://thehttpproxy:1234").unwrap()),
432                        https: Some(Url::parse("http://thehttpsproxy:1234").unwrap()),
433                        no_proxy_rules: Some(
434                            NoProxyRule::MatchExact("example.com".to_string()).into()
435                        )
436                    }
437                )
438            },
439        )
440    }
441
442    #[test]
443    fn from_curl_env_both() {
444        temp_env::with_vars(
445            vec![
446                ("HTTP_PROXY", Some("http://up.thehttpproxy:1234")),
447                ("HTTPS_PROXY", Some("http://up.thehttpsproxy:1234")),
448                ("NO_PROXY", Some("up.example.com")),
449                ("http_proxy", Some("http://low.thehttpproxy:1234")),
450                ("https_proxy", Some("http://low.thehttpsproxy:1234")),
451                ("no_proxy", Some("low.example.com")),
452            ],
453            || {
454                assert_eq!(
455                    EnvProxies::from_curl_env(),
456                    EnvProxies {
457                        http: Some(Url::parse("http://low.thehttpproxy:1234").unwrap()),
458                        https: Some(Url::parse("http://low.thehttpsproxy:1234").unwrap()),
459                        no_proxy_rules: Some(
460                            NoProxyRule::MatchExact("low.example.com".to_string()).into()
461                        )
462                    }
463                )
464            },
465        )
466    }
467
468    #[test]
469    fn parse_no_proxy_rules_many_rules() {
470        let rules = NoProxyRules::parse_curl_env("example.com ,.example.com , foo.bar,192.122.100.10, fe80::2ead:fea3:1423:6637,[fe80::2ead:fea3:1423:6637]");
471        assert_eq!(
472            rules,
473            NoProxyRules::Rules(vec![
474                NoProxyRule::MatchExact("example.com".into()),
475                NoProxyRule::MatchSubdomain(".example.com".into()),
476                NoProxyRule::MatchExact("foo.bar".into()),
477                NoProxyRule::MatchExact("192.122.100.10".into()),
478                NoProxyRule::MatchExact("fe80::2ead:fea3:1423:6637".into()),
479                NoProxyRule::MatchExact("[fe80::2ead:fea3:1423:6637]".into()),
480            ])
481        );
482    }
483
484    #[test]
485    fn parse_no_proxy_rules_wildcard() {
486        assert_eq!(NoProxyRules::parse_curl_env("*"), NoProxyRules::all());
487        assert_eq!(NoProxyRules::parse_curl_env(" * "), NoProxyRules::all());
488        assert_eq!(
489            NoProxyRules::parse_curl_env("*,foo.example.com"),
490            NoProxyRules::Rules(vec![
491                NoProxyRule::MatchExact("*".into()),
492                NoProxyRule::MatchExact("foo.example.com".into())
493            ])
494        );
495    }
496
497    #[test]
498    fn parse_no_proxy_rules_empty() {
499        assert_eq!(NoProxyRules::parse_curl_env(""), NoProxyRules::default());
500        assert_eq!(NoProxyRules::parse_curl_env("  "), NoProxyRules::default());
501        assert_eq!(
502            NoProxyRules::parse_curl_env("\t  "),
503            NoProxyRules::default()
504        );
505    }
506
507    #[test]
508    fn lookup_http_proxy() {
509        let proxies = EnvProxies {
510            http: Some(Url::parse("http://httproxy.example.com:1284").unwrap()),
511            https: None,
512            no_proxy_rules: Some(NoProxyRules::default()),
513        };
514        assert_eq!(
515            proxies.lookup(&Url::parse("http://github.com").unwrap()),
516            Some(&Url::parse("http://httproxy.example.com:1284").unwrap())
517        );
518        assert_eq!(
519            proxies.lookup(&Url::parse("https://github.com").unwrap()),
520            None
521        );
522    }
523
524    #[test]
525    fn lookup_https_proxy() {
526        let proxies = EnvProxies {
527            http: None,
528            https: Some(Url::parse("http://httpsproxy.example.com:1284").unwrap()),
529            no_proxy_rules: Some(NoProxyRules::default()),
530        };
531        assert_eq!(
532            proxies.lookup(&Url::parse("https://github.com").unwrap()),
533            Some(&Url::parse("http://httpsproxy.example.com:1284").unwrap())
534        );
535        assert_eq!(
536            proxies.lookup(&Url::parse("http://github.com").unwrap()),
537            None
538        );
539    }
540
541    #[test]
542    fn lookup_rule_matches() {
543        let proxies = EnvProxies {
544            http: Some(Url::parse("http://httproxy.example.com:1284").unwrap()),
545            https: Some(Url::parse("http://httpsproxy.example.com:1284").unwrap()),
546            no_proxy_rules: Some(NoProxyRules::All),
547        };
548        assert_eq!(
549            proxies.lookup(&Url::parse("https://github.com").unwrap()),
550            None
551        );
552        assert_eq!(
553            proxies.lookup(&Url::parse("http://github.com").unwrap()),
554            None
555        );
556
557        let proxies = EnvProxies {
558            http: Some(Url::parse("http://httproxy.example.com:1284").unwrap()),
559            https: Some(Url::parse("http://httpsproxy.example.com:1284").unwrap()),
560            no_proxy_rules: Some(NoProxyRules::parse_curl_env("github.com")),
561        };
562        assert_eq!(
563            proxies.lookup(&Url::parse("https://github.com").unwrap()),
564            None
565        );
566        assert_eq!(
567            proxies.lookup(&Url::parse("http://github.com").unwrap()),
568            None
569        );
570    }
571
572    #[test]
573    fn lookup_rule_does_not_match() {
574        let resolver = EnvProxies {
575            http: Some(Url::parse("http://httproxy.example.com:1284").unwrap()),
576            https: Some(Url::parse("http://httpsproxy.example.com:1284").unwrap()),
577            no_proxy_rules: Some(NoProxyRules::default()),
578        };
579        assert_eq!(
580            resolver.lookup(&Url::parse("https://github.com").unwrap()),
581            Some(&Url::parse("http://httpsproxy.example.com:1284").unwrap())
582        );
583        assert_eq!(
584            resolver.lookup(&Url::parse("http://github.com").unwrap()),
585            Some(&Url::parse("http://httproxy.example.com:1284").unwrap())
586        );
587
588        let proxies = EnvProxies {
589            http: Some(Url::parse("http://httproxy.example.com:1284").unwrap()),
590            https: Some(Url::parse("http://httpsproxy.example.com:1284").unwrap()),
591            no_proxy_rules: Some(NoProxyRules::parse_curl_env("github.net")),
592        };
593        assert_eq!(
594            proxies.lookup(&Url::parse("https://github.com").unwrap()),
595            Some(&Url::parse("http://httpsproxy.example.com:1284").unwrap())
596        );
597        assert_eq!(
598            proxies.lookup(&Url::parse("http://github.com").unwrap()),
599            Some(&Url::parse("http://httproxy.example.com:1284").unwrap())
600        );
601    }
602}