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