diff --git a/src/cli.rs b/src/cli.rs index 00b5b8e0..47a69e7f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,3 +1,4 @@ +use std::borrow::Borrow; use std::convert::TryFrom; use std::env; use std::ffi::OsString; @@ -295,6 +296,19 @@ Example: --print=Hb" #[clap(long, value_name = "PROTOCOL:URL", number_of_values = 1)] pub proxy: Vec, + /// Comma-separated list of hosts for which not to use a proxy, if one is specified. + /// + /// - A "*" matches all hosts, and effectively disables proxies altogether. + /// + /// - IP addresses are allowed, as are subnets (in CIDR notation, i.e.: "127.0.0.0/8"). + /// + /// - Any other entry in the list is assumed to be a hostname. + /// + /// The environment variable "NO_PROXY"/"no_proxy" can also be used, but its completely ignored + /// if --disable-proxy-for is passed. + #[clap(long, value_name = "no-proxy-list", value_delimiter = ',')] + pub disable_proxy_for: Vec, + /// If "no", skip SSL verification. If a file path, use it as a CA bundle. /// /// Specifying a CA bundle will disable the system's built-in root certificates. @@ -1087,6 +1101,46 @@ impl FromStr for Proxy { } } +impl Proxy { + pub fn into_reqwest_proxy( + self, + disable_proxy_for: &[DisableProxyFor], + ) -> anyhow::Result { + let proxy = match self { + Proxy::Http(url) => reqwest::Proxy::http(url), + Proxy::Https(url) => reqwest::Proxy::https(url), + Proxy::All(url) => reqwest::Proxy::all(url), + }?; + + let mut noproxy_comma_delimited = disable_proxy_for.join(","); + if disable_proxy_for.contains(&"*".into()) { + // reqwest's NoProxy wildcard doesn't apply to IP addresses, while curl's does + // See: https://github.com/seanmonstar/reqwest/issues/2579 + noproxy_comma_delimited.push_str(",0.0.0.0/0,::/0"); + } + + Ok(proxy.no_proxy( + reqwest::NoProxy::from_string(&noproxy_comma_delimited) + .or_else(reqwest::NoProxy::from_env), + )) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DisableProxyFor(String); + +impl From<&str> for DisableProxyFor { + fn from(s: &str) -> Self { + Self(s.trim().to_string()) + } +} + +impl Borrow for DisableProxyFor { + fn borrow(&self) -> &str { + &self.0 + } +} + #[derive(Debug, Clone)] pub struct Resolve { pub domain: String, @@ -1476,6 +1530,11 @@ mod tests { ); } + #[test] + fn disable_proxy_for_trims_whitespace() { + assert_eq!(DisableProxyFor::from("*"), DisableProxyFor::from(" * ")); + } + #[test] fn executable_name() { let args = Cli::try_parse_from(["xhs", "example.org"]).unwrap(); diff --git a/src/main.rs b/src/main.rs index 6a1eac0f..0781649a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -41,7 +41,7 @@ use utils::reason_phrase; use crate::auth::{Auth, DigestAuthMiddleware}; use crate::buffer::Buffer; -use crate::cli::{Cli, FormatOptions, HttpVersion, Print, Proxy, Verify}; +use crate::cli::{Cli, FormatOptions, HttpVersion, Print, Verify}; use crate::download::{download_file, get_file_size}; use crate::middleware::ClientWithMiddleware; use crate::printer::Printer; @@ -274,11 +274,7 @@ fn run(args: Cli) -> Result { } for proxy in args.proxy.into_iter().rev() { - client = client.proxy(match proxy { - Proxy::Http(url) => reqwest::Proxy::http(url), - Proxy::Https(url) => reqwest::Proxy::https(url), - Proxy::All(url) => reqwest::Proxy::all(url), - }?); + client = client.proxy(proxy.into_reqwest_proxy(&args.disable_proxy_for)?); } client = match args.http_version { diff --git a/src/to_curl.rs b/src/to_curl.rs index adeebf1f..776ac87d 100644 --- a/src/to_curl.rs +++ b/src/to_curl.rs @@ -233,6 +233,10 @@ pub fn translate(args: Cli) -> Result { } } } + if !args.disable_proxy_for.is_empty() { + cmd.arg("--noproxy"); + cmd.arg(args.disable_proxy_for.join(",")); + } if let Some(timeout) = args.timeout.and_then(|t| t.as_duration()) { cmd.arg("--max-time"); cmd.arg(timeout.as_secs_f64().to_string()); diff --git a/tests/cli.rs b/tests/cli.rs index b3adf374..2b6d07c4 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1136,6 +1136,99 @@ fn proxy_multiple_valid_proxies() { cmd.assert().success(); } +enum DisableProxyForTestType { + ProxyUsed, + ProxyNotUsed, +} +fn disable_proxy_for_test(disable_proxy_for_arg: &str, test_type: DisableProxyForTestType) { + let mut proxy_server = server::http(|_| async move { + hyper::Response::builder() + .status(200) + .body("Proxy shouldn't have been used.".into()) + .unwrap() + }); + let mut actual_server = server::http(|_| async move { + hyper::Response::builder() + .status(200) + .body("".into()) + .unwrap() + }); + + get_command() + .arg(format!("--proxy=http:{}", proxy_server.base_url())) + .arg(format!("--disable-proxy-for={}", disable_proxy_for_arg)) + .arg("GET") + .arg(actual_server.base_url().as_str()) + .assert() + .success(); + + if let DisableProxyForTestType::ProxyNotUsed = test_type { + proxy_server.disable_hit_checks(); + proxy_server.assert_hits(0); + actual_server.assert_hits(1); + } else { + proxy_server.assert_hits(1); + actual_server.disable_hit_checks(); + actual_server.assert_hits(0); + } +} + +#[test] +fn disable_proxy_for_wildcard() { + disable_proxy_for_test("*", DisableProxyForTestType::ProxyNotUsed); +} + +#[test] +fn disable_proxy_for_ip() { + disable_proxy_for_test("127.0.0.1", DisableProxyForTestType::ProxyNotUsed); +} + +#[test] +fn disable_proxy_for_ip_cidr() { + disable_proxy_for_test("127.0.0.0/8", DisableProxyForTestType::ProxyNotUsed); +} + +#[test] +fn disable_proxy_for_multiple() { + disable_proxy_for_test("127.0.0.2,127.0.0.1", DisableProxyForTestType::ProxyNotUsed); +} + +#[test] +fn disable_proxy_for_whitespace() { + disable_proxy_for_test( + "example.test, 127.0.0.1", + DisableProxyForTestType::ProxyNotUsed, + ); +} + +#[test] +fn disable_proxy_for_whitespace_wildcard() { + disable_proxy_for_test("example.test, *", DisableProxyForTestType::ProxyNotUsed); +} + +#[test] +fn disable_proxy_for_whitespace_ip() { + disable_proxy_for_test( + "127.0.0.2, 127.0.0.1", + DisableProxyForTestType::ProxyNotUsed, + ); +} + +#[test] +fn disable_proxy_for_other_host() { + disable_proxy_for_test("example.test", DisableProxyForTestType::ProxyUsed); +} + +#[test] +fn disable_proxy_for_other_ip() { + disable_proxy_for_test("127.0.0.2", DisableProxyForTestType::ProxyUsed); +} + +#[test] +fn disable_proxy_for_other_ip_cidr() { + disable_proxy_for_test("127.0.1.0/24", DisableProxyForTestType::ProxyUsed); +} + // temporarily disabled for builds not using rustls #[cfg(all(feature = "online-tests", feature = "rustls"))] #[ignore = "endpoint is randomly timing out"]