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