Skip to main content

seer_core/rdap/
client.rs

1use std::collections::{HashMap, HashSet};
2use std::net::{IpAddr, SocketAddr};
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6
7use reqwest::Client;
8use serde::Deserialize;
9use std::sync::LazyLock;
10use tokio::sync::{Notify, RwLock};
11use tracing::{debug, info, instrument, warn};
12
13use super::bootstrap::{ip_matches_prefix, parse_asn_range, validate_bootstrap_url};
14use super::types::RdapResponse;
15use crate::error::{Result, SeerError};
16use crate::http::{read_body_capped, BodyReadError, Overflow};
17use crate::retry::{RetryExecutor, RetryPolicy};
18use crate::validation::normalize_domain;
19
20/// IANA RDAP bootstrap registry for DNS (also probed by `seer doctor`).
21pub(crate) const IANA_BOOTSTRAP_DNS: &str = "https://data.iana.org/rdap/dns.json";
22const IANA_BOOTSTRAP_IPV4: &str = "https://data.iana.org/rdap/ipv4.json";
23const IANA_BOOTSTRAP_IPV6: &str = "https://data.iana.org/rdap/ipv6.json";
24const IANA_BOOTSTRAP_ASN: &str = "https://data.iana.org/rdap/asn.json";
25
26/// Cap on one bootstrap registry body (10 MB), streamed incrementally to
27/// prevent memory exhaustion. `seer doctor`'s bootstrap probe reads under it too.
28pub(crate) const MAX_BOOTSTRAP_SIZE: usize = 10 * 1024 * 1024;
29
30/// Default timeout for RDAP queries (15 seconds).
31/// With the 5s connect_timeout, this gives 10s for the server to respond.
32/// Most RDAP servers respond within 2-5 seconds; slow ccTLD registries
33/// may need the full 15s.
34const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15);
35
36/// Connect timeout — fail fast when a host is unreachable rather than
37/// waiting the full request timeout on a TCP handshake that will never complete.
38const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
39
40/// TTL for bootstrap data (24 hours)
41const BOOTSTRAP_TTL: Duration = Duration::from_secs(24 * 60 * 60);
42
43/// Minimum interval between bootstrap refresh attempts when the cache is
44/// expired-but-present or empty. Prevents a thundering herd of concurrent
45/// callers from all hammering IANA simultaneously during an outage.
46const BOOTSTRAP_REFRESH_MIN_INTERVAL: Duration = Duration::from_secs(60);
47
48/// TTL for a *partial* bootstrap load — one where some IANA registry file
49/// (say dns.json) failed while the others loaded. The failed section is
50/// carried over from the previous dataset (or left empty on a cold start),
51/// and the short TTL makes the next caller retry the gap within minutes
52/// instead of pinning it for [`BOOTSTRAP_TTL`]. Kept above
53/// [`BOOTSTRAP_REFRESH_MIN_INTERVAL`] so the throttle stays meaningful.
54const BOOTSTRAP_PARTIAL_TTL: Duration = Duration::from_secs(5 * 60);
55
56/// `Accept` header for RDAP queries. RFC 7480 §4.2 asks for
57/// `application/rdap+json`, but a few servers (rdap.nic.sn) answer 406 unless
58/// plain JSON is also acceptable, so it is offered at a lower preference.
59const RDAP_ACCEPT: &str = "application/rdap+json, application/json;q=0.9";
60
61/// Maximum HTTP redirect hops followed for one RDAP query. RFC 7480 §5.2
62/// uses redirects for cross-registry referral (ARIN → RIPE for an IP it
63/// doesn't hold, LACNIC → registro.br, a TLD's moved base URL); real chains
64/// are one hop, so three is ample while bounding a hostile server.
65const MAX_RDAP_REDIRECTS: usize = 3;
66
67/// User agent for every RDAP request (bootstrap and queries).
68const RDAP_USER_AGENT: &str = "Seer/1.0 (RDAP Client)";
69
70/// Shared HTTP client for bootstrap fetches against IANA.
71/// The bootstrap targets are hardcoded data.iana.org URLs, so this client
72/// does not need DNS-rebinding protection. Per-query RDAP requests use
73/// per-host clients that pin validated resolved IPs, cached briefly in
74/// [`PINNED_CLIENT_CACHE`].
75///
76/// Wrapped in `Option` so a reqwest builder failure surfaces as a typed
77/// `SeerError::HttpError` via `rdap_http_client()` instead of a process
78/// panic at first use (library code must not `.expect()` on shared state).
79static RDAP_HTTP_CLIENT: LazyLock<Option<Client>> = LazyLock::new(|| {
80    // Bootstrap targets are hardcoded https://data.iana.org URLs that return
81    // terminal JSON; the builder's no-redirect policy is defense in depth so a
82    // compromised/MITM'd hop can't bounce the fetch to an internal address.
83    rdap_client_builder(DEFAULT_TIMEOUT)
84        .pool_max_idle_per_host(10)
85        .build()
86        .ok()
87});
88
89/// Returns a reference to the shared RDAP bootstrap HTTP client, or a typed
90/// error if the builder failed at initialization time. Call sites use
91/// `rdap_http_client()?` instead of dereferencing the static directly.
92fn rdap_http_client() -> Result<&'static Client> {
93    RDAP_HTTP_CLIENT
94        .as_ref()
95        .ok_or_else(|| SeerError::HttpError("failed to initialize HTTP client".into()))
96}
97
98/// Bootstrap cache with TTL support
99static BOOTSTRAP_CACHE: LazyLock<RwLock<Option<CachedBootstrap>>> =
100    LazyLock::new(|| RwLock::new(None));
101
102/// Timestamp of the most recent bootstrap refresh attempt (success or failure).
103/// Used together with `BOOTSTRAP_REFRESH_MIN_INTERVAL` to throttle retry
104/// storms when IANA is unreachable.
105static BOOTSTRAP_LAST_ATTEMPT: LazyLock<RwLock<Option<Instant>>> =
106    LazyLock::new(|| RwLock::new(None));
107
108/// Notifies waiters when an in-flight bootstrap load completes (success or
109/// failure). Solves the first-boot thundering-herd race where two concurrent
110/// cold-cache callers would otherwise see: caller A records its attempt
111/// timestamp, then caller B checks the timestamp and finds it "too recent"
112/// and returns a spurious `throttled and no cache available` error while A
113/// is still actively loading. Losers instead wait on this notify with a
114/// bounded timeout, then re-check the cache.
115static BOOTSTRAP_LOAD_NOTIFY: LazyLock<Notify> = LazyLock::new(Notify::new);
116
117/// True while some task is running a bootstrap load. Lets a throttled
118/// cold-cache caller tell "a load is in flight — wait for its notify" apart
119/// from "the last load already finished (and failed) — nobody will notify",
120/// so it only blocks in the former case instead of sleeping the full bounded
121/// timeout for the rest of the throttle window.
122static BOOTSTRAP_LOAD_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
123
124/// Marks a bootstrap load in flight for its lifetime. Dropping it — on
125/// completion *or* cancellation of the loading future — clears the flag and
126/// wakes every waiter, so a dropped winner can't leave losers hanging.
127struct BootstrapLoadGuard;
128
129impl BootstrapLoadGuard {
130    fn start() -> Self {
131        BOOTSTRAP_LOAD_IN_FLIGHT.store(true, Ordering::SeqCst);
132        Self
133    }
134}
135
136impl Drop for BootstrapLoadGuard {
137    fn drop(&mut self) {
138        BOOTSTRAP_LOAD_IN_FLIGHT.store(false, Ordering::SeqCst);
139        BOOTSTRAP_LOAD_NOTIFY.notify_waiters();
140    }
141}
142
143/// Cached bootstrap data with timestamp for TTL tracking
144struct CachedBootstrap {
145    data: BootstrapData,
146    loaded_at: Instant,
147    /// [`BOOTSTRAP_TTL`] for a complete load; [`BOOTSTRAP_PARTIAL_TTL`] when
148    /// some registry section failed to load this time.
149    ttl: Duration,
150}
151
152impl CachedBootstrap {
153    #[cfg(test)]
154    fn new(data: BootstrapData) -> Self {
155        Self::with_ttl(data, BOOTSTRAP_TTL)
156    }
157
158    fn with_ttl(data: BootstrapData, ttl: Duration) -> Self {
159        Self {
160            data,
161            loaded_at: Instant::now(),
162            ttl,
163        }
164    }
165
166    fn is_expired(&self) -> bool {
167        self.loaded_at.elapsed() > self.ttl
168    }
169
170    fn age(&self) -> Duration {
171        self.loaded_at.elapsed()
172    }
173}
174
175/// Parsed IANA bootstrap data.
176/// Each TLD/prefix/ASN range is associated with an ordered list of
177/// candidate RDAP base URLs (IANA may list multiple per RFC 9224). Callers
178/// try them in order and fall back on failure.
179struct BootstrapData {
180    dns: HashMap<String, Arc<Vec<url::Url>>>,
181    ipv4: Vec<(IpRange, Arc<Vec<url::Url>>)>,
182    ipv6: Vec<(IpRange, Arc<Vec<url::Url>>)>,
183    asn: Vec<(AsnRange, Arc<Vec<url::Url>>)>,
184}
185
186#[derive(Clone)]
187struct IpRange {
188    prefix: String,
189}
190
191#[derive(Clone)]
192struct AsnRange {
193    start: u32,
194    end: u32,
195}
196
197#[derive(Deserialize)]
198struct BootstrapResponse {
199    services: Vec<Vec<serde_json::Value>>,
200}
201
202/// One bootstrap fetch. Each registry section is `None` when that IANA file
203/// failed to fetch or parse (or parsed to nothing), so a partial failure can
204/// be merged over the previous dataset instead of replacing it.
205struct BootstrapLoad {
206    dns: Option<HashMap<String, Arc<Vec<url::Url>>>>,
207    ipv4: Option<Vec<(IpRange, Arc<Vec<url::Url>>)>>,
208    ipv6: Option<Vec<(IpRange, Arc<Vec<url::Url>>)>>,
209    asn: Option<Vec<(AsnRange, Arc<Vec<url::Url>>)>>,
210}
211
212impl BootstrapLoad {
213    /// True when every registry section loaded.
214    fn is_complete(&self) -> bool {
215        self.dns.is_some() && self.ipv4.is_some() && self.ipv6.is_some() && self.asn.is_some()
216    }
217
218    /// Cache TTL for this load: the full day when complete, the short
219    /// [`BOOTSTRAP_PARTIAL_TTL`] when a section is missing.
220    fn ttl(&self) -> Duration {
221        if self.is_complete() {
222            BOOTSTRAP_TTL
223        } else {
224            BOOTSTRAP_PARTIAL_TTL
225        }
226    }
227
228    /// Builds the dataset to cache, keeping `previous`'s section for every
229    /// registry that failed this time — a refresh where only dns.json timed
230    /// out must not wipe good (if stale) TLD data.
231    fn merge_over(self, previous: Option<BootstrapData>) -> BootstrapData {
232        let (dns, ipv4, ipv6, asn) = match previous {
233            Some(p) => (Some(p.dns), Some(p.ipv4), Some(p.ipv6), Some(p.asn)),
234            None => (None, None, None, None),
235        };
236        BootstrapData {
237            dns: self.dns.or(dns).unwrap_or_default(),
238            ipv4: self.ipv4.or(ipv4).unwrap_or_default(),
239            ipv6: self.ipv6.or(ipv6).unwrap_or_default(),
240            asn: self.asn.or(asn).unwrap_or_default(),
241        }
242    }
243}
244
245/// Stores a finished load into `cache` (the contents of [`BOOTSTRAP_CACHE`])
246/// by merging it over the previous dataset, unless another task already
247/// stored a still-fresh dataset while this load ran.
248fn store_bootstrap_load(cache: &mut Option<CachedBootstrap>, load: BootstrapLoad) {
249    if cache.as_ref().is_some_and(|c| !c.is_expired()) {
250        return;
251    }
252    let ttl = load.ttl();
253    if !load.is_complete() {
254        warn!(
255            dns = load.dns.is_some(),
256            ipv4 = load.ipv4.is_some(),
257            ipv6 = load.ipv6.is_some(),
258            asn = load.asn.is_some(),
259            retry_in_secs = ttl.as_secs(),
260            "RDAP bootstrap partially loaded; keeping previous data for failed registries"
261        );
262    }
263    let previous = cache.take().map(|c| c.data);
264    *cache = Some(CachedBootstrap::with_ttl(load.merge_over(previous), ttl));
265}
266
267/// Waits (bounded) for an in-flight bootstrap load to complete, then
268/// re-checks the cache. Used by losers of the throttle race so a concurrent
269/// cold-cache caller doesn't spuriously error with "throttled and no cache
270/// available" while the winner is still loading.
271///
272/// The `notified` future must be created BEFORE the caller observes the
273/// throttle condition — otherwise `notify_waiters()` could fire in the gap
274/// between observing "still throttled, empty cache" and subscribing, and
275/// this call would then block until timeout.
276async fn wait_for_in_flight_load(
277    notified: std::pin::Pin<&mut tokio::sync::futures::Notified<'_>>,
278) -> Result<()> {
279    // Only block while a load is actually running. Once the last load has
280    // finished (a cold load that failed leaves the cache empty), nobody will
281    // notify again, and waiting would stall every caller for the full timeout
282    // until the throttle window passes. The winner writes the cache before
283    // clearing the flag, so observing `false` here means the cache check
284    // below already sees its result. Still bounded, in case the winner hangs.
285    if BOOTSTRAP_LOAD_IN_FLIGHT.load(Ordering::SeqCst) {
286        let _ = tokio::time::timeout(DEFAULT_TIMEOUT, notified).await;
287    }
288    let cache = BOOTSTRAP_CACHE.read().await;
289    if cache.is_some() {
290        Ok(())
291    } else {
292        Err(SeerError::RdapBootstrapError(
293            "bootstrap refresh throttled and no cache available".to_string(),
294        ))
295    }
296}
297
298/// Outcome of trying to claim the (single-flight) bootstrap load.
299enum BootstrapClaim {
300    /// This caller owns the load; dropping the guard ends it.
301    Won(BootstrapLoadGuard),
302    /// A load is in flight, or one was attempted within
303    /// [`BOOTSTRAP_REFRESH_MIN_INTERVAL`].
304    Busy,
305}
306
307/// Claims the bootstrap load unless one is running or was attempted within
308/// the throttle window. The attempt timestamp and the in-flight flag are set
309/// in one critical section, so a loser that observes the timestamp also
310/// observes the flag (see [`wait_for_in_flight_load`]).
311async fn claim_bootstrap_load() -> BootstrapClaim {
312    let mut last = BOOTSTRAP_LAST_ATTEMPT.write().await;
313    let throttled = last.is_some_and(|ts| ts.elapsed() < BOOTSTRAP_REFRESH_MIN_INTERVAL);
314    if throttled || BOOTSTRAP_LOAD_IN_FLIGHT.load(Ordering::SeqCst) {
315        return BootstrapClaim::Busy;
316    }
317    *last = Some(Instant::now());
318    BootstrapClaim::Won(BootstrapLoadGuard::start())
319}
320
321/// Stores a finished load (merged over any previous data). A failed load
322/// keeps whatever (stale) data is cached: it is an error only when there is
323/// nothing to serve.
324async fn finish_bootstrap_load(result: Result<BootstrapLoad>) -> Result<()> {
325    match result {
326        Ok(load) => {
327            store_bootstrap_load(&mut *BOOTSTRAP_CACHE.write().await, load);
328            Ok(())
329        }
330        Err(e) => match BOOTSTRAP_CACHE.read().await.as_ref() {
331            Some(cached) => {
332                debug!(
333                    error = %e,
334                    age_hours = cached.age().as_secs() / 3600,
335                    "Bootstrap refresh failed, using stale data"
336                );
337                Ok(())
338            }
339            None => Err(e),
340        },
341    }
342}
343
344/// Makes bootstrap data available, running `load` at most once at a time
345/// (single-flight) and at most once per [`BOOTSTRAP_REFRESH_MIN_INTERVAL`].
346///
347/// * Fresh cache: returns at once; `load` is dropped unrun.
348/// * Stale cache: returns at once, serving the stale data, and — when this
349///   caller wins the claim — refreshes it on a spawned task. The task owns the
350///   in-flight guard, so a later caller cannot start a second refresh while it
351///   runs.
352/// * Cold cache: the winner loads inline (the cache lock is not held across
353///   the network load); losers wait, bounded, for its result via
354///   [`BOOTSTRAP_LOAD_NOTIFY`] instead of failing with "throttled".
355async fn ensure_bootstrap_with<F>(load: F) -> Result<()>
356where
357    F: std::future::Future<Output = Result<BootstrapLoad>> + Send + 'static,
358{
359    let stale = match BOOTSTRAP_CACHE.read().await.as_ref() {
360        Some(cached) if !cached.is_expired() => return Ok(()),
361        Some(_) => true,
362        None => false,
363    };
364
365    // Subscribe BEFORE the claim: a `notify_waiters()` from the winner then
366    // cannot slip between observing "busy, empty cache" and the wait below.
367    let notified = BOOTSTRAP_LOAD_NOTIFY.notified();
368    tokio::pin!(notified);
369
370    let guard = match claim_bootstrap_load().await {
371        BootstrapClaim::Won(guard) => guard,
372        BootstrapClaim::Busy if stale => return Ok(()),
373        BootstrapClaim::Busy => return wait_for_in_flight_load(notified).await,
374    };
375
376    if stale {
377        debug!("Serving stale RDAP bootstrap data; refreshing in the background");
378        tokio::spawn(async move {
379            // The result is stored (or logged) for later callers; this one
380            // has already been served the stale data.
381            let _ = finish_bootstrap_load(load.await).await;
382            drop(guard);
383        });
384        return Ok(());
385    }
386
387    debug!("Loading RDAP bootstrap data");
388    let outcome = finish_bootstrap_load(load.await).await;
389    // Clear the in-flight flag and wake losers only after the cache write
390    // above, so they observe its result.
391    drop(guard);
392    outcome
393}
394
395#[derive(Debug, Clone)]
396pub struct RdapClient {
397    retry_policy: RetryPolicy,
398    /// Per-request timeout for RDAP queries (default [`DEFAULT_TIMEOUT`]).
399    timeout: Duration,
400    /// When true, skips the reserved-IP SSRF validation for loopback URLs so
401    /// tests can target a 127.0.0.1 wiremock fixture (any other URL, such as
402    /// a scripted redirect target, still gets the full guard). Not settable
403    /// outside `#[cfg(test)]` builds — production requests always validate
404    /// and pin resolved IPs.
405    allow_reserved: bool,
406}
407
408impl Default for RdapClient {
409    fn default() -> Self {
410        Self::new()
411    }
412}
413
414impl RdapClient {
415    /// Creates a new RDAP client with default settings.
416    pub fn new() -> Self {
417        Self {
418            // RDAP registries rate-limit hard. Give 429s a few jittered,
419            // server-hint-aware retries to clear a *brief* limit, but keep the
420            // total bounded (≈10s worst case) so a sticky rate limit falls
421            // through to the WHOIS/DNS fallback fast instead of hanging an
422            // interactive lookup. The old 2× 100ms never cleared a real limit;
423            // a multi-attempt 30s honor was the opposite mistake.
424            retry_policy: RetryPolicy::new()
425                .with_max_attempts(3)
426                .with_initial_delay(Duration::from_millis(500))
427                .with_max_delay(Duration::from_secs(5)),
428            timeout: DEFAULT_TIMEOUT,
429            allow_reserved: false,
430        }
431    }
432
433    /// Builds a client honoring `~/.seer/config.toml` settings.
434    ///
435    /// Reads `timeouts.rdap_secs` (already clamped to 1–300s by
436    /// [`crate::config::SeerConfig::load`]). Sugar over
437    /// [`RdapClient::with_timeout`] — equivalent to
438    /// `RdapClient::new().with_timeout(config.rdap_timeout())`.
439    pub fn from_config(config: &crate::config::SeerConfig) -> Self {
440        Self::new().with_timeout(config.rdap_timeout())
441    }
442
443    /// Sets the per-request timeout for RDAP queries.
444    pub fn with_timeout(mut self, timeout: Duration) -> Self {
445        self.timeout = timeout;
446        self
447    }
448
449    /// Test-only: allow requests to loopback/reserved addresses (mock servers).
450    #[cfg(test)]
451    pub(crate) fn allowing_reserved_for_tests(mut self) -> Self {
452        self.allow_reserved = true;
453        self
454    }
455
456    /// Sets the retry policy for transient network failures.
457    ///
458    /// The default policy retries up to 2 times with exponential backoff.
459    pub fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
460        self.retry_policy = policy;
461        self
462    }
463
464    /// Disables retries (single attempt only).
465    pub fn without_retries(mut self) -> Self {
466        self.retry_policy = RetryPolicy::no_retry();
467        self
468    }
469
470    /// Ensures bootstrap data is loaded, loading or refreshing it as needed.
471    ///
472    /// Stale-while-revalidate: once the cache holds data, an expired dataset
473    /// is served as-is and refreshed by a background task, so no lookup waits
474    /// on IANA after the first load. Only a cold (empty) cache loads inline —
475    /// there is nothing to serve until it lands. See [`ensure_bootstrap_with`].
476    async fn ensure_bootstrap(&self) -> Result<()> {
477        ensure_bootstrap_with(load_bootstrap_data_with_retry(self.retry_policy.clone())).await
478    }
479
480    /// Looks up the candidate RDAP base URLs for a domain's TLD.
481    fn get_rdap_urls_for_domain(cache: &BootstrapData, domain: &str) -> Option<Arc<Vec<url::Url>>> {
482        let tld = domain.rsplit('.').next()?;
483        cache.dns.get(&tld.to_lowercase()).cloned()
484    }
485
486    /// Looks up the candidate RDAP base URLs for an IP address.
487    fn get_rdap_urls_for_ip(cache: &BootstrapData, ip: IpAddr) -> Option<Arc<Vec<url::Url>>> {
488        let ranges = match ip {
489            IpAddr::V4(_) => &cache.ipv4,
490            IpAddr::V6(_) => &cache.ipv6,
491        };
492        ranges
493            .iter()
494            .find(|(range, _)| ip_matches_prefix(&range.prefix, ip))
495            .map(|(_, urls)| Arc::clone(urls))
496    }
497
498    /// Looks up the candidate RDAP base URLs for an ASN.
499    fn get_rdap_urls_for_asn(cache: &BootstrapData, asn: u32) -> Option<Arc<Vec<url::Url>>> {
500        cache
501            .asn
502            .iter()
503            .find(|(range, _)| (range.start..=range.end).contains(&asn))
504            .map(|(_, urls)| Arc::clone(urls))
505    }
506
507    /// Resolves the candidate query URLs for one lookup: `pick` selects the
508    /// base URLs from the loaded bootstrap (`what` names the query in the
509    /// "no RDAP server" error) and each is joined with `path`. The cache lock
510    /// is released on return, before any HTTP request.
511    async fn candidate_urls(
512        pick: impl FnOnce(&BootstrapData) -> Option<Arc<Vec<url::Url>>>,
513        what: &str,
514        path: &str,
515    ) -> Result<Vec<url::Url>> {
516        let cache_guard = BOOTSTRAP_CACHE.read().await;
517        let cache = cache_guard.as_ref().ok_or_else(|| {
518            SeerError::RdapBootstrapError("bootstrap data not loaded".to_string())
519        })?;
520        let bases = pick(&cache.data)
521            .ok_or_else(|| SeerError::RdapBootstrapError(format!("no RDAP server for {}", what)))?;
522        Ok(build_rdap_urls(&bases, path))
523    }
524
525    /// Looks up RDAP registration data for a domain.
526    ///
527    /// Uses IANA bootstrap data to find the appropriate RDAP server for the TLD.
528    #[instrument(skip(self), fields(domain = %domain))]
529    pub async fn lookup_domain(&self, domain: &str) -> Result<RdapResponse> {
530        self.ensure_bootstrap().await?;
531
532        let domain = normalize_domain(domain)?;
533        let urls = Self::candidate_urls(
534            |data| Self::get_rdap_urls_for_domain(data, &domain),
535            &domain,
536            &format!("domain/{}", domain),
537        )
538        .await?;
539        self.query_rdap_urls(&urls).await
540    }
541
542    /// Looks up RDAP registration data for an IP address.
543    ///
544    /// Uses IANA bootstrap data to find the appropriate RIR (Regional Internet Registry).
545    #[instrument(skip(self), fields(ip = %ip))]
546    pub async fn lookup_ip(&self, ip: &str) -> Result<RdapResponse> {
547        self.ensure_bootstrap().await?;
548
549        let ip_addr: IpAddr = ip
550            .parse()
551            .map_err(|_| SeerError::InvalidIpAddress(ip.to_string()))?;
552        let urls = Self::candidate_urls(
553            |data| Self::get_rdap_urls_for_ip(data, ip_addr),
554            ip,
555            &format!("ip/{}", ip),
556        )
557        .await?;
558        self.query_rdap_urls(&urls).await
559    }
560
561    /// Looks up RDAP registration data for an Autonomous System Number (ASN).
562    ///
563    /// Uses IANA bootstrap data to find the appropriate RIR for the ASN range.
564    #[instrument(skip(self), fields(asn = %asn))]
565    pub async fn lookup_asn(&self, asn: u32) -> Result<RdapResponse> {
566        self.ensure_bootstrap().await?;
567        let urls = Self::candidate_urls(
568            |data| Self::get_rdap_urls_for_asn(data, asn),
569            &format!("AS{}", asn),
570            &format!("autnum/{}", asn),
571        )
572        .await?;
573        self.query_rdap_urls(&urls).await
574    }
575
576    /// Returns the RDAP base URL for a given TLD, if known from bootstrap data.
577    ///
578    /// Loads bootstrap data if not already cached. Returns `None` if the TLD
579    /// has no registered RDAP server in the IANA bootstrap registry. When
580    /// IANA lists multiple URLs for a TLD, the first one is returned.
581    #[instrument(skip(self), fields(tld = %tld))]
582    pub async fn get_rdap_base_url_for_tld(&self, tld: &str) -> Option<String> {
583        if self.ensure_bootstrap().await.is_err() {
584            return None;
585        }
586
587        let cache_guard = BOOTSTRAP_CACHE.read().await;
588        let cache = cache_guard.as_ref()?;
589        // IANA bootstrap keys are A-labels (punycode); convert a Unicode TLD
590        // (e.g. "рф" -> "xn--p1ai") so it matches. ASCII TLDs are unchanged;
591        // an un-convertible value falls back to the lowercased input.
592        let lower = tld.to_lowercase();
593        let key = crate::validation::domain_to_ascii(&lower).unwrap_or(lower);
594        cache
595            .data
596            .dns
597            .get(&key)
598            .and_then(|urls| urls.first())
599            .map(|u| u.to_string())
600    }
601
602    /// Queries a list of candidate RDAP URLs in order, returning the first
603    /// successful response. Each URL is attempted with the full retry policy.
604    /// If all candidates fail, the last error is returned wrapped with context.
605    async fn query_rdap_urls(&self, urls: &[url::Url]) -> Result<RdapResponse> {
606        if urls.is_empty() {
607            return Err(SeerError::RdapError(
608                "no candidate RDAP URLs available".to_string(),
609            ));
610        }
611
612        let mut last_error: Option<SeerError> = None;
613        // A definitive 404 from any candidate is the strongest available-ness
614        // signal there is. Preserve the first one we see so a later candidate's
615        // non-404 failure (timeout/5xx/conn) can't bury it — otherwise
616        // `rdap_error_is_404` would return false and a genuinely available
617        // domain would be misreported as inconclusive.
618        let mut not_found_error: Option<SeerError> = None;
619        for (idx, url) in urls.iter().enumerate() {
620            let url_str = url.as_str().to_string();
621            debug!(url = %url_str, candidate = idx + 1, total = urls.len(), "Querying RDAP");
622            match self.query_rdap_with_retry(&url_str).await {
623                Ok(resp) => return Ok(resp),
624                Err(e) => {
625                    if urls.len() > 1 {
626                        debug!(
627                            url = %url_str,
628                            error = %e,
629                            candidate = idx + 1,
630                            total = urls.len(),
631                            "RDAP candidate failed, trying next",
632                        );
633                    }
634                    if not_found_error.is_none() && crate::rdap::rdap_error_is_404(&e) {
635                        not_found_error = Some(e);
636                    } else {
637                        last_error = Some(e);
638                    }
639                }
640            }
641        }
642
643        // All candidates failed. Prefer a preserved 404 over a later non-404
644        // failure so the authoritative not-found signal reaches the caller.
645        Err(wrap_all_candidates_failed(
646            not_found_error.or(last_error),
647            urls.len(),
648        ))
649    }
650
651    /// Queries a single RDAP endpoint, retrying transient failures. A 429's
652    /// `Retry-After` is handed to the executor as the delay hint —
653    /// registries rate-limit aggressively, and the server-suggested delay
654    /// clears the limit far more reliably than blind exponential backoff.
655    /// The policy's `max_delay` caps the hint, so a sticky limit still falls
656    /// through to the WHOIS/DNS fallback instead of hanging a lookup.
657    async fn query_rdap_with_retry(&self, url: &str) -> Result<RdapResponse> {
658        RetryExecutor::new(self.retry_policy.clone())
659            .execute_with_delay_hint(|| query_rdap_attempt(url, self.timeout, self.allow_reserved))
660            .await
661    }
662}
663
664/// Maximum RDAP response body size (10 MB, matching CT log response limit).
665const MAX_RDAP_RESPONSE_SIZE: usize = 10 * 1024 * 1024;
666
667/// Parses an RDAP URL and enforces the https-only scheme, returning the
668/// (unbracketed) host and effective port.
669///
670/// Defense-in-depth: RDAP is HTTPS-only. The scheme is enforced here — on
671/// every request, pinned-client cache hit or miss — so the guard does not
672/// silently depend on the bootstrap's parse-time check. A plaintext `http://`
673/// (downgrade) or any non-https URL — including a server-supplied link or
674/// redirect target ever fed in — must never be fetched, even when the host
675/// resolves to a public address.
676fn parse_rdap_url(url: &str) -> Result<(String, u16)> {
677    let parsed = url::Url::parse(url)
678        .map_err(|e| SeerError::RdapError(format!("invalid URL '{}': {}", url, e)))?;
679    if parsed.scheme() != "https" {
680        return Err(SeerError::RdapError(format!(
681            "RDAP URL '{}' is not https — request blocked (downgrade/SSRF protection)",
682            url
683        )));
684    }
685    let host = crate::net::url_host(&parsed)
686        .ok_or_else(|| SeerError::RdapError(format!("URL '{}' has no host", url)))?;
687    let port = parsed.port_or_known_default().unwrap_or(443);
688    Ok((host, port))
689}
690
691/// Resolves an RDAP host through the shared SSRF guard
692/// ([`crate::net::resolve_public_host`]), mapping failures into the RDAP
693/// error domain.
694///
695/// The shared guard bounds the OS-resolver lookup (`getaddrinfo` has no
696/// deadline, so a black-holed hostname could otherwise pin a worker thread)
697/// and falls back to hickory when the system resolver is broken — the same
698/// envelope as every other outbound leg. The reserved-range policy is
699/// unchanged (the previous local check delegated to the same
700/// `net::is_reserved_ip`), and the guard's error deliberately omits the
701/// resolved IP (internal-DNS-oracle hardening, issue #49).
702async fn resolve_rdap_host(host: &str, port: u16) -> Result<Vec<SocketAddr>> {
703    crate::net::resolve_public_host(host, port)
704        .await
705        .map_err(|e| SeerError::RdapError(format!("{} — request blocked (SSRF protection)", e)))
706}
707
708/// Validates that a URL is https and does not resolve to a reserved/private IP
709/// address (SSRF protection). Test-only composition of the two production
710/// pieces: [`send_rdap_request`] runs [`parse_rdap_url`] on every request and
711/// [`resolve_rdap_host`] on pinned-client cache misses.
712#[cfg(test)]
713async fn validate_url_not_reserved(url: &str) -> Result<Vec<SocketAddr>> {
714    let (host, port) = parse_rdap_url(url)?;
715    resolve_rdap_host(&host, port).await
716}
717
718/// Parses an HTTP `Retry-After` header value. Supports the common
719/// delta-seconds form (`Retry-After: 5`); the HTTP-date form is not used by
720/// RDAP rate limiters in practice and yields `None` (caller falls back to
721/// exponential backoff).
722fn parse_retry_after(value: &str) -> Option<Duration> {
723    value.trim().parse::<u64>().ok().map(Duration::from_secs)
724}
725
726/// TTL for cached pinned RDAP clients. Short by design: on expiry the next
727/// request re-runs the full SSRF validation (fresh DNS + reserved-range
728/// check), so pinned addresses are re-checked against rebinding at most a
729/// minute apart, while bulk lookups, confusables scans, and per-query retries
730/// inside the window reuse one connection pool instead of paying DNS + TCP +
731/// TLS handshake per attempt.
732const PINNED_CLIENT_TTL: Duration = Duration::from_secs(60);
733
734/// Cache key for pinned RDAP clients: (host, port, request timeout). The
735/// timeout is part of the key because it is baked into the built
736/// `reqwest::Client` — two `RdapClient`s configured with different timeouts
737/// must not share one pinned client.
738type PinnedClientKey = (String, u16, Duration);
739
740/// Pinned per-host RDAP clients. `reqwest::Client` is Arc-backed, so cloning
741/// out of the cache shares the underlying connection pool. Entries are only
742/// ever inserted after full SSRF validation ([`parse_rdap_url`] +
743/// [`resolve_rdap_host`] in [`send_rdap_request`]);
744/// the loopback requests the `#[cfg(test)]` allow-reserved mode exempts
745/// bypass this cache in both directions (never insert, never read).
746/// Capacity-bounded: evicting
747/// a live entry merely forces a re-validate + rebuild on next use.
748static PINNED_CLIENT_CACHE: LazyLock<crate::cache::TtlCache<PinnedClientKey, Client>> =
749    LazyLock::new(|| crate::cache::TtlCache::with_max_capacity(PINNED_CLIENT_TTL, 64));
750
751/// Client settings shared by every RDAP request. The connect timeout is kept
752/// no larger than the overall one so a sub-5s configured timeout stays
753/// consistent. Redirects are never auto-followed: `resolve_to_addrs` pins only
754/// the first host, so reqwest's policy would re-resolve a 3xx target itself
755/// and bypass the reserved-IP guard. `query_rdap_attempt` follows them one hop
756/// at a time instead, each hop re-entering [`send_rdap_request`] and so
757/// re-running the https check, SSRF validation, and pinning.
758fn rdap_client_builder(timeout: Duration) -> reqwest::ClientBuilder {
759    crate::net::client_builder(timeout)
760        .connect_timeout(CONNECT_TIMEOUT.min(timeout))
761        .user_agent(RDAP_USER_AGENT)
762}
763
764/// Sends one RDAP request: SSRF-validates the URL and pins the resolved IPs on
765/// a cached per-host client (DNS-rebinding defense), returning the raw response.
766///
767/// The pinned client is cached for [`PINNED_CLIENT_TTL`] keyed by
768/// (host, port, timeout), so repeated queries — and retry attempts within one
769/// query — skip the DNS lookup and client build. The cheap URL-shape checks
770/// (parse + https enforcement) still run on every request: keying on host:port
771/// alone would otherwise let a cached https entry serve a plaintext `http://`
772/// URL aimed at the same port.
773///
774/// `allow_reserved` (test seam, see [`RdapClient::allow_reserved`]) skips the
775/// validation and IP pinning for **loopback** URLs only, so `#[cfg(test)]`
776/// mock servers are reachable; any other URL (e.g. a redirect target a test
777/// scripts) still runs the full production guard. Always false on
778/// production paths.
779async fn send_rdap_request(
780    url: &str,
781    timeout: Duration,
782    allow_reserved: bool,
783) -> Result<reqwest::Response> {
784    if allow_reserved && is_loopback_url(url) {
785        // Test seam: build a one-off unpinned client and never touch the
786        // shared pinned-client cache — a test-mode client reaching loopback
787        // must not be servable to a production request (nor vice versa).
788        let client = rdap_client_builder(timeout)
789            .build()
790            .map_err(|e| SeerError::RdapError(format!("failed to build HTTP client: {}", e)))?;
791        return client
792            .get(url)
793            .header(reqwest::header::ACCEPT, RDAP_ACCEPT)
794            .send()
795            .await
796            .map_err(Into::into);
797    }
798
799    // Always-on URL-shape checks (parse + https-only enforcement).
800    let (host, port) = parse_rdap_url(url)?;
801
802    let key = (host.clone(), port, timeout);
803    let client = match PINNED_CLIENT_CACHE.get(&key) {
804        Some(client) => client,
805        None => {
806            // SSRF protection: validate the URL does not resolve to reserved
807            // IPs and capture the resolved SocketAddrs so we can pin them on
808            // the HTTP client. If the host is an IP literal the resolved vec
809            // already holds it, so `resolve_to_addrs` is still correct.
810            let resolved = resolve_rdap_host(&host, port).await?;
811            let client = rdap_client_builder(timeout)
812                .resolve_to_addrs(&host, &resolved)
813                .build()
814                .map_err(|e| SeerError::RdapError(format!("failed to build HTTP client: {}", e)))?;
815            PINNED_CLIENT_CACHE.insert(key.clone(), client.clone());
816            client
817        }
818    };
819
820    match client
821        .get(url)
822        .header(reqwest::header::ACCEPT, RDAP_ACCEPT)
823        .send()
824        .await
825    {
826        Ok(resp) => Ok(resp),
827        Err(e) => {
828            // A connect-level failure may mean the pinned addresses went
829            // stale (host re-IP'd inside the TTL). Evict so the next attempt
830            // re-resolves instead of failing on the same dead pin for the
831            // rest of the window.
832            if e.is_connect() {
833                PINNED_CLIENT_CACHE.remove(&key);
834            }
835            Err(e.into())
836        }
837    }
838}
839
840/// Streams, size-bounds, and parses an RDAP response body. `url` is only used
841/// for the timeout error message. `timeout` is the per-request deadline for the
842/// body-read phase, so a caller-configured timeout is honored end-to-end (not
843/// silently capped at the hardcoded default).
844async fn read_and_parse_rdap_body(
845    response: reqwest::Response,
846    url: &str,
847    timeout: Duration,
848) -> Result<RdapResponse> {
849    // Stream body with incremental size check to prevent memory exhaustion.
850    // A server that opens the connection but trickles bytes forever is
851    // classified as a timeout (not a generic RdapError) so retries can be
852    // driven appropriately.
853    let body = read_body_capped(response, MAX_RDAP_RESPONSE_SIZE, timeout, Overflow::Reject)
854        .await
855        .map_err(|e| match e {
856            BodyReadError::Chunk(e) => {
857                SeerError::RdapError(format!("failed to read response: {}", e))
858            }
859            BodyReadError::TooLarge => SeerError::RdapError(format!(
860                "RDAP response exceeds {} byte limit",
861                MAX_RDAP_RESPONSE_SIZE
862            )),
863            BodyReadError::TimedOut => SeerError::Timeout(format!(
864                "timed out reading RDAP response body from {} after {:?}",
865                url, timeout
866            )),
867        })?;
868
869    let rdap: RdapResponse = serde_json::from_slice(&body)?;
870    // Bound attacker-controlled payload post-deserialization. The 10MB
871    // body cap prevents unbounded download, but a well-formed response
872    // can still pack millions of keys or deeply-nested values into the
873    // serde_json::Map, and adversarial `entities` nesting can drive
874    // recursive walkers to stack-overflow. See RdapResponse::validate.
875    rdap.validate()?;
876    Ok(rdap)
877}
878
879/// Whether `url` targets a loopback IP literal — the only hosts the
880/// `#[cfg(test)]` allow-reserved seam may reach unvalidated.
881fn is_loopback_url(url: &str) -> bool {
882    match url::Url::parse(url).ok().as_ref().and_then(url::Url::host) {
883        Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
884        Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
885        _ => false,
886    }
887}
888
889/// Returns the `Location` of a redirect response RDAP clients follow
890/// (301/302/303/307/308), or `None` for any other status (including a
891/// redirect status without a usable `Location`, which then fails as an
892/// ordinary non-success status).
893fn redirect_location(response: &reqwest::Response) -> Option<&str> {
894    match response.status().as_u16() {
895        301 | 302 | 303 | 307 | 308 => response
896            .headers()
897            .get(reqwest::header::LOCATION)
898            .and_then(|v| v.to_str().ok()),
899        _ => None,
900    }
901}
902
903/// Resolves a redirect `Location` (absolute or relative) against the URL
904/// that returned it. The target is NOT trusted here: the next hop goes back
905/// through [`send_rdap_request`], which enforces https and runs the SSRF
906/// validation + pinning for the new host.
907fn resolve_redirect_target(current: &str, location: &str) -> Result<String> {
908    url::Url::parse(current)
909        .and_then(|base| base.join(location))
910        .map(String::from)
911        .map_err(|e| SeerError::RdapError(format!("invalid RDAP redirect target: {}", e)))
912}
913
914/// One RDAP attempt. On failure, returns the error together with an optional
915/// server-suggested retry delay parsed from a 429 `Retry-After` header so the
916/// caller's backoff can honor it. Uses a cached per-host HTTP client that pins
917/// the validated resolved IPs to prevent DNS rebinding (TOCTOU between
918/// validation and connect); see [`send_rdap_request`].
919///
920/// Redirects (RFC 7480 §5.2 — e.g. ARIN 303 → RIPE for an address it doesn't
921/// hold) are followed manually for up to [`MAX_RDAP_REDIRECTS`] hops. Each
922/// hop re-enters `send_rdap_request`, so an http:// (downgrade) or
923/// reserved/internal target is refused exactly like a bootstrap URL would
924/// be; a URL seen twice aborts the chain as a loop.
925async fn query_rdap_attempt(
926    url: &str,
927    timeout: Duration,
928    allow_reserved: bool,
929) -> std::result::Result<RdapResponse, (SeerError, Option<Duration>)> {
930    let mut current = url.to_string();
931    let mut visited: HashSet<String> = HashSet::new();
932    let response = loop {
933        visited.insert(current.clone());
934        let response = send_rdap_request(&current, timeout, allow_reserved)
935            .await
936            .map_err(|e| (e, None))?;
937        let Some(location) = redirect_location(&response) else {
938            break response;
939        };
940        if visited.len() > MAX_RDAP_REDIRECTS {
941            return Err((
942                SeerError::RdapError(format!(
943                    "RDAP query exceeded {} redirects",
944                    MAX_RDAP_REDIRECTS
945                )),
946                None,
947            ));
948        }
949        let next = resolve_redirect_target(&current, location).map_err(|e| (e, None))?;
950        if visited.contains(&next) {
951            return Err((
952                SeerError::RdapError("RDAP redirect loop detected".to_string()),
953                None,
954            ));
955        }
956        debug!(from = %current, to = %next, "Following RDAP redirect");
957        current = next;
958    };
959
960    if !response.status().is_success() {
961        let status = response.status();
962        // A 429 may carry a `Retry-After`; surface it so the retry loop can
963        // wait exactly as long as the registry asks instead of guessing.
964        let retry_after = if status.as_u16() == 429 {
965            response
966                .headers()
967                .get(reqwest::header::RETRY_AFTER)
968                .and_then(|v| v.to_str().ok())
969                .and_then(parse_retry_after)
970        } else {
971            None
972        };
973        // A 429 is its own error kind, so it survives retry exhaustion as
974        // RateLimited (Python `RateLimitedError`, REST 429) instead of a
975        // generic RDAP failure.
976        let error = if status.as_u16() == 429 {
977            SeerError::RateLimited(format!("RDAP query failed with status {}", status))
978        } else {
979            SeerError::RdapError(format!("query failed with status {}", status))
980        };
981        return Err((error, retry_after));
982    }
983
984    read_and_parse_rdap_body(response, &current, timeout)
985        .await
986        .map_err(|e| (e, None))
987}
988
989/// Loads IANA RDAP bootstrap data from all registries with retry. Only a
990/// load where *every* registry failed is an error (and is retryable); a
991/// partial load succeeds and is merged by [`store_bootstrap_load`].
992async fn load_bootstrap_data_with_retry(policy: RetryPolicy) -> Result<BootstrapLoad> {
993    RetryExecutor::new(policy)
994        .execute(load_bootstrap_data)
995        .await
996}
997
998/// Loads IANA RDAP bootstrap data from all registries. Each section of the
999/// returned [`BootstrapLoad`] is `None` when that registry failed.
1000async fn load_bootstrap_data() -> Result<BootstrapLoad> {
1001    debug!("Loading RDAP bootstrap data from IANA");
1002
1003    // SSRF validation is skipped here — these are hardcoded IANA URLs, not user input.
1004    // User-supplied URLs are still validated in send_rdap_request() via
1005    // validate_url_not_reserved() (which also enforces the https scheme).
1006
1007    let http = rdap_http_client()?;
1008
1009    let dns_future = http.get(IANA_BOOTSTRAP_DNS).send();
1010    let ipv4_future = http.get(IANA_BOOTSTRAP_IPV4).send();
1011    let ipv6_future = http.get(IANA_BOOTSTRAP_IPV6).send();
1012    let asn_future = http.get(IANA_BOOTSTRAP_ASN).send();
1013
1014    // Use join! instead of try_join! so one slow/failing registry doesn't
1015    // block the others. We load whatever data is available.
1016    let (dns_resp, ipv4_resp, ipv6_resp, asn_resp) =
1017        tokio::join!(dns_future, ipv4_future, ipv6_future, asn_future);
1018
1019    async fn read_bootstrap(resp: reqwest::Response) -> Result<BootstrapResponse> {
1020        // Bound the streaming read with the same timeout used for RDAP
1021        // queries. Without this, a slow or stalled IANA response (open TCP
1022        // but no bytes arriving) could hang all RDAP lookups indefinitely
1023        // because `ensure_bootstrap` awaits this future.
1024        let body = read_body_capped(resp, MAX_BOOTSTRAP_SIZE, DEFAULT_TIMEOUT, Overflow::Reject)
1025            .await
1026            .map_err(|e| match e {
1027                BodyReadError::Chunk(e) => {
1028                    SeerError::RdapBootstrapError(format!("failed to read body: {}", e))
1029                }
1030                BodyReadError::TooLarge => SeerError::RdapBootstrapError(format!(
1031                    "bootstrap response too large (exceeds {} bytes)",
1032                    MAX_BOOTSTRAP_SIZE
1033                )),
1034                BodyReadError::TimedOut => SeerError::Timeout(format!(
1035                    "RDAP bootstrap body read timed out after {:?}",
1036                    DEFAULT_TIMEOUT
1037                )),
1038            })?;
1039
1040        serde_json::from_slice(&body).map_err(Into::into)
1041    }
1042
1043    // Parse each response independently, logging failures
1044    let dns_data = match dns_resp {
1045        Ok(resp) => match read_bootstrap(resp).await {
1046            Ok(data) => Some(data),
1047            Err(e) => {
1048                warn!(error = %e, "Failed to parse DNS bootstrap response");
1049                None
1050            }
1051        },
1052        Err(e) => {
1053            warn!(error = %e, "Failed to fetch DNS bootstrap from IANA");
1054            None
1055        }
1056    };
1057    let ipv4_data = match ipv4_resp {
1058        Ok(resp) => match read_bootstrap(resp).await {
1059            Ok(data) => Some(data),
1060            Err(e) => {
1061                warn!(error = %e, "Failed to parse IPv4 bootstrap response");
1062                None
1063            }
1064        },
1065        Err(e) => {
1066            warn!(error = %e, "Failed to fetch IPv4 bootstrap from IANA");
1067            None
1068        }
1069    };
1070    let ipv6_data = match ipv6_resp {
1071        Ok(resp) => match read_bootstrap(resp).await {
1072            Ok(data) => Some(data),
1073            Err(e) => {
1074                warn!(error = %e, "Failed to parse IPv6 bootstrap response");
1075                None
1076            }
1077        },
1078        Err(e) => {
1079            warn!(error = %e, "Failed to fetch IPv6 bootstrap from IANA");
1080            None
1081        }
1082    };
1083    let asn_data = match asn_resp {
1084        Ok(resp) => match read_bootstrap(resp).await {
1085            Ok(data) => Some(data),
1086            Err(e) => {
1087                warn!(error = %e, "Failed to parse ASN bootstrap response");
1088                None
1089            }
1090        },
1091        Err(e) => {
1092            warn!(error = %e, "Failed to fetch ASN bootstrap from IANA");
1093            None
1094        }
1095    };
1096
1097    // Helper: extract and validate all URLs in order, preserving IANA-listed
1098    // ordering. Invalid URLs are logged and skipped rather than rejecting the
1099    // entire service entry. Returns None when no valid URLs remain.
1100    fn collect_valid_urls(urls: &[serde_json::Value]) -> Option<Arc<Vec<url::Url>>> {
1101        let mut out = Vec::new();
1102        for u in urls {
1103            if let Some(s) = u.as_str() {
1104                match validate_bootstrap_url(s) {
1105                    Ok(parsed) => out.push(parsed),
1106                    Err(e) => {
1107                        debug!(url = s, error = %e, "Skipping invalid bootstrap URL");
1108                    }
1109                }
1110            }
1111        }
1112        if out.is_empty() {
1113            None
1114        } else {
1115            Some(Arc::new(out))
1116        }
1117    }
1118
1119    // Helper: parse an IPv4/IPv6 prefix registry into (prefix, urls) pairs.
1120    fn parse_prefix_services(data: BootstrapResponse) -> Vec<(IpRange, Arc<Vec<url::Url>>)> {
1121        let mut out = Vec::new();
1122        for service in data.services {
1123            if service.len() >= 2 {
1124                if let (Some(prefixes), Some(urls)) = (service[0].as_array(), service[1].as_array())
1125                {
1126                    if let Some(urls_arc) = collect_valid_urls(urls) {
1127                        for prefix in prefixes {
1128                            if let Some(prefix_str) = prefix.as_str() {
1129                                out.push((
1130                                    IpRange {
1131                                        prefix: prefix_str.to_string(),
1132                                    },
1133                                    Arc::clone(&urls_arc),
1134                                ));
1135                            }
1136                        }
1137                    }
1138                }
1139            }
1140        }
1141        out
1142    }
1143
1144    // Each section below is `None` when its registry failed. A section that
1145    // fetched but parsed to nothing is treated the same way, so a truncated
1146    // or empty file is retried rather than cached over good data.
1147
1148    // Parse DNS bootstrap
1149    let dns = dns_data
1150        .map(|data| {
1151            let mut dns = HashMap::new();
1152            for service in data.services {
1153                if service.len() >= 2 {
1154                    if let (Some(tlds), Some(urls)) = (service[0].as_array(), service[1].as_array())
1155                    {
1156                        if let Some(urls_arc) = collect_valid_urls(urls) {
1157                            for tld in tlds {
1158                                if let Some(tld_str) = tld.as_str() {
1159                                    dns.insert(tld_str.to_lowercase(), Arc::clone(&urls_arc));
1160                                }
1161                            }
1162                        }
1163                    }
1164                }
1165            }
1166            dns
1167        })
1168        .filter(|dns| !dns.is_empty());
1169
1170    // Parse IPv4 / IPv6 bootstrap
1171    let ipv4 = ipv4_data
1172        .map(parse_prefix_services)
1173        .filter(|v| !v.is_empty());
1174    let ipv6 = ipv6_data
1175        .map(parse_prefix_services)
1176        .filter(|v| !v.is_empty());
1177
1178    // Parse ASN bootstrap
1179    let asn = asn_data
1180        .map(|data| {
1181            let mut asn = Vec::new();
1182            for service in data.services {
1183                if service.len() >= 2 {
1184                    if let (Some(ranges), Some(urls)) =
1185                        (service[0].as_array(), service[1].as_array())
1186                    {
1187                        if let Some(urls_arc) = collect_valid_urls(urls) {
1188                            for range in ranges {
1189                                if let Some(range_str) = range.as_str() {
1190                                    if let Some((start, end)) = parse_asn_range(range_str) {
1191                                        asn.push((AsnRange { start, end }, Arc::clone(&urls_arc)));
1192                                    }
1193                                }
1194                            }
1195                        }
1196                    }
1197                }
1198            }
1199            asn
1200        })
1201        .filter(|v| !v.is_empty());
1202
1203    // If ALL four registries failed, that's a real error. The message is
1204    // matched by `retry::is_retryable` as transient, so the retry policy
1205    // gets another go at IANA.
1206    if dns.is_none() && ipv4.is_none() && ipv6.is_none() && asn.is_none() {
1207        return Err(SeerError::RdapBootstrapError(
1208            "all IANA bootstrap registries failed".to_string(),
1209        ));
1210    }
1211
1212    info!(
1213        dns_entries = dns.as_ref().map_or(0, HashMap::len),
1214        ipv4_ranges = ipv4.as_ref().map_or(0, Vec::len),
1215        ipv6_ranges = ipv6.as_ref().map_or(0, Vec::len),
1216        asn_ranges = asn.as_ref().map_or(0, Vec::len),
1217        "RDAP bootstrap loaded"
1218    );
1219
1220    Ok(BootstrapLoad {
1221        dns,
1222        ipv4,
1223        ipv6,
1224        asn,
1225    })
1226}
1227
1228/// Wraps the "all N candidate URLs failed" case for `query_rdap_urls`.
1229///
1230/// Preserves the `SeerError::Timeout` variant when the last failure was a
1231/// timeout — bare, or after retries (`RetryExhausted` wrapping a timeout,
1232/// the usual shape since timeouts are retryable) — so upstream callers that
1233/// branch on `Timeout` still can. Non-timeout failures are wrapped in a
1234/// generic `RdapError` with the last error's Display in the message. The
1235/// single-candidate case returns the last error unchanged to avoid
1236/// double-wrapping.
1237fn wrap_all_candidates_failed(last_error: Option<SeerError>, candidate_count: usize) -> SeerError {
1238    let last = last_error.unwrap_or_else(|| SeerError::RdapError("no candidates".to_string()));
1239
1240    if candidate_count <= 1 {
1241        return last;
1242    }
1243
1244    match last {
1245        SeerError::Timeout(msg) => SeerError::Timeout(format!(
1246            "all {} RDAP candidate URLs timed out; last error: {}",
1247            candidate_count, msg
1248        )),
1249        SeerError::RetryExhausted { ref last_error, .. }
1250            if matches!(**last_error, SeerError::Timeout(_)) =>
1251        {
1252            SeerError::Timeout(format!(
1253                "all {} RDAP candidate URLs timed out; last error: {}",
1254                candidate_count, last
1255            ))
1256        }
1257        SeerError::RateLimited(_) => SeerError::RateLimited(format!(
1258            "all {} RDAP candidate URLs failed; last error: {}",
1259            candidate_count, last
1260        )),
1261        SeerError::RetryExhausted { ref last_error, .. }
1262            if matches!(**last_error, SeerError::RateLimited(_)) =>
1263        {
1264            SeerError::RateLimited(format!(
1265                "all {} RDAP candidate URLs failed; last error: {}",
1266                candidate_count, last
1267            ))
1268        }
1269        other => SeerError::RdapError(format!(
1270            "all {} RDAP candidate URLs failed; last error: {}",
1271            candidate_count, other
1272        )),
1273    }
1274}
1275
1276/// Builds full RDAP query URLs for each candidate base URL, preserving order.
1277fn build_rdap_urls(bases: &[url::Url], path: &str) -> Vec<url::Url> {
1278    bases
1279        .iter()
1280        .filter_map(|base| {
1281            // Ensure the base URL ends with `/` before joining so the path is
1282            // appended (not replacing the final path segment).
1283            let base_str = base.as_str();
1284            let normalized = if base_str.ends_with('/') {
1285                base_str.to_string()
1286            } else {
1287                format!("{}/", base_str)
1288            };
1289            url::Url::parse(&normalized).and_then(|u| u.join(path)).ok()
1290        })
1291        .collect()
1292}
1293
1294#[cfg(test)]
1295mod tests {
1296    use super::*;
1297
1298    #[test]
1299    fn from_config_applies_rdap_timeout() {
1300        let mut config = crate::config::SeerConfig::default();
1301        config.timeouts.rdap_secs = 33;
1302        let client = RdapClient::from_config(&config);
1303        assert_eq!(client.timeout, Duration::from_secs(33));
1304    }
1305
1306    #[test]
1307    fn test_default_client_has_retry_policy() {
1308        let client = RdapClient::new();
1309        // Tuned up from 2 so 429 rate limits get a couple of backoff-and-retry
1310        // chances, but kept small so a sticky limit falls through to the
1311        // WHOIS/DNS fallback fast instead of hanging.
1312        assert_eq!(client.retry_policy.max_attempts, 3);
1313    }
1314
1315    // --- Retry-After parsing / delay selection (Fix #1) ------------------
1316
1317    #[test]
1318    fn parse_retry_after_parses_delta_seconds() {
1319        assert_eq!(parse_retry_after("5"), Some(Duration::from_secs(5)));
1320        assert_eq!(parse_retry_after("  10 "), Some(Duration::from_secs(10)));
1321        assert_eq!(parse_retry_after("0"), Some(Duration::from_secs(0)));
1322    }
1323
1324    #[test]
1325    fn parse_retry_after_rejects_http_date_and_junk() {
1326        // Only the delta-seconds form is supported; an HTTP-date or garbage
1327        // value yields None (caller falls back to exponential backoff).
1328        assert_eq!(parse_retry_after("Wed, 21 Oct 2015 07:28:00 GMT"), None);
1329        assert_eq!(parse_retry_after("soon"), None);
1330        assert_eq!(parse_retry_after(""), None);
1331    }
1332
1333    #[test]
1334    fn default_policy_caps_retry_after_at_five_seconds() {
1335        // The executor caps a Retry-After hint at the policy's max_delay; the
1336        // RDAP default keeps it at 5s so a sticky limit falls through fast.
1337        assert_eq!(
1338            RdapClient::new().retry_policy.max_delay,
1339            Duration::from_secs(5)
1340        );
1341    }
1342
1343    #[test]
1344    fn test_client_without_retries() {
1345        let client = RdapClient::new().without_retries();
1346        assert_eq!(client.retry_policy.max_attempts, 1);
1347    }
1348
1349    #[test]
1350    fn test_client_custom_retry_policy() {
1351        let policy = RetryPolicy::new().with_max_attempts(5);
1352        let client = RdapClient::new().with_retry_policy(policy);
1353        assert_eq!(client.retry_policy.max_attempts, 5);
1354    }
1355
1356    #[test]
1357    fn test_cached_bootstrap_expiration() {
1358        let data = BootstrapData {
1359            dns: HashMap::new(),
1360            ipv4: Vec::new(),
1361            ipv6: Vec::new(),
1362            asn: Vec::new(),
1363        };
1364        let cached = CachedBootstrap::new(data);
1365        // Fresh cache should not be expired
1366        assert!(!cached.is_expired());
1367    }
1368
1369    #[test]
1370    fn test_rdap_http_client_is_configured() {
1371        // Force lazy initialization and verify it doesn't panic; the real
1372        // reqwest builder is expected to succeed in any normal environment.
1373        let client = rdap_http_client();
1374        assert!(client.is_ok(), "RDAP HTTP client builder must succeed");
1375    }
1376
1377    #[test]
1378    fn test_parse_bootstrap_empty_services() {
1379        // Verifies that parsing empty bootstrap data doesn't panic
1380        let data = BootstrapData {
1381            dns: HashMap::new(),
1382            ipv4: Vec::new(),
1383            ipv6: Vec::new(),
1384            asn: Vec::new(),
1385        };
1386        // Should return None for any lookup on empty data
1387        assert!(RdapClient::get_rdap_urls_for_domain(&data, "example.com").is_none());
1388        assert!(RdapClient::get_rdap_urls_for_asn(&data, 12345).is_none());
1389    }
1390
1391    // --- validate_url_not_reserved tests (C1 regression) ----------------
1392
1393    #[tokio::test]
1394    async fn test_validate_url_not_reserved_rejects_loopback_literal() {
1395        let err = validate_url_not_reserved("https://127.0.0.1/domain/example.com")
1396            .await
1397            .unwrap_err();
1398        assert!(
1399            matches!(err, SeerError::RdapError(ref s) if s.contains("reserved")),
1400            "expected reserved-IP error, got: {:?}",
1401            err
1402        );
1403    }
1404
1405    #[tokio::test]
1406    async fn test_validate_url_not_reserved_rejects_private_ipv4_literal() {
1407        let err = validate_url_not_reserved("https://10.0.0.1/")
1408            .await
1409            .unwrap_err();
1410        assert!(
1411            matches!(err, SeerError::RdapError(ref s) if s.contains("reserved")),
1412            "expected reserved-IP error, got: {:?}",
1413            err
1414        );
1415    }
1416
1417    #[tokio::test]
1418    async fn test_validate_url_not_reserved_rejects_non_https_scheme() {
1419        // Defense-in-depth (M3): an http:// URL to an otherwise-public host must
1420        // be refused at fetch time, independent of bootstrap parse-time
1421        // validation. Uses an IP literal so the check is hermetic (no DNS).
1422        let err = validate_url_not_reserved("http://93.184.216.34/domain/example.com")
1423            .await
1424            .unwrap_err();
1425        assert!(
1426            matches!(err, SeerError::RdapError(ref s) if s.contains("not https")),
1427            "expected non-https rejection, got: {:?}",
1428            err
1429        );
1430    }
1431
1432    #[tokio::test]
1433    async fn test_validate_url_not_reserved_rejects_ipv6_loopback_literal() {
1434        // Also exercises the `Url::host()` unbracketing: "[::1]" must reach
1435        // the shared guard as the parseable literal "::1".
1436        let err = validate_url_not_reserved("https://[::1]/")
1437            .await
1438            .unwrap_err();
1439        assert!(
1440            matches!(err, SeerError::RdapError(ref s) if s.contains("reserved")),
1441            "expected reserved-IP error, got: {:?}",
1442            err
1443        );
1444    }
1445
1446    #[tokio::test]
1447    async fn test_validate_url_not_reserved_returns_resolved_addrs_for_public_literal() {
1448        // A public IP literal should return a one-element vector containing
1449        // exactly that address, ready for `resolve_to_addrs` pinning.
1450        let addrs = validate_url_not_reserved("https://8.8.8.8/").await.unwrap();
1451        assert_eq!(addrs.len(), 1);
1452        assert!(addrs[0].ip().is_ipv4());
1453        assert_eq!(addrs[0].port(), 443);
1454    }
1455
1456    // --- build_rdap_urls tests (M16) ------------------------------------
1457
1458    #[test]
1459    fn test_build_rdap_urls_preserves_order_and_appends_path() {
1460        let bases = vec![
1461            url::Url::parse("https://rdap.a.example/").unwrap(),
1462            url::Url::parse("https://rdap.b.example").unwrap(), // no trailing slash
1463        ];
1464        let built = build_rdap_urls(&bases, "domain/example.com");
1465        assert_eq!(built.len(), 2);
1466        assert_eq!(
1467            built[0].as_str(),
1468            "https://rdap.a.example/domain/example.com"
1469        );
1470        assert_eq!(
1471            built[1].as_str(),
1472            "https://rdap.b.example/domain/example.com"
1473        );
1474    }
1475
1476    #[test]
1477    fn test_build_rdap_urls_empty_input_returns_empty() {
1478        let built = build_rdap_urls(&[], "domain/example.com");
1479        assert!(built.is_empty());
1480    }
1481
1482    // --- wrap_all_candidates_failed tests (Issue 2 regression) ----------
1483
1484    #[test]
1485    fn test_wrap_all_candidates_failed_preserves_timeout_variant() {
1486        // When the last failure was a Timeout, the wrapped error must ALSO
1487        // be a Timeout so upstream retry logic can still branch on it.
1488        let last = SeerError::Timeout("body read timed out".to_string());
1489        let wrapped = wrap_all_candidates_failed(Some(last), 3);
1490        match wrapped {
1491            SeerError::Timeout(msg) => {
1492                assert!(
1493                    msg.contains("all 3 RDAP candidate URLs timed out"),
1494                    "expected wrapped timeout message, got: {}",
1495                    msg
1496                );
1497                assert!(
1498                    msg.contains("body read timed out"),
1499                    "expected original message preserved, got: {}",
1500                    msg
1501                );
1502            }
1503            other => panic!(
1504                "expected SeerError::Timeout after wrapping a Timeout, got: {:?}",
1505                other
1506            ),
1507        }
1508    }
1509
1510    #[test]
1511    fn test_wrap_all_candidates_failed_preserves_retried_timeout() {
1512        // Timeouts are retryable, so a candidate's timeout usually arrives
1513        // wrapped in RetryExhausted; it must still surface as a Timeout.
1514        let last = SeerError::RetryExhausted {
1515            attempts: 3,
1516            last_error: Box::new(SeerError::Timeout("request timed out".to_string())),
1517        };
1518        let wrapped = wrap_all_candidates_failed(Some(last), 2);
1519        assert!(
1520            matches!(wrapped, SeerError::Timeout(ref s)
1521                if s.contains("all 2 RDAP candidate URLs timed out")
1522                    && s.contains("request timed out")),
1523            "expected wrapped Timeout, got: {wrapped:?}"
1524        );
1525        // A retried non-timeout failure stays an RdapError.
1526        let last = SeerError::RetryExhausted {
1527            attempts: 3,
1528            last_error: Box::new(SeerError::RdapError("query failed with status 503".into())),
1529        };
1530        assert!(matches!(
1531            wrap_all_candidates_failed(Some(last), 2),
1532            SeerError::RdapError(_)
1533        ));
1534    }
1535
1536    #[test]
1537    fn test_wrap_all_candidates_failed_wraps_non_timeout_as_rdap_error() {
1538        let last = SeerError::RdapError("500 internal error".to_string());
1539        let wrapped = wrap_all_candidates_failed(Some(last), 2);
1540        assert!(
1541            matches!(wrapped, SeerError::RdapError(ref s) if s.contains("all 2 RDAP candidate URLs failed")),
1542            "expected wrapped RdapError, got: {:?}",
1543            wrapped
1544        );
1545    }
1546
1547    #[test]
1548    fn test_wrap_all_candidates_failed_single_candidate_returns_unchanged() {
1549        // Single-candidate case: return the last error unchanged to avoid
1550        // misleading "all 1 candidates failed" wrapping.
1551        let last = SeerError::Timeout("single timeout".to_string());
1552        let wrapped = wrap_all_candidates_failed(Some(last), 1);
1553        assert!(
1554            matches!(wrapped, SeerError::Timeout(ref s) if s == "single timeout"),
1555            "expected unchanged Timeout, got: {:?}",
1556            wrapped
1557        );
1558    }
1559
1560    #[test]
1561    fn test_wrap_all_candidates_failed_no_last_error_returns_placeholder() {
1562        let wrapped = wrap_all_candidates_failed(None, 0);
1563        assert!(matches!(wrapped, SeerError::RdapError(_)));
1564    }
1565
1566    // --- BOOTSTRAP_LOAD_NOTIFY concurrency test (Issue 1 regression) ----
1567    //
1568    // This test spawns two concurrent `ensure_bootstrap` calls on what is
1569    // effectively a cold/expired cache. The point is to exercise the
1570    // throttle-race path: before the Notify fix, one of the tasks could
1571    // observe `last_attempt.elapsed() < BOOTSTRAP_REFRESH_MIN_INTERVAL`
1572    // with an empty cache and immediately return
1573    // `RdapBootstrapError("bootstrap refresh throttled and no cache available")`.
1574    //
1575    // We cannot easily mock `load_bootstrap_data_with_retry`, but we CAN
1576    // exercise the coordination primitives directly to verify that a waiter
1577    // subscribing to BOOTSTRAP_LOAD_NOTIFY before a notify_waiters() call
1578    // correctly wakes, and that a spurious wake followed by a populated
1579    // cache is treated as success.
1580
1581    // Both bootstrap-notify tests mutate the shared BOOTSTRAP_CACHE static,
1582    // so they must be serialized against each other (cargo test parallelism
1583    // would otherwise race them).
1584    static BOOTSTRAP_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
1585
1586    #[tokio::test]
1587    async fn test_bootstrap_load_notify_wakes_waiter_when_cache_populated() {
1588        let _guard = BOOTSTRAP_TEST_LOCK.lock().await;
1589
1590        // Start from a known-empty state.
1591        {
1592            let mut cache = BOOTSTRAP_CACHE.write().await;
1593            *cache = None;
1594        }
1595
1596        // Construct a notified subscription BEFORE triggering the notify,
1597        // mirroring the order in ensure_bootstrap.
1598        let notified = BOOTSTRAP_LOAD_NOTIFY.notified();
1599        tokio::pin!(notified);
1600
1601        // Simulate a winning loader populating the cache and signalling.
1602        {
1603            let mut cache = BOOTSTRAP_CACHE.write().await;
1604            *cache = Some(CachedBootstrap::new(BootstrapData {
1605                dns: HashMap::new(),
1606                ipv4: Vec::new(),
1607                ipv6: Vec::new(),
1608                asn: Vec::new(),
1609            }));
1610        }
1611        BOOTSTRAP_LOAD_NOTIFY.notify_waiters();
1612
1613        let result = wait_for_in_flight_load(notified).await;
1614        assert!(
1615            result.is_ok(),
1616            "expected waiter to see populated cache, got: {:?}",
1617            result
1618        );
1619
1620        // Clean up so we don't leak state into other tests.
1621        {
1622            let mut cache = BOOTSTRAP_CACHE.write().await;
1623            *cache = None;
1624        }
1625    }
1626
1627    #[tokio::test]
1628    async fn test_bootstrap_load_notify_empty_cache_after_wake_returns_error() {
1629        let _guard = BOOTSTRAP_TEST_LOCK.lock().await;
1630
1631        // Ensure cache is empty.
1632        {
1633            let mut cache = BOOTSTRAP_CACHE.write().await;
1634            *cache = None;
1635        }
1636
1637        let notified = BOOTSTRAP_LOAD_NOTIFY.notified();
1638        tokio::pin!(notified);
1639
1640        // Winner's load failed — they notify with empty cache.
1641        BOOTSTRAP_LOAD_NOTIFY.notify_waiters();
1642
1643        let result = wait_for_in_flight_load(notified).await;
1644        assert!(
1645            matches!(
1646                result,
1647                Err(SeerError::RdapBootstrapError(ref s))
1648                    if s.contains("throttled and no cache available")
1649            ),
1650            "expected throttled error when cache still empty after notify, got: {:?}",
1651            result
1652        );
1653    }
1654
1655    // ---- deterministic mock-server tests -----------------------------------
1656    //
1657    // wiremock serves scripted RDAP responses on 127.0.0.1. These exercise the
1658    // single-endpoint query path (`query_rdap_with_retry` / `query_rdap_urls`)
1659    // directly — no IANA bootstrap involved, so the global bootstrap cache is
1660    // untouched and tests stay parallel-safe. The SSRF guard deliberately
1661    // refuses loopback, so the client uses the `#[cfg(test)]`-only
1662    // `allowing_reserved_for_tests` seam, absent from release builds.
1663
1664    use wiremock::matchers::method;
1665    use wiremock::{Mock, MockServer, ResponseTemplate};
1666
1667    #[tokio::test]
1668    async fn mock_rdap_404_is_nonretryable_typed_error() {
1669        let server = MockServer::start().await;
1670        Mock::given(method("GET"))
1671            .respond_with(ResponseTemplate::new(404))
1672            .mount(&server)
1673            .await;
1674
1675        let client = RdapClient::new()
1676            .without_retries()
1677            .allowing_reserved_for_tests();
1678        let err = client
1679            .query_rdap_with_retry(&format!("{}/domain/example.com", server.uri()))
1680            .await
1681            .unwrap_err();
1682        assert!(
1683            matches!(err, SeerError::RdapError(ref m) if m.contains("404")),
1684            "got: {err:?}"
1685        );
1686    }
1687
1688    #[tokio::test]
1689    async fn mock_rdap_429_honors_retry_after_and_succeeds() {
1690        let server = MockServer::start().await;
1691        // First request: rate-limited with an immediate retry hint. The mock
1692        // expires after one use, so the retry falls through to the 200 below.
1693        Mock::given(method("GET"))
1694            .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "0"))
1695            .up_to_n_times(1)
1696            .mount(&server)
1697            .await;
1698        Mock::given(method("GET"))
1699            .respond_with(ResponseTemplate::new(200).set_body_raw(
1700                r#"{"objectClassName":"domain","handle":"MOCK-1"}"#,
1701                "application/rdap+json",
1702            ))
1703            .mount(&server)
1704            .await;
1705
1706        let client = RdapClient::new().allowing_reserved_for_tests();
1707        let resp = client
1708            .query_rdap_with_retry(&format!("{}/domain/example.com", server.uri()))
1709            .await
1710            .unwrap();
1711        assert_eq!(resp.handle.as_deref(), Some("MOCK-1"));
1712    }
1713
1714    /// Regression: a registry that kept answering 429 surfaced as a generic
1715    /// RDAP error, so Python raised RuntimeError and the API returned 500.
1716    #[tokio::test]
1717    async fn mock_rdap_persistent_429_is_rate_limited() {
1718        let server = MockServer::start().await;
1719        Mock::given(method("GET"))
1720            .respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "0"))
1721            .mount(&server)
1722            .await;
1723
1724        let client = RdapClient::new().allowing_reserved_for_tests();
1725        let err = client
1726            .query_rdap_with_retry(&format!("{}/domain/example.com", server.uri()))
1727            .await
1728            .unwrap_err();
1729        let inner = match &err {
1730            SeerError::RetryExhausted { last_error, .. } => last_error.as_ref(),
1731            other => other,
1732        };
1733        assert!(matches!(inner, SeerError::RateLimited(_)), "got: {err:?}");
1734
1735        // Across several candidate URLs the kind is kept too.
1736        let wrapped = wrap_all_candidates_failed(Some(err), 2);
1737        assert!(
1738            matches!(wrapped, SeerError::RateLimited(_)),
1739            "got: {wrapped:?}"
1740        );
1741    }
1742
1743    #[tokio::test]
1744    async fn mock_rdap_malformed_body_is_parse_error_not_panic() {
1745        let server = MockServer::start().await;
1746        Mock::given(method("GET"))
1747            .respond_with(ResponseTemplate::new(200).set_body_raw("not json", "text/plain"))
1748            .mount(&server)
1749            .await;
1750
1751        let client = RdapClient::new()
1752            .without_retries()
1753            .allowing_reserved_for_tests();
1754        let err = client
1755            .query_rdap_with_retry(&format!("{}/domain/example.com", server.uri()))
1756            .await
1757            .unwrap_err();
1758        assert!(matches!(err, SeerError::JsonError(_)), "got: {err:?}");
1759    }
1760
1761    #[tokio::test]
1762    async fn mock_rdap_candidate_fallback_uses_second_url() {
1763        let bad = MockServer::start().await;
1764        Mock::given(method("GET"))
1765            .respond_with(ResponseTemplate::new(500))
1766            .mount(&bad)
1767            .await;
1768        let good = MockServer::start().await;
1769        Mock::given(method("GET"))
1770            .respond_with(ResponseTemplate::new(200).set_body_raw(
1771                r#"{"objectClassName":"domain","handle":"MOCK-2"}"#,
1772                "application/rdap+json",
1773            ))
1774            .mount(&good)
1775            .await;
1776
1777        let client = RdapClient::new()
1778            .without_retries()
1779            .allowing_reserved_for_tests();
1780        let urls = vec![
1781            url::Url::parse(&format!("{}/domain/example.com", bad.uri())).unwrap(),
1782            url::Url::parse(&format!("{}/domain/example.com", good.uri())).unwrap(),
1783        ];
1784        let resp = client.query_rdap_urls(&urls).await.unwrap();
1785        assert_eq!(resp.handle.as_deref(), Some("MOCK-2"));
1786    }
1787
1788    /// When an earlier candidate authoritatively reports 404 (no such object)
1789    /// but a later candidate fails for a different reason (5xx/timeout/conn),
1790    /// the definitive 404 — the strongest availability signal — must survive in
1791    /// the returned error. Otherwise `rdap_error_is_404` returns false and a
1792    /// genuinely available domain is misreported as inconclusive.
1793    #[tokio::test]
1794    async fn mock_rdap_404_on_first_candidate_survives_later_non_404_failure() {
1795        let not_found = MockServer::start().await;
1796        Mock::given(method("GET"))
1797            .respond_with(ResponseTemplate::new(404))
1798            .mount(&not_found)
1799            .await;
1800        let broken = MockServer::start().await;
1801        Mock::given(method("GET"))
1802            .respond_with(ResponseTemplate::new(500))
1803            .mount(&broken)
1804            .await;
1805
1806        let client = RdapClient::new()
1807            .without_retries()
1808            .allowing_reserved_for_tests();
1809        let urls = vec![
1810            url::Url::parse(&format!("{}/domain/example.com", not_found.uri())).unwrap(),
1811            url::Url::parse(&format!("{}/domain/example.com", broken.uri())).unwrap(),
1812        ];
1813        let err = client.query_rdap_urls(&urls).await.unwrap_err();
1814        assert!(
1815            crate::rdap::rdap_error_is_404(&err),
1816            "404 from candidate 1 must survive candidate 2's non-404 failure, got: {err:?}"
1817        );
1818    }
1819
1820    // ---- pinned-client cache tests ------------------------------------
1821
1822    /// A cache hit must skip DNS resolution and the SSRF re-validation
1823    /// entirely. The test seeds the cache under a host that can never
1824    /// resolve (`.invalid`, RFC 2606): if `send_rdap_request` consults the
1825    /// cache, the request proceeds to the pinned (dead) loopback address and
1826    /// fails with a connect-level `ReqwestError`; if the cache were bypassed,
1827    /// validation would fail first with the DNS-failed `RdapError`.
1828    /// Hermetic: no DNS happens on the hit path, and the loopback connect is
1829    /// refused immediately.
1830    #[tokio::test]
1831    async fn pinned_client_cache_hit_skips_dns_and_revalidation() {
1832        let host = "pinned-cache-hit.seer-test.invalid";
1833        let timeout = Duration::from_secs(2);
1834        let key = (host.to_string(), 443u16, timeout);
1835
1836        // Simulate a prior validated build (production inserts only after
1837        // validate; the pinned address here is loopback purely so the connect
1838        // fails fast without any network). NOTE: `resolve_to_addrs` ignores
1839        // the SocketAddr port — traffic goes to the URL's port (443).
1840        let pinned: Vec<SocketAddr> = vec!["127.0.0.1:443".parse().expect("valid addr")];
1841        let client = Client::builder()
1842            .timeout(timeout)
1843            .connect_timeout(timeout)
1844            .resolve_to_addrs(host, &pinned)
1845            .redirect(reqwest::redirect::Policy::none())
1846            .build()
1847            .expect("client builds");
1848        PINNED_CLIENT_CACHE.insert(key.clone(), client);
1849
1850        let err = send_rdap_request(
1851            &format!("https://{}/domain/example.com", host),
1852            timeout,
1853            false,
1854        )
1855        .await
1856        .unwrap_err();
1857        assert!(
1858            matches!(err, SeerError::ReqwestError { .. }),
1859            "cache hit must reach the connect stage (ReqwestError), not fail \
1860             SSRF validation (RdapError) — got: {err:?}"
1861        );
1862
1863        // Don't leak the synthetic entry into other tests (the connect-error
1864        // eviction usually removes it already; this makes it unconditional).
1865        PINNED_CLIENT_CACHE.remove(&key);
1866    }
1867
1868    /// The `#[cfg(test)]` allow-reserved seam must bypass the shared cache in
1869    /// both directions: a test-mode request (whose client would happily reach
1870    /// loopback) must never insert an entry a production request could be
1871    /// served from.
1872    #[tokio::test]
1873    async fn test_mode_requests_never_populate_pinned_client_cache() {
1874        let server = MockServer::start().await;
1875        Mock::given(method("GET"))
1876            .respond_with(ResponseTemplate::new(200).set_body_raw(
1877                r#"{"objectClassName":"domain","handle":"MOCK-CACHE"}"#,
1878                "application/rdap+json",
1879            ))
1880            .mount(&server)
1881            .await;
1882
1883        let client = RdapClient::new()
1884            .without_retries()
1885            .allowing_reserved_for_tests();
1886        let uri = format!("{}/domain/example.com", server.uri());
1887        let resp = client.query_rdap_with_retry(&uri).await.unwrap();
1888        assert_eq!(resp.handle.as_deref(), Some("MOCK-CACHE"));
1889
1890        // The key the production path would have used for this host must be
1891        // absent (test-mode requests run with the client's default timeout).
1892        let parsed = url::Url::parse(&uri).unwrap();
1893        let host = parsed.host_str().unwrap().to_string();
1894        let port = parsed.port_or_known_default().unwrap();
1895        assert!(
1896            PINNED_CLIENT_CACHE
1897                .get(&(host, port, DEFAULT_TIMEOUT))
1898                .is_none(),
1899            "test-mode request must not populate the shared pinned-client cache"
1900        );
1901    }
1902
1903    // ---- RDAP redirect following (RFC 7480 §5.2) -------------------------
1904
1905    use wiremock::matchers::path;
1906
1907    const MOCK_OK_BODY: &str = r#"{"objectClassName":"domain","handle":"MOCK-REDIRECT"}"#;
1908
1909    #[test]
1910    fn is_loopback_url_only_matches_loopback_literals() {
1911        assert!(is_loopback_url("http://127.0.0.1:8080/domain/x"));
1912        assert!(is_loopback_url("http://[::1]:8080/domain/x"));
1913        assert!(!is_loopback_url("https://10.0.0.1/domain/x"));
1914        assert!(!is_loopback_url("https://rdap.example.com/domain/x"));
1915        assert!(!is_loopback_url("not a url"));
1916    }
1917
1918    #[tokio::test]
1919    async fn mock_rdap_303_redirect_is_followed() {
1920        // ARIN answers 303 → RIPE for an address it doesn't hold; this used
1921        // to surface as "query failed with status 303".
1922        let target = MockServer::start().await;
1923        Mock::given(method("GET"))
1924            .and(path("/ip/192.36.148.17"))
1925            .respond_with(
1926                ResponseTemplate::new(200).set_body_raw(MOCK_OK_BODY, "application/rdap+json"),
1927            )
1928            .expect(1)
1929            .mount(&target)
1930            .await;
1931        let origin = MockServer::start().await;
1932        Mock::given(method("GET"))
1933            .respond_with(
1934                ResponseTemplate::new(303)
1935                    .insert_header("Location", format!("{}/ip/192.36.148.17", target.uri())),
1936            )
1937            .mount(&origin)
1938            .await;
1939
1940        let client = RdapClient::new()
1941            .without_retries()
1942            .allowing_reserved_for_tests();
1943        let resp = client
1944            .query_rdap_with_retry(&format!("{}/ip/192.36.148.17", origin.uri()))
1945            .await
1946            .unwrap();
1947        assert_eq!(resp.handle.as_deref(), Some("MOCK-REDIRECT"));
1948    }
1949
1950    #[tokio::test]
1951    async fn mock_rdap_relative_redirect_is_resolved_against_current_url() {
1952        let server = MockServer::start().await;
1953        Mock::given(method("GET"))
1954            .and(path("/old/domain/example.com"))
1955            .respond_with(
1956                ResponseTemplate::new(301).insert_header("Location", "/new/domain/example.com"),
1957            )
1958            .mount(&server)
1959            .await;
1960        Mock::given(method("GET"))
1961            .and(path("/new/domain/example.com"))
1962            .respond_with(
1963                ResponseTemplate::new(200).set_body_raw(MOCK_OK_BODY, "application/rdap+json"),
1964            )
1965            .mount(&server)
1966            .await;
1967
1968        let client = RdapClient::new()
1969            .without_retries()
1970            .allowing_reserved_for_tests();
1971        let resp = client
1972            .query_rdap_with_retry(&format!("{}/old/domain/example.com", server.uri()))
1973            .await
1974            .unwrap();
1975        assert_eq!(resp.handle.as_deref(), Some("MOCK-REDIRECT"));
1976    }
1977
1978    /// Scripts a redirect to `location` and returns the error the client
1979    /// reports. The test seam only exempts loopback, so the redirect target
1980    /// runs through the full production guard.
1981    async fn redirect_error(location: &str) -> SeerError {
1982        let server = MockServer::start().await;
1983        Mock::given(method("GET"))
1984            .respond_with(ResponseTemplate::new(302).insert_header("Location", location))
1985            .mount(&server)
1986            .await;
1987        let client = RdapClient::new()
1988            .without_retries()
1989            .allowing_reserved_for_tests();
1990        client
1991            .query_rdap_with_retry(&format!("{}/domain/example.com", server.uri()))
1992            .await
1993            .unwrap_err()
1994    }
1995
1996    #[tokio::test]
1997    async fn mock_rdap_redirect_to_plain_http_is_refused() {
1998        // Downgrade: a public IP literal keeps this hermetic (the https check
1999        // fires before any resolution or connect).
2000        let err = redirect_error("http://93.184.216.34/domain/example.com").await;
2001        assert!(
2002            matches!(err, SeerError::RdapError(ref m) if m.contains("not https")),
2003            "got: {err:?}"
2004        );
2005    }
2006
2007    #[tokio::test]
2008    async fn mock_rdap_redirect_to_reserved_address_is_refused() {
2009        for location in [
2010            "https://10.0.0.1/domain/example.com",
2011            "https://169.254.169.254/latest/meta-data/",
2012        ] {
2013            let err = redirect_error(location).await;
2014            assert!(
2015                matches!(err, SeerError::RdapError(ref m) if m.contains("reserved")),
2016                "{location}: got {err:?}"
2017            );
2018        }
2019    }
2020
2021    #[tokio::test]
2022    async fn mock_rdap_redirect_loop_is_detected() {
2023        let server = MockServer::start().await;
2024        Mock::given(method("GET"))
2025            .and(path("/a"))
2026            .respond_with(ResponseTemplate::new(307).insert_header("Location", "/b"))
2027            .mount(&server)
2028            .await;
2029        Mock::given(method("GET"))
2030            .and(path("/b"))
2031            .respond_with(ResponseTemplate::new(307).insert_header("Location", "/a"))
2032            .mount(&server)
2033            .await;
2034        let client = RdapClient::new()
2035            .without_retries()
2036            .allowing_reserved_for_tests();
2037        let err = client
2038            .query_rdap_with_retry(&format!("{}/a", server.uri()))
2039            .await
2040            .unwrap_err();
2041        assert!(
2042            matches!(err, SeerError::RdapError(ref m) if m.contains("loop")),
2043            "got: {err:?}"
2044        );
2045    }
2046
2047    #[tokio::test]
2048    async fn mock_rdap_redirect_chain_is_capped() {
2049        let server = MockServer::start().await;
2050        for hop in 0..=MAX_RDAP_REDIRECTS {
2051            Mock::given(method("GET"))
2052                .and(path(format!("/hop{hop}")))
2053                .respond_with(
2054                    ResponseTemplate::new(308)
2055                        .insert_header("Location", format!("/hop{}", hop + 1)),
2056                )
2057                .mount(&server)
2058                .await;
2059        }
2060        let client = RdapClient::new()
2061            .without_retries()
2062            .allowing_reserved_for_tests();
2063        let err = client
2064            .query_rdap_with_retry(&format!("{}/hop0", server.uri()))
2065            .await
2066            .unwrap_err();
2067        assert!(
2068            matches!(err, SeerError::RdapError(ref m) if m.contains("exceeded")),
2069            "got: {err:?}"
2070        );
2071    }
2072
2073    #[tokio::test]
2074    async fn mock_rdap_sends_accept_header_with_json_fallback() {
2075        // rdap.nic.sn answers 406 to `application/rdap+json` alone.
2076        let server = MockServer::start().await;
2077        Mock::given(method("GET"))
2078            // Compare the raw header: wiremock's `header` matcher splits on
2079            // commas, so it could never equal the whole list.
2080            .and(|req: &wiremock::Request| {
2081                req.headers.get("accept").and_then(|v| v.to_str().ok()) == Some(RDAP_ACCEPT)
2082            })
2083            .respond_with(ResponseTemplate::new(200).set_body_raw(MOCK_OK_BODY, "application/json"))
2084            .mount(&server)
2085            .await;
2086        Mock::given(method("GET"))
2087            .respond_with(ResponseTemplate::new(406))
2088            .mount(&server)
2089            .await;
2090        let client = RdapClient::new()
2091            .without_retries()
2092            .allowing_reserved_for_tests();
2093        let resp = client
2094            .query_rdap_with_retry(&format!("{}/domain/example.sn", server.uri()))
2095            .await
2096            .unwrap();
2097        assert_eq!(resp.handle.as_deref(), Some("MOCK-REDIRECT"));
2098        assert!(RDAP_ACCEPT.contains("application/json"));
2099    }
2100
2101    // ---- bootstrap partial loads (per-registry merge) --------------------
2102
2103    fn url_arc(u: &str) -> Arc<Vec<url::Url>> {
2104        Arc::new(vec![url::Url::parse(u).expect("valid url")])
2105    }
2106
2107    fn full_load() -> BootstrapLoad {
2108        let mut dns = HashMap::new();
2109        dns.insert("com".to_string(), url_arc("https://rdap.verisign.example/"));
2110        BootstrapLoad {
2111            dns: Some(dns),
2112            ipv4: Some(vec![(
2113                IpRange {
2114                    prefix: "8.0.0.0/8".to_string(),
2115                },
2116                url_arc("https://rdap.arin.example/"),
2117            )]),
2118            ipv6: Some(Vec::new()),
2119            asn: Some(vec![(
2120                AsnRange { start: 1, end: 10 },
2121                url_arc("https://rdap.arin.example/"),
2122            )]),
2123        }
2124    }
2125
2126    #[test]
2127    fn complete_bootstrap_load_gets_full_ttl() {
2128        let load = full_load();
2129        assert!(load.is_complete());
2130        assert_eq!(load.ttl(), BOOTSTRAP_TTL);
2131    }
2132
2133    #[test]
2134    fn partial_bootstrap_load_keeps_previous_section_and_short_ttl() {
2135        // Previous (expired) dataset with good DNS data.
2136        let mut previous = Some(CachedBootstrap::with_ttl(
2137            full_load().merge_over(None),
2138            Duration::ZERO,
2139        ));
2140        std::thread::sleep(Duration::from_millis(2));
2141        assert!(previous.as_ref().is_some_and(CachedBootstrap::is_expired));
2142
2143        // Refresh where dns.json failed but the others loaded.
2144        let mut partial = full_load();
2145        partial.dns = None;
2146        assert!(!partial.is_complete());
2147        store_bootstrap_load(&mut previous, partial);
2148
2149        let cached = previous.expect("cache populated");
2150        assert_eq!(cached.ttl, BOOTSTRAP_PARTIAL_TTL);
2151        assert!(
2152            RdapClient::get_rdap_urls_for_domain(&cached.data, "example.com").is_some(),
2153            "the failed dns section must be carried over, not wiped"
2154        );
2155        assert!(RdapClient::get_rdap_urls_for_asn(&cached.data, 5).is_some());
2156    }
2157
2158    #[test]
2159    fn partial_cold_bootstrap_load_leaves_failed_section_empty() {
2160        let mut cache = None;
2161        let mut partial = full_load();
2162        partial.dns = None;
2163        store_bootstrap_load(&mut cache, partial);
2164        let cached = cache.expect("cache populated");
2165        assert_eq!(cached.ttl, BOOTSTRAP_PARTIAL_TTL);
2166        assert!(RdapClient::get_rdap_urls_for_domain(&cached.data, "example.com").is_none());
2167    }
2168
2169    #[test]
2170    fn bootstrap_store_does_not_overwrite_fresh_data() {
2171        let mut fresh = full_load();
2172        fresh.dns = Some(HashMap::from([(
2173            "org".to_string(),
2174            url_arc("https://rdap.pir.example/"),
2175        )]));
2176        let mut cache = Some(CachedBootstrap::new(fresh.merge_over(None)));
2177        store_bootstrap_load(&mut cache, full_load());
2178        let cached = cache.expect("cache populated");
2179        assert!(RdapClient::get_rdap_urls_for_domain(&cached.data, "example.org").is_some());
2180        assert!(RdapClient::get_rdap_urls_for_domain(&cached.data, "example.com").is_none());
2181    }
2182
2183    // ---- in-flight tracking for throttled cold-cache callers -------------
2184
2185    #[tokio::test]
2186    async fn wait_returns_immediately_when_no_load_is_in_flight() {
2187        let _guard = BOOTSTRAP_TEST_LOCK.lock().await;
2188        {
2189            let mut cache = BOOTSTRAP_CACHE.write().await;
2190            *cache = None;
2191        }
2192        assert!(!BOOTSTRAP_LOAD_IN_FLIGHT.load(Ordering::SeqCst));
2193
2194        // A failed cold load already finished: nobody will notify. The
2195        // caller must get the throttle error right away, not after the full
2196        // DEFAULT_TIMEOUT wait.
2197        let notified = BOOTSTRAP_LOAD_NOTIFY.notified();
2198        tokio::pin!(notified);
2199        let result =
2200            tokio::time::timeout(Duration::from_secs(2), wait_for_in_flight_load(notified))
2201                .await
2202                .expect("must not block when no load is in flight");
2203        assert!(
2204            matches!(result, Err(SeerError::RdapBootstrapError(ref s)) if s.contains("throttled")),
2205            "got: {result:?}"
2206        );
2207    }
2208
2209    #[tokio::test]
2210    async fn wait_blocks_until_in_flight_load_finishes() {
2211        let _guard = BOOTSTRAP_TEST_LOCK.lock().await;
2212        {
2213            let mut cache = BOOTSTRAP_CACHE.write().await;
2214            *cache = None;
2215        }
2216
2217        let load = BootstrapLoadGuard::start();
2218        let notified = BOOTSTRAP_LOAD_NOTIFY.notified();
2219        tokio::pin!(notified);
2220
2221        let loader = tokio::spawn(async move {
2222            tokio::time::sleep(Duration::from_millis(50)).await;
2223            {
2224                let mut cache = BOOTSTRAP_CACHE.write().await;
2225                *cache = Some(CachedBootstrap::new(full_load().merge_over(None)));
2226            }
2227            // Clears the flag and notifies, like the end of ensure_bootstrap.
2228            drop(load);
2229        });
2230
2231        let result =
2232            tokio::time::timeout(Duration::from_secs(5), wait_for_in_flight_load(notified))
2233                .await
2234                .expect("waiter must wake via the loader's notify");
2235        assert!(result.is_ok(), "got: {result:?}");
2236        loader.await.expect("loader joined");
2237        assert!(!BOOTSTRAP_LOAD_IN_FLIGHT.load(Ordering::SeqCst));
2238
2239        let mut cache = BOOTSTRAP_CACHE.write().await;
2240        *cache = None;
2241    }
2242
2243    /// Seeds an already-expired dataset and clears the refresh throttle.
2244    async fn seed_stale_bootstrap() {
2245        *BOOTSTRAP_CACHE.write().await = Some(CachedBootstrap::with_ttl(
2246            full_load().merge_over(None),
2247            Duration::ZERO,
2248        ));
2249        *BOOTSTRAP_LAST_ATTEMPT.write().await = None;
2250        tokio::time::sleep(Duration::from_millis(2)).await;
2251        assert!(BOOTSTRAP_CACHE.read().await.as_ref().unwrap().is_expired());
2252    }
2253
2254    /// Stale-while-revalidate: a caller holding stale data is served at once
2255    /// while the refresh runs in the background, and a second caller during
2256    /// that refresh neither waits nor starts another load (single-flight).
2257    #[tokio::test]
2258    async fn stale_bootstrap_is_served_while_refreshing_in_background() {
2259        let _guard = BOOTSTRAP_TEST_LOCK.lock().await;
2260        seed_stale_bootstrap().await;
2261
2262        let (release, gate) = tokio::sync::oneshot::channel::<()>();
2263        let loads = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2264        let counted = loads.clone();
2265        let slow_load = async move {
2266            counted.fetch_add(1, Ordering::SeqCst);
2267            let _ = gate.await;
2268            Ok(full_load())
2269        };
2270
2271        // Returns before the (blocked) load completes.
2272        tokio::time::timeout(Duration::from_secs(1), ensure_bootstrap_with(slow_load))
2273            .await
2274            .expect("a stale read must not wait for the refresh")
2275            .expect("stale data is served");
2276        assert!(BOOTSTRAP_LOAD_IN_FLIGHT.load(Ordering::SeqCst));
2277
2278        // A concurrent caller is served too, without a second load.
2279        *BOOTSTRAP_LAST_ATTEMPT.write().await = None; // only in-flight gates it
2280        let second = loads.clone();
2281        tokio::time::timeout(
2282            Duration::from_secs(1),
2283            ensure_bootstrap_with(async move {
2284                second.fetch_add(1, Ordering::SeqCst);
2285                Ok(full_load())
2286            }),
2287        )
2288        .await
2289        .expect("second stale read must not wait")
2290        .expect("stale data is served");
2291        tokio::task::yield_now().await;
2292        assert_eq!(loads.load(Ordering::SeqCst), 1, "one refresh at a time");
2293
2294        // Let the refresh finish: the cache becomes fresh.
2295        release.send(()).expect("refresh task is waiting");
2296        let refreshed = async {
2297            while BOOTSTRAP_LOAD_IN_FLIGHT.load(Ordering::SeqCst) {
2298                tokio::time::sleep(Duration::from_millis(5)).await;
2299            }
2300        };
2301        tokio::time::timeout(Duration::from_secs(5), refreshed)
2302            .await
2303            .expect("background refresh completes");
2304        {
2305            let cache = BOOTSTRAP_CACHE.read().await;
2306            let cached = cache.as_ref().expect("cache populated");
2307            assert!(!cached.is_expired());
2308            assert_eq!(cached.ttl, BOOTSTRAP_TTL);
2309        }
2310
2311        *BOOTSTRAP_CACHE.write().await = None;
2312        *BOOTSTRAP_LAST_ATTEMPT.write().await = None;
2313    }
2314
2315    /// A failed background refresh keeps serving the stale data.
2316    #[tokio::test]
2317    async fn failed_background_refresh_keeps_stale_bootstrap() {
2318        let _guard = BOOTSTRAP_TEST_LOCK.lock().await;
2319        seed_stale_bootstrap().await;
2320
2321        ensure_bootstrap_with(async {
2322            Err(SeerError::RdapBootstrapError(
2323                "all IANA bootstrap registries failed".to_string(),
2324            ))
2325        })
2326        .await
2327        .expect("stale data is served");
2328        let settled = async {
2329            while BOOTSTRAP_LOAD_IN_FLIGHT.load(Ordering::SeqCst) {
2330                tokio::time::sleep(Duration::from_millis(5)).await;
2331            }
2332        };
2333        tokio::time::timeout(Duration::from_secs(5), settled)
2334            .await
2335            .expect("background refresh settles");
2336        assert!(
2337            BOOTSTRAP_CACHE.read().await.is_some(),
2338            "stale data survives a failed refresh"
2339        );
2340
2341        *BOOTSTRAP_CACHE.write().await = None;
2342        *BOOTSTRAP_LAST_ATTEMPT.write().await = None;
2343    }
2344}