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(|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 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 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 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(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 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 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 #[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 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}