iota_names/
domain.rs

1// Copyright (c) 2025 IOTA Stiftung
2// SPDX-License-Identifier: Apache-2.0
3
4use std::{fmt, str::FromStr};
5
6use iota_types::base_types::IotaAddress;
7use move_core_types::{ident_str, identifier::IdentStr, language_storage::StructTag};
8use serde::{Deserialize, Serialize};
9
10use crate::{
11    constants::{
12        IOTA_NAMES_MAX_DOMAIN_LENGTH, IOTA_NAMES_MAX_LABEL_LENGTH, IOTA_NAMES_MIN_LABEL_LENGTH,
13        IOTA_NAMES_SEPARATOR_AT, IOTA_NAMES_SEPARATOR_DOT, IOTA_NAMES_TLD,
14    },
15    error::IotaNamesError,
16};
17
18#[derive(Debug, Serialize, Deserialize, Clone, Eq, Hash, PartialEq)]
19pub struct Domain {
20    // Labels of the domain name, in reverse order
21    labels: Vec<String>,
22}
23
24impl FromStr for Domain {
25    type Err = IotaNamesError;
26
27    fn from_str(s: &str) -> Result<Self, Self::Err> {
28        if s.len() > IOTA_NAMES_MAX_DOMAIN_LENGTH {
29            return Err(IotaNamesError::DomainLengthExceeded(
30                s.len(),
31                IOTA_NAMES_MAX_DOMAIN_LENGTH,
32            ));
33        }
34
35        let formatted_string = convert_from_at_format(s, &IOTA_NAMES_SEPARATOR_DOT)?;
36
37        let labels = formatted_string
38            .split(IOTA_NAMES_SEPARATOR_DOT)
39            .rev()
40            .map(validate_label)
41            .collect::<Result<Vec<_>, Self::Err>>()?;
42
43        // A valid domain in our system has at least a TLD and an SLD (len == 2).
44        if labels.len() < 2 {
45            return Err(IotaNamesError::NotEnoughLabels);
46        }
47
48        if labels[0] != IOTA_NAMES_TLD {
49            return Err(IotaNamesError::InvalidTld(labels[0].to_string()));
50        }
51
52        let labels = labels.into_iter().map(ToOwned::to_owned).collect();
53
54        Ok(Domain { labels })
55    }
56}
57
58impl fmt::Display for Domain {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        // We use to_string() to check on-chain state and parse on-chain data
61        // so we should always default to DOT format.
62        let output = self.format(DomainFormat::Dot);
63        f.write_str(&output)?;
64
65        Ok(())
66    }
67}
68
69impl Domain {
70    pub fn type_(package_address: IotaAddress) -> StructTag {
71        const IOTA_NAMES_DOMAIN_MODULE: &IdentStr = ident_str!("domain");
72        const IOTA_NAMES_DOMAIN_STRUCT: &IdentStr = ident_str!("Domain");
73
74        StructTag {
75            address: package_address.into(),
76            module: IOTA_NAMES_DOMAIN_MODULE.to_owned(),
77            name: IOTA_NAMES_DOMAIN_STRUCT.to_owned(),
78            type_params: vec![],
79        }
80    }
81
82    /// Derive the parent domain for a given domain. Only subdomains have
83    /// parents; second-level domains return `None`.
84    ///
85    /// ```
86    /// # use std::str::FromStr;
87    /// # use iota_names::domain::Domain;
88    /// assert_eq!(
89    ///     Domain::from_str("test.example.iota").unwrap().parent(),
90    ///     Some(Domain::from_str("example.iota").unwrap())
91    /// );
92    /// assert_eq!(
93    ///     Domain::from_str("sub.test.example.iota").unwrap().parent(),
94    ///     Some(Domain::from_str("test.example.iota").unwrap())
95    /// );
96    /// assert_eq!(Domain::from_str("example.iota").unwrap().parent(), None);
97    /// ```
98    pub fn parent(&self) -> Option<Self> {
99        if self.is_subdomain() {
100            Some(Self {
101                labels: self
102                    .labels
103                    .iter()
104                    .take(self.num_labels() - 1)
105                    .cloned()
106                    .collect(),
107            })
108        } else {
109            None
110        }
111    }
112
113    /// Returns whether this domain is a second-level domain (Ex. `test.iota`)
114    pub fn is_sld(&self) -> bool {
115        self.num_labels() == 2
116    }
117
118    /// Returns whether this domain is a subdomain (Ex. `sub.test.iota`)
119    pub fn is_subdomain(&self) -> bool {
120        self.num_labels() >= 3
121    }
122
123    /// Returns the number of labels including TLD.
124    ///
125    /// ```
126    /// # use std::str::FromStr;
127    /// # use iota_names::domain::Domain;
128    /// assert_eq!(
129    ///     Domain::from_str("test.example.iota").unwrap().num_labels(),
130    ///     3
131    /// )
132    /// ```
133    pub fn num_labels(&self) -> usize {
134        self.labels.len()
135    }
136
137    /// Get the label at the given index
138    pub fn label(&self, index: usize) -> Option<&String> {
139        self.labels.get(index)
140    }
141
142    /// Get all of the labels. NOTE: These are in reverse order starting with
143    /// the top-level domain and proceeding to subdomains.
144    pub fn labels(&self) -> &[String] {
145        &self.labels
146    }
147
148    /// Formats a domain into a string based on the available output formats.
149    /// The default separator is `.`
150    pub fn format(&self, format: DomainFormat) -> String {
151        let mut labels = self.labels.clone();
152        let sep = &IOTA_NAMES_SEPARATOR_DOT.to_string();
153        labels.reverse();
154
155        if format == DomainFormat::Dot {
156            // DOT format, all labels joined together with dots, including the TLD.
157            labels.join(sep)
158        } else {
159            // SAFETY: This is a safe operation because we only allow a
160            // domain's label vector size to be >= 2 (see `Domain::from_str`)
161            let _tld = labels.pop();
162            let sld = labels.pop().unwrap();
163
164            // AT format, labels minus SLD joined together with dots, then joined to SLD
165            // with @, no TLD.
166            format!("{}{IOTA_NAMES_SEPARATOR_AT}{sld}", labels.join(sep))
167        }
168    }
169}
170
171/// Two different view options for a domain.
172/// `At` -> `test@example` | `Dot` -> `test.example.iota`
173#[derive(Clone, Eq, PartialEq, Debug)]
174pub enum DomainFormat {
175    At,
176    Dot,
177}
178
179/// Converts @label ending to label{separator}iota ending.
180///
181/// E.g. `@example` -> `example.iota` | `test@example` -> `test.example.iota`
182fn convert_from_at_format(s: &str, separator: &char) -> Result<String, IotaNamesError> {
183    let mut splits = s.split(IOTA_NAMES_SEPARATOR_AT);
184
185    let Some(before) = splits.next() else {
186        return Err(IotaNamesError::InvalidSeparator);
187    };
188
189    let Some(after) = splits.next() else {
190        return Ok(before.to_string());
191    };
192
193    if splits.next().is_some() || after.contains(*separator) || after.is_empty() {
194        return Err(IotaNamesError::InvalidSeparator);
195    }
196
197    let mut parts = vec![];
198
199    if !before.is_empty() {
200        parts.push(before);
201    }
202
203    parts.push(after);
204    parts.push(IOTA_NAMES_TLD);
205
206    Ok(parts.join(&separator.to_string()))
207}
208
209/// Checks the validity of a label according to these rules:
210/// - length must be in
211///   [IOTA_NAMES_MIN_LABEL_LENGTH..IOTA_NAMES_MAX_LABEL_LENGTH]
212/// - must contain only '0'..'9', 'a'..'z' and '-'
213/// - must not start or end with '-'
214pub fn validate_label(label: &str) -> Result<&str, IotaNamesError> {
215    let bytes = label.as_bytes();
216    let len = bytes.len();
217
218    if !(IOTA_NAMES_MIN_LABEL_LENGTH..=IOTA_NAMES_MAX_LABEL_LENGTH).contains(&len) {
219        return Err(IotaNamesError::InvalidLabelLength(
220            len,
221            IOTA_NAMES_MIN_LABEL_LENGTH,
222            IOTA_NAMES_MAX_LABEL_LENGTH,
223        ));
224    }
225
226    for (i, character) in bytes.iter().enumerate() {
227        match character {
228            b'a'..=b'z' | b'0'..=b'9' => continue,
229            b'-' if i == 0 || i == len - 1 => {
230                return Err(IotaNamesError::HyphensAsFirstOrLastLabelChar);
231            }
232            _ => return Err(IotaNamesError::InvalidLabelChar),
233        };
234    }
235
236    Ok(label)
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn parent_extraction() {
245        let name = Domain::from_str("leaf.node.test.iota")
246            .unwrap()
247            .parent()
248            .unwrap();
249
250        assert_eq!(name.to_string(), "node.test.iota");
251
252        let name = name.parent().unwrap();
253
254        assert_eq!(name.to_string(), "test.iota");
255
256        assert!(name.parent().is_none());
257    }
258
259    #[test]
260    fn name_service_outputs() {
261        assert_eq!("@test".parse::<Domain>().unwrap().to_string(), "test.iota");
262        assert_eq!(
263            "test.iota".parse::<Domain>().unwrap().to_string(),
264            "test.iota"
265        );
266        assert_eq!(
267            "test@sld".parse::<Domain>().unwrap().to_string(),
268            "test.sld.iota"
269        );
270        assert_eq!(
271            "test.test@example".parse::<Domain>().unwrap().to_string(),
272            "test.test.example.iota"
273        );
274        assert_eq!(
275            "iota@iota".parse::<Domain>().unwrap().to_string(),
276            "iota.iota.iota"
277        );
278        assert_eq!("@iota".parse::<Domain>().unwrap().to_string(), "iota.iota");
279        assert_eq!(
280            "test.test.iota".parse::<Domain>().unwrap().to_string(),
281            "test.test.iota"
282        );
283        assert_eq!(
284            "test.test.test.iota".parse::<Domain>().unwrap().to_string(),
285            "test.test.test.iota"
286        );
287    }
288
289    #[test]
290    fn invalid_inputs() {
291        assert!(".".parse::<Domain>().is_err());
292        assert!("@".parse::<Domain>().is_err());
293        assert!("@inner.iota".parse::<Domain>().is_err());
294        assert!("test@".parse::<Domain>().is_err());
295        assert!("iota".parse::<Domain>().is_err());
296        assert!("test.test@example.iota".parse::<Domain>().is_err());
297        assert!("test@test@example".parse::<Domain>().is_err());
298        assert!("test.atoi".parse::<Domain>().is_err());
299    }
300
301    #[test]
302    fn outputs() {
303        let mut domain = "test.iota".parse::<Domain>().unwrap();
304        assert!(domain.format(DomainFormat::Dot) == "test.iota");
305        assert!(domain.format(DomainFormat::At) == "@test");
306
307        domain = "test.test.iota".parse::<Domain>().unwrap();
308        assert!(domain.format(DomainFormat::Dot) == "test.test.iota");
309        assert!(domain.format(DomainFormat::At) == "test@test");
310
311        domain = "test.test.test.iota".parse::<Domain>().unwrap();
312        assert!(domain.format(DomainFormat::Dot) == "test.test.test.iota");
313        assert!(domain.format(DomainFormat::At) == "test.test@test");
314
315        domain = "test.test.test.test.iota".parse::<Domain>().unwrap();
316        assert!(domain.format(DomainFormat::Dot) == "test.test.test.test.iota");
317        assert!(domain.format(DomainFormat::At) == "test.test.test@test");
318    }
319}