use std::collections::HashMap;
use url::Url;
use crate::bypass::{BypassDialect, BypassRules, HostPattern};
use crate::diagnostic::{RejectedValue, RejectionKind, RejectionSource};
use crate::endpoint::{ProxyEndpoint, ProxyEntry, Scheme, parse_host};
use crate::error::Error;
use crate::mode::ProxyMode;
#[cfg(feature = "pac-android-native")]
use crate::resolve::ProxyStep;
const DEFAULT_EXCLUSIONS: [&str; 5] = ["localhost", "127.*", "[::1]", "0.0.0.0", "[::0]"];
pub(crate) struct ProxyInfo {
pub(crate) host: Option<String>,
pub(crate) port: i32,
pub(crate) exclusions: Vec<String>,
pub(crate) pac_url: Option<String>,
}
pub(crate) fn mode_from_proxy_info(info: Option<&ProxyInfo>) -> Result<ProxyMode, Error> {
let Some(info) = info else {
return Ok(ProxyMode::Direct);
};
if let Some(url) = info.pac_url.as_deref().filter(|url| !url.is_empty()) {
let parsed = Url::parse(url).map_err(|source| Error::invalid_proxy_url(url, source))?;
return Ok(ProxyMode::pac(parsed));
}
let Some(host) = info.host.as_deref().filter(|host| !host.is_empty()) else {
return Ok(ProxyMode::Direct);
};
let mut per_scheme = HashMap::new();
let mut rejected = Vec::new();
for (scheme, default_port) in [(Scheme::Http, 80), (Scheme::Https, 443)] {
match endpoint(host, info.port, default_port) {
Ok(endpoint) => {
per_scheme.insert(scheme, ProxyEntry::Use(endpoint));
}
Err((field, input)) => rejected.push(
RejectedValue::new(
RejectionKind::InvalidProxyEndpoint,
RejectionSource::ProxyInfo(field.to_owned()),
input,
)
.for_scheme(Some(scheme)),
),
}
}
Ok(ProxyMode::manual(per_scheme, bypass(&info.exclusions)).with_rejected(rejected))
}
fn endpoint(host: &str, port: i32, default_port: u16) -> Result<ProxyEndpoint, (&str, String)> {
let port = match port {
0 => default_port,
port => u16::try_from(port).map_err(|_| ("port", port.to_string()))?,
};
let host = parse_host(host).map_err(|_| ("host", host.to_owned()))?;
Ok(ProxyEndpoint::new(host, port))
}
fn bypass(exclusions: &[String]) -> BypassRules {
let mut rules = BypassRules::new();
rules.push_pattern(HostPattern::SubtractImplicit);
rules.strip_trailing_dot = false;
rules.ipv4_mapped_as_ipv4 = false;
let defaults = if exclusions.join(",").is_empty() {
&[][..]
} else {
&DEFAULT_EXCLUSIONS[..]
};
let entries = exclusions
.iter()
.flat_map(|element| element.split([',', '|']))
.chain(defaults.iter().copied());
for entry in entries {
let source = || RejectionSource::ProxyInfo("exclusionList".to_owned());
if matches_no_host(entry) {
rules.rejected.push(RejectedValue::new(
RejectionKind::InvalidBypassPattern,
source(),
entry,
));
} else {
rules.push_entry_from(entry, BypassDialect::Windows, source());
}
}
rules.dedup_patterns();
rules
}
fn matches_no_host(entry: &str) -> bool {
if entry.starts_with('<') || entry.contains(char::is_whitespace) {
return true;
}
let inner = entry.strip_prefix('*').unwrap_or(entry);
let inner = if inner.len() == entry.len() {
inner.strip_suffix('*').unwrap_or(inner)
} else {
inner
};
let bracketed = entry.starts_with('[') && entry.ends_with(']');
inner.contains('*') || (entry.contains(':') && !bracketed)
}
#[cfg(feature = "pac-android-native")]
pub(crate) struct SelectedProxy {
pub(crate) kind: String,
pub(crate) host: Option<String>,
pub(crate) port: i32,
}
#[cfg(feature = "pac-android-native")]
pub(crate) fn java_uri_text(url: &Url) -> String {
let (authority, rest) = url
.as_str()
.split_at(url[..url::Position::BeforePath].len());
let mut text = String::with_capacity(url.as_str().len());
text.push_str(authority);
for (at, c) in rest.char_indices() {
let escape = |b: Option<&u8>| b.is_some_and(u8::is_ascii_hexdigit);
let bytes = rest.as_bytes();
match c {
'|' | '^' | '[' | ']' | '{' | '}' | '`' | '\\' => {
text.push_str(&format!("%{:02X}", u32::from(c)));
}
'%' if !(escape(bytes.get(at + 1)) && escape(bytes.get(at + 2))) => {
text.push_str("%25");
}
_ => text.push(c),
}
}
text
}
#[cfg(feature = "pac-android-native")]
pub(crate) fn steps_from_selected(selected: &[SelectedProxy]) -> Result<Vec<ProxyStep>, Error> {
let mut chain = crate::pac::result::Chain::new();
let mut first_error = None;
for result in selected.iter().map(|proxy| {
let endpoint = || {
let host = proxy.host.as_deref().and_then(|host| parse_host(host).ok());
let port = u16::try_from(proxy.port).ok().filter(|&port| port != 0);
match (host, port) {
(Some(host), Some(port)) => Ok(ProxyEndpoint::new(host, port)),
_ => Err(Error::pac_invalid_result(format!(
"{} {}:{}",
proxy.kind,
proxy.host.as_deref().unwrap_or("null"),
proxy.port
))),
}
};
match proxy.kind.as_str() {
"DIRECT" => Ok(ProxyStep::Direct),
"HTTP" => endpoint().map(ProxyStep::Http),
"SOCKS" => endpoint().map(ProxyStep::Socks5),
other => Err(Error::pac_invalid_result(other)),
}
}) {
match result {
Ok(step) => {
chain.push(step);
if chain.is_full() {
break;
}
}
Err(error) => {
first_error.get_or_insert(error);
}
}
}
chain.finish().ok_or_else(|| {
first_error
.unwrap_or_else(|| Error::pac_invalid_result("ProxySelector.select returned no proxy"))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "pac-android-native")]
#[test]
fn a_url_is_encoded_where_java_uri_rejects_it() {
let text = |url: &str| java_uri_text(&Url::parse(url).unwrap());
assert_eq!(
text("http://h.corp/a|b^c[d]"),
"http://h.corp/a%7Cb%5Ec%5Bd%5D"
);
assert_eq!(
text("http://h.corp/?a={b}&c=`d|e^f\\g"),
"http://h.corp/?a=%7Bb%7D&c=%60d%7Ce%5Ef%5Cg"
);
assert_eq!(text("http://[::1]:8080/p%7C?q"), "http://[::1]:8080/p%7C?q");
assert_eq!(text("http://h.corp/100%"), "http://h.corp/100%25");
assert_eq!(text("http://h.corp/%zz?q=%4"), "http://h.corp/%25zz?q=%254");
assert_eq!(text("http://h.corp/%41%2f"), "http://h.corp/%41%2f");
}
#[cfg(feature = "pac-android-native")]
#[test]
fn a_selector_answer_maps_in_order_and_rejects_what_no_client_could_use() {
let proxy = |kind: &str, host: Option<&str>, port| SelectedProxy {
kind: kind.to_owned(),
host: host.map(str::to_owned),
port,
};
let endpoint = |host| ProxyEndpoint::new(parse_host(host).unwrap(), 1080);
assert_eq!(
steps_from_selected(&[
proxy("HTTP", Some("proxy.example"), 1080),
proxy("SOCKS", Some("2001:db8::1"), 1080),
proxy("DIRECT", None, 0),
])
.unwrap(),
[
ProxyStep::Http(endpoint("proxy.example")),
ProxyStep::Socks5(endpoint("2001:db8::1")),
ProxyStep::Direct,
]
);
for bad in [
vec![],
vec![proxy("HTTP", None, 1080)],
vec![proxy("HTTP", Some("proxy.example"), 0)],
vec![proxy("HTTP", Some("proxy.example"), 65536)],
vec![proxy("FTP", Some("proxy.example"), 21)],
] {
assert!(matches!(
steps_from_selected(&bad),
Err(Error::PacInvalidResult { .. })
));
}
assert_eq!(
steps_from_selected(&[proxy("HTTP", None, 1080), proxy("DIRECT", None, 0)]).unwrap(),
[ProxyStep::Direct]
);
assert_eq!(
steps_from_selected(&[
proxy("HTTP", Some("proxy.example"), 1080),
proxy("DIRECT", None, 0),
proxy("HTTP", Some("proxy.example"), 1080),
])
.unwrap(),
[
ProxyStep::Http(endpoint("proxy.example")),
ProxyStep::Direct
]
);
let mut long = (1..=crate::pac::result::MAX_CHAIN as i32 + 1)
.map(|port| proxy("HTTP", Some("proxy.example"), port))
.collect::<Vec<_>>();
long.push(proxy("DIRECT", None, 0));
let steps = steps_from_selected(&long).unwrap();
assert_eq!(steps.len(), crate::pac::result::MAX_CHAIN);
assert!(!steps.contains(&ProxyStep::Direct));
}
fn info(host: &str, port: i32, exclusions: &[&str]) -> ProxyInfo {
ProxyInfo {
host: Some(host.to_owned()),
port,
exclusions: exclusions.iter().map(|&e| e.to_owned()).collect(),
pac_url: None,
}
}
fn manual(info: &ProxyInfo) -> (HashMap<Scheme, ProxyEntry>, BypassRules) {
match mode_from_proxy_info(Some(info)).unwrap() {
ProxyMode::Manual {
per_scheme, bypass, ..
} => (per_scheme, bypass),
other => panic!("expected Manual, got {other:?}"),
}
}
fn bypassed(rules: &BypassRules, host: &str) -> bool {
rules.matches(&parse_host(host).unwrap(), Some(443))
}
#[test]
fn null_and_hostless_are_direct() {
assert_eq!(mode_from_proxy_info(None).unwrap(), ProxyMode::Direct);
assert_eq!(
mode_from_proxy_info(Some(&info("", 8080, &[]))).unwrap(),
ProxyMode::Direct
);
}
#[test]
fn a_pac_url_wins_over_the_local_host_it_comes_with() {
let mut pac = info("localhost", 34567, &[]);
pac.pac_url = Some("http://wpad.example/proxy.pac".to_owned());
assert_eq!(
mode_from_proxy_info(Some(&pac)).unwrap(),
ProxyMode::pac(Url::parse("http://wpad.example/proxy.pac").unwrap())
);
pac.pac_url = Some(String::new());
assert!(matches!(
mode_from_proxy_info(Some(&pac)).unwrap(),
ProxyMode::Manual { .. }
));
pac.pac_url = Some("not a url".to_owned());
assert!(matches!(
mode_from_proxy_info(Some(&pac)),
Err(Error::InvalidProxyUrl { .. })
));
}
#[test]
fn the_host_serves_http_and_https_only_with_port_zero_as_each_default() {
let (per_scheme, _) = manual(&info("proxy.example", 0, &[]));
let port_of = |scheme| match &per_scheme[&scheme] {
ProxyEntry::Use(endpoint) => endpoint.port,
other => panic!("{other:?}"),
};
assert_eq!((port_of(Scheme::Http), port_of(Scheme::Https)), (80, 443));
assert_eq!(per_scheme.len(), 2);
let (per_scheme, _) = manual(&info("2001:db8::1", 3128, &[]));
assert!(matches!(
&per_scheme[&Scheme::Http],
ProxyEntry::Use(e) if e.host == parse_host("2001:db8::1").unwrap() && e.port == 3128
));
}
#[test]
fn an_out_of_range_port_makes_both_schemes_unusable() {
let mode = mode_from_proxy_info(Some(&info("proxy.example", -1, &[]))).unwrap();
let rejected = mode.rejected().unwrap();
assert_eq!(rejected.len(), 2);
assert!(rejected.iter().all(|r| r.redacted_input() == "-1"
&& r.source() == &RejectionSource::ProxyInfo("port".to_owned())));
let ProxyMode::Manual { per_scheme, .. } = mode else {
unreachable!()
};
assert!(matches!(
per_scheme[&Scheme::Https],
ProxyEntry::Unusable(_)
));
}
#[test]
fn an_empty_list_bypasses_nothing_not_even_localhost() {
for list in [&[][..], &[""][..]] {
let (_, rules) = manual(&info("proxy.example", 8080, list));
assert!(!bypassed(&rules, "localhost"), "{list:?}");
assert!(!bypassed(&rules, "127.0.0.1"), "{list:?}");
assert!(!bypassed(&rules, "169.254.1.1"), "{list:?}");
assert!(rules.rejected.is_empty(), "{list:?}");
}
}
#[test]
fn the_selector_compares_the_host_as_written() {
let (_, rules) = manual(&info("proxy.example", 8080, &["intra.example", "10.0.0.1"]));
assert!(bypassed(&rules, "intra.example"));
assert!(!bypassed(&rules, "intra.example."));
assert!(bypassed(&rules, "localhost"));
assert!(!bypassed(&rules, "localhost."));
assert!(bypassed(&rules, "10.0.0.1"));
assert!(!bypassed(&rules, "[::ffff:10.0.0.1]"));
}
#[test]
fn a_non_empty_list_adds_loopback_but_not_link_local() {
let (_, rules) = manual(&info("proxy.example", 8080, &["intra.example"]));
for host in [
"intra.example",
"localhost",
"127.0.0.1",
"127.9.9.9",
"[::1]",
"0.0.0.0",
"[::]",
] {
assert!(bypassed(&rules, host), "{host}");
}
assert!(!bypassed(&rules, "169.254.1.1"));
assert!(!bypassed(&rules, "a.intra.example"));
assert!(rules.rejected.is_empty());
}
#[test]
fn a_star_is_read_at_either_end_and_nowhere_else() {
let (_, rules) = manual(&info(
"proxy.example",
8080,
&[
"*.corp.example",
"10.*",
"a*b.example",
"*mid*",
"host.example:443",
],
));
assert!(bypassed(&rules, "a.corp.example"));
assert!(!bypassed(&rules, "corp.example"));
assert!(bypassed(&rules, "10.1.2.3"));
let rejected: Vec<_> = rules.rejected.iter().map(|r| r.redacted_input()).collect();
assert_eq!(rejected, ["a*b.example", "*mid*", "host.example:443"]);
let (_, every) = manual(&info("proxy.example", 8080, &["*"]));
assert!(bypassed(&every, "anything.example"));
}
#[test]
fn a_host_no_client_can_use_is_rejected_for_both_schemes() {
let mode = mode_from_proxy_info(Some(&info("bad host", 8080, &[]))).unwrap();
let rejected = mode.rejected().unwrap();
let mut schemes: Vec<_> = rejected.iter().map(|r| r.affected_scheme()).collect();
schemes.sort_by_key(|scheme| format!("{scheme:?}"));
assert_eq!(schemes, [Some(Scheme::Http), Some(Scheme::Https)]);
for r in rejected {
assert_eq!(r.kind(), RejectionKind::InvalidProxyEndpoint);
assert_eq!(r.source(), &RejectionSource::ProxyInfo("host".to_owned()));
assert_eq!(r.redacted_input(), "bad host");
}
}
#[test]
fn every_refused_entry_names_the_exclusion_list() {
let (_, rules) = manual(&info(
"proxy.example",
8080,
&["<local>", ".corp.example", " a.example", "b.example "],
));
assert!(!bypassed(&rules, "a.example"));
assert!(!bypassed(&rules, "b.example"));
let rejected: Vec<_> = rules.rejected.iter().map(|r| r.redacted_input()).collect();
assert_eq!(
rejected,
["<local>", ".corp.example", " a.example", "b.example "]
);
for r in &rules.rejected {
assert_eq!(
r.source(),
&RejectionSource::ProxyInfo("exclusionList".to_owned()),
"{}",
r.redacted_input()
);
}
}
#[test]
fn an_element_holding_a_comma_or_a_bar_is_several_hosts_as_the_selector_reads_it() {
let (_, rules) = manual(&info(
"proxy.example",
8080,
&["a.example,b.example", "c.example|d.example", ",,"],
));
for host in ["a.example", "b.example", "c.example", "d.example"] {
assert!(bypassed(&rules, host), "{host}");
}
assert!(!bypassed(&rules, "e.example"));
assert!(rules.rejected.is_empty(), "{:?}", rules.rejected);
}
#[test]
fn a_windows_token_is_a_literal_the_selector_never_matches() {
let (_, rules) = manual(&info(
"proxy.example",
8080,
&["<local>", "<-loopback>", "*.corp.example"],
));
assert!(!bypassed(&rules, "intranet"));
assert!(!bypassed(&rules, "corp.example"));
assert!(bypassed(&rules, "a.corp.example"));
let rejected: Vec<_> = rules.rejected.iter().map(|r| r.redacted_input()).collect();
assert_eq!(rejected, ["<local>", "<-loopback>"]);
}
}