1use std::collections::{HashMap, HashSet};
2use std::net::{IpAddr, SocketAddr};
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6
7use reqwest::Client;
8use serde::Deserialize;
9use std::sync::LazyLock;
10use tokio::sync::{Notify, RwLock};
11use tracing::{debug, info, instrument, warn};
12
13use super::bootstrap::{ip_matches_prefix, parse_asn_range, validate_bootstrap_url};
14use super::types::RdapResponse;
15use crate::error::{Result, SeerError};
16use crate::http::{read_body_capped, BodyReadError, Overflow};
17use crate::retry::{NetworkRetryClassifier, RetryClassifier, RetryExecutor, RetryPolicy};
18use crate::validation::normalize_domain;
19
20const IANA_BOOTSTRAP_DNS: &str = "https://data.iana.org/rdap/dns.json";
21const IANA_BOOTSTRAP_IPV4: &str = "https://data.iana.org/rdap/ipv4.json";
22const IANA_BOOTSTRAP_IPV6: &str = "https://data.iana.org/rdap/ipv6.json";
23const IANA_BOOTSTRAP_ASN: &str = "https://data.iana.org/rdap/asn.json";
24
25pub(crate) const MAX_BOOTSTRAP_SIZE: usize = 10 * 1024 * 1024;
28
29const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15);
34
35const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
38
39const BOOTSTRAP_TTL: Duration = Duration::from_secs(24 * 60 * 60);
41
42const BOOTSTRAP_REFRESH_MIN_INTERVAL: Duration = Duration::from_secs(60);
46
47const BOOTSTRAP_PARTIAL_TTL: Duration = Duration::from_secs(5 * 60);
54
55const RDAP_ACCEPT: &str = "application/rdap+json, application/json;q=0.9";
59
60const MAX_RDAP_REDIRECTS: usize = 3;
65
66const RDAP_USER_AGENT: &str = "Seer/1.0 (RDAP Client)";
68
69static RDAP_HTTP_CLIENT: LazyLock<Option<Client>> = LazyLock::new(|| {
79 rdap_client_builder(DEFAULT_TIMEOUT)
83 .pool_max_idle_per_host(10)
84 .build()
85 .ok()
86});
87
88fn rdap_http_client() -> Result<&'static Client> {
92 RDAP_HTTP_CLIENT
93 .as_ref()
94 .ok_or_else(|| SeerError::HttpError("failed to initialize HTTP client".into()))
95}
96
97static BOOTSTRAP_CACHE: LazyLock<RwLock<Option<CachedBootstrap>>> =
99 LazyLock::new(|| RwLock::new(None));
100
101static BOOTSTRAP_LAST_ATTEMPT: LazyLock<RwLock<Option<Instant>>> =
105 LazyLock::new(|| RwLock::new(None));
106
107static BOOTSTRAP_LOAD_NOTIFY: LazyLock<Notify> = LazyLock::new(Notify::new);
115
116static BOOTSTRAP_LOAD_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
122
123struct BootstrapLoadGuard;
127
128impl BootstrapLoadGuard {
129 fn start() -> Self {
130 BOOTSTRAP_LOAD_IN_FLIGHT.store(true, Ordering::SeqCst);
131 Self
132 }
133}
134
135impl Drop for BootstrapLoadGuard {
136 fn drop(&mut self) {
137 BOOTSTRAP_LOAD_IN_FLIGHT.store(false, Ordering::SeqCst);
138 BOOTSTRAP_LOAD_NOTIFY.notify_waiters();
139 }
140}
141
142struct CachedBootstrap {
144 data: BootstrapData,
145 loaded_at: Instant,
146 ttl: Duration,
149}
150
151impl CachedBootstrap {
152 #[cfg(test)]
153 fn new(data: BootstrapData) -> Self {
154 Self::with_ttl(data, BOOTSTRAP_TTL)
155 }
156
157 fn with_ttl(data: BootstrapData, ttl: Duration) -> Self {
158 Self {
159 data,
160 loaded_at: Instant::now(),
161 ttl,
162 }
163 }
164
165 fn is_expired(&self) -> bool {
166 self.loaded_at.elapsed() > self.ttl
167 }
168
169 fn age(&self) -> Duration {
170 self.loaded_at.elapsed()
171 }
172}
173
174struct BootstrapData {
179 dns: HashMap<String, Arc<Vec<url::Url>>>,
180 ipv4: Vec<(IpRange, Arc<Vec<url::Url>>)>,
181 ipv6: Vec<(IpRange, Arc<Vec<url::Url>>)>,
182 asn: Vec<(AsnRange, Arc<Vec<url::Url>>)>,
183}
184
185#[derive(Clone)]
186struct IpRange {
187 prefix: String,
188}
189
190#[derive(Clone)]
191struct AsnRange {
192 start: u32,
193 end: u32,
194}
195
196#[derive(Deserialize)]
197struct BootstrapResponse {
198 services: Vec<Vec<serde_json::Value>>,
199}
200
201struct BootstrapLoad {
205 dns: Option<HashMap<String, Arc<Vec<url::Url>>>>,
206 ipv4: Option<Vec<(IpRange, Arc<Vec<url::Url>>)>>,
207 ipv6: Option<Vec<(IpRange, Arc<Vec<url::Url>>)>>,
208 asn: Option<Vec<(AsnRange, Arc<Vec<url::Url>>)>>,
209}
210
211impl BootstrapLoad {
212 fn is_complete(&self) -> bool {
214 self.dns.is_some() && self.ipv4.is_some() && self.ipv6.is_some() && self.asn.is_some()
215 }
216
217 fn ttl(&self) -> Duration {
220 if self.is_complete() {
221 BOOTSTRAP_TTL
222 } else {
223 BOOTSTRAP_PARTIAL_TTL
224 }
225 }
226
227 fn merge_over(self, previous: Option<BootstrapData>) -> BootstrapData {
231 let (dns, ipv4, ipv6, asn) = match previous {
232 Some(p) => (Some(p.dns), Some(p.ipv4), Some(p.ipv6), Some(p.asn)),
233 None => (None, None, None, None),
234 };
235 BootstrapData {
236 dns: self.dns.or(dns).unwrap_or_default(),
237 ipv4: self.ipv4.or(ipv4).unwrap_or_default(),
238 ipv6: self.ipv6.or(ipv6).unwrap_or_default(),
239 asn: self.asn.or(asn).unwrap_or_default(),
240 }
241 }
242}
243
244fn store_bootstrap_load(cache: &mut Option<CachedBootstrap>, load: BootstrapLoad) {
248 if cache.as_ref().is_some_and(|c| !c.is_expired()) {
249 return;
250 }
251 let ttl = load.ttl();
252 if !load.is_complete() {
253 warn!(
254 dns = load.dns.is_some(),
255 ipv4 = load.ipv4.is_some(),
256 ipv6 = load.ipv6.is_some(),
257 asn = load.asn.is_some(),
258 retry_in_secs = ttl.as_secs(),
259 "RDAP bootstrap partially loaded; keeping previous data for failed registries"
260 );
261 }
262 let previous = cache.take().map(|c| c.data);
263 *cache = Some(CachedBootstrap::with_ttl(load.merge_over(previous), ttl));
264}
265
266async fn wait_for_in_flight_load(
276 notified: std::pin::Pin<&mut tokio::sync::futures::Notified<'_>>,
277) -> Result<()> {
278 if BOOTSTRAP_LOAD_IN_FLIGHT.load(Ordering::SeqCst) {
285 let _ = tokio::time::timeout(DEFAULT_TIMEOUT, notified).await;
286 }
287 let cache = BOOTSTRAP_CACHE.read().await;
288 if cache.is_some() {
289 Ok(())
290 } else {
291 Err(SeerError::RdapBootstrapError(
292 "bootstrap refresh throttled and no cache available".to_string(),
293 ))
294 }
295}
296
297#[derive(Debug, Clone)]
298pub struct RdapClient {
299 retry_policy: RetryPolicy,
300 timeout: Duration,
302 allow_reserved: bool,
308}
309
310impl Default for RdapClient {
311 fn default() -> Self {
312 Self::new()
313 }
314}
315
316impl RdapClient {
317 pub fn new() -> Self {
319 Self {
320 retry_policy: RetryPolicy::new()
327 .with_max_attempts(3)
328 .with_initial_delay(Duration::from_millis(500))
329 .with_max_delay(Duration::from_secs(5)),
330 timeout: DEFAULT_TIMEOUT,
331 allow_reserved: false,
332 }
333 }
334
335 pub fn from_config(config: &crate::config::SeerConfig) -> Self {
342 Self::new().with_timeout(config.rdap_timeout())
343 }
344
345 pub fn with_timeout(mut self, timeout: Duration) -> Self {
347 self.timeout = timeout;
348 self
349 }
350
351 #[cfg(test)]
353 pub(crate) fn allowing_reserved_for_tests(mut self) -> Self {
354 self.allow_reserved = true;
355 self
356 }
357
358 pub fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
362 self.retry_policy = policy;
363 self
364 }
365
366 pub fn without_retries(mut self) -> Self {
368 self.retry_policy = RetryPolicy::no_retry();
369 self
370 }
371
372 async fn ensure_bootstrap(&self) -> Result<()> {
387 {
389 let cache = BOOTSTRAP_CACHE.read().await;
390 if let Some(cached) = cache.as_ref() {
391 if !cached.is_expired() {
392 return Ok(());
393 }
394 }
395 }
396
397 let notified = BOOTSTRAP_LOAD_NOTIFY.notified();
403 tokio::pin!(notified);
404
405 {
409 let last = BOOTSTRAP_LAST_ATTEMPT.read().await;
410 if let Some(ts) = *last {
411 if ts.elapsed() < BOOTSTRAP_REFRESH_MIN_INTERVAL {
412 let cache = BOOTSTRAP_CACHE.read().await;
414 if cache.is_some() {
415 return Ok(());
417 }
418 drop(cache);
423 drop(last);
424 return wait_for_in_flight_load(notified).await;
425 }
426 }
427 }
428
429 let load_guard = {
434 let mut last = BOOTSTRAP_LAST_ATTEMPT.write().await;
435 if let Some(ts) = *last {
437 if ts.elapsed() < BOOTSTRAP_REFRESH_MIN_INTERVAL {
438 drop(last);
439 let cache = BOOTSTRAP_CACHE.read().await;
440 if cache.is_some() {
441 return Ok(());
442 }
443 drop(cache);
444 return wait_for_in_flight_load(notified).await;
445 }
446 }
447 *last = Some(Instant::now());
448 BootstrapLoadGuard::start()
449 };
450
451 debug!("Loading/refreshing RDAP bootstrap data");
456 let load_result = load_bootstrap_data_with_retry(&self.retry_policy).await;
457
458 let outcome = match load_result {
459 Ok(load) => {
460 let mut cache = BOOTSTRAP_CACHE.write().await;
464 store_bootstrap_load(&mut cache, load);
465 Ok(())
466 }
467 Err(e) => {
468 let cache = BOOTSTRAP_CACHE.read().await;
470 if let Some(cached) = cache.as_ref() {
471 debug!(
472 error = %e,
473 age_hours = cached.age().as_secs() / 3600,
474 "Bootstrap refresh failed, using stale data"
475 );
476 Ok(())
477 } else {
478 Err(e)
480 }
481 }
482 };
483
484 drop(load_guard);
487 outcome
488 }
489
490 fn get_rdap_urls_for_domain(cache: &BootstrapData, domain: &str) -> Option<Arc<Vec<url::Url>>> {
492 let tld = domain.rsplit('.').next()?;
493 cache.dns.get(&tld.to_lowercase()).cloned()
494 }
495
496 fn get_rdap_urls_for_ip(cache: &BootstrapData, ip: IpAddr) -> Option<Arc<Vec<url::Url>>> {
498 let ranges = match ip {
499 IpAddr::V4(_) => &cache.ipv4,
500 IpAddr::V6(_) => &cache.ipv6,
501 };
502 ranges
503 .iter()
504 .find(|(range, _)| ip_matches_prefix(&range.prefix, ip))
505 .map(|(_, urls)| Arc::clone(urls))
506 }
507
508 fn get_rdap_urls_for_asn(cache: &BootstrapData, asn: u32) -> Option<Arc<Vec<url::Url>>> {
510 cache
511 .asn
512 .iter()
513 .find(|(range, _)| (range.start..=range.end).contains(&asn))
514 .map(|(_, urls)| Arc::clone(urls))
515 }
516
517 async fn candidate_urls(
522 pick: impl FnOnce(&BootstrapData) -> Option<Arc<Vec<url::Url>>>,
523 what: &str,
524 path: &str,
525 ) -> Result<Vec<url::Url>> {
526 let cache_guard = BOOTSTRAP_CACHE.read().await;
527 let cache = cache_guard.as_ref().ok_or_else(|| {
528 SeerError::RdapBootstrapError("bootstrap data not loaded".to_string())
529 })?;
530 let bases = pick(&cache.data)
531 .ok_or_else(|| SeerError::RdapBootstrapError(format!("no RDAP server for {}", what)))?;
532 Ok(build_rdap_urls(&bases, path))
533 }
534
535 #[instrument(skip(self), fields(domain = %domain))]
539 pub async fn lookup_domain(&self, domain: &str) -> Result<RdapResponse> {
540 self.ensure_bootstrap().await?;
541
542 let domain = normalize_domain(domain)?;
543 let urls = Self::candidate_urls(
544 |data| Self::get_rdap_urls_for_domain(data, &domain),
545 &domain,
546 &format!("domain/{}", domain),
547 )
548 .await?;
549 self.query_rdap_urls(&urls).await
550 }
551
552 #[instrument(skip(self), fields(ip = %ip))]
556 pub async fn lookup_ip(&self, ip: &str) -> Result<RdapResponse> {
557 self.ensure_bootstrap().await?;
558
559 let ip_addr: IpAddr = ip
560 .parse()
561 .map_err(|_| SeerError::InvalidIpAddress(ip.to_string()))?;
562 let urls = Self::candidate_urls(
563 |data| Self::get_rdap_urls_for_ip(data, ip_addr),
564 ip,
565 &format!("ip/{}", ip),
566 )
567 .await?;
568 self.query_rdap_urls(&urls).await
569 }
570
571 #[instrument(skip(self), fields(asn = %asn))]
575 pub async fn lookup_asn(&self, asn: u32) -> Result<RdapResponse> {
576 self.ensure_bootstrap().await?;
577 let urls = Self::candidate_urls(
578 |data| Self::get_rdap_urls_for_asn(data, asn),
579 &format!("AS{}", asn),
580 &format!("autnum/{}", asn),
581 )
582 .await?;
583 self.query_rdap_urls(&urls).await
584 }
585
586 #[instrument(skip(self), fields(tld = %tld))]
592 pub async fn get_rdap_base_url_for_tld(&self, tld: &str) -> Option<String> {
593 if self.ensure_bootstrap().await.is_err() {
594 return None;
595 }
596
597 let cache_guard = BOOTSTRAP_CACHE.read().await;
598 let cache = cache_guard.as_ref()?;
599 let lower = tld.to_lowercase();
603 let key = crate::validation::domain_to_ascii(&lower).unwrap_or(lower);
604 cache
605 .data
606 .dns
607 .get(&key)
608 .and_then(|urls| urls.first())
609 .map(|u| u.to_string())
610 }
611
612 async fn query_rdap_urls(&self, urls: &[url::Url]) -> Result<RdapResponse> {
616 if urls.is_empty() {
617 return Err(SeerError::RdapError(
618 "no candidate RDAP URLs available".to_string(),
619 ));
620 }
621
622 let mut last_error: Option<SeerError> = None;
623 let mut not_found_error: Option<SeerError> = None;
629 for (idx, url) in urls.iter().enumerate() {
630 let url_str = url.as_str().to_string();
631 debug!(url = %url_str, candidate = idx + 1, total = urls.len(), "Querying RDAP");
632 match self.query_rdap_with_retry(&url_str).await {
633 Ok(resp) => return Ok(resp),
634 Err(e) => {
635 if urls.len() > 1 {
636 debug!(
637 url = %url_str,
638 error = %e,
639 candidate = idx + 1,
640 total = urls.len(),
641 "RDAP candidate failed, trying next",
642 );
643 }
644 if not_found_error.is_none() && crate::rdap::rdap_error_is_404(&e) {
645 not_found_error = Some(e);
646 } else {
647 last_error = Some(e);
648 }
649 }
650 }
651 }
652
653 Err(wrap_all_candidates_failed(
656 not_found_error.or(last_error),
657 urls.len(),
658 ))
659 }
660
661 async fn query_rdap_with_retry(&self, url: &str) -> Result<RdapResponse> {
666 let classifier = NetworkRetryClassifier::new();
667 let mut attempt = 0;
668 loop {
669 match query_rdap_attempt(url, self.timeout, self.allow_reserved).await {
670 Ok(resp) => return Ok(resp),
671 Err((err, retry_after)) => {
672 let attempts_remaining =
673 self.retry_policy.max_attempts.saturating_sub(attempt + 1);
674 if !classifier.is_retryable(&err) || attempts_remaining == 0 {
675 return Err(if attempt > 0 {
676 SeerError::RetryExhausted {
677 attempts: attempt + 1,
678 last_error: Box::new(err),
679 }
680 } else {
681 err
682 });
683 }
684 let backoff = self.retry_policy.delay_for_attempt(attempt);
685 let delay = effective_retry_delay(backoff, retry_after);
686 debug!(
687 url = %url,
688 attempt = attempt + 1,
689 max_attempts = self.retry_policy.max_attempts,
690 delay_ms = delay.as_millis(),
691 error = %err,
692 "Retrying RDAP after transient error"
693 );
694 tokio::time::sleep(delay).await;
695 attempt += 1;
696 }
697 }
698 }
699 }
700}
701
702const MAX_RDAP_RESPONSE_SIZE: usize = 10 * 1024 * 1024;
704
705const MAX_RETRY_AFTER: Duration = Duration::from_secs(5);
710
711fn parse_rdap_url(url: &str) -> Result<(String, u16)> {
721 let parsed = url::Url::parse(url)
722 .map_err(|e| SeerError::RdapError(format!("invalid URL '{}': {}", url, e)))?;
723 if parsed.scheme() != "https" {
724 return Err(SeerError::RdapError(format!(
725 "RDAP URL '{}' is not https — request blocked (downgrade/SSRF protection)",
726 url
727 )));
728 }
729 let host = crate::net::url_host(&parsed)
730 .ok_or_else(|| SeerError::RdapError(format!("URL '{}' has no host", url)))?;
731 let port = parsed.port_or_known_default().unwrap_or(443);
732 Ok((host, port))
733}
734
735async fn resolve_rdap_host(host: &str, port: u16) -> Result<Vec<SocketAddr>> {
747 crate::net::resolve_public_host(host, port)
748 .await
749 .map_err(|e| SeerError::RdapError(format!("{} — request blocked (SSRF protection)", e)))
750}
751
752#[cfg(test)]
757async fn validate_url_not_reserved(url: &str) -> Result<Vec<SocketAddr>> {
758 let (host, port) = parse_rdap_url(url)?;
759 resolve_rdap_host(&host, port).await
760}
761
762fn parse_retry_after(value: &str) -> Option<Duration> {
767 value.trim().parse::<u64>().ok().map(Duration::from_secs)
768}
769
770fn effective_retry_delay(backoff: Duration, retry_after: Option<Duration>) -> Duration {
774 match retry_after {
775 Some(hint) => hint.min(MAX_RETRY_AFTER),
776 None => backoff,
777 }
778}
779
780const PINNED_CLIENT_TTL: Duration = Duration::from_secs(60);
787
788type PinnedClientKey = (String, u16, Duration);
793
794static PINNED_CLIENT_CACHE: LazyLock<crate::cache::TtlCache<PinnedClientKey, Client>> =
802 LazyLock::new(|| crate::cache::TtlCache::with_max_capacity(PINNED_CLIENT_TTL, 64));
803
804fn rdap_client_builder(timeout: Duration) -> reqwest::ClientBuilder {
812 crate::net::client_builder(timeout)
813 .connect_timeout(CONNECT_TIMEOUT.min(timeout))
814 .user_agent(RDAP_USER_AGENT)
815}
816
817async fn send_rdap_request(
833 url: &str,
834 timeout: Duration,
835 allow_reserved: bool,
836) -> Result<reqwest::Response> {
837 if allow_reserved && is_loopback_url(url) {
838 let client = rdap_client_builder(timeout)
842 .build()
843 .map_err(|e| SeerError::RdapError(format!("failed to build HTTP client: {}", e)))?;
844 return client
845 .get(url)
846 .header(reqwest::header::ACCEPT, RDAP_ACCEPT)
847 .send()
848 .await
849 .map_err(Into::into);
850 }
851
852 let (host, port) = parse_rdap_url(url)?;
854
855 let key = (host.clone(), port, timeout);
856 let client = match PINNED_CLIENT_CACHE.get(&key) {
857 Some(client) => client,
858 None => {
859 let resolved = resolve_rdap_host(&host, port).await?;
864 let client = rdap_client_builder(timeout)
865 .resolve_to_addrs(&host, &resolved)
866 .build()
867 .map_err(|e| SeerError::RdapError(format!("failed to build HTTP client: {}", e)))?;
868 PINNED_CLIENT_CACHE.insert(key.clone(), client.clone());
869 client
870 }
871 };
872
873 match client
874 .get(url)
875 .header(reqwest::header::ACCEPT, RDAP_ACCEPT)
876 .send()
877 .await
878 {
879 Ok(resp) => Ok(resp),
880 Err(e) => {
881 if e.is_connect() {
886 PINNED_CLIENT_CACHE.remove(&key);
887 }
888 Err(e.into())
889 }
890 }
891}
892
893async fn read_and_parse_rdap_body(
898 response: reqwest::Response,
899 url: &str,
900 timeout: Duration,
901) -> Result<RdapResponse> {
902 let body = read_body_capped(response, MAX_RDAP_RESPONSE_SIZE, timeout, Overflow::Reject)
907 .await
908 .map_err(|e| match e {
909 BodyReadError::Chunk(e) => {
910 SeerError::RdapError(format!("failed to read response: {}", e))
911 }
912 BodyReadError::TooLarge => SeerError::RdapError(format!(
913 "RDAP response exceeds {} byte limit",
914 MAX_RDAP_RESPONSE_SIZE
915 )),
916 BodyReadError::TimedOut => SeerError::Timeout(format!(
917 "timed out reading RDAP response body from {} after {:?}",
918 url, timeout
919 )),
920 })?;
921
922 let rdap: RdapResponse = serde_json::from_slice(&body)?;
923 rdap.validate()?;
929 Ok(rdap)
930}
931
932fn is_loopback_url(url: &str) -> bool {
935 match url::Url::parse(url).ok().as_ref().and_then(url::Url::host) {
936 Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
937 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
938 _ => false,
939 }
940}
941
942fn redirect_location(response: &reqwest::Response) -> Option<&str> {
947 match response.status().as_u16() {
948 301 | 302 | 303 | 307 | 308 => response
949 .headers()
950 .get(reqwest::header::LOCATION)
951 .and_then(|v| v.to_str().ok()),
952 _ => None,
953 }
954}
955
956fn resolve_redirect_target(current: &str, location: &str) -> Result<String> {
961 url::Url::parse(current)
962 .and_then(|base| base.join(location))
963 .map(String::from)
964 .map_err(|e| SeerError::RdapError(format!("invalid RDAP redirect target: {}", e)))
965}
966
967async fn query_rdap_attempt(
979 url: &str,
980 timeout: Duration,
981 allow_reserved: bool,
982) -> std::result::Result<RdapResponse, (SeerError, Option<Duration>)> {
983 let mut current = url.to_string();
984 let mut visited: HashSet<String> = HashSet::new();
985 let response = loop {
986 visited.insert(current.clone());
987 let response = send_rdap_request(¤t, timeout, allow_reserved)
988 .await
989 .map_err(|e| (e, None))?;
990 let Some(location) = redirect_location(&response) else {
991 break response;
992 };
993 if visited.len() > MAX_RDAP_REDIRECTS {
994 return Err((
995 SeerError::RdapError(format!(
996 "RDAP query exceeded {} redirects",
997 MAX_RDAP_REDIRECTS
998 )),
999 None,
1000 ));
1001 }
1002 let next = resolve_redirect_target(¤t, location).map_err(|e| (e, None))?;
1003 if visited.contains(&next) {
1004 return Err((
1005 SeerError::RdapError("RDAP redirect loop detected".to_string()),
1006 None,
1007 ));
1008 }
1009 debug!(from = %current, to = %next, "Following RDAP redirect");
1010 current = next;
1011 };
1012
1013 if !response.status().is_success() {
1014 let status = response.status();
1015 let retry_after = if status.as_u16() == 429 {
1018 response
1019 .headers()
1020 .get(reqwest::header::RETRY_AFTER)
1021 .and_then(|v| v.to_str().ok())
1022 .and_then(parse_retry_after)
1023 } else {
1024 None
1025 };
1026 return Err((
1027 SeerError::RdapError(format!("query failed with status {}", status)),
1028 retry_after,
1029 ));
1030 }
1031
1032 read_and_parse_rdap_body(response, ¤t, timeout)
1033 .await
1034 .map_err(|e| (e, None))
1035}
1036
1037async fn load_bootstrap_data_with_retry(policy: &RetryPolicy) -> Result<BootstrapLoad> {
1041 let executor = RetryExecutor::new(policy.clone());
1042 executor.execute(load_bootstrap_data).await
1043}
1044
1045async fn load_bootstrap_data() -> Result<BootstrapLoad> {
1048 debug!("Loading RDAP bootstrap data from IANA");
1049
1050 let http = rdap_http_client()?;
1055
1056 let dns_future = http.get(IANA_BOOTSTRAP_DNS).send();
1057 let ipv4_future = http.get(IANA_BOOTSTRAP_IPV4).send();
1058 let ipv6_future = http.get(IANA_BOOTSTRAP_IPV6).send();
1059 let asn_future = http.get(IANA_BOOTSTRAP_ASN).send();
1060
1061 let (dns_resp, ipv4_resp, ipv6_resp, asn_resp) =
1064 tokio::join!(dns_future, ipv4_future, ipv6_future, asn_future);
1065
1066 async fn read_bootstrap(resp: reqwest::Response) -> Result<BootstrapResponse> {
1067 let body = read_body_capped(resp, MAX_BOOTSTRAP_SIZE, DEFAULT_TIMEOUT, Overflow::Reject)
1072 .await
1073 .map_err(|e| match e {
1074 BodyReadError::Chunk(e) => {
1075 SeerError::RdapBootstrapError(format!("failed to read body: {}", e))
1076 }
1077 BodyReadError::TooLarge => SeerError::RdapBootstrapError(format!(
1078 "bootstrap response too large (exceeds {} bytes)",
1079 MAX_BOOTSTRAP_SIZE
1080 )),
1081 BodyReadError::TimedOut => SeerError::Timeout(format!(
1082 "RDAP bootstrap body read timed out after {:?}",
1083 DEFAULT_TIMEOUT
1084 )),
1085 })?;
1086
1087 serde_json::from_slice(&body).map_err(Into::into)
1088 }
1089
1090 let dns_data = match dns_resp {
1092 Ok(resp) => match read_bootstrap(resp).await {
1093 Ok(data) => Some(data),
1094 Err(e) => {
1095 warn!(error = %e, "Failed to parse DNS bootstrap response");
1096 None
1097 }
1098 },
1099 Err(e) => {
1100 warn!(error = %e, "Failed to fetch DNS bootstrap from IANA");
1101 None
1102 }
1103 };
1104 let ipv4_data = match ipv4_resp {
1105 Ok(resp) => match read_bootstrap(resp).await {
1106 Ok(data) => Some(data),
1107 Err(e) => {
1108 warn!(error = %e, "Failed to parse IPv4 bootstrap response");
1109 None
1110 }
1111 },
1112 Err(e) => {
1113 warn!(error = %e, "Failed to fetch IPv4 bootstrap from IANA");
1114 None
1115 }
1116 };
1117 let ipv6_data = match ipv6_resp {
1118 Ok(resp) => match read_bootstrap(resp).await {
1119 Ok(data) => Some(data),
1120 Err(e) => {
1121 warn!(error = %e, "Failed to parse IPv6 bootstrap response");
1122 None
1123 }
1124 },
1125 Err(e) => {
1126 warn!(error = %e, "Failed to fetch IPv6 bootstrap from IANA");
1127 None
1128 }
1129 };
1130 let asn_data = match asn_resp {
1131 Ok(resp) => match read_bootstrap(resp).await {
1132 Ok(data) => Some(data),
1133 Err(e) => {
1134 warn!(error = %e, "Failed to parse ASN bootstrap response");
1135 None
1136 }
1137 },
1138 Err(e) => {
1139 warn!(error = %e, "Failed to fetch ASN bootstrap from IANA");
1140 None
1141 }
1142 };
1143
1144 fn collect_valid_urls(urls: &[serde_json::Value]) -> Option<Arc<Vec<url::Url>>> {
1148 let mut out = Vec::new();
1149 for u in urls {
1150 if let Some(s) = u.as_str() {
1151 match validate_bootstrap_url(s) {
1152 Ok(parsed) => out.push(parsed),
1153 Err(e) => {
1154 debug!(url = s, error = %e, "Skipping invalid bootstrap URL");
1155 }
1156 }
1157 }
1158 }
1159 if out.is_empty() {
1160 None
1161 } else {
1162 Some(Arc::new(out))
1163 }
1164 }
1165
1166 fn parse_prefix_services(data: BootstrapResponse) -> Vec<(IpRange, Arc<Vec<url::Url>>)> {
1168 let mut out = Vec::new();
1169 for service in data.services {
1170 if service.len() >= 2 {
1171 if let (Some(prefixes), Some(urls)) = (service[0].as_array(), service[1].as_array())
1172 {
1173 if let Some(urls_arc) = collect_valid_urls(urls) {
1174 for prefix in prefixes {
1175 if let Some(prefix_str) = prefix.as_str() {
1176 out.push((
1177 IpRange {
1178 prefix: prefix_str.to_string(),
1179 },
1180 Arc::clone(&urls_arc),
1181 ));
1182 }
1183 }
1184 }
1185 }
1186 }
1187 }
1188 out
1189 }
1190
1191 let dns = dns_data
1197 .map(|data| {
1198 let mut dns = HashMap::new();
1199 for service in data.services {
1200 if service.len() >= 2 {
1201 if let (Some(tlds), Some(urls)) = (service[0].as_array(), service[1].as_array())
1202 {
1203 if let Some(urls_arc) = collect_valid_urls(urls) {
1204 for tld in tlds {
1205 if let Some(tld_str) = tld.as_str() {
1206 dns.insert(tld_str.to_lowercase(), Arc::clone(&urls_arc));
1207 }
1208 }
1209 }
1210 }
1211 }
1212 }
1213 dns
1214 })
1215 .filter(|dns| !dns.is_empty());
1216
1217 let ipv4 = ipv4_data
1219 .map(parse_prefix_services)
1220 .filter(|v| !v.is_empty());
1221 let ipv6 = ipv6_data
1222 .map(parse_prefix_services)
1223 .filter(|v| !v.is_empty());
1224
1225 let asn = asn_data
1227 .map(|data| {
1228 let mut asn = Vec::new();
1229 for service in data.services {
1230 if service.len() >= 2 {
1231 if let (Some(ranges), Some(urls)) =
1232 (service[0].as_array(), service[1].as_array())
1233 {
1234 if let Some(urls_arc) = collect_valid_urls(urls) {
1235 for range in ranges {
1236 if let Some(range_str) = range.as_str() {
1237 if let Some((start, end)) = parse_asn_range(range_str) {
1238 asn.push((AsnRange { start, end }, Arc::clone(&urls_arc)));
1239 }
1240 }
1241 }
1242 }
1243 }
1244 }
1245 }
1246 asn
1247 })
1248 .filter(|v| !v.is_empty());
1249
1250 if dns.is_none() && ipv4.is_none() && ipv6.is_none() && asn.is_none() {
1254 return Err(SeerError::RdapBootstrapError(
1255 "all IANA bootstrap registries failed".to_string(),
1256 ));
1257 }
1258
1259 info!(
1260 dns_entries = dns.as_ref().map_or(0, HashMap::len),
1261 ipv4_ranges = ipv4.as_ref().map_or(0, Vec::len),
1262 ipv6_ranges = ipv6.as_ref().map_or(0, Vec::len),
1263 asn_ranges = asn.as_ref().map_or(0, Vec::len),
1264 "RDAP bootstrap loaded"
1265 );
1266
1267 Ok(BootstrapLoad {
1268 dns,
1269 ipv4,
1270 ipv6,
1271 asn,
1272 })
1273}
1274
1275fn wrap_all_candidates_failed(last_error: Option<SeerError>, candidate_count: usize) -> SeerError {
1284 let last = last_error.unwrap_or_else(|| SeerError::RdapError("no candidates".to_string()));
1285
1286 if candidate_count <= 1 {
1287 return last;
1288 }
1289
1290 match last {
1291 SeerError::Timeout(msg) => SeerError::Timeout(format!(
1292 "all {} RDAP candidate URLs timed out; last error: {}",
1293 candidate_count, msg
1294 )),
1295 other => SeerError::RdapError(format!(
1296 "all {} RDAP candidate URLs failed; last error: {}",
1297 candidate_count, other
1298 )),
1299 }
1300}
1301
1302fn build_rdap_urls(bases: &[url::Url], path: &str) -> Vec<url::Url> {
1304 bases
1305 .iter()
1306 .filter_map(|base| {
1307 let base_str = base.as_str();
1310 let normalized = if base_str.ends_with('/') {
1311 base_str.to_string()
1312 } else {
1313 format!("{}/", base_str)
1314 };
1315 url::Url::parse(&normalized).and_then(|u| u.join(path)).ok()
1316 })
1317 .collect()
1318}
1319
1320#[cfg(test)]
1321mod tests {
1322 use super::*;
1323
1324 #[test]
1325 fn from_config_applies_rdap_timeout() {
1326 let mut config = crate::config::SeerConfig::default();
1327 config.timeouts.rdap_secs = 33;
1328 let client = RdapClient::from_config(&config);
1329 assert_eq!(client.timeout, Duration::from_secs(33));
1330 }
1331
1332 #[test]
1333 fn test_default_client_has_retry_policy() {
1334 let client = RdapClient::new();
1335 assert_eq!(client.retry_policy.max_attempts, 3);
1339 }
1340
1341 #[test]
1344 fn parse_retry_after_parses_delta_seconds() {
1345 assert_eq!(parse_retry_after("5"), Some(Duration::from_secs(5)));
1346 assert_eq!(parse_retry_after(" 10 "), Some(Duration::from_secs(10)));
1347 assert_eq!(parse_retry_after("0"), Some(Duration::from_secs(0)));
1348 }
1349
1350 #[test]
1351 fn parse_retry_after_rejects_http_date_and_junk() {
1352 assert_eq!(parse_retry_after("Wed, 21 Oct 2015 07:28:00 GMT"), None);
1355 assert_eq!(parse_retry_after("soon"), None);
1356 assert_eq!(parse_retry_after(""), None);
1357 }
1358
1359 #[test]
1360 fn effective_retry_delay_prefers_capped_retry_after() {
1361 assert_eq!(
1363 effective_retry_delay(Duration::from_millis(100), Some(Duration::from_secs(5))),
1364 Duration::from_secs(5)
1365 );
1366 assert_eq!(
1368 effective_retry_delay(Duration::from_millis(100), Some(Duration::from_secs(600))),
1369 MAX_RETRY_AFTER
1370 );
1371 }
1372
1373 #[test]
1374 fn effective_retry_delay_falls_back_to_backoff() {
1375 assert_eq!(
1376 effective_retry_delay(Duration::from_millis(250), None),
1377 Duration::from_millis(250)
1378 );
1379 }
1380
1381 #[test]
1382 fn test_client_without_retries() {
1383 let client = RdapClient::new().without_retries();
1384 assert_eq!(client.retry_policy.max_attempts, 1);
1385 }
1386
1387 #[test]
1388 fn test_client_custom_retry_policy() {
1389 let policy = RetryPolicy::new().with_max_attempts(5);
1390 let client = RdapClient::new().with_retry_policy(policy);
1391 assert_eq!(client.retry_policy.max_attempts, 5);
1392 }
1393
1394 #[test]
1395 fn test_cached_bootstrap_expiration() {
1396 let data = BootstrapData {
1397 dns: HashMap::new(),
1398 ipv4: Vec::new(),
1399 ipv6: Vec::new(),
1400 asn: Vec::new(),
1401 };
1402 let cached = CachedBootstrap::new(data);
1403 assert!(!cached.is_expired());
1405 }
1406
1407 #[test]
1408 fn test_rdap_http_client_is_configured() {
1409 let client = rdap_http_client();
1412 assert!(client.is_ok(), "RDAP HTTP client builder must succeed");
1413 }
1414
1415 #[test]
1416 fn test_parse_bootstrap_empty_services() {
1417 let data = BootstrapData {
1419 dns: HashMap::new(),
1420 ipv4: Vec::new(),
1421 ipv6: Vec::new(),
1422 asn: Vec::new(),
1423 };
1424 assert!(RdapClient::get_rdap_urls_for_domain(&data, "example.com").is_none());
1426 assert!(RdapClient::get_rdap_urls_for_asn(&data, 12345).is_none());
1427 }
1428
1429 #[tokio::test]
1432 async fn test_validate_url_not_reserved_rejects_loopback_literal() {
1433 let err = validate_url_not_reserved("https://127.0.0.1/domain/example.com")
1434 .await
1435 .unwrap_err();
1436 assert!(
1437 matches!(err, SeerError::RdapError(ref s) if s.contains("reserved")),
1438 "expected reserved-IP error, got: {:?}",
1439 err
1440 );
1441 }
1442
1443 #[tokio::test]
1444 async fn test_validate_url_not_reserved_rejects_private_ipv4_literal() {
1445 let err = validate_url_not_reserved("https://10.0.0.1/")
1446 .await
1447 .unwrap_err();
1448 assert!(
1449 matches!(err, SeerError::RdapError(ref s) if s.contains("reserved")),
1450 "expected reserved-IP error, got: {:?}",
1451 err
1452 );
1453 }
1454
1455 #[tokio::test]
1456 async fn test_validate_url_not_reserved_rejects_non_https_scheme() {
1457 let err = validate_url_not_reserved("http://93.184.216.34/domain/example.com")
1461 .await
1462 .unwrap_err();
1463 assert!(
1464 matches!(err, SeerError::RdapError(ref s) if s.contains("not https")),
1465 "expected non-https rejection, got: {:?}",
1466 err
1467 );
1468 }
1469
1470 #[tokio::test]
1471 async fn test_validate_url_not_reserved_rejects_ipv6_loopback_literal() {
1472 let err = validate_url_not_reserved("https://[::1]/")
1475 .await
1476 .unwrap_err();
1477 assert!(
1478 matches!(err, SeerError::RdapError(ref s) if s.contains("reserved")),
1479 "expected reserved-IP error, got: {:?}",
1480 err
1481 );
1482 }
1483
1484 #[tokio::test]
1485 async fn test_validate_url_not_reserved_returns_resolved_addrs_for_public_literal() {
1486 let addrs = validate_url_not_reserved("https://8.8.8.8/").await.unwrap();
1489 assert_eq!(addrs.len(), 1);
1490 assert!(addrs[0].ip().is_ipv4());
1491 assert_eq!(addrs[0].port(), 443);
1492 }
1493
1494 #[test]
1497 fn test_build_rdap_urls_preserves_order_and_appends_path() {
1498 let bases = vec![
1499 url::Url::parse("https://rdap.a.example/").unwrap(),
1500 url::Url::parse("https://rdap.b.example").unwrap(), ];
1502 let built = build_rdap_urls(&bases, "domain/example.com");
1503 assert_eq!(built.len(), 2);
1504 assert_eq!(
1505 built[0].as_str(),
1506 "https://rdap.a.example/domain/example.com"
1507 );
1508 assert_eq!(
1509 built[1].as_str(),
1510 "https://rdap.b.example/domain/example.com"
1511 );
1512 }
1513
1514 #[test]
1515 fn test_build_rdap_urls_empty_input_returns_empty() {
1516 let built = build_rdap_urls(&[], "domain/example.com");
1517 assert!(built.is_empty());
1518 }
1519
1520 #[test]
1523 fn test_wrap_all_candidates_failed_preserves_timeout_variant() {
1524 let last = SeerError::Timeout("body read timed out".to_string());
1527 let wrapped = wrap_all_candidates_failed(Some(last), 3);
1528 match wrapped {
1529 SeerError::Timeout(msg) => {
1530 assert!(
1531 msg.contains("all 3 RDAP candidate URLs timed out"),
1532 "expected wrapped timeout message, got: {}",
1533 msg
1534 );
1535 assert!(
1536 msg.contains("body read timed out"),
1537 "expected original message preserved, got: {}",
1538 msg
1539 );
1540 }
1541 other => panic!(
1542 "expected SeerError::Timeout after wrapping a Timeout, got: {:?}",
1543 other
1544 ),
1545 }
1546 }
1547
1548 #[test]
1549 fn test_wrap_all_candidates_failed_wraps_non_timeout_as_rdap_error() {
1550 let last = SeerError::RdapError("500 internal error".to_string());
1551 let wrapped = wrap_all_candidates_failed(Some(last), 2);
1552 assert!(
1553 matches!(wrapped, SeerError::RdapError(ref s) if s.contains("all 2 RDAP candidate URLs failed")),
1554 "expected wrapped RdapError, got: {:?}",
1555 wrapped
1556 );
1557 }
1558
1559 #[test]
1560 fn test_wrap_all_candidates_failed_single_candidate_returns_unchanged() {
1561 let last = SeerError::Timeout("single timeout".to_string());
1564 let wrapped = wrap_all_candidates_failed(Some(last), 1);
1565 assert!(
1566 matches!(wrapped, SeerError::Timeout(ref s) if s == "single timeout"),
1567 "expected unchanged Timeout, got: {:?}",
1568 wrapped
1569 );
1570 }
1571
1572 #[test]
1573 fn test_wrap_all_candidates_failed_no_last_error_returns_placeholder() {
1574 let wrapped = wrap_all_candidates_failed(None, 0);
1575 assert!(matches!(wrapped, SeerError::RdapError(_)));
1576 }
1577
1578 static BOOTSTRAP_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
1597
1598 #[tokio::test]
1599 async fn test_bootstrap_load_notify_wakes_waiter_when_cache_populated() {
1600 let _guard = BOOTSTRAP_TEST_LOCK.lock().await;
1601
1602 {
1604 let mut cache = BOOTSTRAP_CACHE.write().await;
1605 *cache = None;
1606 }
1607
1608 let notified = BOOTSTRAP_LOAD_NOTIFY.notified();
1611 tokio::pin!(notified);
1612
1613 {
1615 let mut cache = BOOTSTRAP_CACHE.write().await;
1616 *cache = Some(CachedBootstrap::new(BootstrapData {
1617 dns: HashMap::new(),
1618 ipv4: Vec::new(),
1619 ipv6: Vec::new(),
1620 asn: Vec::new(),
1621 }));
1622 }
1623 BOOTSTRAP_LOAD_NOTIFY.notify_waiters();
1624
1625 let result = wait_for_in_flight_load(notified).await;
1626 assert!(
1627 result.is_ok(),
1628 "expected waiter to see populated cache, got: {:?}",
1629 result
1630 );
1631
1632 {
1634 let mut cache = BOOTSTRAP_CACHE.write().await;
1635 *cache = None;
1636 }
1637 }
1638
1639 #[tokio::test]
1640 async fn test_bootstrap_load_notify_empty_cache_after_wake_returns_error() {
1641 let _guard = BOOTSTRAP_TEST_LOCK.lock().await;
1642
1643 {
1645 let mut cache = BOOTSTRAP_CACHE.write().await;
1646 *cache = None;
1647 }
1648
1649 let notified = BOOTSTRAP_LOAD_NOTIFY.notified();
1650 tokio::pin!(notified);
1651
1652 BOOTSTRAP_LOAD_NOTIFY.notify_waiters();
1654
1655 let result = wait_for_in_flight_load(notified).await;
1656 assert!(
1657 matches!(
1658 result,
1659 Err(SeerError::RdapBootstrapError(ref s))
1660 if s.contains("throttled and no cache available")
1661 ),
1662 "expected throttled error when cache still empty after notify, got: {:?}",
1663 result
1664 );
1665 }
1666
1667 use wiremock::matchers::method;
1677 use wiremock::{Mock, MockServer, ResponseTemplate};
1678
1679 #[tokio::test]
1680 async fn mock_rdap_404_is_nonretryable_typed_error() {
1681 let server = MockServer::start().await;
1682 Mock::given(method("GET"))
1683 .respond_with(ResponseTemplate::new(404))
1684 .mount(&server)
1685 .await;
1686
1687 let client = RdapClient::new()
1688 .without_retries()
1689 .allowing_reserved_for_tests();
1690 let err = client
1691 .query_rdap_with_retry(&format!("{}/domain/example.com", server.uri()))
1692 .await
1693 .unwrap_err();
1694 assert!(
1695 matches!(err, SeerError::RdapError(ref m) if m.contains("404")),
1696 "got: {err:?}"
1697 );
1698 }
1699
1700 #[tokio::test]
1701 async fn mock_rdap_429_honors_retry_after_and_succeeds() {
1702 let server = MockServer::start().await;
1703 Mock::given(method("GET"))
1706 .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "0"))
1707 .up_to_n_times(1)
1708 .mount(&server)
1709 .await;
1710 Mock::given(method("GET"))
1711 .respond_with(ResponseTemplate::new(200).set_body_raw(
1712 r#"{"objectClassName":"domain","handle":"MOCK-1"}"#,
1713 "application/rdap+json",
1714 ))
1715 .mount(&server)
1716 .await;
1717
1718 let client = RdapClient::new().allowing_reserved_for_tests();
1719 let resp = client
1720 .query_rdap_with_retry(&format!("{}/domain/example.com", server.uri()))
1721 .await
1722 .unwrap();
1723 assert_eq!(resp.handle.as_deref(), Some("MOCK-1"));
1724 }
1725
1726 #[tokio::test]
1727 async fn mock_rdap_malformed_body_is_parse_error_not_panic() {
1728 let server = MockServer::start().await;
1729 Mock::given(method("GET"))
1730 .respond_with(ResponseTemplate::new(200).set_body_raw("not json", "text/plain"))
1731 .mount(&server)
1732 .await;
1733
1734 let client = RdapClient::new()
1735 .without_retries()
1736 .allowing_reserved_for_tests();
1737 let err = client
1738 .query_rdap_with_retry(&format!("{}/domain/example.com", server.uri()))
1739 .await
1740 .unwrap_err();
1741 assert!(matches!(err, SeerError::JsonError(_)), "got: {err:?}");
1742 }
1743
1744 #[tokio::test]
1745 async fn mock_rdap_candidate_fallback_uses_second_url() {
1746 let bad = MockServer::start().await;
1747 Mock::given(method("GET"))
1748 .respond_with(ResponseTemplate::new(500))
1749 .mount(&bad)
1750 .await;
1751 let good = MockServer::start().await;
1752 Mock::given(method("GET"))
1753 .respond_with(ResponseTemplate::new(200).set_body_raw(
1754 r#"{"objectClassName":"domain","handle":"MOCK-2"}"#,
1755 "application/rdap+json",
1756 ))
1757 .mount(&good)
1758 .await;
1759
1760 let client = RdapClient::new()
1761 .without_retries()
1762 .allowing_reserved_for_tests();
1763 let urls = vec![
1764 url::Url::parse(&format!("{}/domain/example.com", bad.uri())).unwrap(),
1765 url::Url::parse(&format!("{}/domain/example.com", good.uri())).unwrap(),
1766 ];
1767 let resp = client.query_rdap_urls(&urls).await.unwrap();
1768 assert_eq!(resp.handle.as_deref(), Some("MOCK-2"));
1769 }
1770
1771 #[tokio::test]
1777 async fn mock_rdap_404_on_first_candidate_survives_later_non_404_failure() {
1778 let not_found = MockServer::start().await;
1779 Mock::given(method("GET"))
1780 .respond_with(ResponseTemplate::new(404))
1781 .mount(¬_found)
1782 .await;
1783 let broken = MockServer::start().await;
1784 Mock::given(method("GET"))
1785 .respond_with(ResponseTemplate::new(500))
1786 .mount(&broken)
1787 .await;
1788
1789 let client = RdapClient::new()
1790 .without_retries()
1791 .allowing_reserved_for_tests();
1792 let urls = vec![
1793 url::Url::parse(&format!("{}/domain/example.com", not_found.uri())).unwrap(),
1794 url::Url::parse(&format!("{}/domain/example.com", broken.uri())).unwrap(),
1795 ];
1796 let err = client.query_rdap_urls(&urls).await.unwrap_err();
1797 assert!(
1798 crate::rdap::rdap_error_is_404(&err),
1799 "404 from candidate 1 must survive candidate 2's non-404 failure, got: {err:?}"
1800 );
1801 }
1802
1803 #[tokio::test]
1814 async fn pinned_client_cache_hit_skips_dns_and_revalidation() {
1815 let host = "pinned-cache-hit.seer-test.invalid";
1816 let timeout = Duration::from_secs(2);
1817 let key = (host.to_string(), 443u16, timeout);
1818
1819 let pinned: Vec<SocketAddr> = vec!["127.0.0.1:443".parse().expect("valid addr")];
1824 let client = Client::builder()
1825 .timeout(timeout)
1826 .connect_timeout(timeout)
1827 .resolve_to_addrs(host, &pinned)
1828 .redirect(reqwest::redirect::Policy::none())
1829 .build()
1830 .expect("client builds");
1831 PINNED_CLIENT_CACHE.insert(key.clone(), client);
1832
1833 let err = send_rdap_request(
1834 &format!("https://{}/domain/example.com", host),
1835 timeout,
1836 false,
1837 )
1838 .await
1839 .unwrap_err();
1840 assert!(
1841 matches!(err, SeerError::ReqwestError { .. }),
1842 "cache hit must reach the connect stage (ReqwestError), not fail \
1843 SSRF validation (RdapError) — got: {err:?}"
1844 );
1845
1846 PINNED_CLIENT_CACHE.remove(&key);
1849 }
1850
1851 #[tokio::test]
1856 async fn test_mode_requests_never_populate_pinned_client_cache() {
1857 let server = MockServer::start().await;
1858 Mock::given(method("GET"))
1859 .respond_with(ResponseTemplate::new(200).set_body_raw(
1860 r#"{"objectClassName":"domain","handle":"MOCK-CACHE"}"#,
1861 "application/rdap+json",
1862 ))
1863 .mount(&server)
1864 .await;
1865
1866 let client = RdapClient::new()
1867 .without_retries()
1868 .allowing_reserved_for_tests();
1869 let uri = format!("{}/domain/example.com", server.uri());
1870 let resp = client.query_rdap_with_retry(&uri).await.unwrap();
1871 assert_eq!(resp.handle.as_deref(), Some("MOCK-CACHE"));
1872
1873 let parsed = url::Url::parse(&uri).unwrap();
1876 let host = parsed.host_str().unwrap().to_string();
1877 let port = parsed.port_or_known_default().unwrap();
1878 assert!(
1879 PINNED_CLIENT_CACHE
1880 .get(&(host, port, DEFAULT_TIMEOUT))
1881 .is_none(),
1882 "test-mode request must not populate the shared pinned-client cache"
1883 );
1884 }
1885
1886 use wiremock::matchers::path;
1889
1890 const MOCK_OK_BODY: &str = r#"{"objectClassName":"domain","handle":"MOCK-REDIRECT"}"#;
1891
1892 #[test]
1893 fn is_loopback_url_only_matches_loopback_literals() {
1894 assert!(is_loopback_url("http://127.0.0.1:8080/domain/x"));
1895 assert!(is_loopback_url("http://[::1]:8080/domain/x"));
1896 assert!(!is_loopback_url("https://10.0.0.1/domain/x"));
1897 assert!(!is_loopback_url("https://rdap.example.com/domain/x"));
1898 assert!(!is_loopback_url("not a url"));
1899 }
1900
1901 #[tokio::test]
1902 async fn mock_rdap_303_redirect_is_followed() {
1903 let target = MockServer::start().await;
1906 Mock::given(method("GET"))
1907 .and(path("/ip/192.36.148.17"))
1908 .respond_with(
1909 ResponseTemplate::new(200).set_body_raw(MOCK_OK_BODY, "application/rdap+json"),
1910 )
1911 .expect(1)
1912 .mount(&target)
1913 .await;
1914 let origin = MockServer::start().await;
1915 Mock::given(method("GET"))
1916 .respond_with(
1917 ResponseTemplate::new(303)
1918 .insert_header("Location", format!("{}/ip/192.36.148.17", target.uri())),
1919 )
1920 .mount(&origin)
1921 .await;
1922
1923 let client = RdapClient::new()
1924 .without_retries()
1925 .allowing_reserved_for_tests();
1926 let resp = client
1927 .query_rdap_with_retry(&format!("{}/ip/192.36.148.17", origin.uri()))
1928 .await
1929 .unwrap();
1930 assert_eq!(resp.handle.as_deref(), Some("MOCK-REDIRECT"));
1931 }
1932
1933 #[tokio::test]
1934 async fn mock_rdap_relative_redirect_is_resolved_against_current_url() {
1935 let server = MockServer::start().await;
1936 Mock::given(method("GET"))
1937 .and(path("/old/domain/example.com"))
1938 .respond_with(
1939 ResponseTemplate::new(301).insert_header("Location", "/new/domain/example.com"),
1940 )
1941 .mount(&server)
1942 .await;
1943 Mock::given(method("GET"))
1944 .and(path("/new/domain/example.com"))
1945 .respond_with(
1946 ResponseTemplate::new(200).set_body_raw(MOCK_OK_BODY, "application/rdap+json"),
1947 )
1948 .mount(&server)
1949 .await;
1950
1951 let client = RdapClient::new()
1952 .without_retries()
1953 .allowing_reserved_for_tests();
1954 let resp = client
1955 .query_rdap_with_retry(&format!("{}/old/domain/example.com", server.uri()))
1956 .await
1957 .unwrap();
1958 assert_eq!(resp.handle.as_deref(), Some("MOCK-REDIRECT"));
1959 }
1960
1961 async fn redirect_error(location: &str) -> SeerError {
1965 let server = MockServer::start().await;
1966 Mock::given(method("GET"))
1967 .respond_with(ResponseTemplate::new(302).insert_header("Location", location))
1968 .mount(&server)
1969 .await;
1970 let client = RdapClient::new()
1971 .without_retries()
1972 .allowing_reserved_for_tests();
1973 client
1974 .query_rdap_with_retry(&format!("{}/domain/example.com", server.uri()))
1975 .await
1976 .unwrap_err()
1977 }
1978
1979 #[tokio::test]
1980 async fn mock_rdap_redirect_to_plain_http_is_refused() {
1981 let err = redirect_error("http://93.184.216.34/domain/example.com").await;
1984 assert!(
1985 matches!(err, SeerError::RdapError(ref m) if m.contains("not https")),
1986 "got: {err:?}"
1987 );
1988 }
1989
1990 #[tokio::test]
1991 async fn mock_rdap_redirect_to_reserved_address_is_refused() {
1992 for location in [
1993 "https://10.0.0.1/domain/example.com",
1994 "https://169.254.169.254/latest/meta-data/",
1995 ] {
1996 let err = redirect_error(location).await;
1997 assert!(
1998 matches!(err, SeerError::RdapError(ref m) if m.contains("reserved")),
1999 "{location}: got {err:?}"
2000 );
2001 }
2002 }
2003
2004 #[tokio::test]
2005 async fn mock_rdap_redirect_loop_is_detected() {
2006 let server = MockServer::start().await;
2007 Mock::given(method("GET"))
2008 .and(path("/a"))
2009 .respond_with(ResponseTemplate::new(307).insert_header("Location", "/b"))
2010 .mount(&server)
2011 .await;
2012 Mock::given(method("GET"))
2013 .and(path("/b"))
2014 .respond_with(ResponseTemplate::new(307).insert_header("Location", "/a"))
2015 .mount(&server)
2016 .await;
2017 let client = RdapClient::new()
2018 .without_retries()
2019 .allowing_reserved_for_tests();
2020 let err = client
2021 .query_rdap_with_retry(&format!("{}/a", server.uri()))
2022 .await
2023 .unwrap_err();
2024 assert!(
2025 matches!(err, SeerError::RdapError(ref m) if m.contains("loop")),
2026 "got: {err:?}"
2027 );
2028 }
2029
2030 #[tokio::test]
2031 async fn mock_rdap_redirect_chain_is_capped() {
2032 let server = MockServer::start().await;
2033 for hop in 0..=MAX_RDAP_REDIRECTS {
2034 Mock::given(method("GET"))
2035 .and(path(format!("/hop{hop}")))
2036 .respond_with(
2037 ResponseTemplate::new(308)
2038 .insert_header("Location", format!("/hop{}", hop + 1)),
2039 )
2040 .mount(&server)
2041 .await;
2042 }
2043 let client = RdapClient::new()
2044 .without_retries()
2045 .allowing_reserved_for_tests();
2046 let err = client
2047 .query_rdap_with_retry(&format!("{}/hop0", server.uri()))
2048 .await
2049 .unwrap_err();
2050 assert!(
2051 matches!(err, SeerError::RdapError(ref m) if m.contains("exceeded")),
2052 "got: {err:?}"
2053 );
2054 }
2055
2056 #[tokio::test]
2057 async fn mock_rdap_sends_accept_header_with_json_fallback() {
2058 let server = MockServer::start().await;
2060 Mock::given(method("GET"))
2061 .and(|req: &wiremock::Request| {
2064 req.headers.get("accept").and_then(|v| v.to_str().ok()) == Some(RDAP_ACCEPT)
2065 })
2066 .respond_with(ResponseTemplate::new(200).set_body_raw(MOCK_OK_BODY, "application/json"))
2067 .mount(&server)
2068 .await;
2069 Mock::given(method("GET"))
2070 .respond_with(ResponseTemplate::new(406))
2071 .mount(&server)
2072 .await;
2073 let client = RdapClient::new()
2074 .without_retries()
2075 .allowing_reserved_for_tests();
2076 let resp = client
2077 .query_rdap_with_retry(&format!("{}/domain/example.sn", server.uri()))
2078 .await
2079 .unwrap();
2080 assert_eq!(resp.handle.as_deref(), Some("MOCK-REDIRECT"));
2081 assert!(RDAP_ACCEPT.contains("application/json"));
2082 }
2083
2084 fn url_arc(u: &str) -> Arc<Vec<url::Url>> {
2087 Arc::new(vec![url::Url::parse(u).expect("valid url")])
2088 }
2089
2090 fn full_load() -> BootstrapLoad {
2091 let mut dns = HashMap::new();
2092 dns.insert("com".to_string(), url_arc("https://rdap.verisign.example/"));
2093 BootstrapLoad {
2094 dns: Some(dns),
2095 ipv4: Some(vec![(
2096 IpRange {
2097 prefix: "8.0.0.0/8".to_string(),
2098 },
2099 url_arc("https://rdap.arin.example/"),
2100 )]),
2101 ipv6: Some(Vec::new()),
2102 asn: Some(vec![(
2103 AsnRange { start: 1, end: 10 },
2104 url_arc("https://rdap.arin.example/"),
2105 )]),
2106 }
2107 }
2108
2109 #[test]
2110 fn complete_bootstrap_load_gets_full_ttl() {
2111 let load = full_load();
2112 assert!(load.is_complete());
2113 assert_eq!(load.ttl(), BOOTSTRAP_TTL);
2114 }
2115
2116 #[test]
2117 fn partial_bootstrap_load_keeps_previous_section_and_short_ttl() {
2118 let mut previous = Some(CachedBootstrap::with_ttl(
2120 full_load().merge_over(None),
2121 Duration::ZERO,
2122 ));
2123 std::thread::sleep(Duration::from_millis(2));
2124 assert!(previous.as_ref().is_some_and(CachedBootstrap::is_expired));
2125
2126 let mut partial = full_load();
2128 partial.dns = None;
2129 assert!(!partial.is_complete());
2130 store_bootstrap_load(&mut previous, partial);
2131
2132 let cached = previous.expect("cache populated");
2133 assert_eq!(cached.ttl, BOOTSTRAP_PARTIAL_TTL);
2134 assert!(
2135 RdapClient::get_rdap_urls_for_domain(&cached.data, "example.com").is_some(),
2136 "the failed dns section must be carried over, not wiped"
2137 );
2138 assert!(RdapClient::get_rdap_urls_for_asn(&cached.data, 5).is_some());
2139 }
2140
2141 #[test]
2142 fn partial_cold_bootstrap_load_leaves_failed_section_empty() {
2143 let mut cache = None;
2144 let mut partial = full_load();
2145 partial.dns = None;
2146 store_bootstrap_load(&mut cache, partial);
2147 let cached = cache.expect("cache populated");
2148 assert_eq!(cached.ttl, BOOTSTRAP_PARTIAL_TTL);
2149 assert!(RdapClient::get_rdap_urls_for_domain(&cached.data, "example.com").is_none());
2150 }
2151
2152 #[test]
2153 fn bootstrap_store_does_not_overwrite_fresh_data() {
2154 let mut fresh = full_load();
2155 fresh.dns = Some(HashMap::from([(
2156 "org".to_string(),
2157 url_arc("https://rdap.pir.example/"),
2158 )]));
2159 let mut cache = Some(CachedBootstrap::new(fresh.merge_over(None)));
2160 store_bootstrap_load(&mut cache, full_load());
2161 let cached = cache.expect("cache populated");
2162 assert!(RdapClient::get_rdap_urls_for_domain(&cached.data, "example.org").is_some());
2163 assert!(RdapClient::get_rdap_urls_for_domain(&cached.data, "example.com").is_none());
2164 }
2165
2166 #[tokio::test]
2169 async fn wait_returns_immediately_when_no_load_is_in_flight() {
2170 let _guard = BOOTSTRAP_TEST_LOCK.lock().await;
2171 {
2172 let mut cache = BOOTSTRAP_CACHE.write().await;
2173 *cache = None;
2174 }
2175 assert!(!BOOTSTRAP_LOAD_IN_FLIGHT.load(Ordering::SeqCst));
2176
2177 let notified = BOOTSTRAP_LOAD_NOTIFY.notified();
2181 tokio::pin!(notified);
2182 let result =
2183 tokio::time::timeout(Duration::from_secs(2), wait_for_in_flight_load(notified))
2184 .await
2185 .expect("must not block when no load is in flight");
2186 assert!(
2187 matches!(result, Err(SeerError::RdapBootstrapError(ref s)) if s.contains("throttled")),
2188 "got: {result:?}"
2189 );
2190 }
2191
2192 #[tokio::test]
2193 async fn wait_blocks_until_in_flight_load_finishes() {
2194 let _guard = BOOTSTRAP_TEST_LOCK.lock().await;
2195 {
2196 let mut cache = BOOTSTRAP_CACHE.write().await;
2197 *cache = None;
2198 }
2199
2200 let load = BootstrapLoadGuard::start();
2201 let notified = BOOTSTRAP_LOAD_NOTIFY.notified();
2202 tokio::pin!(notified);
2203
2204 let loader = tokio::spawn(async move {
2205 tokio::time::sleep(Duration::from_millis(50)).await;
2206 {
2207 let mut cache = BOOTSTRAP_CACHE.write().await;
2208 *cache = Some(CachedBootstrap::new(full_load().merge_over(None)));
2209 }
2210 drop(load);
2212 });
2213
2214 let result =
2215 tokio::time::timeout(Duration::from_secs(5), wait_for_in_flight_load(notified))
2216 .await
2217 .expect("waiter must wake via the loader's notify");
2218 assert!(result.is_ok(), "got: {result:?}");
2219 loader.await.expect("loader joined");
2220 assert!(!BOOTSTRAP_LOAD_IN_FLIGHT.load(Ordering::SeqCst));
2221
2222 let mut cache = BOOTSTRAP_CACHE.write().await;
2223 *cache = None;
2224 }
2225}