Skip to main content

bare_types/net/
hostname.rs

1//! Hostname type for network programming.
2//!
3//! This module provides a type-safe abstraction for DNS hostnames,
4//! ensuring compliance with RFC 1123 hostname specifications.
5//!
6//! # RFC 1123 Hostname Rules
7//!
8//! According to [RFC 1123 ยง2.1](https://datatracker.ietf.org/doc/html/rfc1123#section-2.1):
9//!
10//! - Total length: 1-253 characters
11//! - Each label (segment separated by dots): 1-63 characters
12//! - Valid characters: letters (a-z, A-Z), digits (0-9), and hyphens (-)
13//! - Labels cannot start or end with a hyphen
14//! - Hostnames are case-insensitive (stored in lowercase internally)
15//! - Only ASCII characters are allowed
16//!
17//! # Examples
18//!
19//! ```rust
20//! use bare_types::net::Hostname;
21//!
22//! // Create a hostname
23//! let hostname = Hostname::new("example.com")?;
24//!
25//! // Check if it's localhost
26//! assert!(!hostname.is_localhost());
27//!
28//! // Get the string representation
29//! assert_eq!(hostname.as_str(), "example.com");
30//!
31//! // Parse from string
32//! let hostname: Hostname = "www.example.com".parse()?;
33//! # Ok::<(), bare_types::net::HostnameError>(())
34//! ```
35
36use core::fmt;
37use core::str::FromStr;
38
39#[cfg(feature = "serde")]
40use serde::{Deserialize, Serialize};
41
42#[cfg(feature = "zeroize")]
43use zeroize::Zeroize;
44
45/// Error type for hostname validation.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
48#[non_exhaustive]
49pub enum HostnameError {
50    /// Empty hostname
51    ///
52    /// The provided string is empty. Hostnames must contain at least one character.
53    Empty,
54    /// Hostname exceeds maximum length of 253 characters
55    ///
56    /// According to RFC 1123, hostnames must not exceed 253 characters.
57    /// This variant contains the actual length of the provided hostname.
58    TooLong(usize),
59    /// Label exceeds maximum length of 63 characters
60    ///
61    /// Each label (segment separated by dots) must not exceed 63 characters.
62    /// This variant contains the label index and its actual length.
63    LabelTooLong {
64        /// Label index
65        label: usize,
66        /// Label length
67        len: usize,
68    },
69    /// Label starts with invalid character
70    ///
71    /// Labels must start with a letter (a-z, A-Z). Hyphens and digits
72    /// are not allowed as the first character.
73    /// This variant contains the invalid character.
74    InvalidLabelStart(char),
75    /// Label ends with invalid character
76    ///
77    /// Labels must end with a letter (a-z, A-Z) or digit (0-9).
78    /// Hyphens are not allowed as the last character.
79    /// This variant contains the invalid character.
80    InvalidLabelEnd(char),
81    /// Invalid character in hostname
82    ///
83    /// Hostnames can only contain ASCII letters, digits, and hyphens.
84    /// This variant contains the invalid character.
85    InvalidChar(char),
86    /// Empty label (consecutive dots or leading/trailing dots)
87    ///
88    /// Consecutive dots (e.g., "example..com") or leading/trailing dots
89    /// (e.g., ".example.com" or "example.com.") are not allowed.
90    EmptyLabel,
91}
92
93impl fmt::Display for HostnameError {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        match self {
96            Self::Empty => write!(f, "hostname cannot be empty"),
97            Self::TooLong(len) => write!(
98                f,
99                "hostname exceeds maximum length of 253 characters (got {len})"
100            ),
101            Self::LabelTooLong { label, len } => {
102                write!(
103                    f,
104                    "label {label} exceeds maximum length of 63 characters (got {len})"
105                )
106            }
107            Self::InvalidLabelStart(c) => write!(f, "label cannot start with '{c}'"),
108            Self::InvalidLabelEnd(c) => write!(f, "label cannot end with '{c}'"),
109            Self::InvalidChar(c) => write!(f, "invalid character '{c}' in hostname"),
110            Self::EmptyLabel => write!(f, "hostname cannot contain empty labels"),
111        }
112    }
113}
114
115#[cfg(feature = "std")]
116impl std::error::Error for HostnameError {}
117
118/// A DNS hostname.
119///
120/// This type provides type-safe hostnames with RFC 1123 validation.
121/// It uses the newtype pattern with `#[repr(transparent)]` for zero-cost abstraction.
122///
123/// # Invariants
124///
125/// - Total length is 1-253 characters
126/// - Each label is 1-63 characters
127/// - Only ASCII letters, digits, and hyphens are allowed
128/// - Labels cannot start or end with hyphens
129/// - Stored in lowercase for case-insensitive comparison
130///
131/// # Examples
132///
133/// ```rust
134/// use bare_types::net::Hostname;
135///
136/// // Create a hostname
137/// let hostname = Hostname::new("example.com")?;
138///
139/// // Access the string representation
140/// assert_eq!(hostname.as_str(), "example.com");
141///
142/// // Check if it's localhost
143/// assert!(!hostname.is_localhost());
144///
145/// // Iterate over labels
146/// let labels: Vec<&str> = hostname.labels().collect();
147/// assert_eq!(labels, vec!["example", "com"]);
148///
149/// // Parse from string
150/// let hostname: Hostname = "www.example.com".parse()?;
151/// # Ok::<(), bare_types::net::HostnameError>(())
152/// ```
153#[repr(transparent)]
154#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
155#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
156#[cfg_attr(feature = "zeroize", derive(Zeroize))]
157pub struct Hostname(heapless::String<253>);
158
159#[cfg(feature = "arbitrary")]
160impl<'a> arbitrary::Arbitrary<'a> for Hostname {
161    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
162        const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz";
163        const DIGITS: &[u8] = b"0123456789";
164
165        // Generate 1-4 labels
166        let label_count = 1 + (u8::arbitrary(u)? % 4);
167        let mut inner = heapless::String::<253>::new();
168
169        for label_idx in 0..label_count {
170            // Generate 1-20 character label
171            let label_len = 1 + (u8::arbitrary(u)? % 20).min(19);
172
173            // First character: alphanumeric
174            let first_byte = u8::arbitrary(u)?;
175            let first = ALPHABET[(first_byte % 26) as usize] as char;
176            inner
177                .push(first)
178                .map_err(|_| arbitrary::Error::IncorrectFormat)?;
179
180            // Middle characters: alphanumeric or hyphen
181            for _ in 1..label_len.saturating_sub(1) {
182                let byte = u8::arbitrary(u)?;
183                let c = match byte % 3 {
184                    0 => ALPHABET[((byte >> 2) % 26) as usize] as char,
185                    1 => DIGITS[((byte >> 2) % 10) as usize] as char,
186                    _ => '-',
187                };
188                inner
189                    .push(c)
190                    .map_err(|_| arbitrary::Error::IncorrectFormat)?;
191            }
192
193            // Last character: alphanumeric (if label_len > 1)
194            if label_len > 1 {
195                let last_byte = u8::arbitrary(u)?;
196                let last = ALPHABET[(last_byte % 26) as usize] as char;
197                inner
198                    .push(last)
199                    .map_err(|_| arbitrary::Error::IncorrectFormat)?;
200            }
201
202            // Add dot between labels (but not after the last one)
203            if label_idx < label_count - 1 {
204                inner
205                    .push('.')
206                    .map_err(|_| arbitrary::Error::IncorrectFormat)?;
207            }
208        }
209
210        Ok(Self(inner))
211    }
212}
213
214impl Hostname {
215    /// Creates a new hostname from a string.
216    ///
217    /// # Errors
218    ///
219    /// Returns `HostnameError` if the string does not comply with RFC 1123.
220    ///
221    /// # Examples
222    ///
223    /// ```rust
224    /// use bare_types::net::Hostname;
225    ///
226    /// let hostname = Hostname::new("example.com")?;
227    /// assert_eq!(hostname.as_str(), "example.com");
228    /// # Ok::<(), bare_types::net::HostnameError>(())
229    /// ```
230    #[allow(clippy::missing_panics_doc)]
231    pub fn new(s: &str) -> Result<Self, HostnameError> {
232        if s.is_empty() {
233            return Err(HostnameError::Empty);
234        }
235
236        if s.len() > 253 {
237            return Err(HostnameError::TooLong(s.len()));
238        }
239
240        let mut inner = heapless::String::<253>::new();
241        let mut label_index = 0;
242        let mut label_len = 0;
243        let mut first_char: Option<char> = None;
244        let mut last_char: char = '\0';
245
246        for c in s.chars() {
247            if c == '.' {
248                if label_len == 0 {
249                    return Err(HostnameError::EmptyLabel);
250                }
251
252                if label_len > 63 {
253                    return Err(HostnameError::LabelTooLong {
254                        label: label_index,
255                        len: label_len,
256                    });
257                }
258
259                let first = first_char.expect("label_len > 0 guarantees first_char is Some");
260                if !first.is_ascii_alphanumeric() {
261                    return Err(HostnameError::InvalidLabelStart(first));
262                }
263
264                if !last_char.is_ascii_alphanumeric() {
265                    return Err(HostnameError::InvalidLabelEnd(last_char));
266                }
267
268                inner.push('.').map_err(|_| HostnameError::TooLong(253))?;
269                label_index += 1;
270                label_len = 0;
271                first_char = None;
272            } else {
273                if !c.is_ascii() {
274                    return Err(HostnameError::InvalidChar(c));
275                }
276
277                if !c.is_ascii_alphanumeric() && c != '-' {
278                    return Err(HostnameError::InvalidChar(c));
279                }
280
281                if label_len == 0 {
282                    first_char = Some(c);
283                }
284                last_char = c;
285                label_len += 1;
286
287                inner
288                    .push(c.to_ascii_lowercase())
289                    .map_err(|_| HostnameError::TooLong(253))?;
290            }
291        }
292
293        if label_len == 0 {
294            return Err(HostnameError::EmptyLabel);
295        }
296
297        if label_len > 63 {
298            return Err(HostnameError::LabelTooLong {
299                label: label_index,
300                len: label_len,
301            });
302        }
303
304        let first = first_char.expect("label_len > 0 guarantees first_char is Some");
305        if !first.is_ascii_alphanumeric() {
306            return Err(HostnameError::InvalidLabelStart(first));
307        }
308
309        if !last_char.is_ascii_alphanumeric() {
310            return Err(HostnameError::InvalidLabelEnd(last_char));
311        }
312
313        Ok(Self(inner))
314    }
315
316    /// Returns the hostname as a string slice.
317    ///
318    /// # Examples
319    ///
320    /// ```rust
321    /// use bare_types::net::Hostname;
322    ///
323    /// let hostname = Hostname::new("example.com").unwrap();
324    /// assert_eq!(hostname.as_str(), "example.com");
325    /// ```
326    #[must_use]
327    #[inline]
328    pub fn as_str(&self) -> &str {
329        &self.0
330    }
331
332    /// Returns a reference to the underlying `heapless::String`.
333    ///
334    /// # Examples
335    ///
336    /// ```rust
337    /// use bare_types::net::Hostname;
338    ///
339    /// let hostname = Hostname::new("example.com").unwrap();
340    /// let inner: &heapless::String<253> = hostname.as_inner();
341    /// assert_eq!(inner.as_str(), "example.com");
342    /// ```
343    #[must_use]
344    #[inline]
345    pub const fn as_inner(&self) -> &heapless::String<253> {
346        &self.0
347    }
348
349    /// Consumes this hostname and returns the underlying string.
350    ///
351    /// # Examples
352    ///
353    /// ```rust
354    /// use bare_types::net::Hostname;
355    ///
356    /// let hostname = Hostname::new("example.com").unwrap();
357    /// let inner = hostname.into_inner();
358    /// assert_eq!(inner.as_str(), "example.com");
359    /// ```
360    #[must_use]
361    #[inline]
362    pub fn into_inner(self) -> heapless::String<253> {
363        self.0
364    }
365
366    /// Returns `true` if this is the localhost hostname.
367    ///
368    /// # Examples
369    ///
370    /// ```rust
371    /// use bare_types::net::Hostname;
372    ///
373    /// assert!(Hostname::new("localhost").unwrap().is_localhost());
374    /// assert!(!Hostname::new("example.com").unwrap().is_localhost());
375    /// ```
376    #[must_use]
377    #[inline]
378    pub fn is_localhost(&self) -> bool {
379        self.as_str() == "localhost"
380    }
381
382    /// Returns an iterator over the labels in this hostname.
383    ///
384    /// # Examples
385    ///
386    /// ```rust
387    /// use bare_types::net::Hostname;
388    ///
389    /// let hostname = Hostname::new("www.example.com").unwrap();
390    /// let labels: Vec<&str> = hostname.labels().collect();
391    /// assert_eq!(labels, vec!["www", "example", "com"]);
392    /// ```
393    pub fn labels(&self) -> impl Iterator<Item = &str> {
394        self.as_str().split('.')
395    }
396}
397
398impl TryFrom<&str> for Hostname {
399    type Error = HostnameError;
400
401    fn try_from(s: &str) -> Result<Self, Self::Error> {
402        Self::new(s)
403    }
404}
405
406impl From<Hostname> for heapless::String<253> {
407    fn from(hostname: Hostname) -> Self {
408        hostname.0
409    }
410}
411
412impl FromStr for Hostname {
413    type Err = HostnameError;
414
415    fn from_str(s: &str) -> Result<Self, Self::Err> {
416        Self::new(s)
417    }
418}
419
420impl fmt::Display for Hostname {
421    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
422        write!(f, "{}", self.0)
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    #[test]
431    fn test_new_valid_hostname() {
432        assert!(Hostname::new("example.com").is_ok());
433        assert!(Hostname::new("www.example.com").is_ok());
434        assert!(Hostname::new("localhost").is_ok());
435        assert!(Hostname::new("a").is_ok());
436    }
437
438    #[test]
439    fn test_empty_hostname() {
440        assert_eq!(Hostname::new(""), Err(HostnameError::Empty));
441    }
442
443    #[test]
444    fn test_too_long_hostname() {
445        let long = "a".repeat(254);
446        assert_eq!(Hostname::new(&long), Err(HostnameError::TooLong(254)));
447    }
448
449    #[test]
450    fn test_label_too_long() {
451        let long_label = "a".repeat(64);
452        assert_eq!(
453            Hostname::new(&long_label),
454            Err(HostnameError::LabelTooLong { label: 0, len: 64 })
455        );
456    }
457
458    #[test]
459    fn test_invalid_label_start() {
460        assert_eq!(
461            Hostname::new("-example.com"),
462            Err(HostnameError::InvalidLabelStart('-'))
463        );
464    }
465
466    #[test]
467    fn test_invalid_label_end() {
468        assert_eq!(
469            Hostname::new("example-.com"),
470            Err(HostnameError::InvalidLabelEnd('-'))
471        );
472    }
473
474    #[test]
475    fn test_invalid_char() {
476        assert_eq!(
477            Hostname::new("example_com"),
478            Err(HostnameError::InvalidChar('_'))
479        );
480    }
481
482    #[test]
483    fn test_empty_label() {
484        assert_eq!(
485            Hostname::new("example..com"),
486            Err(HostnameError::EmptyLabel)
487        );
488        assert_eq!(
489            Hostname::new(".example.com"),
490            Err(HostnameError::EmptyLabel)
491        );
492        assert_eq!(
493            Hostname::new("example.com."),
494            Err(HostnameError::EmptyLabel)
495        );
496    }
497
498    #[test]
499    fn test_as_str() {
500        let hostname = Hostname::new("example.com").unwrap();
501        assert_eq!(hostname.as_str(), "example.com");
502    }
503
504    #[test]
505    fn test_into_inner() {
506        let hostname = Hostname::new("example.com").unwrap();
507        let inner = hostname.into_inner();
508        assert_eq!(inner.as_str(), "example.com");
509    }
510
511    #[test]
512    fn test_is_localhost() {
513        assert!(Hostname::new("localhost").unwrap().is_localhost());
514        assert!(!Hostname::new("example.com").unwrap().is_localhost());
515    }
516
517    #[test]
518    fn test_labels() {
519        let hostname = Hostname::new("www.example.com").unwrap();
520        let labels: Vec<&str> = hostname.labels().collect();
521        assert_eq!(labels, vec!["www", "example", "com"]);
522    }
523
524    #[test]
525    fn test_labels_single() {
526        let hostname = Hostname::new("localhost").unwrap();
527        let labels: Vec<&str> = hostname.labels().collect();
528        assert_eq!(labels, vec!["localhost"]);
529    }
530
531    #[test]
532    fn test_try_from_str() {
533        let hostname = Hostname::try_from("example.com").unwrap();
534        assert_eq!(hostname.as_str(), "example.com");
535    }
536
537    #[test]
538    fn test_from_hostname_to_string() {
539        let hostname = Hostname::new("example.com").unwrap();
540        let inner: heapless::String<253> = hostname.into();
541        assert_eq!(inner.as_str(), "example.com");
542    }
543
544    #[test]
545    fn test_from_str() {
546        let hostname: Hostname = "example.com".parse().unwrap();
547        assert_eq!(hostname.as_str(), "example.com");
548    }
549
550    #[test]
551    fn test_from_str_invalid() {
552        assert!("".parse::<Hostname>().is_err());
553        assert!("-example.com".parse::<Hostname>().is_err());
554        assert!("example..com".parse::<Hostname>().is_err());
555    }
556
557    #[test]
558    fn test_display() {
559        let hostname = Hostname::new("example.com").unwrap();
560        assert_eq!(format!("{hostname}"), "example.com");
561    }
562
563    #[test]
564    fn test_equality() {
565        let hostname1 = Hostname::new("example.com").unwrap();
566        let hostname2 = Hostname::new("example.com").unwrap();
567        let hostname3 = Hostname::new("www.example.com").unwrap();
568
569        assert_eq!(hostname1, hostname2);
570        assert_ne!(hostname1, hostname3);
571    }
572
573    #[test]
574    fn test_ordering() {
575        let hostname1 = Hostname::new("a.example.com").unwrap();
576        let hostname2 = Hostname::new("b.example.com").unwrap();
577
578        assert!(hostname1 < hostname2);
579    }
580
581    #[test]
582    fn test_clone() {
583        let hostname = Hostname::new("example.com").unwrap();
584        let hostname2 = hostname.clone();
585        assert_eq!(hostname, hostname2);
586    }
587
588    #[test]
589    fn test_valid_characters() {
590        assert!(Hostname::new("a-b.example.com").is_ok());
591        assert!(Hostname::new("a1.example.com").is_ok());
592        assert!(Hostname::new("example-123.com").is_ok());
593    }
594
595    #[test]
596    fn test_maximum_length() {
597        let hostname = format!(
598            "{}.{}.{}.{}",
599            "a".repeat(63),
600            "b".repeat(63),
601            "c".repeat(63),
602            "d".repeat(61)
603        );
604        assert_eq!(hostname.len(), 253);
605        assert!(Hostname::new(&hostname).is_ok());
606    }
607
608    #[test]
609    fn test_error_display() {
610        assert_eq!(
611            format!("{}", HostnameError::Empty),
612            "hostname cannot be empty"
613        );
614        assert_eq!(
615            format!("{}", HostnameError::TooLong(300)),
616            "hostname exceeds maximum length of 253 characters (got 300)"
617        );
618        assert_eq!(
619            format!("{}", HostnameError::LabelTooLong { label: 0, len: 70 }),
620            "label 0 exceeds maximum length of 63 characters (got 70)"
621        );
622        assert_eq!(
623            format!("{}", HostnameError::InvalidLabelStart('-')),
624            "label cannot start with '-'"
625        );
626        assert_eq!(
627            format!("{}", HostnameError::InvalidLabelEnd('-')),
628            "label cannot end with '-'"
629        );
630        assert_eq!(
631            format!("{}", HostnameError::InvalidChar('_')),
632            "invalid character '_' in hostname"
633        );
634        assert_eq!(
635            format!("{}", HostnameError::EmptyLabel),
636            "hostname cannot contain empty labels"
637        );
638    }
639
640    #[test]
641    fn test_case_insensitive() {
642        let hostname1 = Hostname::new("Example.COM").unwrap();
643        let hostname2 = Hostname::new("example.com").unwrap();
644        assert_eq!(hostname1, hostname2);
645        assert_eq!(hostname1.as_str(), "example.com");
646    }
647}