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>> =
803 LazyLock::new(|| crate::cache::TtlCache::with_max_capacity(PINNED_CLIENT_TTL, 64));
804
805fn rdap_client_builder(timeout: Duration) -> reqwest::ClientBuilder {
813 crate::net::client_builder(timeout)
814 .connect_timeout(CONNECT_TIMEOUT.min(timeout))
815 .user_agent(RDAP_USER_AGENT)
816}
817
818async fn send_rdap_request(
834 url: &str,
835 timeout: Duration,
836 allow_reserved: bool,
837) -> Result<reqwest::Response> {
838 if allow_reserved && is_loopback_url(url) {
839 let client = rdap_client_builder(timeout)
843 .build()
844 .map_err(|e| SeerError::RdapError(format!("failed to build HTTP client: {}", e)))?;
845 return client
846 .get(url)
847 .header(reqwest::header::ACCEPT, RDAP_ACCEPT)
848 .send()
849 .await
850 .map_err(Into::into);
851 }
852
853 let (host, port) = parse_rdap_url(url)?;
855
856 let key = (host.clone(), port, timeout);
857 let client = match PINNED_CLIENT_CACHE.get(&key) {
858 Some(client) => client,
859 None => {
860 let resolved = resolve_rdap_host(&host, port).await?;
865 let client = rdap_client_builder(timeout)
866 .resolve_to_addrs(&host, &resolved)
867 .build()
868 .map_err(|e| SeerError::RdapError(format!("failed to build HTTP client: {}", e)))?;
869 PINNED_CLIENT_CACHE.insert(key.clone(), client.clone());
870 client
871 }
872 };
873
874 match client
875 .get(url)
876 .header(reqwest::header::ACCEPT, RDAP_ACCEPT)
877 .send()
878 .await
879 {
880 Ok(resp) => Ok(resp),
881 Err(e) => {
882 if e.is_connect() {
887 PINNED_CLIENT_CACHE.remove(&key);
888 }
889 Err(e.into())
890 }
891 }
892}
893
894async fn read_and_parse_rdap_body(
899 response: reqwest::Response,
900 url: &str,
901 timeout: Duration,
902) -> Result<RdapResponse> {
903 let body = read_body_capped(response, MAX_RDAP_RESPONSE_SIZE, timeout, Overflow::Reject)
908 .await
909 .map_err(|e| match e {
910 BodyReadError::Chunk(e) => {
911 SeerError::RdapError(format!("failed to read response: {}", e))
912 }
913 BodyReadError::TooLarge => SeerError::RdapError(format!(
914 "RDAP response exceeds {} byte limit",
915 MAX_RDAP_RESPONSE_SIZE
916 )),
917 BodyReadError::TimedOut => SeerError::Timeout(format!(
918 "timed out reading RDAP response body from {} after {:?}",
919 url, timeout
920 )),
921 })?;
922
923 let rdap: RdapResponse = serde_json::from_slice(&body)?;
924 rdap.validate()?;
930 Ok(rdap)
931}
932
933fn is_loopback_url(url: &str) -> bool {
936 match url::Url::parse(url).ok().as_ref().and_then(url::Url::host) {
937 Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
938 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
939 _ => false,
940 }
941}
942
943fn redirect_location(response: &reqwest::Response) -> Option<&str> {
948 match response.status().as_u16() {
949 301 | 302 | 303 | 307 | 308 => response
950 .headers()
951 .get(reqwest::header::LOCATION)
952 .and_then(|v| v.to_str().ok()),
953 _ => None,
954 }
955}
956
957fn resolve_redirect_target(current: &str, location: &str) -> Result<String> {
962 url::Url::parse(current)
963 .and_then(|base| base.join(location))
964 .map(String::from)
965 .map_err(|e| SeerError::RdapError(format!("invalid RDAP redirect target: {}", e)))
966}
967
968async fn query_rdap_attempt(
980 url: &str,
981 timeout: Duration,
982 allow_reserved: bool,
983) -> std::result::Result<RdapResponse, (SeerError, Option<Duration>)> {
984 let mut current = url.to_string();
985 let mut visited: HashSet<String> = HashSet::new();
986 let response = loop {
987 visited.insert(current.clone());
988 let response = send_rdap_request(¤t, timeout, allow_reserved)
989 .await
990 .map_err(|e| (e, None))?;
991 let Some(location) = redirect_location(&response) else {
992 break response;
993 };
994 if visited.len() > MAX_RDAP_REDIRECTS {
995 return Err((
996 SeerError::RdapError(format!(
997 "RDAP query exceeded {} redirects",
998 MAX_RDAP_REDIRECTS
999 )),
1000 None,
1001 ));
1002 }
1003 let next = resolve_redirect_target(¤t, location).map_err(|e| (e, None))?;
1004 if visited.contains(&next) {
1005 return Err((
1006 SeerError::RdapError("RDAP redirect loop detected".to_string()),
1007 None,
1008 ));
1009 }
1010 debug!(from = %current, to = %next, "Following RDAP redirect");
1011 current = next;
1012 };
1013
1014 if !response.status().is_success() {
1015 let status = response.status();
1016 let retry_after = if status.as_u16() == 429 {
1019 response
1020 .headers()
1021 .get(reqwest::header::RETRY_AFTER)
1022 .and_then(|v| v.to_str().ok())
1023 .and_then(parse_retry_after)
1024 } else {
1025 None
1026 };
1027 return Err((
1028 SeerError::RdapError(format!("query failed with status {}", status)),
1029 retry_after,
1030 ));
1031 }
1032
1033 read_and_parse_rdap_body(response, ¤t, timeout)
1034 .await
1035 .map_err(|e| (e, None))
1036}
1037
1038async fn load_bootstrap_data_with_retry(policy: &RetryPolicy) -> Result<BootstrapLoad> {
1042 let executor = RetryExecutor::new(policy.clone());
1043 executor.execute(load_bootstrap_data).await
1044}
1045
1046async fn load_bootstrap_data() -> Result<BootstrapLoad> {
1049 debug!("Loading RDAP bootstrap data from IANA");
1050
1051 let http = rdap_http_client()?;
1056
1057 let dns_future = http.get(IANA_BOOTSTRAP_DNS).send();
1058 let ipv4_future = http.get(IANA_BOOTSTRAP_IPV4).send();
1059 let ipv6_future = http.get(IANA_BOOTSTRAP_IPV6).send();
1060 let asn_future = http.get(IANA_BOOTSTRAP_ASN).send();
1061
1062 let (dns_resp, ipv4_resp, ipv6_resp, asn_resp) =
1065 tokio::join!(dns_future, ipv4_future, ipv6_future, asn_future);
1066
1067 async fn read_bootstrap(resp: reqwest::Response) -> Result<BootstrapResponse> {
1068 let body = read_body_capped(resp, MAX_BOOTSTRAP_SIZE, DEFAULT_TIMEOUT, Overflow::Reject)
1073 .await
1074 .map_err(|e| match e {
1075 BodyReadError::Chunk(e) => {
1076 SeerError::RdapBootstrapError(format!("failed to read body: {}", e))
1077 }
1078 BodyReadError::TooLarge => SeerError::RdapBootstrapError(format!(
1079 "bootstrap response too large (exceeds {} bytes)",
1080 MAX_BOOTSTRAP_SIZE
1081 )),
1082 BodyReadError::TimedOut => SeerError::Timeout(format!(
1083 "RDAP bootstrap body read timed out after {:?}",
1084 DEFAULT_TIMEOUT
1085 )),
1086 })?;
1087
1088 serde_json::from_slice(&body).map_err(Into::into)
1089 }
1090
1091 let dns_data = match dns_resp {
1093 Ok(resp) => match read_bootstrap(resp).await {
1094 Ok(data) => Some(data),
1095 Err(e) => {
1096 warn!(error = %e, "Failed to parse DNS bootstrap response");
1097 None
1098 }
1099 },
1100 Err(e) => {
1101 warn!(error = %e, "Failed to fetch DNS bootstrap from IANA");
1102 None
1103 }
1104 };
1105 let ipv4_data = match ipv4_resp {
1106 Ok(resp) => match read_bootstrap(resp).await {
1107 Ok(data) => Some(data),
1108 Err(e) => {
1109 warn!(error = %e, "Failed to parse IPv4 bootstrap response");
1110 None
1111 }
1112 },
1113 Err(e) => {
1114 warn!(error = %e, "Failed to fetch IPv4 bootstrap from IANA");
1115 None
1116 }
1117 };
1118 let ipv6_data = match ipv6_resp {
1119 Ok(resp) => match read_bootstrap(resp).await {
1120 Ok(data) => Some(data),
1121 Err(e) => {
1122 warn!(error = %e, "Failed to parse IPv6 bootstrap response");
1123 None
1124 }
1125 },
1126 Err(e) => {
1127 warn!(error = %e, "Failed to fetch IPv6 bootstrap from IANA");
1128 None
1129 }
1130 };
1131 let asn_data = match asn_resp {
1132 Ok(resp) => match read_bootstrap(resp).await {
1133 Ok(data) => Some(data),
1134 Err(e) => {
1135 warn!(error = %e, "Failed to parse ASN bootstrap response");
1136 None
1137 }
1138 },
1139 Err(e) => {
1140 warn!(error = %e, "Failed to fetch ASN bootstrap from IANA");
1141 None
1142 }
1143 };
1144
1145 fn collect_valid_urls(urls: &[serde_json::Value]) -> Option<Arc<Vec<url::Url>>> {
1149 let mut out = Vec::new();
1150 for u in urls {
1151 if let Some(s) = u.as_str() {
1152 match validate_bootstrap_url(s) {
1153 Ok(parsed) => out.push(parsed),
1154 Err(e) => {
1155 debug!(url = s, error = %e, "Skipping invalid bootstrap URL");
1156 }
1157 }
1158 }
1159 }
1160 if out.is_empty() {
1161 None
1162 } else {
1163 Some(Arc::new(out))
1164 }
1165 }
1166
1167 fn parse_prefix_services(data: BootstrapResponse) -> Vec<(IpRange, Arc<Vec<url::Url>>)> {
1169 let mut out = Vec::new();
1170 for service in data.services {
1171 if service.len() >= 2 {
1172 if let (Some(prefixes), Some(urls)) = (service[0].as_array(), service[1].as_array())
1173 {
1174 if let Some(urls_arc) = collect_valid_urls(urls) {
1175 for prefix in prefixes {
1176 if let Some(prefix_str) = prefix.as_str() {
1177 out.push((
1178 IpRange {
1179 prefix: prefix_str.to_string(),
1180 },
1181 Arc::clone(&urls_arc),
1182 ));
1183 }
1184 }
1185 }
1186 }
1187 }
1188 }
1189 out
1190 }
1191
1192 let dns = dns_data
1198 .map(|data| {
1199 let mut dns = HashMap::new();
1200 for service in data.services {
1201 if service.len() >= 2 {
1202 if let (Some(tlds), Some(urls)) = (service[0].as_array(), service[1].as_array())
1203 {
1204 if let Some(urls_arc) = collect_valid_urls(urls) {
1205 for tld in tlds {
1206 if let Some(tld_str) = tld.as_str() {
1207 dns.insert(tld_str.to_lowercase(), Arc::clone(&urls_arc));
1208 }
1209 }
1210 }
1211 }
1212 }
1213 }
1214 dns
1215 })
1216 .filter(|dns| !dns.is_empty());
1217
1218 let ipv4 = ipv4_data
1220 .map(parse_prefix_services)
1221 .filter(|v| !v.is_empty());
1222 let ipv6 = ipv6_data
1223 .map(parse_prefix_services)
1224 .filter(|v| !v.is_empty());
1225
1226 let asn = asn_data
1228 .map(|data| {
1229 let mut asn = Vec::new();
1230 for service in data.services {
1231 if service.len() >= 2 {
1232 if let (Some(ranges), Some(urls)) =
1233 (service[0].as_array(), service[1].as_array())
1234 {
1235 if let Some(urls_arc) = collect_valid_urls(urls) {
1236 for range in ranges {
1237 if let Some(range_str) = range.as_str() {
1238 if let Some((start, end)) = parse_asn_range(range_str) {
1239 asn.push((AsnRange { start, end }, Arc::clone(&urls_arc)));
1240 }
1241 }
1242 }
1243 }
1244 }
1245 }
1246 }
1247 asn
1248 })
1249 .filter(|v| !v.is_empty());
1250
1251 if dns.is_none() && ipv4.is_none() && ipv6.is_none() && asn.is_none() {
1255 return Err(SeerError::RdapBootstrapError(
1256 "all IANA bootstrap registries failed".to_string(),
1257 ));
1258 }
1259
1260 info!(
1261 dns_entries = dns.as_ref().map_or(0, HashMap::len),
1262 ipv4_ranges = ipv4.as_ref().map_or(0, Vec::len),
1263 ipv6_ranges = ipv6.as_ref().map_or(0, Vec::len),
1264 asn_ranges = asn.as_ref().map_or(0, Vec::len),
1265 "RDAP bootstrap loaded"
1266 );
1267
1268 Ok(BootstrapLoad {
1269 dns,
1270 ipv4,
1271 ipv6,
1272 asn,
1273 })
1274}
1275
1276fn wrap_all_candidates_failed(last_error: Option<SeerError>, candidate_count: usize) -> SeerError {
1285 let last = last_error.unwrap_or_else(|| SeerError::RdapError("no candidates".to_string()));
1286
1287 if candidate_count <= 1 {
1288 return last;
1289 }
1290
1291 match last {
1292 SeerError::Timeout(msg) => SeerError::Timeout(format!(
1293 "all {} RDAP candidate URLs timed out; last error: {}",
1294 candidate_count, msg
1295 )),
1296 other => SeerError::RdapError(format!(
1297 "all {} RDAP candidate URLs failed; last error: {}",
1298 candidate_count, other
1299 )),
1300 }
1301}
1302
1303fn build_rdap_urls(bases: &[url::Url], path: &str) -> Vec<url::Url> {
1305 bases
1306 .iter()
1307 .filter_map(|base| {
1308 let base_str = base.as_str();
1311 let normalized = if base_str.ends_with('/') {
1312 base_str.to_string()
1313 } else {
1314 format!("{}/", base_str)
1315 };
1316 url::Url::parse(&normalized).and_then(|u| u.join(path)).ok()
1317 })
1318 .collect()
1319}
1320
1321#[cfg(test)]
1322mod tests {
1323 use super::*;
1324
1325 #[test]
1326 fn from_config_applies_rdap_timeout() {
1327 let mut config = crate::config::SeerConfig::default();
1328 config.timeouts.rdap_secs = 33;
1329 let client = RdapClient::from_config(&config);
1330 assert_eq!(client.timeout, Duration::from_secs(33));
1331 }
1332
1333 #[test]
1334 fn test_default_client_has_retry_policy() {
1335 let client = RdapClient::new();
1336 assert_eq!(client.retry_policy.max_attempts, 3);
1340 }
1341
1342 #[test]
1345 fn parse_retry_after_parses_delta_seconds() {
1346 assert_eq!(parse_retry_after("5"), Some(Duration::from_secs(5)));
1347 assert_eq!(parse_retry_after(" 10 "), Some(Duration::from_secs(10)));
1348 assert_eq!(parse_retry_after("0"), Some(Duration::from_secs(0)));
1349 }
1350
1351 #[test]
1352 fn parse_retry_after_rejects_http_date_and_junk() {
1353 assert_eq!(parse_retry_after("Wed, 21 Oct 2015 07:28:00 GMT"), None);
1356 assert_eq!(parse_retry_after("soon"), None);
1357 assert_eq!(parse_retry_after(""), None);
1358 }
1359
1360 #[test]
1361 fn effective_retry_delay_prefers_capped_retry_after() {
1362 assert_eq!(
1364 effective_retry_delay(Duration::from_millis(100), Some(Duration::from_secs(5))),
1365 Duration::from_secs(5)
1366 );
1367 assert_eq!(
1369 effective_retry_delay(Duration::from_millis(100), Some(Duration::from_secs(600))),
1370 MAX_RETRY_AFTER
1371 );
1372 }
1373
1374 #[test]
1375 fn effective_retry_delay_falls_back_to_backoff() {
1376 assert_eq!(
1377 effective_retry_delay(Duration::from_millis(250), None),
1378 Duration::from_millis(250)
1379 );
1380 }
1381
1382 #[test]
1383 fn test_client_without_retries() {
1384 let client = RdapClient::new().without_retries();
1385 assert_eq!(client.retry_policy.max_attempts, 1);
1386 }
1387
1388 #[test]
1389 fn test_client_custom_retry_policy() {
1390 let policy = RetryPolicy::new().with_max_attempts(5);
1391 let client = RdapClient::new().with_retry_policy(policy);
1392 assert_eq!(client.retry_policy.max_attempts, 5);
1393 }
1394
1395 #[test]
1396 fn test_cached_bootstrap_expiration() {
1397 let data = BootstrapData {
1398 dns: HashMap::new(),
1399 ipv4: Vec::new(),
1400 ipv6: Vec::new(),
1401 asn: Vec::new(),
1402 };
1403 let cached = CachedBootstrap::new(data);
1404 assert!(!cached.is_expired());
1406 }
1407
1408 #[test]
1409 fn test_rdap_http_client_is_configured() {
1410 let client = rdap_http_client();
1413 assert!(client.is_ok(), "RDAP HTTP client builder must succeed");
1414 }
1415
1416 #[test]
1417 fn test_parse_bootstrap_empty_services() {
1418 let data = BootstrapData {
1420 dns: HashMap::new(),
1421 ipv4: Vec::new(),
1422 ipv6: Vec::new(),
1423 asn: Vec::new(),
1424 };
1425 assert!(RdapClient::get_rdap_urls_for_domain(&data, "example.com").is_none());
1427 assert!(RdapClient::get_rdap_urls_for_asn(&data, 12345).is_none());
1428 }
1429
1430 #[tokio::test]
1433 async fn test_validate_url_not_reserved_rejects_loopback_literal() {
1434 let err = validate_url_not_reserved("https://127.0.0.1/domain/example.com")
1435 .await
1436 .unwrap_err();
1437 assert!(
1438 matches!(err, SeerError::RdapError(ref s) if s.contains("reserved")),
1439 "expected reserved-IP error, got: {:?}",
1440 err
1441 );
1442 }
1443
1444 #[tokio::test]
1445 async fn test_validate_url_not_reserved_rejects_private_ipv4_literal() {
1446 let err = validate_url_not_reserved("https://10.0.0.1/")
1447 .await
1448 .unwrap_err();
1449 assert!(
1450 matches!(err, SeerError::RdapError(ref s) if s.contains("reserved")),
1451 "expected reserved-IP error, got: {:?}",
1452 err
1453 );
1454 }
1455
1456 #[tokio::test]
1457 async fn test_validate_url_not_reserved_rejects_non_https_scheme() {
1458 let err = validate_url_not_reserved("http://93.184.216.34/domain/example.com")
1462 .await
1463 .unwrap_err();
1464 assert!(
1465 matches!(err, SeerError::RdapError(ref s) if s.contains("not https")),
1466 "expected non-https rejection, got: {:?}",
1467 err
1468 );
1469 }
1470
1471 #[tokio::test]
1472 async fn test_validate_url_not_reserved_rejects_ipv6_loopback_literal() {
1473 let err = validate_url_not_reserved("https://[::1]/")
1476 .await
1477 .unwrap_err();
1478 assert!(
1479 matches!(err, SeerError::RdapError(ref s) if s.contains("reserved")),
1480 "expected reserved-IP error, got: {:?}",
1481 err
1482 );
1483 }
1484
1485 #[tokio::test]
1486 async fn test_validate_url_not_reserved_returns_resolved_addrs_for_public_literal() {
1487 let addrs = validate_url_not_reserved("https://8.8.8.8/").await.unwrap();
1490 assert_eq!(addrs.len(), 1);
1491 assert!(addrs[0].ip().is_ipv4());
1492 assert_eq!(addrs[0].port(), 443);
1493 }
1494
1495 #[test]
1498 fn test_build_rdap_urls_preserves_order_and_appends_path() {
1499 let bases = vec![
1500 url::Url::parse("https://rdap.a.example/").unwrap(),
1501 url::Url::parse("https://rdap.b.example").unwrap(), ];
1503 let built = build_rdap_urls(&bases, "domain/example.com");
1504 assert_eq!(built.len(), 2);
1505 assert_eq!(
1506 built[0].as_str(),
1507 "https://rdap.a.example/domain/example.com"
1508 );
1509 assert_eq!(
1510 built[1].as_str(),
1511 "https://rdap.b.example/domain/example.com"
1512 );
1513 }
1514
1515 #[test]
1516 fn test_build_rdap_urls_empty_input_returns_empty() {
1517 let built = build_rdap_urls(&[], "domain/example.com");
1518 assert!(built.is_empty());
1519 }
1520
1521 #[test]
1524 fn test_wrap_all_candidates_failed_preserves_timeout_variant() {
1525 let last = SeerError::Timeout("body read timed out".to_string());
1528 let wrapped = wrap_all_candidates_failed(Some(last), 3);
1529 match wrapped {
1530 SeerError::Timeout(msg) => {
1531 assert!(
1532 msg.contains("all 3 RDAP candidate URLs timed out"),
1533 "expected wrapped timeout message, got: {}",
1534 msg
1535 );
1536 assert!(
1537 msg.contains("body read timed out"),
1538 "expected original message preserved, got: {}",
1539 msg
1540 );
1541 }
1542 other => panic!(
1543 "expected SeerError::Timeout after wrapping a Timeout, got: {:?}",
1544 other
1545 ),
1546 }
1547 }
1548
1549 #[test]
1550 fn test_wrap_all_candidates_failed_wraps_non_timeout_as_rdap_error() {
1551 let last = SeerError::RdapError("500 internal error".to_string());
1552 let wrapped = wrap_all_candidates_failed(Some(last), 2);
1553 assert!(
1554 matches!(wrapped, SeerError::RdapError(ref s) if s.contains("all 2 RDAP candidate URLs failed")),
1555 "expected wrapped RdapError, got: {:?}",
1556 wrapped
1557 );
1558 }
1559
1560 #[test]
1561 fn test_wrap_all_candidates_failed_single_candidate_returns_unchanged() {
1562 let last = SeerError::Timeout("single timeout".to_string());
1565 let wrapped = wrap_all_candidates_failed(Some(last), 1);
1566 assert!(
1567 matches!(wrapped, SeerError::Timeout(ref s) if s == "single timeout"),
1568 "expected unchanged Timeout, got: {:?}",
1569 wrapped
1570 );
1571 }
1572
1573 #[test]
1574 fn test_wrap_all_candidates_failed_no_last_error_returns_placeholder() {
1575 let wrapped = wrap_all_candidates_failed(None, 0);
1576 assert!(matches!(wrapped, SeerError::RdapError(_)));
1577 }
1578
1579 static BOOTSTRAP_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
1598
1599 #[tokio::test]
1600 async fn test_bootstrap_load_notify_wakes_waiter_when_cache_populated() {
1601 let _guard = BOOTSTRAP_TEST_LOCK.lock().await;
1602
1603 {
1605 let mut cache = BOOTSTRAP_CACHE.write().await;
1606 *cache = None;
1607 }
1608
1609 let notified = BOOTSTRAP_LOAD_NOTIFY.notified();
1612 tokio::pin!(notified);
1613
1614 {
1616 let mut cache = BOOTSTRAP_CACHE.write().await;
1617 *cache = Some(CachedBootstrap::new(BootstrapData {
1618 dns: HashMap::new(),
1619 ipv4: Vec::new(),
1620 ipv6: Vec::new(),
1621 asn: Vec::new(),
1622 }));
1623 }
1624 BOOTSTRAP_LOAD_NOTIFY.notify_waiters();
1625
1626 let result = wait_for_in_flight_load(notified).await;
1627 assert!(
1628 result.is_ok(),
1629 "expected waiter to see populated cache, got: {:?}",
1630 result
1631 );
1632
1633 {
1635 let mut cache = BOOTSTRAP_CACHE.write().await;
1636 *cache = None;
1637 }
1638 }
1639
1640 #[tokio::test]
1641 async fn test_bootstrap_load_notify_empty_cache_after_wake_returns_error() {
1642 let _guard = BOOTSTRAP_TEST_LOCK.lock().await;
1643
1644 {
1646 let mut cache = BOOTSTRAP_CACHE.write().await;
1647 *cache = None;
1648 }
1649
1650 let notified = BOOTSTRAP_LOAD_NOTIFY.notified();
1651 tokio::pin!(notified);
1652
1653 BOOTSTRAP_LOAD_NOTIFY.notify_waiters();
1655
1656 let result = wait_for_in_flight_load(notified).await;
1657 assert!(
1658 matches!(
1659 result,
1660 Err(SeerError::RdapBootstrapError(ref s))
1661 if s.contains("throttled and no cache available")
1662 ),
1663 "expected throttled error when cache still empty after notify, got: {:?}",
1664 result
1665 );
1666 }
1667
1668 use wiremock::matchers::method;
1678 use wiremock::{Mock, MockServer, ResponseTemplate};
1679
1680 #[tokio::test]
1681 async fn mock_rdap_404_is_nonretryable_typed_error() {
1682 let server = MockServer::start().await;
1683 Mock::given(method("GET"))
1684 .respond_with(ResponseTemplate::new(404))
1685 .mount(&server)
1686 .await;
1687
1688 let client = RdapClient::new()
1689 .without_retries()
1690 .allowing_reserved_for_tests();
1691 let err = client
1692 .query_rdap_with_retry(&format!("{}/domain/example.com", server.uri()))
1693 .await
1694 .unwrap_err();
1695 assert!(
1696 matches!(err, SeerError::RdapError(ref m) if m.contains("404")),
1697 "got: {err:?}"
1698 );
1699 }
1700
1701 #[tokio::test]
1702 async fn mock_rdap_429_honors_retry_after_and_succeeds() {
1703 let server = MockServer::start().await;
1704 Mock::given(method("GET"))
1707 .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "0"))
1708 .up_to_n_times(1)
1709 .mount(&server)
1710 .await;
1711 Mock::given(method("GET"))
1712 .respond_with(ResponseTemplate::new(200).set_body_raw(
1713 r#"{"objectClassName":"domain","handle":"MOCK-1"}"#,
1714 "application/rdap+json",
1715 ))
1716 .mount(&server)
1717 .await;
1718
1719 let client = RdapClient::new().allowing_reserved_for_tests();
1720 let resp = client
1721 .query_rdap_with_retry(&format!("{}/domain/example.com", server.uri()))
1722 .await
1723 .unwrap();
1724 assert_eq!(resp.handle.as_deref(), Some("MOCK-1"));
1725 }
1726
1727 #[tokio::test]
1728 async fn mock_rdap_malformed_body_is_parse_error_not_panic() {
1729 let server = MockServer::start().await;
1730 Mock::given(method("GET"))
1731 .respond_with(ResponseTemplate::new(200).set_body_raw("not json", "text/plain"))
1732 .mount(&server)
1733 .await;
1734
1735 let client = RdapClient::new()
1736 .without_retries()
1737 .allowing_reserved_for_tests();
1738 let err = client
1739 .query_rdap_with_retry(&format!("{}/domain/example.com", server.uri()))
1740 .await
1741 .unwrap_err();
1742 assert!(matches!(err, SeerError::JsonError(_)), "got: {err:?}");
1743 }
1744
1745 #[tokio::test]
1746 async fn mock_rdap_candidate_fallback_uses_second_url() {
1747 let bad = MockServer::start().await;
1748 Mock::given(method("GET"))
1749 .respond_with(ResponseTemplate::new(500))
1750 .mount(&bad)
1751 .await;
1752 let good = MockServer::start().await;
1753 Mock::given(method("GET"))
1754 .respond_with(ResponseTemplate::new(200).set_body_raw(
1755 r#"{"objectClassName":"domain","handle":"MOCK-2"}"#,
1756 "application/rdap+json",
1757 ))
1758 .mount(&good)
1759 .await;
1760
1761 let client = RdapClient::new()
1762 .without_retries()
1763 .allowing_reserved_for_tests();
1764 let urls = vec![
1765 url::Url::parse(&format!("{}/domain/example.com", bad.uri())).unwrap(),
1766 url::Url::parse(&format!("{}/domain/example.com", good.uri())).unwrap(),
1767 ];
1768 let resp = client.query_rdap_urls(&urls).await.unwrap();
1769 assert_eq!(resp.handle.as_deref(), Some("MOCK-2"));
1770 }
1771
1772 #[tokio::test]
1778 async fn mock_rdap_404_on_first_candidate_survives_later_non_404_failure() {
1779 let not_found = MockServer::start().await;
1780 Mock::given(method("GET"))
1781 .respond_with(ResponseTemplate::new(404))
1782 .mount(¬_found)
1783 .await;
1784 let broken = MockServer::start().await;
1785 Mock::given(method("GET"))
1786 .respond_with(ResponseTemplate::new(500))
1787 .mount(&broken)
1788 .await;
1789
1790 let client = RdapClient::new()
1791 .without_retries()
1792 .allowing_reserved_for_tests();
1793 let urls = vec![
1794 url::Url::parse(&format!("{}/domain/example.com", not_found.uri())).unwrap(),
1795 url::Url::parse(&format!("{}/domain/example.com", broken.uri())).unwrap(),
1796 ];
1797 let err = client.query_rdap_urls(&urls).await.unwrap_err();
1798 assert!(
1799 crate::rdap::rdap_error_is_404(&err),
1800 "404 from candidate 1 must survive candidate 2's non-404 failure, got: {err:?}"
1801 );
1802 }
1803
1804 #[tokio::test]
1815 async fn pinned_client_cache_hit_skips_dns_and_revalidation() {
1816 let host = "pinned-cache-hit.seer-test.invalid";
1817 let timeout = Duration::from_secs(2);
1818 let key = (host.to_string(), 443u16, timeout);
1819
1820 let pinned: Vec<SocketAddr> = vec!["127.0.0.1:443".parse().expect("valid addr")];
1825 let client = Client::builder()
1826 .timeout(timeout)
1827 .connect_timeout(timeout)
1828 .resolve_to_addrs(host, &pinned)
1829 .redirect(reqwest::redirect::Policy::none())
1830 .build()
1831 .expect("client builds");
1832 PINNED_CLIENT_CACHE.insert(key.clone(), client);
1833
1834 let err = send_rdap_request(
1835 &format!("https://{}/domain/example.com", host),
1836 timeout,
1837 false,
1838 )
1839 .await
1840 .unwrap_err();
1841 assert!(
1842 matches!(err, SeerError::ReqwestError { .. }),
1843 "cache hit must reach the connect stage (ReqwestError), not fail \
1844 SSRF validation (RdapError) — got: {err:?}"
1845 );
1846
1847 PINNED_CLIENT_CACHE.remove(&key);
1850 }
1851
1852 #[tokio::test]
1857 async fn test_mode_requests_never_populate_pinned_client_cache() {
1858 let server = MockServer::start().await;
1859 Mock::given(method("GET"))
1860 .respond_with(ResponseTemplate::new(200).set_body_raw(
1861 r#"{"objectClassName":"domain","handle":"MOCK-CACHE"}"#,
1862 "application/rdap+json",
1863 ))
1864 .mount(&server)
1865 .await;
1866
1867 let client = RdapClient::new()
1868 .without_retries()
1869 .allowing_reserved_for_tests();
1870 let uri = format!("{}/domain/example.com", server.uri());
1871 let resp = client.query_rdap_with_retry(&uri).await.unwrap();
1872 assert_eq!(resp.handle.as_deref(), Some("MOCK-CACHE"));
1873
1874 let parsed = url::Url::parse(&uri).unwrap();
1877 let host = parsed.host_str().unwrap().to_string();
1878 let port = parsed.port_or_known_default().unwrap();
1879 assert!(
1880 PINNED_CLIENT_CACHE
1881 .get(&(host, port, DEFAULT_TIMEOUT))
1882 .is_none(),
1883 "test-mode request must not populate the shared pinned-client cache"
1884 );
1885 }
1886
1887 use wiremock::matchers::path;
1890
1891 const MOCK_OK_BODY: &str = r#"{"objectClassName":"domain","handle":"MOCK-REDIRECT"}"#;
1892
1893 #[test]
1894 fn is_loopback_url_only_matches_loopback_literals() {
1895 assert!(is_loopback_url("http://127.0.0.1:8080/domain/x"));
1896 assert!(is_loopback_url("http://[::1]:8080/domain/x"));
1897 assert!(!is_loopback_url("https://10.0.0.1/domain/x"));
1898 assert!(!is_loopback_url("https://rdap.example.com/domain/x"));
1899 assert!(!is_loopback_url("not a url"));
1900 }
1901
1902 #[tokio::test]
1903 async fn mock_rdap_303_redirect_is_followed() {
1904 let target = MockServer::start().await;
1907 Mock::given(method("GET"))
1908 .and(path("/ip/192.36.148.17"))
1909 .respond_with(
1910 ResponseTemplate::new(200).set_body_raw(MOCK_OK_BODY, "application/rdap+json"),
1911 )
1912 .expect(1)
1913 .mount(&target)
1914 .await;
1915 let origin = MockServer::start().await;
1916 Mock::given(method("GET"))
1917 .respond_with(
1918 ResponseTemplate::new(303)
1919 .insert_header("Location", format!("{}/ip/192.36.148.17", target.uri())),
1920 )
1921 .mount(&origin)
1922 .await;
1923
1924 let client = RdapClient::new()
1925 .without_retries()
1926 .allowing_reserved_for_tests();
1927 let resp = client
1928 .query_rdap_with_retry(&format!("{}/ip/192.36.148.17", origin.uri()))
1929 .await
1930 .unwrap();
1931 assert_eq!(resp.handle.as_deref(), Some("MOCK-REDIRECT"));
1932 }
1933
1934 #[tokio::test]
1935 async fn mock_rdap_relative_redirect_is_resolved_against_current_url() {
1936 let server = MockServer::start().await;
1937 Mock::given(method("GET"))
1938 .and(path("/old/domain/example.com"))
1939 .respond_with(
1940 ResponseTemplate::new(301).insert_header("Location", "/new/domain/example.com"),
1941 )
1942 .mount(&server)
1943 .await;
1944 Mock::given(method("GET"))
1945 .and(path("/new/domain/example.com"))
1946 .respond_with(
1947 ResponseTemplate::new(200).set_body_raw(MOCK_OK_BODY, "application/rdap+json"),
1948 )
1949 .mount(&server)
1950 .await;
1951
1952 let client = RdapClient::new()
1953 .without_retries()
1954 .allowing_reserved_for_tests();
1955 let resp = client
1956 .query_rdap_with_retry(&format!("{}/old/domain/example.com", server.uri()))
1957 .await
1958 .unwrap();
1959 assert_eq!(resp.handle.as_deref(), Some("MOCK-REDIRECT"));
1960 }
1961
1962 async fn redirect_error(location: &str) -> SeerError {
1966 let server = MockServer::start().await;
1967 Mock::given(method("GET"))
1968 .respond_with(ResponseTemplate::new(302).insert_header("Location", location))
1969 .mount(&server)
1970 .await;
1971 let client = RdapClient::new()
1972 .without_retries()
1973 .allowing_reserved_for_tests();
1974 client
1975 .query_rdap_with_retry(&format!("{}/domain/example.com", server.uri()))
1976 .await
1977 .unwrap_err()
1978 }
1979
1980 #[tokio::test]
1981 async fn mock_rdap_redirect_to_plain_http_is_refused() {
1982 let err = redirect_error("http://93.184.216.34/domain/example.com").await;
1985 assert!(
1986 matches!(err, SeerError::RdapError(ref m) if m.contains("not https")),
1987 "got: {err:?}"
1988 );
1989 }
1990
1991 #[tokio::test]
1992 async fn mock_rdap_redirect_to_reserved_address_is_refused() {
1993 for location in [
1994 "https://10.0.0.1/domain/example.com",
1995 "https://169.254.169.254/latest/meta-data/",
1996 ] {
1997 let err = redirect_error(location).await;
1998 assert!(
1999 matches!(err, SeerError::RdapError(ref m) if m.contains("reserved")),
2000 "{location}: got {err:?}"
2001 );
2002 }
2003 }
2004
2005 #[tokio::test]
2006 async fn mock_rdap_redirect_loop_is_detected() {
2007 let server = MockServer::start().await;
2008 Mock::given(method("GET"))
2009 .and(path("/a"))
2010 .respond_with(ResponseTemplate::new(307).insert_header("Location", "/b"))
2011 .mount(&server)
2012 .await;
2013 Mock::given(method("GET"))
2014 .and(path("/b"))
2015 .respond_with(ResponseTemplate::new(307).insert_header("Location", "/a"))
2016 .mount(&server)
2017 .await;
2018 let client = RdapClient::new()
2019 .without_retries()
2020 .allowing_reserved_for_tests();
2021 let err = client
2022 .query_rdap_with_retry(&format!("{}/a", server.uri()))
2023 .await
2024 .unwrap_err();
2025 assert!(
2026 matches!(err, SeerError::RdapError(ref m) if m.contains("loop")),
2027 "got: {err:?}"
2028 );
2029 }
2030
2031 #[tokio::test]
2032 async fn mock_rdap_redirect_chain_is_capped() {
2033 let server = MockServer::start().await;
2034 for hop in 0..=MAX_RDAP_REDIRECTS {
2035 Mock::given(method("GET"))
2036 .and(path(format!("/hop{hop}")))
2037 .respond_with(
2038 ResponseTemplate::new(308)
2039 .insert_header("Location", format!("/hop{}", hop + 1)),
2040 )
2041 .mount(&server)
2042 .await;
2043 }
2044 let client = RdapClient::new()
2045 .without_retries()
2046 .allowing_reserved_for_tests();
2047 let err = client
2048 .query_rdap_with_retry(&format!("{}/hop0", server.uri()))
2049 .await
2050 .unwrap_err();
2051 assert!(
2052 matches!(err, SeerError::RdapError(ref m) if m.contains("exceeded")),
2053 "got: {err:?}"
2054 );
2055 }
2056
2057 #[tokio::test]
2058 async fn mock_rdap_sends_accept_header_with_json_fallback() {
2059 let server = MockServer::start().await;
2061 Mock::given(method("GET"))
2062 .and(|req: &wiremock::Request| {
2065 req.headers.get("accept").and_then(|v| v.to_str().ok()) == Some(RDAP_ACCEPT)
2066 })
2067 .respond_with(ResponseTemplate::new(200).set_body_raw(MOCK_OK_BODY, "application/json"))
2068 .mount(&server)
2069 .await;
2070 Mock::given(method("GET"))
2071 .respond_with(ResponseTemplate::new(406))
2072 .mount(&server)
2073 .await;
2074 let client = RdapClient::new()
2075 .without_retries()
2076 .allowing_reserved_for_tests();
2077 let resp = client
2078 .query_rdap_with_retry(&format!("{}/domain/example.sn", server.uri()))
2079 .await
2080 .unwrap();
2081 assert_eq!(resp.handle.as_deref(), Some("MOCK-REDIRECT"));
2082 assert!(RDAP_ACCEPT.contains("application/json"));
2083 }
2084
2085 fn url_arc(u: &str) -> Arc<Vec<url::Url>> {
2088 Arc::new(vec![url::Url::parse(u).expect("valid url")])
2089 }
2090
2091 fn full_load() -> BootstrapLoad {
2092 let mut dns = HashMap::new();
2093 dns.insert("com".to_string(), url_arc("https://rdap.verisign.example/"));
2094 BootstrapLoad {
2095 dns: Some(dns),
2096 ipv4: Some(vec![(
2097 IpRange {
2098 prefix: "8.0.0.0/8".to_string(),
2099 },
2100 url_arc("https://rdap.arin.example/"),
2101 )]),
2102 ipv6: Some(Vec::new()),
2103 asn: Some(vec![(
2104 AsnRange { start: 1, end: 10 },
2105 url_arc("https://rdap.arin.example/"),
2106 )]),
2107 }
2108 }
2109
2110 #[test]
2111 fn complete_bootstrap_load_gets_full_ttl() {
2112 let load = full_load();
2113 assert!(load.is_complete());
2114 assert_eq!(load.ttl(), BOOTSTRAP_TTL);
2115 }
2116
2117 #[test]
2118 fn partial_bootstrap_load_keeps_previous_section_and_short_ttl() {
2119 let mut previous = Some(CachedBootstrap::with_ttl(
2121 full_load().merge_over(None),
2122 Duration::ZERO,
2123 ));
2124 std::thread::sleep(Duration::from_millis(2));
2125 assert!(previous.as_ref().is_some_and(CachedBootstrap::is_expired));
2126
2127 let mut partial = full_load();
2129 partial.dns = None;
2130 assert!(!partial.is_complete());
2131 store_bootstrap_load(&mut previous, partial);
2132
2133 let cached = previous.expect("cache populated");
2134 assert_eq!(cached.ttl, BOOTSTRAP_PARTIAL_TTL);
2135 assert!(
2136 RdapClient::get_rdap_urls_for_domain(&cached.data, "example.com").is_some(),
2137 "the failed dns section must be carried over, not wiped"
2138 );
2139 assert!(RdapClient::get_rdap_urls_for_asn(&cached.data, 5).is_some());
2140 }
2141
2142 #[test]
2143 fn partial_cold_bootstrap_load_leaves_failed_section_empty() {
2144 let mut cache = None;
2145 let mut partial = full_load();
2146 partial.dns = None;
2147 store_bootstrap_load(&mut cache, partial);
2148 let cached = cache.expect("cache populated");
2149 assert_eq!(cached.ttl, BOOTSTRAP_PARTIAL_TTL);
2150 assert!(RdapClient::get_rdap_urls_for_domain(&cached.data, "example.com").is_none());
2151 }
2152
2153 #[test]
2154 fn bootstrap_store_does_not_overwrite_fresh_data() {
2155 let mut fresh = full_load();
2156 fresh.dns = Some(HashMap::from([(
2157 "org".to_string(),
2158 url_arc("https://rdap.pir.example/"),
2159 )]));
2160 let mut cache = Some(CachedBootstrap::new(fresh.merge_over(None)));
2161 store_bootstrap_load(&mut cache, full_load());
2162 let cached = cache.expect("cache populated");
2163 assert!(RdapClient::get_rdap_urls_for_domain(&cached.data, "example.org").is_some());
2164 assert!(RdapClient::get_rdap_urls_for_domain(&cached.data, "example.com").is_none());
2165 }
2166
2167 #[tokio::test]
2170 async fn wait_returns_immediately_when_no_load_is_in_flight() {
2171 let _guard = BOOTSTRAP_TEST_LOCK.lock().await;
2172 {
2173 let mut cache = BOOTSTRAP_CACHE.write().await;
2174 *cache = None;
2175 }
2176 assert!(!BOOTSTRAP_LOAD_IN_FLIGHT.load(Ordering::SeqCst));
2177
2178 let notified = BOOTSTRAP_LOAD_NOTIFY.notified();
2182 tokio::pin!(notified);
2183 let result =
2184 tokio::time::timeout(Duration::from_secs(2), wait_for_in_flight_load(notified))
2185 .await
2186 .expect("must not block when no load is in flight");
2187 assert!(
2188 matches!(result, Err(SeerError::RdapBootstrapError(ref s)) if s.contains("throttled")),
2189 "got: {result:?}"
2190 );
2191 }
2192
2193 #[tokio::test]
2194 async fn wait_blocks_until_in_flight_load_finishes() {
2195 let _guard = BOOTSTRAP_TEST_LOCK.lock().await;
2196 {
2197 let mut cache = BOOTSTRAP_CACHE.write().await;
2198 *cache = None;
2199 }
2200
2201 let load = BootstrapLoadGuard::start();
2202 let notified = BOOTSTRAP_LOAD_NOTIFY.notified();
2203 tokio::pin!(notified);
2204
2205 let loader = tokio::spawn(async move {
2206 tokio::time::sleep(Duration::from_millis(50)).await;
2207 {
2208 let mut cache = BOOTSTRAP_CACHE.write().await;
2209 *cache = Some(CachedBootstrap::new(full_load().merge_over(None)));
2210 }
2211 drop(load);
2213 });
2214
2215 let result =
2216 tokio::time::timeout(Duration::from_secs(5), wait_for_in_flight_load(notified))
2217 .await
2218 .expect("waiter must wake via the loader's notify");
2219 assert!(result.is_ok(), "got: {result:?}");
2220 loader.await.expect("loader joined");
2221 assert!(!BOOTSTRAP_LOAD_IN_FLIGHT.load(Ordering::SeqCst));
2222
2223 let mut cache = BOOTSTRAP_CACHE.write().await;
2224 *cache = None;
2225 }
2226}