Skip to main content

tuwunel_core/utils/
url.rs

1//! Hostname and URL matching utilities.
2//!
3//! These helpers provide reusable matching rules that the URL parser does not
4//! expose directly.
5
6use std::fmt::{self, Display};
7
8use http::Uri;
9
10/// Reports whether a hostname is equal to or beneath a domain name.
11///
12/// Matching is ASCII case-insensitive and accepts a domain with an optional
13/// leading dot. A suffix only matches at a DNS label boundary. A single dot
14/// matches only a hostname with a trailing dot.
15#[must_use]
16pub fn hostname_matches_domain(hostname: &str, domain: &str) -> bool {
17	if domain == "." {
18		return hostname.ends_with('.');
19	}
20
21	let domain = domain.strip_prefix('.').unwrap_or(domain);
22
23	if domain.is_empty() {
24		return false;
25	}
26
27	if hostname.eq_ignore_ascii_case(domain) {
28		return true;
29	}
30
31	let Some(separator) = hostname
32		.len()
33		.checked_sub(domain.len())
34		.and_then(|index| index.checked_sub(1))
35	else {
36		return false;
37	};
38
39	let Some(suffix_start) = separator.checked_add(1) else {
40		return false;
41	};
42
43	hostname.as_bytes().get(separator) == Some(&b'.')
44		&& hostname
45			.get(suffix_start..)
46			.is_some_and(|suffix| suffix.eq_ignore_ascii_case(domain))
47}
48
49/// Formats a request URI without exposing sensitive components.
50///
51/// Query values are always replaced. A matched route template can replace
52/// concrete path parameters when the caller has one.
53pub struct SanitizedUri<'a> {
54	uri: &'a Uri,
55	path: Option<&'a str>,
56}
57
58impl<'a> SanitizedUri<'a> {
59	/// Creates a redacted display wrapper for a request URI.
60	///
61	/// The original path is retained, while any query value is replaced.
62	#[must_use]
63	pub const fn new(uri: &'a Uri) -> Self { Self { uri, path: None } }
64
65	/// Uses a route template instead of concrete path parameters.
66	///
67	/// Query values remain replaced when the URI contains a query.
68	#[must_use]
69	pub const fn with_path(uri: &'a Uri, path: &'a str) -> Self { Self { uri, path: Some(path) } }
70}
71
72impl Display for SanitizedUri<'_> {
73	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74		let path = self.path.unwrap_or_else(|| self.uri.path());
75
76		match self.uri.query() {
77			| Some(_) => write!(f, "{path}?<redacted>"),
78			| None => f.write_str(path),
79		}
80	}
81}
82
83#[cfg(test)]
84mod tests {
85	use super::*;
86
87	#[test]
88	fn redacts_query() {
89		let uri: Uri = "/_matrix/client/v3/sync?access_token=syt_realtoken"
90			.parse()
91			.expect("valid uri");
92
93		let out = SanitizedUri::new(&uri).to_string();
94
95		assert_eq!(out, "/_matrix/client/v3/sync?<redacted>");
96		assert!(!out.contains("access_token"));
97		assert!(!out.contains("syt_realtoken"));
98	}
99
100	#[test]
101	fn preserves_path_without_query() {
102		let uri: Uri = "/_matrix/client/v3/sync"
103			.parse()
104			.expect("valid uri");
105
106		assert_eq!(SanitizedUri::new(&uri).to_string(), "/_matrix/client/v3/sync");
107	}
108
109	#[test]
110	fn replaces_concrete_path_parameters() {
111		let uri = Uri::builder()
112			.path_and_query(
113				"/_matrix/client/unstable/org.matrix.msc4140/delayed_events/secret?action=send",
114			)
115			.build()
116			.expect("valid uri");
117
118		let out = SanitizedUri::with_path(
119			&uri,
120			"/_matrix/client/unstable/org.matrix.msc4140/delayed_events/{delay_id}",
121		)
122		.to_string();
123
124		assert_eq!(
125			out,
126			"/_matrix/client/unstable/org.matrix.msc4140/delayed_events/{delay_id}?<redacted>"
127		);
128		assert!(!out.contains("secret"));
129	}
130}