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