#![cfg_attr(not(all(target_os = "linux", feature = "linux-kde")), allow(dead_code))]
use std::borrow::Cow;
use std::collections::HashMap;
use std::fmt;
use std::net::Ipv6Addr;
use url::Url;
use crate::bypass::{BypassRules, ImplicitBypass};
use crate::config::ProxyConfigSource;
use crate::diagnostic::{RejectedValue, RejectionKind, RejectionSource};
use crate::endpoint::{ProxyEndpoint, ProxyEntry, ProxyScheme, Scheme};
use crate::env::ProxyEnv;
use crate::error::Error;
use crate::mode::ProxyMode;
use crate::parse;
pub(crate) const FILE_NAME: &str = "kioslaverc";
pub(crate) const SECTION: &str = "Proxy Settings";
const KEY_PROXY_TYPE: &str = "ProxyType";
const KEY_NO_PROXY_FOR: &str = "NoProxyFor";
const KEY_REVERSED_EXCEPTION: &str = "ReversedException";
const KEY_CONFIG_SCRIPT: &str = "Proxy Config Script";
#[allow(clippy::enum_variant_names)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ProxyType {
NoProxy,
ManualProxy,
PacProxy,
WpadProxy,
EnvVarProxy,
}
impl ProxyType {
pub(crate) fn from_i64(value: i64) -> Option<Self> {
match value {
0 => Some(ProxyType::NoProxy),
1 => Some(ProxyType::ManualProxy),
2 => Some(ProxyType::PacProxy),
3 => Some(ProxyType::WpadProxy),
4 => Some(ProxyType::EnvVarProxy),
_ => None,
}
}
}
struct SlotKeys {
scheme: Scheme,
key: &'static str,
env_var: Option<&'static str>,
default_port: u16,
hint: Option<ProxyScheme>,
}
const SLOTS: [SlotKeys; 4] = [
SlotKeys {
scheme: Scheme::Http,
key: "httpProxy",
env_var: Some("http_proxy"),
default_port: 8080,
hint: None,
},
SlotKeys {
scheme: Scheme::Https,
key: "httpsProxy",
env_var: Some("https_proxy"),
default_port: 8080,
hint: None,
},
SlotKeys {
scheme: Scheme::Ftp,
key: "ftpProxy",
env_var: Some("ftp_proxy"),
default_port: 8080,
hint: None,
},
SlotKeys {
scheme: Scheme::Socks,
key: "socksProxy",
env_var: None,
default_port: 1080,
hint: Some(ProxyScheme::Socks5),
},
];
#[derive(Clone, Default, PartialEq, Eq)]
pub(crate) struct KioslavercSettings {
entries: HashMap<String, KconfigEntry>,
group_immutable: bool,
}
impl fmt::Debug for KioslavercSettings {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let entries: std::collections::BTreeMap<String, &KconfigEntry> = self
.entries
.iter()
.map(|(key, entry)| (crate::util::redact_offending_token(key), entry))
.collect();
f.debug_struct("KioslavercSettings")
.field("entries", &entries)
.field("group_immutable", &self.group_immutable)
.finish()
}
}
#[derive(Clone, PartialEq, Eq)]
struct KconfigEntry {
value: Option<String>,
immutable: bool,
expand: bool,
}
impl fmt::Debug for KconfigEntry {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let value = self
.value
.as_deref()
.map(crate::util::redact_offending_token);
f.debug_struct("KconfigEntry")
.field("value", &value)
.field("immutable", &self.immutable)
.field("expand", &self.expand)
.finish()
}
}
impl KioslavercSettings {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn insert(&mut self, key: &str, value: impl Into<String>) {
self.entries.insert(
normalize_key(key).to_owned(),
KconfigEntry {
value: Some(value.into()),
immutable: false,
expand: has_kconfig_flag(key, 'e'),
},
);
}
pub(crate) fn apply(&mut self, raw_key: &str, value: Option<String>, group_immutable: bool) {
let key = normalize_key(raw_key).to_owned();
if self.entries.get(&key).is_some_and(|entry| entry.immutable) {
return;
}
let flags = kconfig_flags(raw_key);
let deleted_at = flags.find('d');
let immutable_flag = flags
.find('i')
.is_some_and(|i| deleted_at.is_none_or(|d| i < d));
let immutable = group_immutable || immutable_flag;
let deleted = deleted_at.is_some();
self.entries.insert(
key,
KconfigEntry {
value: if deleted { None } else { value },
immutable,
expand: has_kconfig_flag(raw_key, 'e'),
},
);
}
pub(crate) fn group_is_immutable(&self) -> bool {
self.group_immutable
}
pub(crate) fn mark_group_immutable(&mut self) {
self.group_immutable = true;
}
fn text(&self, key: &str) -> Option<&str> {
self.entries
.get(key)
.and_then(|entry| entry.value.as_deref())
.filter(|value| !value.is_empty())
}
fn needs_expansion(&self, key: &str) -> bool {
self.entries.get(key).is_some_and(|entry| {
entry.expand && entry.value.as_deref().is_some_and(|v| v.contains('$'))
})
}
fn has(&self, key: &str) -> bool {
self.entries
.get(key)
.is_some_and(|entry| entry.value.is_some())
}
fn reversed_exception(&self) -> bool {
self.text(KEY_REVERSED_EXCEPTION)
.is_some_and(atoi_is_nonzero)
}
fn reversed_exception_is_disputed(&self) -> bool {
self.text(KEY_REVERSED_EXCEPTION).is_some_and(|value| {
!atoi_is_nonzero(value)
&& !KCONFIG_FALSE
.iter()
.any(|no| value.eq_ignore_ascii_case(no))
})
}
}
const KCONFIG_FALSE: [&str; 4] = ["false", "no", "off", "0"];
fn atoi_is_nonzero(value: &str) -> bool {
let value = value.trim_ascii_start();
let rest = value.strip_prefix(['+', '-']).unwrap_or(value);
let digits = rest
.find(|c: char| !c.is_ascii_digit())
.map_or(rest, |end| &rest[..end]);
digits.bytes().any(|byte| byte != b'0')
}
impl FromIterator<(String, String)> for KioslavercSettings {
fn from_iter<I: IntoIterator<Item = (String, String)>>(iter: I) -> Self {
let mut settings = Self::new();
for (key, value) in iter {
settings.insert(&key, value);
}
settings
}
}
pub(crate) fn normalize_key(key: &str) -> &str {
let key = key.trim_ascii();
match key.find("[$") {
Some(index) if key.ends_with(']') => &key[..index],
_ => key,
}
}
pub(crate) fn has_kconfig_flag(name: &str, flag: char) -> bool {
kconfig_flags(name).contains(flag)
}
fn kconfig_flags(name: &str) -> &str {
let Some(start) = name.find("[$") else {
return "";
};
let tail = &name[start + 2..];
tail.find(']').map_or("", |end| &tail[..end])
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct KdeConfig {
pub(crate) source: ProxyConfigSource,
pub(crate) mode: ProxyMode,
}
pub(crate) fn config_from_kioslaverc<F>(
settings: &KioslavercSettings,
env: F,
) -> Result<KdeConfig, Error>
where
F: Fn(&str) -> Option<std::ffi::OsString>,
{
let kind = settings
.text(KEY_PROXY_TYPE)
.and_then(|value| value.trim_ascii().parse::<i64>().ok())
.and_then(ProxyType::from_i64)
.unwrap_or(ProxyType::NoProxy);
let mode = match kind {
ProxyType::NoProxy => ProxyMode::Direct,
ProxyType::WpadProxy => ProxyMode::WpadAutoDetect,
ProxyType::PacProxy => pac_mode(settings)?,
ProxyType::ManualProxy => manual_mode(settings)?,
ProxyType::EnvVarProxy => {
return Ok(KdeConfig {
source: ProxyConfigSource::KioslavercEnv,
mode: env_mode(settings, env)?,
});
}
};
Ok(KdeConfig {
source: ProxyConfigSource::Kioslaverc,
mode,
})
}
pub(crate) fn configured_from_kioslaverc<F>(
settings: &KioslavercSettings,
env: F,
) -> Result<Option<KdeConfig>, Error>
where
F: Fn(&str) -> Option<std::ffi::OsString>,
{
if !settings.has(KEY_PROXY_TYPE) {
return Ok(None);
}
config_from_kioslaverc(settings, env).map(Some)
}
fn pac_mode(settings: &KioslavercSettings) -> Result<ProxyMode, Error> {
let Some(script) = settings.text(KEY_CONFIG_SCRIPT) else {
return Ok(ProxyMode::Direct);
};
if settings.needs_expansion(KEY_CONFIG_SCRIPT) {
let mut rules = BypassRules::new();
rules.implicit = ImplicitBypass::Empty;
rules.ipv4_mapped_as_ipv4 = false;
return Ok(ProxyMode::manual(HashMap::new(), rules).with_rejected(vec![
RejectedValue::new(
RejectionKind::UnsupportedMapping,
RejectionSource::Kioslaverc(KEY_CONFIG_SCRIPT.to_owned()),
script,
)
.for_scheme(Some(Scheme::All)),
]));
}
Ok(ProxyMode::pac(parse_script_location(script)?))
}
fn parse_script_location(script: &str) -> Result<Url, Error> {
if let Ok(url) = Url::parse(script) {
return Ok(url);
}
let invalid = |source| Error::invalid_proxy_url(script, source);
if !script.starts_with('/') {
return Err(invalid(url::ParseError::RelativeUrlWithoutBase));
}
const HEX: &[u8; 16] = b"0123456789ABCDEF";
let mut path = String::with_capacity(script.len());
for byte in script.bytes() {
if byte.is_ascii_alphanumeric() || b"-._~!$&'()*+,;=/".contains(&byte) {
path.push(char::from(byte));
} else {
path.push('%');
path.push(char::from(HEX[usize::from(byte >> 4)]));
path.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
}
Url::parse(&format!("file://{path}")).map_err(invalid)
}
fn manual_mode(settings: &KioslavercSettings) -> Result<ProxyMode, Error> {
let mut per_scheme = HashMap::new();
let mut rejected = Vec::new();
let mut socks = None;
let mut socks_unusable = false;
let mut blank = Vec::new();
for slot in &SLOTS {
if !settings.has(slot.key) {
continue;
}
let Some(raw) = settings.text(slot.key) else {
blank.push(slot.scheme);
continue;
};
let attributed_scheme = if slot.scheme == Scheme::Socks {
socks_unusable = true;
Scheme::All
} else {
slot.scheme
};
if settings.needs_expansion(slot.key) {
rejected.push(
RejectedValue::new(
RejectionKind::UnsupportedMapping,
RejectionSource::Kioslaverc(slot.key.to_owned()),
raw,
)
.for_scheme(Some(attributed_scheme)),
);
continue;
}
let mut endpoint = match ProxyEndpoint::parse(&normalize_address(raw), slot.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 kioslaverc <scheme>Proxy value"
);
rejected.push(
RejectedValue::new(
RejectionKind::InvalidProxyEndpoint,
RejectionSource::Kioslaverc(slot.key.to_owned()),
raw,
)
.for_scheme(Some(attributed_scheme)),
);
continue;
}
};
if let Some(hint) = slot.hint
&& endpoint.scheme_hint.is_none()
{
endpoint = endpoint.with_scheme_hint(hint);
}
if slot.scheme == Scheme::Socks {
socks = Some(endpoint.clone());
}
per_scheme.insert(slot.scheme, ProxyEntry::Use(endpoint));
}
if let Some(endpoint) = socks {
per_scheme.insert(Scheme::All, ProxyEntry::Use(endpoint));
} else if !socks_unusable {
for scheme in blank {
per_scheme.insert(scheme, ProxyEntry::Disabled);
}
}
if per_scheme.values().all(ProxyEntry::is_disabled) && rejected.is_empty() {
return Ok(ProxyMode::Direct);
}
Ok(ProxyMode::manual(per_scheme, bypass_from(settings)?).with_rejected(rejected))
}
fn env_mode<F>(settings: &KioslavercSettings, env: F) -> Result<ProxyMode, Error>
where
F: Fn(&str) -> Option<std::ffi::OsString>,
{
let env = |name: &str| env(name).map(|value| value.to_string_lossy().into_owned());
let in_cgi = env(crate::env::CGI_MARKER_VAR).is_some_and(|method| !method.is_empty());
let mut vars: Vec<(String, String)> = Vec::new();
let mut skipped = Vec::new();
for slot in &SLOTS {
let Some(name) = settings.text(slot.key) else {
continue;
};
let Some(canonical) = slot.env_var else {
warn_skipped_env_var_scheme(slot.key, name);
skipped.push(
RejectedValue::new(
RejectionKind::UnsupportedMapping,
RejectionSource::Kioslaverc(slot.key.to_owned()),
format!("{}={name}", slot.key),
)
.for_scheme(Some(if slot.scheme == Scheme::Socks {
Scheme::All
} else {
slot.scheme
})),
);
continue;
};
if settings.needs_expansion(slot.key) {
skipped.push(
RejectedValue::new(
RejectionKind::UnsupportedMapping,
RejectionSource::Kioslaverc(slot.key.to_owned()),
name,
)
.for_scheme(Some(slot.scheme)),
);
continue;
}
let Some(value) = env(name) else {
continue;
};
if in_cgi && forgeable_by_a_request_header(name) {
return Err(Error::CgiHttpProxy {
variable: name.to_owned(),
});
}
vars.push((canonical.to_owned(), value));
}
let mut no_proxy_for = None;
if let Some(name) = settings.text(KEY_NO_PROXY_FOR)
&& let Some(value) = env(name)
{
if in_cgi && forgeable_by_a_request_header(name) {
return Err(Error::CgiHttpProxy {
variable: name.to_owned(),
});
}
no_proxy_for = Some(value.clone());
vars.push(("no_proxy".to_owned(), value));
}
let resolved = ProxyEnv::from_vars(vars)?;
let collapses = resolved.is_empty() && skipped.is_empty();
let mut rejected = resolved.rejected().to_vec();
rejected.extend(skipped);
let mode = if collapses {
ProxyMode::Direct
} else {
let mut bypass = no_proxy_for
.as_deref()
.map_or_else(BypassRules::new, parse::kde_no_proxy_for);
bypass.implicit = ImplicitBypass::Empty;
bypass.ipv4_mapped_as_ipv4 = false;
bypass.strip_trailing_dot = false;
ProxyMode::manual(resolved.per_scheme().clone(), bypass).with_rejected(rejected)
};
Ok(mode)
}
fn forgeable_by_a_request_header(name: &str) -> bool {
name.get(..5)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("HTTP_"))
}
#[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
fn warn_skipped_env_var_scheme(key: &str, name: &str) {
crate::trace::warning!(
key = %key,
variable = %crate::util::redact_offending_token(name),
"kioslaverc: ProxyType=4 names an environment variable for a scheme ProxyEnv \
cannot delegate to; skipping it, but recording it in \
ProxyMode::Manual::rejected rather than dropping it without a trace"
);
}
fn bypass_from(settings: &KioslavercSettings) -> Result<BypassRules, Error> {
let mut rules = match settings.text(KEY_NO_PROXY_FOR) {
Some(list) if settings.needs_expansion(KEY_NO_PROXY_FOR) => {
let mut rules = BypassRules::new();
rules.rejected.push(RejectedValue::new(
RejectionKind::UnsupportedMapping,
RejectionSource::Kioslaverc(KEY_NO_PROXY_FOR.to_owned()),
list,
));
rules
}
Some(list) => parse::kde_no_proxy_for(list),
None => BypassRules::new(),
};
rules.implicit = ImplicitBypass::Empty;
rules.ipv4_mapped_as_ipv4 = false;
rules.strip_trailing_dot = false;
if settings.reversed_exception() {
rules.reversed_exceptions = true;
if rules.patterns.is_empty() && rules.rejected.is_empty() {
warn_empty_reversed_exception();
}
} else if settings.reversed_exception_is_disputed() {
warn_reversed_exception_not_applied();
}
Ok(rules)
}
fn warn_reversed_exception_not_applied() {
crate::trace::warning!(
key = KEY_REVERSED_EXCEPTION,
"kioslaverc: ReversedException is set to a value KConfig reads as true but \
libproxy's config-kde parses with !!atoi, which reads the leading run of digits \
and yields 0 unless one of them is non-zero, so 000, 0x10 and yes all come out \
false here. libproxy is the implementation on the live path \
since KIO 6.0.0, so the exception list is used the ordinary way round rather \
than inverted"
);
}
fn warn_empty_reversed_exception() {
crate::trace::warning!(
key = KEY_REVERSED_EXCEPTION,
list = KEY_NO_PROXY_FOR,
"kioslaverc: ReversedException is set but NoProxyFor is empty, so the list of \
destinations that use the proxy is empty too; every destination will resolve \
Direct even though a proxy is configured. KIO and libproxy both behave this way, \
so it is not corrected here — but it is almost certainly not what was intended"
);
}
fn normalize_address(raw: &str) -> Cow<'_, str> {
let trimmed = raw.trim();
if let Some((host, port)) = trimmed.rsplit_once(char::is_whitespace) {
let host = host.trim();
if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) {
let (prefix, bare) = match host.split_once("://") {
Some((scheme, rest)) => (&host[..scheme.len() + 3], rest),
None => ("", host),
};
if bare.parse::<Ipv6Addr>().is_ok() {
return Cow::Owned(format!("{prefix}[{bare}]:{port}"));
}
return Cow::Owned(format!("{host}:{port}"));
}
}
Cow::Borrowed(trimmed)
}
#[cfg(test)]
mod tests {
use super::*;
macro_rules! kioslaverc {
($($key:literal => $value:literal),* $(,)?) => {{
#[allow(unused_mut)]
let mut settings = KioslavercSettings::new();
$(settings.insert($key, $value);)*
settings
}};
}
fn no_env(_: &str) -> Option<std::ffi::OsString> {
None
}
fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<std::ffi::OsString> + use<> {
let map: HashMap<String, std::ffi::OsString> = pairs
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).into()))
.collect();
move |name: &str| map.get(name).cloned()
}
#[cfg(unix)]
fn not_unicode() -> std::ffi::OsString {
std::os::unix::ffi::OsStringExt::from_vec(vec![0xff])
}
#[cfg(windows)]
fn not_unicode() -> std::ffi::OsString {
std::os::windows::ffi::OsStringExt::from_wide(&[0xD800])
}
fn mode_of(settings: &KioslavercSettings) -> ProxyMode {
config_from_kioslaverc(settings, no_env).unwrap().mode
}
#[test]
fn both_kconfig_debugs_name_every_field_they_hold() {
let mut settings = kioslaverc! { "httpProxy[$e]" => "http://proxy.corp:8080" };
settings.mark_group_immutable();
assert_eq!(
format!("{settings:?}"),
"KioslavercSettings { entries: {\"httpProxy\": KconfigEntry { \
value: Some(\"http://proxy.corp:8080\"), immutable: false, expand: true }}, \
group_immutable: true }"
);
}
#[test]
fn kconfig_markers_are_normalised() {
assert_eq!(
normalize_key("Proxy Config Script[$e]"),
"Proxy Config Script"
);
assert_eq!(normalize_key("httpProxy"), "httpProxy");
assert_eq!(normalize_key(" ProxyType "), "ProxyType");
assert_eq!(
normalize_key("Proxy Config Script [$e]"),
"Proxy Config Script "
);
assert_eq!(
normalize_key("Proxy Config Script[$ie]"),
"Proxy Config Script"
);
assert_eq!(normalize_key("httpProxy[$i]"), "httpProxy");
assert_eq!(normalize_key("weird[$e"), "weird[$e");
assert!(has_kconfig_flag("Proxy Config Script[$ie]", 'i'));
assert!(has_kconfig_flag("Proxy Config Script[$ie]", 'e'));
assert!(!has_kconfig_flag("Proxy Config Script[$e]", 'i'));
}
#[test]
fn an_empty_section_is_direct() {
let settings = KioslavercSettings::new();
assert_eq!(mode_of(&settings), ProxyMode::Direct);
}
#[test]
fn a_proxy_type_that_is_present_but_blank_still_configures_the_store() {
let blank = kioslaverc! { "ProxyType" => "" };
let configured = configured_from_kioslaverc(&blank, no_env)
.unwrap()
.expect("a `ProxyType` line, even an empty one, is a configured store");
assert_eq!(configured.mode, ProxyMode::Direct);
assert_eq!(configured.source, ProxyConfigSource::Kioslaverc);
let other_keys_only = kioslaverc! { "httpProxy" => "http://proxy.corp:8080" };
assert!(
configured_from_kioslaverc(&other_keys_only, no_env)
.unwrap()
.is_none(),
"no `ProxyType` at all is the unconfigured store"
);
}
#[test]
fn proxy_type_zero_is_direct_and_ignores_other_keys() {
let with_keys = kioslaverc! {
"ProxyType" => "0",
"httpProxy" => "http://proxy.corp:8080",
};
assert_eq!(mode_of(&with_keys), ProxyMode::Direct);
let bare = kioslaverc! { "ProxyType" => "0" };
let config = configured_from_kioslaverc(&bare, no_env)
.unwrap()
.expect("an explicit ProxyType=0 is a decision, not an absent store");
assert_eq!(config.source, ProxyConfigSource::Kioslaverc);
assert_eq!(config.mode, ProxyMode::Direct);
}
#[test]
fn proxy_type_three_is_wpad() {
let settings = kioslaverc! { "ProxyType" => "3" };
assert_eq!(mode_of(&settings), ProxyMode::WpadAutoDetect);
}
#[test]
fn a_proxy_type_padded_by_an_escape_is_read_as_kde_reads_it() {
for value in [" 3", "3\t", " 3 "] {
let mut settings = KioslavercSettings::new();
settings.insert("ProxyType", value);
assert_eq!(mode_of(&settings), ProxyMode::WpadAutoDetect, "{value:?}");
}
}
#[test]
fn an_unknown_proxy_type_is_direct() {
let settings = kioslaverc! { "ProxyType" => "9", "httpProxy" => "http://p:1" };
assert_eq!(mode_of(&settings), ProxyMode::Direct);
let bare = kioslaverc! { "ProxyType" => "9" };
assert_eq!(
configured_from_kioslaverc(&bare, no_env)
.unwrap()
.map(|config| config.mode),
Some(ProxyMode::Direct)
);
}
#[test]
fn an_unexpanded_reference_is_rejected_not_reported_as_a_host() {
let settings = kioslaverc! {
"ProxyType" => "1",
"httpProxy[$e]" => "$PROXY_WATCH_KDE_TEST:8080",
};
let mode = mode_of(&settings);
assert!(
mode.endpoint_for(Scheme::Http).is_none(),
"`$PROXY_WATCH_KDE_TEST` is a variable name, not a host: {mode:?}"
);
assert_eq!(
mode.rejected().unwrap()[0].redacted_input(),
"$PROXY_WATCH_KDE_TEST:8080"
);
assert_eq!(
mode.rejected().unwrap()[0].kind(),
RejectionKind::UnsupportedMapping
);
}
#[cfg(feature = "resolve")]
#[test]
fn an_unexpanded_http_proxy_is_reported_rather_than_resolved_direct() {
let settings = kioslaverc! {
"ProxyType" => "1",
"httpProxy[$e]" => "$PROXY_WATCH_KDE_TEST:8080",
};
let mode = mode_of(&settings);
assert_eq!(
mode.rejected().unwrap()[0].affected_scheme(),
Some(Scheme::Http)
);
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::Http),
"{err:?}"
);
}
#[cfg(feature = "resolve")]
#[test]
fn an_unusable_socks_entry_is_reported_for_a_scheme_it_would_have_covered() {
let settings = kioslaverc! {
"ProxyType" => "1",
"httpsProxy" => "",
"socksProxy" => "not a host with spaces",
};
let config = crate::ProxyConfig::new(mode_of(&settings), Vec::new());
let url = url::Url::parse("https://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_expansion_flag_over_a_plain_value_changes_nothing() {
let settings = kioslaverc! {
"ProxyType" => "1",
"httpProxy[$e]" => "http://proxy.corp:8080",
};
assert_eq!(
mode_of(&settings)
.endpoint_for(Scheme::Http)
.unwrap()
.authority(),
"proxy.corp:8080"
);
}
#[test]
fn a_dollar_sign_without_the_expansion_flag_is_an_ordinary_value() {
let settings = kioslaverc! {
"ProxyType" => "2",
"Proxy Config Script" => "http://wpad.corp/proxy.pac?for=$site",
};
match mode_of(&settings) {
ProxyMode::Pac { url, .. } => {
assert_eq!(url.as_str(), "http://wpad.corp/proxy.pac?for=$site");
}
other => panic!("expected Pac, got {other:?}"),
}
}
#[test]
fn proxy_type_two_reads_the_config_script() {
let settings = kioslaverc! {
"ProxyType" => "2",
"Proxy Config Script[$e]" => "http://wpad.corp/proxy.pac",
};
match mode_of(&settings) {
ProxyMode::Pac { url, .. } => assert_eq!(url.as_str(), "http://wpad.corp/proxy.pac"),
other => panic!("expected Pac, got {other:?}"),
}
}
#[test]
fn a_config_script_path_becomes_a_file_url() {
let settings = kioslaverc! {
"ProxyType" => "2",
"Proxy Config Script" => "/home/alice/proxy.pac",
};
match mode_of(&settings) {
ProxyMode::Pac { url, .. } => {
assert_eq!(url.scheme(), "file");
assert!(url.path().ends_with("/home/alice/proxy.pac"));
}
other => panic!("expected Pac, got {other:?}"),
}
}
#[test]
fn a_relative_config_script_is_an_error() {
let settings = kioslaverc! { "ProxyType" => "2", "Proxy Config Script" => "proxy.pac" };
assert!(matches!(
config_from_kioslaverc(&settings, no_env),
Err(Error::InvalidProxyUrl { .. })
));
}
#[test]
fn an_unexpanded_config_script_is_rejected_rather_than_reported_as_a_dead_url() {
assert!(
Url::parse("file:///home/$USER/proxy.pac").is_ok(),
"the literal has to parse, or the fold would not be silent"
);
let settings = kioslaverc! {
"ProxyType" => "2",
"Proxy Config Script[$e]" => "/home/$USER/proxy.pac",
};
let mode = mode_of(&settings);
assert!(
!matches!(mode, ProxyMode::Pac { .. }),
"a script path this crate will not expand is not a script URL: {mode:?}"
);
assert_eq!(
mode.rejected().unwrap()[0].redacted_input(),
"/home/$USER/proxy.pac"
);
assert_eq!(
mode.rejected().unwrap()[0].kind(),
RejectionKind::UnsupportedMapping
);
}
#[cfg(feature = "resolve")]
#[test]
fn an_unexpanded_config_script_does_not_resolve_direct() {
let settings = kioslaverc! {
"ProxyType" => "2",
"Proxy Config Script[$e]" => "/home/$USER/proxy.pac",
};
let config = crate::ProxyConfig::new(mode_of(&settings), Vec::new());
for u in [
"http://intranet.corp/x",
"http://localhost/",
"http://127.0.0.1/",
"http://169.254.1.1/",
] {
let url = url::Url::parse(u).unwrap();
let err = crate::resolve(&config, &url).unwrap_err();
assert!(
matches!(&err, crate::Error::ProxyEntryUnusable { scheme, .. }
if *scheme == Scheme::All),
"{u}: {err:?}"
);
}
}
#[test]
fn an_unexpanded_env_var_name_is_recorded_rather_than_looked_up_literally() {
let settings = kioslaverc! {
"ProxyType" => "4",
"httpProxy[$e]" => "$PROXY_WATCH_NAME_OF_A_VAR",
};
let mode = config_from_kioslaverc(&settings, env_of(&[("PROXY_WATCH_NAME_OF_A_VAR", "x")]))
.unwrap()
.mode;
assert!(mode.endpoint_for(Scheme::Http).is_none(), "{mode:?}");
assert_eq!(
mode.rejected().unwrap()[0].kind(),
RejectionKind::UnsupportedMapping
);
assert_eq!(
mode.rejected().unwrap()[0].affected_scheme(),
Some(Scheme::Http)
);
}
#[test]
fn a_config_script_path_keeps_the_bytes_url_syntax_would_have_taken() {
for path in [
"/home/a#b/proxy.pac",
"/home/a?b/proxy.pac",
"/home/a\\b/proxy.pac",
"/home/a\tb/proxy.pac",
"/home/a\nb/proxy.pac",
"/home/a%2Fb/proxy.pac",
"/home/proxy.pac\u{1}",
"/c|/proxy.pac",
] {
let mut settings = KioslavercSettings::new();
settings.insert("ProxyType", "2");
settings.insert(KEY_CONFIG_SCRIPT, path);
match mode_of(&settings) {
ProxyMode::Pac { url, .. } => assert_eq!(
crate::util::percent_decode(url.path()),
path,
"{path:?} did not survive the splice into a file: URL"
),
other => panic!("expected Pac for {path:?}, got {other:?}"),
}
}
}
#[test]
fn proxy_type_two_without_a_script_is_direct() {
let settings = kioslaverc! { "ProxyType" => "2" };
assert_eq!(mode_of(&settings), ProxyMode::Direct);
}
#[test]
fn manual_entries_map_onto_their_schemes() {
let settings = kioslaverc! {
"ProxyType" => "1",
"httpProxy" => "http://http.corp:3128",
"httpsProxy" => "http://https.corp:3129",
"ftpProxy" => "http://ftp.corp:3130",
"socksProxy" => "socks://socks.corp:1081",
};
let mode = mode_of(&settings);
assert_eq!(
mode.endpoint_for(Scheme::Http).unwrap().authority(),
"http.corp:3128"
);
assert_eq!(
mode.endpoint_for(Scheme::Https).unwrap().authority(),
"https.corp:3129"
);
assert_eq!(
mode.endpoint_for(Scheme::Ftp).unwrap().authority(),
"ftp.corp:3130"
);
assert_eq!(
mode.endpoint_for(Scheme::Socks).unwrap().authority(),
"socks.corp:1081"
);
}
#[test]
fn the_legacy_space_separated_form_is_accepted() {
let settings = kioslaverc! {
"ProxyType" => "1",
"httpProxy" => "proxy.corp 3128",
};
assert_eq!(
mode_of(&settings)
.endpoint_for(Scheme::Http)
.unwrap()
.authority(),
"proxy.corp:3128"
);
}
#[test]
fn every_slot_reads_a_scheme_and_a_space_separated_port() {
let settings = kioslaverc! {
"ProxyType" => "1",
"ftpProxy" => "ftp://proxy.corp 8080",
"httpProxy" => "http://proxy.corp 8080",
"httpsProxy" => "http://proxy.corp 8080",
"socksProxy" => "socks://proxy.corp 8080",
};
let config = config_from_kioslaverc(&settings, no_env).unwrap();
for scheme in [Scheme::Http, Scheme::Https, Scheme::Socks] {
let endpoint = config.mode.endpoint_for(scheme).unwrap();
assert_eq!(endpoint.authority(), "proxy.corp:8080", "{scheme}");
}
assert_eq!(
config.mode.endpoint_for(Scheme::Socks).unwrap().scheme_hint,
Some(ProxyScheme::Socks5)
);
let rejected = config.mode.rejected().unwrap();
assert_eq!(rejected.len(), 1, "{rejected:?}");
assert_eq!(rejected[0].redacted_input(), "ftp://proxy.corp 8080");
assert_eq!(rejected[0].kind(), RejectionKind::InvalidProxyEndpoint);
assert_eq!(
rejected[0].source(),
&RejectionSource::Kioslaverc("ftpProxy".to_owned())
);
assert_eq!(
config.mode.endpoint_for(Scheme::Ftp).unwrap().authority(),
"proxy.corp:8080"
);
}
#[test]
fn a_version_written_into_the_value_beats_the_socks5_default() {
let settings = kioslaverc! {
"ProxyType" => "1",
"socksProxy" => "socks4://socks.corp 1080",
};
assert_eq!(
mode_of(&settings)
.endpoint_for(Scheme::Socks)
.unwrap()
.scheme_hint,
Some(ProxyScheme::Socks4)
);
}
#[test]
fn a_value_without_a_port_uses_the_source_default() {
let settings = kioslaverc! {
"ProxyType" => "1",
"httpProxy" => "proxy.corp",
"socksProxy" => "socks.corp",
};
let mode = mode_of(&settings);
assert_eq!(mode.endpoint_for(Scheme::Http).unwrap().port, 8080);
let socks = mode.endpoint_for(Scheme::Socks).unwrap();
assert_eq!(socks.port, 1080);
assert_eq!(socks.scheme_hint, Some(ProxyScheme::Socks5));
}
#[test]
fn an_empty_value_is_disabled_and_an_absent_key_is_nothing() {
let settings = kioslaverc! {
"ProxyType" => "1",
"httpProxy" => "proxy.corp:3128",
"ftpProxy" => "",
};
let mode = mode_of(&settings);
assert!(
mode.entry_for(Scheme::Ftp)
.expect("ftp entry")
.is_disabled()
);
assert!(
mode.entry_for(Scheme::Https).is_none(),
"httpsProxy was never written, so there is no entry at all"
);
}
#[test]
fn a_socks_entry_catches_every_scheme_that_named_nothing() {
let settings = kioslaverc! {
"ProxyType" => "1",
"httpProxy" => "http.corp 3128",
"ftpProxy" => "",
"socksProxy" => "socks.corp 1081",
};
let mode = mode_of(&settings);
assert_eq!(
mode.endpoint_for(Scheme::Http).unwrap().authority(),
"http.corp:3128",
"a scheme with its own proxy is never overridden by the catch-all"
);
for scheme in [Scheme::Https, Scheme::Ftp] {
let endpoint = mode.endpoint_for(scheme).unwrap_or_else(|| {
panic!("{scheme} named no proxy, so the SOCKS entry must carry it")
});
assert_eq!(endpoint.authority(), "socks.corp:1081");
assert_eq!(endpoint.scheme_hint, Some(ProxyScheme::Socks5));
}
}
#[test]
fn manual_with_only_empty_values_is_direct() {
let settings = kioslaverc! { "ProxyType" => "1", "httpProxy" => "", "ftpProxy" => "" };
assert_eq!(mode_of(&settings), ProxyMode::Direct);
}
#[test]
fn malformed_scheme_hosts_are_dropped_and_recorded() {
for label in ["mixed", "only_malformed", "masked"] {
let settings = match label {
"mixed" => kioslaverc! {
"ProxyType" => "1",
"httpProxy" => "http.corp:3128",
"httpsProxy" => "not a host with spaces",
"ftpProxy" => "ftp.corp:3130",
},
"only_malformed" => kioslaverc! {
"ProxyType" => "1",
"httpProxy" => "not a host with spaces",
},
"masked" => kioslaverc! {
"ProxyType" => "1",
"httpProxy" => "http://alice:hunter2@bad host:8080",
},
_ => unreachable!(),
};
let mode = mode_of(&settings);
match label {
"mixed" => {
assert_eq!(
mode.endpoint_for(Scheme::Http).unwrap().authority(),
"http.corp:3128"
);
assert_eq!(
mode.endpoint_for(Scheme::Ftp).unwrap().authority(),
"ftp.corp:3130"
);
assert!(mode.endpoint_for(Scheme::Https).is_none());
assert_eq!(
mode.rejected().unwrap()[0].redacted_input(),
"not a host with spaces"
);
}
"only_malformed" => {
assert!(!mode.is_direct());
assert!(mode.endpoint_for(Scheme::Http).is_none());
assert_eq!(
mode.rejected().unwrap()[0].redacted_input(),
"not a host with spaces"
);
}
"masked" => {
assert_eq!(
mode.rejected().unwrap()[0].redacted_input(),
"http://alice:***@bad host:8080"
);
}
_ => unreachable!(),
}
}
}
#[test]
fn no_proxy_for_becomes_bypass_rules() {
let settings = kioslaverc! {
"ProxyType" => "1",
"httpProxy" => "http://proxy.corp:8080",
"NoProxyFor" => "localhost,.corp.example,10.0.0.0/8",
};
let mode = mode_of(&settings);
let bypass = mode.bypass().expect("manual mode has bypass rules");
assert!(bypass.matches_authority("api.corp.example"));
assert!(bypass.matches_authority("10.1.2.3"));
assert!(!bypass.matches_authority("example.net"));
assert!(!bypass.reversed_exceptions);
}
#[test]
fn a_semicolon_does_not_separate_a_no_proxy_for_list() {
let settings = kioslaverc! {
"ProxyType" => "1",
"httpProxy" => "http://proxy.corp:8080",
"NoProxyFor" => "a.example;b.example",
};
let mode = mode_of(&settings);
let bypass = mode.bypass().expect("manual mode has bypass rules");
assert_eq!(bypass.patterns.len(), 1);
assert!(!bypass.matches_authority("a.example"));
assert!(!bypass.matches_authority("b.example"));
}
#[test]
fn a_reversed_exception_with_no_list_sends_everything_direct() {
for settings in [
kioslaverc! {
"ProxyType" => "1",
"httpProxy" => "http://proxy.corp:8080",
"ReversedException" => "1",
},
kioslaverc! {
"ProxyType" => "1",
"httpProxy" => "http://proxy.corp:8080",
"NoProxyFor" => "",
"ReversedException" => "1",
},
] {
let mode = mode_of(&settings);
let bypass = mode.bypass().expect("manual mode has bypass rules");
assert!(bypass.reversed_exceptions);
assert!(bypass.patterns.is_empty());
assert!(bypass.rejected.is_empty());
for host in ["example.net", "api.corp.example", "10.1.2.3"] {
assert!(
bypass.matches_authority(host),
"{host} must be direct: an empty reversed list names no destination \
that uses the proxy"
);
}
}
}
#[test]
fn proxy_type_4_ignores_reversed_exception() {
let settings = kioslaverc! {
"ProxyType" => "4",
"httpProxy" => "MY_HTTP_PROXY",
"NoProxyFor" => "MY_NO_PROXY",
"ReversedException" => "1",
};
let mode = env_mode(&settings, |name| match name {
"MY_HTTP_PROXY" => Some("http://proxy.corp:8080".into()),
"MY_NO_PROXY" => Some("localhost,127.0.0.1,api.corp.example,example.com.".into()),
_ => None,
})
.expect("env mode");
let bypass = mode.bypass().expect("manual mode has bypass rules");
assert!(!bypass.matches_authority("example.com"));
assert_eq!(bypass.rejected.len(), 1, "{:?}", bypass.rejected);
assert!(
!bypass.reversed_exceptions,
"ProxyType=4 must not reverse: KIO's useRevProxy requires ManualProxy"
);
assert!(!bypass.matches_authority("example.net"));
assert!(bypass.matches_authority("api.corp.example"));
assert_eq!(bypass.implicit, crate::ImplicitBypass::Empty);
assert!(!bypass.matches_authority("api.corp.example."));
assert!(bypass.matches_authority("127.0.0.1"));
assert!(!bypass.matches_authority("127.0.0.2"));
assert!(!bypass.matches_authority("[::1]"));
assert!(!bypass.matches_authority("169.254.169.254"));
assert!(!bypass.matches_authority("[::ffff:127.0.0.1]"));
}
#[cfg(any(unix, windows))]
#[test]
fn a_named_variable_whose_value_is_not_unicode_does_not_read_back_as_direct() {
let settings = kioslaverc! {
"ProxyType" => "4",
"httpProxy" => "MY_HTTP_PROXY",
};
let mode = env_mode(&settings, |name| {
(name == "MY_HTTP_PROXY").then(not_unicode)
})
.expect("env mode");
assert!(
!matches!(mode, ProxyMode::Direct),
"a present-but-unreadable value must not become \"no proxy configured\": {mode:?}"
);
}
#[test]
fn an_unexpanded_no_proxy_for_is_rejected_rather_than_parsed_as_a_dead_pattern() {
let settings = kioslaverc! {
"ProxyType" => "1",
"httpProxy" => "http://proxy.corp:8080",
"NoProxyFor[$e]" => "$MY_NO_PROXY",
};
let bypass = mode_of(&settings)
.bypass()
.expect("manual mode has bypass rules")
.clone();
assert!(
bypass.patterns.is_empty(),
"the literal variable name must not become a pattern: {:?}",
bypass.patterns
);
assert_eq!(
bypass.rejected.len(),
1,
"the list has to be reported as unusable: {:?}",
bypass.rejected
);
assert_eq!(
bypass.rejected[0].source(),
&RejectionSource::Kioslaverc("NoProxyFor".to_owned())
);
}
#[test]
fn an_unexpanded_reversed_no_proxy_for_does_not_send_every_destination_direct() {
let settings = kioslaverc! {
"ProxyType" => "1",
"httpProxy" => "http://proxy.corp:8080",
"NoProxyFor[$e]" => "$MY_NO_PROXY",
"ReversedException" => "1",
};
let bypass = mode_of(&settings)
.bypass()
.expect("manual mode has bypass rules")
.clone();
assert!(bypass.reversed_exceptions);
assert!(
!bypass.matches_authority("example.net"),
"an unusable inclusion list must not disable the proxy for everything"
);
}
#[test]
fn reversed_exception_inverts_the_list() {
let settings = kioslaverc! {
"ProxyType" => "1",
"httpProxy" => "http://proxy.corp:8080",
"NoProxyFor" => ".corp.example",
"ReversedException" => "1",
};
let mode = mode_of(&settings);
let bypass = mode.bypass().expect("manual mode has bypass rules");
assert!(bypass.reversed_exceptions);
assert!(!bypass.matches_authority("api.corp.example"));
assert!(bypass.matches_authority("example.net"));
assert!(bypass.matches_authority("localhost"));
}
#[test]
fn a_typo_in_a_reversed_no_proxy_for_list_does_not_silently_bypass_the_proxy() {
let settings = kioslaverc! {
"ProxyType" => "1",
"httpProxy" => "http://proxy.corp:8080",
"NoProxyFor" => ".corp.example,10.0.0/8",
"ReversedException" => "1",
};
let mode = mode_of(&settings);
let bypass = mode.bypass().expect("manual mode has bypass rules");
assert!(bypass.reversed_exceptions);
assert_eq!(bypass.rejected[0].redacted_input(), "10.0.0/8");
assert!(!bypass.matches_authority("10.1.2.3"));
assert!(!bypass.matches_authority("example.net"));
assert!(!bypass.matches_authority("api.corp.example"));
assert!(!bypass.matches_authority("localhost"));
}
#[test]
fn no_proxy_for_carries_no_implicit_bypass() {
let settings = kioslaverc! {
"ProxyType" => "1",
"httpProxy" => "http://proxy.corp:8080",
"NoProxyFor" => "10.0.0.0/8,<local>",
};
let mode = mode_of(&settings);
let bypass = mode.bypass().expect("manual mode has bypass rules");
assert_eq!(bypass.implicit, ImplicitBypass::Empty);
for destination in [
"localhost.",
"127.0.0.1",
"[::1]",
"169.254.169.254",
"[fe80::1]",
] {
assert!(!bypass.matches_authority(destination), "{destination}");
}
assert!(bypass.matches_authority("10.1.2.3"));
assert!(!bypass.matches_authority("[::ffff:10.1.2.3]"));
let mapped = mode_of(&kioslaverc! {
"ProxyType" => "1",
"httpProxy" => "http://proxy.corp:8080",
"NoProxyFor" => "::ffff:10.0.0.0/104",
});
let mapped = mapped.bypass().expect("manual mode has bypass rules");
assert!(!mapped.matches_authority("10.1.2.3"));
assert!(mapped.matches_authority("[::ffff:10.1.2.3]"));
assert!(!bypass.strip_trailing_dot);
let dotted = mode_of(&kioslaverc! {
"ProxyType" => "1",
"httpProxy" => "http://proxy.corp:8080",
"NoProxyFor" => "example.com.,10.0.0.0/8",
});
let dotted = dotted.bypass().expect("manual mode has bypass rules");
assert!(!dotted.matches_authority("example.com"));
assert!(!dotted.matches_authority("www.example.com"));
assert_eq!(dotted.rejected.len(), 1, "{:?}", dotted.rejected);
assert!(bypass.matches_authority("intranet"));
assert!(bypass.matches_authority("localhost"));
assert!(!bypass.matches_authority("intranet."));
}
#[test]
fn a_non_decimal_ipv4_entry_is_refused_in_no_proxy_for() {
for entry in [
"012.1.2.3",
"0xa.1.2.3",
"10.66051",
"192.168.1",
"012.1.2.0/24",
"010.1.2.0/24",
"0xa.1.2.0/24",
] {
let mut settings = kioslaverc! {
"ProxyType" => "1",
"httpProxy" => "http://proxy.corp:8080",
};
settings.insert("NoProxyFor", entry);
let mode = mode_of(&settings);
let bypass = mode.bypass().expect("manual mode has bypass rules");
assert!(bypass.patterns.is_empty(), "{entry:?}: {bypass:?}");
assert_eq!(bypass.rejected.len(), 1, "{entry:?}: {bypass:?}");
assert!(!bypass.matches_authority("10.1.2.3"), "{entry:?}");
}
let decimal = mode_of(&kioslaverc! {
"ProxyType" => "1",
"httpProxy" => "http://proxy.corp:8080",
"NoProxyFor" => "10.1.2.3",
});
let decimal = decimal.bypass().expect("manual mode has bypass rules");
assert!(decimal.rejected.is_empty(), "{decimal:?}");
assert!(decimal.matches_authority("10.1.2.3"));
}
#[test]
fn a_dialog_written_reversed_exception_is_not_applied() {
let settings = kioslaverc! {
"ProxyType" => "1",
"httpProxy" => "http://proxy.corp:8080",
"NoProxyFor" => ".corp.example",
"ReversedException" => "true",
};
let mode = mode_of(&settings);
let bypass = mode.bypass().expect("manual mode has bypass rules");
assert!(!bypass.reversed_exceptions);
assert!(bypass.matches_authority("api.corp.example"));
assert!(!bypass.matches_authority("example.net"));
let settings = kioslaverc! {
"ProxyType" => "1",
"httpProxy" => "http://proxy.corp:8080",
"ReversedException" => "true",
};
let mode = mode_of(&settings);
let bypass = mode.bypass().expect("manual mode has bypass rules");
assert!(!bypass.reversed_exceptions);
assert!(!bypass.matches_authority("example.net"));
}
#[test]
fn the_reversed_exception_flag_follows_libproxys_atoi() {
let cases = [
("true", false, true),
("false", false, false),
("no", false, false),
("off", false, false),
("OFF", false, false),
("0", false, false),
("000", false, true),
("0x10", false, true),
("yes", false, true),
("on", false, true),
("maybe", false, true),
("1", true, false),
("2", true, false),
("007", true, false),
("1abc", true, false),
("-1", true, false),
("+1", true, false),
(" 1", true, false),
("\"1\"", false, true),
("", false, false),
];
for (value, applied, disputed) in cases {
let mut settings = KioslavercSettings::new();
settings.insert(KEY_REVERSED_EXCEPTION, value);
assert_eq!(
settings.reversed_exception(),
applied,
"ReversedException={value:?}"
);
assert_eq!(
settings.reversed_exception_is_disputed(),
disputed,
"ReversedException={value:?}"
);
}
let absent = kioslaverc! {};
assert!(!absent.reversed_exception());
assert!(!absent.reversed_exception_is_disputed());
}
#[test]
fn env_var_proxy_dereferences_the_variable_names() {
let settings = kioslaverc! {
"ProxyType" => "4",
"httpProxy" => "MY_HTTP_PROXY",
"httpsProxy" => "MY_HTTPS_PROXY",
"NoProxyFor" => "MY_NO_PROXY",
};
let env = env_of(&[
("MY_HTTP_PROXY", "http://env.corp:3128"),
("MY_HTTPS_PROXY", "http://env.corp:3129"),
("MY_NO_PROXY", ".corp.example"),
("http_proxy", "http://wrong.corp:1"),
]);
let config = config_from_kioslaverc(&settings, env).unwrap();
assert_eq!(config.source, ProxyConfigSource::KioslavercEnv);
assert_eq!(
config.mode.endpoint_for(Scheme::Http).unwrap().authority(),
"env.corp:3128"
);
assert_eq!(
config.mode.endpoint_for(Scheme::Https).unwrap().authority(),
"env.corp:3129"
);
assert!(
config
.mode
.bypass()
.unwrap()
.matches_authority("api.corp.example")
);
}
#[test]
fn env_var_proxy_with_unset_variables_is_direct() {
let settings = kioslaverc! { "ProxyType" => "4", "httpProxy" => "MY_HTTP_PROXY" };
let config = config_from_kioslaverc(&settings, no_env).unwrap();
assert_eq!(config.source, ProxyConfigSource::KioslavercEnv);
assert_eq!(config.mode, ProxyMode::Direct);
}
#[test]
fn proxy_type_4_records_or_directs_skipped_socks_slots() {
for label in ["socks_only", "socks_and_http", "empty_socks"] {
let (settings, env) = match label {
"socks_only" => (
kioslaverc! { "ProxyType" => "4", "socksProxy" => "MY_SOCKS_PROXY" },
env_of(&[("MY_SOCKS_PROXY", "socks5://socks.corp:1080")]),
),
"socks_and_http" => (
kioslaverc! {
"ProxyType" => "4",
"httpProxy" => "MY_HTTP_PROXY",
"socksProxy" => "MY_SOCKS_PROXY",
},
env_of(&[
("MY_HTTP_PROXY", "http://env.corp:3128"),
("MY_SOCKS_PROXY", "socks5://socks.corp:1080"),
]),
),
"empty_socks" => (
kioslaverc! { "ProxyType" => "4", "socksProxy" => "" },
env_of(&[]),
),
_ => unreachable!(),
};
let config = config_from_kioslaverc(&settings, env).unwrap();
match label {
"socks_only" => {
assert_eq!(config.source, ProxyConfigSource::KioslavercEnv);
assert!(
!config.mode.is_direct(),
"the skipped socksProxy slot must not be silently lost: {:?}",
config.mode
);
assert_eq!(
config.mode.rejected().unwrap()[0].redacted_input(),
"socksProxy=MY_SOCKS_PROXY"
);
assert_eq!(
config.mode.rejected().unwrap()[0].kind(),
RejectionKind::UnsupportedMapping
);
#[cfg(feature = "resolve")]
{
let url = crate::Url::parse("socks://socks.corp/").unwrap();
let resolved = crate::ProxyConfig::new(config.mode.clone(), Vec::new());
let err = crate::resolve(&resolved, &url).unwrap_err();
assert!(
matches!(&err, crate::Error::ProxyEntryUnusable { .. }),
"{err:?}"
);
for url in [
"http://example.net/",
"https://example.net/",
"ftp://example.net/",
] {
let url = crate::Url::parse(url).unwrap();
let err = crate::resolve(&resolved, &url).unwrap_err();
assert!(
matches!(&err, crate::Error::ProxyEntryUnusable { .. }),
"{url}: {err:?}"
);
}
}
}
"socks_and_http" => {
assert_eq!(
config.mode.endpoint_for(Scheme::Http).unwrap().authority(),
"env.corp:3128",
"the resolvable slot must still resolve"
);
assert_eq!(
config.mode.rejected().unwrap()[0].redacted_input(),
"socksProxy=MY_SOCKS_PROXY",
"the unmodellable slot must still be recorded"
);
#[cfg(feature = "resolve")]
{
let resolved = crate::ProxyConfig::new(config.mode.clone(), Vec::new());
for url in ["https://example.net/", "ftp://example.net/"] {
let url = crate::Url::parse(url).unwrap();
let err = crate::resolve(&resolved, &url).unwrap_err();
assert!(
matches!(&err, crate::Error::ProxyEntryUnusable { .. }),
"{url}: {err:?}"
);
}
}
}
"empty_socks" => {
assert_eq!(config.mode, ProxyMode::Direct);
}
_ => unreachable!(),
}
}
}
#[test]
fn a_skipped_socks_slot_is_redacted_in_the_rejected_list() {
let settings = kioslaverc! {
"ProxyType" => "4",
"socksProxy" => "http://alice:hunter2@bad host:1080",
};
let config = config_from_kioslaverc(&settings, no_env).unwrap();
assert_eq!(
config.mode.rejected().unwrap()[0].redacted_input(),
"socksProxy=http://alice:***@bad host:1080"
);
}
#[test]
fn the_cgi_refusal_follows_the_named_variable_and_not_the_slot() {
let cases: &[(&str, &str, bool, &str)] = &[
(
"httpProxy",
"MY_HTTP_PROXY",
false,
"a field name mapping onto MY_HTTP_PROXY would have to be `My-Http-Proxy`, \
which RFC 3875 §4.1.18 turns into HTTP_MY_HTTP_PROXY instead",
),
("httpProxy", "http_proxy", true, "httpoxy itself"),
(
"httpProxy",
"HTTP_PROXY",
true,
"httpoxy itself, upper case",
),
(
"httpsProxy",
"HTTP_PROXY",
true,
"the forged variable does not stop being forged because the file filed it \
under another scheme",
),
(
"httpsProxy",
"HTTPS_PROXY",
false,
"no field name has HTTPS_PROXY as its image",
),
(
"httpProxy",
"HTTP_CORPORATE_PROXY",
true,
"the header `Corporate-Proxy:` produces it",
),
];
for (key, variable, refused, why) in cases {
let mut settings = KioslavercSettings::new();
settings.insert("ProxyType", "4");
settings.insert(key, *variable);
let env = env_of(&[
(*variable, "http://env.corp:3128"),
("REQUEST_METHOD", "GET"),
]);
let result = config_from_kioslaverc(&settings, env);
assert_eq!(
matches!(&result, Err(Error::CgiHttpProxy { variable: named }) if named == variable),
*refused,
"{key}={variable}: {why} ({result:?})"
);
if !refused {
let config =
result.unwrap_or_else(|error| panic!("{key}={variable}: {why} ({error:?})"));
let scheme = if *key == "httpsProxy" {
Scheme::Https
} else {
Scheme::Http
};
assert_eq!(
config
.mode
.endpoint_for(scheme)
.unwrap_or_else(|| panic!("{key}={variable}: no endpoint in the slot"))
.authority(),
"env.corp:3128",
"{key}={variable}"
);
}
}
}
#[test]
fn a_forgeable_variable_name_is_only_refused_under_cgi() {
let settings = kioslaverc! { "ProxyType" => "4", "httpProxy" => "HTTP_PROXY" };
let env = env_of(&[("HTTP_PROXY", "http://env.corp:3128")]);
assert_eq!(
config_from_kioslaverc(&settings, env)
.unwrap()
.mode
.endpoint_for(Scheme::Http)
.unwrap()
.authority(),
"env.corp:3128"
);
}
#[test]
fn the_bypass_slot_is_refused_under_cgi_like_the_proxy_slots() {
let settings = kioslaverc! {
"ProxyType" => "4",
"httpProxy" => "MY_PROXY",
"NoProxyFor" => "HTTP_PROXY",
};
let env = env_of(&[
("MY_PROXY", "http://env.corp:3128"),
("HTTP_PROXY", "*"),
("REQUEST_METHOD", "GET"),
]);
assert!(matches!(
config_from_kioslaverc(&settings, env),
Err(Error::CgiHttpProxy { variable }) if variable == "HTTP_PROXY"
));
}
#[test]
fn an_empty_request_method_is_not_a_cgi_environment() {
let settings = kioslaverc! { "ProxyType" => "4", "httpProxy" => "HTTP_PROXY" };
let env = env_of(&[
("HTTP_PROXY", "http://env.corp:3128"),
("REQUEST_METHOD", ""),
]);
assert_eq!(
config_from_kioslaverc(&settings, env)
.unwrap()
.mode
.endpoint_for(Scheme::Http)
.unwrap()
.authority(),
"env.corp:3128"
);
}
#[test]
fn a_manual_file_is_attributed_to_kioslaverc() {
let settings = kioslaverc! { "ProxyType" => "1", "httpProxy" => "proxy.corp:8080" };
assert_eq!(
config_from_kioslaverc(&settings, no_env).unwrap().source,
ProxyConfigSource::Kioslaverc
);
}
#[test]
fn a_section_without_a_proxy_type_is_unset() {
let settings = kioslaverc! { "NoProxyFor" => "localhost" };
assert!(
configured_from_kioslaverc(&settings, no_env)
.unwrap()
.is_none()
);
assert!(
configured_from_kioslaverc(&KioslavercSettings::new(), no_env)
.unwrap()
.is_none()
);
}
#[test]
fn addresses_are_normalised_conservatively() {
assert_eq!(normalize_address("http://h:8080"), "http://h:8080");
assert_eq!(normalize_address(" h 8080 "), "h:8080");
assert_eq!(normalize_address("h:8080"), "h:8080");
assert_eq!(normalize_address("h notaport"), "h notaport");
assert_eq!(normalize_address(" h notaport "), "h notaport");
assert_eq!(normalize_address("[::1]:8080"), "[::1]:8080");
assert_eq!(normalize_address("h 8080"), "h:8080");
}
#[test]
fn an_unbracketed_ipv6_host_keeps_its_port_when_the_two_are_folded_together() {
assert_eq!(normalize_address("::1 3128"), "[::1]:3128");
assert_eq!(normalize_address("socks://::1 1080"), "socks://[::1]:1080");
let settings = kioslaverc! {
"ProxyType" => "1",
"httpProxy" => "::1 3128",
"socksProxy" => "socks://::1 1080",
};
let config = config_from_kioslaverc(&settings, no_env).unwrap();
assert_eq!(
config.mode.endpoint_for(Scheme::Http).unwrap().authority(),
"[::1]:3128"
);
assert_eq!(
config.mode.endpoint_for(Scheme::Socks).unwrap().authority(),
"[::1]:1080"
);
assert_eq!(config.mode.rejected().unwrap_or_default(), &[]);
}
}