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