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 CACHE_MAX_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60);
12
13const BOOTSTRAP_URL: &str = "https://data.iana.org/rdap/dns.json";
14
15const 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 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 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 #[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
198fn with_trailing_slash(url: String) -> String {
200 if url.ends_with('/') {
201 url
202 } else {
203 format!("{url}/")
204 }
205}
206
207#[must_use]
208fn 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 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 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 #[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 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}