Skip to main content

microsandbox_network/policy/
name.rs

1//! Validated DNS name type used in [`super::Destination`] rules.
2//!
3//! Rules stored on a [`NetworkPolicy`] identify hosts by string, and
4//! user input reaches the policy through several paths (programmatic
5//! construction, JSON deserialization, CLI-supplied JSON). Each path
6//! used to re-apply the same ad-hoc canonicalization (lowercase, trim
7//! trailing dot, strip leading dot for suffixes) and it was easy to
8//! miss — struct-literal construction, in particular, bypassed every
9//! entry point and silently produced rules that never matched.
10//!
11//! [`DomainName`] closes that gap. The inner field is private and the
12//! only way to build one is via [`str::parse`] (or serde, which routes
13//! through the same parser), so the canonical form is a type-level
14//! invariant rather than a convention. Matching code then collapses
15//! to byte equality on the pre-canonicalized string.
16//!
17//! Validation is delegated to `hickory_proto::rr::Name`, which accepts
18//! the real-world DNS label grammar (RFC 2181 §11) rather than the
19//! stricter "preferred name syntax" of RFC 1035 §2.3.1 / RFC 1123
20//! §2.1. That lets `_service._tcp.example.com`, DKIM selectors, and
21//! similarly underscore-bearing names through, matching what the
22//! sandbox's DNS interceptor will actually resolve on the wire.
23//!
24//! [`NetworkPolicy`]: super::NetworkPolicy
25
26use std::fmt;
27use std::str::FromStr;
28
29use hickory_proto::ProtoError;
30use hickory_proto::rr::Name;
31use serde::{Deserialize, Serialize};
32
33//--------------------------------------------------------------------------------------------------
34// Types
35//--------------------------------------------------------------------------------------------------
36
37/// Canonical DNS name used in network policy rules.
38///
39/// Strictly a hostname — port suffixes, userinfo, schemes, paths,
40/// queries, and fragments are rejected at parse time so a rule can
41/// never end up "matching" a name no DNS responder will ever return.
42///
43/// Constructed via `str::parse` or `TryFrom<String>`; both route through
44/// the same validation and canonicalization. The inner form is
45/// lowercased ASCII with no leading or trailing dots — the same form
46/// the DNS interceptor stores on the resolved-hostname cache, which
47/// lets match-time comparisons be byte-equal rather than
48/// case-insensitive.
49#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
50#[serde(try_from = "String", into = "String")]
51pub struct DomainName(String);
52
53/// Errors reported when a string cannot be turned into a [`DomainName`].
54#[derive(Debug, Clone, thiserror::Error)]
55pub enum DomainNameError {
56    /// Input was empty (or contained only dots).
57    #[error("domain name is empty")]
58    Empty,
59
60    /// Input failed the DNS label grammar check (bad length, control
61    /// chars, invalid UTF-8 for an ASCII name, etc.).
62    #[error("invalid domain name: {0}")]
63    Invalid(#[from] ProtoError),
64}
65
66//--------------------------------------------------------------------------------------------------
67// Methods
68//--------------------------------------------------------------------------------------------------
69
70impl DomainName {
71    /// Borrow the canonical string form. The returned slice has no
72    /// trailing dot and is lowercased ASCII.
73    pub fn as_str(&self) -> &str {
74        &self.0
75    }
76}
77
78impl FromStr for DomainName {
79    type Err = DomainNameError;
80
81    fn from_str(s: &str) -> Result<Self, Self::Err> {
82        // Leading-dot acceptance is ergonomic for suffixes
83        // (`.example.com`). Trailing-dot acceptance matches FQDN
84        // inputs coming from DNS responses or hand-typed FQDNs.
85        let trimmed = s.trim_start_matches('.').trim_end_matches('.');
86        if trimmed.is_empty() {
87            return Err(DomainNameError::Empty);
88        }
89        // Validate via hickory. We discard the parsed Name and keep
90        // the lowercased ASCII string so matching is a plain `==`
91        // against the cache entries.
92        let _name: Name = trimmed.parse()?;
93        Ok(Self(trimmed.to_ascii_lowercase()))
94    }
95}
96
97impl TryFrom<String> for DomainName {
98    type Error = DomainNameError;
99
100    fn try_from(s: String) -> Result<Self, Self::Error> {
101        s.parse()
102    }
103}
104
105impl TryFrom<&str> for DomainName {
106    type Error = DomainNameError;
107
108    fn try_from(s: &str) -> Result<Self, Self::Error> {
109        s.parse()
110    }
111}
112
113impl From<DomainName> for String {
114    fn from(name: DomainName) -> Self {
115        name.0
116    }
117}
118
119impl fmt::Display for DomainName {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        f.write_str(&self.0)
122    }
123}
124
125//--------------------------------------------------------------------------------------------------
126// Tests
127//--------------------------------------------------------------------------------------------------
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn parses_plain_lowercase_name() {
135        let name: DomainName = "pypi.org".parse().unwrap();
136        assert_eq!(name.as_str(), "pypi.org");
137    }
138
139    #[test]
140    fn canonicalizes_case_and_trailing_dot() {
141        let name: DomainName = "PyPI.Org.".parse().unwrap();
142        assert_eq!(name.as_str(), "pypi.org");
143    }
144
145    #[test]
146    fn strips_leading_dot_for_suffix_ergonomics() {
147        let name: DomainName = ".pythonhosted.org".parse().unwrap();
148        assert_eq!(name.as_str(), "pythonhosted.org");
149    }
150
151    #[test]
152    fn canonical_form_is_idempotent() {
153        let once: DomainName = "Example.COM.".parse().unwrap();
154        let twice: DomainName = once.as_str().parse().unwrap();
155        assert_eq!(once, twice);
156    }
157
158    #[test]
159    fn accepts_underscore_labels() {
160        // SRV / DKIM / Kubernetes names rely on underscore labels
161        // (RFC 2181 §11). Rejecting them would break real-world
162        // policy inputs.
163        let name: DomainName = "_http._tcp.example.com".parse().unwrap();
164        assert_eq!(name.as_str(), "_http._tcp.example.com");
165    }
166
167    #[test]
168    fn rejects_empty_input() {
169        assert!(matches!(
170            "".parse::<DomainName>(),
171            Err(DomainNameError::Empty)
172        ));
173        assert!(matches!(
174            "...".parse::<DomainName>(),
175            Err(DomainNameError::Empty)
176        ));
177    }
178
179    #[test]
180    fn rejects_whitespace_in_labels() {
181        assert!("foo bar.example".parse::<DomainName>().is_err());
182    }
183
184    #[test]
185    fn serde_round_trip_preserves_canonical_form() {
186        let name: DomainName = ".PyPI.Org.".parse().unwrap();
187        let json = serde_json::to_string(&name).unwrap();
188        assert_eq!(json, r#""pypi.org""#);
189        let back: DomainName = serde_json::from_str(&json).unwrap();
190        assert_eq!(back, name);
191    }
192
193    #[test]
194    fn serde_deserialize_validates() {
195        assert!(serde_json::from_str::<DomainName>(r#""foo bar.example""#).is_err());
196        assert!(serde_json::from_str::<DomainName>(r#""""#).is_err());
197    }
198
199    #[test]
200    fn rejects_url_decorations() {
201        for bad in [
202            "bar.example:443",       // host:port
203            "user@example.com",      // userinfo@host
204            "user:pass@example.com", // userinfo:pass@host
205            "https://example.com",   // scheme://host
206            "example.com/path",      // host/path
207            "example.com?q=1",       // host?query
208            "example.com#frag",      // host#fragment
209        ] {
210            assert!(
211                bad.parse::<DomainName>().is_err(),
212                "expected `{bad}` to be rejected"
213            );
214            let json = serde_json::to_string(bad).unwrap();
215            assert!(
216                serde_json::from_str::<DomainName>(&json).is_err(),
217                "expected serde to reject `{bad}`"
218            );
219        }
220    }
221}