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