kubizone_common/
segment.rs

1use std::{fmt::Display, ops::Add};
2
3use thiserror::Error;
4
5use crate::{DomainName, FullyQualifiedDomainName, PartiallyQualifiedDomainName};
6
7/// Segment of a domain.
8///
9/// This is the part between dots.
10#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
11pub struct DomainSegment(String);
12
13impl DomainSegment {
14    /// Constructs a new DomainSegment without checking the validity of it.
15    pub fn new_unchecked(segment: &str) -> Self {
16        DomainSegment(segment.to_string())
17    }
18
19    /// Length in characters of the domain segment.
20    pub fn len(&self) -> usize {
21        self.0.len()
22    }
23
24    /// Returns true if the segment is empty.
25    pub fn is_empty(&self) -> bool {
26        self.0.is_empty()
27    }
28
29    // Returns true if the segment is equal to "*"
30    pub fn is_wildcard(&self) -> bool {
31        self.0 == "*"
32    }
33}
34
35/// Produced when attempting to construct a [`DomainSegment`] from
36/// an invalid string.
37#[derive(Error, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
38pub enum DomainSegmentError {
39    /// Domain name segments can contain hyphens, but crucially:
40    ///
41    /// * Not at the beginning of a segment.
42    /// * Not at the end of a segment.
43    /// * Not at the 3rd and 4th position *simultaneously* (used for [Punycode encoding](https://en.wikipedia.org/wiki/Punycode))
44    #[error("illegal hyphen at position {0}")]
45    IllegalHyphen(usize),
46    /// Segment contains invalid character.
47    #[error("invalid character {0}")]
48    InvalidCharacter(char),
49    /// Domain segment is longer than the permitted 63 characters.
50    #[error("segment too long {0} > 63")]
51    TooLong(usize),
52    /// Domain segment is empty.
53    #[error("segment is an empty string")]
54    EmptyString,
55    /// Domain segments can be wildcards, but must then *only* contain the wildcard.
56    #[error("wildcard segments must have length 1")]
57    NonStandaloneWildcard,
58}
59
60const VALID_CHARACTERS: &str = "_-0123456789abcdefghijklmnopqrstuvwxyz*";
61
62impl TryFrom<&str> for DomainSegment {
63    type Error = DomainSegmentError;
64
65    fn try_from(value: &str) -> Result<Self, Self::Error> {
66        let value = value.to_ascii_lowercase();
67
68        if value.is_empty() {
69            return Err(DomainSegmentError::EmptyString);
70        }
71
72        if value.len() > 63 {
73            return Err(DomainSegmentError::TooLong(value.len()));
74        }
75
76        if value.contains('*') && value.len() != 1 {
77            return Err(DomainSegmentError::NonStandaloneWildcard);
78        }
79
80        if let Some(character) = value.chars().find(|c| !VALID_CHARACTERS.contains(*c)) {
81            return Err(DomainSegmentError::InvalidCharacter(character));
82        }
83
84        if value.starts_with('-') {
85            return Err(DomainSegmentError::IllegalHyphen(1));
86        }
87
88        if value.ends_with('-') {
89            return Err(DomainSegmentError::IllegalHyphen(value.len()));
90        }
91
92        if value.get(2..4) == Some("--") {
93            return Err(DomainSegmentError::IllegalHyphen(3));
94        }
95
96        Ok(DomainSegment(value))
97    }
98}
99
100impl TryFrom<String> for DomainSegment {
101    type Error = DomainSegmentError;
102
103    fn try_from(value: String) -> Result<Self, Self::Error> {
104        Self::try_from(value.as_str())
105    }
106}
107
108impl Display for DomainSegment {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        f.write_str(&self.0)
111    }
112}
113
114impl AsRef<str> for DomainSegment {
115    fn as_ref(&self) -> &str {
116        self.0.as_str()
117    }
118}
119
120impl Add for DomainSegment {
121    type Output = PartiallyQualifiedDomainName;
122
123    fn add(self, rhs: Self) -> Self::Output {
124        PartiallyQualifiedDomainName::from_iter([self, rhs])
125    }
126}
127
128impl Add<PartiallyQualifiedDomainName> for DomainSegment {
129    type Output = PartiallyQualifiedDomainName;
130
131    fn add(self, mut rhs: PartiallyQualifiedDomainName) -> Self::Output {
132        rhs.0.insert(0, self);
133        rhs
134    }
135}
136
137impl Add<&PartiallyQualifiedDomainName> for DomainSegment {
138    type Output = PartiallyQualifiedDomainName;
139
140    fn add(self, rhs: &PartiallyQualifiedDomainName) -> Self::Output {
141        let mut out = rhs.clone();
142        out.0.insert(0, self);
143        out
144    }
145}
146
147impl Add<FullyQualifiedDomainName> for DomainSegment {
148    type Output = FullyQualifiedDomainName;
149
150    fn add(self, mut rhs: FullyQualifiedDomainName) -> Self::Output {
151        rhs.0.insert(0, self);
152        rhs
153    }
154}
155
156impl Add<&FullyQualifiedDomainName> for DomainSegment {
157    type Output = FullyQualifiedDomainName;
158
159    fn add(self, rhs: &FullyQualifiedDomainName) -> Self::Output {
160        let mut out = rhs.clone();
161        out.0.insert(0, self);
162        out
163    }
164}
165
166impl Add<DomainName> for DomainSegment {
167    type Output = DomainName;
168
169    fn add(self, rhs: DomainName) -> Self::Output {
170        match rhs {
171            DomainName::Full(full) => DomainName::Full(self + full),
172            DomainName::Partial(partial) => DomainName::Partial(self + partial),
173        }
174    }
175}
176
177impl Add<&DomainName> for DomainSegment {
178    type Output = DomainName;
179
180    fn add(self, rhs: &DomainName) -> Self::Output {
181        match rhs {
182            DomainName::Full(full) => DomainName::Full(self + full),
183            DomainName::Partial(partial) => DomainName::Partial(self + partial),
184        }
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use crate::segment::{DomainSegment, DomainSegmentError};
191
192    #[test]
193    fn segment_construction() {
194        assert_eq!(DomainSegment::try_from("abcd").unwrap().as_ref(), "abcd");
195
196        assert_eq!(
197            DomainSegment::try_from(""),
198            Err(DomainSegmentError::EmptyString)
199        );
200    }
201
202    #[test]
203    fn invalid_character() {
204        assert_eq!(
205            DomainSegment::try_from("ab.cd"),
206            Err(DomainSegmentError::InvalidCharacter('.'))
207        );
208    }
209
210    #[test]
211    fn invalid_hyphens() {
212        assert_eq!(DomainSegment::try_from("ab-cd").unwrap().as_ref(), "ab-cd");
213
214        assert_eq!(DomainSegment::try_from("abc-d").unwrap().as_ref(), "abc-d");
215
216        assert_eq!(
217            DomainSegment::try_from("ab--cd"),
218            Err(DomainSegmentError::IllegalHyphen(3))
219        );
220
221        assert_eq!(
222            DomainSegment::try_from("-abcd"),
223            Err(DomainSegmentError::IllegalHyphen(1))
224        );
225
226        assert_eq!(
227            DomainSegment::try_from("abcd-"),
228            Err(DomainSegmentError::IllegalHyphen(5))
229        );
230    }
231
232    #[test]
233    fn wildcards() {
234        assert_eq!(DomainSegment::try_from("*").unwrap().as_ref(), "*");
235
236        assert!(DomainSegment::try_from("*").unwrap().is_wildcard())
237    }
238}