Skip to main content

ans_types/
types.rs

1//! Core domain types for ANS verification.
2
3use crate::error::ParseError;
4use serde::{Deserialize, Serialize};
5use std::fmt;
6use std::str::FromStr;
7
8/// A Fully Qualified Domain Name (FQDN).
9#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
10pub struct Fqdn(String);
11
12impl Fqdn {
13    /// Create a new FQDN from a string.
14    ///
15    /// # Errors
16    /// Returns `ParseError::InvalidFqdn` if the string is not a valid FQDN.
17    pub fn new(domain: impl Into<String>) -> Result<Self, ParseError> {
18        let domain = domain.into();
19
20        // Basic validation
21        if domain.is_empty() {
22            return Err(ParseError::InvalidFqdn("empty domain".to_string()));
23        }
24
25        // Remove trailing dot if present
26        let domain = domain.trim_end_matches('.');
27
28        // Check for valid characters and structure
29        for label in domain.split('.') {
30            if label.is_empty() {
31                return Err(ParseError::InvalidFqdn("empty label".to_string()));
32            }
33            if label.len() > 63 {
34                return Err(ParseError::InvalidFqdn("label too long".to_string()));
35            }
36            if !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
37                return Err(ParseError::InvalidFqdn(format!(
38                    "invalid character in label: {label}"
39                )));
40            }
41            if label.starts_with('-') || label.ends_with('-') {
42                return Err(ParseError::InvalidFqdn(
43                    "label cannot start or end with hyphen".to_string(),
44                ));
45            }
46        }
47
48        Ok(Self(domain.to_lowercase()))
49    }
50
51    /// Get the FQDN as a string slice.
52    pub fn as_str(&self) -> &str {
53        &self.0
54    }
55
56    /// Get the `_ans-badge` subdomain for this FQDN (primary DNS record name).
57    pub fn ans_badge_name(&self) -> String {
58        format!("_ans-badge.{}", self.0)
59    }
60
61    /// Get the `_ra-badge` subdomain for this FQDN (legacy fallback).
62    pub fn ra_badge_name(&self) -> String {
63        format!("_ra-badge.{}", self.0)
64    }
65
66    /// Get the TLSA record name for this FQDN and port.
67    pub fn tlsa_name(&self, port: u16) -> String {
68        format!("_{port}._tcp.{}", self.0)
69    }
70}
71
72impl fmt::Display for Fqdn {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        write!(f, "{}", self.0)
75    }
76}
77
78impl FromStr for Fqdn {
79    type Err = ParseError;
80
81    fn from_str(s: &str) -> Result<Self, Self::Err> {
82        Self::new(s)
83    }
84}
85
86impl AsRef<str> for Fqdn {
87    fn as_ref(&self) -> &str {
88        &self.0
89    }
90}
91
92impl TryFrom<&str> for Fqdn {
93    type Error = ParseError;
94
95    fn try_from(s: &str) -> Result<Self, Self::Error> {
96        Self::new(s)
97    }
98}
99
100impl TryFrom<String> for Fqdn {
101    type Error = ParseError;
102
103    fn try_from(s: String) -> Result<Self, Self::Error> {
104        Self::new(s)
105    }
106}
107
108/// A semantic version (e.g., v1.0.0).
109#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
110pub struct Version {
111    major: u32,
112    minor: u32,
113    patch: u32,
114}
115
116impl Version {
117    /// Create a new version.
118    pub const fn new(major: u32, minor: u32, patch: u32) -> Self {
119        Self {
120            major,
121            minor,
122            patch,
123        }
124    }
125
126    /// Get the major version number.
127    pub const fn major(&self) -> u32 {
128        self.major
129    }
130
131    /// Get the minor version number.
132    pub const fn minor(&self) -> u32 {
133        self.minor
134    }
135
136    /// Get the patch version number.
137    pub const fn patch(&self) -> u32 {
138        self.patch
139    }
140
141    /// Parse a version string (e.g., "v1.0.0" or "1.0.0").
142    pub fn parse(s: &str) -> Result<Self, ParseError> {
143        let s = s.strip_prefix('v').unwrap_or(s);
144        let parts: Vec<&str> = s.split('.').collect();
145
146        if parts.len() != 3 {
147            return Err(ParseError::InvalidVersion(format!(
148                "expected 3 parts, got {}: {}",
149                parts.len(),
150                s
151            )));
152        }
153
154        let major = parts[0].parse().map_err(|_| {
155            ParseError::InvalidVersion(format!("invalid major version: {}", parts[0]))
156        })?;
157        let minor = parts[1].parse().map_err(|_| {
158            ParseError::InvalidVersion(format!("invalid minor version: {}", parts[1]))
159        })?;
160        let patch = parts[2].parse().map_err(|_| {
161            ParseError::InvalidVersion(format!("invalid patch version: {}", parts[2]))
162        })?;
163
164        Ok(Self {
165            major,
166            minor,
167            patch,
168        })
169    }
170}
171
172impl fmt::Display for Version {
173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174        write!(f, "v{}.{}.{}", self.major, self.minor, self.patch)
175    }
176}
177
178impl FromStr for Version {
179    type Err = ParseError;
180
181    fn from_str(s: &str) -> Result<Self, Self::Err> {
182        Self::parse(s)
183    }
184}
185
186impl TryFrom<&str> for Version {
187    type Error = ParseError;
188
189    fn try_from(s: &str) -> Result<Self, Self::Error> {
190        Self::parse(s)
191    }
192}
193
194impl TryFrom<String> for Version {
195    type Error = ParseError;
196
197    fn try_from(s: String) -> Result<Self, Self::Error> {
198        Self::parse(&s)
199    }
200}
201
202/// An ANS Name URI (e.g., <ans://v1.0.0.agent.example.com>).
203#[derive(Debug, Clone, PartialEq, Eq, Hash)]
204pub struct AnsName {
205    version: Version,
206    fqdn: Fqdn,
207}
208
209impl AnsName {
210    /// Returns the version component.
211    pub fn version(&self) -> &Version {
212        &self.version
213    }
214
215    /// Returns the FQDN component.
216    pub fn fqdn(&self) -> &Fqdn {
217        &self.fqdn
218    }
219
220    /// Parse an ANS name from a URI string.
221    ///
222    /// Format: `ans://v<major>.<minor>.<patch>.<fqdn>`
223    pub fn parse(uri: &str) -> Result<Self, ParseError> {
224        const PREFIX: &str = "ans://";
225
226        if !uri.starts_with(PREFIX) {
227            return Err(ParseError::InvalidAnsName(format!(
228                "ANS name must start with '{PREFIX}': {uri}"
229            )));
230        }
231
232        let rest = &uri[PREFIX.len()..];
233
234        // The format is: v<major>.<minor>.<patch>.<fqdn>
235        // We need to find where the version ends and the FQDN begins
236        if !rest.starts_with('v') {
237            return Err(ParseError::InvalidAnsName(format!(
238                "ANS name version must start with 'v': {uri}"
239            )));
240        }
241
242        let parts: Vec<&str> = rest.splitn(4, '.').collect();
243        if parts.len() < 4 {
244            return Err(ParseError::InvalidAnsName(format!(
245                "ANS name must have format 'ans://vX.Y.Z.fqdn', got: {uri}"
246            )));
247        }
248
249        // Parse version from first 3 parts (including the 'v' prefix)
250        let version_str = format!("{}.{}.{}", parts[0], parts[1], parts[2]);
251        let version = Version::parse(&version_str)?;
252
253        // The rest is the FQDN
254        let fqdn = Fqdn::new(parts[3])?;
255
256        Ok(Self { version, fqdn })
257    }
258}
259
260impl fmt::Display for AnsName {
261    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
262        write!(f, "ans://{}.{}", self.version, self.fqdn)
263    }
264}
265
266impl FromStr for AnsName {
267    type Err = ParseError;
268
269    fn from_str(s: &str) -> Result<Self, Self::Err> {
270        Self::parse(s)
271    }
272}
273
274impl Serialize for AnsName {
275    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
276        serializer.serialize_str(&self.to_string())
277    }
278}
279
280impl<'de> Deserialize<'de> for AnsName {
281    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
282        let s = String::deserialize(deserializer)?;
283        Self::parse(&s).map_err(serde::de::Error::custom)
284    }
285}
286
287impl TryFrom<&str> for AnsName {
288    type Error = ParseError;
289
290    fn try_from(s: &str) -> Result<Self, Self::Error> {
291        Self::parse(s)
292    }
293}
294
295impl TryFrom<String> for AnsName {
296    type Error = ParseError;
297
298    fn try_from(s: String) -> Result<Self, Self::Error> {
299        Self::parse(&s)
300    }
301}
302
303#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    mod fqdn_tests {
309        use super::*;
310
311        #[test]
312        fn test_valid_fqdn() {
313            let fqdn = Fqdn::new("agent.example.com").unwrap();
314            assert_eq!(fqdn.as_str(), "agent.example.com");
315        }
316
317        #[test]
318        fn test_fqdn_with_trailing_dot() {
319            let fqdn = Fqdn::new("agent.example.com.").unwrap();
320            assert_eq!(fqdn.as_str(), "agent.example.com");
321        }
322
323        #[test]
324        fn test_fqdn_lowercased() {
325            let fqdn = Fqdn::new("Agent.Example.COM").unwrap();
326            assert_eq!(fqdn.as_str(), "agent.example.com");
327        }
328
329        #[test]
330        fn test_ans_badge_name() {
331            let fqdn = Fqdn::new("agent.example.com").unwrap();
332            assert_eq!(fqdn.ans_badge_name(), "_ans-badge.agent.example.com");
333        }
334
335        #[test]
336        fn test_ra_badge_name() {
337            let fqdn = Fqdn::new("agent.example.com").unwrap();
338            assert_eq!(fqdn.ra_badge_name(), "_ra-badge.agent.example.com");
339        }
340
341        #[test]
342        fn test_tlsa_name() {
343            let fqdn = Fqdn::new("agent.example.com").unwrap();
344            assert_eq!(fqdn.tlsa_name(443), "_443._tcp.agent.example.com");
345        }
346
347        #[test]
348        fn test_empty_fqdn() {
349            assert!(Fqdn::new("").is_err());
350        }
351
352        #[test]
353        fn test_fqdn_with_invalid_chars() {
354            assert!(Fqdn::new("agent_test.example.com").is_err());
355        }
356    }
357
358    mod version_tests {
359        use super::*;
360
361        #[test]
362        fn test_parse_with_v_prefix() {
363            let v = Version::parse("v1.2.3").unwrap();
364            assert_eq!(v.major(), 1);
365            assert_eq!(v.minor(), 2);
366            assert_eq!(v.patch(), 3);
367        }
368
369        #[test]
370        fn test_parse_without_v_prefix() {
371            let v = Version::parse("1.2.3").unwrap();
372            assert_eq!(v.major(), 1);
373            assert_eq!(v.minor(), 2);
374            assert_eq!(v.patch(), 3);
375        }
376
377        #[test]
378        fn test_version_display() {
379            let v = Version::new(1, 2, 3);
380            assert_eq!(v.to_string(), "v1.2.3");
381        }
382
383        #[test]
384        fn test_version_ordering() {
385            let v1 = Version::new(1, 0, 0);
386            let v2 = Version::new(1, 0, 1);
387            let v3 = Version::new(1, 1, 0);
388            let v4 = Version::new(2, 0, 0);
389            assert!(v1 < v2);
390            assert!(v2 < v3);
391            assert!(v3 < v4);
392        }
393
394        #[test]
395        fn test_invalid_version() {
396            assert!(Version::parse("1.2").is_err());
397            assert!(Version::parse("1.2.3.4").is_err());
398            assert!(Version::parse("a.b.c").is_err());
399        }
400    }
401
402    mod fqdn_extra_tests {
403        use super::*;
404
405        #[test]
406        fn test_fqdn_single_label() {
407            let fqdn = Fqdn::new("localhost").unwrap();
408            assert_eq!(fqdn.as_str(), "localhost");
409        }
410
411        #[test]
412        fn test_fqdn_label_too_long() {
413            let long_label = "a".repeat(64);
414            assert!(Fqdn::new(&long_label).is_err());
415        }
416
417        #[test]
418        fn test_fqdn_leading_hyphen() {
419            assert!(Fqdn::new("-example.com").is_err());
420        }
421
422        #[test]
423        fn test_fqdn_trailing_hyphen() {
424            assert!(Fqdn::new("example-.com").is_err());
425        }
426
427        #[test]
428        fn test_fqdn_double_dots() {
429            assert!(Fqdn::new("agent..example.com").is_err());
430        }
431
432        #[test]
433        fn test_fqdn_display() {
434            let fqdn = Fqdn::new("agent.example.com").unwrap();
435            assert_eq!(format!("{fqdn}"), "agent.example.com");
436        }
437
438        #[test]
439        fn test_fqdn_as_ref() {
440            let fqdn = Fqdn::new("agent.example.com").unwrap();
441            let s: &str = fqdn.as_ref();
442            assert_eq!(s, "agent.example.com");
443        }
444
445        #[test]
446        fn test_fqdn_try_from_str() {
447            let fqdn = Fqdn::try_from("agent.example.com").unwrap();
448            assert_eq!(fqdn.as_str(), "agent.example.com");
449        }
450
451        #[test]
452        fn test_fqdn_try_from_string() {
453            let fqdn = Fqdn::try_from("agent.example.com".to_string()).unwrap();
454            assert_eq!(fqdn.as_str(), "agent.example.com");
455        }
456
457        #[test]
458        fn test_fqdn_from_str() {
459            let fqdn: Fqdn = "agent.example.com".parse().unwrap();
460            assert_eq!(fqdn.as_str(), "agent.example.com");
461        }
462    }
463
464    mod version_extra_tests {
465        use super::*;
466
467        #[test]
468        fn test_version_try_from_str() {
469            let v = Version::try_from("v1.2.3").unwrap();
470            assert_eq!(v, Version::new(1, 2, 3));
471        }
472
473        #[test]
474        fn test_version_try_from_string() {
475            let v = Version::try_from("1.2.3".to_string()).unwrap();
476            assert_eq!(v, Version::new(1, 2, 3));
477        }
478
479        #[test]
480        fn test_version_from_str() {
481            let v: Version = "v1.0.0".parse().unwrap();
482            assert_eq!(v, Version::new(1, 0, 0));
483        }
484
485        #[test]
486        fn test_version_hash_equality() {
487            use std::collections::HashSet;
488            let mut set = HashSet::new();
489            set.insert(Version::new(1, 0, 0));
490            assert!(set.contains(&Version::new(1, 0, 0)));
491            assert!(!set.contains(&Version::new(1, 0, 1)));
492        }
493    }
494
495    mod ans_name_tests {
496        use super::*;
497
498        #[test]
499        fn test_parse_ans_name() {
500            let name = AnsName::parse("ans://v1.0.0.agent.example.com").unwrap();
501            assert_eq!(name.version, Version::new(1, 0, 0));
502            assert_eq!(name.fqdn.as_str(), "agent.example.com");
503        }
504
505        #[test]
506        fn test_parse_ans_name_complex_fqdn() {
507            let name = AnsName::parse("ans://v2.1.3.agent.example.com").unwrap();
508            assert_eq!(name.version, Version::new(2, 1, 3));
509            assert_eq!(name.fqdn.as_str(), "agent.example.com");
510        }
511
512        #[test]
513        fn test_invalid_ans_name_no_prefix() {
514            assert!(AnsName::parse("v1.0.0.agent.example.com").is_err());
515        }
516
517        #[test]
518        fn test_invalid_ans_name_no_version() {
519            assert!(AnsName::parse("ans://agent.example.com").is_err());
520        }
521
522        #[test]
523        fn test_ans_name_display() {
524            let name = AnsName::parse("ans://v1.0.0.agent.example.com").unwrap();
525            assert_eq!(format!("{name}"), "ans://v1.0.0.agent.example.com");
526        }
527
528        #[test]
529        fn test_ans_name_serde_roundtrip() {
530            let name = AnsName::parse("ans://v1.0.0.agent.example.com").unwrap();
531            let json = serde_json::to_string(&name).unwrap();
532            let deserialized: AnsName = serde_json::from_str(&json).unwrap();
533            assert_eq!(name, deserialized);
534        }
535
536        #[test]
537        fn test_ans_name_serde_invalid() {
538            let result = serde_json::from_str::<AnsName>(r#""not-an-ans-name""#);
539            assert!(result.is_err());
540        }
541
542        #[test]
543        fn test_ans_name_try_from_str() {
544            let name = AnsName::try_from("ans://v1.0.0.agent.example.com").unwrap();
545            assert_eq!(name.version(), &Version::new(1, 0, 0));
546        }
547
548        #[test]
549        fn test_ans_name_try_from_string() {
550            let name = AnsName::try_from("ans://v1.0.0.agent.example.com".to_string()).unwrap();
551            assert_eq!(name.fqdn().as_str(), "agent.example.com");
552        }
553
554        #[test]
555        fn test_ans_name_from_str() {
556            let name: AnsName = "ans://v1.0.0.agent.example.com".parse().unwrap();
557            assert_eq!(name.version(), &Version::new(1, 0, 0));
558        }
559
560        #[test]
561        fn test_ans_name_accessors() {
562            let name = AnsName::parse("ans://v2.1.3.agent.example.com").unwrap();
563            assert_eq!(name.version(), &Version::new(2, 1, 3));
564            assert_eq!(name.fqdn().as_str(), "agent.example.com");
565        }
566
567        #[test]
568        fn test_ans_name_no_v_prefix_error() {
569            let result = AnsName::parse("ans://1.0.0.agent.example.com");
570            assert!(result.is_err());
571        }
572    }
573}