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