1use std::collections::HashMap;
4use std::path::Path;
5use std::time::{Duration, SystemTime};
6
7use serde::Deserialize;
8
9use crate::error::{Error, Result};
10
11const MAX_BOOTSTRAP_BYTES: usize = 8 * 1024 * 1024;
13
14const CACHE_MAX_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60);
15
16pub const BOOTSTRAP_URL: &str = "https://data.iana.org/rdap/dns.json";
18
19const 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 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
51async 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
72async 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(|name| name.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 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(services) = Self::parse(&text)
116 {
117 return Ok((services, Freshness::Cached));
118 }
119
120 match Self::download(client).await {
121 Ok(text) => {
122 let services = Self::parse(&text)?;
123 if let Err(error) = store_cache(cache, &text).await {
124 tracing::warn!(path = %cache.display(), %error, "the registry list could not be cached");
126 }
127 Ok((services, Freshness::Fresh))
128 }
129 Err(error) => {
130 if let Ok(text) = read_cache(cache).await
131 && let Ok(services) = Self::parse(&text)
132 {
133 return Ok((services, 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 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 #[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
278fn with_trailing_slash(url: String) -> String {
280 if url.ends_with('/') {
281 url
282 } else {
283 format!("{url}/")
284 }
285}
286
287#[must_use]
288fn 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
301pub(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(ip) => is_public_ip(ip),
312 Err(_) => true,
313 }
314}
315
316pub(crate) fn is_public_ip(ip: std::net::IpAddr) -> bool {
318 match ip {
319 std::net::IpAddr::V4(ip) => is_public_v4(ip),
320 std::net::IpAddr::V6(ip) => {
321 if ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() {
322 return false;
323 }
324 if let Some(written_as_v6) = ip.to_ipv4() {
326 return is_public_v4(written_as_v6);
327 }
328 let first = ip.segments().first().copied().unwrap_or(0);
330 let unique_local = (first & 0xfe00) == 0xfc00;
331 let link_local = (first & 0xffc0) == 0xfe80;
332 !(unique_local || link_local)
333 }
334 }
335}
336
337fn is_public_v4(ip: std::net::Ipv4Addr) -> bool {
339 let [first, second, ..] = ip.octets();
340 let carrier_grade_nat = first == 100 && (64..=127).contains(&second);
341 !(ip.is_loopback()
342 || ip.is_private()
343 || ip.is_link_local()
344 || ip.is_unspecified()
345 || ip.is_multicast()
346 || ip.is_broadcast()
347 || carrier_grade_nat)
348}
349
350pub(crate) fn host_of(url: &str) -> &str {
351 let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
352 let authority_end = rest.find('/').unwrap_or(rest.len());
353 let authority = rest.get(..authority_end).unwrap_or(rest);
354 let host = authority
355 .rsplit_once('@')
356 .map_or(authority, |(_, host)| host);
357 if let Some(rest) = host.strip_prefix('[') {
359 return rest.split_once(']').map_or(host, |(inside, _)| inside);
360 }
361 let end = host.find([':', '?']).unwrap_or(host.len());
362 host.get(..end).unwrap_or(host)
363}
364
365#[cfg(test)]
366mod tests {
367
368 #[test]
369 fn a_referral_host_naming_an_internal_address_is_refused() {
370 for host in [
371 "127.0.0.1",
372 "169.254.169.254",
373 "10.0.0.1",
374 "192.168.1.1",
375 "localhost",
376 "whois.internal.localhost",
377 "::1",
378 "fe80::1",
379 "fd00::1",
380 "::ffff:127.0.0.1",
381 "::ffff:169.254.169.254",
382 "::ffff:10.0.0.1",
383 "::ffff:192.168.1.1",
384 "ff02::1",
385 "100.64.0.1",
386 "100.127.255.254",
387 "224.0.0.1",
388 "255.255.255.255",
389 "",
390 "whois example com",
391 ] {
392 assert!(
393 !is_public_host(host),
394 "{host} must never be dialled from a cleartext referral"
395 );
396 }
397 }
398
399 #[test]
400 fn a_real_registry_host_still_passes() {
401 for host in ["whois.btcl.net.bd", "whois.nic.example", "203.0.113.10"] {
402 assert!(is_public_host(host), "{host} is a normal public host");
403 }
404 }
405 use std::sync::Arc;
406
407 use tempfile::tempdir;
408
409 use super::*;
410 use crate::error::ErrorId;
411
412 #[test]
413 fn a_bracketed_ipv6_host_is_read_whole_rather_than_cut_at_its_first_colon() {
414 assert_eq!(host_of("https://[::1]/rdap/"), "::1");
415 assert_eq!(host_of("https://[fd00::1]:8443/rdap/"), "fd00::1");
416 assert_eq!(host_of("https://rdap.example/x"), "rdap.example");
417 assert_eq!(host_of("https://user:pass@rdap.example/x"), "rdap.example");
418 }
419
420 #[test]
421 fn an_internal_service_address_is_refused_in_either_address_family() {
422 for bad in [
423 "https://[::1]/rdap/",
424 "https://[fd00::1]/rdap/",
425 "https://127.0.0.1/rdap/",
426 "https://10.0.0.5/rdap/",
427 "https://169.254.169.254/rdap/",
428 "https://localhost/rdap/",
429 "http://rdap.example/",
430 "https://user:key@rdap.example/",
431 ] {
432 assert!(!is_usable_service(bad), "{bad} must not be fetched");
433 }
434 assert!(is_usable_service("https://rdap.verisign.com/com/v1/"));
435 }
436
437 const SAMPLE: &str = r#"{"services":[
438 [["com","net"],["https://rdap.verisign.com/com/v1"]],
439 [["uk"],["https://rdap.nominet.uk/uk/"]]
440 ]}"#;
441
442 #[derive(Debug)]
444 struct NeverResolves;
445
446 impl reqwest::dns::Resolve for NeverResolves {
447 fn resolve(&self, _name: reqwest::dns::Name) -> reqwest::dns::Resolving {
448 Box::pin(async {
449 Err(Box::<dyn std::error::Error + Send + Sync>::from(
450 "this test never leaves the machine",
451 ))
452 })
453 }
454 }
455
456 fn grounded_client() -> reqwest::Client {
457 reqwest::Client::builder()
458 .no_proxy()
459 .dns_resolver(Arc::new(NeverResolves))
460 .build()
461 .expect("a client that can reach nothing")
462 }
463
464 fn age_by_days(path: &Path, days: u64) {
465 let when = SystemTime::now()
466 .checked_sub(Duration::from_secs(days * 24 * 60 * 60))
467 .expect("a moment inside the epoch");
468 let file = std::fs::File::options()
469 .write(true)
470 .open(path)
471 .expect("the cache opens for writing");
472 file.set_times(std::fs::FileTimes::new().set_modified(when))
473 .expect("the cache takes a new modified time");
474 }
475
476 fn cache_holding(dir: &Path, text: &str) -> std::path::PathBuf {
477 let path = dir.join("servers.json");
478 std::fs::write(&path, text).expect("the cache is written");
479 path
480 }
481
482 #[tokio::test]
483 async fn a_cache_written_today_is_read_instead_of_downloaded() {
484 let dir = tempdir().expect("temp dir");
485 let cache = cache_holding(dir.path(), SAMPLE);
486
487 let (services, freshness) = ServiceMap::load(&grounded_client(), &cache, false)
488 .await
489 .expect("a fresh cache needs no download");
490
491 assert_eq!(freshness, Freshness::Cached);
492 assert!(services.for_suffix("com").is_some());
493 }
494
495 #[tokio::test]
496 async fn a_cache_older_than_a_week_is_still_used_when_the_download_fails() {
497 let dir = tempdir().expect("temp dir");
498 let cache = cache_holding(dir.path(), SAMPLE);
499 age_by_days(&cache, 8);
500
501 let (services, freshness) = ServiceMap::load(&grounded_client(), &cache, false)
502 .await
503 .expect("a week-old list beats no list");
504
505 assert_eq!(freshness, Freshness::Stale);
506 assert!(services.for_suffix("com").is_some());
507 }
508
509 #[tokio::test]
510 async fn asking_for_a_refresh_still_falls_back_to_the_cache_it_skipped() {
511 let dir = tempdir().expect("temp dir");
512 let cache = cache_holding(dir.path(), SAMPLE);
513
514 let (services, freshness) = ServiceMap::load(&grounded_client(), &cache, true)
515 .await
516 .expect("a failed refresh falls back rather than failing");
517
518 assert_eq!(freshness, Freshness::Stale);
519 assert!(services.for_suffix("com").is_some());
520 }
521
522 #[tokio::test]
523 async fn no_cache_and_no_download_is_an_error_rather_than_an_empty_list() {
524 let dir = tempdir().expect("temp dir");
525 let missing = dir.path().join("never-written").join("servers.json");
526
527 let error = ServiceMap::load(&grounded_client(), &missing, false)
528 .await
529 .expect_err("an empty service map would read every extension as unserved");
530
531 assert_eq!(error.id(), ErrorId::BootstrapUnavailable);
532 }
533
534 #[tokio::test]
535 async fn a_corrupt_cache_is_not_read_as_a_list_with_nothing_in_it() {
536 let dir = tempdir().expect("temp dir");
537 let cache = cache_holding(dir.path(), "half a file, no json");
538
539 let error = ServiceMap::load(&grounded_client(), &cache, false)
540 .await
541 .expect_err("a corrupt cache must not stand in for a real list");
542
543 assert_eq!(error.id(), ErrorId::BootstrapUnavailable);
544 }
545
546 #[tokio::test]
547 async fn a_failed_download_never_overwrites_the_cache_it_fell_back_to() {
548 let dir = tempdir().expect("temp dir");
549 let cache = cache_holding(dir.path(), SAMPLE);
550 age_by_days(&cache, 8);
551
552 let _ = ServiceMap::load(&grounded_client(), &cache, false).await;
553
554 assert_eq!(
555 std::fs::read_to_string(&cache).expect("the cache survives"),
556 SAMPLE
557 );
558 }
559
560 #[test]
561 fn a_base_url_always_ends_in_a_slash() {
562 let services = ServiceMap::parse(SAMPLE).unwrap();
563 assert_eq!(
564 services.for_suffix("com"),
565 Some(&["https://rdap.verisign.com/com/v1/".to_owned()][..])
566 );
567 }
568
569 #[test]
570 fn a_multi_label_suffix_resolves_through_its_parent() {
571 let services = ServiceMap::parse(SAMPLE).unwrap();
572 assert!(services.for_suffix("co.uk").is_some());
573 assert_eq!(services.for_suffix("co.uk"), services.for_suffix("uk"));
574 }
575
576 #[test]
577 fn an_extension_with_no_service_reports_none() {
578 let services = ServiceMap::parse(SAMPLE).unwrap();
579 assert!(services.for_suffix("bd").is_none());
580 assert!(services.for_suffix("com.bd").is_none());
581 }
582
583 #[test]
584 fn services_missing_from_the_published_list_are_still_reachable() {
585 let services = ServiceMap::parse(SAMPLE).unwrap();
587 for suffix in ["de", "io", "us"] {
588 assert!(
589 services.for_suffix(suffix).is_some(),
590 ".{suffix} has a working service and must not read as having none"
591 );
592 }
593 }
594
595 #[test]
596 fn a_published_entry_wins_over_the_unlisted_fallback() {
597 let text = r#"{"services":[[["io"],["https://published.example/"]]]}"#;
598 let services = ServiceMap::parse(text).unwrap();
599 assert_eq!(
600 services.for_suffix("io"),
601 Some(&["https://published.example/".to_owned()][..])
602 );
603 }
604
605 #[test]
606 fn a_custom_list_overlays_the_published_one() {
607 let mut services = ServiceMap::parse(SAMPLE).unwrap();
608 let custom = ServiceMap::parse(r#"{"services":[[["com"],["https://mine/"]]]}"#).unwrap();
609 services.merge(custom);
610 assert_eq!(
611 services.for_suffix("com"),
612 Some(&["https://mine/".to_owned()][..])
613 );
614 assert!(services.for_suffix("uk").is_some());
615 }
616
617 #[test]
618 fn rubbish_is_refused() {
619 assert!(ServiceMap::parse("not json").is_err());
620 assert!(ServiceMap::parse(r#"{"services":"nope"}"#).is_err());
621 }
622
623 #[test]
624 fn an_empty_service_entry_is_skipped_rather_than_stored() {
625 let services = ServiceMap::parse(r#"{"services":[[[],[]],[["com"],[]]]}"#).unwrap();
626 assert!(services.for_suffix("com").is_none());
627 }
628
629 #[test]
630 fn hosts_come_out_of_urls() {
631 assert_eq!(
632 host_of("https://rdap.verisign.com/com/v1/"),
633 "rdap.verisign.com"
634 );
635 assert_eq!(host_of("http://a.b.c:8080/x"), "a.b.c");
636 assert_eq!(host_of("whois.nic.io"), "whois.nic.io");
637 }
638
639 #[test]
640 fn each_freshness_describes_itself() {
641 assert_eq!(Freshness::Fresh.label(), "downloaded");
642 assert!(Freshness::Stale.label().contains("out of date"));
643 }
644
645 #[test]
646 fn an_address_a_name_resolved_to_is_judged_the_same_way_the_name_was() {
647 for raw in [
648 "127.0.0.1",
649 "10.0.0.1",
650 "169.254.169.254",
651 "100.64.0.1",
652 "224.0.0.1",
653 "255.255.255.255",
654 "::1",
655 "fd00::1",
656 "fe80::1",
657 "::ffff:127.0.0.1",
658 "::ffff:10.0.0.1",
659 ] {
660 let ip: std::net::IpAddr = raw.parse().expect("a literal address parses");
661 assert!(
662 !is_public_ip(ip),
663 "{raw} must never be dialled, however the name reached it"
664 );
665 }
666
667 for raw in ["203.0.113.10", "2606:4700:4700::1111"] {
668 let ip: std::net::IpAddr = raw.parse().expect("a literal address parses");
669 assert!(is_public_ip(ip), "{raw} is a normal public address");
670 }
671 }
672}