Skip to main content

agent_uri/
trust_root.rs

1//! Trust root type for agent authorities.
2//!
3//! # Grammar Reference
4//!
5//! The trust root grammar is defined in `grammar.abnf`:
6//!
7//! ```abnf
8//! trust-root = host [ ":" port ]
9//! host       = domain / ip-literal / ipv4-address
10//! domain     = label *( "." label )
11//! label      = 1*63( ALPHA / DIGIT / "-" )
12//! ```
13//!
14//! `ipv4-address` takes precedence over `domain` for hosts shaped as exactly four
15//! dotted numeric labels.
16//!
17//! Maximum trust root length: 128 characters (including port).
18
19use std::cmp::Ordering;
20use std::fmt;
21use std::net::{Ipv4Addr, Ipv6Addr};
22use std::str::FromStr;
23
24use crate::constants::{MAX_DNS_DOMAIN_LENGTH, MAX_DNS_LABEL_LENGTH, MAX_TRUST_ROOT_LENGTH};
25use crate::error::TrustRootError;
26
27/// The host portion of a trust root.
28#[derive(Debug, Clone, PartialEq, Eq, Hash)]
29pub enum Host {
30    /// A domain name (e.g., "anthropic.com")
31    Domain(String),
32    /// An IPv4 address (e.g., "192.168.1.1")
33    Ipv4(Ipv4Addr),
34    /// An IPv6 address (e.g., `::1`)
35    Ipv6(Ipv6Addr),
36}
37
38/// A validated trust root (authority) from an agent URI.
39///
40/// The trust root identifies the authority that vouches for an agent's
41/// identity and capabilities. It consists of a host (domain or IP) and
42/// an optional port.
43///
44/// # Examples
45///
46/// ```
47/// use agent_uri::TrustRoot;
48///
49/// let root = TrustRoot::parse("anthropic.com").unwrap();
50/// assert_eq!(root.host_str(), "anthropic.com");
51/// assert!(root.port().is_none());
52///
53/// let root = TrustRoot::parse("localhost:8472").unwrap();
54/// assert_eq!(root.host_str(), "localhost");
55/// assert_eq!(root.port(), Some(8472));
56/// ```
57///
58/// # Internationalized names
59///
60/// **A trust root is ASCII. Converting an internationalized name to A-label
61/// form is the caller's job, and this type will not tell you whether you did
62/// it right.** Three consequences, none of them accidents:
63///
64/// 1. **A non-ASCII name is rejected, not converted.** `münchen.de` is an
65///    error, not a synonym for `xn--mnchen-3ya.de`. Doing the conversion here
66///    would mean shipping a Unicode normalization table and a version of
67///    [UTS #46] inside an identity type, and two versions of that table
68///    disagreeing would make one name two identities. Refusing is the smaller
69///    surface.
70/// 2. **An `xn--` label is opaque ASCII.** It is checked as a DNS label and
71///    nothing more: no Punycode decode, no [IDNA] validity check, no
72///    bidirectional or script rules. `xn--zzzzzz` is not valid Punycode and
73///    parses anyway. If a name reaches here already broken, it stays broken.
74/// 3. **Nothing here detects confusables.** `аpple.com` with a Cyrillic `а` is
75///    rejected only because it is not ASCII; its A-label form,
76///    `xn--pple-43d.com`, is accepted and is a different trust root than
77///    `apple.com`. Two names that a person cannot tell apart are two
78///    identities, and only the second ever reaches this type. Homograph
79///    defence belongs where names are shown to people and where keys are
80///    fetched, not in a parser that sees ASCII.
81///
82/// A trailing dot is rejected rather than stripped, so the DNS root-anchored
83/// `example.com.` is not a spelling of `example.com`; only the latter names
84/// that identity. ASCII case is folded, per RFC 4343.
85///
86/// ```
87/// use agent_uri::TrustRoot;
88///
89/// // Convert before you get here.
90/// assert!(TrustRoot::parse("münchen.de").is_err());
91/// assert!(TrustRoot::parse("xn--mnchen-3ya.de").is_ok());
92///
93/// // Which means a name that was converted wrongly is accepted as written.
94/// assert!(TrustRoot::parse("xn--zzzzzz").is_ok());
95///
96/// // And a trailing dot is a different string, so it is not a trust root.
97/// assert!(TrustRoot::parse("example.com.").is_err());
98/// ```
99///
100/// See [Section 8.11 of the specification][spec] for the threat this leaves
101/// to the caller.
102///
103/// [UTS #46]: https://www.unicode.org/reports/tr46/
104/// [IDNA]: https://datatracker.ietf.org/doc/html/rfc5891
105/// [spec]: https://github.com/Govcraft/agent-uri-rs/blob/main/SPECIFICATION.md#811-internationalized-trust-root-names
106#[derive(Debug, Clone, PartialEq, Eq, Hash)]
107pub struct TrustRoot {
108    host: Host,
109    port: Option<u16>,
110    /// Original string representation (normalized to lowercase)
111    normalized: String,
112}
113
114impl TrustRoot {
115    /// Parses a trust root from a string.
116    ///
117    /// # Errors
118    ///
119    /// Returns `TrustRootError` if:
120    /// - The input is empty
121    /// - The input exceeds 128 characters
122    /// - The domain contains invalid characters or labels
123    /// - A host of exactly four dotted numeric labels has an IPv4 octet outside
124    ///   0-255 or an octet with a leading zero; this shape is parsed as IPv4 and
125    ///   is never treated as a domain
126    /// - The port is invalid (not numeric or out of range)
127    pub fn parse(input: &str) -> Result<Self, TrustRootError> {
128        if input.is_empty() {
129            return Err(TrustRootError::Empty);
130        }
131
132        if input.len() > MAX_TRUST_ROOT_LENGTH {
133            return Err(TrustRootError::TooLong {
134                max: MAX_TRUST_ROOT_LENGTH,
135                actual: input.len(),
136            });
137        }
138
139        // Handle IPv6 literals: [::1]:port
140        if input.starts_with('[') {
141            return Self::parse_ipv6_literal(input);
142        }
143
144        // Split host and port
145        let (host_str, port) = Self::split_host_port(input)?;
146        let host = Self::parse_host(host_str)?;
147        let normalized = Self::normalize(&host, port);
148
149        Ok(Self {
150            host,
151            port,
152            normalized,
153        })
154    }
155
156    /// Returns the host portion.
157    #[must_use]
158    pub const fn host(&self) -> &Host {
159        &self.host
160    }
161
162    /// Returns the host as a string.
163    #[must_use]
164    pub fn host_str(&self) -> &str {
165        match &self.host {
166            Host::Domain(d) => d,
167            Host::Ipv4(_) => {
168                // Use the normalized form, split by colon
169                self.normalized
170                    .split(':')
171                    .next()
172                    .unwrap_or(&self.normalized)
173            }
174            Host::Ipv6(_) => {
175                // Extract from [addr] format
176                let start = self.normalized.find('[').map_or(0, |i| i + 1);
177                let end = self.normalized.find(']').unwrap_or(self.normalized.len());
178                &self.normalized[start..end]
179            }
180        }
181    }
182
183    /// Returns the port, if specified.
184    #[must_use]
185    pub const fn port(&self) -> Option<u16> {
186        self.port
187    }
188
189    /// Returns true if this is a localhost address.
190    #[must_use]
191    pub fn is_localhost(&self) -> bool {
192        match &self.host {
193            Host::Domain(d) => d == "localhost",
194            Host::Ipv4(ip) => ip.is_loopback(),
195            Host::Ipv6(ip) => ip.is_loopback(),
196        }
197    }
198
199    /// Returns the normalized string representation.
200    #[must_use]
201    pub fn as_str(&self) -> &str {
202        &self.normalized
203    }
204
205    /// Returns a new trust root with the given port.
206    ///
207    /// # Errors
208    ///
209    /// Returns `TrustRootError` if the resulting trust root would exceed
210    /// the maximum length.
211    ///
212    /// # Examples
213    ///
214    /// ```
215    /// use agent_uri::TrustRoot;
216    ///
217    /// let root = TrustRoot::parse("localhost").unwrap();
218    /// let with_port = root.with_port(8472).unwrap();
219    /// assert_eq!(with_port.port(), Some(8472));
220    /// ```
221    pub fn with_port(&self, port: u16) -> Result<Self, TrustRootError> {
222        let normalized = Self::normalize(&self.host, Some(port));
223        if normalized.len() > MAX_TRUST_ROOT_LENGTH {
224            return Err(TrustRootError::TooLong {
225                max: MAX_TRUST_ROOT_LENGTH,
226                actual: normalized.len(),
227            });
228        }
229        Ok(Self {
230            host: self.host.clone(),
231            port: Some(port),
232            normalized,
233        })
234    }
235
236    /// Returns a new trust root without a port.
237    ///
238    /// # Examples
239    ///
240    /// ```
241    /// use agent_uri::TrustRoot;
242    ///
243    /// let root = TrustRoot::parse("localhost:8472").unwrap();
244    /// let without_port = root.without_port();
245    /// assert!(without_port.port().is_none());
246    /// ```
247    #[must_use]
248    pub fn without_port(&self) -> Self {
249        let normalized = Self::normalize(&self.host, None);
250        Self {
251            host: self.host.clone(),
252            port: None,
253            normalized,
254        }
255    }
256
257    fn split_host_port(input: &str) -> Result<(&str, Option<u16>), TrustRootError> {
258        if let Some(colon_idx) = input.rfind(':') {
259            let host_part = &input[..colon_idx];
260            let port_part = &input[colon_idx + 1..];
261
262            // Check if this looks like a port (all digits)
263            if !port_part.is_empty() && port_part.chars().all(|c| c.is_ascii_digit()) {
264                let port: u16 = port_part.parse().map_err(|_| TrustRootError::InvalidPort {
265                    value: port_part.to_string(),
266                    reason: "port must be 0-65535",
267                })?;
268                return Ok((host_part, Some(port)));
269            }
270        }
271        Ok((input, None))
272    }
273
274    fn parse_host(host_str: &str) -> Result<Host, TrustRootError> {
275        if let Ok(ip) = host_str.parse::<Ipv4Addr>() {
276            return Ok(Host::Ipv4(ip));
277        }
278
279        // Four dotted numeric labels are an ipv4-address by grammar shape;
280        // they must not fall through to domain validation (issue #24).
281        if Self::is_ipv4_shaped(host_str) {
282            return Err(TrustRootError::InvalidIpAddress {
283                value: host_str.to_string(),
284                reason: Self::ipv4_rejection_reason(host_str),
285            });
286        }
287
288        Self::validate_domain(host_str)?;
289        Ok(Host::Domain(host_str.to_lowercase()))
290    }
291
292    /// Returns `true` if the host is syntactically four dot-separated,
293    /// non-empty, all-ASCII-digit labels — the shape of `ipv4-address`
294    /// in the grammar, regardless of whether the octet values are valid.
295    fn is_ipv4_shaped(host: &str) -> bool {
296        let mut labels = 0usize;
297        for label in host.split('.') {
298            labels += 1;
299            if labels > 4 || label.is_empty() || !label.bytes().all(|b| b.is_ascii_digit()) {
300                return false;
301            }
302        }
303        labels == 4
304    }
305
306    /// Explains why an IPv4-shaped host is not a valid dotted-decimal address.
307    ///
308    /// Callers must only invoke this for hosts that satisfy `is_ipv4_shaped`
309    /// and that `Ipv4Addr` rejected; for such hosts the only failure modes are
310    /// an out-of-range octet and a leading zero.
311    fn ipv4_rejection_reason(host: &str) -> &'static str {
312        for label in host.split('.') {
313            match label.parse::<u64>() {
314                Ok(value) if value <= 255 => {}
315                _ => return "IPv4 octets must be 0-255",
316            }
317        }
318        "IPv4 octets must not have leading zeros"
319    }
320
321    fn parse_ipv6_literal(input: &str) -> Result<Self, TrustRootError> {
322        let closing_bracket = input
323            .find(']')
324            .ok_or_else(|| TrustRootError::InvalidIpAddress {
325                value: input.to_string(),
326                reason: "missing closing bracket for IPv6 literal",
327            })?;
328
329        let ipv6_str = &input[1..closing_bracket];
330        let ipv6: Ipv6Addr = ipv6_str
331            .parse()
332            .map_err(|_| TrustRootError::InvalidIpAddress {
333                value: ipv6_str.to_string(),
334                reason: "invalid IPv6 address",
335            })?;
336
337        let port = if input.len() > closing_bracket + 1 {
338            if input.as_bytes().get(closing_bracket + 1) != Some(&b':') {
339                return Err(TrustRootError::InvalidPort {
340                    value: input[closing_bracket + 1..].to_string(),
341                    reason: "expected ':' after IPv6 closing bracket",
342                });
343            }
344            let port_str = &input[closing_bracket + 2..];
345            Some(
346                port_str
347                    .parse::<u16>()
348                    .map_err(|_| TrustRootError::InvalidPort {
349                        value: port_str.to_string(),
350                        reason: "port must be 0-65535",
351                    })?,
352            )
353        } else {
354            None
355        };
356
357        let normalized = Self::normalize(&Host::Ipv6(ipv6), port);
358        Ok(Self {
359            host: Host::Ipv6(ipv6),
360            port,
361            normalized,
362        })
363    }
364
365    fn validate_domain(domain: &str) -> Result<(), TrustRootError> {
366        if domain.len() > MAX_DNS_DOMAIN_LENGTH {
367            return Err(TrustRootError::InvalidDomain {
368                domain: domain.to_string(),
369                reason: "domain exceeds 253 character limit",
370            });
371        }
372
373        for label in domain.split('.') {
374            if label.is_empty() {
375                return Err(TrustRootError::InvalidDomain {
376                    domain: domain.to_string(),
377                    reason: "empty label (consecutive dots or leading/trailing dot)",
378                });
379            }
380
381            if label.len() > MAX_DNS_LABEL_LENGTH {
382                return Err(TrustRootError::LabelTooLong {
383                    label: label.to_string(),
384                    max: MAX_DNS_LABEL_LENGTH,
385                    actual: label.len(),
386                });
387            }
388
389            // Check characters (alphanumeric and hyphens)
390            for (j, c) in label.chars().enumerate() {
391                if !c.is_ascii_alphanumeric() && c != '-' {
392                    return Err(TrustRootError::InvalidChar {
393                        char: c,
394                        position: domain.find(label).unwrap_or(0) + j,
395                    });
396                }
397            }
398
399            // Labels cannot start or end with hyphen
400            if label.starts_with('-') || label.ends_with('-') {
401                return Err(TrustRootError::InvalidDomain {
402                    domain: domain.to_string(),
403                    reason: "label cannot start or end with hyphen",
404                });
405            }
406        }
407
408        Ok(())
409    }
410
411    fn normalize(host: &Host, port: Option<u16>) -> String {
412        let host_str = match host {
413            Host::Domain(d) => d.clone(),
414            Host::Ipv4(ip) => ip.to_string(),
415            Host::Ipv6(ip) => format!("[{ip}]"),
416        };
417
418        match port {
419            Some(p) => format!("{host_str}:{p}"),
420            None => host_str,
421        }
422    }
423}
424
425impl fmt::Display for TrustRoot {
426    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
427        write!(f, "{}", self.normalized)
428    }
429}
430
431impl FromStr for TrustRoot {
432    type Err = TrustRootError;
433
434    fn from_str(s: &str) -> Result<Self, Self::Err> {
435        Self::parse(s)
436    }
437}
438
439impl AsRef<str> for TrustRoot {
440    fn as_ref(&self) -> &str {
441        &self.normalized
442    }
443}
444
445impl TryFrom<&str> for TrustRoot {
446    type Error = TrustRootError;
447
448    fn try_from(s: &str) -> Result<Self, Self::Error> {
449        Self::parse(s)
450    }
451}
452
453impl PartialOrd for TrustRoot {
454    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
455        Some(self.cmp(other))
456    }
457}
458
459impl Ord for TrustRoot {
460    fn cmp(&self, other: &Self) -> Ordering {
461        self.normalized.cmp(&other.normalized)
462    }
463}
464
465impl PartialOrd for Host {
466    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
467        Some(self.cmp(other))
468    }
469}
470
471impl Ord for Host {
472    fn cmp(&self, other: &Self) -> Ordering {
473        match (self, other) {
474            (Self::Domain(a), Self::Domain(b)) => a.cmp(b),
475            (Self::Ipv4(a), Self::Ipv4(b)) => a.cmp(b),
476            (Self::Ipv6(a), Self::Ipv6(b)) => a.cmp(b),
477            (Self::Domain(_), _) | (Self::Ipv4(_), Self::Ipv6(_)) => Ordering::Less,
478            (_, Self::Domain(_)) | (Self::Ipv6(_), Self::Ipv4(_)) => Ordering::Greater,
479        }
480    }
481}
482
483#[cfg(feature = "serde")]
484impl serde::Serialize for TrustRoot {
485    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
486    where
487        S: serde::Serializer,
488    {
489        serializer.serialize_str(&self.normalized)
490    }
491}
492
493#[cfg(feature = "serde")]
494impl<'de> serde::Deserialize<'de> for TrustRoot {
495    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
496    where
497        D: serde::Deserializer<'de>,
498    {
499        let s = String::deserialize(deserializer)?;
500        Self::parse(&s).map_err(serde::de::Error::custom)
501    }
502}
503
504#[cfg(feature = "serde")]
505impl serde::Serialize for Host {
506    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
507    where
508        S: serde::Serializer,
509    {
510        match self {
511            Self::Domain(d) => serializer.serialize_str(d),
512            Self::Ipv4(ip) => serializer.serialize_str(&ip.to_string()),
513            Self::Ipv6(ip) => serializer.serialize_str(&format!("[{ip}]")),
514        }
515    }
516}
517
518#[cfg(feature = "serde")]
519impl<'de> serde::Deserialize<'de> for Host {
520    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
521    where
522        D: serde::Deserializer<'de>,
523    {
524        let s = String::deserialize(deserializer)?;
525        let trust_root = TrustRoot::parse(&s).map_err(serde::de::Error::custom)?;
526        Ok(trust_root.host.clone())
527    }
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    #[test]
535    fn parse_domain() {
536        let root = TrustRoot::parse("anthropic.com").unwrap();
537        assert_eq!(root.host_str(), "anthropic.com");
538        assert!(root.port().is_none());
539    }
540
541    #[test]
542    fn parse_domain_with_port() {
543        let root = TrustRoot::parse("localhost:8472").unwrap();
544        assert_eq!(root.host_str(), "localhost");
545        assert_eq!(root.port(), Some(8472));
546    }
547
548    #[test]
549    fn parse_ipv4() {
550        let root = TrustRoot::parse("192.168.1.1").unwrap();
551        assert!(matches!(root.host(), Host::Ipv4(_)));
552    }
553
554    #[test]
555    fn parse_ipv4_with_port() {
556        let root = TrustRoot::parse("192.168.1.1:8080").unwrap();
557        assert!(matches!(root.host(), Host::Ipv4(_)));
558        assert_eq!(root.port(), Some(8080));
559    }
560
561    #[test]
562    fn parse_ipv6_literal() {
563        let root = TrustRoot::parse("[::1]:8472").unwrap();
564        assert!(matches!(root.host(), Host::Ipv6(_)));
565        assert_eq!(root.port(), Some(8472));
566    }
567
568    #[test]
569    fn parse_ipv6_without_port() {
570        let root = TrustRoot::parse("[::1]").unwrap();
571        assert!(matches!(root.host(), Host::Ipv6(_)));
572        assert!(root.port().is_none());
573    }
574
575    #[test]
576    fn normalizes_to_lowercase() {
577        let root = TrustRoot::parse("ANTHROPIC.COM").unwrap();
578        assert_eq!(root.as_str(), "anthropic.com");
579    }
580
581    #[test]
582    fn is_localhost_domain() {
583        let root = TrustRoot::parse("localhost").unwrap();
584        assert!(root.is_localhost());
585    }
586
587    #[test]
588    fn is_localhost_ipv4() {
589        let root = TrustRoot::parse("127.0.0.1").unwrap();
590        assert!(root.is_localhost());
591    }
592
593    #[test]
594    fn is_localhost_ipv6() {
595        let root = TrustRoot::parse("[::1]").unwrap();
596        assert!(root.is_localhost());
597    }
598
599    #[test]
600    fn is_not_localhost() {
601        let root = TrustRoot::parse("anthropic.com").unwrap();
602        assert!(!root.is_localhost());
603    }
604
605    #[test]
606    fn parse_empty_fails() {
607        let result = TrustRoot::parse("");
608        assert!(matches!(result, Err(TrustRootError::Empty)));
609    }
610
611    #[test]
612    fn parse_too_long_fails() {
613        let long = "a".repeat(129);
614        let result = TrustRoot::parse(&long);
615        assert!(matches!(result, Err(TrustRootError::TooLong { .. })));
616    }
617
618    #[test]
619    fn parse_invalid_domain_fails() {
620        let result = TrustRoot::parse("invalid..domain");
621        assert!(matches!(result, Err(TrustRootError::InvalidDomain { .. })));
622    }
623
624    #[test]
625    fn parse_label_with_hyphen_start_fails() {
626        let result = TrustRoot::parse("-invalid.com");
627        assert!(matches!(result, Err(TrustRootError::InvalidDomain { .. })));
628    }
629
630    #[test]
631    fn ipv4_shaped_host_with_out_of_range_octet_is_rejected() {
632        let result = TrustRoot::parse("256.1.1.1");
633
634        assert!(matches!(
635            result,
636            Err(TrustRootError::InvalidIpAddress {
637                reason: "IPv4 octets must be 0-255",
638                ..
639            })
640        ));
641    }
642
643    #[test]
644    fn ipv4_shaped_host_with_port_and_out_of_range_octet_is_rejected() {
645        let result = TrustRoot::parse("256.1.1.1:8080");
646
647        assert!(matches!(
648            result,
649            Err(TrustRootError::InvalidIpAddress {
650                reason: "IPv4 octets must be 0-255",
651                ..
652            })
653        ));
654    }
655
656    #[test]
657    fn ipv4_shaped_host_with_leading_zero_is_rejected() {
658        let result = TrustRoot::parse("192.168.001.1");
659
660        assert!(matches!(
661            result,
662            Err(TrustRootError::InvalidIpAddress { .. })
663        ));
664    }
665
666    #[test]
667    fn ipv4_shaped_host_with_large_octet_is_rejected() {
668        let result = TrustRoot::parse("1.2.3.99999");
669
670        assert!(matches!(
671            result,
672            Err(TrustRootError::InvalidIpAddress { .. })
673        ));
674    }
675
676    #[test]
677    fn ipv4_shaped_host_with_all_octets_out_of_range_is_rejected() {
678        let result = TrustRoot::parse("999.999.999.999");
679
680        assert!(matches!(
681            result,
682            Err(TrustRootError::InvalidIpAddress { .. })
683        ));
684    }
685
686    #[test]
687    fn valid_ipv4_hosts_parse_as_ipv4() {
688        for input in ["192.168.1.1", "127.0.0.1", "0.0.0.0", "255.255.255.255"] {
689            let root = TrustRoot::parse(input).unwrap();
690
691            assert!(matches!(root.host(), Host::Ipv4(_)));
692        }
693    }
694
695    #[test]
696    fn valid_ipv4_host_with_port_parses_as_ipv4() {
697        let root = TrustRoot::parse("192.168.1.1:8080").unwrap();
698
699        assert!(matches!(root.host(), Host::Ipv4(_)));
700        assert_eq!(root.port(), Some(8080));
701    }
702
703    #[test]
704    fn hosts_without_exactly_four_numeric_labels_parse_as_domains() {
705        for input in ["1.2.3", "1.2.3.4.5", "1a.2.3.4", "256.1.1.1.example.com"] {
706            let root = TrustRoot::parse(input).unwrap();
707
708            assert!(matches!(root.host(), Host::Domain(domain) if domain == input));
709        }
710    }
711
712    #[test]
713    fn a_non_ascii_host_is_refused_and_not_converted() {
714        // Section 8.11: converting here would put a Unicode version table on
715        // the identity path, where two versions disagreeing make one name two
716        // trust roots.
717        for input in [
718            "münchen.de",
719            "\u{43f}\u{440}\u{438}\u{43c}\u{435}\u{440}.com",
720            "café.fr",
721        ] {
722            let result = TrustRoot::parse(input);
723            assert!(
724                matches!(result, Err(TrustRootError::InvalidChar { .. })),
725                "{input} was not refused as a non-ASCII host: {result:?}"
726            );
727        }
728    }
729
730    #[test]
731    fn an_a_label_is_an_opaque_dns_label() {
732        // `xn--` gets no Punycode decode and no IDNA check. The second of
733        // these does not decode to anything, and parses anyway; documenting
734        // that is the point of section 8.11, so it is pinned rather than
735        // wished away.
736        let converted = TrustRoot::parse("xn--mnchen-3ya.de").expect("an A-label is ASCII");
737        assert_eq!(converted.host_str(), "xn--mnchen-3ya.de");
738
739        let nonsense = TrustRoot::parse("xn--zzzzzz").expect("not decoded, so not refused");
740        assert_eq!(nonsense.host_str(), "xn--zzzzzz");
741    }
742
743    #[test]
744    fn a_homograph_is_a_different_trust_root_and_nothing_here_says_so() {
745        // `xn--pple-43d.com` is the A-label form of `apple.com` spelled with
746        // a Cyrillic first letter. Both parse, and they are unrelated.
747        let latin = TrustRoot::parse("apple.com").expect("valid");
748        let cyrillic = TrustRoot::parse("xn--pple-43d.com").expect("valid");
749
750        assert_ne!(latin, cyrillic);
751        assert_ne!(latin.host_str(), cyrillic.host_str());
752    }
753
754    #[test]
755    fn a_root_anchored_name_is_not_a_second_spelling() {
756        // Rejecting rather than stripping removes an equivalence question at
757        // the cost of refusing a form DNS accepts (sections 4.1 and 8.11).
758        assert!(TrustRoot::parse("example.com.").is_err());
759        assert!(TrustRoot::parse("example.com").is_ok());
760    }
761}