Skip to main content

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