#[cfg_attr(not(pac_quickjs), allow(dead_code))]
mod hostfn;
mod policy;
pub(crate) mod result;
#[cfg_attr(not(pac_quickjs), allow(dead_code))]
mod time;
#[cfg(any(pac_quickjs, feature = "pac-subprocess"))]
mod budget;
mod dispatch;
#[cfg(pac_quickjs)]
mod quickjs;
#[cfg(feature = "pac-subprocess")]
mod subprocess;
#[cfg(all(windows, feature = "pac-windows-native"))]
mod winhttp;
#[cfg(all(target_os = "android", feature = "pac-android-native"))]
mod android;
#[cfg(any(pac_cfnetwork, test))]
mod cfnetwork;
use std::fmt;
use url::Url;
use crate::error::Error;
use crate::mode::ProxyMode;
use crate::resolve::ProxyStep;
pub use self::dispatch::PacResolver;
pub use self::policy::{DEFAULT_PAC_TIMEOUT, PacPolicy};
pub use self::result::parse_find_proxy_result;
#[cfg(pac_quickjs)]
pub use self::quickjs::QuickJsEvaluator;
pub const QUICKJS_AVAILABLE: bool = cfg!(pac_quickjs);
#[cfg(feature = "pac-subprocess")]
pub use self::subprocess::SubprocessEvaluator;
#[cfg(all(feature = "pac-subprocess", pac_quickjs))]
pub use self::subprocess::serve_worker;
#[cfg(all(windows, feature = "pac-windows-native"))]
pub use self::winhttp::{
AutoProxyReset, DEFAULT_WINHTTP_PAC_TIMEOUT, WinHttpPacResolver, WinHttpPacSource,
};
#[cfg(all(target_os = "android", feature = "pac-android-native"))]
pub use self::android::AndroidPacResolver;
#[cfg(all(windows, feature = "pac-windows-native"))]
pub type NativePacResolver = WinHttpPacResolver;
#[cfg(pac_cfnetwork)]
pub type NativePacResolver = CfNetworkPacResolver;
#[cfg(all(target_os = "android", feature = "pac-android-native"))]
pub type NativePacResolver = AndroidPacResolver;
#[cfg(pac_cfnetwork)]
pub use self::cfnetwork::{
CfNetworkPacEvaluator, CfNetworkPacResolver, DEFAULT_CFNETWORK_PAC_TIMEOUT,
};
#[derive(Clone, PartialEq, Eq)]
pub struct PacScript {
source: String,
}
impl fmt::Debug for PacScript {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PacScript")
.field("len", &self.source.len())
.field(
"fnv1a",
&format_args!("{:016x}", crate::util::fnv1a(self.source.as_bytes())),
)
.finish()
}
}
impl PacScript {
#[must_use]
pub fn new(source: impl Into<String>) -> Self {
Self {
source: source.into(),
}
}
#[must_use]
pub fn source(&self) -> &str {
&self.source
}
#[must_use]
pub fn from_mode(mode: &ProxyMode) -> Option<Self> {
match mode {
ProxyMode::PacInline { script, .. } => Some(Self::new(script.clone())),
_ => None,
}
}
}
impl From<String> for PacScript {
fn from(source: String) -> Self {
Self::new(source)
}
}
impl From<&str> for PacScript {
fn from(source: &str) -> Self {
Self::new(source)
}
}
#[derive(Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum PacRequirement<'a> {
NotNeeded,
Inline(&'a str),
Fetch(&'a Url),
Discover,
}
impl fmt::Debug for PacRequirement<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotNeeded => f.write_str("NotNeeded"),
Self::Discover => f.write_str("Discover"),
Self::Inline(script) => f
.debug_struct("Inline")
.field("len", &script.len())
.field(
"fnv1a",
&format_args!("{:016x}", crate::util::fnv1a(script.as_bytes())),
)
.finish(),
Self::Fetch(url) => f
.debug_tuple("Fetch")
.field(&format_args!(
"{}",
crate::util::redact_userinfo(url.as_str())
))
.finish(),
}
}
}
#[must_use]
pub fn requirement(mode: &ProxyMode) -> PacRequirement<'_> {
match mode {
ProxyMode::Pac { url, .. } => PacRequirement::Fetch(url),
ProxyMode::PacInline { script, .. } => PacRequirement::Inline(script),
ProxyMode::WpadAutoDetect => PacRequirement::Discover,
_ => PacRequirement::NotNeeded,
}
}
pub trait PacEvaluator {
fn evaluate(&self, script: &PacScript, url: &Url, host: &str) -> Result<Vec<ProxyStep>, Error>;
}
#[must_use]
pub fn sanitize_url(url: &Url) -> Url {
let mut sanitized = url.clone();
let refused = sanitized.set_username("").is_err();
let _ = sanitized.set_password(None);
if refused && sanitized.has_authority() {
let mut lent = sanitized.clone();
if lent.set_host(Some("x")).is_ok()
&& lent.set_username("").is_ok()
&& lent.set_password(None).is_ok()
&& lent.set_host(Some("")).is_ok()
{
sanitized = lent;
}
}
sanitized.set_fragment(None);
if matches!(sanitized.scheme(), "https" | "wss") {
sanitized.set_path("/");
sanitized.set_query(None);
}
sanitized
}
pub fn evaluate(
script: &PacScript,
url: &Url,
policy: &PacPolicy,
) -> Result<Vec<ProxyStep>, Error> {
evaluate_with_host(script, url, url.host_str().unwrap_or_default(), policy)
}
pub fn evaluate_with_host(
script: &PacScript,
url: &Url,
host: &str,
policy: &PacPolicy,
) -> Result<Vec<ProxyStep>, Error> {
let url = &sanitize_url(url);
#[cfg(pac_quickjs)]
{
QuickJsEvaluator::new(*policy).evaluate(script, url, host)
}
#[cfg(not(pac_quickjs))]
{
let _ = (script, url, host, policy);
Err(Error::PacEngineUnavailable)
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
use crate::BypassRules;
#[test]
fn every_requirement_debug_names_itself_and_keeps_its_fields() {
const SCRIPT: &str = "function FindProxyForURL(){}";
let url = Url::parse("https://wpad.corp/proxy.pac").unwrap();
let digest = format!(
"len: {}, fnv1a: {:016x}",
SCRIPT.len(),
crate::util::fnv1a(SCRIPT.as_bytes())
);
for (rendered, expected) in [
(
format!("{:?}", PacRequirement::NotNeeded),
"NotNeeded".to_owned(),
),
(
format!("{:?}", PacRequirement::Discover),
"Discover".to_owned(),
),
(
format!("{:?}", PacRequirement::Inline(SCRIPT)),
format!("Inline {{ {digest} }}"),
),
(
format!("{:?}", PacRequirement::Fetch(&url)),
"Fetch(https://wpad.corp/proxy.pac)".to_owned(),
),
(
format!("{:?}", PacScript::new(SCRIPT)),
format!("PacScript {{ {digest} }}"),
),
] {
assert_eq!(rendered, expected);
}
}
#[test]
fn the_documented_chromium_examples_come_out_the_same_way() {
for (actual, expected) in [
("https://www.google.com/Foo", "https://www.google.com/"),
("https://[dead::beef]/foo?bar", "https://[dead::beef]/"),
(
"https://www.example.com:8080#search",
"https://www.example.com:8080/",
),
(
"https://username:password@www.example.com",
"https://www.example.com/",
),
] {
let url = Url::parse(actual).expect("valid url");
assert_eq!(sanitize_url(&url).as_str(), expected, "for {actual}");
}
}
#[test]
fn only_cryptographic_schemes_lose_their_path_and_query() {
let http = Url::parse("http://user:pw@example.net/deep/path?q=1#frag").unwrap();
assert_eq!(
sanitize_url(&http).as_str(),
"http://example.net/deep/path?q=1"
);
let wss = Url::parse("wss://user:pw@example.net/socket?q=1#frag").unwrap();
assert_eq!(sanitize_url(&wss).as_str(), "wss://example.net/");
let ws = Url::parse("ws://user:pw@example.net/socket?q=1#frag").unwrap();
assert_eq!(sanitize_url(&ws).as_str(), "ws://example.net/socket?q=1");
}
#[test]
fn sanitizing_is_idempotent_and_leaves_the_host_alone() {
for raw in [
"https://user:pw@example.net:8443/a?b#c",
"http://example.net/",
"ftp://user@files.corp/pub",
] {
let url = Url::parse(raw).unwrap();
let once = sanitize_url(&url);
assert_eq!(sanitize_url(&once), once, "not idempotent for {raw}");
assert_eq!(once.host_str(), url.host_str(), "host changed for {raw}");
assert_eq!(once.port(), url.port(), "port changed for {raw}");
}
}
#[test]
fn cannot_be_a_base_urls_survive_untouched_except_for_the_fragment() {
let url = Url::parse("mailto:someone@example.net?subject=hi#frag").unwrap();
assert_eq!(
sanitize_url(&url).as_str(),
"mailto:someone@example.net?subject=hi"
);
}
#[test]
fn an_emptied_host_does_not_carry_the_credentials_through() {
for (input, expected) in [
(
"socks5://user:secret@example.net/p?q=1#frag",
"socks5:///p?q=1",
),
(
"socks5://user:secret@example.net:8080/p",
"socks5://:8080/p",
),
("socks5://:secret@example.net/p", "socks5:///p"),
("socks5://example.net:8080/p", "socks5://:8080/p"),
] {
let mut url = Url::parse(input).unwrap();
url.set_host(Some(""))
.expect("a non-special scheme accepts an empty host");
let sanitized = sanitize_url(&url);
assert_eq!(sanitized.as_str(), expected, "for {input}");
assert_eq!(
sanitize_url(&sanitized),
sanitized,
"not idempotent for {input}"
);
}
}
#[test]
fn a_path_only_url_is_not_lent_an_authority_it_never_had() {
for (input, expected) in [
("unix:/run/foo.socket", "unix:/run/foo.socket"),
("git:/a/b?q=1#frag", "git:/a/b?q=1"),
] {
let url = Url::parse(input).unwrap();
assert!(!url.has_authority(), "premise for {input}: {url:?}");
assert!(!url.cannot_be_a_base(), "premise for {input}: {url:?}");
assert_eq!(sanitize_url(&url).as_str(), expected, "for {input}");
}
}
#[cfg(pac_quickjs)]
#[test]
fn the_script_is_handed_the_sanitized_url_end_to_end() {
let script = PacScript::new(
"function FindProxyForURL(url, host) { return 'PROXY ' + url.replace(/[^a-zA-Z0-9.]/g, '-') + ':8080'; }",
);
let url = Url::parse("https://user:secret@example.net/private/doc?token=abc#x").unwrap();
let steps = evaluate(&script, &url, &PacPolicy::new()).expect("evaluated");
let seen = steps[0].endpoint().expect("a proxy step").authority();
assert_eq!(
seen, "https---example.net-:8080",
"the script must not see the credentials, path, query or fragment"
);
}
#[cfg(pac_quickjs)]
#[test]
fn a_hostless_url_reaches_the_script_with_an_empty_host() {
let script = PacScript::new(
"function FindProxyForURL(url, host) {
return host === '' ? 'PROXY empty.example:1' : 'PROXY host.example:2';
}",
);
let url = Url::parse("mailto:someone@example.net").unwrap();
let steps = evaluate(&script, &url, &PacPolicy::new()).expect("evaluated");
assert_eq!(
steps[0].endpoint().expect("a proxy step").authority(),
"empty.example:1",
"a hostless URL must reach the engine, with the host empty"
);
}
#[cfg(pac_quickjs)]
#[test]
fn the_host_override_is_what_the_script_sees() {
let script = PacScript::new(
"function FindProxyForURL(url, host) {
if (host === '[dead::beef]') { return 'PROXY bracketed.example:1'; }
if (host === 'dead::beef') { return 'PROXY unbracketed.example:2'; }
return 'PROXY neither.example:3';
}",
);
let url = Url::parse("https://[dead::beef]/foo").unwrap();
let steps = evaluate(&script, &url, &PacPolicy::new()).expect("evaluated");
assert_eq!(
steps[0].endpoint().expect("a proxy step").authority(),
"bracketed.example:1",
"`evaluate` must pass the spelling the URL itself carries"
);
let steps =
evaluate_with_host(&script, &url, "dead::beef", &PacPolicy::new()).expect("evaluated");
assert_eq!(
steps[0].endpoint().expect("a proxy step").authority(),
"unbracketed.example:2",
"the chosen host must reach the script instead of the URL's own"
);
}
#[test]
fn scripts_come_from_inline_modes_only() {
let inline = ProxyMode::pac_inline("body".to_owned());
assert_eq!(PacScript::from_mode(&inline), Some(PacScript::new("body")));
assert_eq!(PacScript::from_mode(&ProxyMode::Direct), None);
assert_eq!(
PacScript::from_mode(&ProxyMode::manual(HashMap::new(), BypassRules::new())),
None
);
let url = Url::parse("http://wpad.corp/proxy.pac").unwrap();
assert_eq!(PacScript::from_mode(&ProxyMode::pac(url)), None);
}
#[test]
fn requirements_cover_every_mode() {
let url = Url::parse("http://wpad.corp/proxy.pac").unwrap();
assert_eq!(
requirement(&ProxyMode::pac(url.clone())),
PacRequirement::Fetch(&url)
);
assert_eq!(
requirement(&ProxyMode::pac_inline("b".to_owned())),
PacRequirement::Inline("b")
);
assert_eq!(
requirement(&ProxyMode::WpadAutoDetect),
PacRequirement::Discover
);
assert_eq!(requirement(&ProxyMode::Direct), PacRequirement::NotNeeded);
assert_eq!(
requirement(&ProxyMode::manual(HashMap::new(), BypassRules::new())),
PacRequirement::NotNeeded
);
}
#[test]
#[cfg(not(pac_quickjs))]
fn without_an_engine_evaluation_reports_why() {
let script = PacScript::new("function FindProxyForURL(u, h) { return 'DIRECT'; }");
let url = Url::parse("http://example.com/").unwrap();
let error = evaluate(&script, &url, &PacPolicy::new()).unwrap_err();
assert!(matches!(error, Error::PacEngineUnavailable), "{error:?}");
}
}