use crate::endpoint::{ProxyEndpoint, ProxyScheme};
use crate::error::Error;
use crate::resolve::ProxyStep;
use std::collections::HashSet;
pub(crate) const MAX_CHAIN: usize = 64;
pub(crate) struct Chain {
steps: Vec<ProxyStep>,
seen: HashSet<ProxyStep>,
}
impl Chain {
pub(crate) fn new() -> Self {
Self {
steps: Vec::new(),
seen: HashSet::new(),
}
}
pub(crate) fn push(&mut self, step: ProxyStep) {
if !self.is_full() && self.seen.insert(step.clone()) {
self.steps.push(step);
}
}
pub(crate) fn is_full(&self) -> bool {
self.steps.len() == MAX_CHAIN
}
pub(crate) fn finish(self) -> Option<Vec<ProxyStep>> {
(!self.steps.is_empty()).then_some(self.steps)
}
}
pub fn parse_find_proxy_result(result: &str) -> Result<Vec<ProxyStep>, Error> {
let mut chain = Chain::new();
for candidate in result.split([';', '\n', '\r']) {
if let Some(step) = parse_candidate(candidate) {
chain.push(step);
if chain.is_full() {
break;
}
}
}
chain
.finish()
.ok_or_else(|| Error::pac_invalid_result(result))
}
fn parse_candidate(candidate: &str) -> Option<ProxyStep> {
let mut words = candidate.split_ascii_whitespace();
let keyword = words.next()?;
let address = words.next();
if words.next().is_some() {
return None;
}
let scheme = match keyword.to_ascii_uppercase().as_str() {
"DIRECT" => return address.is_none().then_some(ProxyStep::Direct),
"PROXY" | "HTTP" => ProxyScheme::Http,
"HTTPS" => ProxyScheme::Https,
"SOCKS" | "SOCKS4" => ProxyScheme::Socks4,
"SOCKS5" => ProxyScheme::Socks5h,
_ => return None,
};
let address = address?;
if address.contains(['/', '?', '#']) {
return None;
}
if address.contains(char::is_whitespace) {
return None;
}
let mut endpoint = ProxyEndpoint::parse(address, scheme.default_port())
.ok()?
.with_scheme_hint(scheme);
if endpoint.port == 0 {
endpoint.port = scheme.default_port();
}
if endpoint.auth.take().is_some() {
crate::trace::warning!(
keyword,
"dropping the credentials embedded in a PAC result candidate"
);
}
Some(ProxyStep::from_endpoint(endpoint))
}
#[cfg(test)]
mod tests {
use super::*;
fn authorities(result: &str) -> Vec<String> {
parse_find_proxy_result(result)
.unwrap()
.iter()
.map(|step| match step.endpoint() {
Some(endpoint) => format!("{}/{}", step.scheme().unwrap(), endpoint.authority()),
None => "direct".to_owned(),
})
.collect()
}
#[test]
fn every_keyword_maps_onto_a_step() {
assert_eq!(authorities("DIRECT"), ["direct"]);
assert_eq!(authorities("PROXY p:8080"), ["http/p:8080"]);
assert_eq!(authorities("HTTP p:8080"), ["http/p:8080"]);
assert_eq!(authorities("HTTPS p:8443"), ["https/p:8443"]);
assert_eq!(authorities("SOCKS p:1080"), ["socks4/p:1080"]);
assert_eq!(authorities("SOCKS4 p:1080"), ["socks4/p:1080"]);
assert_eq!(authorities("SOCKS5 p:1080"), ["socks5/p:1080"]);
}
#[test]
fn socks5_uses_remote_dns_in_to_url() {
let step = &parse_find_proxy_result("SOCKS5 proxy.corp:1080").unwrap()[0];
assert_eq!(step.scheme(), Some("socks5"));
assert_eq!(
step.endpoint().unwrap().scheme_hint,
Some(ProxyScheme::Socks5h)
);
assert_eq!(step.to_url().unwrap().as_str(), "socks5h://proxy.corp:1080");
}
#[test]
fn socks_and_socks4_resolve_locally_in_to_url() {
for keyword in ["SOCKS", "SOCKS4"] {
let step = &parse_find_proxy_result(&format!("{keyword} proxy.corp")).unwrap()[0];
assert_eq!(
step.to_url().unwrap().as_str(),
"socks4://proxy.corp:1080",
"{keyword}"
);
}
}
#[test]
fn keywords_are_case_insensitive_and_ports_default() {
assert_eq!(authorities("proxy p"), ["http/p:80"]);
assert_eq!(authorities("Https p"), ["https/p:443"]);
assert_eq!(authorities("socks5 p"), ["socks5/p:1080"]);
assert_eq!(authorities("direct"), ["direct"]);
assert_eq!(authorities("PROXY p:0"), ["http/p:80"]);
assert_eq!(authorities("SOCKS5 p:0"), ["socks5/p:1080"]);
}
#[test]
fn a_chain_keeps_its_order() {
assert_eq!(
authorities("PROXY a:1; SOCKS5 b:2; DIRECT"),
["http/a:1", "socks5/b:2", "direct"]
);
}
#[test]
fn whitespace_around_a_candidate_is_ignored() {
assert_eq!(
authorities(" PROXY a:1 ;\n\tPROXY b:2 ;; "),
["http/a:1", "http/b:2"]
);
}
#[test]
fn a_newline_separates_candidates_where_no_semicolon_does() {
assert_eq!(
authorities("PROXY a:1\nPROXY b:2\r\nDIRECT"),
["http/a:1", "http/b:2", "direct"]
);
assert_eq!(authorities("PROXY a:1\rDIRECT"), ["http/a:1", "direct"]);
}
#[test]
fn ipv6_literals_survive() {
assert_eq!(authorities("PROXY [::1]:8080"), ["http/[::1]:8080"]);
assert_eq!(authorities("PROXY ::1"), ["http/[::1]:80"]);
}
#[test]
fn duplicates_collapse() {
assert_eq!(
authorities("PROXY a:1; PROXY a:1; DIRECT; DIRECT"),
["http/a:1", "direct"]
);
assert_eq!(
authorities("PROXY a:1; PROXY b:2; PROXY a:1"),
["http/a:1", "http/b:2"]
);
assert_eq!(
authorities("PROXY a:1; HTTPS a:1; SOCKS5 a:1; HTTP a:1"),
["http/a:1", "https/a:1", "socks5/a:1"]
);
}
#[test]
fn junk_candidates_are_skipped_but_do_not_poison_the_chain() {
assert_eq!(
authorities("GOPHER g:70; PROXY a:1; PROXY ; DIRECT x; SOCKS9 b:2"),
["http/a:1"]
);
assert_eq!(authorities("PROXY a:99999; PROXY a:1"), ["http/a:1"]);
assert_eq!(authorities("PROXY http://a:1; PROXY a:1"), ["http/a:1"]);
assert_eq!(authorities("PROXY https://p:8443; DIRECT"), ["direct"]);
assert_eq!(authorities("PROXY a:1 b:2; DIRECT"), ["direct"]);
}
#[test]
fn a_candidate_that_is_not_a_bare_authority_is_skipped_whole() {
for spec in [
"PROXY corp:8080/path",
"PROXY corp:8080/",
"PROXY corp/path",
"PROXY corp:8080?q=1",
"PROXY corp:8080#f",
"SOCKS5 corp:1080/path",
] {
assert_eq!(
authorities(&format!("{spec}; DIRECT")),
["direct"],
"{spec}"
);
}
assert_eq!(
authorities("PROXY corp:8080/path; PROXY other:3128"),
["http/other:3128"]
);
assert!(matches!(
parse_find_proxy_result("PROXY corp:8080/path").unwrap_err(),
Error::PacInvalidResult { .. }
));
}
#[test]
fn credentials_in_a_candidate_are_dropped_but_the_proxy_survives() {
let steps = parse_find_proxy_result("PROXY alice:hunter2@p:8080").unwrap();
let endpoint = steps[0].endpoint().unwrap();
assert_eq!(endpoint.authority(), "p:8080");
assert_eq!(endpoint.auth, None);
assert_eq!(steps[0].to_url().unwrap().as_str(), "http://p:8080/");
assert_eq!(authorities("SOCKS5 bob@s:1080"), ["socks5/s:1080"]);
}
#[test]
fn a_result_with_nothing_usable_is_an_error() {
for junk in [
"",
" ",
";;;",
"null",
"undefined",
"GOPHER g:70",
"PROXY",
] {
let error = parse_find_proxy_result(junk).unwrap_err();
assert!(
matches!(error, Error::PacInvalidResult { .. }),
"{junk:?} gave {error:?}"
);
}
}
#[test]
fn a_long_result_of_repeats_and_junk_parses_in_linear_time() {
let result = (0..200_000)
.map(|i| match i % 3 {
0 => format!("PROXY h0.example.com:{}", 8080 + i % 2),
1 => format!("PROXY h{i}.example.com/"),
_ => format!("JUNK{i}"),
})
.collect::<Vec<_>>()
.join(";");
let start = std::time::Instant::now();
let chain = authorities(&result);
let elapsed = start.elapsed();
assert_eq!(
chain,
["http/h0.example.com:8080", "http/h0.example.com:8081"]
);
assert!(
elapsed < std::time::Duration::from_secs(20),
"parsing 200,000 candidates took {elapsed:?}"
);
}
#[test]
fn the_chain_keeps_its_first_steps_and_drops_the_rest() {
assert_eq!(MAX_CHAIN, 64);
let mut result = String::from("junk; PROXY h0:1; PROXY h0:1;");
for i in 1..=MAX_CHAIN {
result.push_str(&format!(" PROXY h{i}:1;"));
}
result.push_str(" DIRECT");
let chain = authorities(&result);
assert_eq!(chain.len(), MAX_CHAIN);
assert_eq!(chain[0], "http/h0:1");
assert_eq!(chain[MAX_CHAIN - 1], format!("http/h{}:1", MAX_CHAIN - 1));
assert!(!chain.contains(&"direct".to_owned()));
}
#[test]
fn words_are_split_at_ascii_whitespace_only() {
assert_eq!(authorities("PROXY\ta:1"), ["http/a:1"]);
for space in ['\u{a0}', '\u{3000}', '\u{b}'] {
for result in [
format!("PROXY{space}a:1; DIRECT"),
format!("{space}PROXY a:1; DIRECT"),
format!("PROXY a:1{space}; DIRECT"),
] {
assert_eq!(authorities(&result), ["direct"], "{result:?}");
}
let result = format!("DIRECT{space}; PROXY a:1");
assert_eq!(authorities(&result), ["http/a:1"], "{result:?}");
}
}
}