rama_net/address/
host_pattern.rs1use core::{fmt, str::FromStr};
2
3use crate::std::{
4 borrow::{Cow, ToOwned as _},
5 boxed::Box,
6 string::String,
7};
8
9use rama_core::error::{BoxError, BoxErrorExt, ErrorContext};
10use rama_utils::thirdparty::wildcard::Wildcard;
11
12use super::{Domain, DomainPattern, Host, HostRef, domain::build_glob};
13
14#[derive(Clone)]
29pub struct HostPattern(HostPatternKind);
30
31#[derive(Clone)]
32enum HostPatternKind {
33 Exact(Host),
34 Domain(DomainPattern),
35 Glob(Wildcard<'static>),
36}
37
38impl HostPattern {
39 #[must_use]
43 pub const fn exact(host: Host) -> Self {
44 Self(HostPatternKind::Exact(host))
45 }
46
47 #[must_use]
51 pub fn sub(domain: Domain) -> Self {
52 DomainPattern::sub(domain).into()
53 }
54
55 pub fn try_glob(pattern: impl Into<Cow<'static, str>>) -> Result<Self, BoxError> {
62 let pattern = pattern.into();
63 if pattern.is_empty() {
64 return Err(BoxError::from_static_str("host glob cannot be empty"));
65 }
66 if !pattern.is_ascii() {
67 return Err(BoxError::from_static_str(
68 "host glob must be ASCII; use an exact or subtree pattern for IDNA names",
69 ));
70 }
71 if !pattern.contains('*') {
72 return Err(BoxError::from_static_str(
73 "host glob must contain at least one '*' wildcard",
74 ));
75 }
76 Ok(Self(HostPatternKind::Glob(build_glob(pattern)?)))
77 }
78
79 pub fn try_new(pattern: impl TryIntoHostPattern) -> Result<Self, BoxError> {
84 private::TryIntoHostPatternPriv::try_into_host_pattern(pattern)
85 }
86
87 #[must_use]
89 pub fn matches(&self, host: HostRef<'_>) -> bool {
90 self.matches_with_text(host, None)
91 }
92
93 #[cfg(feature = "std")]
94 pub(crate) fn is_glob(&self) -> bool {
95 matches!(self.0, HostPatternKind::Glob(_))
96 }
97
98 pub(crate) fn matches_with_text(&self, host: HostRef<'_>, host_text: Option<&str>) -> bool {
99 match &self.0 {
100 HostPatternKind::Exact(expected) => host == expected.view(),
101 HostPatternKind::Domain(pattern) => match host {
102 HostRef::Name(domain) => pattern.matches(domain),
103 HostRef::Uninterpreted(_) => host
104 .try_as_domain()
105 .is_ok_and(|domain| pattern.matches(domain.view())),
106 HostRef::Address(_) => false,
107 },
108 HostPatternKind::Glob(pattern) => {
109 let host = host_text.map_or_else(|| host.to_str(), Cow::Borrowed);
110 let host = host.strip_suffix('.').unwrap_or(&host);
111 pattern.is_match(host.as_bytes())
112 }
113 }
114 }
115}
116
117impl fmt::Debug for HostPattern {
118 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119 match &self.0 {
120 HostPatternKind::Exact(host) => f.debug_tuple("Exact").field(host).finish(),
121 HostPatternKind::Domain(pattern) => f.debug_tuple("Domain").field(pattern).finish(),
122 HostPatternKind::Glob(_) => f.write_str("Glob(..)"),
123 }
124 }
125}
126
127impl FromStr for HostPattern {
128 type Err = BoxError;
129
130 fn from_str(pattern: &str) -> Result<Self, Self::Err> {
131 let pattern = pattern.trim();
132 match Host::try_from(pattern) {
133 Ok(Host::Name(domain)) => {
134 let kind = match domain
135 .as_wildcard_parent()
136 .or_else(|| domain.strip_leading_dot())
137 {
138 Some(apex) => HostPatternKind::Domain(DomainPattern::sub(apex)),
139 None => HostPatternKind::Exact(Host::Name(domain)),
140 };
141 Ok(Self(kind))
142 }
143 Ok(_) if pattern.contains('*') => Self::try_glob(pattern.to_owned()),
144 Ok(host) => Ok(Self::exact(host)),
145 Err(error) => Err(error).context("parse exact host pattern"),
146 }
147 }
148}
149
150impl TryFrom<String> for HostPattern {
151 type Error = BoxError;
152
153 fn try_from(pattern: String) -> Result<Self, Self::Error> {
154 pattern.parse()
155 }
156}
157
158impl TryFrom<Box<str>> for HostPattern {
159 type Error = BoxError;
160
161 fn try_from(pattern: Box<str>) -> Result<Self, Self::Error> {
162 pattern.parse()
163 }
164}
165
166impl From<DomainPattern> for HostPattern {
169 fn from(pattern: DomainPattern) -> Self {
170 Self(HostPatternKind::Domain(pattern))
171 }
172}
173
174impl TryFrom<HostPattern> for DomainPattern {
175 type Error = BoxError;
176
177 fn try_from(pattern: HostPattern) -> Result<Self, Self::Error> {
178 match pattern.0 {
179 HostPatternKind::Exact(host) => host
180 .try_into_domain()
181 .map(Self::exact)
182 .context("exact host pattern is not a domain"),
183 HostPatternKind::Domain(pattern) => Ok(pattern),
184 HostPatternKind::Glob(_) => Err(BoxError::from_static_str(
185 "a flat host glob cannot be narrowed to a domain pattern",
186 )),
187 }
188 }
189}
190
191#[expect(private_bounds)]
192pub trait TryIntoHostPattern: private::TryIntoHostPatternPriv {}
198
199impl TryIntoHostPattern for HostPattern {}
200impl TryIntoHostPattern for DomainPattern {}
201impl TryIntoHostPattern for &str {}
202impl TryIntoHostPattern for String {}
203impl TryIntoHostPattern for Box<str> {}
204
205mod private {
206 use super::*;
207
208 pub(super) trait TryIntoHostPatternPriv {
209 fn try_into_host_pattern(self) -> Result<HostPattern, BoxError>;
210 }
211
212 impl TryIntoHostPatternPriv for HostPattern {
213 fn try_into_host_pattern(self) -> Result<HostPattern, BoxError> {
214 Ok(self)
215 }
216 }
217
218 impl TryIntoHostPatternPriv for DomainPattern {
219 fn try_into_host_pattern(self) -> Result<HostPattern, BoxError> {
220 Ok(self.into())
221 }
222 }
223
224 impl TryIntoHostPatternPriv for &str {
225 fn try_into_host_pattern(self) -> Result<HostPattern, BoxError> {
226 self.parse()
227 }
228 }
229
230 impl TryIntoHostPatternPriv for String {
231 fn try_into_host_pattern(self) -> Result<HostPattern, BoxError> {
232 self.parse()
233 }
234 }
235
236 impl TryIntoHostPatternPriv for Box<str> {
237 fn try_into_host_pattern(self) -> Result<HostPattern, BoxError> {
238 self.parse()
239 }
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 #[test]
248 fn explicit_constructors_do_not_parse_or_guess_semantics() {
249 let exact = HostPattern::exact(Host::from(Domain::from_static("example.com")));
250 assert!(exact.matches(Host::from(Domain::from_static("example.com")).view()));
251 assert!(!exact.matches(Host::from(Domain::from_static("api.example.com")).view()));
252
253 let sub = HostPattern::sub(Domain::from_static("example.com"));
254 assert!(sub.matches(Host::from(Domain::from_static("api.example.com")).view()));
255 }
256
257 #[test]
258 fn parser_supports_exact_subtree_and_flat_glob_patterns() {
259 let exact = HostPattern::try_new("127.0.0.1").unwrap();
260 let sub = HostPattern::try_new("*.example.com").unwrap();
261 let glob = HostPattern::try_new("192.168.*").unwrap();
262 let wildcard_prefix_glob = HostPattern::try_new("*.corp*").unwrap();
263 let ip_glob = HostPattern::try_new("*.1*").unwrap();
264 let all = HostPattern::try_new("*").unwrap();
265
266 assert!(exact.matches(Host::try_from("127.0.0.1").unwrap().view()));
267 assert!(sub.matches(Host::try_from("deep.api.example.com").unwrap().view()));
268 assert!(glob.matches(Host::try_from("192.168.10.20").unwrap().view()));
269 assert!(wildcard_prefix_glob.matches(Host::try_from("api.corporate").unwrap().view()));
270 assert!(!wildcard_prefix_glob.matches(Host::try_from("corp.example").unwrap().view()));
271 assert!(ip_glob.matches(Host::try_from("10.1.2.3").unwrap().view()));
272 assert!(all.matches(Host::try_from("example.com").unwrap().view()));
273 assert!(all.matches(Host::try_from("2001:db8::1").unwrap().view()));
274 HostPattern::try_new("bad pattern*").unwrap_err();
275 }
276
277 #[test]
278 fn explicit_glob_matches_the_host_text() {
279 let pattern = HostPattern::try_glob("api-*.example.com").unwrap();
280 assert!(pattern.matches(Host::try_from("api-one.example.com").unwrap().view()));
281 assert!(pattern.matches(Host::try_from("api-one.example.com.").unwrap().view()));
282 }
283
284 #[test]
285 fn glob_rejects_non_ascii_patterns() {
286 HostPattern::try_glob("mün*.example").unwrap_err();
287 }
288
289 #[test]
290 fn domain_conversion_is_intentionally_asymmetric() {
291 let domain = DomainPattern::sub(Domain::from_static("example.com"));
292 let host = HostPattern::from(domain);
293 let domain = DomainPattern::try_from(host).unwrap();
294 assert!(domain.matches(Domain::from_static("api.example.com").view()));
295
296 let ip = HostPattern::exact(Host::try_from("127.0.0.1").unwrap());
297 DomainPattern::try_from(ip).unwrap_err();
298
299 let glob = HostPattern::try_glob("api-*.example.com").unwrap();
300 DomainPattern::try_from(glob).unwrap_err();
301 }
302
303 #[test]
304 fn try_into_trait_preserves_existing_pattern_semantics() {
305 let domain = DomainPattern::sub(Domain::from_static("example.com"));
306 let host = HostPattern::try_new(domain).unwrap();
307 let domain = DomainPattern::try_from(host).unwrap();
308 assert!(domain.matches(Domain::from_static("api.example.com").view()));
309 }
310}