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