Skip to main content

rama_net/address/
host_pattern.rs

1use 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/// A compiled pattern for matching a [`Host`].
15///
16/// The representation is intentionally private. Construct patterns explicitly
17/// with [`exact`][Self::exact], [`sub`][Self::sub], or
18/// [`try_glob`][Self::try_glob], or parse conventional syntax through
19/// [`try_new`][Self::try_new].
20///
21/// ```
22/// use rama_net::address::{Domain, Host, HostPattern};
23///
24/// let pattern = HostPattern::sub(Domain::from_static("example.com"));
25/// assert!(pattern.matches(Host::from_static("api.example.com").view()));
26/// assert!(!pattern.matches(Host::LOCALHOST_IPV4.view()));
27/// ```
28#[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    /// Match exactly one host.
40    ///
41    /// This constructor performs no parsing.
42    #[must_use]
43    pub const fn exact(host: Host) -> Self {
44        Self(HostPatternKind::Exact(host))
45    }
46
47    /// Match a domain and all of its descendants.
48    ///
49    /// This constructor performs no parsing.
50    #[must_use]
51    pub fn sub(domain: Domain) -> Self {
52        DomainPattern::sub(domain).into()
53    }
54
55    /// Compile a flat case-insensitive host glob.
56    ///
57    /// `*` matches any sequence of bytes, including dots. Matching is ASCII
58    /// case-insensitive against [`HostRef::to_str`], so a glob can match
59    /// domain names and IP literals alike. A static string is borrowed by the
60    /// compiled wildcard; an owned [`String`] transfers its allocation.
61    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    /// Parse a host pattern.
80    ///
81    /// Plain hosts are exact, `.example.com` and `*.example.com` are domain
82    /// subtree patterns, and other values containing `*` are flat host globs.
83    pub fn try_new(pattern: impl TryIntoHostPattern) -> Result<Self, BoxError> {
84        private::TryIntoHostPatternPriv::try_into_host_pattern(pattern)
85    }
86
87    /// Return whether this pattern matches `host`.
88    #[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
166/// Preserve a domain pattern's exact, subtree, or glob semantics while
167/// widening its candidate type to [`Host`].
168impl 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)]
192/// Convert owned or borrowed pattern syntax into a [`HostPattern`].
193///
194/// This trait is sealed. It is implemented for [`HostPattern`],
195/// [`DomainPattern`], `&str`, [`String`], and `Box<str>`, but deliberately not
196/// for [`Host`] or [`Domain`]: callers must choose exact or subtree semantics.
197pub 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}