Skip to main content

tuwunel_core/config/
proxy.rs

1//! Defines outbound proxy configuration and domain matching.
2//!
3//! The module supports no proxy, one global proxy, or domain-specific include
4//! and exclude rules. URL matching selects the applicable proxy at request
5//! time.
6
7use std::{
8	env::{var, var_os},
9	fmt::Write as _,
10	net::IpAddr,
11	sync::Arc,
12};
13
14use http::Uri;
15use ipnet::IpNet;
16use reqwest::{ClientBuilder, NoProxy as ReqwestNoProxy, Proxy, Url};
17use serde::Deserialize;
18use smallstr::SmallString;
19use smallvec::SmallVec;
20use url::Host;
21
22use crate::{Err, Result, implement, utils::url::hostname_matches_domain};
23
24type Domain = SmallString<[u8; 32]>;
25type Domains = Box<[Domain]>;
26type Networks = Box<[Network]>;
27type Proxies = SmallVec<[Proxy; 1]>;
28type ProxyHostBuffer = SmallVec<[ProxyHost; 2]>;
29
30/// Stores a proxy endpoint hostname inline when it is short.
31///
32/// Longer DNS names spill to the heap without changing resolver matching.
33pub type ProxyHost = SmallString<[u8; 32]>;
34
35/// Shared proxy endpoint names used by outbound resolver policy.
36///
37/// The slice is built once with the client proxy snapshot and shared by every
38/// resolver that filters request destinations.
39pub type ProxyHosts = Arc<[ProxyHost]>;
40type ProxyUrlString = SmallString<[u8; 128]>;
41
42#[derive(Clone, Copy, Eq, PartialEq)]
43enum ProxyScheme {
44	Http,
45	Https,
46	Socks4,
47	Socks4a,
48	Socks5,
49	Socks5h,
50}
51
52/// Captures the proxy policy used by one generation of outbound clients.
53///
54/// The snapshot owns environment values and configured rules so resolver,
55/// response checks, and lazily built clients retain one policy generation.
56pub struct ProxySnapshot {
57	configured: Option<Arc<ProxyConfig>>,
58	environment: Option<EnvironmentProxy>,
59	proxies: Proxies,
60	hosts: ProxyHosts,
61}
62
63#[derive(Default)]
64struct EnvironmentProxy {
65	http: Option<ProxyScheme>,
66	https: Option<ProxyScheme>,
67	bypass: NoProxyRules,
68}
69
70#[derive(Clone, PartialEq)]
71struct EnvironmentEndpoint {
72	scheme: ProxyScheme,
73	url: Url,
74	host: Option<ProxyHost>,
75}
76
77#[derive(Default)]
78struct EnvironmentProxyBuild {
79	policy: EnvironmentProxy,
80	proxies: [Option<Proxy>; 2],
81	hosts: [Option<ProxyHost>; 2],
82}
83
84#[derive(Default)]
85struct NoProxyRules {
86	all_domains: bool,
87	ips: Networks,
88	domains: Domains,
89}
90
91enum Network {
92	Address(IpAddr),
93	Range(IpNet),
94}
95
96/// ## Examples:
97/// - No proxy (default):
98/// ```toml
99/// proxy ="none"
100/// ```
101/// - Global proxy
102/// ```toml
103/// [global.proxy]
104/// global = { url = "socks5h://localhost:9050" }
105/// ```
106/// - Proxy some domains
107/// ```toml
108/// [global.proxy]
109/// [[global.proxy.by_domain]]
110/// url = "socks5h://localhost:9050"
111/// include = ["*.onion", "matrix.myspecial.onion"]
112/// exclude = ["*.myspecial.onion"]
113/// ```
114/// ## Include vs. Exclude
115/// If include is an empty list, it is assumed to be `["*"]`.
116///
117/// If a domain matches both the exclude and include list, the proxy will only
118/// be used if it was included because of a more specific rule than it was
119/// excluded. In the above example, the proxy would be used for
120/// `ordinary.onion`, `matrix.myspecial.onion`, but not `hello.myspecial.onion`.
121#[derive(Clone, Default, Debug, Deserialize)]
122#[serde(rename_all = "snake_case")]
123pub enum ProxyConfig {
124	#[default]
125	/// Adds no application-configured proxy.
126	///
127	/// The HTTP client builder remains unchanged, so automatic system proxy
128	/// discovery may still apply. This is the default configuration.
129	None,
130
131	/// Routes every eligible request through one proxy.
132	///
133	/// The same proxy URL applies regardless of the request destination. The
134	/// URL is parsed while the configuration is loaded.
135	Global {
136		/// Identifies the proxy endpoint.
137		///
138		/// The URL may select any proxy scheme supported by the HTTP client. It
139		/// is converted into a request proxy during client construction.
140		#[serde(deserialize_with = "crate::utils::deserialize_from_str")]
141		url: Url,
142	},
143
144	/// Selects proxies using ordered domain rules.
145	///
146	/// Each rule may include or exclude wildcarded domains. The first rule that
147	/// accepts a request supplies its proxy URL.
148	ByDomain(Vec<PartialProxyConfig>),
149}
150
151/// Builds the HTTP client's proxy configuration.
152///
153/// The default configuration returns no application proxy. Global and
154/// domain-based configuration produce the corresponding `reqwest` proxy
155/// selector.
156#[implement(ProxyConfig)]
157pub fn to_proxy(&self) -> Result<Option<Proxy>> {
158	self.validate_proxy_schemes()?;
159
160	let proxy = match self {
161		| Self::None => None,
162		| Self::Global { url } => Some(Proxy::all(url.clone())?),
163		| Self::ByDomain(_) => {
164			let config = self.clone();
165
166			Some(Proxy::custom(move |url| config.proxy_for(url).cloned()))
167		},
168	};
169
170	Ok(proxy)
171}
172
173#[implement(ProxyConfig)]
174fn validate_proxy_schemes(&self) -> Result {
175	if let Some(url) = self
176		.proxy_urls()
177		.find(|url| ProxyScheme::parse(url.scheme()).is_none())
178	{
179		let scheme = url.scheme();
180
181		return Err!(Config("proxy", "Unsupported proxy scheme: {scheme}"));
182	}
183
184	Ok(())
185}
186
187#[implement(ProxyScheme)]
188fn parse(scheme: &str) -> Option<Self> {
189	match scheme {
190		| "http" => Some(Self::Http),
191		| "https" => Some(Self::Https),
192		| "socks4" => Some(Self::Socks4),
193		| "socks4a" => Some(Self::Socks4a),
194		| "socks5" => Some(Self::Socks5),
195		| "socks5h" => Some(Self::Socks5h),
196		| _ => None,
197	}
198}
199
200#[implement(ProxyScheme)]
201const fn as_str(self) -> &'static str {
202	match self {
203		| Self::Http => "http",
204		| Self::Https => "https",
205		| Self::Socks4 => "socks4",
206		| Self::Socks4a => "socks4a",
207		| Self::Socks5 => "socks5",
208		| Self::Socks5h => "socks5h",
209	}
210}
211
212/// Iterates over application-configured proxy endpoint names.
213///
214/// The iterator borrows the configured URLs and allocates no intermediate
215/// collection. Automatic environment proxies are captured by `ProxySnapshot`.
216#[implement(ProxyConfig)]
217#[inline]
218pub fn hosts(&self) -> impl Iterator<Item = &str> { self.proxy_urls().filter_map(proxy_hostname) }
219
220fn proxy_hostname(url: &Url) -> Option<&str> {
221	match url.host()? {
222		| Host::Domain(host) => Some(host),
223		| Host::Ipv4(_) | Host::Ipv6(_) => None,
224	}
225}
226
227#[implement(ProxyConfig)]
228fn proxy_urls(&self) -> impl Iterator<Item = &Url> {
229	let global = match self {
230		| Self::Global { url } => Some(url),
231		| _ => None,
232	};
233
234	let domains = match self {
235		| Self::ByDomain(proxies) => Some(proxies.as_slice()),
236		| _ => None,
237	};
238
239	global.into_iter().chain(
240		domains
241			.into_iter()
242			.flatten()
243			.map(|proxy| &proxy.url),
244	)
245}
246
247/// Reports whether an application-configured proxy carries a request URL.
248///
249/// Domain rules reuse the same include and exclude predicate as the reqwest
250/// proxy selector.
251#[implement(ProxyConfig)]
252#[inline]
253#[must_use]
254pub fn intercepts(&self, url: &Url) -> bool { self.proxy_for(url).is_some() }
255
256#[implement(ProxyConfig)]
257fn proxy_for(&self, url: &Url) -> Option<&Url> {
258	matches!(url.scheme(), "http" | "https")
259		.then(|| match self {
260			| Self::None => None,
261			| Self::Global { url } => Some(url),
262			| Self::ByDomain(proxies) => proxies
263				.iter()
264				.find_map(|proxy| proxy.for_url(url)),
265		})
266		.flatten()
267		.filter(|proxy| ProxyScheme::parse(proxy.scheme()).is_some())
268}
269
270/// Captures the effective proxy configuration for new outbound clients.
271///
272/// Explicit configuration replaces reqwest's automatic environment proxy.
273/// Otherwise the relevant environment variables are read once and normalized
274/// to the effective endpoints used by reqwest's native matcher.
275#[implement(ProxySnapshot)]
276#[must_use]
277pub fn new(config: &ProxyConfig) -> Result<Self> {
278	Self::with_vars(config, var_os("REQUEST_METHOD").is_some(), |name| var(name).ok())
279}
280
281#[implement(ProxySnapshot)]
282pub(super) fn with_vars<F>(config: &ProxyConfig, is_cgi: bool, var: F) -> Result<Self>
283where
284	F: Fn(&str) -> Option<String>,
285{
286	let configured = (!matches!(config, ProxyConfig::None)).then(|| Arc::new(config.clone()));
287	let environment = matches!(config, ProxyConfig::None)
288		.then(|| EnvironmentProxy::with_vars(is_cgi, &var))
289		.transpose()?;
290
291	let (environment, proxies, environment_hosts) = match environment {
292		| Some(EnvironmentProxyBuild { policy, proxies, hosts }) =>
293			(Some(policy), proxies.into_iter().flatten().collect(), hosts),
294		| None => {
295			let proxy = configured
296				.as_ref()
297				.map(ProxyConfig::to_proxy_shared)
298				.transpose()?
299				.flatten();
300
301			(None, proxy.into_iter().collect(), [None, None])
302		},
303	};
304
305	let hosts = config
306		.hosts()
307		.map(ProxyHost::from)
308		.chain(environment_hosts.into_iter().flatten())
309		.fold(ProxyHostBuffer::new(), |mut hosts, host| {
310			if !hosts
311				.iter()
312				.any(|known| known.eq_ignore_ascii_case(&host))
313			{
314				hosts.push(host);
315			}
316
317			hosts
318		});
319
320	let spills = hosts.spilled() || hosts.iter().any(ProxyHost::spilled);
321	let hosts = match hosts {
322		| hosts if hosts.is_empty() => ProxyHosts::default(),
323		| hosts if spills => ProxyHosts::from(hosts.into_vec()),
324		| hosts => ProxyHosts::from(hosts.as_slice()),
325	};
326
327	Ok(Self { configured, environment, proxies, hosts })
328}
329
330#[implement(EnvironmentProxy)]
331fn with_vars<F>(is_cgi: bool, var: &F) -> Result<EnvironmentProxyBuild>
332where
333	F: Fn(&str) -> Option<String>,
334{
335	if is_cgi {
336		return Ok(EnvironmentProxyBuild::default());
337	}
338
339	let http = first_var(var, ["HTTP_PROXY", "http_proxy"])
340		.as_deref()
341		.and_then(parse_environment_url);
342
343	let https = first_var(var, ["HTTPS_PROXY", "https_proxy"])
344		.as_deref()
345		.and_then(parse_environment_url);
346
347	let all = (http.is_none() || https.is_none())
348		.then(|| {
349			first_var(var, ["ALL_PROXY", "all_proxy"])
350				.as_deref()
351				.and_then(parse_environment_url)
352		})
353		.flatten();
354
355	if http.is_none() && https.is_none() && all.is_none() {
356		return Ok(EnvironmentProxyBuild::default());
357	}
358
359	let no_proxy = first_var(var, ["NO_PROXY", "no_proxy"]).unwrap_or_default();
360	let bypass = NoProxyRules::new(&no_proxy);
361	let route = |proxy: &Option<EnvironmentEndpoint>| {
362		proxy
363			.as_ref()
364			.or(all.as_ref())
365			.map(|proxy| proxy.scheme)
366	};
367
368	let policy = Self {
369		http: route(&http),
370		https: route(&https),
371		bypass,
372	};
373
374	let mut http = http;
375	let mut https = https;
376	let mut all = all;
377	let take_host = |proxy: &mut Option<EnvironmentEndpoint>,
378	                 all: &mut Option<EnvironmentEndpoint>| {
379		proxy
380			.as_mut()
381			.and_then(|proxy| proxy.host.take())
382			.or_else(|| all.as_mut().and_then(|proxy| proxy.host.take()))
383	};
384
385	let hosts = [take_host(&mut http, &mut all), take_host(&mut https, &mut all)];
386
387	let proxies = environment_proxies(http, https, all, &no_proxy)?;
388
389	Ok(EnvironmentProxyBuild { policy, proxies, hosts })
390}
391
392fn first_var<F>(var: &F, names: [&str; 2]) -> Option<String>
393where
394	F: Fn(&str) -> Option<String>,
395{
396	names.into_iter().find_map(var)
397}
398
399#[cfg(test)]
400pub(super) fn parse_environment_proxy_url(raw: &str) -> Option<Url> {
401	parse_environment_url(raw).map(|proxy| proxy.url)
402}
403
404fn parse_environment_url(raw: &str) -> Option<EnvironmentEndpoint> {
405	let uri = raw.parse::<Uri>().ok()?;
406	let scheme = uri
407		.scheme_str()
408		.map_or(Some(ProxyScheme::Http), ProxyScheme::parse)?;
409
410	let authority = uri.authority()?;
411	let (userinfo, host_port) = authority
412		.as_str()
413		.split_once('@')
414		.map_or((None, authority.as_str()), |(userinfo, host_port)| (Some(userinfo), host_port));
415
416	let uri = Uri::builder()
417		.scheme(scheme.as_str())
418		.authority(host_port)
419		.path_and_query("/")
420		.build()
421		.ok()?;
422
423	let host = proxy_uri_hostname(&uri).map(ProxyHost::from);
424	let url = effective_environment_url(&uri, userinfo)?;
425
426	Some(EnvironmentEndpoint { scheme, url, host })
427}
428
429fn proxy_uri_hostname(uri: &Uri) -> Option<&str> {
430	let host = proxy_uri_host(uri.host()?);
431
432	host.parse::<IpAddr>().is_err().then_some(host)
433}
434
435fn proxy_uri_host(host: &str) -> &str {
436	host.strip_prefix('[')
437		.and_then(|host| host.strip_suffix(']'))
438		.unwrap_or(host)
439}
440
441fn effective_environment_url(uri: &Uri, userinfo: Option<&str>) -> Option<Url> {
442	let scheme = uri.scheme_str()?;
443	let authority = uri.authority()?;
444	let host = proxy_uri_host(authority.host());
445
446	let port = authority
447		.port_u16()
448		.unwrap_or(if scheme == "https" { 443 } else { 80 });
449
450	let mut normalized = ProxyUrlString::new();
451
452	normalized.push_str(scheme);
453	normalized.push_str("://");
454	if let Some(userinfo) = userinfo {
455		normalized.push_str(userinfo);
456		normalized.push('@');
457	}
458
459	if matches!(host.parse::<IpAddr>(), Ok(IpAddr::V6(_))) {
460		normalized.push('[');
461		normalized.push_str(host);
462		normalized.push(']');
463	} else {
464		normalized.push_str(host);
465	}
466
467	write!(&mut normalized, ":{port}/").ok()?;
468	Url::parse(&normalized).ok()
469}
470
471fn environment_proxies(
472	http: Option<EnvironmentEndpoint>,
473	https: Option<EnvironmentEndpoint>,
474	all: Option<EnvironmentEndpoint>,
475	no_proxy: &str,
476) -> Result<[Option<Proxy>; 2]> {
477	if http.is_none() && https.is_none() {
478		let proxy = all
479			.map(|proxy| {
480				let bypass = ReqwestNoProxy::from_string(no_proxy);
481
482				Proxy::all(proxy.url).map(|proxy| proxy.no_proxy(bypass))
483			})
484			.transpose()?;
485
486		return Ok([proxy, None]);
487	}
488
489	let (http, https) = match (http, https, all) {
490		| (Some(http), Some(https), _) => (Some(http), Some(https)),
491		| (Some(http), None, all) => (Some(http), all),
492		| (None, Some(https), all) => (all, Some(https)),
493		| (None, None, _) => (None, None),
494	};
495
496	let bypass = ReqwestNoProxy::from_string(no_proxy);
497
498	match (http, https) {
499		| (None, None) => Ok([None, None]),
500		| (Some(http), None) => {
501			let proxy = Proxy::http(http.url)?.no_proxy(bypass);
502
503			Ok([Some(proxy), None])
504		},
505		| (None, Some(https)) => {
506			let proxy = Proxy::https(https.url)?.no_proxy(bypass);
507
508			Ok([None, Some(proxy)])
509		},
510		| (Some(http), Some(https)) if http == https => {
511			let proxy = Proxy::all(http.url)?.no_proxy(bypass);
512
513			Ok([Some(proxy), None])
514		},
515		| (Some(http), Some(https)) => {
516			let http = Proxy::http(http.url)?.no_proxy(bypass.clone());
517			let https = Proxy::https(https.url)?.no_proxy(bypass);
518
519			Ok([Some(http), Some(https)])
520		},
521	}
522}
523
524#[implement(ProxyConfig)]
525fn to_proxy_shared(config: &Arc<Self>) -> Result<Option<Proxy>> {
526	config.validate_proxy_schemes()?;
527
528	let proxy = match config.as_ref() {
529		| Self::None => None,
530		| Self::Global { url } => Some(Proxy::all(url.clone())?),
531		| Self::ByDomain(_) => {
532			let config = Arc::clone(config);
533
534			Some(Proxy::custom(move |url| config.proxy_for(url).cloned()))
535		},
536	};
537
538	Ok(proxy)
539}
540
541/// Applies this snapshot to an outbound HTTP client builder.
542///
543/// Environment proxies are explicit so later environment changes cannot alter
544/// a lazily built client.
545#[implement(ProxySnapshot)]
546#[must_use]
547pub fn configure(&self, builder: ClientBuilder) -> ClientBuilder {
548	let builder = if self.environment.is_some() {
549		builder.no_proxy()
550	} else {
551		builder
552	};
553
554	self.proxies
555		.iter()
556		.cloned()
557		.fold(builder, ClientBuilder::proxy)
558}
559
560/// Iterates over proxy endpoint names used by this snapshot.
561///
562/// The names include the configured surface or the effective environment
563/// proxies, never both.
564#[implement(ProxySnapshot)]
565#[inline]
566pub fn hosts(&self) -> impl Iterator<Item = &str> { self.hosts.iter().map(ProxyHost::as_str) }
567
568/// Shares proxy endpoint names with a validating resolver.
569///
570/// Cloning the returned value increments one reference count and does not
571/// duplicate any hostname.
572#[implement(ProxySnapshot)]
573#[inline]
574#[must_use]
575pub fn shared_hosts(&self) -> ProxyHosts { Arc::clone(&self.hosts) }
576
577/// Reports whether this snapshot carries a request URL through a proxy.
578///
579/// Environment matching preserves scheme selection and `NO_PROXY`; explicit
580/// rules use the configuration predicate.
581#[implement(ProxySnapshot)]
582#[inline]
583#[must_use]
584pub fn intercepts(&self, url: &Url) -> bool { self.proxy_scheme(url).is_some() }
585
586/// Reports a destination that can alias a proxy resolver exemption.
587///
588/// Direct requests and local-DNS SOCKS requests resolve the destination in
589/// this process, so guarded clients reject an endpoint-name collision.
590#[implement(ProxySnapshot)]
591#[must_use]
592pub fn resolver_alias(&self, url: &Url) -> bool {
593	let is_proxy_host = url.host_str().is_some_and(|host| {
594		self.hosts
595			.iter()
596			.any(|proxy| proxy.eq_ignore_ascii_case(host))
597	});
598
599	is_proxy_host
600		&& self
601			.proxy_scheme(url)
602			.is_none_or(|scheme| !scheme.resolves_remotely())
603}
604
605#[implement(ProxySnapshot)]
606fn proxy_scheme(&self, url: &Url) -> Option<ProxyScheme> {
607	self.configured
608		.as_deref()
609		.and_then(|proxy| proxy.proxy_for(url))
610		.and_then(|proxy| ProxyScheme::parse(proxy.scheme()))
611		.or_else(|| {
612			self.environment
613				.as_ref()
614				.and_then(|proxy| proxy.proxy_for(url))
615		})
616}
617
618#[implement(EnvironmentProxy)]
619fn proxy_for(&self, url: &Url) -> Option<ProxyScheme> {
620	let proxy = match url.scheme() {
621		| "http" => self.http,
622		| "https" => self.https,
623		| _ => None,
624	}?;
625
626	let host = url.host()?;
627
628	(!self.bypass.contains(&host)).then_some(proxy)
629}
630
631#[implement(ProxyScheme)]
632const fn resolves_remotely(self) -> bool {
633	matches!(self, Self::Http | Self::Https | Self::Socks4a | Self::Socks5h)
634}
635
636#[implement(NoProxyRules)]
637fn new(raw: &str) -> Self {
638	let (all_domains, ips, domains) = raw
639		.split(',')
640		.map(str::trim)
641		.filter(|part| !part.is_empty())
642		.fold(
643			(false, Vec::new(), Vec::new()),
644			|(mut all_domains, mut ips, mut domains), part| {
645				if part == "*" {
646					all_domains = true;
647					domains.clear();
648				} else if let Ok(network) = part.parse::<IpNet>() {
649					ips.push(Network::Range(network));
650				} else if let Ok(address) = part.parse::<IpAddr>() {
651					ips.push(Network::Address(address));
652				} else if !all_domains {
653					domains.push(part.into());
654				}
655
656				(all_domains, ips, domains)
657			},
658		);
659
660	Self {
661		all_domains,
662		ips: ips.into_boxed_slice(),
663		domains: domains.into_boxed_slice(),
664	}
665}
666
667#[implement(NoProxyRules)]
668fn contains(&self, host: &Host<&str>) -> bool {
669	match host {
670		| Host::Ipv4(ip) => self.contains_ip(IpAddr::V4(*ip)),
671		| Host::Ipv6(ip) => self.contains_ip(IpAddr::V6(*ip)),
672		| Host::Domain(host) =>
673			self.all_domains
674				|| self
675					.domains
676					.iter()
677					.any(|domain| hostname_matches_domain(host, domain)),
678	}
679}
680
681#[implement(NoProxyRules)]
682fn contains_ip(&self, ip: IpAddr) -> bool {
683	self.ips.iter().any(|network| match network {
684		| Network::Address(address) => *address == ip,
685		| Network::Range(range) => range.contains(&ip),
686	})
687}
688
689/// Associates one proxy URL with include and exclude patterns.
690///
691/// An empty include list matches every domain. When both lists match, the more
692/// specific wildcard pattern decides whether the proxy applies.
693#[derive(Clone, Debug, Deserialize)]
694pub struct PartialProxyConfig {
695	#[serde(deserialize_with = "crate::utils::deserialize_from_str")]
696	url: Url,
697	#[serde(default)]
698	include: Vec<WildCardedDomain>,
699	#[serde(default)]
700	exclude: Vec<WildCardedDomain>,
701}
702impl PartialProxyConfig {
703	#[must_use]
704	/// Selects this rule's proxy URL for a request URL.
705	///
706	/// A URL without a domain does not match. Otherwise the most specific
707	/// include and exclude patterns compete, with inclusion required for a
708	/// result.
709	pub fn for_url(&self, url: &Url) -> Option<&Url> {
710		let domain = url.domain()?;
711		let mut included_because = None; // most specific reason it was included
712		let mut excluded_because = None; // most specific reason it was excluded
713		if self.include.is_empty() {
714			// treat empty include list as `*`
715			included_because = Some(&WildCardedDomain::WildCard);
716		}
717		for wc_domain in &self.include {
718			if wc_domain.matches(domain) {
719				match included_because {
720					| Some(prev) if !wc_domain.more_specific_than(prev) => (),
721					| _ => included_because = Some(wc_domain),
722				}
723			}
724		}
725		for wc_domain in &self.exclude {
726			if wc_domain.matches(domain) {
727				match excluded_because {
728					| Some(prev) if !wc_domain.more_specific_than(prev) => (),
729					| _ => excluded_because = Some(wc_domain),
730				}
731			}
732		}
733		match (included_because, excluded_because) {
734			| (Some(a), Some(b)) if a.more_specific_than(b) => Some(&self.url),
735			| (Some(_), None) => Some(&self.url),
736			| _ => None,
737		}
738	}
739}
740
741/// A domain name, that optionally allows a * as its first subdomain.
742#[derive(Clone, Debug)]
743enum WildCardedDomain {
744	WildCard,
745	WildCarded(String),
746	Exact(String),
747}
748impl WildCardedDomain {
749	fn matches(&self, domain: &str) -> bool {
750		match self {
751			| Self::WildCard => true,
752			| Self::WildCarded(d) => domain.ends_with(d),
753			| Self::Exact(d) => domain == d,
754		}
755	}
756
757	fn more_specific_than(&self, other: &Self) -> bool {
758		match (self, other) {
759			| (Self::WildCard, Self::WildCard) => false,
760			| (_, Self::WildCard) => true,
761			| (Self::Exact(a), Self::WildCarded(_)) => other.matches(a),
762			| (Self::WildCarded(a), Self::WildCarded(b)) => a != b && a.ends_with(b),
763			| _ => false,
764		}
765	}
766}
767impl std::str::FromStr for WildCardedDomain {
768	type Err = std::convert::Infallible;
769
770	#[expect(clippy::string_slice)]
771	fn from_str(s: &str) -> Result<Self, Self::Err> {
772		// maybe do some domain validation?
773		Ok(if s.starts_with("*.") {
774			Self::WildCarded(s[1..].to_owned())
775		} else if s == "*" {
776			Self::WildCarded(String::new())
777		} else {
778			Self::Exact(s.to_owned())
779		})
780	}
781}
782impl<'de> Deserialize<'de> for WildCardedDomain {
783	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
784	where
785		D: serde::de::Deserializer<'de>,
786	{
787		crate::utils::deserialize_from_str(deserializer)
788	}
789}