Skip to main content

reserve_core/lookup/
registry.rs

1//! Where to ask about an extension, and how to read the answer.
2
3use std::collections::HashMap;
4use std::path::Path;
5use std::time::{Duration, SystemTime};
6
7use serde::Deserialize;
8
9use crate::error::{Error, Result};
10
11const CACHE_MAX_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60);
12
13const BOOTSTRAP_URL: &str = "https://data.iana.org/rdap/dns.json";
14
15/// @docgen Absence from the published list does not mean absence of a service; every extension here runs one.
16const UNLISTED_SERVICES: &[(&str, &str)] = &[
17    ("de", "https://rdap.denic.de/"),
18    ("io", "https://rdap.identitydigital.services/rdap/"),
19    ("us", "https://rdap.nic.us/"),
20    ("co", "https://rdap.nic.co/"),
21    ("me", "https://rdap.identitydigital.services/rdap/"),
22    ("sh", "https://rdap.identitydigital.services/rdap/"),
23    ("tv", "https://tld-rdap.verisign.com/tv/v1/"),
24    ("cc", "https://tld-rdap.verisign.com/cc/v1/"),
25];
26
27#[derive(Debug, Deserialize)]
28struct BootstrapFile {
29    /// @docgen IANA's own shape: `[[["com","net"], ["https://rdap.verisign.com/com/v1/"]], ...]`.
30    services: Vec<Vec<Vec<String>>>,
31}
32
33#[derive(Debug, Default, Clone)]
34pub struct ServiceMap {
35    by_suffix: HashMap<String, Vec<String>>,
36}
37
38impl ServiceMap {
39    /// @docgen A failed download falls back to the cached copy however stale, because a week-old list beats no list.
40    pub async fn load(
41        client: &reqwest::Client,
42        cache: &Path,
43        refresh: bool,
44    ) -> Result<(Self, Freshness)> {
45        let cache_is_fresh = !refresh
46            && cache
47                .metadata()
48                .and_then(|meta| meta.modified())
49                .is_ok_and(|at| {
50                    SystemTime::now()
51                        .duration_since(at)
52                        .is_ok_and(|age| age < CACHE_MAX_AGE)
53                });
54
55        if cache_is_fresh
56            && let Ok(text) = tokio::fs::read_to_string(cache).await
57            && let Ok(directory) = Self::parse(&text)
58        {
59            return Ok((directory, Freshness::Cached));
60        }
61
62        match Self::download(client).await {
63            Ok(text) => {
64                let directory = Self::parse(&text)?;
65                if let Some(parent) = cache.parent() {
66                    let _ = tokio::fs::create_dir_all(parent).await;
67                }
68                let _ = tokio::fs::write(cache, &text).await;
69                Ok((directory, Freshness::Fresh))
70            }
71            Err(error) => {
72                if let Ok(text) = tokio::fs::read_to_string(cache).await
73                    && let Ok(directory) = Self::parse(&text)
74                {
75                    return Ok((directory, Freshness::Stale));
76                }
77                Err(error)
78            }
79        }
80    }
81
82    async fn download(client: &reqwest::Client) -> Result<String> {
83        let response = client
84            .get(BOOTSTRAP_URL)
85            .send()
86            .await
87            .map_err(|source| Error::BootstrapUnavailable {
88                source: Box::new(source),
89            })?
90            .error_for_status()
91            .map_err(|source| Error::BootstrapUnavailable {
92                source: Box::new(source),
93            })?;
94        response
95            .text()
96            .await
97            .map_err(|source| Error::BootstrapUnavailable {
98                source: Box::new(source),
99            })
100    }
101
102    pub fn parse(text: &str) -> Result<Self> {
103        let file: BootstrapFile =
104            serde_json::from_str(text).map_err(|source| Error::CatalogMalformed {
105                source: Box::new(source),
106            })?;
107
108        let mut by_suffix: HashMap<String, Vec<String>> = HashMap::new();
109        for service in file.services {
110            let (suffixes, urls) = match (service.first(), service.get(1)) {
111                (Some(s), Some(u)) if !s.is_empty() && !u.is_empty() => (s, u),
112                _ => continue,
113            };
114            let urls: Vec<String> = urls
115                .iter()
116                .filter(|url| is_usable_service(url))
117                .cloned()
118                .map(with_trailing_slash)
119                .collect();
120            if urls.is_empty() {
121                continue;
122            }
123            for suffix in suffixes {
124                by_suffix.insert(suffix.to_lowercase(), urls.clone());
125            }
126        }
127
128        for (suffix, url) in UNLISTED_SERVICES {
129            by_suffix
130                .entry((*suffix).to_owned())
131                .or_insert_with(|| vec![(*url).to_owned()]);
132        }
133
134        Ok(Self { by_suffix })
135    }
136
137    pub fn from_file(path: &Path) -> Result<Self> {
138        let text = std::fs::read_to_string(path).map_err(|source| Error::FileUnreadable {
139            path: path.to_path_buf(),
140            source,
141        })?;
142        let parsed = Self::parse(&text)?;
143        if parsed.by_suffix.is_empty() {
144            return Err(Error::CatalogEmptySelection);
145        }
146        Ok(parsed)
147    }
148
149    pub fn merge(&mut self, other: Self) {
150        self.by_suffix.extend(other.by_suffix);
151    }
152
153    /// @docgen Matching longest first lets a multi-label suffix resolve through its parent when it has no service of its own.
154    #[must_use]
155    pub fn for_suffix(&self, suffix: &str) -> Option<&[String]> {
156        let suffix = suffix.trim_matches('.').to_lowercase();
157        let mut rest = suffix.as_str();
158        loop {
159            if let Some(urls) = self.by_suffix.get(rest) {
160                return Some(urls);
161            }
162            match rest.split_once('.') {
163                Some((_, tail)) if !tail.is_empty() => rest = tail,
164                _ => return None,
165            }
166        }
167    }
168
169    #[must_use]
170    pub fn len(&self) -> usize {
171        self.by_suffix.len()
172    }
173
174    #[must_use]
175    pub fn is_empty(&self) -> bool {
176        self.by_suffix.is_empty()
177    }
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub enum Freshness {
182    Fresh,
183    Cached,
184    Stale,
185}
186
187impl Freshness {
188    #[must_use]
189    pub const fn label(self) -> &'static str {
190        match self {
191            Self::Fresh => "downloaded",
192            Self::Cached => "cached",
193            Self::Stale => "cached, out of date",
194        }
195    }
196}
197
198/// @docgen Service base URLs are joined with `domain/<name>`, so they need the trailing slash.
199fn with_trailing_slash(url: String) -> String {
200    if url.ends_with('/') {
201        url
202    } else {
203        format!("{url}/")
204    }
205}
206
207#[must_use]
208/// @docgen Userinfo is dropped so a credentialed URL never becomes the pacing key or reaches the output.
209/// @docgen A cache or a supplied list decides what the tool fetches, so a plaintext or internal address is refused here.
210fn is_usable_service(url: &str) -> bool {
211    if !url.starts_with("https://") {
212        return false;
213    }
214    let host = host_of(url);
215    if host.is_empty() || url.contains('@') {
216        return false;
217    }
218    let lowered = host.to_lowercase();
219    if lowered == "localhost" || lowered.ends_with(".localhost") {
220        return false;
221    }
222    match lowered.parse::<std::net::IpAddr>() {
223        Ok(std::net::IpAddr::V4(ip)) => {
224            !(ip.is_loopback() || ip.is_private() || ip.is_link_local() || ip.is_unspecified())
225        }
226        Ok(std::net::IpAddr::V6(ip)) => {
227            // @docgen Rust still gates is_unique_local, so fc00::/7 and fe80::/10 are matched on their prefix here.
228            let first = ip.segments().first().copied().unwrap_or(0);
229            let unique_local = (first & 0xfe00) == 0xfc00;
230            let link_local = (first & 0xffc0) == 0xfe80;
231            !(ip.is_loopback() || ip.is_unspecified() || unique_local || link_local)
232        }
233        Err(_) => true,
234    }
235}
236
237pub(crate) fn host_of(url: &str) -> &str {
238    let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
239    let authority_end = rest.find('/').unwrap_or(rest.len());
240    let authority = rest.get(..authority_end).unwrap_or(rest);
241    let host = authority
242        .rsplit_once('@')
243        .map_or(authority, |(_, host)| host);
244    // @docgen An IPv6 host is bracketed and full of colons, so cutting at the first one leaves a stub that matches nothing.
245    if let Some(rest) = host.strip_prefix('[') {
246        return rest.split_once(']').map_or(host, |(inside, _)| inside);
247    }
248    let end = host.find([':', '?']).unwrap_or(host.len());
249    host.get(..end).unwrap_or(host)
250}
251
252#[cfg(test)]
253mod tests {
254    use std::sync::Arc;
255
256    use tempfile::tempdir;
257
258    use super::*;
259    use crate::error::ErrorId;
260
261    #[test]
262    fn a_bracketed_ipv6_host_is_read_whole_rather_than_cut_at_its_first_colon() {
263        assert_eq!(host_of("https://[::1]/rdap/"), "::1");
264        assert_eq!(host_of("https://[fd00::1]:8443/rdap/"), "fd00::1");
265        assert_eq!(host_of("https://rdap.example/x"), "rdap.example");
266        assert_eq!(host_of("https://user:pass@rdap.example/x"), "rdap.example");
267    }
268
269    #[test]
270    fn an_internal_service_address_is_refused_in_either_address_family() {
271        for bad in [
272            "https://[::1]/rdap/",
273            "https://[fd00::1]/rdap/",
274            "https://127.0.0.1/rdap/",
275            "https://10.0.0.5/rdap/",
276            "https://169.254.169.254/rdap/",
277            "https://localhost/rdap/",
278            "http://rdap.example/",
279            "https://user:key@rdap.example/",
280        ] {
281            assert!(!is_usable_service(bad), "{bad} must not be fetched");
282        }
283        assert!(is_usable_service("https://rdap.verisign.com/com/v1/"));
284    }
285
286    const SAMPLE: &str = r#"{"services":[
287        [["com","net"],["https://rdap.verisign.com/com/v1"]],
288        [["uk"],["https://rdap.nominet.uk/uk/"]]
289    ]}"#;
290
291    /// @docgen A resolver that answers nothing keeps the download path in the test offline and instant.
292    #[derive(Debug)]
293    struct NeverResolves;
294
295    impl reqwest::dns::Resolve for NeverResolves {
296        fn resolve(&self, _name: reqwest::dns::Name) -> reqwest::dns::Resolving {
297            Box::pin(async {
298                Err(Box::<dyn std::error::Error + Send + Sync>::from(
299                    "this test never leaves the machine",
300                ))
301            })
302        }
303    }
304
305    fn grounded_client() -> reqwest::Client {
306        reqwest::Client::builder()
307            .no_proxy()
308            .dns_resolver(Arc::new(NeverResolves))
309            .build()
310            .expect("a client that can reach nothing")
311    }
312
313    fn age_by_days(path: &Path, days: u64) {
314        let when = SystemTime::now()
315            .checked_sub(Duration::from_secs(days * 24 * 60 * 60))
316            .expect("a moment inside the epoch");
317        let file = std::fs::File::options()
318            .write(true)
319            .open(path)
320            .expect("the cache opens for writing");
321        file.set_times(std::fs::FileTimes::new().set_modified(when))
322            .expect("the cache takes a new modified time");
323    }
324
325    fn cache_holding(dir: &Path, text: &str) -> std::path::PathBuf {
326        let path = dir.join("servers.json");
327        std::fs::write(&path, text).expect("the cache is written");
328        path
329    }
330
331    #[tokio::test]
332    async fn a_cache_written_today_is_read_instead_of_downloaded() {
333        let dir = tempdir().expect("temp dir");
334        let cache = cache_holding(dir.path(), SAMPLE);
335
336        let (directory, freshness) = ServiceMap::load(&grounded_client(), &cache, false)
337            .await
338            .expect("a fresh cache needs no download");
339
340        assert_eq!(freshness, Freshness::Cached);
341        assert!(directory.for_suffix("com").is_some());
342    }
343
344    #[tokio::test]
345    async fn a_cache_older_than_a_week_is_still_used_when_the_download_fails() {
346        let dir = tempdir().expect("temp dir");
347        let cache = cache_holding(dir.path(), SAMPLE);
348        age_by_days(&cache, 8);
349
350        let (directory, freshness) = ServiceMap::load(&grounded_client(), &cache, false)
351            .await
352            .expect("a week-old list beats no list");
353
354        assert_eq!(freshness, Freshness::Stale);
355        assert!(directory.for_suffix("com").is_some());
356    }
357
358    #[tokio::test]
359    async fn asking_for_a_refresh_still_falls_back_to_the_cache_it_skipped() {
360        let dir = tempdir().expect("temp dir");
361        let cache = cache_holding(dir.path(), SAMPLE);
362
363        let (directory, freshness) = ServiceMap::load(&grounded_client(), &cache, true)
364            .await
365            .expect("a failed refresh falls back rather than failing");
366
367        assert_eq!(freshness, Freshness::Stale);
368        assert!(directory.for_suffix("com").is_some());
369    }
370
371    #[tokio::test]
372    async fn no_cache_and_no_download_is_an_error_rather_than_an_empty_list() {
373        let dir = tempdir().expect("temp dir");
374        let missing = dir.path().join("never-written").join("servers.json");
375
376        let error = ServiceMap::load(&grounded_client(), &missing, false)
377            .await
378            .expect_err("an empty service map would read every extension as unserved");
379
380        assert_eq!(error.id(), ErrorId::BootstrapUnavailable);
381    }
382
383    #[tokio::test]
384    async fn a_corrupt_cache_is_not_read_as_a_list_with_nothing_in_it() {
385        let dir = tempdir().expect("temp dir");
386        let cache = cache_holding(dir.path(), "half a file, no json");
387
388        let error = ServiceMap::load(&grounded_client(), &cache, false)
389            .await
390            .expect_err("a corrupt cache must not stand in for a real list");
391
392        assert_eq!(error.id(), ErrorId::BootstrapUnavailable);
393    }
394
395    #[tokio::test]
396    async fn a_failed_download_never_overwrites_the_cache_it_fell_back_to() {
397        let dir = tempdir().expect("temp dir");
398        let cache = cache_holding(dir.path(), SAMPLE);
399        age_by_days(&cache, 8);
400
401        let _ = ServiceMap::load(&grounded_client(), &cache, false).await;
402
403        assert_eq!(
404            std::fs::read_to_string(&cache).expect("the cache survives"),
405            SAMPLE
406        );
407    }
408
409    #[test]
410    fn a_base_url_always_ends_in_a_slash() {
411        let directory = ServiceMap::parse(SAMPLE).unwrap();
412        assert_eq!(
413            directory.for_suffix("com"),
414            Some(&["https://rdap.verisign.com/com/v1/".to_owned()][..])
415        );
416    }
417
418    #[test]
419    fn a_multi_label_suffix_resolves_through_its_parent() {
420        let directory = ServiceMap::parse(SAMPLE).unwrap();
421        assert!(directory.for_suffix("co.uk").is_some());
422        assert_eq!(directory.for_suffix("co.uk"), directory.for_suffix("uk"));
423    }
424
425    #[test]
426    fn an_extension_with_no_service_reports_none() {
427        let directory = ServiceMap::parse(SAMPLE).unwrap();
428        assert!(directory.for_suffix("bd").is_none());
429        assert!(directory.for_suffix("com.bd").is_none());
430    }
431
432    #[test]
433    fn services_missing_from_the_published_list_are_still_reachable() {
434        // @docgen The sample carries none of these, yet all of them answer, so the fallback must fill them in.
435        let directory = ServiceMap::parse(SAMPLE).unwrap();
436        for suffix in ["de", "io", "us"] {
437            assert!(
438                directory.for_suffix(suffix).is_some(),
439                ".{suffix} has a working service and must not read as having none"
440            );
441        }
442    }
443
444    #[test]
445    fn a_published_entry_wins_over_the_unlisted_fallback() {
446        let text = r#"{"services":[[["io"],["https://published.example/"]]]}"#;
447        let directory = ServiceMap::parse(text).unwrap();
448        assert_eq!(
449            directory.for_suffix("io"),
450            Some(&["https://published.example/".to_owned()][..])
451        );
452    }
453
454    #[test]
455    fn a_custom_list_overlays_the_published_one() {
456        let mut directory = ServiceMap::parse(SAMPLE).unwrap();
457        let custom = ServiceMap::parse(r#"{"services":[[["com"],["https://mine/"]]]}"#).unwrap();
458        directory.merge(custom);
459        assert_eq!(
460            directory.for_suffix("com"),
461            Some(&["https://mine/".to_owned()][..])
462        );
463        assert!(directory.for_suffix("uk").is_some());
464    }
465
466    #[test]
467    fn rubbish_is_refused() {
468        assert!(ServiceMap::parse("not json").is_err());
469        assert!(ServiceMap::parse(r#"{"services":"nope"}"#).is_err());
470    }
471
472    #[test]
473    fn an_empty_service_entry_is_skipped_rather_than_stored() {
474        let directory = ServiceMap::parse(r#"{"services":[[[],[]],[["com"],[]]]}"#).unwrap();
475        assert!(directory.for_suffix("com").is_none());
476    }
477
478    #[test]
479    fn hosts_come_out_of_urls() {
480        assert_eq!(
481            host_of("https://rdap.verisign.com/com/v1/"),
482            "rdap.verisign.com"
483        );
484        assert_eq!(host_of("http://a.b.c:8080/x"), "a.b.c");
485        assert_eq!(host_of("whois.nic.io"), "whois.nic.io");
486    }
487
488    #[test]
489    fn origins_describe_themselves() {
490        assert_eq!(Freshness::Fresh.label(), "downloaded");
491        assert!(Freshness::Stale.label().contains("out of date"));
492    }
493}