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    /// 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}