use std::collections::HashSet;
use std::fmt;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use ipnet::{IpNet, Ipv4Net};
use url::{Host, Url};
use crate::diagnostic::{RejectedValue, RejectionKind, RejectionSource};
use crate::error::Error;
use crate::util::{glob_match, redact_offending_token, split_host_port, strip_brackets};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum HostPattern {
All,
Cidr(IpNet),
Exact {
host: Host,
port: Option<u16>,
},
Domain {
suffix: String,
match_self: bool,
port: Option<u16>,
},
Wildcard {
pattern: String,
port: Option<u16>,
},
Local,
SubtractImplicit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum BypassDialect {
Suffix,
Windows,
MacOs,
Gnome,
#[cfg_attr(not(all(target_os = "linux", feature = "linux-kde")), allow(dead_code))]
Kde,
}
impl BypassDialect {
pub(crate) fn trim(self, entry: &str) -> &str {
match self {
BypassDialect::Gnome => entry.trim_end_matches(|c: char| c.is_ascii_whitespace()),
BypassDialect::MacOs => entry,
BypassDialect::Windows => {
entry.trim_matches(|c| matches!(c, '\t' | '\n' | '\x0B' | '\x0C' | '\r' | ' '))
}
BypassDialect::Suffix | BypassDialect::Kde => entry.trim(),
}
}
}
impl HostPattern {
pub fn parse(entry: &str) -> Result<Option<Self>, Error> {
Self::parse_in(entry, BypassDialect::Suffix)
}
pub(crate) fn parse_in(entry: &str, dialect: BypassDialect) -> Result<Option<Self>, Error> {
let entry = dialect.trim(entry);
if entry.is_empty() {
return Ok(None);
}
let lowered = entry.to_ascii_lowercase();
match lowered.as_str() {
"*" if !matches!(dialect, BypassDialect::MacOs | BypassDialect::Gnome) => {
return Ok(Some(HostPattern::All));
}
LOCAL_TOKEN => return Ok(Some(HostPattern::Local)),
NO_LOOPBACK_TOKEN => return Ok(Some(HostPattern::SubtractImplicit)),
_ => {}
}
if entry.contains('@') {
return Err(Error::bypass(
entry,
format!(
"entry looks like a proxy URL with credentials ({}), not a bare \
host[:port] or CIDR; bypass/no-proxy lists cannot carry a \
username or password",
redact_offending_token(entry)
),
));
}
if entry.chars().any(char::is_whitespace) {
return Err(Error::bypass(
entry,
"entry contains whitespace, so it is not a single host[:port] or CIDR \
(separate entries with ',', or on Windows with ';' or a space)",
));
}
if lowered.contains("://") {
return Err(Error::bypass(
entry,
"entry names a scheme, and a bypass pattern here applies to every scheme, \
so the restriction cannot be honoured (write the host on its own, for \
example example.com)",
));
}
if dialect == BypassDialect::Windows && lowered.contains('/') {
return Err(Error::bypass(
entry,
"entry contains a '/', which Windows does not read as a CIDR block — it \
invalidates the whole bypass list (write the range with wildcards, for \
example 10.* rather than 10.0.0.0/8)",
));
}
if dialect == BypassDialect::Windows
&& lowered.starts_with('.')
&& !lowered.trim_start_matches('.').is_empty()
{
return Err(Error::bypass(
entry,
"entry starts with a '.', which Windows does not read as a subdomain rule — \
WinINet refuses the whole bypass list over one (write the subdomains as \
*.example.com rather than .example.com)",
));
}
if let Some((address, prefix)) = lowered.split_once('/')
&& address.parse::<IpAddr>().is_err()
&& address.split('.').count() == 4
&& address.split('.').all(|part| !part.is_empty())
&& !address.chars().any(is_forbidden_host_char)
&& let Ok(Host::Ipv4(v4)) = crate::endpoint::parse_host(address)
{
if matches!(dialect, BypassDialect::MacOs | BypassDialect::Kde) {
return Err(Error::bypass(
entry,
"entry is a CIDR block whose IPv4 address is written other than as four \
decimal octets, which the system reads as decimal and Chromium as a URL \
host (write the four decimal octets, for example 10.1.2.0/24)",
));
}
if let Ok(net) = format!("{v4}/{prefix}").parse::<IpNet>() {
return Ok(Some(HostPattern::Cidr(net)));
}
}
if let Ok(net) = lowered.parse::<IpNet>() {
return Ok(Some(HostPattern::Cidr(net)));
}
if lowered.contains('/') {
return Err(Error::bypass(
entry,
"entry contains a '/', so it can only be a CIDR block, but it is not a \
valid one (an address, a '/', and a prefix length — for example \
10.0.0.0/8 or fe80::/10)",
));
}
let (bracketed_text, port) =
split_host_port(&lowered).map_err(|reason| Error::bypass(entry, reason))?;
let host_text = strip_brackets(bracketed_text);
if matches!(dialect, BypassDialect::Windows | BypassDialect::Kde)
&& host_text.ends_with('.')
&& !host_text.trim_matches(['*', '.']).is_empty()
{
return Err(Error::bypass(
entry,
"entry ends in a '.', which this store compares as written, so it matches \
only a destination spelled with the same trailing dot (write the name \
without it)",
));
}
if dialect == BypassDialect::MacOs && port.is_some() {
return Err(Error::bypass(
entry,
"entry carries a port, which macOS does not read as a restriction — it \
compares the whole entry to the destination's host name, so no destination \
could ever match it (write the host on its own)",
));
}
if host_text.len() != bracketed_text.len()
&& !matches!(
crate::endpoint::parse_host(host_text),
Ok(Host::Ipv4(_) | Host::Ipv6(_))
)
{
return Err(Error::bypass(
entry,
"entry is bracketed, so it can only be an address literal, but it is not a \
valid one (for example [::1] or [2001:db8::1])",
));
}
if let Some(bad) = host_text.chars().find(|c| is_forbidden_host_char(*c)) {
return Err(Error::bypass(
entry,
format!(
"entry contains {bad:?}, which cannot appear in a host name, so no \
destination could ever match it"
),
));
}
let encoded;
let host_text = if host_text.is_ascii() {
host_text
} else {
encoded = idna_ascii(host_text).map_err(|reason| Error::bypass(entry, reason))?;
&encoded
};
let subdomain_body = host_text
.strip_prefix("*.")
.or_else(|| host_text.strip_prefix('.'));
let labelled = subdomain_body.unwrap_or(host_text);
let labelled = labelled.strip_suffix('.').unwrap_or(labelled);
if !labelled.is_empty() && labelled.split('.').any(str::is_empty) {
return Err(Error::bypass(
entry,
"entry has an empty label (two dots in a row), so no destination could \
ever match it (write example.com, .example.com or *.example.com)",
));
}
let suffix_body = subdomain_body
.map(|rest| rest.strip_suffix('.').unwrap_or(rest))
.filter(|body| !body.is_empty());
if let Some(body) = suffix_body {
let fits_a_dotted_quad = body.contains('*')
&& body.matches('.').count() < 3
&& body
.bytes()
.all(|b| b.is_ascii_digit() || b == b'.' || b == b'*');
match crate::endpoint::parse_host(body) {
Ok(Host::Domain(_)) => {}
Ok(_) => {
return Err(Error::bypass(
entry,
"entry names a subdomain of an address literal, which has none, so \
no destination could ever match it (write the address on its own, \
or a CIDR range such as 10.0.0.0/8)",
));
}
Err(_) if fits_a_dotted_quad => {}
Err(_) => return Err(Error::bypass(entry, UNREACHABLE_NAME)),
}
}
if let Some(rest) = host_text.strip_prefix("*.") {
let rest = rest.strip_suffix('.').unwrap_or(rest);
if rest.is_empty() {
return Ok(None);
}
if !rest.contains('*') {
return Ok(Some(HostPattern::Domain {
suffix: format!(".{rest}"),
match_self: dialect == BypassDialect::Gnome,
port,
}));
}
if dialect == BypassDialect::Gnome {
return Err(Error::bypass(entry, GNOME_LITERAL_STAR));
}
if dialect == BypassDialect::MacOs {
return Err(Error::bypass(entry, MACOS_LITERAL_STAR));
}
return Ok(Some(HostPattern::Wildcard {
pattern: format!("*.{rest}"),
port,
}));
}
let host_text = host_text.strip_suffix('.').unwrap_or(host_text);
if host_text.is_empty() {
return Ok(None);
}
if let Ok(ip) = host_text.parse::<IpAddr>() {
let host = match ip {
IpAddr::V4(v4) => Host::Ipv4(v4),
IpAddr::V6(v6) => Host::Ipv6(v6),
};
return Ok(Some(HostPattern::Exact { host, port }));
}
if host_text.contains('*') {
if dialect == BypassDialect::Gnome {
return Err(Error::bypass(entry, GNOME_LITERAL_STAR));
}
if dialect == BypassDialect::MacOs {
let head = host_text.strip_suffix(".*").filter(|head| {
!head.is_empty() && !head.contains('*') && !head.starts_with('.')
});
return match head {
Some(head) => Ok(Some(HostPattern::Wildcard {
pattern: format!("{head}.*"),
port,
})),
None => Err(Error::bypass(entry, MACOS_LITERAL_STAR)),
};
}
let pattern = if host_text.starts_with('.') {
format!("*{host_text}")
} else {
host_text.to_owned()
};
return Ok(Some(HostPattern::Wildcard { pattern, port }));
}
if let Some(rest) = host_text.strip_prefix('.') {
if rest.is_empty() {
return Ok(None);
}
return Ok(Some(HostPattern::Domain {
suffix: host_text.to_owned(),
match_self: dialect == BypassDialect::Gnome,
port,
}));
}
let Ok(host) = crate::endpoint::parse_host(host_text) else {
return Err(Error::bypass(entry, UNREACHABLE_NAME));
};
if matches!(
dialect,
BypassDialect::Windows | BypassDialect::MacOs | BypassDialect::Kde
) && matches!(host, Host::Ipv4(_))
{
return Err(Error::bypass(
entry,
"entry is an IPv4 address written other than as four decimal octets, which \
this list's reader does not read as the address a URL parser does — Windows, \
macOS and Android compare it as text, KIO reads each part as decimal \
(write the four decimal octets, for example 10.1.2.3)",
));
}
if matches!(host, Host::Ipv4(_))
|| matches!(dialect, BypassDialect::Windows | BypassDialect::MacOs)
{
return Ok(Some(HostPattern::Exact { host, port }));
}
Ok(Some(HostPattern::Domain {
suffix: format!(".{host_text}"),
match_self: true,
port,
}))
}
fn matches(
&self,
host_text: &str,
ip: Option<IpAddr>,
port: Option<u16>,
reduce: bool,
) -> bool {
match self {
HostPattern::All => true,
HostPattern::Cidr(net) => {
let net = if reduce {
reduce_mapped_net(*net)
} else {
*net
};
ip.is_some_and(|ip| net.contains(&ip))
}
HostPattern::Exact {
host,
port: rule_port,
} => {
let rule_ip = host_ip(host).map(|rule_ip| {
if reduce {
reduce_mapped(rule_ip)
} else {
rule_ip
}
});
let hit = match (rule_ip, ip) {
(Some(rule_ip), Some(destination_ip)) => rule_ip == destination_ip,
_ => host_text == host_key(host),
};
hit && port_matches(*rule_port, port)
}
HostPattern::Domain {
suffix,
match_self,
port: rule_port,
} => {
let bare = suffix.strip_prefix('.').unwrap_or(suffix);
let hit = !bare.is_empty()
&& strip_suffix_ascii_case(host_text, bare).is_some_and(|rest| {
rest.ends_with('.') || (*match_self && rest.is_empty())
});
hit && port_matches(*rule_port, port)
}
HostPattern::Wildcard {
pattern,
port: rule_port,
} => {
let hit = if pattern.bytes().any(|byte| byte.is_ascii_uppercase()) {
glob_match(&pattern.to_ascii_lowercase(), host_text)
} else {
glob_match(pattern, host_text)
};
hit && port_matches(*rule_port, port)
}
HostPattern::Local => is_simple_host_name(host_text, ip),
HostPattern::SubtractImplicit => false,
}
}
}
impl fmt::Display for HostPattern {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
HostPattern::All => f.write_str("*"),
HostPattern::Cidr(net) => write!(f, "{net}"),
HostPattern::Exact { host, port } => write_with_port(f, &host_display(host), *port),
HostPattern::Domain {
suffix,
match_self,
port,
} => {
let base = match (*match_self, suffix.strip_prefix('.')) {
(true, Some(bare)) => bare.to_owned(),
(false, None) => format!(".{suffix}"),
_ => suffix.clone(),
};
write_with_port(f, &base, *port)
}
HostPattern::Wildcard { pattern, port } => write_with_port(f, pattern, *port),
HostPattern::Local => f.write_str(LOCAL_TOKEN),
HostPattern::SubtractImplicit => f.write_str(NO_LOOPBACK_TOKEN),
}
}
}
pub const LOCAL_TOKEN: &str = "<local>";
pub const NO_LOOPBACK_TOKEN: &str = "<-loopback>";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[non_exhaustive]
pub enum ImplicitBypass {
#[default]
Broad,
WinInet,
CfNetwork,
Empty,
}
impl ImplicitBypass {
fn covers(self, host_text: &str, ip: Option<IpAddr>) -> bool {
match self {
Self::Broad => is_loopback(host_text, ip.map(reduce_mapped)),
Self::WinInet => match ip {
Some(ip) => is_loopback_address(ip) || is_link_local(Some(ip)),
None => host_text == "localhost" || host_text == "loopback",
},
Self::CfNetwork => match ip {
Some(ip) => is_loopback_address(ip),
None => host_text == "localhost",
},
Self::Empty => false,
}
}
}
const UNREACHABLE_NAME: &str = "entry is not a name any destination could carry, so no \
destination could ever match it (a last label that reads as \
a number, as in example.123, is taken for an address and \
refused as one; so is an unbracketed IPv6 that is not a \
valid address, as in 2001:db8:1)";
const GNOME_LITERAL_STAR: &str = "entry contains a '*' that GNOME does not read as a \
wildcard — only a leading '*.' is one there — so no \
destination could ever match it (write *.example.com for \
a domain, or the host on its own)";
const MACOS_LITERAL_STAR: &str = "entry contains a '*' that macOS does not read as a \
wildcard — only a leading '*.' or a trailing '.*' is one \
there — so no destination could ever match it (write \
*.example.com for a domain, or the host on its own)";
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct BypassRules {
pub patterns: Vec<HostPattern>,
pub exclude_simple_hostnames: bool,
pub reversed_exceptions: bool,
pub rejected: Vec<RejectedValue>,
pub require_explicit_port: bool,
pub implicit: ImplicitBypass,
pub ipv4_mapped_as_ipv4: bool,
pub strip_trailing_dot: bool,
}
impl Default for BypassRules {
fn default() -> Self {
Self::new()
}
}
impl BypassRules {
#[must_use]
pub const fn new() -> Self {
Self {
patterns: Vec::new(),
exclude_simple_hostnames: false,
reversed_exceptions: false,
rejected: Vec::new(),
require_explicit_port: false,
implicit: ImplicitBypass::Broad,
ipv4_mapped_as_ipv4: true,
strip_trailing_dot: true,
}
}
#[must_use]
pub fn bypass_loopback(&self) -> bool {
!self
.patterns
.iter()
.any(|p| matches!(p, HostPattern::SubtractImplicit))
}
pub(crate) fn push_pattern(&mut self, pattern: HostPattern) {
self.patterns.push(pattern);
}
pub(crate) fn dedup_patterns(&mut self) {
let mut seen = HashSet::with_capacity(self.patterns.len());
self.patterns.retain(|pattern| {
if matches!(pattern, HostPattern::SubtractImplicit) {
seen.clear();
return true;
}
seen.insert(pattern.clone())
});
}
#[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
pub(crate) fn push_gnome_entries<'a>(&mut self, entries: impl Iterator<Item = &'a str>) {
let mut names_ended = false;
for entry in entries {
let entry = BypassDialect::Gnome.trim(entry);
if let Some(net) = glib_mask(entry) {
self.push_pattern(HostPattern::Cidr(net));
continue;
}
let refusal = match glib_name(entry) {
Err(reason) => Some(reason),
Ok("") => {
names_ended = true;
None
}
Ok(_) if names_ended => Some(
"entry follows one that leaves no name (empty, '.', '*.' or a bare \
port), and GLib stops reading names there, so it never matches",
),
Ok(name)
if name.eq_ignore_ascii_case(LOCAL_TOKEN)
|| name.eq_ignore_ascii_case(NO_LOOPBACK_TOKEN) =>
{
Some(
"entry is a WinINet token, which GLib does not read: it compares the \
entry to a host so named, and no host is",
)
}
Ok(name) if !name.is_ascii() => Some(
"entry is not ASCII, and GLib compares it unconverted with the ASCII \
form of the destination, so it never matches (write the xn-- form)",
),
Ok(name) if name.ends_with('.') => Some(
"entry ends in a '.', which GLib compares as written, so it matches \
only a destination spelled with the same trailing dot",
),
Ok(name) if name.contains('/') => Some(
"entry is not a mask GLib accepts (an address with bits set past the \
prefix, or with a leading zero), so GLib reads it as a name that \
matches nothing",
),
Ok(name)
if name.parse::<IpAddr>().is_err()
&& matches!(crate::endpoint::parse_host(name), Ok(Host::Ipv4(_))) =>
{
Some(
"entry is an IPv4 spelling inet_pton does not read (short, octal or \
hex), so GLib compares it as text and it never matches",
)
}
Ok(_) => None,
};
match refusal {
Some(reason) => {
crate::trace::warning!(
error = %crate::trace::SafeError(&Error::bypass(entry, reason)),
"skipping an ignore-hosts entry GLib would not match"
);
self.rejected.push(RejectedValue::new(
RejectionKind::InvalidBypassPattern,
RejectionSource::BypassList,
entry,
));
}
None if names_ended && entry.is_empty() => {}
None => self.push_entry_in(entry, BypassDialect::Gnome),
}
}
}
pub(crate) fn push_entry_in(&mut self, entry: &str, dialect: BypassDialect) {
self.push_entry_from(entry, dialect, RejectionSource::BypassList);
}
#[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
pub(crate) fn push_entry_from(
&mut self,
entry: &str,
dialect: BypassDialect,
source: RejectionSource,
) {
match HostPattern::parse_in(entry, dialect) {
Ok(Some(pattern)) => self.push_pattern(pattern),
Ok(None) => {}
Err(err) => {
crate::trace::warning!(
error = %crate::trace::SafeError(&err),
"skipping an unparseable bypass list entry"
);
self.rejected.push(RejectedValue::new(
RejectionKind::InvalidBypassPattern,
source,
entry,
));
}
}
}
#[must_use]
pub fn excludes_simple_hostnames(&self) -> bool {
self.exclude_simple_hostnames
|| self
.patterns
.iter()
.any(|p| matches!(p, HostPattern::Local))
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.patterns.is_empty()
&& !self.exclude_simple_hostnames
&& !self.reversed_exceptions
}
#[must_use]
pub fn matches(&self, host: &Host, port: Option<u16>) -> bool {
let text = host_key(host);
if text.is_empty() {
return false;
}
let written = host_ip(host);
let reduce = self.ipv4_mapped_as_ipv4;
let ip = if reduce {
written.map(reduce_mapped)
} else {
written
};
let implicit = self.implicit.covers(&text, written);
let entry_text = if self.strip_trailing_dot {
text.strip_suffix('.').unwrap_or(&text)
} else {
&text
};
let unnamed = self.reversed_exceptions && self.rejected.is_empty();
if self.exclude_simple_hostnames
&& !text.contains('.')
&& (self.bypass_loopback() || !implicit)
{
return true;
}
for pattern in self.patterns.iter().rev() {
if matches!(pattern, HostPattern::SubtractImplicit) {
if implicit {
return unnamed;
}
} else if pattern.matches(
if matches!(pattern, HostPattern::Local) {
&text
} else {
entry_text
},
ip,
port,
reduce,
) {
return !self.reversed_exceptions;
}
}
implicit || unnamed
}
#[must_use]
pub fn matches_url(&self, url: &Url) -> bool {
let Some(host) = crate::endpoint::request_host(url) else {
return false;
};
let port = if self.require_explicit_port {
url.port()
} else {
url.port_or_known_default()
};
self.matches(&host, port)
}
#[must_use]
pub fn matches_authority(&self, authority: &str) -> bool {
let Ok((host_text, port)) = split_host_port(authority.trim()) else {
return false;
};
let Ok(host) = crate::endpoint::parse_host(host_text) else {
return false;
};
self.matches(&host, port)
}
}
fn is_forbidden_host_char(c: char) -> bool {
c.is_control()
|| matches!(
c,
'#' | '%' | '<' | '>' | '?' | '[' | '\\' | ']' | '^' | '|'
)
}
fn idna_ascii(host_text: &str) -> Result<String, String> {
const LOAN: &str = ".a";
let mut out = String::with_capacity(host_text.len());
for (index, label) in host_text.split('.').enumerate() {
if index > 0 {
out.push('.');
}
if label.is_ascii() {
out.push_str(label);
continue;
}
if label.contains('*') {
return Err(
"entry mixes a '*' glob with non-ASCII text in one label, which has no \
punycode spelling; write the label as punycode (xn--…) instead"
.to_owned(),
);
}
match Host::parse(&format!("{label}{LOAN}")) {
Ok(Host::Domain(ascii)) if ascii.ends_with(LOAN) => {
out.push_str(&ascii[..ascii.len() - LOAN.len()]);
}
_ => {
return Err(
"entry contains non-ASCII text that is not a valid internationalised \
domain name"
.to_owned(),
);
}
}
}
Ok(out)
}
fn port_matches(rule_port: Option<u16>, port: Option<u16>) -> bool {
match rule_port {
None => true,
Some(expected) => port == Some(expected),
}
}
fn glib_mask(entry: &str) -> Option<IpNet> {
let (address, prefix) = match entry.split_once('/') {
Some((address, prefix)) => (address, Some(prefix)),
None => (entry, None),
};
let address: IpAddr = address.parse().ok()?;
let full = if address.is_ipv4() { 32 } else { 128 };
let prefix = match prefix {
Some(text) if !text.is_empty() && text.bytes().all(|b| b.is_ascii_digit()) => {
text.parse::<u8>().ok()?
}
Some(_) => return None,
None => full,
};
let net = IpNet::new(address, prefix).ok()?;
(net.trunc().addr() == address).then_some(net)
}
fn glib_name(entry: &str) -> Result<&str, &'static str> {
let (name, port) = if let Some(inner) = entry.strip_prefix('[') {
let (address, rest) = inner
.split_once(']')
.ok_or("entry opens a bracket it does not close")?;
let port = rest.strip_prefix(':').ok_or(
"entry is a bracketed address with no port, which GLib refuses (write it \
without brackets, or as [address]:port)",
)?;
(address, Some(port))
} else {
match entry.split_once(':') {
Some((name, port)) if !port.contains(':') => (name, Some(port)),
_ => (entry, None),
}
};
if port.is_some_and(|port| !port.bytes().all(|b| b.is_ascii_digit())) {
return Err("entry's port is not a number");
}
if name.parse::<IpAddr>().is_ok() {
return Ok(name);
}
Ok(name
.strip_prefix("*.")
.or_else(|| name.strip_prefix('.'))
.unwrap_or(name))
}
fn is_loopback(host_text: &str, ip: Option<IpAddr>) -> bool {
if let Some(ip) = ip {
return ip.is_loopback();
}
let name = host_text.strip_suffix('.').unwrap_or(host_text);
name == "localhost" || name == "loopback" || name.ends_with(".localhost")
}
fn is_link_local(ip: Option<IpAddr>) -> bool {
match ip {
Some(IpAddr::V4(v4)) => v4.is_link_local(),
Some(IpAddr::V6(v6)) => v6.is_unicast_link_local(),
None => false,
}
}
fn is_simple_host_name(host_text: &str, ip: Option<IpAddr>) -> bool {
ip.is_none() && !host_text.contains('.')
}
fn host_key(host: &Host) -> String {
match host {
Host::Domain(domain) if !domain.is_ascii() => idna_ascii(domain)
.unwrap_or_else(|_| domain.clone())
.to_ascii_lowercase(),
Host::Domain(domain) => domain.to_ascii_lowercase(),
Host::Ipv4(ip) => ip.to_string(),
Host::Ipv6(ip) => ip.to_string(),
}
}
fn strip_suffix_ascii_case<'a>(text: &'a str, suffix: &str) -> Option<&'a str> {
let split = text.len().checked_sub(suffix.len())?;
if !text.is_char_boundary(split) {
return None;
}
text[split..]
.eq_ignore_ascii_case(suffix)
.then(|| &text[..split])
}
fn host_display(host: &Host) -> String {
match host {
Host::Ipv6(ip) => format!("[{ip}]"),
other => other.to_string(),
}
}
fn host_ip(host: &Host) -> Option<IpAddr> {
match host {
Host::Domain(_) => None,
Host::Ipv4(ip) => Some(IpAddr::V4(*ip)),
Host::Ipv6(ip) => Some(IpAddr::V6(*ip)),
}
}
fn reduce_mapped(ip: IpAddr) -> IpAddr {
match ip {
IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(ip, IpAddr::V4),
IpAddr::V4(_) => ip,
}
}
fn is_loopback_address(ip: IpAddr) -> bool {
ip == IpAddr::V4(Ipv4Addr::LOCALHOST) || ip == IpAddr::V6(Ipv6Addr::LOCALHOST)
}
fn reduce_mapped_net(net: IpNet) -> IpNet {
let IpNet::V6(v6) = net else {
return net;
};
let (Some(addr), true) = (v6.addr().to_ipv4_mapped(), v6.prefix_len() >= 96) else {
return net;
};
Ipv4Net::new(addr, v6.prefix_len() - 96).map_or(net, IpNet::V4)
}
fn write_with_port(f: &mut fmt::Formatter<'_>, base: &str, port: Option<u16>) -> fmt::Result {
match port {
Some(port) => write!(f, "{base}:{port}"),
None => f.write_str(base),
}
}