Skip to main content

acme_proxy/
proxy.rs

1//! `[proxy]` — which forward proxy, if any, an outbound connection goes
2//! through.
3//!
4//! The *policy* half of outbound HTTP, the way [`crate::http_client`] is the
5//! plumbing half: that module's own doc says it "owns the plumbing and nothing
6//! else", and selection is not plumbing. It is an environment fallback with a
7//! documented precedence, six matching rules, a startup vocabulary of refusals
8//! and a credential that must never reach a log.
9//!
10//! Reaching into [`crate::filter`] for [`parse_net`]/[`canonical`] is a choice,
11//! not an accident: that subsystem is inbound policy and this is outbound, but
12//! CIDR parsing is CIDR parsing, and two of them would drift. If a third
13//! consumer ever appears, hoist those four helpers into a neutral module rather
14//! than growing a second copy here.
15
16use std::net::IpAddr;
17use std::sync::Arc;
18
19use base64::prelude::*;
20use ipnet::IpNet;
21use tracing::info;
22use url::Url;
23
24use crate::config::ProxyConfig;
25use crate::filter::{canonical, parse_net};
26use crate::http_client::Endpoint;
27
28/// One proxy, already picked apart.
29///
30/// Cleartext to the proxy itself by construction — [`OutboundProxies`] refuses
31/// an `https://` URL before one of these is ever built.
32#[derive(Clone, PartialEq, Eq)]
33pub struct ProxyTarget {
34    endpoint: Endpoint,
35    /// The finished `Basic …` header value, built once from the URL's userinfo.
36    /// `None` when the URL carried no credentials.
37    authorization: Option<String>,
38    /// The configured URL with any password replaced — the only rendering of
39    /// this value that exists.
40    redacted: String,
41}
42
43impl ProxyTarget {
44    pub(crate) fn endpoint(&self) -> &Endpoint {
45        &self.endpoint
46    }
47
48    pub(crate) fn authorization(&self) -> Option<&str> {
49        self.authorization.as_deref()
50    }
51
52    /// The proxy's URL with any password replaced by `***`, for an error
53    /// message or a log line.
54    pub(crate) fn redacted(&self) -> &str {
55        &self.redacted
56    }
57}
58
59#[cfg(test)]
60impl ProxyTarget {
61    /// One target straight from a URL, for the transport tests.
62    pub(crate) fn for_test(url: &str) -> Self {
63        Self::parse(url, "proxy.http_url").expect("the test URL must parse")
64    }
65}
66
67/// Hand-written, never derived: `authorization` is a credential, and a derived
68/// `Debug` on a struct holding one is how it ends up in a log. The same reason
69/// [`crate::ipam::http::JsonApi`]'s is hand-written.
70impl std::fmt::Debug for ProxyTarget {
71    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        formatter
73            .debug_struct("ProxyTarget")
74            .field("url", &self.redacted)
75            .field("authenticated", &self.authorization.is_some())
76            .finish()
77    }
78}
79
80/// One `no_proxy` entry, parsed.
81#[derive(Debug, Clone, PartialEq, Eq)]
82enum BypassRule {
83    /// `*` — every target bypasses.
84    Everything,
85    /// A domain, normalized: no leading `.`, no trailing `.`, lowercase.
86    /// Matches the name itself and anything under it.
87    Suffix(String),
88    /// An address or CIDR block, matched only against a target that is itself
89    /// an address literal.
90    Network(IpNet),
91}
92
93/// The process-wide outbound proxy configuration, resolved once at startup.
94///
95/// Two independent proxies rather than one: an estate that proxies only its TLS
96/// egress is ordinary, and `select` returning `None` for https because only
97/// `http_url` was set is a decision an operator made, not a gap.
98#[derive(Debug, Clone, Default)]
99pub struct OutboundProxies {
100    http: Option<ProxyTarget>,
101    https: Option<ProxyTarget>,
102    bypass: Vec<BypassRule>,
103}
104
105impl OutboundProxies {
106    /// Resolves `[proxy]`, falling back to the conventional environment
107    /// variables for any key left empty.
108    ///
109    /// Precedence, highest first:
110    ///
111    /// | key | then | then |
112    /// | --- | --- | --- |
113    /// | `proxy.http_url` | `$http_proxy` | — |
114    /// | `proxy.https_url` | `$https_proxy` | `$HTTPS_PROXY` |
115    /// | `proxy.no_proxy` | `$no_proxy` | `$NO_PROXY` |
116    ///
117    /// An empty string is "unset" in both sources: `config` cannot tell an
118    /// absent environment variable from one a `${VAR:-}` shell default left
119    /// empty, and neither can a shell.
120    ///
121    /// **Uppercase `HTTP_PROXY` is deliberately not read.** Under CGI a
122    /// client-supplied `Proxy:` request header lands in the environment as
123    /// `HTTP_PROXY` (httpoxy, CVE-2016-5385 and friends). This server is never
124    /// a CGI process, so the vector does not reach it — but Go's `net/http`
125    /// dropped the variable for exactly this reason, matching it costs nothing,
126    /// and honouring a variable purely because everyone does is the kind of
127    /// thing that is only ever wrong. `HTTPS_PROXY` has no such history and is
128    /// honoured.
129    ///
130    /// Every failure here is fatal: a proxy that cannot be understood must stop
131    /// the process, never quietly leave egress going somewhere else.
132    pub fn from_config(cfg: &ProxyConfig) -> anyhow::Result<Self> {
133        let (http_url, http_source) = resolve(&cfg.http_url, &["http_proxy"]);
134        let (https_url, https_source) = resolve(&cfg.https_url, &["https_proxy", "HTTPS_PROXY"]);
135        let (no_proxy, no_proxy_source) = match cfg.no_proxy.is_empty() {
136            false => (cfg.no_proxy.clone(), Source::Config),
137            true => {
138                let (raw, source) = resolve("", &["no_proxy", "NO_PROXY"]);
139                let entries = raw
140                    .map(|value| value.split(',').map(str::to_string).collect())
141                    .unwrap_or_default();
142                (entries, source)
143            }
144        };
145
146        let http = http_url
147            .as_deref()
148            .map(|url| ProxyTarget::parse(url, "proxy.http_url"))
149            .transpose()?;
150        let https = https_url
151            .as_deref()
152            .map(|url| ProxyTarget::parse(url, "proxy.https_url"))
153            .transpose()?;
154        let bypass = parse_bypass(&no_proxy)?;
155
156        let resolved = Self {
157            http,
158            https,
159            bypass,
160        };
161        if resolved.is_configured() {
162            info!(
163                event = "proxy_configured",
164                outcome = "advisory",
165                source = %Source::describe(&[http_source, https_source, no_proxy_source]),
166                http_proxy = resolved.http.as_ref().map_or("-", ProxyTarget::redacted),
167                https_proxy = resolved.https.as_ref().map_or("-", ProxyTarget::redacted),
168                no_proxy_rules = resolved.bypass.len(),
169            );
170        }
171        Ok(resolved)
172    }
173
174    /// Nothing configured: every connection dials the origin directly.
175    pub fn direct() -> Self {
176        Self::default()
177    }
178
179    /// Whether any proxy is configured at all.
180    pub fn is_configured(&self) -> bool {
181        self.http.is_some() || self.https.is_some()
182    }
183
184    /// Which proxy, if any, `endpoint` goes through.
185    ///
186    /// Loopback and `localhost` bypass unconditionally, before `no_proxy` is
187    /// consulted and whatever it says. The reason is the environment fallback:
188    /// an operator's inherited shell `http_proxy` must not route this server's
189    /// own loopback traffic through a corporate proxy, and the failure that
190    /// would cause has no signal in it at all.
191    ///
192    /// A `Network` rule is compared only against a target that is *already* an
193    /// address literal. Resolving a name to test one would mean a DNS lookup
194    /// per outbound request, and a lookup whose answer the connect path is then
195    /// free to disagree with.
196    pub(crate) fn select(&self, endpoint: &Endpoint) -> Option<&ProxyTarget> {
197        let proxy = match endpoint.https {
198            true => self.https.as_ref(),
199            false => self.http.as_ref(),
200        }?;
201
202        let host = normalize_host(endpoint.host_for_lookup());
203        let address = host.parse::<IpAddr>().ok().map(canonical);
204        if host == "localhost" || address.is_some_and(|ip| ip.is_loopback()) {
205            return None;
206        }
207
208        let bypassed = self.bypass.iter().any(|rule| match rule {
209            BypassRule::Everything => true,
210            BypassRule::Suffix(suffix) => host == *suffix || host.ends_with(&format!(".{suffix}")),
211            BypassRule::Network(net) => address.is_some_and(|ip| net.contains(&ip)),
212        });
213
214        match bypassed {
215            true => None,
216            false => Some(proxy),
217        }
218    }
219
220    /// A proxy every endpoint goes through, loopback included — the only way a
221    /// test can drive the transport against a proxy on `127.0.0.1`.
222    #[cfg(test)]
223    pub(crate) fn always(target: ProxyTarget) -> Self {
224        Self {
225            http: Some(target.clone()),
226            https: Some(target),
227            bypass: Vec::new(),
228        }
229    }
230
231    /// The `no_proxy` half alone, for the test that proves a bypass really
232    /// leaves the proxy untouched.
233    #[cfg(test)]
234    pub(crate) fn with_bypass(mut self, entries: &[&str]) -> anyhow::Result<Self> {
235        let entries: Vec<String> = entries.iter().map(|entry| (*entry).to_string()).collect();
236        self.bypass = parse_bypass(&entries)?;
237        Ok(self)
238    }
239}
240
241impl ProxyTarget {
242    /// Parses one proxy URL, naming `setting` in any refusal so the message
243    /// points at the key that has to change.
244    ///
245    /// A bare `proxy.corp:3128` is accepted by prepending `http://`: it is what
246    /// operators write, and `Url::parse` would otherwise read `proxy.corp` as a
247    /// scheme and `3128` as the path.
248    fn parse(url: &str, setting: &str) -> anyhow::Result<Self> {
249        let trimmed = url.trim();
250        let spelled = match trimmed.contains("://") {
251            true => trimmed.to_string(),
252            false => format!("http://{trimmed}"),
253        };
254        let parsed = Url::parse(&spelled)
255            .map_err(|error| anyhow::anyhow!("{setting}: {url} is not a URL: {error}"))?;
256
257        match parsed.scheme() {
258            "http" => {}
259            "https" => anyhow::bail!(
260                "{setting}: {url} would reach the proxy over TLS, which is not supported. \
261                 This key names the proxy used *for* https targets, and that proxy is \
262                 normally still spelled http://host:port"
263            ),
264            other => anyhow::bail!(
265                "{setting}: unsupported proxy scheme {other}, expected http (there is no \
266                 SOCKS support)"
267            ),
268        }
269
270        let endpoint = Endpoint::from_url(&parsed)
271            .map_err(|error| anyhow::anyhow!("{setting}: {url}: {error}"))?;
272
273        let user = percent_decode(parsed.username())
274            .map_err(|error| anyhow::anyhow!("{setting}: {url}: username {error}"))?;
275        let authorization = match user.is_empty() {
276            true => None,
277            false => {
278                let password = match parsed.password() {
279                    Some(password) => percent_decode(password)
280                        .map_err(|error| anyhow::anyhow!("{setting}: {url}: password {error}"))?,
281                    None => String::new(),
282                };
283                Some(format!(
284                    "Basic {}",
285                    BASE64_STANDARD.encode(format!("{user}:{password}"))
286                ))
287            }
288        };
289
290        Ok(Self {
291            redacted: redact(&parsed),
292            endpoint,
293            authorization,
294        })
295    }
296}
297
298/// Where a resolved value came from, for the startup line.
299#[derive(Debug, Clone, Copy, PartialEq, Eq)]
300enum Source {
301    Config,
302    Environment,
303    Unset,
304}
305
306impl Source {
307    fn describe(sources: &[Self]) -> &'static str {
308        let config = sources.contains(&Self::Config);
309        let environment = sources.contains(&Self::Environment);
310        match (config, environment) {
311            (true, true) => "config+environment",
312            (true, false) => "config",
313            (false, true) => "environment",
314            (false, false) => "unset",
315        }
316    }
317}
318
319/// The configured value, or the first non-empty variable in `variables`.
320fn resolve(configured: &str, variables: &[&str]) -> (Option<String>, Source) {
321    if !configured.trim().is_empty() {
322        return (Some(configured.trim().to_string()), Source::Config);
323    }
324    for name in variables {
325        if let Ok(value) = std::env::var(name)
326            && !value.trim().is_empty()
327        {
328            return (Some(value.trim().to_string()), Source::Environment);
329        }
330    }
331    (None, Source::Unset)
332}
333
334/// Parses every `no_proxy` entry, refusing an unusable one by name.
335fn parse_bypass(entries: &[String]) -> anyhow::Result<Vec<BypassRule>> {
336    let mut rules = Vec::new();
337    for entry in entries {
338        let entry = entry.trim();
339        if entry.is_empty() {
340            continue;
341        }
342        if entry == "*" {
343            rules.push(BypassRule::Everything);
344            continue;
345        }
346        if let Ok(net) = parse_net(entry) {
347            rules.push(BypassRule::Network(net));
348            continue;
349        }
350        // A port here answers a different question than it looks like it
351        // answers — matching is on the host, and an entry that silently ignored
352        // half of itself is the failure this codebase refuses elsewhere.
353        if entry.contains(':') {
354            anyhow::bail!(
355                "proxy.no_proxy: {entry} carries a port, which is not honoured — \
356                 entries match on the host alone"
357            );
358        }
359        let suffix = entry
360            .trim_start_matches('.')
361            .trim_end_matches('.')
362            .to_ascii_lowercase();
363        if suffix.is_empty() {
364            anyhow::bail!("proxy.no_proxy: {entry} is not a domain, address or network");
365        }
366        rules.push(BypassRule::Suffix(suffix));
367    }
368    Ok(rules)
369}
370
371/// Lowercased, with any IPv6 brackets and trailing root dot removed.
372fn normalize_host(host: &str) -> String {
373    host.trim_end_matches('.').to_ascii_lowercase()
374}
375
376/// Percent-decodes one userinfo component.
377///
378/// `Url::username`/`Url::password` hand back the *encoded* text, and
379/// `DOMAIN\user` or `user@corp` are entirely ordinary proxy credentials that
380/// arrive here as `DOMAIN%5Cuser` / `user%40corp`. Encoding those verbatim into
381/// `Basic` sends the wrong credential and yields a `407` an operator cannot
382/// explain.
383fn percent_decode(value: &str) -> Result<String, String> {
384    percent_encoding::percent_decode_str(value)
385        .decode_utf8()
386        .map(|decoded| decoded.into_owned())
387        .map_err(|error| format!("is not valid UTF-8 once decoded: {error}"))
388}
389
390/// The URL with any password replaced, ready to be logged.
391fn redact(url: &Url) -> String {
392    match url.password().is_some() {
393        false => url.as_str().trim_end_matches('/').to_string(),
394        true => {
395            let mut redacted = url.clone();
396            let _ = redacted.set_password(Some("***"));
397            redacted.as_str().trim_end_matches('/').to_string()
398        }
399    }
400}
401
402/// Builds the process's proxy configuration, wrapped for the subsystems that
403/// each keep a clone.
404pub fn from_config(cfg: &ProxyConfig) -> anyhow::Result<Arc<OutboundProxies>> {
405    OutboundProxies::from_config(cfg).map(Arc::new)
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use crate::testutil::EnvGuard;
412
413    fn config(http: &str, https: &str, no_proxy: &[&str]) -> ProxyConfig {
414        ProxyConfig {
415            http_url: http.to_string(),
416            https_url: https.to_string(),
417            no_proxy: no_proxy.iter().map(|entry| (*entry).to_string()).collect(),
418        }
419    }
420
421    /// `EnvGuard` pins `ACME_PROXY_CONFIG` and takes the crate-wide lock, which
422    /// is what keeps a test reading `http_proxy` away from one setting it.
423    fn without_env() -> EnvGuard {
424        EnvGuard::new(&[])
425    }
426
427    fn endpoint(host: &str, https: bool) -> Endpoint {
428        Endpoint {
429            host: host.to_string(),
430            port: if https { 443 } else { 80 },
431            https,
432        }
433    }
434
435    #[test]
436    fn both_urls_are_parsed() {
437        let _guard = without_env();
438        let proxies = OutboundProxies::from_config(&config(
439            "http://p1.example:3128",
440            "http://p2.example",
441            &[],
442        ))
443        .unwrap();
444
445        let http = proxies.select(&endpoint("example.com", false)).unwrap();
446        assert_eq!(http.endpoint().host, "p1.example");
447        assert_eq!(http.endpoint().port, 3128);
448        assert!(!http.endpoint().https);
449
450        let https = proxies.select(&endpoint("example.com", true)).unwrap();
451        assert_eq!(https.endpoint().host, "p2.example");
452        assert_eq!(https.endpoint().port, 80);
453    }
454
455    /// A bare `host:port` is what operators write, and `Url::parse` would read
456    /// it as a scheme with a path.
457    #[test]
458    fn a_bare_authority_is_read_as_http() {
459        let _guard = without_env();
460        let proxies = OutboundProxies::from_config(&config("proxy.example:3128", "", &[])).unwrap();
461        let target = proxies.select(&endpoint("example.com", false)).unwrap();
462        assert_eq!(target.endpoint().host, "proxy.example");
463        assert_eq!(target.endpoint().port, 3128);
464    }
465
466    /// The two keys are independent: setting only `http_url` must leave https
467    /// targets direct rather than quietly reusing it. "It worked for http and
468    /// did nothing for https" is the bug this pins.
469    #[test]
470    fn the_two_keys_are_not_interchangeable() {
471        let _guard = without_env();
472        let proxies = OutboundProxies::from_config(&config("http://p.example", "", &[])).unwrap();
473        assert!(proxies.select(&endpoint("example.com", false)).is_some());
474        assert!(proxies.select(&endpoint("example.com", true)).is_none());
475    }
476
477    #[test]
478    fn nothing_configured_is_direct() {
479        let _guard = without_env();
480        let proxies = OutboundProxies::from_config(&ProxyConfig::default()).unwrap();
481        assert!(!proxies.is_configured());
482        assert!(proxies.select(&endpoint("example.com", true)).is_none());
483        assert!(!OutboundProxies::direct().is_configured());
484    }
485
486    #[test]
487    fn credentials_become_a_basic_header() {
488        let _guard = without_env();
489        let proxies =
490            OutboundProxies::from_config(&config("http://user:pass@p.example", "", &[])).unwrap();
491        let target = proxies.select(&endpoint("example.com", false)).unwrap();
492        // RFC 7617's worked shape: base64("user:pass").
493        assert_eq!(target.authorization(), Some("Basic dXNlcjpwYXNz"));
494    }
495
496    /// `DOMAIN\user` and `user@corp` are ordinary proxy usernames and reach
497    /// `Url::username` percent-encoded; sending them that way is a 407 nobody
498    /// can explain.
499    #[test]
500    fn percent_encoded_credentials_are_decoded_before_encoding() {
501        let _guard = without_env();
502        let proxies =
503            OutboundProxies::from_config(&config("http://user%40corp:p%3Ass@p.example", "", &[]))
504                .unwrap();
505        let target = proxies.select(&endpoint("example.com", false)).unwrap();
506        assert_eq!(
507            target.authorization(),
508            Some(format!("Basic {}", BASE64_STANDARD.encode("user@corp:p:ss")).as_str())
509        );
510    }
511
512    #[test]
513    fn a_url_without_userinfo_carries_no_authorization() {
514        let _guard = without_env();
515        let proxies = OutboundProxies::from_config(&config("http://p.example", "", &[])).unwrap();
516        assert!(
517            proxies
518                .select(&endpoint("example.com", false))
519                .unwrap()
520                .authorization()
521                .is_none()
522        );
523    }
524
525    /// Neither rendering may carry the password: `Debug` derives are how a
526    /// credential reaches a log.
527    #[test]
528    fn neither_debug_nor_redacted_leaks_the_password() {
529        let _guard = without_env();
530        let proxies =
531            OutboundProxies::from_config(&config("http://user:hunter2@p.example", "", &[]))
532                .unwrap();
533        let target = proxies.select(&endpoint("example.com", false)).unwrap();
534        assert!(
535            !target.redacted().contains("hunter2"),
536            "{}",
537            target.redacted()
538        );
539        assert!(target.redacted().contains("***"), "{}", target.redacted());
540
541        let rendered = format!("{target:?}");
542        assert!(!rendered.contains("hunter2"), "{rendered}");
543        // …and not the header either, which is only base64 away from readable.
544        assert!(!rendered.contains("Basic"), "{rendered}");
545
546        let whole = format!("{proxies:?}");
547        assert!(!whole.contains("hunter2"), "{whole}");
548    }
549
550    #[test]
551    fn every_refusal_names_what_has_to_change() {
552        let _guard = without_env();
553        let cases: &[(&str, &[&str])] = &[
554            (
555                "https://p.example:3128",
556                &["proxy.http_url", "http://host:port"],
557            ),
558            ("socks5://p.example:1080", &["proxy.http_url", "socks5"]),
559            ("http://", &["proxy.http_url"]),
560        ];
561        for (url, expected) in cases {
562            let error = OutboundProxies::from_config(&config(url, "", &[]))
563                .expect_err("{url} must be refused")
564                .to_string();
565            for fragment in *expected {
566                assert!(error.contains(fragment), "{url}: {error}");
567            }
568        }
569
570        let https_error = OutboundProxies::from_config(&config("", "https://p.example", &[]))
571            .expect_err("an https proxy URL must be refused")
572            .to_string();
573        assert!(https_error.contains("proxy.https_url"), "{https_error}");
574    }
575
576    #[test]
577    fn a_no_proxy_entry_with_a_port_is_refused_by_name() {
578        let _guard = without_env();
579        let error =
580            OutboundProxies::from_config(&config("http://p.example", "", &["example.com:8080"]))
581                .expect_err("a port in no_proxy must be refused")
582                .to_string();
583        assert!(error.contains("example.com:8080"), "{error}");
584        assert!(error.contains("port"), "{error}");
585    }
586
587    #[test]
588    fn an_unusable_no_proxy_entry_is_refused() {
589        let _guard = without_env();
590        let error = OutboundProxies::from_config(&config("http://p.example", "", &["."]))
591            .expect_err("a bare dot must be refused")
592            .to_string();
593        assert!(error.contains("proxy.no_proxy"), "{error}");
594    }
595
596    #[test]
597    fn no_proxy_matching() {
598        let _guard = without_env();
599        // (rules, host, bypassed?)
600        let cases: &[(&[&str], &str, bool)] = &[
601            (&["example.com"], "example.com", true),
602            (&["example.com"], "a.b.example.com", true),
603            (&[".example.com"], "example.com", true),
604            (&[".example.com"], "a.example.com", true),
605            // The `.` in the suffix comparison is the whole point: without it
606            // `notexample.com` matches `example.com`.
607            (&["example.com"], "notexample.com", false),
608            (&["example.com"], "example.com.evil.net", false),
609            (&["EXAMPLE.COM"], "Example.Com", true),
610            (&["example.com"], "example.com.", true),
611            (&["*"], "anything.example", true),
612            (&["192.0.2.7"], "192.0.2.7", true),
613            (&["192.0.2.7"], "192.0.2.8", false),
614            (&["10.0.0.0/8"], "10.1.2.3", true),
615            (&["10.0.0.0/8"], "11.1.2.3", false),
616            (&["2001:db8::/32"], "[2001:db8::1]", true),
617            // A name is never resolved to test a network rule.
618            (&["10.0.0.0/8"], "host.example", false),
619            (&["other.example", "example.com"], "www.example.com", true),
620        ];
621        for (rules, host, bypassed) in cases {
622            let proxies =
623                OutboundProxies::from_config(&config("http://p.example", "", rules)).unwrap();
624            assert_eq!(
625                proxies.select(&endpoint(host, false)).is_none(),
626                *bypassed,
627                "{rules:?} against {host}"
628            );
629        }
630    }
631
632    /// An inherited shell `http_proxy` must not send this server's own loopback
633    /// traffic through a corporate proxy — a failure with no signal in it.
634    #[test]
635    fn loopback_and_localhost_bypass_unconditionally() {
636        let _guard = without_env();
637        let proxies =
638            OutboundProxies::from_config(&config("http://p.example", "http://p.example", &["*"]))
639                .unwrap();
640        for host in ["localhost", "LocalHost", "127.0.0.1", "[::1]", "127.9.9.9"] {
641            assert!(
642                proxies.select(&endpoint(host, false)).is_none(),
643                "{host} must bypass"
644            );
645        }
646        // …and a non-loopback address still goes through, so the rule is not
647        // just "everything bypasses".
648        let proxies = OutboundProxies::from_config(&config("http://p.example", "", &[])).unwrap();
649        assert!(proxies.select(&endpoint("203.0.113.7", false)).is_some());
650    }
651
652    #[test]
653    fn the_environment_fills_in_an_unset_key() {
654        let _guard = EnvGuard::new(&[
655            ("http_proxy", "http://env-http.example:3128"),
656            ("https_proxy", "http://env-https.example:3128"),
657            ("no_proxy", "one.example, .two.example"),
658        ]);
659        let proxies = OutboundProxies::from_config(&ProxyConfig::default()).unwrap();
660        assert_eq!(
661            proxies
662                .select(&endpoint("example.com", false))
663                .unwrap()
664                .endpoint()
665                .host,
666            "env-http.example"
667        );
668        assert_eq!(
669            proxies
670                .select(&endpoint("example.com", true))
671                .unwrap()
672                .endpoint()
673                .host,
674            "env-https.example"
675        );
676        assert!(proxies.select(&endpoint("one.example", false)).is_none());
677        assert!(proxies.select(&endpoint("a.two.example", false)).is_none());
678    }
679
680    #[test]
681    fn a_configured_key_beats_the_environment() {
682        let _guard = EnvGuard::new(&[
683            ("http_proxy", "http://env.example:3128"),
684            ("no_proxy", "env.example"),
685        ]);
686        let proxies =
687            OutboundProxies::from_config(&config("http://file.example", "", &["file.example.com"]))
688                .unwrap();
689        assert_eq!(
690            proxies
691                .select(&endpoint("example.com", false))
692                .unwrap()
693                .endpoint()
694                .host,
695            "file.example"
696        );
697        // The configured no_proxy replaced the environment's wholesale rather
698        // than merging with it — arrays never append here.
699        assert!(proxies.select(&endpoint("env.example", false)).is_some());
700        assert!(
701            proxies
702                .select(&endpoint("file.example.com", false))
703                .is_none()
704        );
705    }
706
707    /// `HTTPS_PROXY` is honoured and `HTTP_PROXY` is not — see
708    /// [`OutboundProxies::from_config`] for why.
709    #[test]
710    fn uppercase_http_proxy_is_ignored_while_https_proxy_is_not() {
711        let _guard = EnvGuard::new(&[
712            ("HTTP_PROXY", "http://attacker.example:3128"),
713            ("HTTPS_PROXY", "http://upper.example:3128"),
714        ]);
715        let proxies = OutboundProxies::from_config(&ProxyConfig::default()).unwrap();
716        assert!(proxies.select(&endpoint("example.com", false)).is_none());
717        assert_eq!(
718            proxies
719                .select(&endpoint("example.com", true))
720                .unwrap()
721                .endpoint()
722                .host,
723            "upper.example"
724        );
725    }
726
727    /// A `${VAR:-}` shell default arrives as present-but-empty, which is not a
728    /// proxy at the empty URL.
729    #[test]
730    fn an_empty_environment_variable_is_unset() {
731        let _guard = EnvGuard::new(&[("http_proxy", ""), ("no_proxy", "")]);
732        let proxies = OutboundProxies::from_config(&ProxyConfig::default()).unwrap();
733        assert!(!proxies.is_configured());
734    }
735
736    #[test]
737    fn the_startup_source_is_described_from_where_the_values_came() {
738        assert_eq!(Source::describe(&[Source::Unset, Source::Unset]), "unset");
739        assert_eq!(Source::describe(&[Source::Config, Source::Unset]), "config");
740        assert_eq!(
741            Source::describe(&[Source::Environment, Source::Unset]),
742            "environment"
743        );
744        assert_eq!(
745            Source::describe(&[Source::Config, Source::Environment]),
746            "config+environment"
747        );
748    }
749
750    #[test]
751    fn from_config_hands_back_a_shared_value() {
752        let _guard = without_env();
753        let proxies = crate::proxy::from_config(&ProxyConfig::default()).unwrap();
754        assert!(!proxies.is_configured());
755    }
756}