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