Skip to main content

seer_core/rdap/
client.rs

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