compose_spec/service/
hostname.rs

1//! RFC 1123 compliant [`Hostname`].
2
3use compose_spec_macros::{DeserializeTryFromString, SerializeDisplay};
4use thiserror::Error;
5
6use crate::common::key_impls;
7
8/// An RFC 1123 compliant hostname.
9///
10/// Hostnames must only contain contain ASCII letters (a-z, A-Z), digits (0-9), dots (.), and
11/// dashes (-). Hostnames are split on dots (.) into labels. Each label must not be empty and cannot
12/// start or end with a dash (-).
13#[derive(
14    SerializeDisplay, DeserializeTryFromString, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash,
15)]
16pub struct Hostname(Box<str>);
17
18impl Hostname {
19    /// Create a new [`Hostname`], validating the given string.
20    ///
21    /// # Errors
22    ///
23    /// Returns an error if the hostname contains a character other than ASCII letters (a-z, A-Z),
24    /// digits (0-9), dots (.), and dashes (-), a label is empty, or a label starts or ends with a
25    /// dash (-).
26    pub fn new<T>(hostname: T) -> Result<Self, InvalidHostnameError>
27    where
28        T: AsRef<str> + Into<Box<str>>,
29    {
30        let hostname_str = hostname.as_ref();
31
32        for label in hostname_str.split('.') {
33            if label.is_empty() {
34                return Err(InvalidHostnameError::LabelEmpty);
35            }
36            for char in label.chars() {
37                if !char.is_ascii_alphanumeric() && char != '-' {
38                    return Err(InvalidHostnameError::Character(char));
39                }
40            }
41            if label.starts_with('-') || label.ends_with('-') {
42                return Err(InvalidHostnameError::LabelStartEnd);
43            }
44        }
45
46        Ok(Self(hostname.into()))
47    }
48}
49
50/// Error returned when creating a [`Hostname`].
51#[derive(Error, Debug, Clone, Copy, PartialEq, Eq)]
52pub enum InvalidHostnameError {
53    /// One of the hostname's labels was empty.
54    #[error("hostname label was empty")]
55    LabelEmpty,
56
57    /// Hostnames can only contain ASCII letters (a-z, A-Z), digits (0-9), dots (.), and dashes (-).
58    #[error(
59        "invalid hostname character `{0}`, hostnames can only contain ASCII letters (a-z, A-Z), \
60            digits (0-9), dots (.), and dashes (-)"
61    )]
62    Character(char),
63
64    /// Hostname labels cannot start or end with a dash (-).
65    #[error("hostname labels cannot start or end with dashes (-)")]
66    LabelStartEnd,
67}
68
69key_impls!(Hostname => InvalidHostnameError);
70
71#[cfg(test)]
72mod tests {
73    use pomsky_macro::pomsky;
74    use proptest::proptest;
75
76    use super::*;
77
78    const HOSTNAME: &str = pomsky! {
79        let end = [ascii_alnum];
80        let middle = [ascii_alnum '-']*;
81        let label = end (middle end)?;
82
83        label ('.' label)*
84    };
85
86    proptest! {
87        #[test]
88        fn no_panic(string: String) {
89            let _ = Hostname::new(string);
90        }
91
92        #[test]
93        fn valid(string in HOSTNAME) {
94            Hostname::new(string)?;
95        }
96    }
97}