Skip to main content

seer_core/rdap/
client.rs

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