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