Skip to main content

seer_core/rdap/
client.rs

1use std::collections::HashMap;
2use std::net::{IpAddr, SocketAddr};
3use std::sync::Arc;
4use std::time::{Duration, Instant};
5
6use futures::StreamExt;
7use once_cell::sync::Lazy;
8use reqwest::Client;
9use serde::Deserialize;
10use tokio::sync::{Notify, RwLock};
11use tracing::{debug, info, instrument, warn};
12
13use super::bootstrap::{
14    ipv4_matches_prefix, ipv6_matches_prefix, parse_asn_range, validate_bootstrap_url,
15};
16use super::types::RdapResponse;
17use crate::error::{Result, SeerError};
18use crate::retry::{NetworkRetryClassifier, RetryClassifier, RetryExecutor, RetryPolicy};
19use crate::validation::{describe_reserved_ip, normalize_domain};
20
21const IANA_BOOTSTRAP_DNS: &str = "https://data.iana.org/rdap/dns.json";
22const IANA_BOOTSTRAP_IPV4: &str = "https://data.iana.org/rdap/ipv4.json";
23const IANA_BOOTSTRAP_IPV6: &str = "https://data.iana.org/rdap/ipv6.json";
24const IANA_BOOTSTRAP_ASN: &str = "https://data.iana.org/rdap/asn.json";
25
26/// Default timeout for RDAP queries (15 seconds).
27/// With the 5s connect_timeout, this gives 10s for the server to respond.
28/// Most RDAP servers respond within 2-5 seconds; slow ccTLD registries
29/// may need the full 15s.
30const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15);
31
32/// Connect timeout — fail fast when a host is unreachable rather than
33/// waiting the full request timeout on a TCP handshake that will never complete.
34const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
35
36/// TTL for bootstrap data (24 hours)
37const BOOTSTRAP_TTL: Duration = Duration::from_secs(24 * 60 * 60);
38
39/// Minimum interval between bootstrap refresh attempts when the cache is
40/// expired-but-present or empty. Prevents a thundering herd of concurrent
41/// callers from all hammering IANA simultaneously during an outage.
42const BOOTSTRAP_REFRESH_MIN_INTERVAL: Duration = Duration::from_secs(60);
43
44/// Shared HTTP client for bootstrap fetches against IANA.
45/// The bootstrap targets are hardcoded data.iana.org URLs, so this client
46/// does not need DNS-rebinding protection. Per-query RDAP requests build
47/// their own short-lived client that pins resolved IPs.
48///
49/// Wrapped in `Option` so a reqwest builder failure surfaces as a typed
50/// `SeerError::HttpError` via `rdap_http_client()` instead of a process
51/// panic at first use (library code must not `.expect()` on shared state).
52static RDAP_HTTP_CLIENT: Lazy<Option<Client>> = Lazy::new(|| {
53    Client::builder()
54        .timeout(DEFAULT_TIMEOUT)
55        .connect_timeout(CONNECT_TIMEOUT)
56        .user_agent("Seer/1.0 (RDAP Client)")
57        .pool_max_idle_per_host(10)
58        // Bootstrap targets are hardcoded https://data.iana.org URLs that return
59        // terminal JSON; disable redirect-following for defense in depth so a
60        // compromised/MITM'd hop can't bounce the fetch to an internal address.
61        .redirect(reqwest::redirect::Policy::none())
62        .build()
63        .ok()
64});
65
66/// Returns a reference to the shared RDAP bootstrap HTTP client, or a typed
67/// error if the builder failed at initialization time. Call sites use
68/// `rdap_http_client()?` instead of dereferencing the static directly.
69fn rdap_http_client() -> Result<&'static Client> {
70    RDAP_HTTP_CLIENT
71        .as_ref()
72        .ok_or_else(|| SeerError::HttpError("failed to initialize HTTP client".into()))
73}
74
75/// Bootstrap cache with TTL support
76static BOOTSTRAP_CACHE: Lazy<RwLock<Option<CachedBootstrap>>> = Lazy::new(|| RwLock::new(None));
77
78/// Timestamp of the most recent bootstrap refresh attempt (success or failure).
79/// Used together with `BOOTSTRAP_REFRESH_MIN_INTERVAL` to throttle retry
80/// storms when IANA is unreachable.
81static BOOTSTRAP_LAST_ATTEMPT: Lazy<RwLock<Option<Instant>>> = Lazy::new(|| RwLock::new(None));
82
83/// Notifies waiters when an in-flight bootstrap load completes (success or
84/// failure). Solves the first-boot thundering-herd race where two concurrent
85/// cold-cache callers would otherwise see: caller A records its attempt
86/// timestamp, then caller B checks the timestamp and finds it "too recent"
87/// and returns a spurious `throttled and no cache available` error while A
88/// is still actively loading. Losers instead wait on this notify with a
89/// bounded timeout, then re-check the cache.
90static BOOTSTRAP_LOAD_NOTIFY: Lazy<Notify> = Lazy::new(Notify::new);
91
92/// Cached bootstrap data with timestamp for TTL tracking
93struct CachedBootstrap {
94    data: BootstrapData,
95    loaded_at: Instant,
96}
97
98impl CachedBootstrap {
99    fn new(data: BootstrapData) -> Self {
100        Self {
101            data,
102            loaded_at: Instant::now(),
103        }
104    }
105
106    fn is_expired(&self) -> bool {
107        self.loaded_at.elapsed() > BOOTSTRAP_TTL
108    }
109
110    fn age(&self) -> Duration {
111        self.loaded_at.elapsed()
112    }
113}
114
115/// Parsed IANA bootstrap data.
116/// Each TLD/prefix/ASN range is associated with an ordered list of
117/// candidate RDAP base URLs (IANA may list multiple per RFC 9224). Callers
118/// try them in order and fall back on failure.
119struct BootstrapData {
120    dns: HashMap<String, Arc<Vec<url::Url>>>,
121    ipv4: Vec<(IpRange, Arc<Vec<url::Url>>)>,
122    ipv6: Vec<(IpRange, Arc<Vec<url::Url>>)>,
123    asn: Vec<(AsnRange, Arc<Vec<url::Url>>)>,
124}
125
126#[derive(Clone)]
127struct IpRange {
128    prefix: String,
129}
130
131#[derive(Clone)]
132struct AsnRange {
133    start: u32,
134    end: u32,
135}
136
137#[derive(Deserialize)]
138struct BootstrapResponse {
139    services: Vec<Vec<serde_json::Value>>,
140}
141
142/// Waits (bounded) for an in-flight bootstrap load to complete, then
143/// re-checks the cache. Used by losers of the throttle race so a concurrent
144/// cold-cache caller doesn't spuriously error with "throttled and no cache
145/// available" while the winner is still loading.
146///
147/// The `notified` future must be created BEFORE the caller observes the
148/// throttle condition — otherwise `notify_waiters()` could fire in the gap
149/// between observing "still throttled, empty cache" and subscribing, and
150/// this call would then block until timeout.
151async fn wait_for_in_flight_load(
152    notified: std::pin::Pin<&mut tokio::sync::futures::Notified<'_>>,
153) -> Result<()> {
154    // Bounded wait so we don't block forever if the winner's future was
155    // cancelled/dropped before it could notify.
156    let _ = tokio::time::timeout(DEFAULT_TIMEOUT, notified).await;
157    let cache = BOOTSTRAP_CACHE.read().await;
158    if cache.is_some() {
159        Ok(())
160    } else {
161        Err(SeerError::RdapBootstrapError(
162            "bootstrap refresh throttled and no cache available".to_string(),
163        ))
164    }
165}
166
167#[derive(Debug, Clone)]
168pub struct RdapClient {
169    retry_policy: RetryPolicy,
170    /// Per-request timeout for RDAP queries (default [`DEFAULT_TIMEOUT`]).
171    timeout: Duration,
172    /// When true, skips the reserved-IP SSRF validation so tests can target a
173    /// 127.0.0.1 wiremock fixture. Not settable outside `#[cfg(test)]` builds
174    /// — production requests always validate and pin resolved IPs.
175    allow_reserved: bool,
176}
177
178impl Default for RdapClient {
179    fn default() -> Self {
180        Self::new()
181    }
182}
183
184impl RdapClient {
185    /// Creates a new RDAP client with default settings.
186    pub fn new() -> Self {
187        Self {
188            // RDAP registries rate-limit hard. Give 429s a few jittered,
189            // server-hint-aware retries to clear a *brief* limit, but keep the
190            // total bounded (≈10s worst case) so a sticky rate limit falls
191            // through to the WHOIS/DNS fallback fast instead of hanging an
192            // interactive lookup. The old 2× 100ms never cleared a real limit;
193            // a multi-attempt 30s honor was the opposite mistake.
194            retry_policy: RetryPolicy::new()
195                .with_max_attempts(3)
196                .with_initial_delay(Duration::from_millis(500))
197                .with_max_delay(Duration::from_secs(5)),
198            timeout: DEFAULT_TIMEOUT,
199            allow_reserved: false,
200        }
201    }
202
203    /// Sets the per-request timeout for RDAP queries.
204    pub fn with_timeout(mut self, timeout: Duration) -> Self {
205        self.timeout = timeout;
206        self
207    }
208
209    /// Test-only: allow requests to loopback/reserved addresses (mock servers).
210    #[cfg(test)]
211    pub(crate) fn allowing_reserved_for_tests(mut self) -> Self {
212        self.allow_reserved = true;
213        self
214    }
215
216    /// Sets the retry policy for transient network failures.
217    ///
218    /// The default policy retries up to 2 times with exponential backoff.
219    pub fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
220        self.retry_policy = policy;
221        self
222    }
223
224    /// Disables retries (single attempt only).
225    pub fn without_retries(mut self) -> Self {
226        self.retry_policy = RetryPolicy::no_retry();
227        self
228    }
229
230    /// Ensures bootstrap data is loaded and not expired.
231    ///
232    /// Uses stale-while-revalidate: if refresh fails, stale data is used.
233    /// Performs the actual network load WITHOUT holding the write lock, so
234    /// concurrent readers are never blocked by an in-flight HTTP request
235    /// (fix for the previous deadlock/await-under-lock hazard).
236    ///
237    /// Refresh attempts are also throttled to at most one per
238    /// `BOOTSTRAP_REFRESH_MIN_INTERVAL` to avoid thundering-herd storms
239    /// against IANA when bootstrap is down.
240    ///
241    /// Concurrent cold-cache callers coordinate via `BOOTSTRAP_LOAD_NOTIFY`:
242    /// losers of the throttle race wait (with a bounded timeout) for the
243    /// winner's load instead of erroring out immediately.
244    async fn ensure_bootstrap(&self) -> Result<()> {
245        // Fast path: read-lock and return if fresh.
246        {
247            let cache = BOOTSTRAP_CACHE.read().await;
248            if let Some(cached) = cache.as_ref() {
249                if !cached.is_expired() {
250                    return Ok(());
251                }
252            }
253        }
254
255        // Register a notify subscription BEFORE we check the throttle gate,
256        // so a `notify_waiters()` from the winner can't slip between our
257        // "still throttled, empty cache" check and our `.notified().await`.
258        // `Notify::notified()` holds the permit slot the moment it's
259        // constructed; only `.await` blocks.
260        let notified = BOOTSTRAP_LOAD_NOTIFY.notified();
261        tokio::pin!(notified);
262
263        // Throttle refresh attempts. If another caller tried very recently,
264        // either return stale data we already have, or wait for their load
265        // to complete rather than erroring with "throttled and no cache".
266        {
267            let last = BOOTSTRAP_LAST_ATTEMPT.read().await;
268            if let Some(ts) = *last {
269                if ts.elapsed() < BOOTSTRAP_REFRESH_MIN_INTERVAL {
270                    // Another caller attempted a refresh very recently.
271                    let cache = BOOTSTRAP_CACHE.read().await;
272                    if cache.is_some() {
273                        // We have some data (possibly stale) — accept it.
274                        return Ok(());
275                    }
276                    // Cache is empty AND another task is mid-load (or just
277                    // failed). Wait for them instead of returning an error.
278                    drop(cache);
279                    drop(last);
280                    return wait_for_in_flight_load(notified).await;
281                }
282            }
283        }
284
285        // Record the attempt timestamp before we begin the network load.
286        // Holding this lock is cheap (no await in between read+write here).
287        {
288            let mut last = BOOTSTRAP_LAST_ATTEMPT.write().await;
289            // Double-check in case another task just updated it.
290            if let Some(ts) = *last {
291                if ts.elapsed() < BOOTSTRAP_REFRESH_MIN_INTERVAL {
292                    drop(last);
293                    let cache = BOOTSTRAP_CACHE.read().await;
294                    if cache.is_some() {
295                        return Ok(());
296                    }
297                    drop(cache);
298                    return wait_for_in_flight_load(notified).await;
299                }
300            }
301            *last = Some(Instant::now());
302        }
303
304        // Perform the actual load WITHOUT holding any cache lock. Whichever
305        // branch exits, we must notify waiters so losers don't hang for the
306        // full bounded timeout.
307        debug!("Loading/refreshing RDAP bootstrap data");
308        let load_result = load_bootstrap_data_with_retry(&self.retry_policy).await;
309
310        let outcome = match load_result {
311            Ok(data) => {
312                let mut cache = BOOTSTRAP_CACHE.write().await;
313                // Double-check: another task may have loaded while we ran.
314                // Only overwrite if the current cache is missing or expired.
315                let should_store = cache.as_ref().map(|c| c.is_expired()).unwrap_or(true);
316                if should_store {
317                    *cache = Some(CachedBootstrap::new(data));
318                }
319                Ok(())
320            }
321            Err(e) => {
322                // Stale-while-revalidate: keep using any existing stale cache.
323                let cache = BOOTSTRAP_CACHE.read().await;
324                if let Some(cached) = cache.as_ref() {
325                    debug!(
326                        error = %e,
327                        age_hours = cached.age().as_secs() / 3600,
328                        "Bootstrap refresh failed, using stale data"
329                    );
330                    Ok(())
331                } else {
332                    // No stale data available.
333                    Err(e)
334                }
335            }
336        };
337
338        // Wake any losers waiting on our load. Safe to call in both branches.
339        BOOTSTRAP_LOAD_NOTIFY.notify_waiters();
340        outcome
341    }
342
343    /// Looks up the candidate RDAP base URLs for a domain's TLD.
344    fn get_rdap_urls_for_domain(cache: &BootstrapData, domain: &str) -> Option<Arc<Vec<url::Url>>> {
345        let tld = domain.rsplit('.').next()?;
346        cache.dns.get(&tld.to_lowercase()).cloned()
347    }
348
349    /// Looks up the candidate RDAP base URLs for an IP address.
350    fn get_rdap_urls_for_ip(cache: &BootstrapData, ip: &IpAddr) -> Option<Arc<Vec<url::Url>>> {
351        match ip {
352            IpAddr::V4(addr) => {
353                for (range, urls) in &cache.ipv4 {
354                    if ipv4_matches_prefix(&range.prefix, addr) {
355                        return Some(Arc::clone(urls));
356                    }
357                }
358            }
359            IpAddr::V6(addr) => {
360                for (range, urls) in &cache.ipv6 {
361                    if ipv6_matches_prefix(&range.prefix, addr) {
362                        return Some(Arc::clone(urls));
363                    }
364                }
365            }
366        }
367
368        None
369    }
370
371    /// Looks up the candidate RDAP base URLs for an ASN.
372    fn get_rdap_urls_for_asn(cache: &BootstrapData, asn: u32) -> Option<Arc<Vec<url::Url>>> {
373        for (range, urls) in &cache.asn {
374            if asn >= range.start && asn <= range.end {
375                return Some(Arc::clone(urls));
376            }
377        }
378
379        None
380    }
381
382    /// Looks up RDAP registration data for a domain.
383    ///
384    /// Uses IANA bootstrap data to find the appropriate RDAP server for the TLD.
385    #[instrument(skip(self), fields(domain = %domain))]
386    pub async fn lookup_domain(&self, domain: &str) -> Result<RdapResponse> {
387        self.ensure_bootstrap().await?;
388
389        let domain = normalize_domain(domain)?;
390
391        // Extract candidate URLs while holding the lock, then release before HTTP requests.
392        let urls = {
393            let cache_guard = BOOTSTRAP_CACHE.read().await;
394            let cache = cache_guard.as_ref().ok_or_else(|| {
395                SeerError::RdapBootstrapError("bootstrap data not loaded".to_string())
396            })?;
397
398            let bases = Self::get_rdap_urls_for_domain(&cache.data, &domain).ok_or_else(|| {
399                SeerError::RdapBootstrapError(format!("no RDAP server for {}", domain))
400            })?;
401
402            build_rdap_urls(&bases, &format!("domain/{}", domain))
403        }; // Lock released here
404
405        self.query_rdap_urls(&urls).await
406    }
407
408    /// Looks up RDAP registration data for an IP address.
409    ///
410    /// Uses IANA bootstrap data to find the appropriate RIR (Regional Internet Registry).
411    #[instrument(skip(self), fields(ip = %ip))]
412    pub async fn lookup_ip(&self, ip: &str) -> Result<RdapResponse> {
413        self.ensure_bootstrap().await?;
414
415        let ip_addr: IpAddr = ip
416            .parse()
417            .map_err(|_| SeerError::InvalidIpAddress(ip.to_string()))?;
418
419        let urls = {
420            let cache_guard = BOOTSTRAP_CACHE.read().await;
421            let cache = cache_guard.as_ref().ok_or_else(|| {
422                SeerError::RdapBootstrapError("bootstrap data not loaded".to_string())
423            })?;
424
425            let bases = Self::get_rdap_urls_for_ip(&cache.data, &ip_addr).ok_or_else(|| {
426                SeerError::RdapBootstrapError(format!("no RDAP server for {}", ip))
427            })?;
428
429            build_rdap_urls(&bases, &format!("ip/{}", ip))
430        };
431
432        self.query_rdap_urls(&urls).await
433    }
434
435    /// Looks up RDAP registration data for an Autonomous System Number (ASN).
436    ///
437    /// Uses IANA bootstrap data to find the appropriate RIR for the ASN range.
438    #[instrument(skip(self), fields(asn = %asn))]
439    pub async fn lookup_asn(&self, asn: u32) -> Result<RdapResponse> {
440        self.ensure_bootstrap().await?;
441
442        let urls = {
443            let cache_guard = BOOTSTRAP_CACHE.read().await;
444            let cache = cache_guard.as_ref().ok_or_else(|| {
445                SeerError::RdapBootstrapError("bootstrap data not loaded".to_string())
446            })?;
447
448            let bases = Self::get_rdap_urls_for_asn(&cache.data, asn).ok_or_else(|| {
449                SeerError::RdapBootstrapError(format!("no RDAP server for AS{}", asn))
450            })?;
451
452            build_rdap_urls(&bases, &format!("autnum/{}", asn))
453        };
454
455        self.query_rdap_urls(&urls).await
456    }
457
458    /// Returns the RDAP base URL for a given TLD, if known from bootstrap data.
459    ///
460    /// Loads bootstrap data if not already cached. Returns `None` if the TLD
461    /// has no registered RDAP server in the IANA bootstrap registry. When
462    /// IANA lists multiple URLs for a TLD, the first one is returned.
463    #[instrument(skip(self), fields(tld = %tld))]
464    pub async fn get_rdap_base_url_for_tld(&self, tld: &str) -> Option<String> {
465        if self.ensure_bootstrap().await.is_err() {
466            return None;
467        }
468
469        let cache_guard = BOOTSTRAP_CACHE.read().await;
470        let cache = cache_guard.as_ref()?;
471        // IANA bootstrap keys are A-labels (punycode); convert a Unicode TLD
472        // (e.g. "рф" -> "xn--p1ai") so it matches. ASCII TLDs are unchanged;
473        // an un-convertible value falls back to the lowercased input.
474        let lower = tld.to_lowercase();
475        let key = crate::validation::domain_to_ascii(&lower).unwrap_or(lower);
476        cache
477            .data
478            .dns
479            .get(&key)
480            .and_then(|urls| urls.first())
481            .map(|u| u.to_string())
482    }
483
484    /// Queries a list of candidate RDAP URLs in order, returning the first
485    /// successful response. Each URL is attempted with the full retry policy.
486    /// If all candidates fail, the last error is returned wrapped with context.
487    async fn query_rdap_urls(&self, urls: &[url::Url]) -> Result<RdapResponse> {
488        if urls.is_empty() {
489            return Err(SeerError::RdapError(
490                "no candidate RDAP URLs available".to_string(),
491            ));
492        }
493
494        let mut last_error: Option<SeerError> = None;
495        // A definitive 404 from any candidate is the strongest available-ness
496        // signal there is. Preserve the first one we see so a later candidate's
497        // non-404 failure (timeout/5xx/conn) can't bury it — otherwise
498        // `rdap_error_is_404` would return false and a genuinely available
499        // domain would be misreported as inconclusive.
500        let mut not_found_error: Option<SeerError> = None;
501        for (idx, url) in urls.iter().enumerate() {
502            let url_str = url.as_str().to_string();
503            debug!(url = %url_str, candidate = idx + 1, total = urls.len(), "Querying RDAP");
504            match self.query_rdap_with_retry(&url_str).await {
505                Ok(resp) => return Ok(resp),
506                Err(e) => {
507                    if urls.len() > 1 {
508                        debug!(
509                            url = %url_str,
510                            error = %e,
511                            candidate = idx + 1,
512                            total = urls.len(),
513                            "RDAP candidate failed, trying next",
514                        );
515                    }
516                    if not_found_error.is_none() && crate::rdap::rdap_error_is_404(&e) {
517                        not_found_error = Some(e);
518                    } else {
519                        last_error = Some(e);
520                    }
521                }
522            }
523        }
524
525        // All candidates failed. Prefer a preserved 404 over a later non-404
526        // failure so the authoritative not-found signal reaches the caller.
527        Err(wrap_all_candidates_failed(
528            not_found_error.or(last_error),
529            urls.len(),
530        ))
531    }
532
533    /// Queries a single RDAP endpoint, retrying transient failures. Unlike the
534    /// generic `RetryExecutor`, this honors a `Retry-After` header on HTTP 429
535    /// responses — registries rate-limit aggressively, and the server-suggested
536    /// delay clears the limit far more reliably than blind exponential backoff.
537    async fn query_rdap_with_retry(&self, url: &str) -> Result<RdapResponse> {
538        let classifier = NetworkRetryClassifier::new();
539        let mut attempt = 0;
540        loop {
541            match query_rdap_attempt(url, self.timeout, self.allow_reserved).await {
542                Ok(resp) => return Ok(resp),
543                Err((err, retry_after)) => {
544                    let attempts_remaining =
545                        self.retry_policy.max_attempts.saturating_sub(attempt + 1);
546                    if !classifier.is_retryable(&err) || attempts_remaining == 0 {
547                        return Err(if attempt > 0 {
548                            SeerError::RetryExhausted {
549                                attempts: attempt + 1,
550                                last_error: Box::new(err),
551                            }
552                        } else {
553                            err
554                        });
555                    }
556                    let backoff = self.retry_policy.delay_for_attempt(attempt);
557                    let delay = effective_retry_delay(backoff, retry_after);
558                    debug!(
559                        url = %url,
560                        attempt = attempt + 1,
561                        max_attempts = self.retry_policy.max_attempts,
562                        delay_ms = delay.as_millis(),
563                        error = %err,
564                        "Retrying RDAP after transient error"
565                    );
566                    tokio::time::sleep(delay).await;
567                    attempt += 1;
568                }
569            }
570        }
571    }
572}
573
574/// Maximum RDAP response body size (10 MB, matching CT log response limit).
575const MAX_RDAP_RESPONSE_SIZE: usize = 10 * 1024 * 1024;
576
577/// Cap on how long we'll honor a server-supplied `Retry-After`. Real RDAP
578/// 429s ask for a second or two; anything larger we treat as "give up and
579/// fall back to WHOIS/DNS" rather than hang an interactive lookup — and the
580/// cap also stops a hostile/misconfigured header from pinning the client.
581const MAX_RETRY_AFTER: Duration = Duration::from_secs(5);
582
583/// Validates that a URL does not resolve to a reserved/private IP address (SSRF protection).
584///
585/// Returns the full list of resolved `SocketAddr`s so the caller can pin them on a
586/// per-request HTTP client via `resolve_to_addrs`. Pinning prevents a DNS rebinding
587/// TOCTOU where the hostname could resolve to a different (private) address between
588/// validation here and the actual HTTP connect.
589async fn validate_url_not_reserved(url: &str) -> Result<Vec<SocketAddr>> {
590    let parsed = url::Url::parse(url)
591        .map_err(|e| SeerError::RdapError(format!("invalid URL '{}': {}", url, e)))?;
592    // Defense-in-depth: RDAP is HTTPS-only. Enforce the scheme at fetch time so
593    // this guard does not silently depend on the bootstrap's parse-time check.
594    // A plaintext `http://` (downgrade) or any non-https URL — including a
595    // server-supplied link or redirect target ever fed in — must never be
596    // fetched, even when the host resolves to a public address.
597    if parsed.scheme() != "https" {
598        return Err(SeerError::RdapError(format!(
599            "RDAP URL '{}' is not https — request blocked (downgrade/SSRF protection)",
600            url
601        )));
602    }
603    let host = parsed
604        .host_str()
605        .ok_or_else(|| SeerError::RdapError(format!("URL '{}' has no host", url)))?;
606    let port = parsed.port_or_known_default().unwrap_or(443);
607
608    // If the host is already an IP literal, check it directly.
609    if let Ok(ip) = host.parse::<IpAddr>() {
610        if let Some(reason) = describe_reserved_ip(&ip) {
611            return Err(SeerError::RdapError(format!(
612                "RDAP URL resolves to reserved IP {}: {} — request blocked (SSRF protection)",
613                ip, reason
614            )));
615        }
616        return Ok(vec![SocketAddr::new(ip, port)]);
617    }
618
619    let addr = format!("{}:{}", host, port);
620
621    let socket_addrs: Vec<SocketAddr> = tokio::net::lookup_host(&addr)
622        .await
623        .map_err(|e| SeerError::RdapError(format!("failed to resolve host '{}': {}", host, e)))?
624        .collect();
625
626    if socket_addrs.is_empty() {
627        return Err(SeerError::RdapError(format!(
628            "host '{}' resolved to no addresses",
629            host
630        )));
631    }
632
633    for socket_addr in &socket_addrs {
634        if let Some(reason) = describe_reserved_ip(&socket_addr.ip()) {
635            return Err(SeerError::RdapError(format!(
636                "RDAP URL resolves to reserved IP {}: {} — request blocked (SSRF protection)",
637                socket_addr.ip(),
638                reason
639            )));
640        }
641    }
642
643    Ok(socket_addrs)
644}
645
646/// Parses an HTTP `Retry-After` header value. Supports the common
647/// delta-seconds form (`Retry-After: 5`); the HTTP-date form is not used by
648/// RDAP rate limiters in practice and yields `None` (caller falls back to
649/// exponential backoff).
650fn parse_retry_after(value: &str) -> Option<Duration> {
651    value.trim().parse::<u64>().ok().map(Duration::from_secs)
652}
653
654/// Chooses the delay before the next RDAP attempt: honor the server's
655/// `Retry-After` (capped at [`MAX_RETRY_AFTER`]) when present, otherwise use
656/// the policy's exponential backoff.
657fn effective_retry_delay(backoff: Duration, retry_after: Option<Duration>) -> Duration {
658    match retry_after {
659        Some(hint) => hint.min(MAX_RETRY_AFTER),
660        None => backoff,
661    }
662}
663
664/// Sends one RDAP request: SSRF-validates the URL and pins the resolved IPs on
665/// a short-lived client (DNS-rebinding defense), returning the raw response.
666///
667/// `allow_reserved` (test seam, see [`RdapClient::allow_reserved`]) skips the
668/// validation and IP pinning so `#[cfg(test)]` mock servers on loopback are
669/// reachable; it is always false on production paths.
670async fn send_rdap_request(
671    url: &str,
672    timeout: Duration,
673    allow_reserved: bool,
674) -> Result<reqwest::Response> {
675    // Keep the connect timeout no larger than the overall request timeout so a
676    // sub-5s configured timeout stays internally consistent.
677    let connect_timeout = CONNECT_TIMEOUT.min(timeout);
678    if allow_reserved {
679        let client = Client::builder()
680            .timeout(timeout)
681            .connect_timeout(connect_timeout)
682            .user_agent("Seer/1.0 (RDAP Client)")
683            .redirect(reqwest::redirect::Policy::none())
684            .build()
685            .map_err(|e| SeerError::RdapError(format!("failed to build HTTP client: {}", e)))?;
686        return client
687            .get(url)
688            .header("Accept", "application/rdap+json")
689            .send()
690            .await
691            .map_err(Into::into);
692    }
693
694    // SSRF protection: validate the URL does not resolve to reserved IPs and
695    // capture the resolved SocketAddrs so we can pin them on the HTTP client.
696    let resolved = validate_url_not_reserved(url).await?;
697
698    let parsed = url::Url::parse(url)
699        .map_err(|e| SeerError::RdapError(format!("invalid URL '{}': {}", url, e)))?;
700    let host = parsed
701        .host_str()
702        .ok_or_else(|| SeerError::RdapError(format!("URL '{}' has no host", url)))?;
703
704    // Build a short-lived client pinning the validated IPs. If the host was
705    // an IP literal the resolved vec already holds it, so `resolve_to_addrs`
706    // is still correct.
707    let client = Client::builder()
708        .timeout(timeout)
709        .connect_timeout(connect_timeout)
710        .user_agent("Seer/1.0 (RDAP Client)")
711        .resolve_to_addrs(host, &resolved)
712        // SSRF defense: `resolve_to_addrs` pins only THIS host's validated IPs.
713        // reqwest's default policy would follow up to 10 redirects, re-resolving
714        // each new host with its own resolver — so a 3xx to http://169.254.169.254
715        // (or any internal host) would bypass the reserved-IP guard entirely.
716        // RDAP base URLs come from the IANA bootstrap as terminal https endpoints
717        // and cross-server references are JSON `links`, not HTTP redirects, so we
718        // fail closed: a redirecting RDAP server makes the lookup fall through to
719        // WHOIS/availability rather than chasing an unvalidated hop.
720        .redirect(reqwest::redirect::Policy::none())
721        .build()
722        .map_err(|e| SeerError::RdapError(format!("failed to build HTTP client: {}", e)))?;
723
724    client
725        .get(url)
726        .header("Accept", "application/rdap+json")
727        .send()
728        .await
729        .map_err(Into::into)
730}
731
732/// Streams, size-bounds, and parses an RDAP response body. `url` is only used
733/// for the timeout error message. `timeout` is the per-request deadline for the
734/// body-read phase, so a caller-configured timeout is honored end-to-end (not
735/// silently capped at the hardcoded default).
736async fn read_and_parse_rdap_body(
737    response: reqwest::Response,
738    url: &str,
739    timeout: Duration,
740) -> Result<RdapResponse> {
741    // Stream body with incremental size check to prevent memory exhaustion.
742    // Wrap the chunk loop in a timeout so a server that opens the connection
743    // but trickles bytes forever is classified as a timeout (not a generic
744    // RdapError) and retries can be driven appropriately.
745    let mut body = Vec::new();
746    let mut stream = response.bytes_stream();
747    let streamed = tokio::time::timeout(timeout, async {
748        while let Some(chunk) = stream.next().await {
749            let chunk = chunk
750                .map_err(|e| SeerError::RdapError(format!("failed to read response: {}", e)))?;
751            body.extend_from_slice(&chunk);
752            if body.len() > MAX_RDAP_RESPONSE_SIZE {
753                return Err(SeerError::RdapError(format!(
754                    "RDAP response exceeds {} byte limit",
755                    MAX_RDAP_RESPONSE_SIZE
756                )));
757            }
758        }
759        Ok::<(), SeerError>(())
760    })
761    .await;
762
763    match streamed {
764        Ok(Ok(())) => {}
765        Ok(Err(e)) => return Err(e),
766        Err(_) => {
767            return Err(SeerError::Timeout(format!(
768                "timed out reading RDAP response body from {} after {:?}",
769                url, timeout
770            )));
771        }
772    }
773
774    let rdap: RdapResponse = serde_json::from_slice(&body)?;
775    // Bound attacker-controlled payload post-deserialization. The 10MB
776    // body cap prevents unbounded download, but a well-formed response
777    // can still pack millions of keys or deeply-nested values into the
778    // serde_json::Map, and adversarial `entities` nesting can drive
779    // recursive walkers to stack-overflow. See RdapResponse::validate.
780    rdap.validate()?;
781    Ok(rdap)
782}
783
784/// One RDAP attempt. On failure, returns the error together with an optional
785/// server-suggested retry delay parsed from a 429 `Retry-After` header so the
786/// caller's backoff can honor it. Builds a per-request HTTP client that pins
787/// the validated resolved IPs to prevent DNS rebinding (TOCTOU between
788/// validation and connect).
789async fn query_rdap_attempt(
790    url: &str,
791    timeout: Duration,
792    allow_reserved: bool,
793) -> std::result::Result<RdapResponse, (SeerError, Option<Duration>)> {
794    let response = send_rdap_request(url, timeout, allow_reserved)
795        .await
796        .map_err(|e| (e, None))?;
797
798    if !response.status().is_success() {
799        let status = response.status();
800        // A 429 may carry a `Retry-After`; surface it so the retry loop can
801        // wait exactly as long as the registry asks instead of guessing.
802        let retry_after = if status.as_u16() == 429 {
803            response
804                .headers()
805                .get(reqwest::header::RETRY_AFTER)
806                .and_then(|v| v.to_str().ok())
807                .and_then(parse_retry_after)
808        } else {
809            None
810        };
811        return Err((
812            SeerError::RdapError(format!("query failed with status {}", status)),
813            retry_after,
814        ));
815    }
816
817    read_and_parse_rdap_body(response, url, timeout)
818        .await
819        .map_err(|e| (e, None))
820}
821
822/// Loads IANA RDAP bootstrap data from all registries with retry.
823async fn load_bootstrap_data_with_retry(policy: &RetryPolicy) -> Result<BootstrapData> {
824    let executor = RetryExecutor::new(policy.clone());
825    executor.execute(load_bootstrap_data).await
826}
827
828/// Loads IANA RDAP bootstrap data from all registries.
829async fn load_bootstrap_data() -> Result<BootstrapData> {
830    debug!("Loading RDAP bootstrap data from IANA");
831
832    // SSRF validation is skipped here — these are hardcoded IANA URLs, not user input.
833    // User-supplied URLs are still validated in send_rdap_request() via
834    // validate_url_not_reserved() (which also enforces the https scheme).
835
836    let http = rdap_http_client()?;
837
838    let dns_future = http.get(IANA_BOOTSTRAP_DNS).send();
839    let ipv4_future = http.get(IANA_BOOTSTRAP_IPV4).send();
840    let ipv6_future = http.get(IANA_BOOTSTRAP_IPV6).send();
841    let asn_future = http.get(IANA_BOOTSTRAP_ASN).send();
842
843    // Use join! instead of try_join! so one slow/failing registry doesn't
844    // block the others. We load whatever data is available.
845    let (dns_resp, ipv4_resp, ipv6_resp, asn_resp) =
846        tokio::join!(dns_future, ipv4_future, ipv6_future, asn_future);
847
848    // Stream body with incremental size check to prevent memory exhaustion
849    const MAX_BOOTSTRAP_SIZE: usize = 10 * 1024 * 1024; // 10 MB
850
851    async fn read_bootstrap(resp: reqwest::Response) -> Result<BootstrapResponse> {
852        // Bound the streaming-read loop with the same timeout used for RDAP
853        // queries. Without this, a slow or stalled IANA response (open TCP
854        // but no bytes arriving) could hang all RDAP lookups indefinitely
855        // because `ensure_bootstrap` awaits this future. Mirrors the pattern
856        // in `read_and_parse_rdap_body`.
857        let mut body = Vec::new();
858        let mut stream = resp.bytes_stream();
859        let streamed = tokio::time::timeout(DEFAULT_TIMEOUT, async {
860            while let Some(chunk) = stream.next().await {
861                let chunk = chunk.map_err(|e| {
862                    SeerError::RdapBootstrapError(format!("failed to read body: {}", e))
863                })?;
864                body.extend_from_slice(&chunk);
865                if body.len() > MAX_BOOTSTRAP_SIZE {
866                    return Err(SeerError::RdapBootstrapError(format!(
867                        "bootstrap response too large (exceeds {} bytes)",
868                        MAX_BOOTSTRAP_SIZE
869                    )));
870                }
871            }
872            Ok::<(), SeerError>(())
873        })
874        .await;
875
876        match streamed {
877            Ok(Ok(())) => {}
878            Ok(Err(e)) => return Err(e),
879            Err(_) => {
880                return Err(SeerError::Timeout(format!(
881                    "RDAP bootstrap body read timed out after {:?}",
882                    DEFAULT_TIMEOUT
883                )));
884            }
885        }
886
887        serde_json::from_slice(&body).map_err(Into::into)
888    }
889
890    // Parse each response independently, logging failures
891    let dns_data = match dns_resp {
892        Ok(resp) => match read_bootstrap(resp).await {
893            Ok(data) => Some(data),
894            Err(e) => {
895                warn!(error = %e, "Failed to parse DNS bootstrap response");
896                None
897            }
898        },
899        Err(e) => {
900            warn!(error = %e, "Failed to fetch DNS bootstrap from IANA");
901            None
902        }
903    };
904    let ipv4_data = match ipv4_resp {
905        Ok(resp) => match read_bootstrap(resp).await {
906            Ok(data) => Some(data),
907            Err(e) => {
908                warn!(error = %e, "Failed to parse IPv4 bootstrap response");
909                None
910            }
911        },
912        Err(e) => {
913            warn!(error = %e, "Failed to fetch IPv4 bootstrap from IANA");
914            None
915        }
916    };
917    let ipv6_data = match ipv6_resp {
918        Ok(resp) => match read_bootstrap(resp).await {
919            Ok(data) => Some(data),
920            Err(e) => {
921                warn!(error = %e, "Failed to parse IPv6 bootstrap response");
922                None
923            }
924        },
925        Err(e) => {
926            warn!(error = %e, "Failed to fetch IPv6 bootstrap from IANA");
927            None
928        }
929    };
930    let asn_data = match asn_resp {
931        Ok(resp) => match read_bootstrap(resp).await {
932            Ok(data) => Some(data),
933            Err(e) => {
934                warn!(error = %e, "Failed to parse ASN bootstrap response");
935                None
936            }
937        },
938        Err(e) => {
939            warn!(error = %e, "Failed to fetch ASN bootstrap from IANA");
940            None
941        }
942    };
943
944    // If ALL four registries failed, that's a real error
945    if dns_data.is_none() && ipv4_data.is_none() && ipv6_data.is_none() && asn_data.is_none() {
946        return Err(SeerError::RdapBootstrapError(
947            "all IANA bootstrap registries failed".to_string(),
948        ));
949    }
950
951    let mut dns = HashMap::new();
952    let mut ipv4 = Vec::new();
953    let mut ipv6 = Vec::new();
954    let mut asn = Vec::new();
955
956    // Helper: extract and validate all URLs in order, preserving IANA-listed
957    // ordering. Invalid URLs are logged and skipped rather than rejecting the
958    // entire service entry. Returns None when no valid URLs remain.
959    fn collect_valid_urls(urls: &[serde_json::Value]) -> Option<Arc<Vec<url::Url>>> {
960        let mut out = Vec::new();
961        for u in urls {
962            if let Some(s) = u.as_str() {
963                match validate_bootstrap_url(s) {
964                    Ok(parsed) => out.push(parsed),
965                    Err(e) => {
966                        debug!(url = s, error = %e, "Skipping invalid bootstrap URL");
967                    }
968                }
969            }
970        }
971        if out.is_empty() {
972            None
973        } else {
974            Some(Arc::new(out))
975        }
976    }
977
978    // Parse DNS bootstrap
979    if let Some(dns_data) = dns_data {
980        for service in dns_data.services {
981            if service.len() >= 2 {
982                if let (Some(tlds), Some(urls)) = (service[0].as_array(), service[1].as_array()) {
983                    if let Some(urls_arc) = collect_valid_urls(urls) {
984                        for tld in tlds {
985                            if let Some(tld_str) = tld.as_str() {
986                                dns.insert(tld_str.to_lowercase(), Arc::clone(&urls_arc));
987                            }
988                        }
989                    }
990                }
991            }
992        }
993    }
994
995    // Parse IPv4 bootstrap
996    if let Some(ipv4_data) = ipv4_data {
997        for service in ipv4_data.services {
998            if service.len() >= 2 {
999                if let (Some(prefixes), Some(urls)) = (service[0].as_array(), service[1].as_array())
1000                {
1001                    if let Some(urls_arc) = collect_valid_urls(urls) {
1002                        for prefix in prefixes {
1003                            if let Some(prefix_str) = prefix.as_str() {
1004                                ipv4.push((
1005                                    IpRange {
1006                                        prefix: prefix_str.to_string(),
1007                                    },
1008                                    Arc::clone(&urls_arc),
1009                                ));
1010                            }
1011                        }
1012                    }
1013                }
1014            }
1015        }
1016    }
1017
1018    // Parse IPv6 bootstrap
1019    if let Some(ipv6_data) = ipv6_data {
1020        for service in ipv6_data.services {
1021            if service.len() >= 2 {
1022                if let (Some(prefixes), Some(urls)) = (service[0].as_array(), service[1].as_array())
1023                {
1024                    if let Some(urls_arc) = collect_valid_urls(urls) {
1025                        for prefix in prefixes {
1026                            if let Some(prefix_str) = prefix.as_str() {
1027                                ipv6.push((
1028                                    IpRange {
1029                                        prefix: prefix_str.to_string(),
1030                                    },
1031                                    Arc::clone(&urls_arc),
1032                                ));
1033                            }
1034                        }
1035                    }
1036                }
1037            }
1038        }
1039    }
1040
1041    // Parse ASN bootstrap
1042    if let Some(asn_data) = asn_data {
1043        for service in asn_data.services {
1044            if service.len() >= 2 {
1045                if let (Some(ranges), Some(urls)) = (service[0].as_array(), service[1].as_array()) {
1046                    if let Some(urls_arc) = collect_valid_urls(urls) {
1047                        for range in ranges {
1048                            if let Some(range_str) = range.as_str() {
1049                                if let Some((start, end)) = parse_asn_range(range_str) {
1050                                    asn.push((AsnRange { start, end }, Arc::clone(&urls_arc)));
1051                                }
1052                            }
1053                        }
1054                    }
1055                }
1056            }
1057        }
1058    }
1059
1060    info!(
1061        dns_entries = dns.len(),
1062        ipv4_ranges = ipv4.len(),
1063        ipv6_ranges = ipv6.len(),
1064        asn_ranges = asn.len(),
1065        "RDAP bootstrap loaded"
1066    );
1067
1068    Ok(BootstrapData {
1069        dns,
1070        ipv4,
1071        ipv6,
1072        asn,
1073    })
1074}
1075
1076/// Wraps the "all N candidate URLs failed" case for `query_rdap_urls`.
1077///
1078/// Preserves the `SeerError::Timeout` variant when the last failure was a
1079/// timeout, so upstream callers that branch on `Timeout` for retry-or-not
1080/// decisions can still do so. Non-timeout failures are wrapped in a generic
1081/// `RdapError` with the last error's Display in the message. The
1082/// single-candidate case returns the last error unchanged to avoid
1083/// double-wrapping.
1084fn wrap_all_candidates_failed(last_error: Option<SeerError>, candidate_count: usize) -> SeerError {
1085    let last = last_error.unwrap_or_else(|| SeerError::RdapError("no candidates".to_string()));
1086
1087    if candidate_count <= 1 {
1088        return last;
1089    }
1090
1091    match last {
1092        SeerError::Timeout(msg) => SeerError::Timeout(format!(
1093            "all {} RDAP candidate URLs timed out; last error: {}",
1094            candidate_count, msg
1095        )),
1096        other => SeerError::RdapError(format!(
1097            "all {} RDAP candidate URLs failed; last error: {}",
1098            candidate_count, other
1099        )),
1100    }
1101}
1102
1103/// Builds full RDAP query URLs for each candidate base URL, preserving order.
1104fn build_rdap_urls(bases: &[url::Url], path: &str) -> Vec<url::Url> {
1105    bases
1106        .iter()
1107        .filter_map(|base| {
1108            // Ensure the base URL ends with `/` before joining so the path is
1109            // appended (not replacing the final path segment).
1110            let base_str = base.as_str();
1111            let normalized = if base_str.ends_with('/') {
1112                base_str.to_string()
1113            } else {
1114                format!("{}/", base_str)
1115            };
1116            url::Url::parse(&normalized).and_then(|u| u.join(path)).ok()
1117        })
1118        .collect()
1119}
1120
1121#[cfg(test)]
1122mod tests {
1123    use super::*;
1124
1125    #[test]
1126    fn test_default_client_has_retry_policy() {
1127        let client = RdapClient::new();
1128        // Tuned up from 2 so 429 rate limits get a couple of backoff-and-retry
1129        // chances, but kept small so a sticky limit falls through to the
1130        // WHOIS/DNS fallback fast instead of hanging.
1131        assert_eq!(client.retry_policy.max_attempts, 3);
1132    }
1133
1134    // --- Retry-After parsing / delay selection (Fix #1) ------------------
1135
1136    #[test]
1137    fn parse_retry_after_parses_delta_seconds() {
1138        assert_eq!(parse_retry_after("5"), Some(Duration::from_secs(5)));
1139        assert_eq!(parse_retry_after("  10 "), Some(Duration::from_secs(10)));
1140        assert_eq!(parse_retry_after("0"), Some(Duration::from_secs(0)));
1141    }
1142
1143    #[test]
1144    fn parse_retry_after_rejects_http_date_and_junk() {
1145        // Only the delta-seconds form is supported; an HTTP-date or garbage
1146        // value yields None (caller falls back to exponential backoff).
1147        assert_eq!(parse_retry_after("Wed, 21 Oct 2015 07:28:00 GMT"), None);
1148        assert_eq!(parse_retry_after("soon"), None);
1149        assert_eq!(parse_retry_after(""), None);
1150    }
1151
1152    #[test]
1153    fn effective_retry_delay_prefers_capped_retry_after() {
1154        // Honors the server hint when present.
1155        assert_eq!(
1156            effective_retry_delay(Duration::from_millis(100), Some(Duration::from_secs(5))),
1157            Duration::from_secs(5)
1158        );
1159        // Caps an excessive hint at MAX_RETRY_AFTER so a bad header can't pin us.
1160        assert_eq!(
1161            effective_retry_delay(Duration::from_millis(100), Some(Duration::from_secs(600))),
1162            MAX_RETRY_AFTER
1163        );
1164    }
1165
1166    #[test]
1167    fn effective_retry_delay_falls_back_to_backoff() {
1168        assert_eq!(
1169            effective_retry_delay(Duration::from_millis(250), None),
1170            Duration::from_millis(250)
1171        );
1172    }
1173
1174    #[test]
1175    fn test_client_without_retries() {
1176        let client = RdapClient::new().without_retries();
1177        assert_eq!(client.retry_policy.max_attempts, 1);
1178    }
1179
1180    #[test]
1181    fn test_client_custom_retry_policy() {
1182        let policy = RetryPolicy::new().with_max_attempts(5);
1183        let client = RdapClient::new().with_retry_policy(policy);
1184        assert_eq!(client.retry_policy.max_attempts, 5);
1185    }
1186
1187    #[test]
1188    fn test_cached_bootstrap_expiration() {
1189        let data = BootstrapData {
1190            dns: HashMap::new(),
1191            ipv4: Vec::new(),
1192            ipv6: Vec::new(),
1193            asn: Vec::new(),
1194        };
1195        let cached = CachedBootstrap::new(data);
1196        // Fresh cache should not be expired
1197        assert!(!cached.is_expired());
1198    }
1199
1200    #[test]
1201    fn test_rdap_http_client_is_configured() {
1202        // Force lazy initialization and verify it doesn't panic; the real
1203        // reqwest builder is expected to succeed in any normal environment.
1204        let client = rdap_http_client();
1205        assert!(client.is_ok(), "RDAP HTTP client builder must succeed");
1206    }
1207
1208    #[test]
1209    fn test_parse_bootstrap_empty_services() {
1210        // Verifies that parsing empty bootstrap data doesn't panic
1211        let data = BootstrapData {
1212            dns: HashMap::new(),
1213            ipv4: Vec::new(),
1214            ipv6: Vec::new(),
1215            asn: Vec::new(),
1216        };
1217        // Should return None for any lookup on empty data
1218        assert!(RdapClient::get_rdap_urls_for_domain(&data, "example.com").is_none());
1219        assert!(RdapClient::get_rdap_urls_for_asn(&data, 12345).is_none());
1220    }
1221
1222    // --- validate_url_not_reserved tests (C1 regression) ----------------
1223
1224    #[tokio::test]
1225    async fn test_validate_url_not_reserved_rejects_loopback_literal() {
1226        let err = validate_url_not_reserved("https://127.0.0.1/domain/example.com")
1227            .await
1228            .unwrap_err();
1229        assert!(
1230            matches!(err, SeerError::RdapError(ref s) if s.contains("reserved IP")),
1231            "expected reserved-IP error, got: {:?}",
1232            err
1233        );
1234    }
1235
1236    #[tokio::test]
1237    async fn test_validate_url_not_reserved_rejects_private_ipv4_literal() {
1238        let err = validate_url_not_reserved("https://10.0.0.1/")
1239            .await
1240            .unwrap_err();
1241        assert!(
1242            matches!(err, SeerError::RdapError(ref s) if s.contains("reserved IP")),
1243            "expected reserved-IP error, got: {:?}",
1244            err
1245        );
1246    }
1247
1248    #[tokio::test]
1249    async fn test_validate_url_not_reserved_rejects_non_https_scheme() {
1250        // Defense-in-depth (M3): an http:// URL to an otherwise-public host must
1251        // be refused at fetch time, independent of bootstrap parse-time
1252        // validation. Uses an IP literal so the check is hermetic (no DNS).
1253        let err = validate_url_not_reserved("http://93.184.216.34/domain/example.com")
1254            .await
1255            .unwrap_err();
1256        assert!(
1257            matches!(err, SeerError::RdapError(ref s) if s.contains("not https")),
1258            "expected non-https rejection, got: {:?}",
1259            err
1260        );
1261    }
1262
1263    #[tokio::test]
1264    async fn test_validate_url_not_reserved_rejects_ipv6_loopback_literal() {
1265        let err = validate_url_not_reserved("https://[::1]/")
1266            .await
1267            .unwrap_err();
1268        assert!(
1269            matches!(err, SeerError::RdapError(ref s) if s.contains("reserved IP")),
1270            "expected reserved-IP error, got: {:?}",
1271            err
1272        );
1273    }
1274
1275    #[tokio::test]
1276    async fn test_validate_url_not_reserved_returns_resolved_addrs_for_public_literal() {
1277        // A public IP literal should return a one-element vector containing
1278        // exactly that address, ready for `resolve_to_addrs` pinning.
1279        let addrs = validate_url_not_reserved("https://8.8.8.8/").await.unwrap();
1280        assert_eq!(addrs.len(), 1);
1281        assert!(addrs[0].ip().is_ipv4());
1282        assert_eq!(addrs[0].port(), 443);
1283    }
1284
1285    // --- build_rdap_urls tests (M16) ------------------------------------
1286
1287    #[test]
1288    fn test_build_rdap_urls_preserves_order_and_appends_path() {
1289        let bases = vec![
1290            url::Url::parse("https://rdap.a.example/").unwrap(),
1291            url::Url::parse("https://rdap.b.example").unwrap(), // no trailing slash
1292        ];
1293        let built = build_rdap_urls(&bases, "domain/example.com");
1294        assert_eq!(built.len(), 2);
1295        assert_eq!(
1296            built[0].as_str(),
1297            "https://rdap.a.example/domain/example.com"
1298        );
1299        assert_eq!(
1300            built[1].as_str(),
1301            "https://rdap.b.example/domain/example.com"
1302        );
1303    }
1304
1305    #[test]
1306    fn test_build_rdap_urls_empty_input_returns_empty() {
1307        let built = build_rdap_urls(&[], "domain/example.com");
1308        assert!(built.is_empty());
1309    }
1310
1311    // --- wrap_all_candidates_failed tests (Issue 2 regression) ----------
1312
1313    #[test]
1314    fn test_wrap_all_candidates_failed_preserves_timeout_variant() {
1315        // When the last failure was a Timeout, the wrapped error must ALSO
1316        // be a Timeout so upstream retry logic can still branch on it.
1317        let last = SeerError::Timeout("body read timed out".to_string());
1318        let wrapped = wrap_all_candidates_failed(Some(last), 3);
1319        match wrapped {
1320            SeerError::Timeout(msg) => {
1321                assert!(
1322                    msg.contains("all 3 RDAP candidate URLs timed out"),
1323                    "expected wrapped timeout message, got: {}",
1324                    msg
1325                );
1326                assert!(
1327                    msg.contains("body read timed out"),
1328                    "expected original message preserved, got: {}",
1329                    msg
1330                );
1331            }
1332            other => panic!(
1333                "expected SeerError::Timeout after wrapping a Timeout, got: {:?}",
1334                other
1335            ),
1336        }
1337    }
1338
1339    #[test]
1340    fn test_wrap_all_candidates_failed_wraps_non_timeout_as_rdap_error() {
1341        let last = SeerError::RdapError("500 internal error".to_string());
1342        let wrapped = wrap_all_candidates_failed(Some(last), 2);
1343        assert!(
1344            matches!(wrapped, SeerError::RdapError(ref s) if s.contains("all 2 RDAP candidate URLs failed")),
1345            "expected wrapped RdapError, got: {:?}",
1346            wrapped
1347        );
1348    }
1349
1350    #[test]
1351    fn test_wrap_all_candidates_failed_single_candidate_returns_unchanged() {
1352        // Single-candidate case: return the last error unchanged to avoid
1353        // misleading "all 1 candidates failed" wrapping.
1354        let last = SeerError::Timeout("single timeout".to_string());
1355        let wrapped = wrap_all_candidates_failed(Some(last), 1);
1356        assert!(
1357            matches!(wrapped, SeerError::Timeout(ref s) if s == "single timeout"),
1358            "expected unchanged Timeout, got: {:?}",
1359            wrapped
1360        );
1361    }
1362
1363    #[test]
1364    fn test_wrap_all_candidates_failed_no_last_error_returns_placeholder() {
1365        let wrapped = wrap_all_candidates_failed(None, 0);
1366        assert!(matches!(wrapped, SeerError::RdapError(_)));
1367    }
1368
1369    // --- BOOTSTRAP_LOAD_NOTIFY concurrency test (Issue 1 regression) ----
1370    //
1371    // This test spawns two concurrent `ensure_bootstrap` calls on what is
1372    // effectively a cold/expired cache. The point is to exercise the
1373    // throttle-race path: before the Notify fix, one of the tasks could
1374    // observe `last_attempt.elapsed() < BOOTSTRAP_REFRESH_MIN_INTERVAL`
1375    // with an empty cache and immediately return
1376    // `RdapBootstrapError("bootstrap refresh throttled and no cache available")`.
1377    //
1378    // We cannot easily mock `load_bootstrap_data_with_retry`, but we CAN
1379    // exercise the coordination primitives directly to verify that a waiter
1380    // subscribing to BOOTSTRAP_LOAD_NOTIFY before a notify_waiters() call
1381    // correctly wakes, and that a spurious wake followed by a populated
1382    // cache is treated as success.
1383
1384    // Both bootstrap-notify tests mutate the shared BOOTSTRAP_CACHE static,
1385    // so they must be serialized against each other (cargo test parallelism
1386    // would otherwise race them).
1387    static BOOTSTRAP_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
1388
1389    #[tokio::test]
1390    async fn test_bootstrap_load_notify_wakes_waiter_when_cache_populated() {
1391        let _guard = BOOTSTRAP_TEST_LOCK.lock().await;
1392
1393        // Start from a known-empty state.
1394        {
1395            let mut cache = BOOTSTRAP_CACHE.write().await;
1396            *cache = None;
1397        }
1398
1399        // Construct a notified subscription BEFORE triggering the notify,
1400        // mirroring the order in ensure_bootstrap.
1401        let notified = BOOTSTRAP_LOAD_NOTIFY.notified();
1402        tokio::pin!(notified);
1403
1404        // Simulate a winning loader populating the cache and signalling.
1405        {
1406            let mut cache = BOOTSTRAP_CACHE.write().await;
1407            *cache = Some(CachedBootstrap::new(BootstrapData {
1408                dns: HashMap::new(),
1409                ipv4: Vec::new(),
1410                ipv6: Vec::new(),
1411                asn: Vec::new(),
1412            }));
1413        }
1414        BOOTSTRAP_LOAD_NOTIFY.notify_waiters();
1415
1416        let result = wait_for_in_flight_load(notified).await;
1417        assert!(
1418            result.is_ok(),
1419            "expected waiter to see populated cache, got: {:?}",
1420            result
1421        );
1422
1423        // Clean up so we don't leak state into other tests.
1424        {
1425            let mut cache = BOOTSTRAP_CACHE.write().await;
1426            *cache = None;
1427        }
1428    }
1429
1430    #[tokio::test]
1431    async fn test_bootstrap_load_notify_empty_cache_after_wake_returns_error() {
1432        let _guard = BOOTSTRAP_TEST_LOCK.lock().await;
1433
1434        // Ensure cache is empty.
1435        {
1436            let mut cache = BOOTSTRAP_CACHE.write().await;
1437            *cache = None;
1438        }
1439
1440        let notified = BOOTSTRAP_LOAD_NOTIFY.notified();
1441        tokio::pin!(notified);
1442
1443        // Winner's load failed — they notify with empty cache.
1444        BOOTSTRAP_LOAD_NOTIFY.notify_waiters();
1445
1446        let result = wait_for_in_flight_load(notified).await;
1447        assert!(
1448            matches!(
1449                result,
1450                Err(SeerError::RdapBootstrapError(ref s))
1451                    if s.contains("throttled and no cache available")
1452            ),
1453            "expected throttled error when cache still empty after notify, got: {:?}",
1454            result
1455        );
1456    }
1457
1458    // ---- deterministic mock-server tests -----------------------------------
1459    //
1460    // wiremock serves scripted RDAP responses on 127.0.0.1. These exercise the
1461    // single-endpoint query path (`query_rdap_with_retry` / `query_rdap_urls`)
1462    // directly — no IANA bootstrap involved, so the global bootstrap cache is
1463    // untouched and tests stay parallel-safe. The SSRF guard deliberately
1464    // refuses loopback, so the client uses the `#[cfg(test)]`-only
1465    // `allowing_reserved_for_tests` seam, absent from release builds.
1466
1467    use wiremock::matchers::method;
1468    use wiremock::{Mock, MockServer, ResponseTemplate};
1469
1470    #[tokio::test]
1471    async fn mock_rdap_404_is_nonretryable_typed_error() {
1472        let server = MockServer::start().await;
1473        Mock::given(method("GET"))
1474            .respond_with(ResponseTemplate::new(404))
1475            .mount(&server)
1476            .await;
1477
1478        let client = RdapClient::new()
1479            .without_retries()
1480            .allowing_reserved_for_tests();
1481        let err = client
1482            .query_rdap_with_retry(&format!("{}/domain/example.com", server.uri()))
1483            .await
1484            .unwrap_err();
1485        assert!(
1486            matches!(err, SeerError::RdapError(ref m) if m.contains("404")),
1487            "got: {err:?}"
1488        );
1489    }
1490
1491    #[tokio::test]
1492    async fn mock_rdap_429_honors_retry_after_and_succeeds() {
1493        let server = MockServer::start().await;
1494        // First request: rate-limited with an immediate retry hint. The mock
1495        // expires after one use, so the retry falls through to the 200 below.
1496        Mock::given(method("GET"))
1497            .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "0"))
1498            .up_to_n_times(1)
1499            .mount(&server)
1500            .await;
1501        Mock::given(method("GET"))
1502            .respond_with(ResponseTemplate::new(200).set_body_raw(
1503                r#"{"objectClassName":"domain","handle":"MOCK-1"}"#,
1504                "application/rdap+json",
1505            ))
1506            .mount(&server)
1507            .await;
1508
1509        let client = RdapClient::new().allowing_reserved_for_tests();
1510        let resp = client
1511            .query_rdap_with_retry(&format!("{}/domain/example.com", server.uri()))
1512            .await
1513            .unwrap();
1514        assert_eq!(resp.handle.as_deref(), Some("MOCK-1"));
1515    }
1516
1517    #[tokio::test]
1518    async fn mock_rdap_malformed_body_is_parse_error_not_panic() {
1519        let server = MockServer::start().await;
1520        Mock::given(method("GET"))
1521            .respond_with(ResponseTemplate::new(200).set_body_raw("not json", "text/plain"))
1522            .mount(&server)
1523            .await;
1524
1525        let client = RdapClient::new()
1526            .without_retries()
1527            .allowing_reserved_for_tests();
1528        let err = client
1529            .query_rdap_with_retry(&format!("{}/domain/example.com", server.uri()))
1530            .await
1531            .unwrap_err();
1532        assert!(matches!(err, SeerError::JsonError(_)), "got: {err:?}");
1533    }
1534
1535    #[tokio::test]
1536    async fn mock_rdap_candidate_fallback_uses_second_url() {
1537        let bad = MockServer::start().await;
1538        Mock::given(method("GET"))
1539            .respond_with(ResponseTemplate::new(500))
1540            .mount(&bad)
1541            .await;
1542        let good = MockServer::start().await;
1543        Mock::given(method("GET"))
1544            .respond_with(ResponseTemplate::new(200).set_body_raw(
1545                r#"{"objectClassName":"domain","handle":"MOCK-2"}"#,
1546                "application/rdap+json",
1547            ))
1548            .mount(&good)
1549            .await;
1550
1551        let client = RdapClient::new()
1552            .without_retries()
1553            .allowing_reserved_for_tests();
1554        let urls = vec![
1555            url::Url::parse(&format!("{}/domain/example.com", bad.uri())).unwrap(),
1556            url::Url::parse(&format!("{}/domain/example.com", good.uri())).unwrap(),
1557        ];
1558        let resp = client.query_rdap_urls(&urls).await.unwrap();
1559        assert_eq!(resp.handle.as_deref(), Some("MOCK-2"));
1560    }
1561
1562    /// When an earlier candidate authoritatively reports 404 (no such object)
1563    /// but a later candidate fails for a different reason (5xx/timeout/conn),
1564    /// the definitive 404 — the strongest availability signal — must survive in
1565    /// the returned error. Otherwise `rdap_error_is_404` returns false and a
1566    /// genuinely available domain is misreported as inconclusive.
1567    #[tokio::test]
1568    async fn mock_rdap_404_on_first_candidate_survives_later_non_404_failure() {
1569        let not_found = MockServer::start().await;
1570        Mock::given(method("GET"))
1571            .respond_with(ResponseTemplate::new(404))
1572            .mount(&not_found)
1573            .await;
1574        let broken = MockServer::start().await;
1575        Mock::given(method("GET"))
1576            .respond_with(ResponseTemplate::new(500))
1577            .mount(&broken)
1578            .await;
1579
1580        let client = RdapClient::new()
1581            .without_retries()
1582            .allowing_reserved_for_tests();
1583        let urls = vec![
1584            url::Url::parse(&format!("{}/domain/example.com", not_found.uri())).unwrap(),
1585            url::Url::parse(&format!("{}/domain/example.com", broken.uri())).unwrap(),
1586        ];
1587        let err = client.query_rdap_urls(&urls).await.unwrap_err();
1588        assert!(
1589            crate::rdap::rdap_error_is_404(&err),
1590            "404 from candidate 1 must survive candidate 2's non-404 failure, got: {err:?}"
1591        );
1592    }
1593}