commonware_utils/
hostname.rs

1//! Validated hostname type conforming to RFC 1035/1123.
2
3#[cfg(not(feature = "std"))]
4use alloc::{string::String, vec::Vec};
5use bytes::{Buf, BufMut};
6use commonware_codec::{
7    EncodeSize, Error as CodecError, RangeCfg, Read as CodecRead, Write as CodecWrite,
8};
9use thiserror::Error;
10
11/// Maximum length of a hostname (253 characters per RFC 1035).
12///
13/// While the DNS wire format allows 255 bytes total, the text representation
14/// is limited to 253 characters (255 minus 2 bytes for length encoding overhead).
15pub const MAX_HOSTNAME_LEN: usize = 253;
16
17/// Maximum length of a single hostname label (63 characters per RFC 1035).
18pub const MAX_HOSTNAME_LABEL_LEN: usize = 63;
19
20/// Error type for hostname validation.
21#[derive(Debug, Clone, PartialEq, Eq, Error)]
22pub enum Error {
23    #[error("hostname is empty")]
24    Empty,
25    #[error("hostname exceeds maximum length of {MAX_HOSTNAME_LEN} characters")]
26    TooLong,
27    #[error("hostname label exceeds maximum length of {MAX_HOSTNAME_LABEL_LEN} characters")]
28    LabelTooLong,
29    #[error("hostname contains empty label")]
30    EmptyLabel,
31    #[error("hostname contains invalid character")]
32    InvalidCharacter,
33    #[error("hostname label starts with hyphen")]
34    LabelStartsWithHyphen,
35    #[error("hostname label ends with hyphen")]
36    LabelEndsWithHyphen,
37    #[error("hostname contains invalid UTF-8")]
38    InvalidUtf8,
39}
40
41/// A validated hostname.
42///
43/// This type ensures the hostname conforms to RFC 1035 and RFC 1123:
44/// - Total length is at most 253 characters
45/// - Each label (part between dots) is at most 63 characters
46/// - Labels contain only ASCII letters, digits, and hyphens
47/// - Labels do not start or end with a hyphen
48/// - No empty labels (no consecutive dots, leading dots, or trailing dots)
49#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
50pub struct Hostname(String);
51
52impl Hostname {
53    /// Create a new hostname, validating it according to RFC 1035/1123.
54    pub fn new(hostname: impl Into<String>) -> Result<Self, Error> {
55        let hostname = hostname.into();
56        Self::validate(&hostname)?;
57        Ok(Self(hostname))
58    }
59
60    /// Validate a hostname string according to RFC 1035/1123.
61    fn validate(hostname: &str) -> Result<(), Error> {
62        if hostname.is_empty() {
63            return Err(Error::Empty);
64        }
65
66        if hostname.len() > MAX_HOSTNAME_LEN {
67            return Err(Error::TooLong);
68        }
69
70        for label in hostname.split('.') {
71            Self::validate_label(label)?;
72        }
73
74        Ok(())
75    }
76
77    /// Validate a single hostname label.
78    fn validate_label(label: &str) -> Result<(), Error> {
79        if label.is_empty() {
80            return Err(Error::EmptyLabel);
81        }
82
83        if label.len() > MAX_HOSTNAME_LABEL_LEN {
84            return Err(Error::LabelTooLong);
85        }
86
87        for c in label.chars() {
88            if !c.is_ascii_alphanumeric() && c != '-' {
89                return Err(Error::InvalidCharacter);
90            }
91        }
92
93        if label.starts_with('-') {
94            return Err(Error::LabelStartsWithHyphen);
95        }
96        if label.ends_with('-') {
97            return Err(Error::LabelEndsWithHyphen);
98        }
99
100        Ok(())
101    }
102
103    /// Returns the hostname as a string slice.
104    pub fn as_str(&self) -> &str {
105        &self.0
106    }
107
108    /// Consumes the hostname and returns the underlying String.
109    pub fn into_string(self) -> String {
110        self.0
111    }
112}
113
114impl AsRef<str> for Hostname {
115    fn as_ref(&self) -> &str {
116        &self.0
117    }
118}
119
120impl core::fmt::Display for Hostname {
121    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
122        write!(f, "{}", self.0)
123    }
124}
125
126impl TryFrom<String> for Hostname {
127    type Error = Error;
128
129    fn try_from(value: String) -> Result<Self, Self::Error> {
130        Self::new(value)
131    }
132}
133
134impl TryFrom<&str> for Hostname {
135    type Error = Error;
136
137    fn try_from(value: &str) -> Result<Self, Self::Error> {
138        Self::new(value)
139    }
140}
141
142impl CodecWrite for Hostname {
143    #[inline]
144    fn write(&self, buf: &mut impl BufMut) {
145        self.0.as_bytes().write(buf);
146    }
147}
148
149impl EncodeSize for Hostname {
150    #[inline]
151    fn encode_size(&self) -> usize {
152        self.0.as_bytes().encode_size()
153    }
154}
155
156impl CodecRead for Hostname {
157    type Cfg = ();
158
159    #[inline]
160    fn read_cfg(buf: &mut impl Buf, _: &()) -> Result<Self, CodecError> {
161        let bytes = Vec::<u8>::read_cfg(buf, &(RangeCfg::new(..=MAX_HOSTNAME_LEN), ()))?;
162        let hostname = String::from_utf8(bytes)
163            .map_err(|_| CodecError::Invalid("Hostname", "invalid UTF-8"))?;
164        Self::new(hostname).map_err(|_| CodecError::Invalid("Hostname", "invalid hostname"))
165    }
166}
167
168/// Creates a [`Hostname`] from a string literal or expression.
169///
170/// This macro panics if the hostname is invalid, making it suitable for
171/// use with known-valid hostnames in tests or configuration.
172///
173/// # Examples
174///
175/// ```
176/// use commonware_utils::hostname;
177///
178/// let h1 = hostname!("example.com");
179/// let h2 = hostname!("sub.domain.example.com");
180/// ```
181///
182/// # Panics
183///
184/// Panics if the provided string is not a valid hostname according to RFC 1035/1123.
185#[macro_export]
186macro_rules! hostname {
187    ($s:expr) => {
188        $crate::Hostname::new($s).expect("invalid hostname")
189    };
190}
191
192#[cfg(feature = "arbitrary")]
193impl arbitrary::Arbitrary<'_> for Hostname {
194    fn arbitrary(u: &mut arbitrary::Unstructured<'_>) -> arbitrary::Result<Self> {
195        let num_labels: u8 = u.int_in_range(1..=4)?;
196        let mut labels = Vec::with_capacity(num_labels as usize);
197
198        for _ in 0..num_labels {
199            let label_len: u8 = u.int_in_range(1..=10)?;
200            let label: String = (0..label_len)
201                .map(|i| {
202                    if i == 0 || i == label_len - 1 {
203                        u.choose(&['a', 'b', 'c', 'd', 'e', '1', '2', '3'])
204                    } else {
205                        u.choose(&['a', 'b', 'c', 'd', 'e', '1', '2', '3', '-'])
206                    }
207                })
208                .collect::<Result<_, _>>()?;
209            labels.push(label);
210        }
211
212        let hostname = labels.join(".");
213        Ok(Self(hostname))
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn test_hostname_valid() {
223        // Simple hostnames
224        assert!(Hostname::new("localhost").is_ok());
225        assert!(Hostname::new("example").is_ok());
226        assert!(Hostname::new("a").is_ok());
227
228        // Multi-label hostnames
229        assert!(Hostname::new("example.com").is_ok());
230        assert!(Hostname::new("sub.example.com").is_ok());
231        assert!(Hostname::new("deep.sub.example.com").is_ok());
232
233        // Hostnames with hyphens
234        assert!(Hostname::new("my-host").is_ok());
235        assert!(Hostname::new("my-example-host.com").is_ok());
236        assert!(Hostname::new("a-b-c.d-e-f.com").is_ok());
237
238        // Hostnames with numbers (RFC 1123 allows labels to start with digits)
239        assert!(Hostname::new("123").is_ok());
240        assert!(Hostname::new("123.456").is_ok());
241        assert!(Hostname::new("host1.example2.com").is_ok());
242        assert!(Hostname::new("1host.2example.3com").is_ok());
243
244        // Mixed case (valid but should be treated case-insensitively by DNS)
245        assert!(Hostname::new("Example.COM").is_ok());
246        assert!(Hostname::new("MyHost.Example.Com").is_ok());
247    }
248
249    #[test]
250    fn test_hostname_invalid_empty() {
251        assert!(matches!(Hostname::new("").unwrap_err(), Error::Empty));
252    }
253
254    #[test]
255    fn test_hostname_invalid_too_long() {
256        // Create a hostname that's exactly 255 characters (over the 253 limit)
257        // Use 63-char labels separated by dots: 63 + 1 + 63 + 1 + 63 + 1 + 63 = 255
258        let long_label = "a".repeat(63);
259        let long_hostname = format!("{long_label}.{long_label}.{long_label}.{long_label}");
260        assert_eq!(long_hostname.len(), 255);
261        assert!(matches!(
262            Hostname::new(&long_hostname).unwrap_err(),
263            Error::TooLong
264        ));
265
266        // Hostname at exactly 253 characters should be valid
267        // Use 63-char labels: 63 + 1 + 63 + 1 + 63 + 1 + 61 = 253
268        let short_label = "a".repeat(61);
269        let valid_long = format!("{long_label}.{long_label}.{long_label}.{short_label}");
270        assert_eq!(valid_long.len(), 253);
271        assert!(Hostname::new(&valid_long).is_ok());
272    }
273
274    #[test]
275    fn test_hostname_invalid_label_too_long() {
276        // Label longer than 63 characters
277        let long_label = "a".repeat(64);
278        assert!(matches!(
279            Hostname::new(&long_label).unwrap_err(),
280            Error::LabelTooLong
281        ));
282
283        // Label with exactly 63 characters should be valid
284        let valid_label = "a".repeat(63);
285        assert!(Hostname::new(&valid_label).is_ok());
286    }
287
288    #[test]
289    fn test_hostname_invalid_empty_label() {
290        // Leading dot
291        assert!(matches!(
292            Hostname::new(".example.com").unwrap_err(),
293            Error::EmptyLabel
294        ));
295
296        // Trailing dot
297        assert!(matches!(
298            Hostname::new("example.com.").unwrap_err(),
299            Error::EmptyLabel
300        ));
301
302        // Consecutive dots
303        assert!(matches!(
304            Hostname::new("example..com").unwrap_err(),
305            Error::EmptyLabel
306        ));
307    }
308
309    #[test]
310    fn test_hostname_invalid_characters() {
311        // Underscore (common mistake)
312        assert!(matches!(
313            Hostname::new("my_host.com").unwrap_err(),
314            Error::InvalidCharacter
315        ));
316
317        // Space
318        assert!(matches!(
319            Hostname::new("my host.com").unwrap_err(),
320            Error::InvalidCharacter
321        ));
322
323        // Special characters
324        assert!(matches!(
325            Hostname::new("host@example.com").unwrap_err(),
326            Error::InvalidCharacter
327        ));
328        assert!(matches!(
329            Hostname::new("host!.com").unwrap_err(),
330            Error::InvalidCharacter
331        ));
332
333        // Unicode characters
334        assert!(matches!(
335            Hostname::new("hôst.com").unwrap_err(),
336            Error::InvalidCharacter
337        ));
338    }
339
340    #[test]
341    fn test_hostname_invalid_hyphen_position() {
342        // Label starting with hyphen
343        assert!(matches!(
344            Hostname::new("-example.com").unwrap_err(),
345            Error::LabelStartsWithHyphen
346        ));
347        assert!(matches!(
348            Hostname::new("example.-sub.com").unwrap_err(),
349            Error::LabelStartsWithHyphen
350        ));
351
352        // Label ending with hyphen
353        assert!(matches!(
354            Hostname::new("example-.com").unwrap_err(),
355            Error::LabelEndsWithHyphen
356        ));
357        assert!(matches!(
358            Hostname::new("example.sub-.com").unwrap_err(),
359            Error::LabelEndsWithHyphen
360        ));
361
362        // Single hyphen label
363        assert!(matches!(
364            Hostname::new("-").unwrap_err(),
365            Error::LabelStartsWithHyphen
366        ));
367    }
368
369    #[test]
370    fn test_hostname_try_from() {
371        // From String
372        let hostname: Result<Hostname, _> = "example.com".to_string().try_into();
373        assert!(hostname.is_ok());
374
375        // From &str
376        let hostname: Result<Hostname, _> = "example.com".try_into();
377        assert!(hostname.is_ok());
378
379        // Invalid
380        let hostname: Result<Hostname, _> = "invalid..host".try_into();
381        assert!(hostname.is_err());
382    }
383
384    #[test]
385    fn test_hostname_display_and_as_ref() {
386        let hostname = Hostname::new("example.com").unwrap();
387        assert_eq!(format!("{hostname}"), "example.com");
388        assert_eq!(hostname.as_ref(), "example.com");
389        assert_eq!(hostname.as_str(), "example.com");
390    }
391
392    #[test]
393    fn test_hostname_into_string() {
394        let hostname = Hostname::new("example.com").unwrap();
395        let s: String = hostname.into_string();
396        assert_eq!(s, "example.com");
397    }
398
399    #[test]
400    fn test_hostname_macro() {
401        let h = hostname!("example.com");
402        assert_eq!(h.as_str(), "example.com");
403    }
404
405    #[cfg(feature = "arbitrary")]
406    mod conformance {
407        use super::*;
408        use commonware_codec::conformance::CodecConformance;
409
410        commonware_conformance::conformance_tests! {
411            CodecConformance<Hostname>,
412        }
413    }
414}