#![cfg_attr(not(any(target_os = "macos", target_os = "ios")), allow(dead_code))]
use std::borrow::Cow;
use std::collections::HashMap;
use std::fmt;
use url::Url;
use crate::auth::ProxyAuth;
use crate::bypass::{BypassDialect, BypassRules, ImplicitBypass};
use crate::config::{ProxyConfig, ProxyConfigSource};
use crate::diagnostic::{RejectedValue, RejectionKind, RejectionSource};
use crate::endpoint::{ProxyEndpoint, ProxyEntry, ProxyScheme, Scheme};
use crate::error::Error;
use crate::mode::ProxyMode;
use crate::util::{port_from_digits, redact_offending_token};
const AUTO_DISCOVERY_ENABLE: &str = "ProxyAutoDiscoveryEnable";
const AUTO_CONFIG_ENABLE: &str = "ProxyAutoConfigEnable";
const AUTO_CONFIG_URL: &str = "ProxyAutoConfigURLString";
const AUTO_CONFIG_JAVASCRIPT: &str = "ProxyAutoConfigJavaScript";
const EXCEPTIONS_LIST: &str = "ExceptionsList";
const EXCLUDE_SIMPLE_HOSTNAMES: &str = "ExcludeSimpleHostnames";
struct SchemeKeys {
scheme: Scheme,
enable: &'static str,
host: &'static str,
port: &'static str,
user: &'static str,
default_port: u16,
hint: Option<ProxyScheme>,
}
const SCHEMES: [SchemeKeys; 4] = [
SchemeKeys {
scheme: Scheme::Http,
enable: "HTTPEnable",
host: "HTTPProxy",
port: "HTTPPort",
user: "HTTPUser",
default_port: 80,
hint: None,
},
SchemeKeys {
scheme: Scheme::Https,
enable: "HTTPSEnable",
host: "HTTPSProxy",
port: "HTTPSPort",
user: "HTTPSUser",
default_port: 443,
hint: None,
},
SchemeKeys {
scheme: Scheme::Ftp,
enable: "FTPEnable",
host: "FTPProxy",
port: "FTPPort",
user: "FTPUser",
default_port: 80,
hint: None,
},
SchemeKeys {
scheme: Scheme::Socks,
enable: "SOCKSEnable",
host: "SOCKSProxy",
port: "SOCKSPort",
user: "SOCKSUser",
default_port: 1080,
hint: Some(ProxyScheme::Socks5),
},
];
struct UnroutableKeys {
enable: &'static str,
host: &'static str,
}
const UNROUTABLE: [UnroutableKeys; 2] = [
UnroutableKeys {
enable: "GopherEnable",
host: "GopherProxy",
},
UnroutableKeys {
enable: "RTSPEnable",
host: "RTSPProxy",
},
];
const UNREADABLE: &str = "<unreadable value>";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum DictValue {
Number(i64),
Text(String),
List {
items: Vec<String>,
unreadable: usize,
},
Unreadable,
}
#[derive(Clone, Default, PartialEq, Eq)]
pub(crate) struct ProxyDict {
entries: HashMap<String, DictValue>,
}
pub(super) fn is_known_key(key: &str) -> bool {
const SINGLES: [&str; 6] = [
AUTO_DISCOVERY_ENABLE,
AUTO_CONFIG_ENABLE,
AUTO_CONFIG_URL,
AUTO_CONFIG_JAVASCRIPT,
EXCEPTIONS_LIST,
EXCLUDE_SIMPLE_HOSTNAMES,
];
SINGLES.contains(&key)
|| SCHEMES
.iter()
.any(|keys| [keys.enable, keys.host, keys.port, keys.user].contains(&key))
|| UNROUTABLE
.iter()
.any(|keys| [keys.enable, keys.host].contains(&key))
}
impl fmt::Debug for ProxyDict {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut keys: Vec<&str> = self.entries.keys().map(String::as_str).collect();
keys.sort_unstable();
let mut map = f.debug_map();
for key in keys {
let value = &self.entries[key];
match (key, value) {
(AUTO_CONFIG_JAVASCRIPT, DictValue::Text(script)) => map.entry(
&key,
&format_args!(
"<{} bytes, fnv1a {:016x}>",
script.len(),
crate::util::fnv1a(script.as_bytes())
),
),
(AUTO_CONFIG_URL, DictValue::Text(url)) => {
map.entry(&key, &format_args!("{}", redact_offending_token(url)))
}
(AUTO_CONFIG_JAVASCRIPT | AUTO_CONFIG_URL, _) => {
map.entry(&key, &format_args!("<unmodelled type, withheld>"))
}
(_, DictValue::Unreadable) => map.entry(&key, &format_args!("{UNREADABLE}")),
(_, DictValue::Text(text)) if !is_known_key(key) => {
map.entry(&key, &format_args!("<{} bytes, key not read>", text.len()))
}
(_, DictValue::List { items, .. }) if !is_known_key(key) => map.entry(
&key,
&format_args!("<{} strings, key not read>", items.len()),
),
(_, DictValue::Number(_)) if !is_known_key(key) => {
map.entry(&key, &format_args!("<number, key not read>"))
}
(_, DictValue::Text(text)) => {
map.entry(&key, &format_args!("{}", redact_offending_token(text)))
}
(_, DictValue::List { items, .. }) => map.entry(
&key,
&items
.iter()
.map(|item| redact_offending_token(item))
.collect::<Vec<_>>(),
),
(_, DictValue::Number(number)) => map.entry(&key, number),
};
}
map.finish()
}
}
impl ProxyDict {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn insert(&mut self, key: impl Into<String>, value: DictValue) {
self.entries.insert(key.into(), value);
}
fn flag(&self, key: &str) -> Option<bool> {
match self.entries.get(key)? {
DictValue::Number(value) => Some(*value != 0),
DictValue::Text(text) => text.trim().parse::<i64>().ok().map(|value| value != 0),
DictValue::List { .. } | DictValue::Unreadable => None,
}
}
fn flag_is_unusable(&self, key: &str) -> Option<String> {
match self.entries.get(key)? {
DictValue::Number(_) => None,
DictValue::Text(text) => self.flag(key).is_none().then(|| text.trim().to_owned()),
DictValue::List { items, .. } => Some(format!("<{} strings>", items.len())),
DictValue::Unreadable => Some(UNREADABLE.to_owned()),
}
}
fn text(&self, key: &str) -> Option<&str> {
match self.entries.get(key)? {
DictValue::Text(text) => Some(text.trim()).filter(|text| !text.is_empty()),
_ => None,
}
}
fn raw_text(&self, key: &str) -> Option<&str> {
match self.entries.get(key)? {
DictValue::Text(text) => Some(text.as_str()).filter(|text| !text.trim().is_empty()),
_ => None,
}
}
fn text_is_unusable(&self, key: &str) -> Option<String> {
match self.entries.get(key)? {
DictValue::Text(_) => None,
DictValue::Number(value) => Some(value.to_string()),
DictValue::List { items, .. } => Some(format!("<{} strings>", items.len())),
DictValue::Unreadable => Some(UNREADABLE.to_owned()),
}
}
fn port(&self, key: &str) -> Option<u16> {
let port = match self.entries.get(key)? {
DictValue::Number(value) => u16::try_from(*value).ok(),
DictValue::Text(text) => port_from_digits(text.trim()),
DictValue::List { .. } | DictValue::Unreadable => None,
};
port.filter(|port| *port != 0)
}
fn port_is_unusable(&self, key: &str) -> Option<String> {
let value = self.entries.get(key)?;
if self.port(key).is_some() {
return None;
}
match value {
DictValue::Number(value) => (*value != 0).then(|| value.to_string()),
DictValue::Text(text) => {
(!matches!(text.trim(), "" | "0")).then(|| text.trim().to_owned())
}
DictValue::List { items, .. } => Some(format!("<{} strings>", items.len())),
DictValue::Unreadable => Some(UNREADABLE.to_owned()),
}
}
fn list(&self, key: &str) -> Option<(&[String], usize)> {
match self.entries.get(key)? {
DictValue::List { items, unreadable } => Some((items.as_slice(), *unreadable)),
_ => None,
}
}
fn list_is_unusable(&self, key: &str) -> Option<String> {
match self.entries.get(key)? {
DictValue::List { .. } => None,
DictValue::Text(text) => Some(text.trim().to_owned()).filter(|text| !text.is_empty()),
DictValue::Number(value) => Some(value.to_string()),
DictValue::Unreadable => Some(UNREADABLE.to_owned()),
}
}
}
impl FromIterator<(String, DictValue)> for ProxyDict {
fn from_iter<I: IntoIterator<Item = (String, DictValue)>>(iter: I) -> Self {
Self {
entries: iter.into_iter().collect(),
}
}
}
pub(crate) fn mode_from_dict(dict: &ProxyDict) -> Result<ProxyMode, Error> {
let mut rejected = Vec::new();
record_unreadable_flag(
dict,
AUTO_DISCOVERY_ENABLE,
Some(Scheme::All),
&mut rejected,
);
if dict.flag(AUTO_DISCOVERY_ENABLE) == Some(true) {
return Ok(ProxyMode::WpadAutoDetect);
}
record_unreadable_flag(dict, AUTO_CONFIG_ENABLE, Some(Scheme::All), &mut rejected);
if dict.flag(AUTO_CONFIG_ENABLE) == Some(true) {
if let Some(script) = dict.raw_text(AUTO_CONFIG_JAVASCRIPT) {
return Ok(ProxyMode::pac_inline(script.to_owned()).with_rejected(rejected));
}
if let Some(url) = dict.text(AUTO_CONFIG_URL) {
let parsed = Url::parse(url).map_err(|source| Error::invalid_proxy_url(url, source))?;
return Ok(ProxyMode::pac(parsed).with_rejected(rejected));
}
record_unreadable_text(
dict,
AUTO_CONFIG_JAVASCRIPT,
Some(Scheme::All),
&mut rejected,
);
record_unreadable_text(dict, AUTO_CONFIG_URL, Some(Scheme::All), &mut rejected);
}
let mut per_scheme = HashMap::new();
let mut any_enabled = false;
for keys in &SCHEMES {
let attributed_scheme = if keys.scheme == Scheme::Socks {
Scheme::All
} else {
keys.scheme
};
let Some(enabled) = dict.flag(keys.enable) else {
record_unreadable_flag(dict, keys.enable, Some(attributed_scheme), &mut rejected);
continue;
};
let host = if enabled { dict.text(keys.host) } else { None };
let Some(host) = host else {
if enabled
&& record_unreadable_text(dict, keys.host, Some(attributed_scheme), &mut rejected)
{
continue;
}
per_scheme.insert(keys.scheme, ProxyEntry::Disabled);
continue;
};
let mut endpoint = match ProxyEndpoint::parse(host, keys.default_port) {
Ok(endpoint) => endpoint,
#[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
Err(err) => {
crate::trace::warning!(
error = %crate::trace::SafeError(&err),
"skipping an unparseable <Scheme>Proxy host"
);
rejected.push(
RejectedValue::new(
RejectionKind::InvalidProxyEndpoint,
RejectionSource::SystemConfiguration(keys.host.to_owned()),
host,
)
.for_scheme(Some(attributed_scheme)),
);
continue;
}
};
if let Some(port) = dict.port(keys.port) {
endpoint.port = port;
} else if let Some(value) = dict.port_is_unusable(keys.port) {
crate::trace::warning!(
key = keys.port,
"skipping a scheme whose port is not a port"
);
rejected.push(
RejectedValue::new(
RejectionKind::InvalidProxyEndpoint,
RejectionSource::SystemConfiguration(keys.port.to_owned()),
&value,
)
.for_scheme(Some(attributed_scheme)),
);
continue;
}
if let Some(hint) = keys.hint
&& endpoint.scheme_hint.is_none()
{
endpoint = endpoint.with_scheme_hint(hint);
}
if let Some(user) = dict.text(keys.user) {
endpoint = endpoint.with_auth(ProxyAuth::from_username(user).password_in_keychain());
} else if dict.text_is_unusable(keys.user).is_some() {
warn_unusable_username(keys.user);
}
per_scheme.insert(keys.scheme, ProxyEntry::Use(endpoint));
any_enabled = true;
}
record_unroutable_schemes(dict, &mut rejected);
apply_socks_fallback(&mut per_scheme);
if rejected
.iter()
.any(|value| value.affected_scheme() == Some(Scheme::All))
{
per_scheme.retain(|_, entry| !entry.is_disabled());
}
if !any_enabled && rejected.is_empty() {
return Ok(ProxyMode::Direct);
}
Ok(ProxyMode::manual(per_scheme, bypass_from_dict(dict)).with_rejected(rejected))
}
fn apply_socks_fallback(per_scheme: &mut HashMap<Scheme, ProxyEntry>) {
let Some(ProxyEntry::Use(endpoint)) = per_scheme.get(&Scheme::Socks) else {
return;
};
let endpoint = endpoint.clone();
for scheme in [Scheme::Http, Scheme::Https, Scheme::Ftp, Scheme::All] {
if matches!(per_scheme.get(&scheme), Some(ProxyEntry::Use(_))) {
continue;
}
per_scheme.insert(scheme, ProxyEntry::Use(endpoint.clone()));
}
}
fn record_unroutable_schemes(dict: &ProxyDict, rejected: &mut Vec<RejectedValue>) {
for keys in &UNROUTABLE {
if dict.flag(keys.enable) != Some(true) {
record_unreadable_flag(dict, keys.enable, None, rejected);
continue;
}
let Some(host) = dict.text(keys.host) else {
record_unreadable_text(dict, keys.host, None, rejected);
continue;
};
crate::trace::warning!(
key = keys.enable,
"skipping an enabled proxy whose scheme this crate has no variant for"
);
rejected.push(RejectedValue::new(
RejectionKind::UnknownProxyScheme,
RejectionSource::SystemConfiguration(keys.host.to_owned()),
host,
));
}
}
fn record_unreadable_flag(
dict: &ProxyDict,
key: &str,
scheme: Option<Scheme>,
rejected: &mut Vec<RejectedValue>,
) -> bool {
let Some(value) = dict.flag_is_unusable(key) else {
return false;
};
crate::trace::warning!(key, "skipping a setting whose enable flag is not a flag");
rejected.push(
RejectedValue::new(
RejectionKind::UnsupportedMapping,
RejectionSource::SystemConfiguration(key.to_owned()),
&value,
)
.for_scheme(scheme),
);
true
}
fn record_unreadable_text(
dict: &ProxyDict,
key: &str,
scheme: Option<Scheme>,
rejected: &mut Vec<RejectedValue>,
) -> bool {
let Some(value) = dict.text_is_unusable(key) else {
return false;
};
crate::trace::warning!(key, "skipping a key whose value is not a string");
rejected.push(
RejectedValue::new(
RejectionKind::UnsupportedMapping,
RejectionSource::SystemConfiguration(key.to_owned()),
&value,
)
.for_scheme(scheme),
);
true
}
#[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
fn warn_unusable_username(key: &str) {
crate::trace::warning!(key, "ignoring a username key that is not a string");
}
#[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
fn warn_unusable_exceptions_list(key: &str) {
crate::trace::warning!(key, "ignoring a bypass list that is not an array");
}
fn bypass_from_dict(dict: &ProxyDict) -> BypassRules {
let mut rules = BypassRules::new();
let has_bypass_key = dict
.list(EXCEPTIONS_LIST)
.is_some_and(|(items, unreadable)| !items.is_empty() || unreadable > 0)
|| dict.flag(EXCLUDE_SIMPLE_HOSTNAMES) == Some(true);
rules.strip_trailing_dot = true;
rules.ipv4_mapped_as_ipv4 = false;
rules.implicit = if has_bypass_key {
ImplicitBypass::CfNetwork
} else {
ImplicitBypass::Empty
};
if let Some(value) = dict.list_is_unusable(EXCEPTIONS_LIST) {
warn_unusable_exceptions_list(EXCEPTIONS_LIST);
rules.rejected.push(RejectedValue::new(
RejectionKind::UnsupportedMapping,
RejectionSource::SystemConfiguration(EXCEPTIONS_LIST.to_owned()),
&value,
));
}
let (entries, unreadable) = dict.list(EXCEPTIONS_LIST).unwrap_or((&[], 0));
for entry in entries {
rules.push_entry_in(&expand_abbreviated_cidr(entry), BypassDialect::MacOs);
}
rules.dedup_patterns();
for _ in 0..unreadable {
rules.rejected.push(RejectedValue::new(
RejectionKind::UnsupportedMapping,
RejectionSource::SystemConfiguration(EXCEPTIONS_LIST.to_owned()),
UNREADABLE,
));
}
if dict.flag(EXCLUDE_SIMPLE_HOSTNAMES) == Some(true) {
rules.exclude_simple_hostnames = true;
} else if let Some(value) = dict.flag_is_unusable(EXCLUDE_SIMPLE_HOSTNAMES) {
warn_unusable_exclude_simple_hostnames(EXCLUDE_SIMPLE_HOSTNAMES);
rules.rejected.push(RejectedValue::new(
RejectionKind::UnsupportedMapping,
RejectionSource::SystemConfiguration(EXCLUDE_SIMPLE_HOSTNAMES.to_owned()),
&value,
));
}
rules
}
#[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
fn warn_unusable_exclude_simple_hostnames(key: &str) {
crate::trace::warning!(key, "ignoring a simple-hostname switch that is not a flag");
}
fn expand_abbreviated_cidr(entry: &str) -> Cow<'_, str> {
let Some((address, prefix)) = entry.split_once('/') else {
return Cow::Borrowed(entry);
};
if address.contains(':') || prefix.is_empty() || !prefix.bytes().all(|b| b.is_ascii_digit()) {
return Cow::Borrowed(entry);
}
let octets: Vec<&str> = address.split('.').collect();
if octets.len() >= 4
|| !octets
.iter()
.all(|octet| !octet.is_empty() && octet.bytes().all(|b| b.is_ascii_digit()))
{
return Cow::Borrowed(entry);
}
let mut padded = String::from(address);
for _ in octets.len()..4 {
padded.push_str(".0");
}
Cow::Owned(format!("{padded}/{prefix}"))
}
#[cfg_attr(all(target_os = "ios", not(test)), expect(dead_code))]
pub(crate) fn merge_setup_and_state(
setup: Result<Option<ProxyMode>, Error>,
state: Option<ProxyMode>,
) -> Result<ProxyConfig, Error> {
let mut fallbacks = Vec::new();
let setup = match setup {
Ok(setup) => setup,
#[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
Err(error) if state.is_some() => {
crate::trace::warning!(
error = %crate::trace::SafeError(&error),
"the Setup: proxies scope could not be read; reporting the State: scope \
alone, which is the one in effect"
);
fallbacks.push(ProxyConfigSource::SystemConfigurationSetup);
None
}
Err(error) => return Err(error),
};
let mut sources = Vec::new();
if let Some(mode) = &state {
sources.push((ProxyConfigSource::SystemConfigurationState, mode.clone()));
}
if let Some(mode) = &setup {
sources.push((ProxyConfigSource::SystemConfigurationSetup, mode.clone()));
}
Ok(ProxyConfig::from_ordered_sources(sources).with_fallbacks(fallbacks))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::PasswordState;
macro_rules! dict {
($($key:literal => $value:expr),* $(,)?) => {{
#[allow(unused_mut)]
let mut dict = ProxyDict::new();
$(dict.insert($key, $value.into_dict_value());)*
dict
}};
}
trait IntoDictValue {
fn into_dict_value(self) -> DictValue;
}
impl IntoDictValue for &str {
fn into_dict_value(self) -> DictValue {
DictValue::Text(self.to_owned())
}
}
impl IntoDictValue for i64 {
fn into_dict_value(self) -> DictValue {
DictValue::Number(self)
}
}
impl IntoDictValue for &[&str] {
fn into_dict_value(self) -> DictValue {
DictValue::List {
items: self.iter().map(|s| (*s).to_owned()).collect(),
unreadable: 0,
}
}
}
#[test]
fn debug_never_prints_an_unvetted_value() {
const SECRET: &str = "hunter2";
let dict = dict! {
"HTTPEnable" => 1i64,
"HTTPProxy" => "proxy.corp",
"HTTPPort" => 8080i64,
"HTTPUser" => "alice",
"HTTPProxyPassword" => SECRET,
"SomeFutureList" => &[SECRET][..],
"GopherPort" => 7070i64,
"ProxyAutoConfigURLString" => "http://alice:hunter2@wpad.corp/proxy.pac",
"ProxyAutoConfigJavaScript" => "function FindProxyForURL(u, h) { return 'DIRECT'; }",
};
let rendered = format!("{dict:?}");
assert!(
!rendered.contains(SECRET),
"an unread key's value reached Debug: {rendered}"
);
assert!(
!rendered.contains("7070"),
"an unread key's number reached Debug: {rendered}"
);
assert!(rendered.contains("proxy.corp"), "{rendered}");
assert!(rendered.contains("alice"), "{rendered}");
assert!(rendered.contains("wpad.corp"), "{rendered}");
assert!(rendered.contains("fnv1a"), "{rendered}");
assert!(
!rendered.contains("FindProxyForURL"),
"the inline PAC body reached Debug: {rendered}"
);
}
#[test]
fn a_sensitive_key_is_reduced_even_when_its_value_is_the_wrong_type() {
const SECRET: &str = "hunter2";
let carrier = format!("http://alice:{SECRET}@wpad.corp/proxy.pac");
for key in [AUTO_CONFIG_JAVASCRIPT, AUTO_CONFIG_URL] {
let mut dict = ProxyDict::new();
dict.insert(
key,
DictValue::List {
items: vec![carrier.clone()],
unreadable: 0,
},
);
let rendered = format!("{dict:?}");
assert!(
!rendered.contains(SECRET),
"{key} as a list reached Debug: {rendered}"
);
assert!(rendered.contains(key), "the key itself is not the secret");
let mut dict = ProxyDict::new();
dict.insert(key, DictValue::Number(7070));
let rendered = format!("{dict:?}");
assert!(!rendered.contains("7070"), "{rendered}");
}
let mut dict = ProxyDict::new();
dict.insert(AUTO_CONFIG_URL, DictValue::Text(carrier.clone()));
dict.insert(
"SomeFutureList",
DictValue::List {
items: vec![carrier.clone()],
unreadable: 0,
},
);
let rendered = format!("{dict:?}");
assert!(!rendered.contains(SECRET), "{rendered}");
assert!(rendered.contains("wpad.corp/proxy.pac"), "{rendered}");
assert!(rendered.contains("1 strings, key not read"), "{rendered}");
}
#[test]
fn an_empty_dictionary_is_direct() {
assert_eq!(
mode_from_dict(&ProxyDict::new()).unwrap(),
ProxyMode::Direct
);
}
#[test]
fn all_enable_keys_zero_is_direct() {
let dict = dict! {
"HTTPEnable" => 0i64,
"HTTPSEnable" => 0i64,
"FTPEnable" => 0i64,
"SOCKSEnable" => 0i64,
"ProxyAutoConfigEnable" => 0i64,
"ProxyAutoDiscoveryEnable" => 0i64,
};
assert_eq!(mode_from_dict(&dict).unwrap(), ProxyMode::Direct);
}
#[test]
fn an_enabled_proxy_this_crate_cannot_route_through_is_still_recorded() {
let dict = dict! {
"GopherEnable" => 1i64,
"GopherProxy" => "gopher.corp",
"GopherPort" => 7070i64,
};
let mode = mode_from_dict(&dict).unwrap();
let rejected = mode
.rejected()
.expect("an enabled Gopher proxy left no record");
assert_eq!(rejected.len(), 1, "{rejected:?}");
assert_eq!(rejected[0].kind(), RejectionKind::UnknownProxyScheme);
assert_eq!(
*rejected[0].source(),
RejectionSource::SystemConfiguration("GopherProxy".to_owned())
);
assert_eq!(rejected[0].redacted_input(), "gopher.corp");
for scheme in Scheme::ALL {
assert!(mode.endpoint_for(scheme).is_none(), "{scheme:?}");
}
}
#[test]
fn a_family_this_crate_cannot_route_through_takes_no_answer_away() {
let mut unreadable_host = dict! { "GopherEnable" => 1i64 };
unreadable_host.insert("GopherProxy", DictValue::Unreadable);
for dict in [
dict! { "GopherEnable" => 1i64, "GopherProxy" => "gopher.corp" },
ProxyDict::from_iter([("GopherEnable".to_owned(), DictValue::Unreadable)]),
unreadable_host,
] {
let mode = mode_from_dict(&dict).unwrap();
let rejected = mode
.rejected()
.unwrap_or_else(|| panic!("{dict:?} left no record: {mode:?}"))
.to_vec();
assert_eq!(rejected.len(), 1, "{rejected:?}");
assert_eq!(rejected[0].affected_scheme(), None, "{rejected:?}");
#[cfg(feature = "resolve")]
{
let config = crate::ProxyConfig::new(mode, Vec::new());
let url = url::Url::parse("http://intranet.corp/x").unwrap();
assert_eq!(
crate::resolve(&config, &url).unwrap(),
vec![crate::ProxyStep::Direct],
"{rejected:?}"
);
}
}
}
#[test]
fn an_enable_flag_this_crate_cannot_read_is_still_recorded() {
for value in ["yes", "on"] {
let dict = dict! {
"HTTPEnable" => value,
"HTTPProxy" => "proxy.corp",
"HTTPPort" => 8080i64,
};
let mode = mode_from_dict(&dict).unwrap();
let rejected = mode
.rejected()
.unwrap_or_else(|| panic!("HTTPEnable = {value:?} left no record"));
assert_eq!(rejected.len(), 1, "{rejected:?}");
assert_eq!(rejected[0].kind(), RejectionKind::UnsupportedMapping);
assert_eq!(
*rejected[0].source(),
RejectionSource::SystemConfiguration("HTTPEnable".to_owned())
);
assert_eq!(rejected[0].redacted_input(), value);
assert_eq!(rejected[0].affected_scheme(), Some(Scheme::Http));
assert!(mode.endpoint_for(Scheme::Http).is_none(), "{mode:?}");
}
let listed = dict! {
"HTTPEnable" => &["1"][..],
"HTTPProxy" => "proxy.corp",
};
assert!(
mode_from_dict(&listed).unwrap().rejected().is_some(),
"a list-valued HTTPEnable left no record"
);
}
#[test]
fn an_enabled_schemes_unreadable_host_is_still_recorded() {
let numeric = dict! {
"HTTPEnable" => 1i64,
"HTTPProxy" => 8080i64,
};
let mode = mode_from_dict(&numeric).unwrap();
let rejected = mode.rejected().expect("a numeric HTTPProxy left no record");
assert_eq!(rejected.len(), 1, "{rejected:?}");
assert_eq!(rejected[0].kind(), RejectionKind::UnsupportedMapping);
assert_eq!(
*rejected[0].source(),
RejectionSource::SystemConfiguration("HTTPProxy".to_owned())
);
assert_eq!(rejected[0].redacted_input(), "8080");
assert_eq!(rejected[0].affected_scheme(), Some(Scheme::Http));
assert!(mode.endpoint_for(Scheme::Http).is_none(), "{mode:?}");
let entry = mode
.entry_for(Scheme::Http)
.expect("an unreadable host left the slot empty");
assert!(!entry.is_disabled(), "{entry:?}");
assert_eq!(entry.rejected(), Some(&rejected[0]), "{entry:?}");
let listed = dict! {
"HTTPEnable" => 1i64,
"HTTPProxy" => &["proxy.corp", "other.corp"][..],
};
let mode = mode_from_dict(&listed).unwrap();
let rejected = mode.rejected().expect("a list HTTPProxy left no record");
assert_eq!(rejected[0].redacted_input(), "<2 strings>");
}
#[test]
fn an_enabled_pac_url_that_is_not_a_string_is_still_recorded() {
let dict = dict! {
"ProxyAutoConfigEnable" => 1i64,
"ProxyAutoConfigURLString" => 7i64,
};
let mode = mode_from_dict(&dict).unwrap();
let rejected = mode.rejected().expect("a numeric PAC URL left no record");
assert_eq!(rejected.len(), 1, "{rejected:?}");
assert_eq!(
*rejected[0].source(),
RejectionSource::SystemConfiguration("ProxyAutoConfigURLString".to_owned())
);
assert_ne!(mode, ProxyMode::Direct, "{mode:?}");
#[cfg(feature = "resolve")]
{
let config = crate::ProxyConfig::new(mode, Vec::new());
let url = url::Url::parse("http://intranet.corp/x").unwrap();
let err = crate::resolve(&config, &url).unwrap_err();
assert!(
matches!(&err, crate::Error::ProxyEntryUnusable { scheme, .. }
if *scheme == Scheme::All),
"{err:?}"
);
}
}
#[test]
fn an_unreadable_auto_enable_flag_is_recorded_rather_than_read_as_off() {
for key in [AUTO_DISCOVERY_ENABLE, AUTO_CONFIG_ENABLE] {
let dict = ProxyDict::from_iter([(key.to_owned(), DictValue::Text("yes".to_owned()))]);
let mode = mode_from_dict(&dict).unwrap();
let rejected = mode
.rejected()
.unwrap_or_else(|| panic!("an unreadable {key} left no record: {mode:?}"));
assert_eq!(rejected.len(), 1, "{rejected:?}");
assert_eq!(
*rejected[0].source(),
RejectionSource::SystemConfiguration(key.to_owned())
);
assert_eq!(rejected[0].redacted_input(), "yes");
assert_eq!(rejected[0].affected_scheme(), Some(Scheme::All), "{key}");
}
}
#[test]
fn a_value_lost_at_the_core_foundation_boundary_is_recorded_under_every_reader() {
for (key, kind, scheme) in [
(
"HTTPEnable",
RejectionKind::UnsupportedMapping,
Scheme::Http,
),
("HTTPProxy", RejectionKind::UnsupportedMapping, Scheme::Http),
(
"HTTPPort",
RejectionKind::InvalidProxyEndpoint,
Scheme::Http,
),
(
AUTO_CONFIG_ENABLE,
RejectionKind::UnsupportedMapping,
Scheme::All,
),
] {
let mut dict = dict! { "HTTPEnable" => 1, "HTTPProxy" => "proxy.corp" };
dict.insert(key, DictValue::Unreadable);
let mode = mode_from_dict(&dict).unwrap();
let rejected = mode
.rejected()
.unwrap_or_else(|| panic!("an unreadable {key} left no record: {mode:?}"));
assert_eq!(rejected.len(), 1, "{key}: {rejected:?}");
assert_eq!(
*rejected[0].source(),
RejectionSource::SystemConfiguration(key.to_owned())
);
assert_eq!(rejected[0].kind(), kind, "{key}");
assert_eq!(rejected[0].affected_scheme(), Some(scheme), "{key}");
assert_eq!(rejected[0].redacted_input(), UNREADABLE, "{key}");
}
let mut dict = dict! { "HTTPEnable" => 1, "HTTPProxy" => "proxy.corp" };
dict.insert(EXCEPTIONS_LIST, DictValue::Unreadable);
let mode = mode_from_dict(&dict).unwrap();
let rejected = &mode.bypass().expect("a manual mode carries rules").rejected;
assert_eq!(rejected.len(), 1, "{rejected:?}");
assert_eq!(
*rejected[0].source(),
RejectionSource::SystemConfiguration(EXCEPTIONS_LIST.to_owned())
);
assert_eq!(rejected[0].redacted_input(), UNREADABLE);
}
#[test]
fn every_element_a_bypass_list_lost_is_recorded_on_the_rules() {
let mut dict = dict! { "HTTPEnable" => 1, "HTTPProxy" => "proxy.corp" };
dict.insert(
EXCEPTIONS_LIST,
DictValue::List {
items: vec!["*.internal".to_owned()],
unreadable: 2,
},
);
let mode = mode_from_dict(&dict).unwrap();
let rules = mode.bypass().expect("a manual mode carries rules");
assert!(
rules.matches_authority("db.internal"),
"the element that did arrive still excludes its host: {rules:?}"
);
assert_eq!(rules.rejected.len(), 2, "{:?}", rules.rejected);
for record in &rules.rejected {
assert_eq!(
*record.source(),
RejectionSource::SystemConfiguration(EXCEPTIONS_LIST.to_owned())
);
assert_eq!(record.kind(), RejectionKind::UnsupportedMapping);
assert_eq!(record.redacted_input(), UNREADABLE);
}
}
#[cfg(feature = "resolve")]
#[test]
fn an_unreadable_auto_enable_flag_is_reported_rather_than_resolved_direct() {
for key in [AUTO_DISCOVERY_ENABLE, AUTO_CONFIG_ENABLE] {
let dict = ProxyDict::from_iter([(key.to_owned(), DictValue::Text("yes".to_owned()))]);
let config = crate::ProxyConfig::new(mode_from_dict(&dict).unwrap(), Vec::new());
for text in [
"http://intranet.corp/x",
"https://intranet.corp/x",
"ws://h/",
] {
let url = url::Url::parse(text).unwrap();
let err = crate::resolve(&config, &url).unwrap_err();
assert!(
matches!(&err, crate::Error::ProxyEntryUnusable { scheme, .. }
if *scheme == Scheme::All),
"{key} {text}: {err:?}"
);
}
}
}
#[cfg(feature = "resolve")]
#[test]
fn a_drop_under_one_scheme_takes_no_other_schemes_answer() {
let cases = [
(dict! { "HTTPSEnable" => "yes" }, Scheme::Https, "https"),
(
dict! { "FTPEnable" => 1i64, "FTPProxy" => 7i64 },
Scheme::Ftp,
"ftp",
),
];
for (dict, scheme, dropped) in cases {
let config = crate::ProxyConfig::new(mode_from_dict(&dict).unwrap(), Vec::new());
let url = url::Url::parse(&format!("{dropped}://intranet.corp/x")).unwrap();
let err = crate::resolve(&config, &url).unwrap_err();
assert!(
matches!(&err, crate::Error::ProxyEntryUnusable { scheme: got, .. }
if *got == scheme),
"{dropped}: {err:?}"
);
let other = url::Url::parse("http://intranet.corp/x").unwrap();
assert_eq!(
crate::resolve(&config, &other).unwrap(),
vec![crate::ProxyStep::Direct],
"{dropped} took http's answer away too"
);
}
}
#[test]
fn an_enabled_unroutable_scheme_with_an_unreadable_host_is_still_recorded() {
let dict = dict! {
"GopherEnable" => 1i64,
"GopherProxy" => 70i64,
};
let mode = mode_from_dict(&dict).unwrap();
let rejected = mode
.rejected()
.expect("a numeric GopherProxy left no record");
assert_eq!(rejected.len(), 1, "{rejected:?}");
assert_eq!(rejected[0].kind(), RejectionKind::UnsupportedMapping);
assert_eq!(rejected[0].redacted_input(), "70");
}
#[test]
fn a_blank_or_switched_off_host_is_not_recorded() {
let blank = dict! {
"HTTPEnable" => 1i64,
"HTTPProxy" => " ",
};
assert_eq!(mode_from_dict(&blank).unwrap(), ProxyMode::Direct);
let switched_off = dict! {
"HTTPEnable" => 0i64,
"HTTPProxy" => 8080i64,
};
assert_eq!(mode_from_dict(&switched_off).unwrap(), ProxyMode::Direct);
}
#[test]
fn a_switched_off_scheme_collapses_here_and_stays_manual_on_windows() {
let dict = dict! {
"HTTPEnable" => 0i64,
"HTTPProxy" => "proxy.corp",
};
let mac = mode_from_dict(&dict).unwrap();
let windows = crate::parse::windows_manual("http=", "");
assert!(mac.is_direct(), "{mac:?}");
assert!(!windows.is_direct(), "{windows:?}");
assert_eq!(windows.entry_for(Scheme::Http), Some(&ProxyEntry::Disabled));
#[cfg(feature = "resolve")]
{
let url = url::Url::parse("http://example.net/").unwrap();
for (source, mode) in [
(ProxyConfigSource::SystemConfigurationState, mac),
(ProxyConfigSource::Registry, windows),
] {
let config = ProxyConfig::from_source(source, mode);
assert_eq!(
crate::resolve::resolve(&config, &url).unwrap(),
vec![crate::resolve::ProxyStep::Direct]
);
}
}
}
#[test]
fn a_flag_that_is_absent_or_readable_is_not_recorded() {
let absent = dict! { "HTTPProxy" => "proxy.corp" };
assert_eq!(mode_from_dict(&absent).unwrap(), ProxyMode::Direct);
let text_digits = dict! {
"HTTPEnable" => "0",
"HTTPProxy" => "proxy.corp",
};
assert_eq!(mode_from_dict(&text_digits).unwrap(), ProxyMode::Direct);
}
#[test]
fn an_unroutable_proxy_that_is_off_or_hostless_is_not_recorded() {
let off = dict! {
"RTSPEnable" => 0i64,
"RTSPProxy" => "stream.corp",
};
assert_eq!(mode_from_dict(&off).unwrap(), ProxyMode::Direct);
let hostless = dict! { "RTSPEnable" => 1i64 };
assert_eq!(mode_from_dict(&hostless).unwrap(), ProxyMode::Direct);
}
#[test]
fn http_enable_with_host_and_port_is_manual() {
let dict = dict! {
"HTTPEnable" => 1i64,
"HTTPProxy" => "proxy.corp",
"HTTPPort" => 8080i64,
};
let mode = mode_from_dict(&dict).unwrap();
let endpoint = mode.endpoint_for(Scheme::Http).expect("http endpoint");
assert_eq!(endpoint.authority(), "proxy.corp:8080");
assert_eq!(endpoint.scheme_hint, None);
assert!(mode.endpoint_for(Scheme::Ftp).is_none());
}
#[test]
fn a_missing_port_falls_back_to_the_scheme_default() {
let dict = dict! {
"HTTPEnable" => 1i64,
"HTTPProxy" => "proxy.corp",
"HTTPSEnable" => 1i64,
"HTTPSProxy" => "secure.corp",
"FTPEnable" => 1i64,
"FTPProxy" => "files.corp",
"SOCKSEnable" => 1i64,
"SOCKSProxy" => "socks.corp",
};
let mode = mode_from_dict(&dict).unwrap();
assert_eq!(mode.endpoint_for(Scheme::Http).unwrap().port, 80);
assert_eq!(mode.endpoint_for(Scheme::Https).unwrap().port, 443);
assert_eq!(mode.endpoint_for(Scheme::Ftp).unwrap().port, 80);
let socks = mode.endpoint_for(Scheme::Socks).unwrap();
assert_eq!(socks.port, 1080);
assert_eq!(socks.scheme_hint, Some(ProxyScheme::Socks5));
}
#[test]
fn a_zero_port_is_treated_as_unset() {
let dict = dict! {
"HTTPEnable" => 1i64,
"HTTPProxy" => "proxy.corp",
"HTTPPort" => 0i64,
"HTTPSEnable" => 1i64,
"HTTPSProxy" => "secure.corp",
"HTTPSPort" => 0i64,
};
let mode = mode_from_dict(&dict).unwrap();
assert_eq!(mode.endpoint_for(Scheme::Http).unwrap().port, 80);
assert_eq!(mode.endpoint_for(Scheme::Https).unwrap().port, 443);
assert!(dict.port_is_unusable("HTTPPort").is_none());
assert!(dict.port_is_unusable("HTTPSPort").is_none());
}
#[test]
fn an_out_of_range_port_drops_the_scheme_and_is_recorded() {
for value in [70000i64, -1i64] {
let dict = dict! {
"HTTPEnable" => 1i64,
"HTTPProxy" => "proxy.corp",
"HTTPPort" => value,
};
let mode = mode_from_dict(&dict).unwrap();
assert!(
matches!(mode.entry_for(Scheme::Http), Some(ProxyEntry::Unusable(_))),
"{value}"
);
assert_eq!(
dict.port_is_unusable("HTTPPort").as_deref(),
Some(value.to_string().as_str()),
"{value}"
);
let rejected = mode.rejected().expect("an unusable port left no record");
assert_eq!(rejected.len(), 1, "{rejected:?}");
assert_eq!(rejected[0].kind(), RejectionKind::InvalidProxyEndpoint);
assert_eq!(
*rejected[0].source(),
RejectionSource::SystemConfiguration("HTTPPort".to_owned())
);
assert_eq!(rejected[0].affected_scheme(), Some(Scheme::Http), "{value}");
}
let dict = dict! {
"HTTPEnable" => 1i64,
"HTTPProxy" => "proxy.corp",
"HTTPPort" => "http",
};
assert!(matches!(
mode_from_dict(&dict).unwrap().entry_for(Scheme::Http),
Some(ProxyEntry::Unusable(_))
));
assert_eq!(dict.port_is_unusable("HTTPPort").as_deref(), Some("http"));
}
#[cfg(feature = "resolve")]
#[test]
fn an_unusable_port_under_an_enabled_scheme_is_reported_rather_than_resolved_direct() {
let dict = dict! {
"HTTPEnable" => 1i64,
"HTTPProxy" => "proxy.corp",
"HTTPPort" => 70000i64,
};
let config = crate::ProxyConfig::new(mode_from_dict(&dict).unwrap(), Vec::new());
let url = url::Url::parse("http://intranet.corp/x").unwrap();
let err = crate::resolve(&config, &url).unwrap_err();
assert!(
matches!(&err, crate::Error::ProxyEntryUnusable { scheme, .. }
if *scheme == Scheme::Http),
"{err:?}"
);
}
#[test]
fn a_disabled_scheme_is_recorded_and_suppresses_the_all_fallback() {
let dict = dict! {
"HTTPEnable" => 1i64,
"HTTPProxy" => "proxy.corp",
"FTPEnable" => 0i64,
};
let mode = mode_from_dict(&dict).unwrap();
assert!(
mode.entry_for(Scheme::Ftp)
.expect("ftp entry")
.is_disabled()
);
assert!(mode.endpoint_for(Scheme::Ftp).is_none());
}
#[test]
fn an_enabled_scheme_without_a_host_is_disabled() {
let dict = dict! {
"HTTPEnable" => 1i64,
"HTTPProxy" => " ",
"FTPEnable" => 1i64,
"FTPProxy" => "files.corp",
};
let mode = mode_from_dict(&dict).unwrap();
assert!(mode.entry_for(Scheme::Http).unwrap().is_disabled());
assert!(mode.endpoint_for(Scheme::Ftp).is_some());
}
#[test]
fn a_socks_only_dictionary_routes_every_scheme_through_socks() {
let dict = dict! {
"HTTPEnable" => 0i64,
"HTTPSEnable" => 0i64,
"FTPEnable" => 0i64,
"SOCKSEnable" => 1i64,
"SOCKSProxy" => "socks.corp",
"SOCKSPort" => 1080i64,
};
let mode = mode_from_dict(&dict).unwrap();
for scheme in [
Scheme::Http,
Scheme::Https,
Scheme::Ftp,
Scheme::Socks,
Scheme::All,
] {
let endpoint = mode
.endpoint_for(scheme)
.unwrap_or_else(|| panic!("{scheme:?} must fall back to SOCKS"));
assert_eq!(endpoint.authority(), "socks.corp:1080");
assert_eq!(endpoint.scheme_hint, Some(ProxyScheme::Socks5));
}
}
#[test]
fn an_explicit_socks_scheme_is_not_overwritten_by_the_default_hint() {
let dict = dict! {
"SOCKSEnable" => 1i64,
"SOCKSProxy" => "socks4://socks.corp",
"SOCKSPort" => 1080i64,
};
let mode = mode_from_dict(&dict).unwrap();
let socks = mode.endpoint_for(Scheme::Socks).unwrap();
assert_eq!(
socks.scheme_hint,
Some(ProxyScheme::Socks4),
"an explicit scheme in SOCKSProxy must not be overwritten by the SOCKS5 default"
);
}
#[test]
fn a_scheme_with_its_own_proxy_is_not_overwritten_by_socks() {
let dict = dict! {
"HTTPEnable" => 1i64,
"HTTPProxy" => "proxy.corp",
"HTTPPort" => 8080i64,
"HTTPSEnable" => 0i64,
"SOCKSEnable" => 1i64,
"SOCKSProxy" => "socks.corp",
};
let mode = mode_from_dict(&dict).unwrap();
assert_eq!(
mode.endpoint_for(Scheme::Http).unwrap().authority(),
"proxy.corp:8080",
"an explicit HTTP proxy outranks the SOCKS fallback"
);
assert_eq!(
mode.endpoint_for(Scheme::Https).unwrap().authority(),
"socks.corp:1080",
"HTTPS has none of its own, so the fallback covers it"
);
}
#[test]
fn a_malformed_socks_host_does_not_become_the_fallback() {
let dict = dict! {
"HTTPEnable" => 0i64,
"SOCKSEnable" => 1i64,
"SOCKSProxy" => "http://[not-an-address/",
};
let mode = mode_from_dict(&dict).unwrap();
assert!(mode.endpoint_for(Scheme::Http).is_none());
assert!(mode.endpoint_for(Scheme::Socks).is_none());
assert!(
!mode.rejected().unwrap_or_default().is_empty(),
"the drop still has to be recorded"
);
}
#[cfg(feature = "resolve")]
#[test]
fn an_unusable_socks_endpoint_is_reported_for_a_scheme_it_would_have_covered() {
let dict = dict! {
"SOCKSEnable" => 1i64,
"SOCKSProxy" => "http://[not-an-address/",
};
let config = crate::ProxyConfig::new(mode_from_dict(&dict).unwrap(), Vec::new());
let url = url::Url::parse("http://intranet.corp/x").unwrap();
let err = crate::resolve(&config, &url).unwrap_err();
assert!(
matches!(&err, crate::Error::ProxyEntryUnusable { scheme, .. }
if *scheme == Scheme::All),
"{err:?}"
);
}
#[cfg(feature = "resolve")]
#[test]
fn an_off_switch_does_not_answer_direct_for_a_drop_that_would_have_covered_it() {
let dict = dict! {
"HTTPEnable" => 0i64,
"SOCKSEnable" => 1i64,
"SOCKSProxy" => "http://[not-an-address/",
};
let config = crate::ProxyConfig::new(mode_from_dict(&dict).unwrap(), Vec::new());
let url = url::Url::parse("http://intranet.corp/x").unwrap();
let err = crate::resolve(&config, &url).unwrap_err();
assert!(
matches!(&err, crate::Error::ProxyEntryUnusable { scheme, .. }
if *scheme == Scheme::All),
"{err:?}"
);
}
#[test]
fn ipv6_hosts_are_bracketed_back() {
let dict = dict! {
"SOCKSEnable" => 1i64,
"SOCKSProxy" => "::1",
"SOCKSPort" => 1081i64,
};
let mode = mode_from_dict(&dict).unwrap();
assert_eq!(
mode.endpoint_for(Scheme::Socks).unwrap().authority(),
"[::1]:1081"
);
}
#[test]
fn every_scheme_reads_its_own_port_and_user_key() {
for (scheme, enable, host, port, user, dialled) in [
(
Scheme::Http,
"HTTPEnable",
"HTTPProxy",
"HTTPPort",
"HTTPUser",
8080u16,
),
(
Scheme::Https,
"HTTPSEnable",
"HTTPSProxy",
"HTTPSPort",
"HTTPSUser",
8443,
),
(
Scheme::Ftp,
"FTPEnable",
"FTPProxy",
"FTPPort",
"FTPUser",
8021,
),
(
Scheme::Socks,
"SOCKSEnable",
"SOCKSProxy",
"SOCKSPort",
"SOCKSUser",
1081,
),
] {
let mut dict = ProxyDict::new();
dict.insert(enable, DictValue::Number(1));
dict.insert(host, DictValue::Text("proxy.corp".to_owned()));
dict.insert(port, DictValue::Number(i64::from(dialled)));
dict.insert(user, DictValue::Text("alice".to_owned()));
let mode = mode_from_dict(&dict).unwrap();
let endpoint = mode
.endpoint_for(scheme)
.unwrap_or_else(|| panic!("{scheme:?} must be configured"));
assert_eq!(endpoint.port, dialled, "{scheme:?} read {port}");
let auth = endpoint
.auth
.as_ref()
.unwrap_or_else(|| panic!("{scheme:?} must read {user}"));
assert_eq!(auth.username(), "alice", "{scheme:?}");
assert!(!auth.has_password(), "{scheme:?}");
assert_eq!(
auth.password_state(),
PasswordState::InKeychain,
"{scheme:?}"
);
}
}
#[test]
fn pac_url_becomes_pac() {
let dict = dict! {
"ProxyAutoConfigEnable" => 1i64,
"ProxyAutoConfigURLString" => "http://wpad.corp/proxy.pac",
};
let mode = mode_from_dict(&dict).unwrap();
match mode {
ProxyMode::Pac { url, .. } => assert_eq!(url.as_str(), "http://wpad.corp/proxy.pac"),
other => panic!("expected Pac, got {other:?}"),
}
}
#[test]
fn an_inline_script_wins_over_the_pac_url() {
let dict = dict! {
"ProxyAutoConfigEnable" => 1i64,
"ProxyAutoConfigURLString" => "http://wpad.corp/proxy.pac",
"ProxyAutoConfigJavaScript" => "function FindProxyForURL(u, h) { return \"DIRECT\"; }",
};
match mode_from_dict(&dict).unwrap() {
ProxyMode::PacInline { script, .. } => assert!(script.contains("FindProxyForURL")),
other => panic!("expected PacInline, got {other:?}"),
}
}
#[test]
fn an_inline_script_keeps_its_leading_whitespace() {
let script = " \n function FindProxyForURL(u, h) { return \"DIRECT\"; }";
let dict = dict! {
"ProxyAutoConfigEnable" => 1i64,
"ProxyAutoConfigJavaScript" => script,
};
match mode_from_dict(&dict).unwrap() {
ProxyMode::PacInline { script: got, .. } => assert_eq!(got, script),
other => panic!("expected PacInline, got {other:?}"),
}
}
#[test]
fn a_drop_on_the_way_to_a_pac_answer_survives_it() {
let cases = [
(
"url",
dict! {
"ProxyAutoDiscoveryEnable" => "yes",
"ProxyAutoConfigEnable" => 1i64,
"ProxyAutoConfigURLString" => "http://wpad.corp/proxy.pac",
},
),
(
"inline",
dict! {
"ProxyAutoDiscoveryEnable" => "yes",
"ProxyAutoConfigEnable" => 1i64,
"ProxyAutoConfigJavaScript" => "function FindProxyForURL(u, h) { return \"DIRECT\"; }",
},
),
];
for (label, dict) in cases {
let mode = mode_from_dict(&dict).unwrap();
let rejected = mode
.rejected()
.unwrap_or_else(|| panic!("{label}: the PAC mode carries no list: {mode:?}"));
assert_eq!(rejected.len(), 1, "{label}: {rejected:?}");
assert_eq!(
*rejected[0].source(),
RejectionSource::SystemConfiguration(AUTO_DISCOVERY_ENABLE.to_owned()),
"{label}"
);
assert_eq!(rejected[0].redacted_input(), "yes", "{label}");
}
}
#[test]
fn auto_discovery_wins_over_everything() {
let dict = dict! {
"ProxyAutoDiscoveryEnable" => 1i64,
"ProxyAutoConfigEnable" => 1i64,
"ProxyAutoConfigURLString" => "http://wpad.corp/proxy.pac",
"HTTPEnable" => 1i64,
"HTTPProxy" => "proxy.corp",
};
assert_eq!(
mode_from_dict(&dict).unwrap(),
ProxyMode::WpadAutoDetect,
"auto-discovery must win, exactly as it does on Windows"
);
}
#[test]
fn pac_enabled_but_empty_falls_through_to_the_manual_entries() {
let dict = dict! {
"ProxyAutoConfigEnable" => 1i64,
"ProxyAutoConfigURLString" => "",
"HTTPEnable" => 1i64,
"HTTPProxy" => "proxy.corp",
"HTTPPort" => 3128i64,
};
let mode = mode_from_dict(&dict).unwrap();
assert_eq!(
mode.endpoint_for(Scheme::Http).unwrap().authority(),
"proxy.corp:3128"
);
}
#[test]
fn the_pac_switch_alone_is_direct_because_no_pac_was_ever_named() {
for dict in [
dict! { "ProxyAutoConfigEnable" => 1i64 },
dict! {
"ProxyAutoConfigEnable" => 1i64,
"ProxyAutoConfigURLString" => "",
"ProxyAutoConfigJavaScript" => " ",
},
] {
assert_eq!(mode_from_dict(&dict).unwrap(), ProxyMode::Direct);
}
}
#[test]
fn a_malformed_pac_url_is_an_error() {
let dict = dict! {
"ProxyAutoConfigEnable" => 1i64,
"ProxyAutoConfigURLString" => "not a url",
};
assert!(matches!(
mode_from_dict(&dict),
Err(Error::InvalidProxyUrl { .. })
));
}
#[test]
fn the_implicit_set_follows_the_presence_of_a_bypass_key() {
let empty: &[&str] = &[];
let blank: &[&str] = &[""];
for (dict, expected) in [
(
dict! { "HTTPEnable" => 1i64, "HTTPProxy" => "proxy.corp" },
ImplicitBypass::Empty,
),
(
dict! {
"HTTPEnable" => 1i64,
"HTTPProxy" => "proxy.corp",
"ExceptionsList" => empty,
},
ImplicitBypass::Empty,
),
(
dict! {
"HTTPEnable" => 1i64,
"HTTPProxy" => "proxy.corp",
"ExceptionsList" => blank,
},
ImplicitBypass::CfNetwork,
),
(
dict! {
"HTTPEnable" => 1i64,
"HTTPProxy" => "proxy.corp",
"ExcludeSimpleHostnames" => 1i64,
},
ImplicitBypass::CfNetwork,
),
(
dict! {
"HTTPEnable" => 1i64,
"HTTPProxy" => "proxy.corp",
"ExcludeSimpleHostnames" => 0i64,
},
ImplicitBypass::Empty,
),
] {
let mode = mode_from_dict(&dict).unwrap();
let bypass = mode.bypass().expect("manual mode has bypass rules");
assert_eq!(bypass.implicit, expected, "{dict:?}");
assert_eq!(
bypass.matches_authority("127.0.0.1"),
expected == ImplicitBypass::CfNetwork,
"{dict:?}"
);
}
}
#[test]
fn an_exception_sheds_a_root_dot_and_reads_a_mapped_destination_as_written() {
let list: &[&str] = &["intra.example", "10.0.0.1", "dotted.example."];
let dict = dict! {
"HTTPEnable" => 1i64,
"HTTPProxy" => "proxy.corp",
"ExceptionsList" => list,
};
let mode = mode_from_dict(&dict).unwrap();
let bypass = mode.bypass().expect("manual mode has bypass rules");
assert!(bypass.matches_authority("intra.example"));
assert!(bypass.matches_authority("intra.example."));
assert!(bypass.matches_authority("10.0.0.1"));
assert!(!bypass.matches_authority("[::ffff:10.0.0.1]"));
assert!(bypass.matches_authority("dotted.example"));
assert!(bypass.matches_authority("dotted.example."));
assert!(bypass.rejected.is_empty(), "{:?}", bypass.rejected);
assert!(bypass.matches_authority("localhost"));
assert!(!bypass.matches_authority("localhost."));
}
#[test]
fn exclude_simple_hostnames_bypasses_a_dotless_ipv6_literal() {
let empty: &[&str] = &[];
for (switch, simple) in [(1i64, true), (0i64, false)] {
let dict = dict! {
"HTTPEnable" => 1i64,
"HTTPProxy" => "proxy.corp",
"ExceptionsList" => empty,
"ExcludeSimpleHostnames" => switch,
};
let mode = mode_from_dict(&dict).unwrap();
let bypass = mode.bypass().expect("manual mode has bypass rules");
for host in ["[fe80::1]", "[febf::1]", "[fec0::1]", "intranet"] {
assert_eq!(bypass.matches_authority(host), simple, "{host} {dict:?}");
}
for host in ["[::ffff:127.0.0.1]", "127.0.0.2", "169.254.1.1"] {
assert!(!bypass.matches_authority(host), "{host} {dict:?}");
}
}
}
#[test]
fn a_non_decimal_ipv4_exception_is_refused() {
let list: &[&str] = &[
"012.1.2.3",
"0xa.1.2.3",
"10.66051",
"10.1.2.03",
"012.1.2.0/24",
"010.1.2.0/24",
"0xa.1.2.0/24",
"10.1.2.4",
];
let dict = dict! {
"HTTPEnable" => 1i64,
"HTTPProxy" => "proxy.corp",
"ExceptionsList" => list,
};
let mode = mode_from_dict(&dict).unwrap();
let bypass = mode.bypass().expect("manual mode has bypass rules");
assert_eq!(bypass.patterns.len(), 1, "{bypass:?}");
assert_eq!(bypass.rejected.len(), 7, "{bypass:?}");
for dest in ["10.1.2.3", "10.1.2.5", "12.1.2.5"] {
assert!(!bypass.matches_authority(dest), "{dest}: {bypass:?}");
}
assert!(bypass.matches_authority("10.1.2.4"));
}
#[test]
fn exceptions_and_simple_hostnames_become_bypass_rules() {
let list: &[&str] = &[
"*.local",
"169.254/16",
"192.168.7/24",
"example.com",
"10.0.0.0/8",
];
let dict = dict! {
"HTTPEnable" => 1i64,
"HTTPProxy" => "proxy.corp",
"ExceptionsList" => list,
"ExcludeSimpleHostnames" => 1i64,
};
let mode = mode_from_dict(&dict).unwrap();
let bypass = mode.bypass().expect("manual mode has bypass rules");
assert!(bypass.excludes_simple_hostnames());
assert!(bypass.matches_authority("printer.local"));
assert!(bypass.matches_authority("intranet"), "<local> equivalent");
assert!(
bypass.matches_authority("169.254.1.2"),
"abbreviated CIDR, Apple's"
);
assert!(bypass.matches_authority("192.168.7.9"), "abbreviated CIDR");
assert!(
!bypass.matches_authority("192.168.8.9"),
"and only that /24"
);
assert!(!bypass.matches_authority("169.0.0.254"), "Chromium");
assert!(bypass.matches_authority("example.com"));
assert!(!bypass.matches_authority("www.example.com"));
assert!(bypass.matches_authority("10.1.2.3:443"));
assert!(!bypass.matches_authority("example.net"));
}
#[test]
fn an_exception_entry_macos_matches_nothing_with_is_recorded_rather_than_stored() {
let list: &[&str] = &[
"*",
"*ample*",
"cor*.example",
"*.co*p.example",
".corp.*",
"example.com:8080",
"corp.*",
];
let dict = dict! {
"HTTPEnable" => 1i64,
"HTTPProxy" => "proxy.corp",
"ExceptionsList" => list,
};
let mode = mode_from_dict(&dict).unwrap();
let bypass = mode.bypass().expect("manual mode has bypass rules");
assert!(!bypass.matches_authority("anything.example"));
assert!(!bypass.matches_authority("www.example.com"));
assert!(!bypass.matches_authority("www.corp.example"));
assert!(!bypass.matches_authority("example.com:8080"));
assert!(bypass.matches_authority("corp.example"));
assert!(!bypass.matches_authority("corp"));
assert_eq!(bypass.rejected.len(), 6, "{:?}", bypass.rejected);
}
#[test]
fn an_exception_entry_padded_with_whitespace_is_dead_on_macos_and_recorded() {
let list: &[&str] = &[" example.com", "example.com ", " 10.0/16"];
let dict = dict! {
"HTTPEnable" => 1i64,
"HTTPProxy" => "proxy.corp",
"ExceptionsList" => list,
};
let mode = mode_from_dict(&dict).unwrap();
let bypass = mode.bypass().expect("manual mode has bypass rules");
assert!(!bypass.matches_authority("example.com"));
assert!(!bypass.matches_authority("10.0.1.1"));
assert_eq!(bypass.rejected.len(), 3, "{:?}", bypass.rejected);
}
#[test]
fn a_malformed_scheme_host_is_dropped_and_recorded_while_the_rest_survive() {
let dict = dict! {
"HTTPEnable" => 1i64,
"HTTPProxy" => "proxy.corp",
"HTTPSEnable" => 1i64,
"HTTPSProxy" => "not a host with spaces",
"FTPEnable" => 1i64,
"FTPProxy" => "files.corp",
};
let mode = mode_from_dict(&dict).unwrap();
assert_eq!(
mode.endpoint_for(Scheme::Http).unwrap().authority(),
"proxy.corp:80"
);
assert_eq!(
mode.endpoint_for(Scheme::Ftp).unwrap().authority(),
"files.corp:80"
);
assert!(mode.endpoint_for(Scheme::Https).is_none());
assert_eq!(
mode.rejected().unwrap()[0].redacted_input(),
"not a host with spaces"
);
assert_eq!(
mode.rejected().unwrap()[0].affected_scheme(),
Some(Scheme::Https)
);
}
#[cfg(feature = "resolve")]
#[test]
fn an_unreadable_host_under_an_enabled_scheme_is_reported_rather_than_resolved_direct() {
let dict = dict! {
"HTTPEnable" => 1i64,
"HTTPProxy" => 8080i64,
};
let config = crate::ProxyConfig::new(mode_from_dict(&dict).unwrap(), Vec::new());
let url = url::Url::parse("http://intranet.corp/x").unwrap();
let err = crate::resolve(&config, &url).unwrap_err();
assert!(
matches!(&err, crate::Error::ProxyEntryUnusable { scheme, .. }
if *scheme == Scheme::Http),
"{err:?}"
);
}
#[test]
fn a_dictionary_with_only_a_malformed_host_keeps_the_record() {
let dict = dict! {
"HTTPEnable" => 1i64,
"HTTPProxy" => "not a host with spaces",
};
let mode = mode_from_dict(&dict).unwrap();
assert!(!mode.is_direct());
assert_eq!(
mode.rejected().unwrap()[0].redacted_input(),
"not a host with spaces"
);
}
#[test]
fn a_malformed_exception_entry_is_dropped_and_recorded_while_the_rest_survive() {
let list: &[&str] = &["example.com", "[::1:broken", "10.0.0.0/8"];
let dict = dict! {
"HTTPEnable" => 1i64,
"HTTPProxy" => "proxy.corp",
"ExceptionsList" => list,
};
let mode = mode_from_dict(&dict).unwrap();
let bypass = mode.bypass().expect("manual mode has bypass rules");
assert!(bypass.matches_authority("example.com"));
assert!(bypass.matches_authority("10.1.2.3"));
assert_eq!(bypass.rejected.len(), 1);
assert!(
bypass.rejected[0].redacted_input().contains("withheld"),
"{:?}",
bypass.rejected
);
}
#[test]
fn a_bypass_key_of_the_wrong_type_is_recorded_rather_than_read_as_absent() {
for (value, expected) in [
(
DictValue::Text("*.corp.example".to_owned()),
"*.corp.example",
),
(DictValue::Number(1), "1"),
] {
let mut dict = dict! {
"HTTPEnable" => 1i64,
"HTTPProxy" => "proxy.corp",
"ExcludeSimpleHostnames" => "sometimes",
};
dict.insert(EXCEPTIONS_LIST, value);
let mode = mode_from_dict(&dict).unwrap();
let bypass = mode.bypass().expect("manual mode has bypass rules");
assert!(!bypass.matches_authority("www.corp.example"));
assert!(!bypass.excludes_simple_hostnames());
assert_eq!(bypass.rejected.len(), 2, "{:?}", bypass.rejected);
assert_eq!(bypass.rejected[0].redacted_input(), expected);
assert_eq!(bypass.rejected[1].redacted_input(), "sometimes");
}
let dict = dict! {
"HTTPEnable" => 1i64,
"HTTPProxy" => "proxy.corp",
"ExceptionsList" => " ",
};
let mode = mode_from_dict(&dict).unwrap();
assert!(mode.bypass().expect("manual").rejected.is_empty());
}
#[test]
fn boolean_flags_stored_as_numbers_or_strings_both_work() {
let dict = dict! {
"HTTPEnable" => "1",
"HTTPProxy" => "proxy.corp",
"HTTPPort" => "8080",
};
let mode = mode_from_dict(&dict).unwrap();
assert_eq!(
mode.endpoint_for(Scheme::Http).unwrap().authority(),
"proxy.corp:8080"
);
let signed = dict! {
"HTTPEnable" => "1",
"HTTPProxy" => "proxy.corp",
"HTTPPort" => "+8080",
};
let mode = mode_from_dict(&signed).unwrap();
assert!(
matches!(mode.entry_for(Scheme::Http), Some(ProxyEntry::Unusable(_))),
"{mode:?}"
);
assert_eq!(
mode.rejected().expect("a signed port left no record")[0].affected_scheme(),
Some(Scheme::Http)
);
}
#[test]
fn abbreviated_cidrs_are_padded_and_everything_else_is_left_alone() {
assert_eq!(expand_abbreviated_cidr("169.254/16"), "169.254.0.0/16");
assert_eq!(expand_abbreviated_cidr("10/8"), "10.0.0.0/8");
assert_eq!(expand_abbreviated_cidr("10.0.0.0/8"), "10.0.0.0/8");
assert_eq!(expand_abbreviated_cidr("*.local"), "*.local");
assert_eq!(expand_abbreviated_cidr("2001:db8::/32"), "2001:db8::/32");
assert_eq!(expand_abbreviated_cidr("a.b/16"), "a.b/16");
assert_eq!(expand_abbreviated_cidr("10./8"), "10./8");
assert_eq!(expand_abbreviated_cidr("169.254/"), "169.254/");
}
#[test]
fn merge_setup_and_state_combinations() {
use crate::config::ProxyConfigSource;
type Case = (
Option<ProxyMode>,
Option<ProxyMode>,
ProxyMode,
Vec<(ProxyConfigSource, ProxyMode)>,
);
let setup_wpad = ProxyMode::WpadAutoDetect;
let setup_pac = ProxyMode::pac(Url::parse("http://setup.example/proxy.pac").unwrap());
let state_wpad = ProxyMode::WpadAutoDetect;
let direct = ProxyMode::Direct;
let cases: &[Case] = &[
(
Some(setup_wpad.clone()),
None,
setup_wpad.clone(),
vec![(
ProxyConfigSource::SystemConfigurationSetup,
setup_wpad.clone(),
)],
),
(
None,
Some(state_wpad.clone()),
state_wpad.clone(),
vec![(
ProxyConfigSource::SystemConfigurationState,
state_wpad.clone(),
)],
),
(None, None, direct.clone(), vec![]),
(
Some(setup_pac.clone()),
Some(state_wpad.clone()),
state_wpad.clone(),
vec![
(
ProxyConfigSource::SystemConfigurationState,
state_wpad.clone(),
),
(
ProxyConfigSource::SystemConfigurationSetup,
setup_pac.clone(),
),
],
),
(
Some(direct.clone()),
Some(direct.clone()),
direct.clone(),
vec![
(ProxyConfigSource::SystemConfigurationState, direct.clone()),
(ProxyConfigSource::SystemConfigurationSetup, direct.clone()),
],
),
];
for (setup, state, effective, sources) in cases {
let config = merge_setup_and_state(Ok(setup.clone()), state.clone())
.expect("a readable Setup: scope cannot fail the merge");
assert_eq!(config.effective, *effective);
assert_eq!(config.sources, *sources);
assert!(config.fallbacks.is_empty());
}
}
fn unreadable_setup_scopes() -> [Error; 2] {
[
Error::invalid_proxy_url("http://", url::ParseError::EmptyHost),
Error::io(
"reading the Setup: proxies scope",
std::io::Error::from(std::io::ErrorKind::PermissionDenied),
),
]
}
#[test]
fn an_unreadable_setup_scope_does_not_take_a_usable_state_scope_down() {
for error in unreadable_setup_scopes() {
let case = format!("{error:?}");
let config = merge_setup_and_state(Err(error), Some(ProxyMode::WpadAutoDetect))
.expect("the State: scope is readable, so the read has an answer");
assert_eq!(config.effective, ProxyMode::WpadAutoDetect, "{case}");
assert_eq!(
config.sources,
vec![(
ProxyConfigSource::SystemConfigurationState,
ProxyMode::WpadAutoDetect
)],
"the scope that could not be read must not be reported as if it had been: \
{case}"
);
assert_eq!(
config.fallbacks,
vec![ProxyConfigSource::SystemConfigurationSetup],
"{case}"
);
}
}
#[test]
fn an_unreadable_setup_scope_is_the_whole_read_when_there_is_no_state_scope() {
for error in unreadable_setup_scopes() {
let expected = format!("{error:?}");
let propagated = merge_setup_and_state(Err(error), None)
.expect_err("nothing is left to answer with");
assert_eq!(
format!("{propagated:?}"),
expected,
"the scope's own error is what propagates, whatever kind it is"
);
}
}
}