Skip to main content

urly/
host.rs

1use std::{borrow::Cow, fmt, net::Ipv4Addr, net::Ipv6Addr};
2
3use crate::chars::{self, NONE, REG_NAME, char_at, lowercase};
4use crate::error::{ErrorKind, InvalidUrl};
5use crate::{idna, quoting::unquote_with};
6
7/// Parsed URL host.
8///
9/// ```
10/// use std::net::Ipv6Addr;
11/// use urly::Host;
12///
13/// assert_eq!(Host::parse("example.com").unwrap(), Host::Domain("example.com"));
14/// assert_eq!(Host::parse("[::1]").unwrap(), Host::Ipv6(Ipv6Addr::LOCALHOST));
15/// assert!(Host::parse("exa mple.com").is_err());
16/// ```
17#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
18pub enum Host<'a> {
19    /// Registered name
20    Domain(&'a str),
21    /// IPv4 address
22    Ipv4(Ipv4Addr),
23    /// IPv6 address, written in brackets
24    Ipv6(Ipv6Addr),
25}
26
27impl<'a> Host<'a> {
28    /// Strictly validates and classifies a host.
29    pub fn parse(src: &'a str) -> Result<Host<'a>, InvalidUrl> {
30        validate_host(src)?;
31        Ok(Host::classify(src))
32    }
33
34    pub(crate) fn classify(src: &'a str) -> Host<'a> {
35        if let Some(inner) = src.strip_prefix('[')
36            && let Ok(addr) = ipv6(inner)
37        {
38            Host::Ipv6(addr)
39        } else if looks_like_ipv4(src)
40            && let Ok(addr) = src.parse()
41        {
42            Host::Ipv4(addr)
43        } else {
44            Host::Domain(src)
45        }
46    }
47
48    /// Returns the host with punycode-encoded labels decoded.
49    ///
50    /// ```
51    /// use urly::Host;
52    ///
53    /// let host = Host::parse("xn--mnchen-3ya.de").unwrap();
54    /// assert_eq!(host.to_unicode(), "münchen.de");
55    /// ```
56    pub fn to_unicode(&self) -> Cow<'a, str> {
57        match self {
58            Host::Domain(s) => idna::domain_to_unicode(s),
59            host => Cow::Owned(host.to_string()),
60        }
61    }
62}
63
64impl fmt::Display for Host<'_> {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        match self {
67            Host::Domain(s) => f.write_str(s),
68            Host::Ipv4(addr) => fmt::Display::fmt(addr, f),
69            Host::Ipv6(addr) => write!(f, "[{addr}]"),
70        }
71    }
72}
73
74fn looks_like_ipv4(s: &str) -> bool {
75    !s.is_empty()
76        && s.bytes().all(|b| b.is_ascii_digit() || b == b'.')
77        && s.bytes().filter(|b| *b == b'.').count() == 3
78}
79
80/// Parses the part of an IP literal after `[`.
81fn ipv6(inner: &str) -> Result<Ipv6Addr, InvalidUrl> {
82    inner
83        .strip_suffix(']')
84        .and_then(|inner| inner.parse().ok())
85        .ok_or(InvalidUrl::at(ErrorKind::InvalidIpv6, 0))
86}
87
88/// Rejects dotted-decimal hosts that are not valid IPv4 addresses.
89fn check_ipv4(s: &str) -> Result<(), InvalidUrl> {
90    if looks_like_ipv4(s) && s.parse::<Ipv4Addr>().is_err() {
91        Err(InvalidUrl::at(ErrorKind::InvalidIpv4, 0))
92    } else {
93        Ok(())
94    }
95}
96
97pub(crate) fn validate_host(s: &str) -> Result<(), InvalidUrl> {
98    if let Some(inner) = s.strip_prefix('[') {
99        return ipv6(inner).map(|_| ());
100    }
101    chars::check(s, &REG_NAME)?;
102    check_ipv4(s)
103}
104
105/// Leniently normalizes a host: decodes escapes, lowercases, punycode-encodes
106/// non-ASCII labels, canonicalizes IPv6.
107pub(crate) fn normalize_host(s: &str) -> Result<Cow<'_, str>, InvalidUrl> {
108    if let Some(inner) = s.strip_prefix('[') {
109        let canonical = format!("[{}]", ipv6(inner)?);
110        return Ok(if canonical == s {
111            Cow::Borrowed(s)
112        } else {
113            Cow::Owned(canonical)
114        });
115    }
116
117    let decoded = unquote_with(s, false, &NONE);
118    let host = if !decoded.is_ascii() {
119        Cow::Owned(
120            idna::domain_to_ascii(&decoded).ok_or(InvalidUrl::at(ErrorKind::InvalidHost, 0))?,
121        )
122    } else {
123        lowercase(decoded)
124    };
125    if let Some(i) = host.bytes().position(|b| !REG_NAME.contains(b)) {
126        // positions are only meaningful if nothing was decoded
127        return Err(if s.len() == host.len() && s.is_ascii() {
128            InvalidUrl::at(ErrorKind::InvalidChar(char_at(&host, i)), i)
129        } else {
130            InvalidUrl::at(ErrorKind::InvalidHost, 0)
131        });
132    }
133    check_ipv4(&host)?;
134    Ok(host)
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn normalize() {
143        assert!(matches!(
144            normalize_host("a.com"),
145            Ok(Cow::Borrowed("a.com"))
146        ));
147        assert_eq!(normalize_host("A.Com").unwrap(), "a.com");
148        assert_eq!(normalize_host("%41.com").unwrap(), "a.com");
149        assert_eq!(normalize_host("[0:0::1]").unwrap(), "[::1]");
150        assert_eq!(
151            normalize_host("a b").unwrap_err().kind(),
152            ErrorKind::InvalidChar(' ')
153        );
154        assert_eq!(
155            normalize_host("a%20b").unwrap_err().kind(),
156            ErrorKind::InvalidHost
157        );
158        assert_eq!(normalize_host("München.de").unwrap(), "xn--mnchen-3ya.de");
159        assert_eq!(
160            normalize_host("m%C3%BCnchen.de").unwrap(),
161            "xn--mnchen-3ya.de"
162        );
163        assert_eq!(
164            normalize_host("mü nchen.de").unwrap_err().kind(),
165            ErrorKind::InvalidHost
166        );
167        assert_eq!(
168            normalize_host("1.2.3.999").unwrap_err().kind(),
169            ErrorKind::InvalidIpv4
170        );
171        assert_eq!(
172            normalize_host("[::g]").unwrap_err().kind(),
173            ErrorKind::InvalidIpv6
174        );
175    }
176
177    #[test]
178    fn classify() {
179        assert_eq!(Host::classify("127.0.0.1"), Host::Ipv4(Ipv4Addr::LOCALHOST));
180        assert_eq!(Host::classify("1.2.3"), Host::Domain("1.2.3"));
181        assert_eq!(Host::Ipv6(Ipv6Addr::LOCALHOST).to_string(), "[::1]");
182    }
183}