monovm-whois-rust 1.0.0

Domain WHOIS and RDAP lookups with availability detection, structured record parsing, referral chasing, caching and rate limiting.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
//! The [`DomainName`] value object.

use std::fmt;
use std::str::FromStr;

use crate::domain::idn;
use crate::domain::Tld;
use crate::error::{DomainError, Error, Result};

/// A validated, normalised domain name.
///
/// Constructing one is the only way into the rest of the crate, which means no
/// downstream code has to re-check whether it is holding something queryable.
/// The constructor accepts what people actually paste — a URL, mixed case, a
/// trailing root dot, credentials, a port — and reduces it to the host:
///
/// ```
/// use monovm_whois::DomainName;
///
/// let name = DomainName::parse("  HTTPS://user@WWW.Example.COM:8443/path?q=1  ").unwrap();
/// assert_eq!(name.as_ascii(), "www.example.com");
/// ```
///
/// Both the punycode and the Unicode form are kept, since registries are keyed
/// by the first and humans read the second:
///
/// ```
/// use monovm_whois::DomainName;
///
/// let name = DomainName::parse("münchen.de").unwrap();
/// assert_eq!(name.as_ascii(), "xn--mnchen-3ya.de");
/// assert_eq!(name.as_unicode(), "münchen.de");
/// assert!(name.is_idn());
/// ```
///
/// Names with no dot, IP addresses and illegal labels are rejected up front:
///
/// ```
/// use monovm_whois::DomainName;
///
/// assert!(DomainName::parse("localhost").is_err());
/// assert!(DomainName::parse("192.0.2.1").is_err());
/// assert!(DomainName::parse("-bad.com").is_err());
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct DomainName {
    ascii: String,
    unicode: String,
}

/// One way of splitting a name into a second-level label and a registry suffix.
///
/// Produced by [`DomainName::suffix_candidates`], which yields the longest
/// suffix first so that a registry lookup naturally prefers `co.uk` over `uk`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SuffixSplit<'a> {
    /// The label immediately to the left of the suffix, e.g. `example`.
    pub sld: &'a str,
    /// The candidate suffix in punycode form, without a leading dot.
    pub suffix: &'a str,
    /// Labels to the left of `sld`, e.g. `["www"]` for `www.example.co.uk`.
    ///
    /// Present so a caller can tell a subdomain query from a bare registrable
    /// name; the registrable name itself is `sld` + `.` + `suffix`.
    pub extra_labels: usize,
}

impl DomainName {
    /// Normalise and validate caller input.
    ///
    /// # Errors
    ///
    /// Returns [`Error::InvalidDomain`] when nothing queryable is left: an empty
    /// string, a single label with no suffix, an IP literal, a name over 253
    /// octets, or a label DNS would not accept.
    pub fn parse(input: &str) -> Result<Self> {
        let host = strip_to_host(input);

        let reject = |reason: DomainError| Error::InvalidDomain {
            input: input.to_string(),
            reason,
        };

        if host.is_empty() {
            return Err(reject(DomainError::Empty));
        }
        if idn::is_ip_literal(host) {
            return Err(reject(DomainError::IpAddress));
        }

        let (ascii, unicode) = idn::both_forms(host).map_err(reject)?;

        if ascii.len() > idn::MAX_NAME_LEN {
            return Err(reject(DomainError::TooLong));
        }
        if !ascii.contains('.') {
            return Err(reject(DomainError::NoTld));
        }
        for label in ascii.split('.') {
            idn::validate_label(label).map_err(reject)?;
        }

        Ok(DomainName { ascii, unicode })
    }

    /// The punycode form: what goes on the wire and into a URL.
    pub fn as_ascii(&self) -> &str {
        &self.ascii
    }

    /// The Unicode form: what to show a human.
    pub fn as_unicode(&self) -> &str {
        &self.unicode
    }

    /// Whether the name is internationalised, i.e. its two forms differ.
    pub fn is_idn(&self) -> bool {
        self.ascii != self.unicode
    }

    /// The labels of the punycode form, left to right.
    pub fn labels(&self) -> impl Iterator<Item = &str> + '_ {
        self.ascii.split('.')
    }

    /// How many labels the name has.
    pub fn label_count(&self) -> usize {
        self.ascii.split('.').count()
    }

    /// Every way this name could be split into a second-level label and a
    /// registry suffix, longest suffix first.
    ///
    /// Which split is correct depends on data, not on the name: `co.uk` is a
    /// registry suffix while `co.com` is an ordinary registration. So this
    /// enumerates the candidates and lets a
    /// [`RegistryProvider`](crate::registry::RegistryProvider) pick.
    ///
    /// ```
    /// use monovm_whois::DomainName;
    ///
    /// let name = DomainName::parse("www.example.co.uk").unwrap();
    /// let suffixes: Vec<_> = name.suffix_candidates().map(|s| s.suffix).collect();
    /// assert_eq!(suffixes, ["example.co.uk", "co.uk", "uk"]);
    /// ```
    pub fn suffix_candidates(&self) -> impl Iterator<Item = SuffixSplit<'_>> + '_ {
        // Every label paired with where it starts in `ascii`, so a suffix is one
        // slice rather than a rejoin.
        let mut labels: Vec<(usize, &str)> = Vec::with_capacity(4);
        let mut offset = 0;
        for label in self.ascii.split('.') {
            labels.push((offset, label));
            offset += label.len() + 1; // + the separating dot
        }

        // Start at 1: the leftmost label can never be the whole suffix, because
        // then there would be no name to the left of it.
        (1..labels.len()).map(move |index| SuffixSplit {
            sld: labels[index - 1].1,
            suffix: &self.ascii[labels[index].0..],
            extra_labels: index - 1,
        })
    }

    /// The registrable name for a given suffix: `example.co.uk` for
    /// `www.example.co.uk` under `co.uk`.
    ///
    /// Returns `None` when the name does not end in that suffix, or ends in it
    /// with nothing to the left.
    pub fn registrable_under(&self, tld: &Tld) -> Option<DomainName> {
        let split = self
            .suffix_candidates()
            .find(|split| split.suffix == tld.ascii())?;

        if split.extra_labels == 0 {
            return Some(self.clone());
        }

        let ascii = format!("{}.{}", split.sld, split.suffix);
        let unicode = idn::to_unicode_lossy(&ascii);
        Some(DomainName { ascii, unicode })
    }

    /// Drop the leftmost label: `example.com` for `www.example.com`.
    ///
    /// Returns `None` when only one label would remain, since that is no longer
    /// a queryable name.
    pub fn parent(&self) -> Option<DomainName> {
        let (_, rest) = self.ascii.split_once('.')?;
        if !rest.contains('.') {
            return None;
        }
        Some(DomainName {
            unicode: idn::to_unicode_lossy(rest),
            ascii: rest.to_string(),
        })
    }
}

/// Reduce arbitrary caller input to a bare host.
///
/// Deliberately hand-rolled rather than delegating to a URL parser: the input is
/// usually *not* a URL, and a parser strict enough to be correct for URLs
/// rejects the bare names that make up most of the traffic here.
fn strip_to_host(input: &str) -> &str {
    let mut text = input.trim();

    if let Some((_, rest)) = text.split_once("://") {
        text = rest;
    }
    // Credentials, if any, are everything up to the last `@`.
    if let Some((_, rest)) = text.rsplit_once('@') {
        text = rest;
    }
    for separator in ['/', '?', '#'] {
        if let Some((head, _)) = text.split_once(separator) {
            text = head;
        }
    }
    text = strip_port(text);

    // A fully qualified name may carry the root dot; leading dots are junk.
    text.trim().trim_matches('.')
}

/// Drop a trailing `:port`, and only that.
///
/// Stripping at the first colon unconditionally would turn `::1` into an empty string
/// and have it reported as empty input rather than as an IP address. A port is a
/// trailing run of digits after the *last* colon, with no other colon before it — so
/// an IPv6 literal survives intact and the caller can reject it for what it is.
fn strip_port(text: &str) -> &str {
    // A bracketed IPv6 literal keeps its brackets so the caller recognises it.
    if text.starts_with('[') {
        return text;
    }

    match text.rsplit_once(':') {
        Some((head, port))
            if !head.is_empty()
                && !head.contains(':')
                && !port.is_empty()
                && port.bytes().all(|byte| byte.is_ascii_digit()) =>
        {
            head
        }
        _ => text,
    }
}

impl fmt::Display for DomainName {
    /// Writes the Unicode form, which is what a human expects to read.
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.unicode)
    }
}

impl AsRef<str> for DomainName {
    fn as_ref(&self) -> &str {
        &self.ascii
    }
}

impl FromStr for DomainName {
    type Err = Error;

    fn from_str(s: &str) -> Result<Self> {
        DomainName::parse(s)
    }
}

impl TryFrom<&str> for DomainName {
    type Error = Error;

    fn try_from(value: &str) -> Result<Self> {
        DomainName::parse(value)
    }
}

impl TryFrom<String> for DomainName {
    type Error = Error;

    fn try_from(value: String) -> Result<Self> {
        DomainName::parse(&value)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn strips_url_furniture() {
        let name = DomainName::parse("  HTTPS://user:pw@WWW.Example.COM:8443/a/b?c=1#d  ").unwrap();
        assert_eq!(name.as_ascii(), "www.example.com");
    }

    #[test]
    fn strips_root_dot() {
        assert_eq!(
            DomainName::parse("example.com.").unwrap().as_ascii(),
            "example.com"
        );
    }

    #[test]
    fn keeps_both_forms() {
        let name = DomainName::parse("MÜNCHEN.de").unwrap();
        assert_eq!(name.as_ascii(), "xn--mnchen-3ya.de");
        assert_eq!(name.as_unicode(), "münchen.de");
        assert!(name.is_idn());
        assert_eq!(name.to_string(), "münchen.de");
    }

    #[test]
    fn punycode_input_is_accepted_too() {
        let from_ace = DomainName::parse("xn--mnchen-3ya.de").unwrap();
        let from_unicode = DomainName::parse("münchen.de").unwrap();
        assert_eq!(from_ace, from_unicode);
    }

    #[test]
    fn rejects_unqueryable_input() {
        use DomainError::*;
        let reason = |input: &str| match DomainName::parse(input) {
            Err(Error::InvalidDomain { reason, .. }) => reason,
            other => panic!("expected InvalidDomain for {input:?}, got {other:?}"),
        };

        assert_eq!(reason(""), Empty);
        assert_eq!(reason("   "), Empty);
        assert_eq!(reason("."), Empty);
        assert_eq!(reason("https://"), Empty);
        assert_eq!(reason("localhost"), NoTld);
        assert_eq!(reason("192.0.2.1"), IpAddress);
        assert_eq!(reason("::1"), IpAddress);
        assert_eq!(reason(&format!("{}.com", "a".repeat(250))), TooLong);
        assert!(matches!(reason("-bad.com"), InvalidLabel { .. }));
        assert!(matches!(reason("a..b.com"), InvalidLabel { .. }));
    }

    #[test]
    fn suffix_candidates_are_longest_first() {
        let name = DomainName::parse("www.example.co.uk").unwrap();
        let splits: Vec<_> = name.suffix_candidates().collect();

        assert_eq!(splits.len(), 3);
        assert_eq!(
            splits[0],
            SuffixSplit {
                sld: "www",
                suffix: "example.co.uk",
                extra_labels: 0
            }
        );
        assert_eq!(
            splits[1],
            SuffixSplit {
                sld: "example",
                suffix: "co.uk",
                extra_labels: 1
            }
        );
        assert_eq!(
            splits[2],
            SuffixSplit {
                sld: "co",
                suffix: "uk",
                extra_labels: 2
            }
        );
    }

    #[test]
    fn suffix_candidates_of_a_two_label_name() {
        let name = DomainName::parse("example.com").unwrap();
        let splits: Vec<_> = name.suffix_candidates().collect();
        assert_eq!(
            splits,
            [SuffixSplit {
                sld: "example",
                suffix: "com",
                extra_labels: 0
            }]
        );
    }

    #[test]
    fn registrable_under_trims_subdomains() {
        let name = DomainName::parse("a.b.example.co.uk").unwrap();
        let tld = Tld::parse("co.uk").unwrap();
        assert_eq!(
            name.registrable_under(&tld).unwrap().as_ascii(),
            "example.co.uk"
        );

        let bare = DomainName::parse("example.co.uk").unwrap();
        assert_eq!(
            bare.registrable_under(&tld).unwrap().as_ascii(),
            "example.co.uk"
        );

        assert!(name.registrable_under(&Tld::parse("de").unwrap()).is_none());
    }

    #[test]
    fn parent_stops_at_two_labels() {
        let name = DomainName::parse("a.b.example.com").unwrap();
        assert_eq!(name.parent().unwrap().as_ascii(), "b.example.com");
        assert_eq!(
            name.parent().unwrap().parent().unwrap().as_ascii(),
            "example.com"
        );
        assert!(DomainName::parse("example.com").unwrap().parent().is_none());
    }
}