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}