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 /// Suffix pattern resolves to a single DNS label (e.g. `com`,
66 /// `local`, `internal`). A label-aware suffix matcher treats this
67 /// as "match every subdomain under that TLD," which is almost
68 /// never the operator's intent. Add at least one parent label
69 /// (e.g. `corp.internal`, `myco.com`).
70 #[error(
71 "suffix `{raw}` is a single DNS label and would match every domain under that TLD; \
72 add at least one parent label to scope it (e.g. `myco.{raw}`)"
73 )]
74 SuffixTooBroad { raw: String },
75}
76
77//--------------------------------------------------------------------------------------------------
78// Methods
79//--------------------------------------------------------------------------------------------------
80
81impl DomainName {
82 /// Borrow the canonical string form. The returned slice has no
83 /// trailing dot and is lowercased ASCII.
84 pub fn as_str(&self) -> &str {
85 &self.0
86 }
87
88 /// Validate this name for use as a [`Destination::DomainSuffix`]
89 /// target and return it unchanged on success.
90 ///
91 /// Returns [`DomainNameError::SuffixTooBroad`] when the name is a
92 /// single DNS label (e.g. `com`, `local`, `internal`). The
93 /// label-aware suffix matcher would otherwise treat such a name
94 /// as "match every host under that TLD" which is almost certainly
95 /// not what the operator meant. Use a multi-label suffix
96 /// (`myco.com`, `corp.internal`) instead.
97 ///
98 /// Domains intended for exact-match rules are unaffected: use
99 /// `Destination::Domain` for those.
100 ///
101 /// [`Destination::DomainSuffix`]: super::Destination::DomainSuffix
102 pub fn try_into_suffix(self) -> Result<Self, DomainNameError> {
103 if self.0.contains('.') {
104 Ok(self)
105 } else {
106 Err(DomainNameError::SuffixTooBroad {
107 raw: self.0.clone(),
108 })
109 }
110 }
111}
112
113impl FromStr for DomainName {
114 type Err = DomainNameError;
115
116 fn from_str(s: &str) -> Result<Self, Self::Err> {
117 // Leading-dot acceptance is ergonomic for suffixes
118 // (`.example.com`). Trailing-dot acceptance matches FQDN
119 // inputs coming from DNS responses or hand-typed FQDNs.
120 let trimmed = s.trim_start_matches('.').trim_end_matches('.');
121 if trimmed.is_empty() {
122 return Err(DomainNameError::Empty);
123 }
124 // Validate via hickory. We discard the parsed Name and keep
125 // the lowercased ASCII string so matching is a plain `==`
126 // against the cache entries.
127 let _name: Name = trimmed.parse()?;
128 Ok(Self(trimmed.to_ascii_lowercase()))
129 }
130}
131
132impl TryFrom<String> for DomainName {
133 type Error = DomainNameError;
134
135 fn try_from(s: String) -> Result<Self, Self::Error> {
136 s.parse()
137 }
138}
139
140impl TryFrom<&str> for DomainName {
141 type Error = DomainNameError;
142
143 fn try_from(s: &str) -> Result<Self, Self::Error> {
144 s.parse()
145 }
146}
147
148impl From<DomainName> for String {
149 fn from(name: DomainName) -> Self {
150 name.0
151 }
152}
153
154impl fmt::Display for DomainName {
155 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156 f.write_str(&self.0)
157 }
158}
159
160//--------------------------------------------------------------------------------------------------
161// Tests
162//--------------------------------------------------------------------------------------------------
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 #[test]
169 fn parses_plain_lowercase_name() {
170 let name: DomainName = "pypi.org".parse().unwrap();
171 assert_eq!(name.as_str(), "pypi.org");
172 }
173
174 #[test]
175 fn canonicalizes_case_and_trailing_dot() {
176 let name: DomainName = "PyPI.Org.".parse().unwrap();
177 assert_eq!(name.as_str(), "pypi.org");
178 }
179
180 #[test]
181 fn strips_leading_dot_for_suffix_ergonomics() {
182 let name: DomainName = ".pythonhosted.org".parse().unwrap();
183 assert_eq!(name.as_str(), "pythonhosted.org");
184 }
185
186 #[test]
187 fn canonical_form_is_idempotent() {
188 let once: DomainName = "Example.COM.".parse().unwrap();
189 let twice: DomainName = once.as_str().parse().unwrap();
190 assert_eq!(once, twice);
191 }
192
193 #[test]
194 fn accepts_underscore_labels() {
195 // SRV / DKIM / Kubernetes names rely on underscore labels
196 // (RFC 2181 §11). Rejecting them would break real-world
197 // policy inputs.
198 let name: DomainName = "_http._tcp.example.com".parse().unwrap();
199 assert_eq!(name.as_str(), "_http._tcp.example.com");
200 }
201
202 #[test]
203 fn rejects_empty_input() {
204 assert!(matches!(
205 "".parse::<DomainName>(),
206 Err(DomainNameError::Empty)
207 ));
208 assert!(matches!(
209 "...".parse::<DomainName>(),
210 Err(DomainNameError::Empty)
211 ));
212 }
213
214 #[test]
215 fn rejects_whitespace_in_labels() {
216 assert!("foo bar.example".parse::<DomainName>().is_err());
217 }
218
219 #[test]
220 fn serde_round_trip_preserves_canonical_form() {
221 let name: DomainName = ".PyPI.Org.".parse().unwrap();
222 let json = serde_json::to_string(&name).unwrap();
223 assert_eq!(json, r#""pypi.org""#);
224 let back: DomainName = serde_json::from_str(&json).unwrap();
225 assert_eq!(back, name);
226 }
227
228 #[test]
229 fn serde_deserialize_validates() {
230 assert!(serde_json::from_str::<DomainName>(r#""foo bar.example""#).is_err());
231 assert!(serde_json::from_str::<DomainName>(r#""""#).is_err());
232 }
233
234 #[test]
235 fn try_into_suffix_accepts_multilabel_names() {
236 let name: DomainName = "example.com".parse().unwrap();
237 let suffix = name.try_into_suffix().unwrap();
238 assert_eq!(suffix.as_str(), "example.com");
239
240 let deep: DomainName = "api.staging.example.com".parse().unwrap();
241 let suffix = deep.try_into_suffix().unwrap();
242 assert_eq!(suffix.as_str(), "api.staging.example.com");
243 }
244
245 #[test]
246 fn try_into_suffix_rejects_single_label_tlds() {
247 for raw in ["com", "local", "internal", "intranet", "lan"] {
248 let name: DomainName = raw.parse().unwrap();
249 let err = name.try_into_suffix().unwrap_err();
250 assert!(
251 matches!(&err, DomainNameError::SuffixTooBroad { raw: r } if r == raw),
252 "expected SuffixTooBroad for `{raw}`, got {err:?}"
253 );
254 let msg = err.to_string();
255 assert!(
256 msg.contains("match every domain"),
257 "error message should explain blast radius, got: {msg}"
258 );
259 }
260 }
261
262 #[test]
263 fn rejects_url_decorations() {
264 for bad in [
265 "bar.example:443", // host:port
266 "user@example.com", // userinfo@host
267 "user:pass@example.com", // userinfo:pass@host
268 "https://example.com", // scheme://host
269 "example.com/path", // host/path
270 "example.com?q=1", // host?query
271 "example.com#frag", // host#fragment
272 ] {
273 assert!(
274 bad.parse::<DomainName>().is_err(),
275 "expected `{bad}` to be rejected"
276 );
277 let json = serde_json::to_string(bad).unwrap();
278 assert!(
279 serde_json::from_str::<DomainName>(&json).is_err(),
280 "expected serde to reject `{bad}`"
281 );
282 }
283 }
284}