use url::Url;
use crate::config::ProxyConfig;
use crate::endpoint::{ProxyEndpoint, ProxyEntry, ProxyScheme, Scheme, has_request_host};
use crate::error::Error;
use crate::mode::ProxyMode;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ProxyStep {
Direct,
Http(ProxyEndpoint),
Https(ProxyEndpoint),
Socks4(ProxyEndpoint),
Socks5(ProxyEndpoint),
}
impl ProxyStep {
#[must_use]
pub fn is_direct(&self) -> bool {
matches!(self, ProxyStep::Direct)
}
#[must_use]
pub fn endpoint(&self) -> Option<&ProxyEndpoint> {
match self {
ProxyStep::Direct => None,
ProxyStep::Http(endpoint)
| ProxyStep::Https(endpoint)
| ProxyStep::Socks4(endpoint)
| ProxyStep::Socks5(endpoint) => Some(endpoint),
}
}
#[must_use]
pub fn scheme(&self) -> Option<&'static str> {
match self {
ProxyStep::Direct => None,
ProxyStep::Http(_) => Some("http"),
ProxyStep::Https(_) => Some("https"),
ProxyStep::Socks4(_) => Some("socks4"),
ProxyStep::Socks5(_) => Some("socks5"),
}
}
#[must_use]
pub fn to_url(&self) -> Option<Url> {
let endpoint = self.endpoint()?;
let scheme = self.url_scheme(endpoint);
crate::endpoint::parse_host(&endpoint.host.to_string()).ok()?;
let mut url = Url::parse(&format!("{scheme}://{}", endpoint.authority())).ok()?;
if let Some(auth) = &endpoint.auth {
url.set_username(auth.username()).ok()?;
if let Some(password) = auth.password() {
url.set_password(Some(password)).ok()?;
}
}
Some(url)
}
fn url_scheme(&self, endpoint: &ProxyEndpoint) -> &'static str {
match (self, endpoint.scheme_hint) {
(ProxyStep::Socks4(_), Some(ProxyScheme::Socks4a)) => "socks4a",
(ProxyStep::Socks5(_), Some(ProxyScheme::Socks5h)) => "socks5h",
_ => self.scheme().unwrap_or(""),
}
}
pub(crate) fn from_endpoint(endpoint: ProxyEndpoint) -> Self {
match endpoint.scheme_hint {
None | Some(ProxyScheme::Http) => ProxyStep::Http(endpoint),
Some(ProxyScheme::Https) => ProxyStep::Https(endpoint),
Some(ProxyScheme::Socks4 | ProxyScheme::Socks4a) => ProxyStep::Socks4(endpoint),
Some(ProxyScheme::Socks5 | ProxyScheme::Socks5h) => ProxyStep::Socks5(endpoint),
}
}
}
pub fn resolve(config: &ProxyConfig, url: &Url) -> Result<Vec<ProxyStep>, Error> {
resolve_mode(&config.effective, url)
}
fn resolve_mode(mode: &ProxyMode, url: &Url) -> Result<Vec<ProxyStep>, Error> {
match mode {
ProxyMode::Direct => return Ok(vec![ProxyStep::Direct]),
ProxyMode::Pac { .. } => return Err(Error::PacNotSupported { mode: "pac" }),
ProxyMode::PacInline { .. } => {
return Err(Error::PacNotSupported { mode: "pac-inline" });
}
ProxyMode::WpadAutoDetect => return Err(Error::PacNotSupported { mode: "wpad" }),
ProxyMode::Manual { .. } => {}
}
if !has_request_host(url) {
return Ok(vec![ProxyStep::Direct]);
}
if let Some(bypass) = mode.bypass()
&& bypass.matches_url(url)
{
return Ok(vec![ProxyStep::Direct]);
}
let entry = if matches!(url.scheme(), "ws" | "wss") {
mode.websocket_entry()
} else {
mode.entry_for(request_scheme(url))
};
match entry {
Some(ProxyEntry::Use(endpoint)) => Ok(vec![ProxyStep::from_endpoint(endpoint.clone())]),
Some(ProxyEntry::Disabled) => Ok(vec![ProxyStep::Direct]),
Some(ProxyEntry::Unusable(rejected)) => Err(Error::ProxyEntryUnusable {
scheme: rejected.affected_scheme().unwrap_or(Scheme::All),
rejected: rejected.clone(),
}),
None => Ok(vec![ProxyStep::Direct]),
}
}
#[cfg(feature = "pac")]
pub fn resolve_with_pac(
config: &ProxyConfig,
url: &Url,
script: Option<&crate::pac::PacScript>,
policy: &crate::pac::PacPolicy,
) -> Result<Vec<ProxyStep>, Error> {
resolve_with_pac_using(config, url, script, |script| {
crate::pac::evaluate(script, url, policy)
})
}
#[cfg(feature = "pac")]
pub(crate) fn resolve_with_pac_using(
config: &ProxyConfig,
url: &Url,
script: Option<&crate::pac::PacScript>,
run: impl FnOnce(&crate::pac::PacScript) -> Result<Vec<ProxyStep>, Error>,
) -> Result<Vec<ProxyStep>, Error> {
use crate::pac::PacScript;
let mode = &config.effective;
if matches!(mode, ProxyMode::Direct | ProxyMode::Manual { .. }) {
return resolve_mode(mode, url);
}
if !has_request_host(url) {
return Ok(vec![ProxyStep::Direct]);
}
let from_mode;
let script = match script {
Some(script) => script,
None => match mode {
ProxyMode::PacInline { script, .. } => {
from_mode = PacScript::new(script.clone());
&from_mode
}
ProxyMode::Pac { url, .. } => return Err(Error::PacFetchRequired { url: url.clone() }),
ProxyMode::WpadAutoDetect => return Err(Error::PacNotSupported { mode: "wpad" }),
_ => return Err(Error::PacNotSupported { mode: "unknown" }),
},
};
run(script)
}
fn request_scheme(url: &Url) -> Scheme {
match url.scheme() {
"http" => Scheme::Http,
"https" => Scheme::Https,
"ftp" => Scheme::Ftp,
"socks" | "socks4" | "socks4a" | "socks5" | "socks5h" => Scheme::Socks,
_ => Scheme::All,
}
}
#[cfg(test)]
mod tests {
use super::*;
use url::Host;
use crate::auth::ProxyAuth;
use crate::config::ProxyConfigSource;
use crate::diagnostic::RejectedValue;
#[test]
fn a_bypass_list_that_wants_an_explicit_port_does_not_get_a_default_one() {
let manual = |bypass| ProxyMode::Manual {
per_scheme: std::collections::HashMap::from([(
Scheme::All,
ProxyEntry::Use(ProxyEndpoint::new(
Host::Domain("proxy.corp".to_owned()),
8080,
)),
)]),
bypass,
rejected: Vec::new(),
};
let bare = Url::parse("http://intranet.corp/").unwrap();
let ported = Url::parse("http://intranet.corp:8081/").unwrap();
let proxied = |mode: &ProxyMode, url: &Url| {
resolve_mode(mode, url).unwrap()[0]
.endpoint()
.map(ProxyEndpoint::authority)
};
let usual = manual(crate::parse::no_proxy("intranet.corp:80"));
assert_eq!(
resolve_mode(&usual, &bare).unwrap(),
vec![ProxyStep::Direct]
);
let mut rules = crate::parse::no_proxy("intranet.corp:80");
rules.require_explicit_port = true;
assert_eq!(
proxied(&manual(rules), &bare).as_deref(),
Some("proxy.corp:8080")
);
let mut rules = crate::parse::no_proxy("intranet.corp:8081");
rules.require_explicit_port = true;
let gnome = manual(rules);
assert_eq!(
resolve_mode(&gnome, &ported).unwrap(),
vec![ProxyStep::Direct]
);
assert_eq!(proxied(&gnome, &bare).as_deref(), Some("proxy.corp:8080"));
let mut default_port = crate::parse::no_proxy("intranet.corp:80");
default_port.require_explicit_port = true;
assert_eq!(
proxied(
&manual(default_port),
&Url::parse("http://intranet.corp:80/").unwrap()
)
.as_deref(),
Some("proxy.corp:8080")
);
let mut portless = crate::parse::no_proxy("intranet.corp");
portless.require_explicit_port = true;
assert_eq!(
resolve_mode(&manual(portless), &bare).unwrap(),
vec![ProxyStep::Direct]
);
}
#[test]
fn scheme_hints_pick_the_step_variant() {
let base = ProxyEndpoint::new(Host::Domain("p".to_owned()), 1080);
let with = |hint| ProxyStep::from_endpoint(base.clone().with_scheme_hint(hint));
assert!(matches!(
ProxyStep::from_endpoint(base.clone()),
ProxyStep::Http(_)
));
assert!(matches!(with(ProxyScheme::Http), ProxyStep::Http(_)));
assert!(matches!(with(ProxyScheme::Https), ProxyStep::Https(_)));
assert!(matches!(with(ProxyScheme::Socks4), ProxyStep::Socks4(_)));
assert!(matches!(with(ProxyScheme::Socks4a), ProxyStep::Socks4(_)));
assert!(matches!(with(ProxyScheme::Socks5), ProxyStep::Socks5(_)));
assert!(matches!(with(ProxyScheme::Socks5h), ProxyStep::Socks5(_)));
}
#[test]
fn to_url_keeps_an_ipv6_proxy_in_brackets() {
let endpoint = ProxyEndpoint::parse("http://[2001:db8::1]:8080", 80).unwrap();
let url = ProxyStep::Http(endpoint).to_url().unwrap();
assert_eq!(url.as_str(), "http://[2001:db8::1]:8080/");
let endpoint = ProxyEndpoint::parse("socks5h://[::1]", 80).unwrap();
let url = ProxyStep::Socks5(endpoint).to_url().unwrap();
assert_eq!(url.as_str(), "socks5h://[::1]:1080");
}
#[test]
fn to_url_preserves_the_socks_remote_dns_hint() {
let endpoint = ProxyEndpoint::parse("socks5h://proxy.example:1080", 80).unwrap();
let url = ProxyStep::Socks5(endpoint).to_url().unwrap();
assert_eq!(url.scheme(), "socks5h");
assert_eq!(url.as_str(), "socks5h://proxy.example:1080");
let endpoint = ProxyEndpoint::parse("socks4a://proxy.example:1080", 80).unwrap();
let url = ProxyStep::Socks4(endpoint).to_url().unwrap();
assert_eq!(url.scheme(), "socks4a");
assert_eq!(url.as_str(), "socks4a://proxy.example:1080");
let endpoint = ProxyEndpoint::parse("socks5://proxy.example:1080", 80).unwrap();
let url = ProxyStep::Socks5(endpoint).to_url().unwrap();
assert_eq!(url.scheme(), "socks5");
let endpoint = ProxyEndpoint::parse("socks4://proxy.example:1080", 80).unwrap();
let url = ProxyStep::Socks4(endpoint).to_url().unwrap();
assert_eq!(url.scheme(), "socks4");
let endpoint = ProxyEndpoint::new(Host::Domain("proxy".to_owned()), 1080);
let url = ProxyStep::Socks5(endpoint).to_url().unwrap();
assert_eq!(url.scheme(), "socks5");
let endpoint = ProxyEndpoint::parse("socks5h://proxy.example:1080", 80).unwrap();
assert_eq!(ProxyStep::Socks5(endpoint).scheme(), Some("socks5"));
}
#[test]
fn to_url_answers_none_for_a_host_no_url_can_hold() {
let step = ProxyStep::Http(ProxyEndpoint::new(
Host::Domain("bad host".to_owned()),
8080,
));
assert!(step.to_url().is_none());
assert!(step.endpoint().is_some(), "and yet the step is not Direct");
assert_eq!(step.scheme(), Some("http"));
for host in ["user@evil", "evil/x", "evil?x", "evil#x"] {
let step = ProxyStep::Http(ProxyEndpoint::new(Host::Domain(host.to_owned()), 8080));
assert!(step.to_url().is_none(), "{host} produced a URL");
}
for host in ["例え.jp", "Proxy.Example"] {
let step = ProxyStep::Http(ProxyEndpoint::new(Host::Domain(host.to_owned()), 8080));
assert!(step.to_url().is_some(), "{host} lost its URL");
}
}
#[test]
fn to_url_percent_encodes_credentials() {
let endpoint = ProxyEndpoint::new(Host::Domain("proxy".to_owned()), 8080)
.with_auth(ProxyAuth::new("al ice", Some("p@ss")));
let url = ProxyStep::Http(endpoint).to_url().unwrap();
assert_eq!(url.as_str(), "http://al%20ice:p%40ss@proxy:8080/");
assert_eq!(url.password(), Some("p%40ss"));
}
#[test]
fn a_snapshot_resolves_through_its_effective_mode() {
let mode = crate::parse::windows_manual("proxy:8080", "");
let config = ProxyConfig::from_source(ProxyConfigSource::Registry, mode);
let url = Url::parse("http://example.com/").unwrap();
assert_eq!(
resolve(&config, &url).unwrap()[0].endpoint().unwrap().port,
8080
);
}
#[test]
fn request_schemes_map_onto_entry_keys() {
let cases = [
("http://h/", Scheme::Http),
("https://h/", Scheme::Https),
("ftp://h/", Scheme::Ftp),
("socks://h/", Scheme::Socks),
("socks4://h/", Scheme::Socks),
("socks4a://h/", Scheme::Socks),
("socks5://h/", Scheme::Socks),
("socks5h://h/", Scheme::Socks),
("gopher://h/", Scheme::All),
];
for (input, expected) in cases {
let url = Url::parse(input).unwrap();
assert_eq!(request_scheme(&url), expected, "{input}");
}
}
fn dropped(scheme: Option<Scheme>) -> RejectedValue {
RejectedValue::new(
crate::RejectionKind::UnsupportedMapping,
crate::RejectionSource::Kioslaverc("ftpProxy".to_owned()),
"$ftp_proxy",
)
.for_scheme(scheme)
}
fn mode_with(entries: Vec<(Scheme, ProxyEntry)>, rejected: Vec<RejectedValue>) -> ProxyMode {
ProxyMode::manual(entries.into_iter().collect(), crate::BypassRules::new())
.with_rejected(rejected)
}
#[test]
fn a_drop_that_covered_the_requested_scheme_is_reported_not_called_direct() {
let mode = mode_with(Vec::new(), vec![dropped(Some(Scheme::Ftp))]);
let url = Url::parse("ftp://files.example/x").unwrap();
let err = resolve_mode(&mode, &url).unwrap_err();
assert!(
matches!(&err, Error::ProxyEntryUnusable { scheme, rejected }
if *scheme == Scheme::Ftp && rejected.affected_scheme() == Some(Scheme::Ftp)),
"{err:?}"
);
}
#[test]
fn the_drop_reported_is_the_one_the_lookup_would_have_used_first() {
let mode = mode_with(
Vec::new(),
vec![dropped(Some(Scheme::All)), dropped(Some(Scheme::Http))],
);
let url = Url::parse("http://intranet.corp/x").unwrap();
let err = resolve_mode(&mode, &url).unwrap_err();
assert!(
matches!(&err, Error::ProxyEntryUnusable { scheme, .. } if *scheme == Scheme::Http),
"{err:?}"
);
let mode = mode_with(
Vec::new(),
vec![dropped(Some(Scheme::Http)), dropped(Some(Scheme::Socks))],
);
let url = Url::parse("wss://chat.example/x").unwrap();
let err = resolve_mode(&mode, &url).unwrap_err();
assert!(
matches!(&err, Error::ProxyEntryUnusable { scheme, .. } if *scheme == Scheme::Socks),
"{err:?}"
);
}
#[test]
fn the_first_of_two_drops_naming_one_tier_is_the_one_reported() {
let mode = crate::parse::windows_manual("http=h:99999;http=h:88888", "");
let Some([first, second]) = mode
.rejected()
.and_then(|all| <&[_; 2]>::try_from(all).ok())
else {
panic!("both tokens must be refused, or this holds nothing: {mode:?}");
};
assert_eq!(first.redacted_input(), "http=h:99999");
assert_eq!(second.redacted_input(), "http=h:88888");
let url = Url::parse("http://intranet.corp/x").unwrap();
let err = resolve_mode(&mode, &url).unwrap_err();
assert!(
matches!(&err, Error::ProxyEntryUnusable { rejected, .. }
if rejected.redacted_input() == "http=h:99999"),
"{err:?}"
);
}
#[test]
fn a_live_catch_all_still_covers_a_dropped_scheme() {
let socks = ProxyEndpoint::new(Host::Domain("s".to_owned()), 1080);
let mode = mode_with(
vec![(Scheme::All, ProxyEntry::Use(socks))],
vec![dropped(Some(Scheme::Ftp))],
);
let url = Url::parse("ftp://files.example/x").unwrap();
let steps = resolve_mode(&mode, &url).unwrap();
assert!(!steps.contains(&ProxyStep::Direct), "{steps:?}");
}
#[test]
fn a_drop_naming_another_scheme_leaves_this_one_direct() {
let mode = mode_with(Vec::new(), vec![dropped(Some(Scheme::Ftp))]);
let url = Url::parse("https://a.example/x").unwrap();
assert_eq!(resolve_mode(&mode, &url).unwrap(), vec![ProxyStep::Direct]);
}
#[test]
fn a_drop_naming_no_scheme_never_turns_direct_into_an_error() {
let mode = mode_with(Vec::new(), vec![dropped(None)]);
for input in [
"ftp://f.example/x",
"https://a.example/x",
"ws://a.example/x",
] {
let url = Url::parse(input).unwrap();
assert_eq!(
resolve_mode(&mode, &url).unwrap(),
vec![ProxyStep::Direct],
"{input}"
);
}
}
#[test]
fn a_hand_built_drop_naming_no_scheme_is_reported_against_the_catch_all() {
let mode = mode_with(
vec![(Scheme::Ftp, ProxyEntry::Unusable(dropped(None)))],
Vec::new(),
);
let config = ProxyConfig::from_source(ProxyConfigSource::Registry, mode);
let url = Url::parse("ftp://files.example/x").unwrap();
let err = resolve(&config, &url).unwrap_err();
assert!(
matches!(&err, Error::ProxyEntryUnusable { scheme, .. } if *scheme == Scheme::All),
"{err:?}"
);
}
#[test]
fn an_explicitly_disabled_scheme_stays_direct_beside_a_drop() {
let mode = mode_with(
vec![(Scheme::Ftp, ProxyEntry::Disabled)],
vec![dropped(Some(Scheme::Ftp))],
);
let url = Url::parse("ftp://files.example/x").unwrap();
assert_eq!(resolve_mode(&mode, &url).unwrap(), vec![ProxyStep::Direct]);
}
#[test]
fn an_all_drop_does_not_reach_past_an_explicitly_disabled_scheme() {
let mode = mode_with(
vec![(Scheme::Http, ProxyEntry::Disabled)],
vec![dropped(Some(Scheme::All))],
);
let url = Url::parse("http://a.example/x").unwrap();
assert_eq!(resolve_mode(&mode, &url).unwrap(), vec![ProxyStep::Direct]);
let url = Url::parse("ftp://files.example/x").unwrap();
let err = resolve_mode(&mode, &url).unwrap_err();
assert!(
matches!(&err, Error::ProxyEntryUnusable { rejected, .. }
if rejected.affected_scheme() == Some(Scheme::All)),
"{err:?}"
);
}
#[test]
fn a_drop_never_pre_empts_an_answer_the_chain_reaches_later() {
let proxy = ProxyEndpoint::parse("p.example:3128", 80).unwrap();
let cases = [
(
"http://a.example/x",
vec![(Scheme::All, ProxyEntry::Disabled)],
Scheme::Http,
vec![ProxyStep::Direct],
),
(
"ws://a.example/x",
vec![(Scheme::Http, ProxyEntry::Use(proxy.clone()))],
Scheme::Socks,
vec![ProxyStep::from_endpoint(proxy)],
),
];
for (input, entries, lost, expected) in cases {
let mode = mode_with(entries, vec![dropped(Some(lost))]);
let url = Url::parse(input).unwrap();
assert_eq!(
resolve_mode(&mode, &url).unwrap(),
expected,
"{input} without {lost:?}"
);
}
}
#[test]
fn a_websocket_request_is_covered_by_the_chain_it_would_have_walked() {
let mode = mode_with(Vec::new(), vec![dropped(Some(Scheme::Https))]);
let url = Url::parse("ws://a.example/x").unwrap();
let err = resolve_mode(&mode, &url).unwrap_err();
assert!(
matches!(&err, Error::ProxyEntryUnusable { scheme, .. } if *scheme == Scheme::Https),
"{err:?}"
);
}
}