kubizone_common/
pqdn.rs

1use std::{
2    fmt::{Display, Write},
3    ops::Add,
4};
5
6use schemars::JsonSchema;
7use serde::{de::Error, Deserialize, Serialize};
8use thiserror::Error;
9
10use crate::{
11    segment::{DomainSegment, DomainSegmentError},
12    FullyQualifiedDomainName,
13};
14
15/// Produced when attempting to construct a [`PartiallyQualifiedDomainName`]
16/// from an invalid string.
17#[derive(Error, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
18pub enum PartiallyQualifiedDomainNameError {
19    /// The parsed string is not partially qualified. That is, it contains
20    /// a trailing dot making it fully qualified.
21    #[error("domain is fully qualified")]
22    DomainIsFullyQualified,
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/// Partially qualified domain name (PQDN).
33///
34/// A partially qualified domain name is an incomplete domain, meaning
35/// the domain name is (potentially) a subdomain of another unknown domain.
36/// Unlike fully qualified domain names, PQDNs indicate only some of the
37/// path within the domain name system.
38///
39/// Partially qualified domain names are often used when the root of the
40/// domain name is not known, or specified elsewhere.
41///
42/// See also [`FullyQualifiedDomainName`]
43#[derive(Clone, Debug, Default, Hash, PartialEq, Eq, PartialOrd, Ord)]
44pub struct PartiallyQualifiedDomainName(pub(crate) Vec<DomainSegment>);
45
46impl PartiallyQualifiedDomainName {
47    /// Appends the fqdn to the end of the partial domain.
48    pub fn with_origin(&self, origin: &FullyQualifiedDomainName) -> FullyQualifiedDomainName {
49        self + origin
50    }
51
52    /// Iterates over all [`DomainSegment`]s that make up the domain name.
53    pub fn iter(&self) -> core::slice::Iter<'_, DomainSegment> {
54        self.0.iter()
55    }
56
57    /// Length of the fully qualified domain name as a string.
58    #[allow(clippy::len_without_is_empty)]
59    pub fn len(&self) -> usize {
60        self.0.iter().map(|segment| segment.len()).sum::<usize>() + self.0.len()
61    }
62
63    /// Coerce the domain name into a fully qualified one.
64    pub fn into_fully_qualified(self) -> FullyQualifiedDomainName {
65        FullyQualifiedDomainName(self.0)
66    }
67
68    /// Coerce the domain name into a fully qualified one.
69    pub fn to_fully_qualified(&self) -> FullyQualifiedDomainName {
70        FullyQualifiedDomainName(self.0.clone())
71    }
72}
73
74impl FromIterator<DomainSegment> for PartiallyQualifiedDomainName {
75    fn from_iter<T: IntoIterator<Item = DomainSegment>>(iter: T) -> Self {
76        PartiallyQualifiedDomainName(iter.into_iter().collect())
77    }
78}
79
80impl<'a> FromIterator<&'a DomainSegment> for PartiallyQualifiedDomainName {
81    fn from_iter<T: IntoIterator<Item = &'a DomainSegment>>(iter: T) -> Self {
82        PartiallyQualifiedDomainName(iter.into_iter().cloned().collect())
83    }
84}
85
86impl TryFrom<String> for PartiallyQualifiedDomainName {
87    type Error = PartiallyQualifiedDomainNameError;
88
89    fn try_from(value: String) -> Result<Self, Self::Error> {
90        Self::try_from(value.as_str())
91    }
92}
93
94impl TryFrom<&str> for PartiallyQualifiedDomainName {
95    type Error = PartiallyQualifiedDomainNameError;
96
97    fn try_from(value: &str) -> Result<Self, Self::Error> {
98        if value.ends_with('.') {
99            Err(PartiallyQualifiedDomainNameError::DomainIsFullyQualified)
100        } else {
101            let segments: Vec<DomainSegment> =
102                Result::from_iter(value.split('.').map(DomainSegment::try_from))?;
103
104            if segments.iter().skip(1).any(DomainSegment::is_wildcard) {
105                return Err(PartiallyQualifiedDomainNameError::NonLeadingWildcard);
106            }
107
108            Ok(PartiallyQualifiedDomainName(segments))
109        }
110    }
111}
112
113impl Display for PartiallyQualifiedDomainName {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        for (index, segment) in self.0.iter().enumerate() {
116            segment.fmt(f)?;
117            if index != self.0.len() - 1 {
118                f.write_char('.')?;
119            }
120        }
121
122        Ok(())
123    }
124}
125
126impl Add<&FullyQualifiedDomainName> for &PartiallyQualifiedDomainName {
127    type Output = FullyQualifiedDomainName;
128
129    fn add(self, rhs: &FullyQualifiedDomainName) -> Self::Output {
130        FullyQualifiedDomainName::from_iter(self.0.iter().chain(rhs.iter()).cloned())
131    }
132}
133
134impl Add for &PartiallyQualifiedDomainName {
135    type Output = PartiallyQualifiedDomainName;
136
137    fn add(self, rhs: &PartiallyQualifiedDomainName) -> Self::Output {
138        PartiallyQualifiedDomainName::from_iter(self.0.iter().chain(rhs.iter()).cloned())
139    }
140}
141
142impl AsRef<[DomainSegment]> for PartiallyQualifiedDomainName {
143    fn as_ref(&self) -> &[DomainSegment] {
144        self.0.as_ref()
145    }
146}
147
148impl PartialEq<String> for PartiallyQualifiedDomainName {
149    fn eq(&self, other: &String) -> bool {
150        self.to_string().eq(other)
151    }
152}
153
154impl PartialEq<str> for PartiallyQualifiedDomainName {
155    fn eq(&self, other: &str) -> bool {
156        self.to_string().eq(other)
157    }
158}
159
160impl JsonSchema for PartiallyQualifiedDomainName {
161    fn schema_name() -> String {
162        <String as schemars::JsonSchema>::schema_name()
163    }
164
165    fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
166        <String as schemars::JsonSchema>::json_schema(gen)
167    }
168}
169
170impl<'de> Deserialize<'de> for PartiallyQualifiedDomainName {
171    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
172    where
173        D: serde::Deserializer<'de>,
174    {
175        let value = String::deserialize(deserializer)?;
176
177        Self::try_from(value).map_err(D::Error::custom)
178    }
179}
180
181impl Serialize for PartiallyQualifiedDomainName {
182    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
183    where
184        S: serde::Serializer,
185    {
186        self.to_string().serialize(serializer)
187    }
188}
189
190#[cfg(test)]
191mod test {
192    use crate::{
193        error::PartiallyQualifiedDomainNameError, segment::DomainSegment, FullyQualifiedDomainName,
194        PartiallyQualifiedDomainName,
195    };
196
197    #[test]
198    fn construct_pqdn() {
199        assert_eq!(
200            PartiallyQualifiedDomainName::try_from("example.org").unwrap(),
201            PartiallyQualifiedDomainName::from_iter([
202                DomainSegment::try_from("example").unwrap(),
203                DomainSegment::try_from("org").unwrap()
204            ])
205        );
206    }
207
208    #[test]
209    fn pqdn_from_fqdn_fails() {
210        assert_eq!(
211            PartiallyQualifiedDomainName::try_from("example.org."),
212            Err(PartiallyQualifiedDomainNameError::DomainIsFullyQualified)
213        );
214    }
215
216    #[test]
217    fn addition() {
218        assert_eq!(
219            &PartiallyQualifiedDomainName::try_from("test").unwrap()
220                + &FullyQualifiedDomainName::try_from("example.org.").unwrap(),
221            FullyQualifiedDomainName::try_from("test.example.org.").unwrap()
222        )
223    }
224
225    #[test]
226    fn pqdn_addition() {
227        assert_eq!(
228            &PartiallyQualifiedDomainName::try_from("test").unwrap()
229                + &PartiallyQualifiedDomainName::try_from("example").unwrap(),
230            PartiallyQualifiedDomainName::try_from("test.example").unwrap()
231        )
232    }
233}