Skip to main content

seer_core/
lookup.rs

1use std::collections::HashMap;
2use std::net::Ipv6Addr;
3use std::str::FromStr;
4use std::sync::{Arc, Mutex, Weak};
5use std::time::Duration;
6
7use chrono::{DateTime, Utc};
8use once_cell::sync::Lazy;
9use regex::Regex;
10use serde::{Deserialize, Serialize};
11use tokio::sync::Notify;
12use tracing::{debug, instrument, warn};
13
14use tokio::time::timeout as tokio_timeout;
15
16use crate::availability::{AvailabilityChecker, AvailabilityResult, PriorRdap, PriorWhois};
17use crate::cache::TtlCache;
18use crate::dns::{DnsPresence, DnsResolver};
19use crate::error::{Result, SeerError};
20use crate::rdap::{rdap_error_is_404, RdapClient, RdapResponse};
21use crate::whois::{get_registry_url, get_tld, WhoisClient, WhoisResponse};
22
23/// Cache TTL for lookup results (5 minutes).
24const LOOKUP_CACHE_TTL: Duration = Duration::from_secs(5 * 60);
25
26/// Grace period for the second protocol after the first one finishes.
27/// If WHOIS finishes and RDAP hasn't responded within this window, we
28/// use the WHOIS result rather than waiting the full RDAP timeout.
29const PROTOCOL_GRACE_PERIOD: Duration = Duration::from_secs(5);
30
31/// Maximum length for public-facing error strings.
32const MAX_PUBLIC_ERROR_LEN: usize = 256;
33
34/// Upper bound a coalesced waiter will block on the owner's in-flight lookup
35/// before falling through to re-contend for ownership itself. Bounds the wait
36/// so a lost notification (e.g. the owner's future was cancelled/dropped before
37/// it could notify) can't hang the waiter forever. Sized to comfortably exceed
38/// a full concurrent RDAP+WHOIS race (≈15s per protocol plus the grace period
39/// and retries) so the common path always wakes via `notify_waiters()`.
40const DEFAULT_INFLIGHT_WAIT: Duration = Duration::from_secs(30);
41
42/// Global cache for lookup results to avoid redundant network calls.
43static LOOKUP_CACHE: Lazy<TtlCache<String, LookupResult>> =
44    Lazy::new(|| TtlCache::new(LOOKUP_CACHE_TTL));
45
46/// In-flight lookup coalescing map: normalized-domain -> Weak<Notify>.
47/// Only one network race runs per unique domain at a time; concurrent callers
48/// wait on the shared Notify and then read the result from LOOKUP_CACHE.
49static LOOKUP_INFLIGHT: Lazy<Mutex<HashMap<String, Weak<Notify>>>> =
50    Lazy::new(|| Mutex::new(HashMap::new()));
51
52/// Regex patterns for stripping IP literals from public error messages.
53static IPV4_RE: Lazy<Regex> =
54    Lazy::new(|| Regex::new(r"\b(?:\d{1,3}\.){3}\d{1,3}\b").expect("IPV4_RE is a valid regex"));
55
56/// Candidate pattern for IPv6 literals: a hex/colon token containing either
57/// a `::` compression or at least three colons. This catches plausible IPv6
58/// addresses cheaply; each match is then validated by `Ipv6Addr::from_str`
59/// before redaction, so MAC fragments, hex hashes, and similar colon-laden
60/// tokens are left alone.
61static IPV6_CANDIDATE_RE: Lazy<Regex> = Lazy::new(|| {
62    Regex::new(r"\b[0-9a-fA-F:]*(?:::|(?:[0-9a-fA-F]{1,4}:){3,})[0-9a-fA-F:]*\b")
63        .expect("IPV6_CANDIDATE_RE is a valid regex")
64});
65
66/// Redact substrings that parse as valid IPv6 addresses, leaving non-IPv6
67/// tokens (e.g. `af:ba:12`) untouched.
68fn strip_ipv6(msg: &str) -> String {
69    IPV6_CANDIDATE_RE
70        .replace_all(msg, |caps: &regex::Captures| {
71            let candidate = &caps[0];
72            if Ipv6Addr::from_str(candidate).is_ok() {
73                "[ip-redacted]".to_string()
74            } else {
75                candidate.to_string()
76            }
77        })
78        .into_owned()
79}
80
81/// Test-only hook: counts the number of times `lookup_concurrent` is actually
82/// invoked (i.e., the underlying network race runs). Used to verify request
83/// coalescing. Not exposed outside the crate.
84#[cfg(test)]
85static LOOKUP_CONCURRENT_CALLS: Lazy<std::sync::atomic::AtomicUsize> =
86    Lazy::new(|| std::sync::atomic::AtomicUsize::new(0));
87
88/// Returns true if the parsed WHOIS response lacks all key registration
89/// signals: no registrar, no creation date, and no expiration date.
90///
91/// This is a necessary-but-not-sufficient signal for domain availability;
92/// `lookup_concurrent` combines it with an RDAP 404 before routing to the
93/// availability path. Nameservers alone don't disqualify thinness — some
94/// registries return placeholder nameservers for unregistered domains.
95fn whois_response_is_thin(w: &WhoisResponse) -> bool {
96    w.registrar.is_none() && w.creation_date.is_none() && w.expiration_date.is_none()
97}
98
99/// Decides whether a WHOIS response + RDAP error combination should route
100/// to the availability path. Returns `(confidence, method)` when routing is
101/// warranted, `None` to keep the existing `LookupResult::Whois` behavior.
102///
103/// Case A: WHOIS explicitly indicates no registration (highest priority).
104/// Case B: WHOIS returned but lacks registration data AND RDAP returned 404.
105fn classify_whois_leg(
106    w: &WhoisResponse,
107    rdap_err: &SeerError,
108) -> Option<(&'static str, &'static str)> {
109    if w.is_available() {
110        return Some(("high", "whois"));
111    }
112    if whois_response_is_thin(w) && rdap_error_is_404(rdap_err) {
113        return Some(("medium", "whois_thin_response"));
114    }
115    None
116}
117
118/// Wraps `classify_whois_leg` with the "RDAP returned 200" veto: a successful
119/// RDAP response (HTTP 200, even if the body is thin) is positive evidence
120/// that the domain object exists, so we never let a WHOIS-only signal flip
121/// the verdict to "available" in that case. This guards against WHOIS
122/// propagation lag against freshly-provisioned domains the registry has
123/// already begun serving via RDAP. v0.26.6 regression fix.
124fn should_route_to_availability(
125    rdap_returned_200: bool,
126    rdap_seer_error: Option<&SeerError>,
127    whois_data: &WhoisResponse,
128) -> Option<(&'static str, &'static str)> {
129    if rdap_returned_200 {
130        return None;
131    }
132    // `is_available()` streams the raw response (~1 MB worst case) line by
133    // line. Compute it once and reuse — `classify_whois_leg` also calls it,
134    // so the original code paid the scan twice on every non-404 RDAP-error
135    // path. We pre-check Case A here; if it doesn't fire we drop into the
136    // 404+thin Case B branch via `classify_whois_leg`.
137    if whois_data.is_available() {
138        return Some(("high", "whois"));
139    }
140    rdap_seer_error.and_then(|e| {
141        // Case B only: WHOIS is not available, so the only remaining path
142        // is "thin WHOIS + RDAP 404". `classify_whois_leg` will re-check
143        // `is_available()` for free (it's false now), so this is a single
144        // additional thin-check call.
145        classify_whois_leg(whois_data, e)
146    })
147}
148
149/// Verdict for the "thin WHOIS leg + non-200 RDAP failure" fallback block in
150/// [`SmartLookup::lookup_concurrent`].
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152enum ThinFallback {
153    /// The registry refused/throttled the query — no usable registration
154    /// signal. Report inconclusive rather than guessing.
155    Inconclusive,
156    /// Thin WHOIS, RDAP not 200, apex is NXDOMAIN — likely available.
157    Available,
158    /// Thin/no-service WHOIS, RDAP unavailable, apex IS delegated — registered.
159    Registered,
160    /// Preconditions not met — keep the existing [`LookupResult::Whois`].
161    UseWhois,
162}
163
164/// Pure decision for the thin-WHOIS fallback block. Encodes the same issue-#45
165/// precedence as [`crate::availability::decide_fallback`] so the smart-lookup
166/// hot path and the dedicated availability path cannot diverge: a registry that
167/// *refused/throttled* the query (rather than authoritatively answering) is
168/// inconclusive and must NOT be inverted into "available" or fail-safed to
169/// "registered" on the strength of DNS presence.
170///
171/// `whois_refuses` is [`crate::whois::WhoisResponse::indicates_registry_refusal`]
172/// precomputed by the caller. Routes are only considered when the WHOIS body was
173/// thin (no registrar/dates) and RDAP did NOT return an HTTP 200 — a 200, even
174/// with a thin body, proves the domain object exists and vetoes every
175/// DNS-derived reclassification. [`DnsPresence::Unknown`] (a failed probe) is not
176/// positive evidence either way, so it falls through to WHOIS.
177fn classify_thin_fallback(
178    is_thin: bool,
179    rdap_returned_200: bool,
180    whois_refuses: bool,
181    dns: DnsPresence,
182) -> ThinFallback {
183    if !is_thin || rdap_returned_200 {
184        return ThinFallback::UseWhois;
185    }
186    if whois_refuses {
187        return ThinFallback::Inconclusive;
188    }
189    match dns {
190        DnsPresence::Absent => ThinFallback::Available,
191        DnsPresence::Present => ThinFallback::Registered,
192        DnsPresence::Unknown => ThinFallback::UseWhois,
193    }
194}
195
196/// Sanitizes an error message for inclusion in a public-facing response.
197///
198/// Strips IPv4 and IPv6 literals (to avoid leaking internal addresses when
199/// an SSRF guard rejects a resolved URL) and caps the total length to
200/// [`MAX_PUBLIC_ERROR_LEN`] characters.
201fn sanitize_error_for_public(msg: &str) -> String {
202    let s = IPV4_RE.replace_all(msg, "[ip-redacted]");
203    let s = strip_ipv6(&s);
204    if s.chars().count() > MAX_PUBLIC_ERROR_LEN {
205        let mut trunc: String = s.chars().take(MAX_PUBLIC_ERROR_LEN).collect();
206        trunc.push('…');
207        trunc
208    } else {
209        s
210    }
211}
212
213/// RAII guard for the in-flight-lookup slot. On drop, removes the entry
214/// from `LOOKUP_INFLIGHT` and notifies any waiters so they can read the
215/// freshly-populated cache.
216///
217/// NOTE on failed-owner retry semantics:
218/// When the owning task's lookup fails, `InflightGuard::drop` runs, the
219/// `HashMap` entry is removed, and `notify_waiters()` fires. Waiters wake,
220/// observe an empty cache, and one of them becomes the new owner — triggering
221/// a fresh network race. This means transient failures are automatically
222/// retried by any concurrent waiter. Callers that observe a timeout error
223/// should not assume no work is in flight; another concurrent caller may
224/// already be retrying.
225struct InflightGuard {
226    key: String,
227    notify: Arc<Notify>,
228}
229
230impl Drop for InflightGuard {
231    fn drop(&mut self) {
232        // Always remove the entry before notifying. The earlier `try_lock`
233        // design skipped removal under contention, but that left a stale
234        // `Weak<Notify>` in the map: a caller arriving in the brief window
235        // between `notify_waiters()` firing and the owner's `Arc<Notify>`
236        // dropping could upgrade the Weak, register as a waiter on the
237        // already-fired Notify, and block forever (notify_waiters only
238        // wakes currently-registered waiters; it does not accumulate
239        // permits for later registrations).
240        //
241        // Contention windows on this `std::sync::Mutex<HashMap>` are
242        // microseconds — the brief block here is safer than the stale-entry
243        // hazard. Poisoned-mutex recovery is preserved.
244        let mut inflight = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
245        inflight.remove(&self.key);
246        drop(inflight);
247        self.notify.notify_waiters();
248    }
249}
250
251/// Internal classification of the RDAP leg of a concurrent lookup.
252///
253/// Distinguishing `NoData` (HTTP 200 but response was missing useful fields)
254/// from `Error` lets the orchestrator prefer a thin WHOIS result over the
255/// availability fallback when RDAP silently returned nothing.
256enum RdapOutcome {
257    Useful(RdapResponse),
258    NoData(RdapResponse),
259    Error(SeerError),
260    /// RDAP future did not complete within the grace period after the other
261    /// protocol finished.
262    GraceTimeout,
263}
264
265/// Progress callback for smart lookup operations.
266/// Called with a message describing the current phase of the lookup.
267pub type LookupProgressCallback = Arc<dyn Fn(&str) + Send + Sync>;
268
269#[derive(Debug, Clone, Serialize, Deserialize)]
270#[serde(tag = "source", rename_all = "lowercase")]
271pub enum LookupResult {
272    Rdap {
273        data: Box<RdapResponse>,
274        #[serde(skip_serializing_if = "Option::is_none")]
275        whois_fallback: Option<WhoisResponse>,
276    },
277    Whois {
278        data: WhoisResponse,
279        rdap_error: Option<String>,
280        #[serde(skip_serializing_if = "Option::is_none")]
281        rdap_fallback: Option<Box<RdapResponse>>,
282    },
283    Available {
284        data: Box<AvailabilityResult>,
285        rdap_error: String,
286        whois_error: String,
287        /// Raw WHOIS response, when one was available at routing time
288        /// (Cases A and B in the design spec). `None` preserves the
289        /// pre-existing "both protocols errored" semantics.
290        #[serde(default, skip_serializing_if = "Option::is_none")]
291        whois_data: Option<WhoisResponse>,
292    },
293}
294
295impl LookupResult {
296    /// Returns the domain name from the lookup result.
297    pub fn domain_name(&self) -> Option<String> {
298        match self {
299            LookupResult::Rdap { data, .. } => data.domain_name().map(String::from),
300            LookupResult::Whois { data, .. } => Some(data.domain.clone()),
301            LookupResult::Available { data, .. } => Some(data.domain.clone()),
302        }
303    }
304
305    /// Returns the registrar name, preferring RDAP data with WHOIS fallback.
306    pub fn registrar(&self) -> Option<String> {
307        match self {
308            LookupResult::Rdap {
309                data,
310                whois_fallback,
311            } => data
312                .get_registrar()
313                .or_else(|| whois_fallback.as_ref().and_then(|w| w.registrar.clone())),
314            LookupResult::Whois { data, .. } => data.registrar.clone(),
315            LookupResult::Available { .. } => None,
316        }
317    }
318
319    /// Returns the registrant organization, preferring RDAP data with WHOIS fallback.
320    pub fn organization(&self) -> Option<String> {
321        match self {
322            LookupResult::Rdap {
323                data,
324                whois_fallback,
325            } => data
326                .get_registrant_organization()
327                .or_else(|| whois_fallback.as_ref().and_then(|w| w.organization.clone())),
328            LookupResult::Whois { data, .. } => data.organization.clone(),
329            LookupResult::Available { .. } => None,
330        }
331    }
332
333    /// Returns true if the result came from RDAP.
334    pub fn is_rdap(&self) -> bool {
335        matches!(self, LookupResult::Rdap { .. })
336    }
337
338    /// Returns true if the result came from WHOIS.
339    pub fn is_whois(&self) -> bool {
340        matches!(self, LookupResult::Whois { .. })
341    }
342
343    /// Returns true if the result is an availability check fallback.
344    pub fn is_available(&self) -> bool {
345        matches!(self, LookupResult::Available { .. })
346    }
347
348    /// Returns the expiration date and registrar info from the lookup result.
349    pub fn expiration_info(&self) -> (Option<DateTime<Utc>>, Option<String>) {
350        match self {
351            LookupResult::Rdap {
352                data,
353                whois_fallback,
354            } => {
355                // Try to get expiration from RDAP events
356                let expiration_date = data
357                    .events
358                    .iter()
359                    .find(|e| e.event_action == "expiration")
360                    .and_then(|e| e.parsed_date())
361                    .or_else(|| {
362                        // Fallback to WHOIS if available
363                        whois_fallback.as_ref().and_then(|w| w.expiration_date)
364                    });
365
366                let registrar = data
367                    .get_registrar()
368                    .or_else(|| whois_fallback.as_ref().and_then(|w| w.registrar.clone()));
369
370                (expiration_date, registrar)
371            }
372            LookupResult::Whois { data, .. } => (data.expiration_date, data.registrar.clone()),
373            LookupResult::Available { .. } => (None, None),
374        }
375    }
376}
377
378/// Truncates `s` to at most `max` bytes, backing up to the nearest UTF-8 char
379/// boundary at or below `max`. `String::truncate` panics if the byte offset is
380/// not a char boundary, and WHOIS `raw_response` is server-controlled and may
381/// preserve multi-byte UTF-8, so we must not truncate blindly at a fixed byte
382/// offset. (`str::floor_char_boundary` would do this, but it is unstable on
383/// stable Rust, so we walk backwards manually.)
384fn truncate_on_char_boundary(s: &mut String, max: usize) {
385    if s.len() > max {
386        let mut end = max;
387        while end > 0 && !s.is_char_boundary(end) {
388            end -= 1;
389        }
390        s.truncate(end);
391    }
392}
393
394/// Trims an oversized WHOIS `raw_response` in place (char-boundary safe),
395/// appending a truncation marker. A raw body can be up to 1 MB (the WHOIS
396/// client's response cap); 32 KB is plenty for the parsed fields while
397/// bounding memory wherever responses are retained — the lookup cache here
398/// and the bulk executor's buffered results.
399pub(crate) fn trim_whois_raw(whois: &mut WhoisResponse) {
400    const MAX_RAW: usize = 32 * 1024;
401    if whois.raw_response.len() > MAX_RAW {
402        truncate_on_char_boundary(&mut whois.raw_response, MAX_RAW);
403        whois.raw_response.push_str("\n... [truncated]");
404    }
405}
406
407/// Trims any retained raw WHOIS body inside a [`LookupResult`] via
408/// [`trim_whois_raw`]. Applied before caching lookups and by the bulk
409/// executor before buffering results.
410pub(crate) fn trim_raw_response(mut result: LookupResult) -> LookupResult {
411    match result {
412        LookupResult::Whois { ref mut data, .. } => trim_whois_raw(data),
413        LookupResult::Rdap {
414            ref mut whois_fallback,
415            ..
416        } => {
417            if let Some(w) = whois_fallback {
418                trim_whois_raw(w);
419            }
420        }
421        LookupResult::Available {
422            ref mut whois_data, ..
423        } => {
424            if let Some(w) = whois_data {
425                trim_whois_raw(w);
426            }
427        }
428    }
429
430    result
431}
432
433#[derive(Debug, Clone)]
434pub struct SmartLookup {
435    rdap_client: RdapClient,
436    whois_client: WhoisClient,
437    availability_checker: AvailabilityChecker,
438    dns_resolver: DnsResolver,
439    /// Deprecated: both protocols are now always attempted concurrently.
440    prefer_rdap: bool,
441    /// Deprecated: WHOIS data is now always attached when available.
442    include_fallback: bool,
443}
444
445impl Default for SmartLookup {
446    fn default() -> Self {
447        Self::new()
448    }
449}
450
451impl SmartLookup {
452    /// Creates a new SmartLookup that runs RDAP and WHOIS concurrently,
453    /// falling back to an availability check if both fail.
454    pub fn new() -> Self {
455        Self {
456            rdap_client: RdapClient::new(),
457            whois_client: WhoisClient::new(),
458            availability_checker: AvailabilityChecker::new(),
459            dns_resolver: DnsResolver::new(),
460            prefer_rdap: true,
461            include_fallback: false,
462        }
463    }
464
465    /// Builds a SmartLookup whose RDAP/WHOIS/DNS sub-clients honor the
466    /// per-protocol timeouts in `config`.
467    pub fn from_config(config: &crate::config::SeerConfig) -> Self {
468        Self {
469            rdap_client: RdapClient::new().with_timeout(config.rdap_timeout()),
470            whois_client: WhoisClient::new().with_timeout(config.whois_timeout()),
471            availability_checker: AvailabilityChecker::from_config(config),
472            dns_resolver: DnsResolver::new().with_timeout(config.dns_timeout()),
473            prefer_rdap: true,
474            include_fallback: false,
475        }
476    }
477
478    /// Deprecated: both protocols are now always attempted concurrently.
479    /// This method is kept for API compatibility but has no effect.
480    #[deprecated(note = "This field has no effect. RDAP is always tried concurrently with WHOIS.")]
481    pub fn prefer_rdap(mut self, prefer: bool) -> Self {
482        self.prefer_rdap = prefer;
483        self
484    }
485
486    /// Deprecated: WHOIS data is now always attached when available.
487    /// This method is kept for API compatibility but has no effect.
488    #[deprecated(note = "This field has no effect. RDAP is always tried concurrently with WHOIS.")]
489    pub fn include_fallback(mut self, include: bool) -> Self {
490        self.include_fallback = include;
491        self
492    }
493
494    /// Performs a smart lookup for a domain, trying both RDAP and WHOIS concurrently.
495    /// Falls back to an availability check if both fail.
496    /// Results are cached for 5 minutes to avoid redundant network calls.
497    #[instrument(skip(self), fields(domain = %domain))]
498    pub async fn lookup(&self, domain: &str) -> Result<LookupResult> {
499        self.lookup_with_progress(domain, None).await
500    }
501
502    /// Performs a lookup with an optional progress callback.
503    /// The callback is called with messages describing the current phase.
504    /// Results are cached for 5 minutes. Concurrent lookups for the same
505    /// domain are coalesced — only one network race runs per domain at a time.
506    #[instrument(skip(self, progress), fields(domain = %domain))]
507    pub async fn lookup_with_progress(
508        &self,
509        domain: &str,
510        progress: Option<LookupProgressCallback>,
511    ) -> Result<LookupResult> {
512        let normalized = crate::validation::normalize_domain(domain)?;
513
514        // Check cache first
515        if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
516            debug!(domain = %normalized, "Returning cached lookup result");
517            return Ok(cached);
518        }
519
520        // Coalesce in-flight lookups: if another task is already running a
521        // race for this domain, wait on its Notify rather than starting a
522        // second race. Two branches:
523        //   - Waiter: another task owns the slot; await its notify, then
524        //     read the cache. If the cache is still empty (owner failed),
525        //     loop and re-contend for ownership.
526        //   - Owner: no entry exists; insert a Weak handle, hold the Arc
527        //     for the duration of the work, then remove and notify on drop.
528        //
529        // A `loop` with a separate lock-scope per iteration keeps the
530        // `MutexGuard` from being held across any `.await`.
531        let _guard = loop {
532            enum Slot {
533                Waiter(Arc<Notify>),
534                Owner(InflightGuard),
535            }
536
537            let slot = {
538                // Recover from poisoning rather than panicking: a prior
539                // owner's panic should not permanently wedge the in-flight
540                // tracker for every future lookup.
541                let mut inflight = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
542                match inflight.get(&normalized).and_then(|w| w.upgrade()) {
543                    Some(existing) => Slot::Waiter(existing),
544                    None => {
545                        let n = Arc::new(Notify::new());
546                        inflight.insert(normalized.clone(), Arc::downgrade(&n));
547                        Slot::Owner(InflightGuard {
548                            key: normalized.clone(),
549                            notify: n,
550                        })
551                    }
552                }
553            };
554
555            match slot {
556                Slot::Waiter(n) => {
557                    debug!(domain = %normalized, "Waiting for in-flight lookup to complete");
558                    // Ordering requirement (mirrors rdap::client::ensure_bootstrap):
559                    // construct and pin the `Notified` future BEFORE we re-check
560                    // the cache. `Notify::notified()` reserves the wakeup slot the
561                    // moment it is constructed (only `.await` blocks), so a
562                    // `notify_waiters()` fired by the owner's `InflightGuard::drop`
563                    // after this point cannot be missed. Without this, the owner
564                    // could drop (removing the map entry and firing
565                    // `notify_waiters()`) in the gap between us releasing the lock
566                    // above and subscribing here — `notify_waiters` stores no
567                    // permit for late subscribers, so the waiter would hang.
568                    let notified = n.notified();
569                    tokio::pin!(notified);
570
571                    // Re-check the cache now that we're subscribed: the owner may
572                    // have populated it and notified in the gap between the lock
573                    // release and our subscription.
574                    if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
575                        return Ok(cached);
576                    }
577
578                    // Bounded wait: if the owner's future was cancelled/dropped
579                    // without notifying, or the notification was otherwise lost,
580                    // fall through and re-contend for ownership rather than
581                    // hanging forever. The RDAP timeout is a sensible bound for a
582                    // single domain lookup race.
583                    let _ = tokio_timeout(DEFAULT_INFLIGHT_WAIT, notified.as_mut()).await;
584
585                    if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
586                        return Ok(cached);
587                    }
588                    // Owner finished without populating the cache (failed or
589                    // errored), or the wait timed out. Re-contend for ownership.
590                    continue;
591                }
592                Slot::Owner(guard) => break guard,
593            }
594        };
595
596        let result = self.lookup_concurrent(&normalized, progress).await?;
597
598        // Cache a trimmed copy to limit memory usage before releasing
599        // waiters (via guard drop) so they observe the cached value.
600        LOOKUP_CACHE.insert(normalized.clone(), trim_raw_response(result.clone()));
601
602        Ok(result)
603    }
604
605    /// Clears the lookup result cache.
606    pub fn clear_cache() {
607        LOOKUP_CACHE.clear();
608    }
609
610    #[instrument(skip(self, progress), fields(domain = %domain))]
611    async fn lookup_concurrent(
612        &self,
613        domain: &str,
614        progress: Option<LookupProgressCallback>,
615    ) -> Result<LookupResult> {
616        #[cfg(test)]
617        LOOKUP_CONCURRENT_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
618
619        debug!(domain = %domain, "Attempting RDAP and WHOIS concurrently");
620
621        if let Some(ref cb) = progress {
622            cb("Querying RDAP and WHOIS concurrently");
623        }
624
625        let rdap_fut = self.rdap_client.lookup_domain(domain);
626        let whois_fut = self.whois_client.lookup(domain);
627
628        tokio::pin!(rdap_fut);
629        tokio::pin!(whois_fut);
630
631        // Race: whichever finishes first gets a grace period for the other.
632        //
633        // We track whether each side completed naturally or was truncated by
634        // the grace period, so downstream error messages can distinguish a
635        // true timeout from a loser-truncation.
636        enum LegOutcome<T> {
637            Completed(T),
638            GraceTruncated,
639        }
640
641        let (rdap_leg, whois_leg) = tokio::select! {
642            rdap_res = &mut rdap_fut => {
643                // RDAP finished first — give WHOIS a grace period
644                let whois_leg = match tokio_timeout(PROTOCOL_GRACE_PERIOD, whois_fut).await {
645                    Ok(res) => LegOutcome::Completed(res),
646                    Err(_) => {
647                        debug!("WHOIS did not finish within grace period, proceeding with RDAP only");
648                        LegOutcome::GraceTruncated
649                    }
650                };
651                (LegOutcome::Completed(rdap_res), whois_leg)
652            }
653            whois_res = &mut whois_fut => {
654                // WHOIS finished first — give RDAP a grace period
655                let rdap_leg = match tokio_timeout(PROTOCOL_GRACE_PERIOD, rdap_fut).await {
656                    Ok(res) => LegOutcome::Completed(res),
657                    Err(_) => {
658                        debug!("RDAP did not finish within grace period, proceeding with WHOIS only");
659                        LegOutcome::GraceTruncated
660                    }
661                };
662                (rdap_leg, LegOutcome::Completed(whois_res))
663            }
664        };
665
666        // Classify the RDAP leg.
667        let rdap_outcome = match rdap_leg {
668            LegOutcome::Completed(Ok(data)) => {
669                if self.is_rdap_response_useful(&data) {
670                    RdapOutcome::Useful(data)
671                } else {
672                    RdapOutcome::NoData(data)
673                }
674            }
675            LegOutcome::Completed(Err(e)) => RdapOutcome::Error(e),
676            LegOutcome::GraceTruncated => RdapOutcome::GraceTimeout,
677        };
678
679        // Phase 1: If RDAP returned useful data, use it as primary.
680        if let RdapOutcome::Useful(rdap_data) = rdap_outcome {
681            debug!("RDAP lookup successful");
682            let whois_fallback = match whois_leg {
683                LegOutcome::Completed(Ok(w)) => Some(w),
684                _ => None,
685            };
686            return Ok(LookupResult::Rdap {
687                data: Box::new(rdap_data),
688                whois_fallback,
689            });
690        }
691
692        // RDAP was not useful (NoData, Error, or GraceTimeout). Prefer WHOIS
693        // if it returned any response, even a thin one — this is safer than
694        // falling back to the availability heuristic when we have actual
695        // registry data in hand.
696        //
697        // We separately track whether RDAP returned an HTTP 200 (NoData):
698        // even a thin RDAP 200 is positive evidence the domain object
699        // exists. In that case we must NOT reclassify a WHOIS "no match"
700        // signal as availability — WHOIS lag against a freshly-provisioned
701        // domain would otherwise produce a false "available" verdict.
702        let rdap_returned_200 = matches!(rdap_outcome, RdapOutcome::NoData(_));
703        let (rdap_error_str, rdap_fallback_data, rdap_seer_error) = match rdap_outcome {
704            RdapOutcome::Useful(_) => {
705                // Unreachable in this branch (we returned above), but handle
706                // defensively rather than panicking across the FFI boundary.
707                debug!("Unexpected RdapOutcome::Useful in fallback branch");
708                (String::from("RDAP ok"), None, None)
709            }
710            RdapOutcome::NoData(data) => (
711                "RDAP response incomplete".to_string(),
712                Some(Box::new(data)),
713                None,
714            ),
715            RdapOutcome::Error(e) => (e.to_string(), None, Some(e)),
716            RdapOutcome::GraceTimeout => (
717                format!(
718                    "RDAP did not return within {}s grace period after WHOIS won",
719                    PROTOCOL_GRACE_PERIOD.as_secs()
720                ),
721                None,
722                None,
723            ),
724        };
725
726        if let LegOutcome::Completed(Ok(whois_data)) = whois_leg {
727            // Check Cases A and B: should we reclassify as Available? The
728            // `should_route_to_availability` helper also enforces the
729            // "RDAP returned 200 vetoes WHOIS availability claims" rule.
730            let availability_match = should_route_to_availability(
731                rdap_returned_200,
732                rdap_seer_error.as_ref(),
733                &whois_data,
734            );
735
736            if let Some((confidence, method)) = availability_match {
737                debug!(
738                    domain = %domain,
739                    confidence = %confidence,
740                    "Reclassifying WHOIS as availability signal"
741                );
742                if let Some(ref cb) = progress {
743                    cb("Domain appears unregistered");
744                }
745                let details = match confidence {
746                    "high" => Some("WHOIS indicates domain is not registered".to_string()),
747                    "medium" => Some(
748                        "WHOIS returned no registrar or registration dates; RDAP returned 404"
749                            .to_string(),
750                    ),
751                    _ => None,
752                };
753                let avail = AvailabilityResult {
754                    domain: domain.to_string(),
755                    available: true,
756                    confidence: confidence.to_string(),
757                    method: method.to_string(),
758                    details,
759                };
760                return Ok(LookupResult::Available {
761                    data: Box::new(avail),
762                    rdap_error: sanitize_error_for_public(&rdap_error_str),
763                    whois_error: String::new(),
764                    whois_data: Some(whois_data),
765                });
766            }
767
768            // Fix #2 safety net: a thin WHOIS body plus an RDAP failure that was
769            // not an authoritative 404 leaves us without registry data. Route the
770            // verdict through `classify_thin_fallback`, which shares the issue-#45
771            // precedence with `availability::decide_fallback` so the two fallback
772            // paths cannot diverge: a registry refusal/throttle is inconclusive
773            // (never inverted into "available" or fail-safed to "registered");
774            // otherwise an NXDOMAIN apex reads as available and a delegated apex
775            // reads as registered. The cheap thin / not-200 preconditions gate the
776            // DNS probe so we don't pay for it on the common paths, and a refusal
777            // short-circuits before the probe entirely.
778            let whois_is_thin = whois_response_is_thin(&whois_data);
779            if whois_is_thin && !rdap_returned_200 {
780                let whois_refuses = whois_data.indicates_registry_refusal();
781                let dns_presence = if whois_refuses {
782                    // A refusal already settles the verdict; skip the DNS probe.
783                    DnsPresence::Unknown
784                } else {
785                    self.dns_resolver.presence(domain).await
786                };
787                match classify_thin_fallback(
788                    whois_is_thin,
789                    rdap_returned_200,
790                    whois_refuses,
791                    dns_presence,
792                ) {
793                    ThinFallback::Inconclusive => {
794                        debug!(domain = %domain, "Thin WHOIS is a registry refusal/throttle; availability inconclusive (issue #45)");
795                        if let Some(ref cb) = progress {
796                            cb("Registry refused or throttled the query (availability inconclusive)");
797                        }
798                        let avail = AvailabilityResult {
799                            domain: domain.to_string(),
800                            available: false,
801                            confidence: "none".to_string(),
802                            method: "inconclusive".to_string(),
803                            details: Some(
804                                "Registry refused or throttled the query; availability is inconclusive"
805                                    .to_string(),
806                            ),
807                        };
808                        return Ok(LookupResult::Available {
809                            data: Box::new(avail),
810                            rdap_error: sanitize_error_for_public(&rdap_error_str),
811                            whois_error: String::new(),
812                            whois_data: Some(whois_data),
813                        });
814                    }
815                    ThinFallback::Available => {
816                        debug!(domain = %domain, "Thin WHOIS + NXDOMAIN, reclassifying as available");
817                        if let Some(ref cb) = progress {
818                            cb("Domain appears unregistered (no DNS presence)");
819                        }
820                        let avail = AvailabilityResult {
821                            domain: domain.to_string(),
822                            available: true,
823                            confidence: "medium".to_string(),
824                            method: "dns_nxdomain".to_string(),
825                            details: Some(
826                                "No registry data available; domain has no DNS presence (NXDOMAIN)"
827                                    .to_string(),
828                            ),
829                        };
830                        return Ok(LookupResult::Available {
831                            data: Box::new(avail),
832                            rdap_error: sanitize_error_for_public(&rdap_error_str),
833                            whois_error: String::new(),
834                            whois_data: Some(whois_data),
835                        });
836                    }
837                    ThinFallback::Registered => {
838                        debug!(domain = %domain, "Thin/no-service WHOIS + DNS delegation, reporting registered");
839                        if let Some(ref cb) = progress {
840                            cb("Domain is registered (registry detail unavailable)");
841                        }
842                        let details = if whois_data.registry_unavailable() {
843                            "Domain is registered (the apex is delegated in DNS). This TLD's \
844                             registry provides no port-43 WHOIS data and RDAP was unavailable \
845                             (rate-limited or unreachable); retry shortly for full RDAP detail."
846                        } else {
847                            "Domain is registered (the apex is delegated in DNS). Registry detail \
848                             was unavailable (RDAP rate-limited or unreachable and WHOIS returned \
849                             no data); retry shortly for full detail."
850                        };
851                        let avail = AvailabilityResult {
852                            domain: domain.to_string(),
853                            available: false,
854                            confidence: "high".to_string(),
855                            method: "dns_present".to_string(),
856                            details: Some(details.to_string()),
857                        };
858                        return Ok(LookupResult::Available {
859                            data: Box::new(avail),
860                            rdap_error: sanitize_error_for_public(&rdap_error_str),
861                            whois_error: String::new(),
862                            whois_data: Some(whois_data),
863                        });
864                    }
865                    ThinFallback::UseWhois => {}
866                }
867            }
868            debug!("Using WHOIS result (RDAP not useful)");
869            if let Some(ref cb) = progress {
870                cb("RDAP not available (using WHOIS)");
871            }
872            return Ok(LookupResult::Whois {
873                data: whois_data,
874                rdap_error: Some(sanitize_error_for_public(&rdap_error_str)),
875                rdap_fallback: rdap_fallback_data,
876            });
877        }
878
879        // Both sides failed to provide useful data. Craft a precise WHOIS
880        // error string that distinguishes true errors from grace-period
881        // truncation. Borrow the leg here so we can move it into the reuse
882        // token below.
883        let whois_error_str = match &whois_leg {
884            LegOutcome::Completed(Err(e)) => e.to_string(),
885            LegOutcome::Completed(Ok(_)) => {
886                // Already handled above; treat defensively.
887                debug!("Unexpected completed-Ok WHOIS in availability fallback branch");
888                "WHOIS returned but was not used".to_string()
889            }
890            LegOutcome::GraceTruncated => format!(
891                "WHOIS did not return within {}s grace period after RDAP won",
892                PROTOCOL_GRACE_PERIOD.as_secs()
893            ),
894        };
895
896        // Reuse what the race already learned rather than re-querying the same
897        // registries: a thin HTTP 200 still proves the object exists (feed it
898        // back), a concrete error is reused as-is, and only a grace-truncated
899        // leg (genuinely missing) is re-queried by the checker.
900        let prior_rdap = if rdap_returned_200 {
901            match rdap_fallback_data {
902                // `rdap_fallback_data` is already the boxed NoData response.
903                Some(resp) => PriorRdap::Response(resp),
904                None => PriorRdap::Missing,
905            }
906        } else if let Some(e) = rdap_seer_error {
907            PriorRdap::Failed(e)
908        } else {
909            PriorRdap::Missing
910        };
911        let prior_whois = match whois_leg {
912            LegOutcome::Completed(Err(e)) => PriorWhois::Failed(e),
913            // Handled above; the availability path never reuses an Ok WHOIS.
914            LegOutcome::Completed(Ok(_)) => PriorWhois::Missing,
915            LegOutcome::GraceTruncated => PriorWhois::Missing,
916        };
917
918        self.availability_fallback(
919            domain,
920            prior_rdap,
921            prior_whois,
922            rdap_error_str,
923            whois_error_str,
924            progress,
925        )
926        .await
927    }
928
929    async fn availability_fallback(
930        &self,
931        domain: &str,
932        prior_rdap: PriorRdap,
933        prior_whois: PriorWhois,
934        rdap_error: String,
935        whois_error: String,
936        progress: Option<LookupProgressCallback>,
937    ) -> Result<LookupResult> {
938        if let Some(ref cb) = progress {
939            cb("RDAP and WHOIS unavailable (checking availability)");
940        }
941        warn!(
942            domain = %domain,
943            rdap_error = %rdap_error,
944            whois_error = %whois_error,
945            "Both RDAP and WHOIS failed, falling back to availability check"
946        );
947
948        match self
949            .availability_checker
950            .check_with_prior(domain, prior_rdap, prior_whois)
951            .await
952        {
953            Ok(avail) => Ok(LookupResult::Available {
954                data: Box::new(avail),
955                rdap_error: sanitize_error_for_public(&rdap_error),
956                whois_error: sanitize_error_for_public(&whois_error),
957                whois_data: None,
958            }),
959            Err(avail_err) => {
960                let tld = get_tld(domain).unwrap_or("unknown");
961                let registry_url = get_registry_url(tld).unwrap_or_else(|| {
962                    format!("https://www.iana.org/domains/root/db/{}.html", tld)
963                });
964                Err(SeerError::LookupFailed {
965                    domain: domain.to_string(),
966                    details: format!(
967                        "RDAP failed ({}), WHOIS failed ({}), availability check failed ({})",
968                        rdap_error, whois_error, avail_err
969                    ),
970                    registry_url,
971                })
972            }
973        }
974    }
975
976    fn is_rdap_response_useful(&self, response: &RdapResponse) -> bool {
977        // Check if we have at least some meaningful data
978        let has_name = response.ldh_name.is_some() || response.unicode_name.is_some();
979        let has_dates = response
980            .events
981            .iter()
982            .any(|e| e.event_action == "registration" || e.event_action == "expiration");
983        let has_entities = !response.entities.is_empty();
984        let has_nameservers = !response.nameservers.is_empty();
985        let has_status = !response.status.is_empty();
986
987        // Consider useful if we have the name plus at least one other piece of info
988        has_name && (has_dates || has_entities || has_nameservers || has_status)
989    }
990
991    /// DNS presence passthrough over this lookup's private resolver, exposing
992    /// the same cheap availability pre-signal the fallback ladder uses. The
993    /// confusables scan uses it to skip NXDOMAIN candidates before paying for a
994    /// full RDAP+WHOIS race on each one.
995    pub(crate) async fn presence(&self, domain: &str) -> DnsPresence {
996        self.dns_resolver.presence(domain).await
997    }
998}
999
1000#[cfg(test)]
1001mod tests {
1002    use super::*;
1003
1004    /// Global serialization mutex for the three tests that share
1005    /// `LOOKUP_INFLIGHT` state (coalescing, poison recovery, drop recovery).
1006    /// Running them in parallel creates two races:
1007    ///   1. Guard drop uses `try_lock`; if another test holds the mutex, the
1008    ///      Drop path skips cleanup → stale entries fail later assertions.
1009    ///   2. Poisoning one test leaves the mutex poisoned for the next test,
1010    ///      which is handled by `unwrap_or_else` but still disturbs state.
1011    /// Per-test unique keys (see `unique_test_key`) prevent entry-level
1012    /// collisions; this mutex prevents lock-contention races on Drop.
1013    static INFLIGHT_TEST_SERIAL: Mutex<()> = Mutex::new(());
1014
1015    #[test]
1016    fn test_lookup_result_domain_name_whois() {
1017        let result = LookupResult::Whois {
1018            data: WhoisResponse {
1019                domain: "example.com".to_string(),
1020                registrar: Some("Test Registrar".to_string()),
1021                registrant: None,
1022                organization: None,
1023                registrant_email: None,
1024                registrant_phone: None,
1025                registrant_address: None,
1026                registrant_country: None,
1027                admin_name: None,
1028                admin_organization: None,
1029                admin_email: None,
1030                admin_phone: None,
1031                tech_name: None,
1032                tech_organization: None,
1033                tech_email: None,
1034                tech_phone: None,
1035                creation_date: None,
1036                expiration_date: None,
1037                updated_date: None,
1038                status: vec![],
1039                nameservers: vec![],
1040                dnssec: None,
1041                whois_server: "whois.example.com".to_string(),
1042                raw_response: String::new(),
1043            },
1044            rdap_error: None,
1045            rdap_fallback: None,
1046        };
1047
1048        assert_eq!(result.domain_name(), Some("example.com".to_string()));
1049        assert_eq!(result.registrar(), Some("Test Registrar".to_string()));
1050        assert!(result.is_whois());
1051        assert!(!result.is_rdap());
1052        assert!(!result.is_available());
1053    }
1054
1055    #[test]
1056    fn test_lookup_result_serialization() {
1057        let result = LookupResult::Whois {
1058            data: WhoisResponse {
1059                domain: "test.com".to_string(),
1060                registrar: None,
1061                registrant: None,
1062                organization: None,
1063                registrant_email: None,
1064                registrant_phone: None,
1065                registrant_address: None,
1066                registrant_country: None,
1067                admin_name: None,
1068                admin_organization: None,
1069                admin_email: None,
1070                admin_phone: None,
1071                tech_name: None,
1072                tech_organization: None,
1073                tech_email: None,
1074                tech_phone: None,
1075                creation_date: None,
1076                expiration_date: None,
1077                updated_date: None,
1078                status: vec![],
1079                nameservers: vec![],
1080                dnssec: None,
1081                whois_server: String::new(),
1082                raw_response: String::new(),
1083            },
1084            rdap_error: Some("RDAP failed".to_string()),
1085            rdap_fallback: None,
1086        };
1087
1088        let json = serde_json::to_string(&result).unwrap();
1089        assert!(json.contains("\"source\":\"whois\""));
1090        assert!(json.contains("RDAP failed"));
1091    }
1092
1093    #[test]
1094    fn test_lookup_result_available_serialization() {
1095        let result = LookupResult::Available {
1096            data: Box::new(AvailabilityResult {
1097                domain: "test123.xyz".to_string(),
1098                available: true,
1099                confidence: "medium".to_string(),
1100                method: "whois_error".to_string(),
1101                details: Some("WHOIS server indicates no matching records".to_string()),
1102            }),
1103            rdap_error: "RDAP failed".to_string(),
1104            whois_error: "WHOIS failed".to_string(),
1105            whois_data: None,
1106        };
1107
1108        let json = serde_json::to_string(&result).unwrap();
1109        assert!(json.contains("\"source\":\"available\""));
1110        assert!(json.contains("\"available\":true"));
1111        assert!(json.contains("test123.xyz"));
1112
1113        assert_eq!(result.domain_name(), Some("test123.xyz".to_string()));
1114        assert!(result.is_available());
1115        assert!(!result.is_rdap());
1116        assert!(!result.is_whois());
1117        assert!(result.registrar().is_none());
1118        assert_eq!(result.expiration_info(), (None, None));
1119    }
1120
1121    #[test]
1122    #[allow(deprecated)]
1123    fn test_smart_lookup_builder() {
1124        let lookup = SmartLookup::new().prefer_rdap(false).include_fallback(true);
1125        assert!(!lookup.prefer_rdap);
1126        assert!(lookup.include_fallback);
1127    }
1128
1129    #[test]
1130    fn test_lookup_cache_clear() {
1131        SmartLookup::clear_cache();
1132        assert!(LOOKUP_CACHE.is_empty());
1133    }
1134
1135    // ---------------- trim_raw_response char-boundary safety ----------------
1136
1137    #[test]
1138    fn truncate_on_char_boundary_does_not_panic_on_multibyte_straddle() {
1139        const MAX_RAW: usize = 32 * 1024;
1140        // Build a string longer than MAX_RAW with a 3-byte char straddling the
1141        // MAX_RAW byte offset: fill up to MAX_RAW-1 bytes of ASCII, then a
1142        // multi-byte char so byte MAX_RAW lands mid-character.
1143        let mut s = "a".repeat(MAX_RAW - 1);
1144        s.push('€'); // 3 bytes (E2 82 AC) — byte MAX_RAW is NOT a boundary
1145        s.push_str(&"b".repeat(100));
1146        assert!(!s.is_char_boundary(MAX_RAW));
1147
1148        // Must not panic.
1149        truncate_on_char_boundary(&mut s, MAX_RAW);
1150        assert!(s.len() <= MAX_RAW);
1151        // Result is valid UTF-8 (String invariant upheld) — backed up below
1152        // the straddling char.
1153        assert_eq!(s.len(), MAX_RAW - 1);
1154    }
1155
1156    #[test]
1157    fn trim_raw_response_truncates_multibyte_whois_without_panic() {
1158        const MAX_RAW: usize = 32 * 1024;
1159        let mut raw = "a".repeat(MAX_RAW - 1);
1160        raw.push('€');
1161        raw.push_str(&"b".repeat(1000));
1162
1163        let mut w = empty_whois("example.com");
1164        w.raw_response = raw;
1165        let result = trim_raw_response(LookupResult::Whois {
1166            data: w,
1167            rdap_error: None,
1168            rdap_fallback: None,
1169        });
1170        if let LookupResult::Whois { data, .. } = result {
1171            assert!(data.raw_response.ends_with("[truncated]"));
1172        } else {
1173            panic!("expected Whois variant");
1174        }
1175    }
1176
1177    // ---------------- sanitize_error_for_public ----------------
1178
1179    #[test]
1180    fn test_sanitize_strips_ipv4() {
1181        let msg = "RDAP URL resolves to reserved IP 10.0.0.1 which is forbidden";
1182        let sanitized = sanitize_error_for_public(msg);
1183        assert!(
1184            !sanitized.contains("10.0.0.1"),
1185            "IPv4 should be stripped, got: {}",
1186            sanitized
1187        );
1188        assert!(sanitized.contains("[ip-redacted]"));
1189    }
1190
1191    #[test]
1192    fn test_sanitize_strips_multiple_ipv4() {
1193        let msg = "Could not connect to 192.168.1.1 after trying 127.0.0.1";
1194        let sanitized = sanitize_error_for_public(msg);
1195        assert!(!sanitized.contains("192.168.1.1"));
1196        assert!(!sanitized.contains("127.0.0.1"));
1197        // Two redactions expected.
1198        assert_eq!(sanitized.matches("[ip-redacted]").count(), 2);
1199    }
1200
1201    #[test]
1202    fn test_sanitize_strips_ipv6() {
1203        let msg = "RDAP URL resolves to reserved IP fe80::1 which is forbidden";
1204        let sanitized = sanitize_error_for_public(msg);
1205        assert!(!sanitized.contains("fe80::1"));
1206        assert!(sanitized.contains("[ip-redacted]"));
1207    }
1208
1209    #[test]
1210    fn sanitize_leaves_mac_address_like_tokens_alone() {
1211        let msg = "error code af:ba:12 at line 5";
1212        let out = sanitize_error_for_public(msg);
1213        assert!(
1214            out.contains("af:ba:12"),
1215            "MAC fragment should not be stripped: {}",
1216            out
1217        );
1218    }
1219
1220    #[test]
1221    fn sanitize_strips_real_ipv6() {
1222        let msg = "cannot reach 2001:db8::1 — timeout";
1223        let out = sanitize_error_for_public(msg);
1224        assert!(!out.contains("2001:db8::1"));
1225        assert!(out.contains("[ip-redacted]"));
1226    }
1227
1228    #[test]
1229    fn sanitize_strips_fe80_link_local() {
1230        let msg = "peer at fe80::1 unreachable";
1231        let out = sanitize_error_for_public(msg);
1232        assert!(out.contains("[ip-redacted]"));
1233    }
1234
1235    #[test]
1236    fn test_sanitize_truncates_long_message() {
1237        // Build a 500-char message with no IPs.
1238        let long = "a".repeat(500);
1239        let sanitized = sanitize_error_for_public(&long);
1240        // Should cap at MAX_PUBLIC_ERROR_LEN chars + ellipsis.
1241        let char_count = sanitized.chars().count();
1242        assert_eq!(char_count, MAX_PUBLIC_ERROR_LEN + 1);
1243        assert!(sanitized.ends_with('…'));
1244    }
1245
1246    #[test]
1247    fn test_sanitize_preserves_short_messages() {
1248        let msg = "RDAP timed out after 15s";
1249        let sanitized = sanitize_error_for_public(msg);
1250        assert_eq!(sanitized, msg);
1251    }
1252
1253    // ---------------- RdapOutcome classification ----------------
1254
1255    #[test]
1256    fn test_is_rdap_response_useful_detects_no_data() {
1257        use crate::rdap::RdapResponse;
1258        // Construct a response with a name but no events, entities, NS, or status
1259        // — this is the "200 OK but no useful fields" case that should be
1260        // classified as RdapOutcome::NoData (not Useful, not Error).
1261        let resp = RdapResponse {
1262            ldh_name: Some("example.com".to_string()),
1263            ..Default::default()
1264        };
1265        let lookup = SmartLookup::new();
1266        assert!(
1267            !lookup.is_rdap_response_useful(&resp),
1268            "Response with only a name should be classified as NoData"
1269        );
1270
1271        // And one with a name + status IS useful (sanity check).
1272        let useful = RdapResponse {
1273            ldh_name: Some("example.com".to_string()),
1274            status: vec!["active".to_string()],
1275            ..Default::default()
1276        };
1277        assert!(lookup.is_rdap_response_useful(&useful));
1278    }
1279
1280    // ---------------- Coalescing ----------------
1281
1282    // Verifies that when multiple concurrent lookups hit the in-flight map
1283    // for the same domain, later arrivals observe the existing Weak<Notify>
1284    // and become waiters rather than racing a second lookup. We test the
1285    // map-level primitive here because the full SmartLookup pipeline
1286    // requires network access to exercise.
1287    #[tokio::test]
1288    async fn test_inflight_coalescing_map() {
1289        // Serialize with sibling poisoning tests: we share LOOKUP_INFLIGHT
1290        // state, and `InflightGuard::drop` uses `try_lock` — if a sibling
1291        // holds the mutex during drop, cleanup is skipped and assertions
1292        // fail.
1293        let _serial = INFLIGHT_TEST_SERIAL
1294            .lock()
1295            .unwrap_or_else(|p| p.into_inner());
1296        // Poison-tolerant: the sibling poisoning regression tests may run
1297        // earlier under `cargo test` parallelism and leave LOOKUP_INFLIGHT
1298        // poisoned. The production code recovers via `unwrap_or_else`,
1299        // so this test does the same.
1300        //
1301        // Use a per-run unique key so this test cannot race with the other
1302        // tests that touch LOOKUP_INFLIGHT. Previously we `clear()`ed the
1303        // whole map, which raced with peer tests' entries.
1304        let domain = unique_test_key("__coalesce");
1305
1306        // Defensive: ensure our specific key is not present.
1307        {
1308            let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1309            m.remove(&domain);
1310        }
1311
1312        // First caller: no entry → becomes owner.
1313        let owner_notify = Arc::new(Notify::new());
1314        {
1315            let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1316            assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1317            m.insert(domain.clone(), Arc::downgrade(&owner_notify));
1318        }
1319
1320        // Second caller: sees the existing Weak and upgrades.
1321        let waiter = {
1322            let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1323            m.get(&domain)
1324                .and_then(|w| w.upgrade())
1325                .expect("Second caller must observe in-flight entry")
1326        };
1327
1328        // Waiter listens in the background.
1329        let waiter_clone = waiter.clone();
1330        let handle = tokio::spawn(async move {
1331            waiter_clone.notified().await;
1332        });
1333
1334        // Simulate owner completing.
1335        tokio::time::sleep(Duration::from_millis(20)).await;
1336        {
1337            let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1338            m.remove(&domain);
1339        }
1340        owner_notify.notify_waiters();
1341
1342        // Waiter should unblock quickly.
1343        tokio::time::timeout(Duration::from_secs(1), handle)
1344            .await
1345            .expect("waiter must unblock after notify")
1346            .expect("waiter task joined cleanly");
1347
1348        // After owner removes entry and drops its Arc, the Weak is dead.
1349        drop(owner_notify);
1350        drop(waiter);
1351        let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1352        assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1353    }
1354
1355    /// Builds a domain key guaranteed unique per test invocation, so that
1356    /// tests touching the shared LOOKUP_INFLIGHT static never collide when
1357    /// `cargo test` runs them in parallel. We include a nanosecond timestamp
1358    /// plus an atomic counter to defeat even hash-identical calls within the
1359    /// same nanosecond.
1360    fn unique_test_key(prefix: &str) -> String {
1361        use std::sync::atomic::{AtomicU64, Ordering};
1362        use std::time::{SystemTime, UNIX_EPOCH};
1363        static COUNTER: AtomicU64 = AtomicU64::new(0);
1364        let nanos = SystemTime::now()
1365            .duration_since(UNIX_EPOCH)
1366            .map(|d| d.as_nanos())
1367            .unwrap_or(0);
1368        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1369        format!("{}_{}_{}.example.", prefix, nanos, n)
1370    }
1371
1372    // Demonstrates that the `sanitize_error_for_public` helper is applied
1373    // to the rdap_error / whois_error fields written into the `Available`
1374    // variant. We check the call site indirectly: construct a Available
1375    // manually and then verify a raw error with an IP becomes redacted.
1376    // (Integration via real clients would require network.)
1377    #[test]
1378    fn test_sanitize_applied_to_available_fields() {
1379        let rdap_raw = "RDAP URL resolves to reserved IP 10.0.0.1";
1380        let whois_raw = "connection refused at 192.168.0.5";
1381        let sanitized_rdap = sanitize_error_for_public(rdap_raw);
1382        let sanitized_whois = sanitize_error_for_public(whois_raw);
1383        let result = LookupResult::Available {
1384            data: Box::new(AvailabilityResult {
1385                domain: "unreg.test".to_string(),
1386                available: true,
1387                confidence: "low".to_string(),
1388                method: "heuristic".to_string(),
1389                details: None,
1390            }),
1391            rdap_error: sanitized_rdap,
1392            whois_error: sanitized_whois,
1393            whois_data: None,
1394        };
1395        if let LookupResult::Available {
1396            rdap_error,
1397            whois_error,
1398            ..
1399        } = result
1400        {
1401            assert!(!rdap_error.contains("10.0.0.1"));
1402            assert!(!whois_error.contains("192.168.0.5"));
1403            assert!(rdap_error.contains("[ip-redacted]"));
1404            assert!(whois_error.contains("[ip-redacted]"));
1405        } else {
1406            panic!("expected Available variant");
1407        }
1408    }
1409
1410    #[test]
1411    fn rdap_error_is_404_matches_standard_404() {
1412        let e = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1413        assert!(rdap_error_is_404(&e));
1414    }
1415
1416    #[test]
1417    fn rdap_error_is_404_matches_without_reason_phrase() {
1418        let e = SeerError::RdapError("query failed with status 404".to_string());
1419        assert!(rdap_error_is_404(&e));
1420    }
1421
1422    #[test]
1423    fn rdap_error_is_404_rejects_other_statuses() {
1424        let e = SeerError::RdapError("query failed with status 500 Server Error".to_string());
1425        assert!(!rdap_error_is_404(&e));
1426        let e = SeerError::RdapError("query failed with status 400 Bad Request".to_string());
1427        assert!(!rdap_error_is_404(&e));
1428    }
1429
1430    #[test]
1431    fn rdap_error_is_404_rejects_non_http_errors() {
1432        let e = SeerError::RdapError("connection timeout".to_string());
1433        assert!(!rdap_error_is_404(&e));
1434        let e = SeerError::Timeout("rdap".to_string());
1435        assert!(!rdap_error_is_404(&e));
1436    }
1437
1438    #[test]
1439    fn rdap_error_is_404_rejects_incidental_404_in_message() {
1440        // A 404 substring inside a non-status context must not match.
1441        let e = SeerError::RdapError("error 40404: database corruption".to_string());
1442        assert!(!rdap_error_is_404(&e));
1443    }
1444
1445    // ---------------- whois_response_is_thin ----------------
1446
1447    fn empty_whois(domain: &str) -> WhoisResponse {
1448        WhoisResponse {
1449            domain: domain.to_string(),
1450            registrar: None,
1451            registrant: None,
1452            organization: None,
1453            registrant_email: None,
1454            registrant_phone: None,
1455            registrant_address: None,
1456            registrant_country: None,
1457            admin_name: None,
1458            admin_organization: None,
1459            admin_email: None,
1460            admin_phone: None,
1461            tech_name: None,
1462            tech_organization: None,
1463            tech_email: None,
1464            tech_phone: None,
1465            creation_date: None,
1466            expiration_date: None,
1467            updated_date: None,
1468            nameservers: vec![],
1469            status: vec![],
1470            dnssec: None,
1471            whois_server: String::new(),
1472            raw_response: String::new(),
1473        }
1474    }
1475
1476    #[test]
1477    fn whois_response_is_thin_when_all_key_fields_missing() {
1478        let w = empty_whois("example.com");
1479        assert!(whois_response_is_thin(&w));
1480    }
1481
1482    #[test]
1483    fn whois_response_is_not_thin_when_registrar_present() {
1484        let mut w = empty_whois("example.com");
1485        w.registrar = Some("Test Registrar".to_string());
1486        assert!(!whois_response_is_thin(&w));
1487    }
1488
1489    #[test]
1490    fn whois_response_is_not_thin_when_creation_date_present() {
1491        let mut w = empty_whois("example.com");
1492        w.creation_date = Some(Utc::now());
1493        assert!(!whois_response_is_thin(&w));
1494    }
1495
1496    #[test]
1497    fn whois_response_is_not_thin_when_expiration_date_present() {
1498        let mut w = empty_whois("example.com");
1499        w.expiration_date = Some(Utc::now());
1500        assert!(!whois_response_is_thin(&w));
1501    }
1502
1503    #[test]
1504    fn whois_response_is_thin_even_with_nameservers_alone() {
1505        let mut w = empty_whois("example.com");
1506        w.nameservers = vec!["ns1.example.net".to_string()];
1507        assert!(whois_response_is_thin(&w));
1508    }
1509
1510    // ---------------- classify_whois_leg ----------------
1511
1512    use crate::rdap::RdapResponse;
1513
1514    #[allow(dead_code)]
1515    fn make_empty_rdap_response() -> RdapResponse {
1516        serde_json::from_value(serde_json::json!({
1517            "objectClassName": "domain",
1518        }))
1519        .expect("valid minimal RDAP response")
1520    }
1521
1522    #[test]
1523    fn classify_whois_leg_case_a_high_confidence() {
1524        let mut w = empty_whois("zaccodes.com");
1525        w.raw_response = "No match for \"ZACCODES.COM\".".to_string();
1526        assert!(w.is_available());
1527        let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1528        let (verdict, method) =
1529            classify_whois_leg(&w, &rdap_err).expect("expected a routing decision");
1530        assert_eq!(verdict, "high");
1531        assert_eq!(method, "whois");
1532    }
1533
1534    #[test]
1535    fn classify_whois_leg_case_b_medium_confidence() {
1536        let w = empty_whois("example.xyz");
1537        assert!(!w.is_available(), "this WHOIS body has no 'no match' text");
1538        let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1539        let (verdict, method) =
1540            classify_whois_leg(&w, &rdap_err).expect("expected a routing decision");
1541        assert_eq!(verdict, "medium");
1542        assert_eq!(method, "whois_thin_response");
1543    }
1544
1545    #[test]
1546    fn classify_whois_leg_rejects_thin_whois_without_404() {
1547        let w = empty_whois("example.xyz");
1548        let rdap_err = SeerError::RdapError("connection timeout".to_string());
1549        assert!(classify_whois_leg(&w, &rdap_err).is_none());
1550    }
1551
1552    #[test]
1553    fn classify_whois_leg_rejects_whois_with_real_data() {
1554        let mut w = empty_whois("legacy.tld");
1555        w.registrar = Some("Legacy Registry".to_string());
1556        w.creation_date = Some(Utc::now());
1557        let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1558        assert!(classify_whois_leg(&w, &rdap_err).is_none());
1559    }
1560
1561    #[test]
1562    fn classify_whois_leg_case_a_wins_over_case_b() {
1563        let mut w = empty_whois("example.com");
1564        w.raw_response = "No match for \"EXAMPLE.COM\".".to_string();
1565        let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1566        let (verdict, _) = classify_whois_leg(&w, &rdap_err).unwrap();
1567        assert_eq!(verdict, "high");
1568    }
1569
1570    // ---------------- should_route_to_availability ----------------
1571    //
1572    // Regression coverage for the v0.26.6 fix: when RDAP returned an HTTP 200
1573    // (even with thin body), a WHOIS "no match" must NOT be treated as
1574    // evidence of availability — that would let propagation lag flip the
1575    // verdict for a domain the registry has already provisioned.
1576
1577    #[test]
1578    fn rdap_200_vetoes_whois_no_match() {
1579        let mut w = empty_whois("freshly-registered.com");
1580        w.raw_response = "No match for \"FRESHLY-REGISTERED.COM\".".to_string();
1581        // rdap_returned_200 = true, no rdap_seer_error (NoData has no error).
1582        assert!(
1583            should_route_to_availability(true, None, &w).is_none(),
1584            "RDAP 200 must veto WHOIS-only availability claim",
1585        );
1586    }
1587
1588    #[test]
1589    fn rdap_200_vetoes_even_with_thin_whois() {
1590        let w = empty_whois("freshly-registered.com");
1591        // Thin WHOIS without is_available() patterns.
1592        assert!(
1593            should_route_to_availability(true, None, &w).is_none(),
1594            "RDAP 200 must veto even when WHOIS is thin",
1595        );
1596    }
1597
1598    #[test]
1599    fn rdap_404_with_whois_no_match_routes_to_available() {
1600        let mut w = empty_whois("genuinely-free.com");
1601        w.raw_response = "No match for \"GENUINELY-FREE.COM\".".to_string();
1602        let rdap_err = SeerError::RdapError("query failed with status 404".to_string());
1603        let result = should_route_to_availability(false, Some(&rdap_err), &w);
1604        assert_eq!(result, Some(("high", "whois")));
1605    }
1606
1607    #[test]
1608    fn rdap_error_with_whois_is_available_still_routes_case_a() {
1609        let mut w = empty_whois("genuinely-free.com");
1610        w.raw_response = "Domain not found".to_string();
1611        // RDAP errored for a non-404 reason (e.g. bootstrap failure); WHOIS
1612        // signal alone should still route to availability.
1613        let rdap_err = SeerError::RdapBootstrapError("all registries failed".to_string());
1614        let result = should_route_to_availability(false, Some(&rdap_err), &w);
1615        assert_eq!(result, Some(("high", "whois")));
1616    }
1617
1618    #[test]
1619    fn rdap_grace_timeout_with_whois_is_available_routes_case_a() {
1620        // GraceTimeout path: rdap_returned_200 = false, rdap_seer_error = None.
1621        let mut w = empty_whois("genuinely-free.com");
1622        w.raw_response = "No match".to_string();
1623        let result = should_route_to_availability(false, None, &w);
1624        assert_eq!(result, Some(("high", "whois")));
1625    }
1626
1627    #[test]
1628    fn no_rdap_200_no_error_thick_whois_stays_in_whois_path() {
1629        let mut w = empty_whois("registered.com");
1630        w.registrar = Some("Example Registrar Ltd".to_string());
1631        // GraceTimeout-like: rdap_returned_200=false, no error, and WHOIS
1632        // does not look free. Must return None so the caller picks
1633        // `LookupResult::Whois`.
1634        assert!(should_route_to_availability(false, None, &w).is_none());
1635    }
1636
1637    // ---------------- classify_thin_fallback ----------------
1638
1639    #[test]
1640    fn thin_fallback_refusal_is_inconclusive_regardless_of_dns() {
1641        // issue #45: a registry refusal/throttle must never be guessed into
1642        // "available" (from NXDOMAIN) or "registered" (from delegation). This
1643        // is the inversion that was fixed in `availability::decide_fallback`
1644        // but previously bypassed on the smart-lookup hot path.
1645        for dns in [
1646            DnsPresence::Absent,
1647            DnsPresence::Present,
1648            DnsPresence::Unknown,
1649        ] {
1650            assert_eq!(
1651                classify_thin_fallback(true, false, true, dns),
1652                ThinFallback::Inconclusive
1653            );
1654        }
1655    }
1656
1657    #[test]
1658    fn thin_fallback_nxdomain_is_available() {
1659        assert_eq!(
1660            classify_thin_fallback(true, false, false, DnsPresence::Absent),
1661            ThinFallback::Available
1662        );
1663    }
1664
1665    #[test]
1666    fn thin_fallback_delegated_is_registered() {
1667        // The zac.email / Identity-Digital case: a no-service WHOIS leg, RDAP
1668        // unavailable (throttled / grace-truncated), but the apex IS delegated
1669        // in DNS — the domain is registered and must not render as blank.
1670        assert_eq!(
1671            classify_thin_fallback(true, false, false, DnsPresence::Present),
1672            ThinFallback::Registered
1673        );
1674    }
1675
1676    #[test]
1677    fn thin_fallback_unknown_dns_uses_whois() {
1678        // A failed DNS probe is not positive evidence either way.
1679        assert_eq!(
1680            classify_thin_fallback(true, false, false, DnsPresence::Unknown),
1681            ThinFallback::UseWhois
1682        );
1683    }
1684
1685    #[test]
1686    fn thin_fallback_requires_thin_whois() {
1687        // A WHOIS body with real registration data uses the normal Whois path.
1688        assert_eq!(
1689            classify_thin_fallback(false, false, false, DnsPresence::Absent),
1690            ThinFallback::UseWhois
1691        );
1692    }
1693
1694    #[test]
1695    fn thin_fallback_vetoed_by_rdap_200() {
1696        // A 200 from RDAP (object exists) vetoes any DNS-derived reclassification
1697        // — and even a refusal banner — because the object provably exists.
1698        assert_eq!(
1699            classify_thin_fallback(true, true, false, DnsPresence::Absent),
1700            ThinFallback::UseWhois
1701        );
1702        assert_eq!(
1703            classify_thin_fallback(true, true, true, DnsPresence::Present),
1704            ThinFallback::UseWhois
1705        );
1706    }
1707
1708    // ---------------- Mutex poisoning recovery ----------------
1709
1710    /// Regression: a panic inside `LOOKUP_INFLIGHT.lock()` must not wedge
1711    /// the tracker forever. After the mutex is poisoned, subsequent
1712    /// acquisition attempts must still succeed via `unwrap_or_else`.
1713    ///
1714    /// This isolates the lookup_with_progress acquisition site (formerly a
1715    /// `.expect("LOOKUP_INFLIGHT mutex poisoned")`) by exercising the same
1716    /// `.lock().unwrap_or_else(|p| p.into_inner())` pattern directly.
1717    #[test]
1718    fn lookup_inflight_recovers_from_poisoned_mutex() {
1719        use std::panic::{catch_unwind, AssertUnwindSafe};
1720
1721        // Serialize with sibling tests that also touch LOOKUP_INFLIGHT.
1722        let _serial = INFLIGHT_TEST_SERIAL
1723            .lock()
1724            .unwrap_or_else(|p| p.into_inner());
1725
1726        // Poison the real static by panicking while holding the guard.
1727        let _ = catch_unwind(AssertUnwindSafe(|| {
1728            let _guard = LOOKUP_INFLIGHT.lock().unwrap();
1729            panic!("poisoning LOOKUP_INFLIGHT for test");
1730        }));
1731
1732        // At this point LOOKUP_INFLIGHT is poisoned. Plain .lock() would
1733        // return Err(PoisonError). The recovery pattern used in
1734        // lookup_with_progress must still yield a usable guard.
1735        let mut guard = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1736        // Use a per-run unique canary so parallel tests cannot collide.
1737        let canary = unique_test_key("__poison_recovery");
1738        guard.insert(canary.clone(), Weak::new());
1739        assert!(guard.contains_key(&canary));
1740        guard.remove(&canary);
1741    }
1742
1743    /// Regression: InflightGuard::drop must also tolerate mutex poisoning
1744    /// without panicking — the Poisoned arm should still remove the entry.
1745    #[test]
1746    fn inflight_guard_drop_recovers_from_poisoned_mutex() {
1747        use std::panic::{catch_unwind, AssertUnwindSafe};
1748
1749        // Serialize with sibling tests that also touch LOOKUP_INFLIGHT —
1750        // the critical race was `InflightGuard::drop` using `try_lock`
1751        // and silently skipping cleanup when a parallel test held the
1752        // mutex, leaving this test's entry in the map and failing the
1753        // final assertion.
1754        let _serial = INFLIGHT_TEST_SERIAL
1755            .lock()
1756            .unwrap_or_else(|p| p.into_inner());
1757
1758        // Seed an entry and arm a guard for it. Use a per-run unique key
1759        // so this test can never collide with siblings under parallel
1760        // `cargo test` — previously a hard-coded key raced with the peer
1761        // coalescing test's `m.clear()` call.
1762        let key = unique_test_key("__drop_poison");
1763        let notify = Arc::new(Notify::new());
1764        {
1765            let mut map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1766            map.insert(key.clone(), Arc::downgrade(&notify));
1767        }
1768        let guard = InflightGuard {
1769            key: key.clone(),
1770            notify: notify.clone(),
1771        };
1772
1773        // Poison the mutex.
1774        let _ = catch_unwind(AssertUnwindSafe(|| {
1775            let _g = LOOKUP_INFLIGHT.lock().unwrap();
1776            panic!("poisoning LOOKUP_INFLIGHT for drop test");
1777        }));
1778
1779        // Dropping the guard must not panic and must remove the entry via
1780        // the Poisoned branch of the new try_lock match.
1781        drop(guard);
1782
1783        let map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1784        assert!(
1785            !map.contains_key(&key),
1786            "poisoned-mutex drop path should still remove the in-flight entry"
1787        );
1788    }
1789}