#![cfg_attr(
not(all(target_os = "linux", feature = "linux-gnome")),
allow(dead_code)
)]
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::fmt;
use url::Url;
use crate::auth::ProxyAuth;
use crate::bypass::{BypassDialect, BypassRules, ImplicitBypass};
use crate::diagnostic::{RejectedValue, RejectionKind, RejectionSource};
use crate::endpoint::{ProxyEndpoint, ProxyEntry, ProxyScheme, Scheme};
use crate::error::Error;
use crate::mode::ProxyMode;
use crate::parse;
pub(crate) const SCHEMA: &str = "org.gnome.system.proxy";
pub(crate) const KEY_MODE: &str = "mode";
pub(crate) const KEY_AUTOCONFIG_URL: &str = "autoconfig-url";
pub(crate) const KEY_IGNORE_HOSTS: &str = "ignore-hosts";
pub(crate) const KEY_USE_SAME_PROXY: &str = "use-same-proxy";
pub(crate) const KEY_HOST: &str = "host";
pub(crate) const KEY_PORT: &str = "port";
pub(crate) const KEY_USE_AUTHENTICATION: &str = "use-authentication";
pub(crate) const KEY_AUTHENTICATION_USER: &str = "authentication-user";
pub(crate) const KEY_AUTHENTICATION_PASSWORD: &str = "authentication-password";
const MODE_NONE: &str = "none";
const MODE_MANUAL: &str = "manual";
const MODE_AUTO: &str = "auto";
pub(crate) struct ChildKeys {
pub(crate) scheme: Scheme,
pub(crate) child: &'static str,
default_port: u16,
hint: Option<ProxyScheme>,
}
pub(crate) const CHILDREN: [ChildKeys; 4] = [
ChildKeys {
scheme: Scheme::Http,
child: "http",
default_port: 8080,
hint: None,
},
ChildKeys {
scheme: Scheme::Https,
child: "https",
default_port: 8080,
hint: None,
},
ChildKeys {
scheme: Scheme::Ftp,
child: "ftp",
default_port: 8080,
hint: None,
},
ChildKeys {
scheme: Scheme::Socks,
child: "socks",
default_port: 1080,
hint: Some(ProxyScheme::Socks5),
},
];
#[derive(Clone, PartialEq, Eq)]
pub(crate) enum GValue {
Text(String),
Int(i32),
Flag(bool),
List(Vec<String>),
}
impl fmt::Debug for GValue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Text(text) => f
.debug_tuple("Text")
.field(&crate::util::redact_offending_token(text))
.finish(),
Self::Int(value) => f.debug_tuple("Int").field(value).finish(),
Self::Flag(value) => f.debug_tuple("Flag").field(value).finish(),
Self::List(values) => f.debug_tuple("List").field(values).finish(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct GnomeSettings {
entries: BTreeMap<String, GValue>,
written_values: BTreeSet<String>,
}
impl GnomeSettings {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn insert(&mut self, key: impl Into<String>, value: GValue) {
self.entries.insert(key.into(), value);
}
pub(crate) fn mark_written(&mut self, key: impl Into<String>) {
self.written_values.insert(key.into());
}
pub(crate) fn child_key(child: &str, key: &str) -> String {
format!("{child}.{key}")
}
fn has_written_value(&self) -> bool {
!self.written_values.is_empty()
}
fn text(&self, key: &str) -> Option<&str> {
match self.entries.get(key)? {
GValue::Text(text) => Some(text.trim()).filter(|text| !text.is_empty()),
_ => None,
}
}
fn flag(&self, key: &str) -> Option<bool> {
match self.entries.get(key)? {
GValue::Flag(flag) => Some(*flag),
_ => None,
}
}
fn port(&self, key: &str) -> Option<u16> {
match self.entries.get(key)? {
GValue::Int(value) => u16::try_from(*value).ok().filter(|port| *port != 0),
_ => None,
}
}
fn list(&self, key: &str) -> Option<&[String]> {
match self.entries.get(key)? {
GValue::List(items) => Some(items.as_slice()),
_ => None,
}
}
}
impl FromIterator<(String, GValue)> for GnomeSettings {
fn from_iter<I: IntoIterator<Item = (String, GValue)>>(iter: I) -> Self {
Self {
entries: iter.into_iter().collect(),
written_values: BTreeSet::new(),
}
}
}
pub(crate) fn configured_mode(settings: &GnomeSettings) -> Result<Option<ProxyMode>, Error> {
let mode = mode_from_settings(settings)?;
if settings.has_written_value() || !mode.is_direct() {
Ok(Some(mode))
} else {
Ok(None)
}
}
pub(crate) fn mode_from_settings(settings: &GnomeSettings) -> Result<ProxyMode, Error> {
match settings.text(KEY_MODE).unwrap_or(MODE_NONE) {
MODE_MANUAL => manual_mode(settings),
MODE_AUTO => match settings.text(KEY_AUTOCONFIG_URL) {
Some(url) => {
let parsed =
Url::parse(url).map_err(|source| Error::invalid_proxy_url(url, source))?;
Ok(ProxyMode::pac(parsed))
}
None => Ok(ProxyMode::WpadAutoDetect),
},
_ => Ok(ProxyMode::Direct),
}
}
fn manual_mode(settings: &GnomeSettings) -> Result<ProxyMode, Error> {
let mut per_scheme = HashMap::new();
let mut rejected = Vec::new();
let resolved: Vec<(Scheme, Option<ProxyEndpoint>)> = CHILDREN
.iter()
.map(|keys| (keys.scheme, endpoint_for(settings, keys, &mut rejected)))
.collect();
let endpoint_of = |wanted| {
resolved
.iter()
.find(|(scheme, _)| *scheme == wanted)
.and_then(|(_, endpoint)| endpoint.clone())
};
let socks = endpoint_of(Scheme::Socks);
let http = endpoint_of(Scheme::Http);
let socks_unusable = socks.is_none()
&& CHILDREN
.iter()
.find(|keys| keys.scheme == Scheme::Socks)
.is_some_and(|keys| {
settings
.text(&GnomeSettings::child_key(keys.child, KEY_HOST))
.is_some()
});
if settings.flag(KEY_USE_SAME_PROXY) == Some(true) {
if let Some(endpoint) = http.clone() {
per_scheme.insert(Scheme::All, ProxyEntry::Use(endpoint));
}
for (scheme, endpoint) in resolved
.iter()
.filter(|(scheme, _)| *scheme != Scheme::Http)
{
if let Some(endpoint) = endpoint {
per_scheme.insert(*scheme, ProxyEntry::Use(endpoint.clone()));
}
}
} else {
let inherited_https = match CHILDREN
.iter()
.find(|keys| keys.scheme == Scheme::Https)
.and_then(|keys| settings.text(&GnomeSettings::child_key(keys.child, KEY_HOST)))
{
Some(_) => None,
None => {
let lost = rejected
.iter()
.find(|value| value.affected_scheme() == Some(Scheme::Http))
.cloned();
if let Some(value) = lost {
rejected.push(value.for_scheme(Some(Scheme::Https)));
}
http.clone()
}
};
for (scheme, endpoint) in &resolved {
let endpoint = if endpoint.is_none() && *scheme == Scheme::Https {
&inherited_https
} else {
endpoint
};
match endpoint {
Some(endpoint) => {
per_scheme.insert(*scheme, ProxyEntry::Use(endpoint.clone()));
}
None if socks.is_none()
&& !socks_unusable
&& !rejected
.iter()
.any(|value| value.affected_scheme() == Some(*scheme)) =>
{
per_scheme.insert(*scheme, ProxyEntry::Disabled);
}
None => {}
}
}
}
if let Some(endpoint) = socks
&& !per_scheme.contains_key(&Scheme::All)
{
per_scheme.insert(Scheme::All, ProxyEntry::Use(endpoint));
}
if per_scheme.values().all(ProxyEntry::is_disabled) && rejected.is_empty() {
return Ok(ProxyMode::Direct);
}
Ok(ProxyMode::manual(per_scheme, bypass_from_settings(settings)?).with_rejected(rejected))
}
fn endpoint_for(
settings: &GnomeSettings,
keys: &ChildKeys,
rejected: &mut Vec<RejectedValue>,
) -> Option<ProxyEndpoint> {
let host = settings.text(&GnomeSettings::child_key(keys.child, KEY_HOST))?;
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 GNOME <child>.host"
);
let attributed_scheme = if keys.scheme == Scheme::Socks
|| (keys.scheme == Scheme::Http && settings.flag(KEY_USE_SAME_PROXY) == Some(true))
{
Scheme::All
} else {
keys.scheme
};
rejected.push(
RejectedValue::new(
RejectionKind::InvalidProxyEndpoint,
RejectionSource::GSettings(format!("{}.{}", keys.child, KEY_HOST)),
host,
)
.for_scheme(Some(attributed_scheme)),
);
return None;
}
};
if let Some(port) = settings.port(&GnomeSettings::child_key(keys.child, KEY_PORT)) {
endpoint.port = port;
}
if let Some(hint) = keys.hint
&& endpoint.scheme_hint.is_none()
{
endpoint = endpoint.with_scheme_hint(hint);
}
if keys.scheme == Scheme::Http
&& settings.flag(&GnomeSettings::child_key(
keys.child,
KEY_USE_AUTHENTICATION,
)) == Some(true)
&& let Some(user) = settings.text(&GnomeSettings::child_key(
keys.child,
KEY_AUTHENTICATION_USER,
))
{
let password_key = GnomeSettings::child_key(keys.child, KEY_AUTHENTICATION_PASSWORD);
let auth = ProxyAuth::new(user, settings.text(&password_key));
endpoint = endpoint.with_auth(if settings.entries.contains_key(&password_key) {
auth
} else {
auth.password_not_read()
});
}
Some(endpoint)
}
fn bypass_from_settings(settings: &GnomeSettings) -> Result<BypassRules, Error> {
let mut rules = match settings.list(KEY_IGNORE_HOSTS) {
Some(items) => {
parse::bypass_entries_in(items.iter().map(String::as_str), BypassDialect::Gnome)
}
None => BypassRules::new(),
};
rules.implicit = ImplicitBypass::Empty;
rules.ipv4_mapped_as_ipv4 = false;
rules.strip_trailing_dot = false;
rules.require_explicit_port = true;
Ok(rules)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::PasswordState;
macro_rules! settings {
($($key:literal => $value:expr),* $(,)?) => {{
#[allow(unused_mut)]
let mut settings = GnomeSettings::new();
$(settings.insert($key, $value.into_gvalue());)*
settings
}};
}
trait IntoGValue {
fn into_gvalue(self) -> GValue;
}
impl IntoGValue for &str {
fn into_gvalue(self) -> GValue {
GValue::Text(self.to_owned())
}
}
impl IntoGValue for i32 {
fn into_gvalue(self) -> GValue {
GValue::Int(self)
}
}
impl IntoGValue for bool {
fn into_gvalue(self) -> GValue {
GValue::Flag(self)
}
}
impl IntoGValue for &[&str] {
fn into_gvalue(self) -> GValue {
GValue::List(self.iter().map(|s| (*s).to_owned()).collect())
}
}
#[test]
fn every_gvalue_debug_names_the_type_it_erased() {
let rendered: Vec<String> = [
GValue::Text("proxy.corp".to_owned()),
GValue::Int(8080),
GValue::Flag(true),
GValue::List(vec!["localhost".to_owned()]),
]
.iter()
.map(|value| format!("{value:?}"))
.collect();
assert_eq!(
rendered,
[
"Text(\"proxy.corp\")",
"Int(8080)",
"Flag(true)",
"List([\"localhost\"])",
]
);
}
#[test]
fn an_empty_map_is_direct() {
assert_eq!(
mode_from_settings(&GnomeSettings::new()).unwrap(),
ProxyMode::Direct
);
}
#[test]
fn mode_none_ignores_every_other_key() {
let settings = settings! {
"mode" => "none",
"autoconfig-url" => "http://wpad.corp/proxy.pac",
"http.host" => "proxy.corp",
"http.port" => 8080i32,
};
assert_eq!(mode_from_settings(&settings).unwrap(), ProxyMode::Direct);
}
#[test]
fn an_unknown_mode_value_is_direct() {
let settings = settings! { "mode" => "something-new" };
assert_eq!(mode_from_settings(&settings).unwrap(), ProxyMode::Direct);
}
#[test]
fn mode_auto_with_a_url_is_pac() {
let settings = settings! {
"mode" => "auto",
"autoconfig-url" => "http://wpad.corp/proxy.pac",
};
match mode_from_settings(&settings).unwrap() {
ProxyMode::Pac { url, .. } => assert_eq!(url.as_str(), "http://wpad.corp/proxy.pac"),
other => panic!("expected Pac, got {other:?}"),
}
}
#[test]
fn mode_auto_without_a_url_is_wpad() {
let settings = settings! { "mode" => "auto", "autoconfig-url" => "" };
assert_eq!(
mode_from_settings(&settings).unwrap(),
ProxyMode::WpadAutoDetect
);
}
#[test]
fn a_malformed_autoconfig_url_is_an_error() {
let settings = settings! { "mode" => "auto", "autoconfig-url" => "not a url" };
assert!(matches!(
mode_from_settings(&settings),
Err(Error::InvalidProxyUrl { .. })
));
}
#[test]
fn manual_without_any_host_is_direct() {
let settings = settings! {
"mode" => "manual",
"http.host" => "",
"http.port" => 8080i32,
"https.host" => "",
};
assert_eq!(mode_from_settings(&settings).unwrap(), ProxyMode::Direct);
}
#[test]
fn manual_maps_each_child_onto_its_scheme() {
let settings = settings! {
"mode" => "manual",
"use-same-proxy" => false,
"http.host" => "http.corp",
"http.port" => 3128i32,
"https.host" => "https.corp",
"https.port" => 3129i32,
"ftp.host" => "ftp.corp",
"ftp.port" => 3130i32,
"socks.host" => "socks.corp",
"socks.port" => 1081i32,
};
let mode = mode_from_settings(&settings).unwrap();
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"
);
let socks = mode.endpoint_for(Scheme::Socks).unwrap();
assert_eq!(socks.authority(), "socks.corp:1081");
assert_eq!(socks.scheme_hint, Some(ProxyScheme::Socks5));
}
#[test]
fn malformed_child_hosts_are_dropped_and_recorded() {
for label in ["mixed", "use_same_proxy", "lone_socks"] {
let settings = match label {
"mixed" => settings! {
"mode" => "manual",
"use-same-proxy" => false,
"http.host" => "http.corp",
"https.host" => "not a host with spaces",
"ftp.host" => "ftp.corp",
},
"use_same_proxy" => settings! {
"mode" => "manual",
"use-same-proxy" => true,
"http.host" => "not a host with spaces",
},
"lone_socks" => settings! {
"mode" => "manual",
"use-same-proxy" => false,
"socks.host" => "not a host with spaces",
},
_ => unreachable!(),
};
let mode = mode_from_settings(&settings).unwrap();
match label {
"mixed" => {
assert_eq!(
mode.endpoint_for(Scheme::Http).unwrap().authority(),
"http.corp:8080"
);
assert_eq!(
mode.endpoint_for(Scheme::Ftp).unwrap().authority(),
"ftp.corp:8080"
);
assert!(mode.endpoint_for(Scheme::Https).is_none());
assert_eq!(
mode.rejected().unwrap()[0].redacted_input(),
"not a host with spaces"
);
}
"use_same_proxy" => {
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"
);
}
"lone_socks" => {
assert!(mode.endpoint_for(Scheme::Socks).is_none());
assert!(mode.endpoint_for(Scheme::Http).is_none());
assert_eq!(
mode.rejected().unwrap()[0].redacted_input(),
"not a host with spaces"
);
}
_ => unreachable!(),
}
}
}
#[cfg(feature = "resolve")]
#[test]
fn an_unusable_socks_child_is_reported_for_a_scheme_it_would_have_covered() {
let settings = settings! {
"mode" => "manual",
"use-same-proxy" => false,
"socks.host" => "not a host with spaces",
};
let config = crate::ProxyConfig::new(mode_from_settings(&settings).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_unusable_http_child_is_reported_for_the_schemes_use_same_proxy_gave_it() {
let settings = settings! {
"mode" => "manual",
"use-same-proxy" => true,
"http.host" => "not a host with spaces",
};
let config = crate::ProxyConfig::new(mode_from_settings(&settings).unwrap(), Vec::new());
for url in ["https://intranet.corp/x", "ftp://intranet.corp/x"] {
let err = crate::resolve(&config, &url::Url::parse(url).unwrap()).unwrap_err();
assert!(
matches!(&err, crate::Error::ProxyEntryUnusable { scheme, .. }
if *scheme == Scheme::All),
"{url}: {err:?}"
);
}
}
#[cfg(feature = "resolve")]
#[test]
fn a_schema_that_never_declared_use_same_proxy_is_not_a_catch_all() {
let settings = settings! {
"mode" => "manual",
"http.host" => "not a host with spaces",
};
let config = crate::ProxyConfig::new(mode_from_settings(&settings).unwrap(), Vec::new());
for (url, expected) in [
("http://intranet.corp/x", Scheme::Http),
("https://intranet.corp/x", Scheme::Https),
] {
let err = crate::resolve(&config, &url::Url::parse(url).unwrap()).unwrap_err();
assert!(
matches!(&err, crate::Error::ProxyEntryUnusable { scheme, .. }
if *scheme == expected),
"{url}: {err:?}"
);
}
assert_eq!(
crate::resolve(&config, &url::Url::parse("ftp://intranet.corp/x").unwrap()).unwrap(),
vec![crate::ProxyStep::Direct]
);
}
#[cfg(feature = "resolve")]
#[test]
fn a_child_whose_own_host_was_rejected_is_not_recorded_as_disabled() {
let settings = settings! {
"mode" => "manual",
"use-same-proxy" => false,
"http.host" => "http.corp",
"https.host" => "not a host with spaces",
};
let config = crate::ProxyConfig::new(mode_from_settings(&settings).unwrap(), 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::Https),
"{err:?}"
);
}
#[cfg(feature = "resolve")]
#[test]
fn an_unusable_http_child_is_reported_for_the_https_that_would_have_inherited_it() {
let settings = settings! {
"mode" => "manual",
"use-same-proxy" => false,
"http.host" => "not a host with spaces",
};
let config = crate::ProxyConfig::new(mode_from_settings(&settings).unwrap(), Vec::new());
for (url, expected) in [
("http://intranet.corp/x", Scheme::Http),
("https://intranet.corp/x", Scheme::Https),
] {
let err = crate::resolve(&config, &url::Url::parse(url).unwrap()).unwrap_err();
assert!(
matches!(&err, crate::Error::ProxyEntryUnusable { scheme, .. }
if *scheme == expected),
"{url}: {err:?}"
);
}
let url = url::Url::parse("ftp://intranet.corp/x").unwrap();
let steps = crate::resolve(&config, &url).unwrap();
assert!(
matches!(steps.as_slice(), [crate::ProxyStep::Direct]),
"{steps:?}"
);
}
#[cfg(feature = "resolve")]
#[test]
fn the_ftp_child_is_reported_as_an_http_hop() {
let settings = settings! {
"mode" => "manual",
"use-same-proxy" => false,
"ftp.host" => "ftp-proxy.corp",
"ftp.port" => 2121i32,
};
let config = crate::ProxyConfig::new(mode_from_settings(&settings).unwrap(), Vec::new());
let url = url::Url::parse("ftp://files.example/x").unwrap();
let steps = crate::resolve(&config, &url).unwrap();
assert!(
matches!(steps.as_slice(), [crate::ProxyStep::Http(endpoint)]
if endpoint.authority() == "ftp-proxy.corp:2121"),
"{steps:?}"
);
}
#[test]
fn only_the_http_drop_is_copied_onto_an_inheriting_https() {
let settings = settings! {
"mode" => "manual",
"use-same-proxy" => false,
"http.host" => "http.corp",
"ftp.host" => "not a host with spaces",
};
let mode = mode_from_settings(&settings).unwrap();
let rejected = mode.rejected().expect("manual carries a list");
assert_eq!(rejected.len(), 1, "{rejected:?}");
assert_eq!(rejected[0].affected_scheme(), Some(Scheme::Ftp));
assert_eq!(
rejected[0].source(),
&RejectionSource::GSettings("ftp.host".to_owned())
);
}
#[test]
fn a_zero_port_falls_back_to_the_source_default() {
let settings = settings! {
"mode" => "manual",
"http.host" => "proxy.corp",
"http.port" => 0i32,
"https.host" => "secure.corp",
"https.port" => 0i32,
"ftp.host" => "files.corp",
"ftp.port" => 0i32,
"socks.host" => "socks.corp",
"socks.port" => 0i32,
};
let mode = mode_from_settings(&settings).unwrap();
assert_eq!(mode.endpoint_for(Scheme::Http).unwrap().port, 8080);
assert_eq!(mode.endpoint_for(Scheme::Https).unwrap().port, 8080);
assert_eq!(mode.endpoint_for(Scheme::Ftp).unwrap().port, 8080);
assert_eq!(mode.endpoint_for(Scheme::Socks).unwrap().port, 1080);
}
#[test]
fn a_child_without_a_host_is_disabled_and_suppresses_the_all_fallback() {
let settings = settings! {
"mode" => "manual",
"use-same-proxy" => false,
"http.host" => "proxy.corp",
"ftp.host" => "",
};
let mode = mode_from_settings(&settings).unwrap();
assert!(
mode.entry_for(Scheme::Ftp)
.expect("ftp entry")
.is_disabled()
);
assert!(mode.endpoint_for(Scheme::Ftp).is_none());
}
#[test]
fn a_host_key_is_trimmed_and_blanks_read_as_unset() {
let settings = settings! {
"mode" => "manual",
"http.host" => " proxy.corp ",
"https.host" => " ",
};
let mode = mode_from_settings(&settings).unwrap();
for scheme in [Scheme::Http, Scheme::Https] {
assert_eq!(
mode.endpoint_for(scheme).unwrap().authority(),
"proxy.corp:8080",
"{scheme}"
);
}
assert!(mode.rejected().expect("manual carries a list").is_empty());
}
#[test]
fn a_socks_child_catches_every_scheme_that_named_nothing() {
for same_proxy in [true, false] {
let settings = settings! {
"mode" => "manual",
"use-same-proxy" => same_proxy,
"socks.host" => "socks.corp",
"socks.port" => 1081i32,
};
let mode = mode_from_settings(&settings).unwrap();
for scheme in [Scheme::Http, Scheme::Https, Scheme::Ftp, Scheme::Socks] {
let endpoint = mode.endpoint_for(scheme).unwrap_or_else(|| {
panic!("{scheme} should fall back to the SOCKS proxy (same_proxy={same_proxy})")
});
assert_eq!(endpoint.authority(), "socks.corp:1081");
assert_eq!(
endpoint.scheme_hint,
Some(ProxyScheme::Socks5),
"{scheme} must keep the SOCKS5 hint after falling back"
);
}
}
}
#[test]
fn socks_alongside_http_still_catches_the_rest() {
let settings = settings! {
"mode" => "manual",
"use-same-proxy" => false,
"http.host" => "http.corp",
"http.port" => 3128i32,
"socks.host" => "socks.corp",
"socks.port" => 1081i32,
};
let mode = mode_from_settings(&settings).unwrap();
assert_eq!(
mode.endpoint_for(Scheme::Http).unwrap().authority(),
"http.corp:3128",
"a child with its own host is never overridden by the catch-all"
);
assert_eq!(
mode.endpoint_for(Scheme::Socks).unwrap().authority(),
"socks.corp:1081"
);
assert_eq!(
mode.endpoint_for(Scheme::Ftp).unwrap().authority(),
"socks.corp:1081",
"ftp named no proxy, so the SOCKS child carries it"
);
assert_eq!(
mode.endpoint_for(Scheme::Https).unwrap().authority(),
"http.corp:3128"
);
}
#[test]
fn an_absent_https_child_inherits_the_http_proxy_and_ftp_does_not() {
let settings = settings! {
"mode" => "manual",
"use-same-proxy" => false,
"http.host" => "http.corp",
"http.port" => 3128i32,
};
let mode = mode_from_settings(&settings).unwrap();
assert_eq!(
mode.endpoint_for(Scheme::Https).unwrap().authority(),
"http.corp:3128"
);
assert!(
mode.entry_for(Scheme::Ftp)
.expect("ftp entry")
.is_disabled()
);
assert!(mode.endpoint_for(Scheme::Ftp).is_none());
}
#[test]
fn use_same_proxy_puts_the_http_child_under_all() {
let settings = settings! {
"mode" => "manual",
"use-same-proxy" => true,
"http.host" => "proxy.corp",
"http.port" => 8080i32,
};
let mode = mode_from_settings(&settings).unwrap();
for scheme in [Scheme::Http, Scheme::Https, Scheme::Ftp, Scheme::Socks] {
assert_eq!(
mode.endpoint_for(scheme).unwrap().authority(),
"proxy.corp:8080",
"{scheme} should fall back to the shared proxy"
);
}
let ProxyMode::Manual { per_scheme, .. } = &mode else {
panic!("{mode:?}");
};
assert_eq!(per_scheme.keys().collect::<Vec<_>>(), [&Scheme::All]);
}
#[test]
fn a_child_with_its_own_host_overrides_use_same_proxy() {
let settings = settings! {
"mode" => "manual",
"use-same-proxy" => true,
"http.host" => "proxy.corp",
"socks.host" => "socks.corp",
"socks.port" => 1081i32,
};
let mode = mode_from_settings(&settings).unwrap();
assert_eq!(
mode.endpoint_for(Scheme::Https).unwrap().authority(),
"proxy.corp:8080"
);
assert_eq!(
mode.endpoint_for(Scheme::Socks).unwrap().authority(),
"socks.corp:1081",
"a concrete scheme always wins over All"
);
}
#[test]
fn ipv6_hosts_are_bracketed_back() {
let settings = settings! {
"mode" => "manual",
"socks.host" => "::1",
"socks.port" => 1081i32,
};
let mode = mode_from_settings(&settings).unwrap();
assert_eq!(
mode.endpoint_for(Scheme::Socks).unwrap().authority(),
"[::1]:1081"
);
}
#[test]
fn a_host_stored_as_a_url_keeps_its_scheme_hint() {
let settings = settings! {
"mode" => "manual",
"socks.host" => "socks4://socks.corp",
};
let mode = mode_from_settings(&settings).unwrap();
let socks = mode.endpoint_for(Scheme::Socks).unwrap();
assert_eq!(
socks.scheme_hint,
Some(ProxyScheme::Socks4),
"an explicit scheme must not be overwritten by the SOCKS5 default hint"
);
}
#[test]
fn ignore_hosts_becomes_bypass_rules() {
let list: &[&str] = &["localhost", "10.0.0.0/8", "::1", "*.corp.example"];
let settings = settings! {
"mode" => "manual",
"http.host" => "proxy.corp",
"ignore-hosts" => list,
};
let mode = mode_from_settings(&settings).unwrap();
let bypass = mode.bypass().expect("manual mode has bypass rules");
assert!(bypass.matches_authority("10.1.2.3"));
assert!(!bypass.matches_authority("11.1.2.3"));
assert!(bypass.matches_authority("api.corp.example"));
assert!(bypass.matches_authority("corp.example"));
assert!(!bypass.matches_authority("example.net"));
assert!(!bypass.matches_authority("xcorp.example"));
}
#[test]
fn gnome_reads_a_star_literally_and_trims_only_the_tail() {
let list: &[&str] = &[
"*",
"foo*.corp.example",
"*.bar*.corp.example",
" lead.example",
"trail.example ",
".dot.example",
];
let settings = settings! {
"mode" => "manual",
"http.host" => "proxy.corp",
"ignore-hosts" => list,
};
let mode = mode_from_settings(&settings).unwrap();
let bypass = mode.bypass().expect("manual mode has bypass rules");
for host in [
"foo1.corp.example",
"foo.corp.example",
"bar1.corp.example",
"unrelated.example",
] {
assert!(
!bypass.matches_authority(host),
"{host} is not a GNOME rule"
);
}
assert!(!bypass.matches_authority("lead.example"));
assert!(bypass.matches_authority("trail.example"));
assert!(bypass.matches_authority("api.trail.example"));
assert!(bypass.matches_authority("dot.example"));
assert!(bypass.matches_authority("api.dot.example"));
let refused: Vec<_> = bypass
.rejected
.iter()
.map(RejectedValue::redacted_input)
.collect();
assert_eq!(
refused,
[
"*",
"foo*.corp.example",
"*.bar*.corp.example",
" lead.example"
]
);
}
#[test]
#[cfg(feature = "resolve")]
fn ignore_hosts_entries_glib_never_matches_are_refused() {
let rows: &[(&[&str], &str)] = &[
(&["", "example.com"], "http://example.com/"),
(&["*.", "example.com"], "http://example.com/"),
(&["192.168.1.5/24"], "http://192.168.1.77/"),
(&["010.0.0.0/8"], "http://10.1.2.3/"),
(&["[2001:db8::1]"], "http://[2001:db8::1]/"),
(&["example.com."], "http://example.com/"),
(&["日本.example"], "http://日本.example/"),
(&["example.com\u{a0}"], "http://example.com/"),
(&["192.168.1"], "http://192.168.0.1/"),
(&["010.0.0.1"], "http://10.0.0.1/"),
(&["example.com\u{0b}"], "http://example.com/"),
(&["<local>"], "http://intranet/"),
(&["<-loopback>", "<LOCAL>"], "http://intranet/"),
(&["<-LOOPBACK>"], "http://localhost/"),
];
for (list, url) in rows {
let settings = settings! {
"mode" => "manual",
"http.host" => "proxy.corp",
"http.port" => 3128,
"ignore-hosts" => *list,
};
let mode = mode_from_settings(&settings).unwrap();
assert!(
!mode.bypass().unwrap().rejected.is_empty(),
"{list:?}: a refused entry is listed, not dropped"
);
let config = crate::ProxyConfig::new(mode.clone(), Vec::new());
let steps = crate::resolve(&config, &url::Url::parse(url).unwrap()).unwrap();
assert_ne!(steps, vec![crate::ProxyStep::Direct], "{list:?} {url}");
}
let settings = settings! {
"mode" => "manual",
"http.host" => "proxy.corp",
"http.port" => 3128,
"ignore-hosts" => &["::ffff:10.0.0.0/104"][..],
};
let config = crate::ProxyConfig::new(mode_from_settings(&settings).unwrap(), Vec::new());
let url = url::Url::parse("http://10.1.2.3/").unwrap();
assert_ne!(
crate::resolve(&config, &url).unwrap(),
vec![crate::ProxyStep::Direct]
);
let mapped = url::Url::parse("http://[::ffff:10.1.2.3]/").unwrap();
assert_eq!(
crate::resolve(&config, &mapped).unwrap(),
vec![crate::ProxyStep::Direct]
);
for (list, url) in [
(&["example.com"][..], "http://example.com/"),
(&["2001:db8::1"][..], "http://[2001:db8::1]/"),
(&["10.0.0.0/8"][..], "http://10.1.2.3/"),
(&["[2001:db8::1]:8080"][..], "http://[2001:db8::1]:8080/"),
(&["", "10.0.0.0/8"][..], "http://10.1.2.3/"),
(&["example.com\t\r\n"][..], "http://example.com/"),
] {
let settings = settings! {
"mode" => "manual",
"http.host" => "proxy.corp",
"http.port" => 3128,
"ignore-hosts" => list,
};
let mode = mode_from_settings(&settings).unwrap();
let config = crate::ProxyConfig::new(mode, Vec::new());
let steps = crate::resolve(&config, &url::Url::parse(url).unwrap()).unwrap();
assert_eq!(steps, vec![crate::ProxyStep::Direct], "{list:?} {url}");
}
}
#[test]
fn ignore_hosts_carries_no_implicit_bypass() {
for list in [None, Some(&[][..]), Some(&["example.com"][..])] {
let mut settings = settings! {
"mode" => "manual",
"http.host" => "proxy.corp",
"http.port" => 3128,
};
if let Some(list) = list {
settings.insert(KEY_IGNORE_HOSTS, list.into_gvalue());
}
let mode = mode_from_settings(&settings).unwrap();
let rules = mode.bypass().unwrap();
assert_eq!(rules.implicit, ImplicitBypass::Empty, "{list:?}");
for destination in ["localhost", "127.0.0.1", "[::1]", "169.254.169.254"] {
assert!(
!rules.matches_authority(destination),
"{list:?} {destination}"
);
}
}
let settings = settings! {
"mode" => "manual",
"http.host" => "proxy.corp",
"http.port" => 3128,
"ignore-hosts" => &["localhost", "127.0.0.0/8", "::1"][..],
};
let mode = mode_from_settings(&settings).unwrap();
let rules = mode.bypass().unwrap();
for destination in ["localhost", "127.0.0.1", "127.0.0.2", "[::1]"] {
assert!(rules.matches_authority(destination), "{destination}");
}
assert!(!rules.matches_authority("[::ffff:127.0.0.1]"));
assert!(!rules.matches_authority("169.254.169.254"));
assert!(!rules.strip_trailing_dot);
assert!(!rules.matches_authority("localhost."));
}
#[test]
fn ignore_hosts_asks_resolve_not_to_infer_a_port() {
let list: &[&str] = &["intranet.corp:80"];
let settings = settings! {
"mode" => "manual",
"http.host" => "proxy.corp",
"ignore-hosts" => list,
};
let mode = mode_from_settings(&settings).unwrap();
assert!(mode.bypass().unwrap().require_explicit_port);
assert!(!parse::proxy_override("intranet.corp:80").require_explicit_port);
assert!(!parse::no_proxy("intranet.corp:80").require_explicit_port);
}
#[test]
fn a_separator_inside_one_ignore_hosts_element_is_not_an_entry_boundary() {
let list: &[&str] = &["a.example,b.example", "c.example;d.example"];
let settings = settings! {
"mode" => "manual",
"http.host" => "proxy.corp",
"ignore-hosts" => list,
};
let mode = mode_from_settings(&settings).unwrap();
let bypass = mode.bypass().expect("manual mode has bypass rules");
for host in ["a.example", "b.example", "c.example", "d.example"] {
assert!(
!bypass.matches_authority(host),
"{host} is half of an element, not an entry of its own"
);
}
assert!(bypass.matches_authority("a.example,b.example"));
assert!(bypass.matches_authority("c.example;d.example"));
}
#[test]
fn authentication_needs_the_flag_and_the_user() {
let without_flag = settings! {
"mode" => "manual",
"http.host" => "proxy.corp",
"http.authentication-user" => "alice",
};
assert!(
mode_from_settings(&without_flag)
.unwrap()
.endpoint_for(Scheme::Http)
.unwrap()
.auth
.is_none(),
"use-authentication = false must not produce credentials"
);
let with_flag = settings! {
"mode" => "manual",
"http.host" => "proxy.corp",
"http.use-authentication" => true,
"http.authentication-user" => "alice",
};
let auth = mode_from_settings(&with_flag)
.unwrap()
.endpoint_for(Scheme::Http)
.unwrap()
.auth
.clone()
.expect("authentication-user");
assert_eq!(auth.username(), "alice");
assert!(
!auth.has_password(),
"nothing supplied a password here and this mapper does not invent one. That \
the live reader never supplies one is `gnome::READ_AUTHENTICATION_PASSWORD`, \
which no test on this side can reach; the next test shows this mapper \
maps a password perfectly well once something hands it one"
);
}
#[test]
fn an_opted_in_password_is_masked_in_debug() {
let settings = settings! {
"mode" => "manual",
"http.host" => "proxy.corp",
"http.use-authentication" => true,
"http.authentication-user" => "alice",
"http.authentication-password" => "hunter2",
};
let mode = mode_from_settings(&settings).unwrap();
let auth = mode.endpoint_for(Scheme::Http).unwrap().auth.clone();
assert_eq!(auth.as_ref().unwrap().password(), Some("hunter2"));
assert_eq!(
auth.as_ref().unwrap().password_state(),
PasswordState::Present
);
assert!(
!format!("{mode:?}").contains("hunter2"),
"a password must never reach a Debug rendering"
);
}
#[test]
fn a_missing_password_says_whether_it_was_read() {
let state = |settings: &GnomeSettings| {
mode_from_settings(settings)
.unwrap()
.endpoint_for(Scheme::Http)
.unwrap()
.auth
.as_ref()
.unwrap()
.password_state()
};
let unread = settings! {
"mode" => "manual",
"http.host" => "proxy.corp",
"http.use-authentication" => true,
"http.authentication-user" => "alice",
};
assert_eq!(state(&unread), PasswordState::NotRead);
let empty = settings! {
"mode" => "manual",
"http.host" => "proxy.corp",
"http.use-authentication" => true,
"http.authentication-user" => "alice",
"http.authentication-password" => "",
};
assert_eq!(state(&empty), PasswordState::Absent);
}
#[test]
fn a_store_nobody_wrote_to_and_no_proxy_is_unset() {
let settings = settings! { "mode" => "none", "http.host" => "", "http.port" => 0i32 };
assert_eq!(configured_mode(&settings).unwrap(), None);
assert_eq!(configured_mode(&GnomeSettings::new()).unwrap(), None);
}
#[test]
fn an_explicit_mode_none_is_configured_direct() {
let mut settings = settings! { "mode" => "none" };
settings.mark_written("mode");
assert_eq!(
configured_mode(&settings).unwrap(),
Some(ProxyMode::Direct),
"somebody wrote mode='none'; that is a decision, not an absent store"
);
}
#[test]
fn a_written_value_on_any_key_configures_the_store() {
let mut settings = settings! { "mode" => "none", "http.host" => "proxy.corp" };
settings.mark_written("http.host");
assert_eq!(configured_mode(&settings).unwrap(), Some(ProxyMode::Direct));
}
#[test]
fn a_proxy_nobody_marked_as_written_is_still_configured() {
let settings = settings! { "mode" => "manual", "http.host" => "proxy.corp" };
let mode = configured_mode(&settings)
.unwrap()
.expect("a real proxy is always configured");
assert_eq!(
mode.endpoint_for(Scheme::Http).unwrap().authority(),
"proxy.corp:8080"
);
}
#[test]
fn authentication_only_applies_to_the_http_child() {
let settings = settings! {
"mode" => "manual",
"use-same-proxy" => false,
"http.host" => "proxy.corp",
"https.host" => "secure.corp",
"http.use-authentication" => true,
"http.authentication-user" => "alice",
};
let mode = mode_from_settings(&settings).unwrap();
assert!(mode.endpoint_for(Scheme::Http).unwrap().auth.is_some());
assert!(mode.endpoint_for(Scheme::Https).unwrap().auth.is_none());
let spelled_for_another_child = settings! {
"mode" => "manual",
"use-same-proxy" => false,
"https.host" => "secure.corp",
"https.use-authentication" => true,
"https.authentication-user" => "alice",
};
assert!(
mode_from_settings(&spelled_for_another_child)
.unwrap()
.endpoint_for(Scheme::Https)
.unwrap()
.auth
.is_none()
);
}
}