kubizone_common/
fqdn.rs

1use std::{
2    fmt::{Debug, Display, Write},
3    ops::Sub,
4};
5
6use schemars::JsonSchema;
7use serde::{de::Error, Deserialize, Serialize};
8use thiserror::Error;
9
10use crate::{
11    segment::{DomainSegment, DomainSegmentError},
12    PartiallyQualifiedDomainName,
13};
14
15/// Produced when attempting to construct a [`FullyQualifiedDomainName`]
16/// from an invalid string.
17#[derive(Error, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
18pub enum FullyQualifiedDomainNameError {
19    /// The parsed string is not fully qualified. i.e. it does not contain
20    /// a trailing dot.
21    #[error("domain is partially qualified")]
22    DomainIsPartiallyQualified,
23    /// One or more of the segments of the domain specified in the string
24    /// are invalid.
25    #[error("{0}")]
26    SegmentError(#[from] DomainSegmentError),
27    /// Wildcard segments must only appear at the beginning of a record.
28    #[error("non-leading wildcard segment")]
29    NonLeadingWildcard,
30}
31
32/// Fully qualified domain name (FQDN).
33///
34/// A fully qualified domain name is a domain name consisting of
35/// a series of [`DomainSegment`]s, and ending in a trailing dot.
36/// The trailing dot indicates that this is the entirety of the
37/// domain name, and therefore denotes the exact location of the
38/// domain within the domain name system.
39///
40/// See also [`PartiallyQualifiedDomainName`](crate::PartiallyQualifiedDomainName).
41#[derive(Default, Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
42pub struct FullyQualifiedDomainName(pub(crate) Vec<DomainSegment>);
43
44impl FullyQualifiedDomainName {
45    /// Iterates over all [`DomainSegment`]s that make up the domain name.
46    pub fn iter(&self) -> core::slice::Iter<'_, DomainSegment> {
47        self.0.iter()
48    }
49
50    /// Returns true if `parent` matches the tail end of `self`.
51    pub fn is_subdomain_of(&self, parent: &FullyQualifiedDomainName) -> bool {
52        self.0.ends_with(parent.as_ref()) && self != parent
53    }
54
55    /// Length of the fully qualified domain name as a string, *including* the trailing dot.
56    #[allow(clippy::len_without_is_empty)]
57    pub fn len(&self) -> usize {
58        self.0.iter().map(|segment| segment.len()).sum::<usize>() + self.0.len()
59    }
60
61    /// Coerce the domain name into a partially qualified one.
62    pub fn into_partially_qualified(self) -> PartiallyQualifiedDomainName {
63        PartiallyQualifiedDomainName(self.0)
64    }
65
66    /// Coerce the domain name into a partially qualified one.
67    pub fn to_partially_qualified(&self) -> PartiallyQualifiedDomainName {
68        PartiallyQualifiedDomainName(self.0.clone())
69    }
70}
71
72impl FromIterator<DomainSegment> for FullyQualifiedDomainName {
73    fn from_iter<T: IntoIterator<Item = DomainSegment>>(iter: T) -> Self {
74        FullyQualifiedDomainName(iter.into_iter().collect())
75    }
76}
77
78impl<'a> FromIterator<&'a DomainSegment> for FullyQualifiedDomainName {
79    fn from_iter<T: IntoIterator<Item = &'a DomainSegment>>(iter: T) -> Self {
80        FullyQualifiedDomainName(iter.into_iter().cloned().collect())
81    }
82}
83
84impl TryFrom<String> for FullyQualifiedDomainName {
85    type Error = FullyQualifiedDomainNameError;
86
87    fn try_from(value: String) -> Result<Self, Self::Error> {
88        Self::try_from(value.as_str())
89    }
90}
91
92impl TryFrom<&str> for FullyQualifiedDomainName {
93    type Error = FullyQualifiedDomainNameError;
94
95    fn try_from(value: &str) -> Result<Self, Self::Error> {
96        if !value.ends_with('.') {
97            Err(FullyQualifiedDomainNameError::DomainIsPartiallyQualified)
98        } else {
99            let segments: Vec<DomainSegment> = Result::from_iter(
100                value
101                    .trim_end_matches('.')
102                    .split('.')
103                    .map(DomainSegment::try_from),
104            )?;
105
106            if segments.iter().skip(1).any(DomainSegment::is_wildcard) {
107                return Err(FullyQualifiedDomainNameError::NonLeadingWildcard);
108            }
109
110            Ok(FullyQualifiedDomainName(segments))
111        }
112    }
113}
114
115impl Display for FullyQualifiedDomainName {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        for segment in &self.0 {
118            write!(f, "{}", segment)?;
119            f.write_char('.')?;
120        }
121
122        Ok(())
123    }
124}
125
126impl AsRef<[DomainSegment]> for FullyQualifiedDomainName {
127    fn as_ref(&self) -> &[DomainSegment] {
128        self.0.as_ref()
129    }
130}
131
132impl PartialEq<String> for FullyQualifiedDomainName {
133    fn eq(&self, other: &String) -> bool {
134        self.to_string().eq(other)
135    }
136}
137
138impl PartialEq<str> for FullyQualifiedDomainName {
139    fn eq(&self, other: &str) -> bool {
140        self.to_string().eq(other)
141    }
142}
143
144impl JsonSchema for FullyQualifiedDomainName {
145    fn schema_name() -> String {
146        <String as schemars::JsonSchema>::schema_name()
147    }
148
149    fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
150        <String as schemars::JsonSchema>::json_schema(gen)
151    }
152}
153
154impl<'de> Deserialize<'de> for FullyQualifiedDomainName {
155    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
156    where
157        D: serde::Deserializer<'de>,
158    {
159        let value = String::deserialize(deserializer)?;
160
161        Self::try_from(value).map_err(D::Error::custom)
162    }
163}
164
165impl Serialize for FullyQualifiedDomainName {
166    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
167    where
168        S: serde::Serializer,
169    {
170        self.to_string().serialize(serializer)
171    }
172}
173
174impl<'a> Sub for &'a FullyQualifiedDomainName {
175    type Output = Result<PartiallyQualifiedDomainName, &'a FullyQualifiedDomainName>;
176
177    fn sub(self, rhs: Self) -> Self::Output {
178        let mut own_segments = self.0.clone().into_iter().rev();
179        let parent_segments = rhs.0.iter().rev();
180
181        for parent_domain in parent_segments {
182            if !own_segments
183                .next()
184                .is_some_and(|segment| &segment == parent_domain)
185            {
186                return Err(self);
187            }
188        }
189
190        Ok(PartiallyQualifiedDomainName::from_iter(own_segments.rev()))
191    }
192}
193
194impl Sub for FullyQualifiedDomainName {
195    type Output = Result<PartiallyQualifiedDomainName, FullyQualifiedDomainName>;
196
197    fn sub(self, rhs: Self) -> Self::Output {
198        match &self - &rhs {
199            Ok(partial) => Ok(partial),
200            Err(_) => Err(self),
201        }
202    }
203}
204
205#[cfg(test)]
206mod test {
207    use crate::{
208        fqdn::FullyQualifiedDomainNameError, segment::DomainSegment, FullyQualifiedDomainName,
209        PartiallyQualifiedDomainName,
210    };
211
212    #[test]
213    fn construct_fqdn() {
214        assert_eq!(
215            FullyQualifiedDomainName::try_from("example.org."),
216            Ok(FullyQualifiedDomainName::from_iter([
217                DomainSegment::try_from("example").unwrap(),
218                DomainSegment::try_from("org").unwrap()
219            ]))
220        );
221    }
222
223    #[test]
224    fn fqdn_from_pqdn_fails() {
225        assert_eq!(
226            FullyQualifiedDomainName::try_from("example.org"),
227            Err(FullyQualifiedDomainNameError::DomainIsPartiallyQualified)
228        );
229    }
230
231    #[test]
232    fn subtraction() {
233        assert_eq!(
234            FullyQualifiedDomainName::try_from("www.example.org.").unwrap()
235                - FullyQualifiedDomainName::try_from("example.org.").unwrap(),
236            Ok(PartiallyQualifiedDomainName::try_from("www").unwrap())
237        );
238
239        assert_eq!(
240            FullyQualifiedDomainName::try_from("www.example.org.").unwrap()
241                - FullyQualifiedDomainName::try_from("org.").unwrap(),
242            Ok(PartiallyQualifiedDomainName::try_from("www.example").unwrap())
243        );
244
245        assert_eq!(
246            FullyQualifiedDomainName::try_from("www.example.org.").unwrap()
247                - FullyQualifiedDomainName::try_from("test.org.").unwrap(),
248            Err(FullyQualifiedDomainName::try_from("www.example.org.").unwrap())
249        );
250    }
251}