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