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};
17use crate::cache::TtlCache;
18use crate::error::{Result, SeerError};
19use crate::rdap::{RdapClient, RdapResponse};
20use crate::whois::{get_registry_url, get_tld, WhoisClient, WhoisResponse};
21
22/// Cache TTL for lookup results (5 minutes).
23const LOOKUP_CACHE_TTL: Duration = Duration::from_secs(5 * 60);
24
25/// Grace period for the second protocol after the first one finishes.
26/// If WHOIS finishes and RDAP hasn't responded within this window, we
27/// use the WHOIS result rather than waiting the full RDAP timeout.
28const PROTOCOL_GRACE_PERIOD: Duration = Duration::from_secs(5);
29
30/// Maximum length for public-facing error strings.
31const MAX_PUBLIC_ERROR_LEN: usize = 256;
32
33/// Global cache for lookup results to avoid redundant network calls.
34static LOOKUP_CACHE: Lazy<TtlCache<String, LookupResult>> =
35    Lazy::new(|| TtlCache::new(LOOKUP_CACHE_TTL));
36
37/// In-flight lookup coalescing map: normalized-domain -> Weak<Notify>.
38/// Only one network race runs per unique domain at a time; concurrent callers
39/// wait on the shared Notify and then read the result from LOOKUP_CACHE.
40static LOOKUP_INFLIGHT: Lazy<Mutex<HashMap<String, Weak<Notify>>>> =
41    Lazy::new(|| Mutex::new(HashMap::new()));
42
43/// Regex patterns for stripping IP literals from public error messages.
44static IPV4_RE: Lazy<Regex> =
45    Lazy::new(|| Regex::new(r"\b(?:\d{1,3}\.){3}\d{1,3}\b").expect("IPV4_RE is a valid regex"));
46
47/// Candidate pattern for IPv6 literals: a hex/colon token containing either
48/// a `::` compression or at least three colons. This catches plausible IPv6
49/// addresses cheaply; each match is then validated by `Ipv6Addr::from_str`
50/// before redaction, so MAC fragments, hex hashes, and similar colon-laden
51/// tokens are left alone.
52static IPV6_CANDIDATE_RE: Lazy<Regex> = Lazy::new(|| {
53    Regex::new(r"\b[0-9a-fA-F:]*(?:::|(?:[0-9a-fA-F]{1,4}:){3,})[0-9a-fA-F:]*\b")
54        .expect("IPV6_CANDIDATE_RE is a valid regex")
55});
56
57/// Redact substrings that parse as valid IPv6 addresses, leaving non-IPv6
58/// tokens (e.g. `af:ba:12`) untouched.
59fn strip_ipv6(msg: &str) -> String {
60    IPV6_CANDIDATE_RE
61        .replace_all(msg, |caps: &regex::Captures| {
62            let candidate = &caps[0];
63            if Ipv6Addr::from_str(candidate).is_ok() {
64                "[ip-redacted]".to_string()
65            } else {
66                candidate.to_string()
67            }
68        })
69        .into_owned()
70}
71
72/// Test-only hook: counts the number of times `lookup_concurrent` is actually
73/// invoked (i.e., the underlying network race runs). Used to verify request
74/// coalescing. Not exposed outside the crate.
75#[cfg(test)]
76static LOOKUP_CONCURRENT_CALLS: Lazy<std::sync::atomic::AtomicUsize> =
77    Lazy::new(|| std::sync::atomic::AtomicUsize::new(0));
78
79/// Returns true if the error is an RDAP HTTP 404 response, indicating the
80/// registry's RDAP server has no entry for this domain. Other RDAP errors
81/// (timeouts, 5xx, connection failures, etc.) do NOT match — they mean "we
82/// don't know", not "not registered".
83///
84/// Matches the format produced by `seer-core/src/rdap/client.rs:603`:
85/// `"query failed with status 404 ..."`.
86fn rdap_error_is_404(err: &SeerError) -> bool {
87    if let SeerError::RdapError(msg) = err {
88        msg.contains("query failed with status 404")
89    } else {
90        false
91    }
92}
93
94/// Returns true if the parsed WHOIS response lacks all key registration
95/// signals: no registrar, no creation date, and no expiration date.
96///
97/// This is a necessary-but-not-sufficient signal for domain availability;
98/// `lookup_concurrent` combines it with an RDAP 404 before routing to the
99/// availability path. Nameservers alone don't disqualify thinness — some
100/// registries return placeholder nameservers for unregistered domains.
101fn whois_response_is_thin(w: &WhoisResponse) -> bool {
102    w.registrar.is_none() && w.creation_date.is_none() && w.expiration_date.is_none()
103}
104
105/// Decides whether a WHOIS response + RDAP error combination should route
106/// to the availability path. Returns `(confidence, method)` when routing is
107/// warranted, `None` to keep the existing `LookupResult::Whois` behavior.
108///
109/// Case A: WHOIS explicitly indicates no registration (highest priority).
110/// Case B: WHOIS returned but lacks registration data AND RDAP returned 404.
111fn classify_whois_leg(
112    w: &WhoisResponse,
113    rdap_err: &SeerError,
114) -> Option<(&'static str, &'static str)> {
115    if w.is_available() {
116        return Some(("high", "whois"));
117    }
118    if whois_response_is_thin(w) && rdap_error_is_404(rdap_err) {
119        return Some(("medium", "whois_thin_response"));
120    }
121    None
122}
123
124/// Wraps `classify_whois_leg` with the "RDAP returned 200" veto: a successful
125/// RDAP response (HTTP 200, even if the body is thin) is positive evidence
126/// that the domain object exists, so we never let a WHOIS-only signal flip
127/// the verdict to "available" in that case. This guards against WHOIS
128/// propagation lag against freshly-provisioned domains the registry has
129/// already begun serving via RDAP. v0.26.6 regression fix.
130fn should_route_to_availability(
131    rdap_returned_200: bool,
132    rdap_seer_error: Option<&SeerError>,
133    whois_data: &WhoisResponse,
134) -> Option<(&'static str, &'static str)> {
135    if rdap_returned_200 {
136        return None;
137    }
138    rdap_seer_error
139        .and_then(|e| classify_whois_leg(whois_data, e))
140        .or_else(|| {
141            // Case A can still fire even when RDAP errored for a non-404
142            // reason — the WHOIS signal alone is sufficient.
143            if whois_data.is_available() {
144                Some(("high", "whois"))
145            } else {
146                None
147            }
148        })
149}
150
151/// Sanitizes an error message for inclusion in a public-facing response.
152///
153/// Strips IPv4 and IPv6 literals (to avoid leaking internal addresses when
154/// an SSRF guard rejects a resolved URL) and caps the total length to
155/// [`MAX_PUBLIC_ERROR_LEN`] characters.
156fn sanitize_error_for_public(msg: &str) -> String {
157    let s = IPV4_RE.replace_all(msg, "[ip-redacted]");
158    let s = strip_ipv6(&s);
159    if s.chars().count() > MAX_PUBLIC_ERROR_LEN {
160        let mut trunc: String = s.chars().take(MAX_PUBLIC_ERROR_LEN).collect();
161        trunc.push('…');
162        trunc
163    } else {
164        s
165    }
166}
167
168/// RAII guard for the in-flight-lookup slot. On drop, removes the entry
169/// from `LOOKUP_INFLIGHT` and notifies any waiters so they can read the
170/// freshly-populated cache.
171///
172/// NOTE on failed-owner retry semantics:
173/// When the owning task's lookup fails, `InflightGuard::drop` runs, the
174/// `HashMap` entry is removed, and `notify_waiters()` fires. Waiters wake,
175/// observe an empty cache, and one of them becomes the new owner — triggering
176/// a fresh network race. This means transient failures are automatically
177/// retried by any concurrent waiter. Callers that observe a timeout error
178/// should not assume no work is in flight; another concurrent caller may
179/// already be retrying.
180struct InflightGuard {
181    key: String,
182    notify: Arc<Notify>,
183}
184
185impl Drop for InflightGuard {
186    fn drop(&mut self) {
187        // Avoid blocking on the mutex inside Drop. A cancelled future that
188        // drops this guard could otherwise starve the Tokio executor while
189        // it waits for contention to clear. `try_lock` lets us take the
190        // fast path when the map is uncontended, recover from poisoning
191        // explicitly, and simply skip cleanup when another task holds the
192        // mutex — waiters re-contend for ownership on their next wakeup,
193        // so a missed cleanup is self-healing.
194        match LOOKUP_INFLIGHT.try_lock() {
195            Ok(mut inflight) => {
196                inflight.remove(&self.key);
197            }
198            Err(std::sync::TryLockError::Poisoned(p)) => {
199                let mut inflight = p.into_inner();
200                inflight.remove(&self.key);
201            }
202            Err(std::sync::TryLockError::WouldBlock) => {
203                tracing::debug!(
204                    key = %self.key,
205                    "InflightGuard drop: skipping cleanup under contention"
206                );
207            }
208        }
209        self.notify.notify_waiters();
210    }
211}
212
213/// Internal classification of the RDAP leg of a concurrent lookup.
214///
215/// Distinguishing `NoData` (HTTP 200 but response was missing useful fields)
216/// from `Error` lets the orchestrator prefer a thin WHOIS result over the
217/// availability fallback when RDAP silently returned nothing.
218enum RdapOutcome {
219    Useful(RdapResponse),
220    NoData(RdapResponse),
221    Error(SeerError),
222    /// RDAP future did not complete within the grace period after the other
223    /// protocol finished.
224    GraceTimeout,
225}
226
227/// Progress callback for smart lookup operations.
228/// Called with a message describing the current phase of the lookup.
229pub type LookupProgressCallback = Arc<dyn Fn(&str) + Send + Sync>;
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
232#[serde(tag = "source", rename_all = "lowercase")]
233pub enum LookupResult {
234    Rdap {
235        data: Box<RdapResponse>,
236        #[serde(skip_serializing_if = "Option::is_none")]
237        whois_fallback: Option<WhoisResponse>,
238    },
239    Whois {
240        data: WhoisResponse,
241        rdap_error: Option<String>,
242        #[serde(skip_serializing_if = "Option::is_none")]
243        rdap_fallback: Option<Box<RdapResponse>>,
244    },
245    Available {
246        data: Box<AvailabilityResult>,
247        rdap_error: String,
248        whois_error: String,
249        /// Raw WHOIS response, when one was available at routing time
250        /// (Cases A and B in the design spec). `None` preserves the
251        /// pre-existing "both protocols errored" semantics.
252        #[serde(default, skip_serializing_if = "Option::is_none")]
253        whois_data: Option<WhoisResponse>,
254    },
255}
256
257impl LookupResult {
258    /// Returns the domain name from the lookup result.
259    pub fn domain_name(&self) -> Option<String> {
260        match self {
261            LookupResult::Rdap { data, .. } => data.domain_name().map(String::from),
262            LookupResult::Whois { data, .. } => Some(data.domain.clone()),
263            LookupResult::Available { data, .. } => Some(data.domain.clone()),
264        }
265    }
266
267    /// Returns the registrar name, preferring RDAP data with WHOIS fallback.
268    pub fn registrar(&self) -> Option<String> {
269        match self {
270            LookupResult::Rdap {
271                data,
272                whois_fallback,
273            } => data
274                .get_registrar()
275                .or_else(|| whois_fallback.as_ref().and_then(|w| w.registrar.clone())),
276            LookupResult::Whois { data, .. } => data.registrar.clone(),
277            LookupResult::Available { .. } => None,
278        }
279    }
280
281    /// Returns the registrant organization, preferring RDAP data with WHOIS fallback.
282    pub fn organization(&self) -> Option<String> {
283        match self {
284            LookupResult::Rdap {
285                data,
286                whois_fallback,
287            } => data
288                .get_registrant_organization()
289                .or_else(|| whois_fallback.as_ref().and_then(|w| w.organization.clone())),
290            LookupResult::Whois { data, .. } => data.organization.clone(),
291            LookupResult::Available { .. } => None,
292        }
293    }
294
295    /// Returns true if the result came from RDAP.
296    pub fn is_rdap(&self) -> bool {
297        matches!(self, LookupResult::Rdap { .. })
298    }
299
300    /// Returns true if the result came from WHOIS.
301    pub fn is_whois(&self) -> bool {
302        matches!(self, LookupResult::Whois { .. })
303    }
304
305    /// Returns true if the result is an availability check fallback.
306    pub fn is_available(&self) -> bool {
307        matches!(self, LookupResult::Available { .. })
308    }
309
310    /// Returns the expiration date and registrar info from the lookup result.
311    pub fn expiration_info(&self) -> (Option<DateTime<Utc>>, Option<String>) {
312        match self {
313            LookupResult::Rdap {
314                data,
315                whois_fallback,
316            } => {
317                // Try to get expiration from RDAP events
318                let expiration_date = data
319                    .events
320                    .iter()
321                    .find(|e| e.event_action == "expiration")
322                    .and_then(|e| e.parsed_date())
323                    .or_else(|| {
324                        // Fallback to WHOIS if available
325                        whois_fallback.as_ref().and_then(|w| w.expiration_date)
326                    });
327
328                let registrar = data
329                    .get_registrar()
330                    .or_else(|| whois_fallback.as_ref().and_then(|w| w.registrar.clone()));
331
332                (expiration_date, registrar)
333            }
334            LookupResult::Whois { data, .. } => (data.expiration_date, data.registrar.clone()),
335            LookupResult::Available { .. } => (None, None),
336        }
337    }
338}
339
340/// Before caching, trim raw WHOIS response to limit cache memory.
341/// A full WHOIS raw_response can be up to 1 MB; we cap it at 32 KB which is
342/// plenty for the parsed fields while preventing the cache from ballooning.
343fn trim_for_cache(mut result: LookupResult) -> LookupResult {
344    const MAX_RAW: usize = 32 * 1024;
345
346    match result {
347        LookupResult::Whois { ref mut data, .. } => {
348            if data.raw_response.len() > MAX_RAW {
349                data.raw_response.truncate(MAX_RAW);
350                data.raw_response.push_str("\n... [truncated for cache]");
351            }
352        }
353        LookupResult::Rdap {
354            ref mut whois_fallback,
355            ..
356        } => {
357            if let Some(ref mut w) = whois_fallback {
358                if w.raw_response.len() > MAX_RAW {
359                    w.raw_response.truncate(MAX_RAW);
360                    w.raw_response.push_str("\n... [truncated for cache]");
361                }
362            }
363        }
364        LookupResult::Available {
365            ref mut whois_data, ..
366        } => {
367            if let Some(ref mut w) = whois_data {
368                if w.raw_response.len() > MAX_RAW {
369                    w.raw_response.truncate(MAX_RAW);
370                    w.raw_response.push_str("\n... [truncated for cache]");
371                }
372            }
373        }
374    }
375
376    result
377}
378
379#[derive(Debug, Clone)]
380pub struct SmartLookup {
381    rdap_client: RdapClient,
382    whois_client: WhoisClient,
383    availability_checker: AvailabilityChecker,
384    /// Deprecated: both protocols are now always attempted concurrently.
385    prefer_rdap: bool,
386    /// Deprecated: WHOIS data is now always attached when available.
387    include_fallback: bool,
388}
389
390impl Default for SmartLookup {
391    fn default() -> Self {
392        Self::new()
393    }
394}
395
396impl SmartLookup {
397    /// Creates a new SmartLookup that runs RDAP and WHOIS concurrently,
398    /// falling back to an availability check if both fail.
399    pub fn new() -> Self {
400        Self {
401            rdap_client: RdapClient::new(),
402            whois_client: WhoisClient::new(),
403            availability_checker: AvailabilityChecker::new(),
404            prefer_rdap: true,
405            include_fallback: false,
406        }
407    }
408
409    /// Deprecated: both protocols are now always attempted concurrently.
410    /// This method is kept for API compatibility but has no effect.
411    #[deprecated(note = "This field has no effect. RDAP is always tried concurrently with WHOIS.")]
412    pub fn prefer_rdap(mut self, prefer: bool) -> Self {
413        self.prefer_rdap = prefer;
414        self
415    }
416
417    /// Deprecated: WHOIS data is now always attached when available.
418    /// This method is kept for API compatibility but has no effect.
419    #[deprecated(note = "This field has no effect. RDAP is always tried concurrently with WHOIS.")]
420    pub fn include_fallback(mut self, include: bool) -> Self {
421        self.include_fallback = include;
422        self
423    }
424
425    /// Performs a smart lookup for a domain, trying both RDAP and WHOIS concurrently.
426    /// Falls back to an availability check if both fail.
427    /// Results are cached for 5 minutes to avoid redundant network calls.
428    #[instrument(skip(self), fields(domain = %domain))]
429    pub async fn lookup(&self, domain: &str) -> Result<LookupResult> {
430        self.lookup_with_progress(domain, None).await
431    }
432
433    /// Performs a lookup with an optional progress callback.
434    /// The callback is called with messages describing the current phase.
435    /// Results are cached for 5 minutes. Concurrent lookups for the same
436    /// domain are coalesced — only one network race runs per domain at a time.
437    #[instrument(skip(self, progress), fields(domain = %domain))]
438    pub async fn lookup_with_progress(
439        &self,
440        domain: &str,
441        progress: Option<LookupProgressCallback>,
442    ) -> Result<LookupResult> {
443        let normalized = crate::validation::normalize_domain(domain)?;
444
445        // Check cache first
446        if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
447            debug!(domain = %normalized, "Returning cached lookup result");
448            return Ok(cached);
449        }
450
451        // Coalesce in-flight lookups: if another task is already running a
452        // race for this domain, wait on its Notify rather than starting a
453        // second race. Two branches:
454        //   - Waiter: another task owns the slot; await its notify, then
455        //     read the cache. If the cache is still empty (owner failed),
456        //     loop and re-contend for ownership.
457        //   - Owner: no entry exists; insert a Weak handle, hold the Arc
458        //     for the duration of the work, then remove and notify on drop.
459        //
460        // A `loop` with a separate lock-scope per iteration keeps the
461        // `MutexGuard` from being held across any `.await`.
462        let _guard = loop {
463            enum Slot {
464                Waiter(Arc<Notify>),
465                Owner(InflightGuard),
466            }
467
468            let slot = {
469                // Recover from poisoning rather than panicking: a prior
470                // owner's panic should not permanently wedge the in-flight
471                // tracker for every future lookup.
472                let mut inflight = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
473                match inflight.get(&normalized).and_then(|w| w.upgrade()) {
474                    Some(existing) => Slot::Waiter(existing),
475                    None => {
476                        let n = Arc::new(Notify::new());
477                        inflight.insert(normalized.clone(), Arc::downgrade(&n));
478                        Slot::Owner(InflightGuard {
479                            key: normalized.clone(),
480                            notify: n,
481                        })
482                    }
483                }
484            };
485
486            match slot {
487                Slot::Waiter(n) => {
488                    debug!(domain = %normalized, "Waiting for in-flight lookup to complete");
489                    n.notified().await;
490                    if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
491                        return Ok(cached);
492                    }
493                    // Owner finished without populating the cache (failed
494                    // or errored). Re-contend for ownership.
495                    continue;
496                }
497                Slot::Owner(guard) => break guard,
498            }
499        };
500
501        let result = self.lookup_concurrent(&normalized, progress).await?;
502
503        // Cache a trimmed copy to limit memory usage before releasing
504        // waiters (via guard drop) so they observe the cached value.
505        LOOKUP_CACHE.insert(normalized.clone(), trim_for_cache(result.clone()));
506
507        Ok(result)
508    }
509
510    /// Clears the lookup result cache.
511    pub fn clear_cache() {
512        LOOKUP_CACHE.clear();
513    }
514
515    #[instrument(skip(self, progress), fields(domain = %domain))]
516    async fn lookup_concurrent(
517        &self,
518        domain: &str,
519        progress: Option<LookupProgressCallback>,
520    ) -> Result<LookupResult> {
521        #[cfg(test)]
522        LOOKUP_CONCURRENT_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
523
524        debug!(domain = %domain, "Attempting RDAP and WHOIS concurrently");
525
526        if let Some(ref cb) = progress {
527            cb("Querying RDAP and WHOIS concurrently");
528        }
529
530        let rdap_fut = self.rdap_client.lookup_domain(domain);
531        let whois_fut = self.whois_client.lookup(domain);
532
533        tokio::pin!(rdap_fut);
534        tokio::pin!(whois_fut);
535
536        // Race: whichever finishes first gets a grace period for the other.
537        //
538        // We track whether each side completed naturally or was truncated by
539        // the grace period, so downstream error messages can distinguish a
540        // true timeout from a loser-truncation.
541        enum LegOutcome<T> {
542            Completed(T),
543            GraceTruncated,
544        }
545
546        let (rdap_leg, whois_leg) = tokio::select! {
547            rdap_res = &mut rdap_fut => {
548                // RDAP finished first — give WHOIS a grace period
549                let whois_leg = match tokio_timeout(PROTOCOL_GRACE_PERIOD, whois_fut).await {
550                    Ok(res) => LegOutcome::Completed(res),
551                    Err(_) => {
552                        debug!("WHOIS did not finish within grace period, proceeding with RDAP only");
553                        LegOutcome::GraceTruncated
554                    }
555                };
556                (LegOutcome::Completed(rdap_res), whois_leg)
557            }
558            whois_res = &mut whois_fut => {
559                // WHOIS finished first — give RDAP a grace period
560                let rdap_leg = match tokio_timeout(PROTOCOL_GRACE_PERIOD, rdap_fut).await {
561                    Ok(res) => LegOutcome::Completed(res),
562                    Err(_) => {
563                        debug!("RDAP did not finish within grace period, proceeding with WHOIS only");
564                        LegOutcome::GraceTruncated
565                    }
566                };
567                (rdap_leg, LegOutcome::Completed(whois_res))
568            }
569        };
570
571        // Classify the RDAP leg.
572        let rdap_outcome = match rdap_leg {
573            LegOutcome::Completed(Ok(data)) => {
574                if self.is_rdap_response_useful(&data) {
575                    RdapOutcome::Useful(data)
576                } else {
577                    RdapOutcome::NoData(data)
578                }
579            }
580            LegOutcome::Completed(Err(e)) => RdapOutcome::Error(e),
581            LegOutcome::GraceTruncated => RdapOutcome::GraceTimeout,
582        };
583
584        // Phase 1: If RDAP returned useful data, use it as primary.
585        if let RdapOutcome::Useful(rdap_data) = rdap_outcome {
586            debug!("RDAP lookup successful");
587            let whois_fallback = match whois_leg {
588                LegOutcome::Completed(Ok(w)) => Some(w),
589                _ => None,
590            };
591            return Ok(LookupResult::Rdap {
592                data: Box::new(rdap_data),
593                whois_fallback,
594            });
595        }
596
597        // RDAP was not useful (NoData, Error, or GraceTimeout). Prefer WHOIS
598        // if it returned any response, even a thin one — this is safer than
599        // falling back to the availability heuristic when we have actual
600        // registry data in hand.
601        //
602        // We separately track whether RDAP returned an HTTP 200 (NoData):
603        // even a thin RDAP 200 is positive evidence the domain object
604        // exists. In that case we must NOT reclassify a WHOIS "no match"
605        // signal as availability — WHOIS lag against a freshly-provisioned
606        // domain would otherwise produce a false "available" verdict.
607        let rdap_returned_200 = matches!(rdap_outcome, RdapOutcome::NoData(_));
608        let (rdap_error_str, rdap_fallback_data, rdap_seer_error) = match rdap_outcome {
609            RdapOutcome::Useful(_) => {
610                // Unreachable in this branch (we returned above), but handle
611                // defensively rather than panicking across the FFI boundary.
612                debug!("Unexpected RdapOutcome::Useful in fallback branch");
613                (String::from("RDAP ok"), None, None)
614            }
615            RdapOutcome::NoData(data) => (
616                "RDAP response incomplete".to_string(),
617                Some(Box::new(data)),
618                None,
619            ),
620            RdapOutcome::Error(e) => (e.to_string(), None, Some(e)),
621            RdapOutcome::GraceTimeout => (
622                format!(
623                    "RDAP did not return within {}s grace period after WHOIS won",
624                    PROTOCOL_GRACE_PERIOD.as_secs()
625                ),
626                None,
627                None,
628            ),
629        };
630
631        if let LegOutcome::Completed(Ok(whois_data)) = whois_leg {
632            // Check Cases A and B: should we reclassify as Available? The
633            // `should_route_to_availability` helper also enforces the
634            // "RDAP returned 200 vetoes WHOIS availability claims" rule.
635            let availability_match = should_route_to_availability(
636                rdap_returned_200,
637                rdap_seer_error.as_ref(),
638                &whois_data,
639            );
640
641            if let Some((confidence, method)) = availability_match {
642                debug!(
643                    domain = %domain,
644                    confidence = %confidence,
645                    "Reclassifying WHOIS as availability signal"
646                );
647                if let Some(ref cb) = progress {
648                    cb("Domain appears unregistered");
649                }
650                let details = match confidence {
651                    "high" => Some("WHOIS indicates domain is not registered".to_string()),
652                    "medium" => Some(
653                        "WHOIS returned no registrar or registration dates; RDAP returned 404"
654                            .to_string(),
655                    ),
656                    _ => None,
657                };
658                let avail = AvailabilityResult {
659                    domain: domain.to_string(),
660                    available: true,
661                    confidence: confidence.to_string(),
662                    method: method.to_string(),
663                    details,
664                };
665                return Ok(LookupResult::Available {
666                    data: Box::new(avail),
667                    rdap_error: sanitize_error_for_public(&rdap_error_str),
668                    whois_error: String::new(),
669                    whois_data: Some(whois_data),
670                });
671            }
672
673            debug!("Using WHOIS result (RDAP not useful)");
674            if let Some(ref cb) = progress {
675                cb("RDAP not available (using WHOIS)");
676            }
677            return Ok(LookupResult::Whois {
678                data: whois_data,
679                rdap_error: Some(rdap_error_str),
680                rdap_fallback: rdap_fallback_data,
681            });
682        }
683
684        // Both sides failed to provide useful data. Craft a precise WHOIS
685        // error string that distinguishes true errors from grace-period
686        // truncation.
687        let whois_error_str = match whois_leg {
688            LegOutcome::Completed(Err(e)) => e.to_string(),
689            LegOutcome::Completed(Ok(_)) => {
690                // Already handled above; treat defensively.
691                debug!("Unexpected completed-Ok WHOIS in availability fallback branch");
692                "WHOIS returned but was not used".to_string()
693            }
694            LegOutcome::GraceTruncated => format!(
695                "WHOIS did not return within {}s grace period after RDAP won",
696                PROTOCOL_GRACE_PERIOD.as_secs()
697            ),
698        };
699
700        self.availability_fallback(domain, rdap_error_str, whois_error_str, progress)
701            .await
702    }
703
704    async fn availability_fallback(
705        &self,
706        domain: &str,
707        rdap_error: String,
708        whois_error: String,
709        progress: Option<LookupProgressCallback>,
710    ) -> Result<LookupResult> {
711        if let Some(ref cb) = progress {
712            cb("RDAP and WHOIS unavailable (checking availability)");
713        }
714        warn!(
715            domain = %domain,
716            rdap_error = %rdap_error,
717            whois_error = %whois_error,
718            "Both RDAP and WHOIS failed, falling back to availability check"
719        );
720
721        match self.availability_checker.check(domain).await {
722            Ok(avail) => Ok(LookupResult::Available {
723                data: Box::new(avail),
724                rdap_error: sanitize_error_for_public(&rdap_error),
725                whois_error: sanitize_error_for_public(&whois_error),
726                whois_data: None,
727            }),
728            Err(avail_err) => {
729                let tld = get_tld(domain).unwrap_or("unknown");
730                let registry_url = get_registry_url(tld).unwrap_or_else(|| {
731                    format!("https://www.iana.org/domains/root/db/{}.html", tld)
732                });
733                Err(SeerError::LookupFailed {
734                    domain: domain.to_string(),
735                    details: format!(
736                        "RDAP failed ({}), WHOIS failed ({}), availability check failed ({})",
737                        rdap_error, whois_error, avail_err
738                    ),
739                    registry_url,
740                })
741            }
742        }
743    }
744
745    fn is_rdap_response_useful(&self, response: &RdapResponse) -> bool {
746        // Check if we have at least some meaningful data
747        let has_name = response.ldh_name.is_some() || response.unicode_name.is_some();
748        let has_dates = response
749            .events
750            .iter()
751            .any(|e| e.event_action == "registration" || e.event_action == "expiration");
752        let has_entities = !response.entities.is_empty();
753        let has_nameservers = !response.nameservers.is_empty();
754        let has_status = !response.status.is_empty();
755
756        // Consider useful if we have the name plus at least one other piece of info
757        has_name && (has_dates || has_entities || has_nameservers || has_status)
758    }
759}
760
761#[cfg(test)]
762mod tests {
763    use super::*;
764
765    /// Global serialization mutex for the three tests that share
766    /// `LOOKUP_INFLIGHT` state (coalescing, poison recovery, drop recovery).
767    /// Running them in parallel creates two races:
768    ///   1. Guard drop uses `try_lock`; if another test holds the mutex, the
769    ///      Drop path skips cleanup → stale entries fail later assertions.
770    ///   2. Poisoning one test leaves the mutex poisoned for the next test,
771    ///      which is handled by `unwrap_or_else` but still disturbs state.
772    /// Per-test unique keys (see `unique_test_key`) prevent entry-level
773    /// collisions; this mutex prevents lock-contention races on Drop.
774    static INFLIGHT_TEST_SERIAL: Mutex<()> = Mutex::new(());
775
776    #[test]
777    fn test_lookup_result_domain_name_whois() {
778        let result = LookupResult::Whois {
779            data: WhoisResponse {
780                domain: "example.com".to_string(),
781                registrar: Some("Test Registrar".to_string()),
782                registrant: None,
783                organization: None,
784                registrant_email: None,
785                registrant_phone: None,
786                registrant_address: None,
787                registrant_country: None,
788                admin_name: None,
789                admin_organization: None,
790                admin_email: None,
791                admin_phone: None,
792                tech_name: None,
793                tech_organization: None,
794                tech_email: None,
795                tech_phone: None,
796                creation_date: None,
797                expiration_date: None,
798                updated_date: None,
799                status: vec![],
800                nameservers: vec![],
801                dnssec: None,
802                whois_server: "whois.example.com".to_string(),
803                raw_response: String::new(),
804            },
805            rdap_error: None,
806            rdap_fallback: None,
807        };
808
809        assert_eq!(result.domain_name(), Some("example.com".to_string()));
810        assert_eq!(result.registrar(), Some("Test Registrar".to_string()));
811        assert!(result.is_whois());
812        assert!(!result.is_rdap());
813        assert!(!result.is_available());
814    }
815
816    #[test]
817    fn test_lookup_result_serialization() {
818        let result = LookupResult::Whois {
819            data: WhoisResponse {
820                domain: "test.com".to_string(),
821                registrar: None,
822                registrant: None,
823                organization: None,
824                registrant_email: None,
825                registrant_phone: None,
826                registrant_address: None,
827                registrant_country: None,
828                admin_name: None,
829                admin_organization: None,
830                admin_email: None,
831                admin_phone: None,
832                tech_name: None,
833                tech_organization: None,
834                tech_email: None,
835                tech_phone: None,
836                creation_date: None,
837                expiration_date: None,
838                updated_date: None,
839                status: vec![],
840                nameservers: vec![],
841                dnssec: None,
842                whois_server: String::new(),
843                raw_response: String::new(),
844            },
845            rdap_error: Some("RDAP failed".to_string()),
846            rdap_fallback: None,
847        };
848
849        let json = serde_json::to_string(&result).unwrap();
850        assert!(json.contains("\"source\":\"whois\""));
851        assert!(json.contains("RDAP failed"));
852    }
853
854    #[test]
855    fn test_lookup_result_available_serialization() {
856        let result = LookupResult::Available {
857            data: Box::new(AvailabilityResult {
858                domain: "test123.xyz".to_string(),
859                available: true,
860                confidence: "medium".to_string(),
861                method: "whois_error".to_string(),
862                details: Some("WHOIS server indicates no matching records".to_string()),
863            }),
864            rdap_error: "RDAP failed".to_string(),
865            whois_error: "WHOIS failed".to_string(),
866            whois_data: None,
867        };
868
869        let json = serde_json::to_string(&result).unwrap();
870        assert!(json.contains("\"source\":\"available\""));
871        assert!(json.contains("\"available\":true"));
872        assert!(json.contains("test123.xyz"));
873
874        assert_eq!(result.domain_name(), Some("test123.xyz".to_string()));
875        assert!(result.is_available());
876        assert!(!result.is_rdap());
877        assert!(!result.is_whois());
878        assert!(result.registrar().is_none());
879        assert_eq!(result.expiration_info(), (None, None));
880    }
881
882    #[test]
883    #[allow(deprecated)]
884    fn test_smart_lookup_builder() {
885        let lookup = SmartLookup::new().prefer_rdap(false).include_fallback(true);
886        assert!(!lookup.prefer_rdap);
887        assert!(lookup.include_fallback);
888    }
889
890    #[test]
891    fn test_lookup_cache_clear() {
892        SmartLookup::clear_cache();
893        assert!(LOOKUP_CACHE.is_empty());
894    }
895
896    // ---------------- sanitize_error_for_public ----------------
897
898    #[test]
899    fn test_sanitize_strips_ipv4() {
900        let msg = "RDAP URL resolves to reserved IP 10.0.0.1 which is forbidden";
901        let sanitized = sanitize_error_for_public(msg);
902        assert!(
903            !sanitized.contains("10.0.0.1"),
904            "IPv4 should be stripped, got: {}",
905            sanitized
906        );
907        assert!(sanitized.contains("[ip-redacted]"));
908    }
909
910    #[test]
911    fn test_sanitize_strips_multiple_ipv4() {
912        let msg = "Could not connect to 192.168.1.1 after trying 127.0.0.1";
913        let sanitized = sanitize_error_for_public(msg);
914        assert!(!sanitized.contains("192.168.1.1"));
915        assert!(!sanitized.contains("127.0.0.1"));
916        // Two redactions expected.
917        assert_eq!(sanitized.matches("[ip-redacted]").count(), 2);
918    }
919
920    #[test]
921    fn test_sanitize_strips_ipv6() {
922        let msg = "RDAP URL resolves to reserved IP fe80::1 which is forbidden";
923        let sanitized = sanitize_error_for_public(msg);
924        assert!(!sanitized.contains("fe80::1"));
925        assert!(sanitized.contains("[ip-redacted]"));
926    }
927
928    #[test]
929    fn sanitize_leaves_mac_address_like_tokens_alone() {
930        let msg = "error code af:ba:12 at line 5";
931        let out = sanitize_error_for_public(msg);
932        assert!(
933            out.contains("af:ba:12"),
934            "MAC fragment should not be stripped: {}",
935            out
936        );
937    }
938
939    #[test]
940    fn sanitize_strips_real_ipv6() {
941        let msg = "cannot reach 2001:db8::1 — timeout";
942        let out = sanitize_error_for_public(msg);
943        assert!(!out.contains("2001:db8::1"));
944        assert!(out.contains("[ip-redacted]"));
945    }
946
947    #[test]
948    fn sanitize_strips_fe80_link_local() {
949        let msg = "peer at fe80::1 unreachable";
950        let out = sanitize_error_for_public(msg);
951        assert!(out.contains("[ip-redacted]"));
952    }
953
954    #[test]
955    fn test_sanitize_truncates_long_message() {
956        // Build a 500-char message with no IPs.
957        let long = "a".repeat(500);
958        let sanitized = sanitize_error_for_public(&long);
959        // Should cap at MAX_PUBLIC_ERROR_LEN chars + ellipsis.
960        let char_count = sanitized.chars().count();
961        assert_eq!(char_count, MAX_PUBLIC_ERROR_LEN + 1);
962        assert!(sanitized.ends_with('…'));
963    }
964
965    #[test]
966    fn test_sanitize_preserves_short_messages() {
967        let msg = "RDAP timed out after 15s";
968        let sanitized = sanitize_error_for_public(msg);
969        assert_eq!(sanitized, msg);
970    }
971
972    // ---------------- RdapOutcome classification ----------------
973
974    #[test]
975    fn test_is_rdap_response_useful_detects_no_data() {
976        use crate::rdap::RdapResponse;
977        // Construct a response with a name but no events, entities, NS, or status
978        // — this is the "200 OK but no useful fields" case that should be
979        // classified as RdapOutcome::NoData (not Useful, not Error).
980        let resp = RdapResponse {
981            ldh_name: Some("example.com".to_string()),
982            ..Default::default()
983        };
984        let lookup = SmartLookup::new();
985        assert!(
986            !lookup.is_rdap_response_useful(&resp),
987            "Response with only a name should be classified as NoData"
988        );
989
990        // And one with a name + status IS useful (sanity check).
991        let useful = RdapResponse {
992            ldh_name: Some("example.com".to_string()),
993            status: vec!["active".to_string()],
994            ..Default::default()
995        };
996        assert!(lookup.is_rdap_response_useful(&useful));
997    }
998
999    // ---------------- Coalescing ----------------
1000
1001    // Verifies that when multiple concurrent lookups hit the in-flight map
1002    // for the same domain, later arrivals observe the existing Weak<Notify>
1003    // and become waiters rather than racing a second lookup. We test the
1004    // map-level primitive here because the full SmartLookup pipeline
1005    // requires network access to exercise.
1006    #[tokio::test]
1007    async fn test_inflight_coalescing_map() {
1008        // Serialize with sibling poisoning tests: we share LOOKUP_INFLIGHT
1009        // state, and `InflightGuard::drop` uses `try_lock` — if a sibling
1010        // holds the mutex during drop, cleanup is skipped and assertions
1011        // fail.
1012        let _serial = INFLIGHT_TEST_SERIAL
1013            .lock()
1014            .unwrap_or_else(|p| p.into_inner());
1015        // Poison-tolerant: the sibling poisoning regression tests may run
1016        // earlier under `cargo test` parallelism and leave LOOKUP_INFLIGHT
1017        // poisoned. The production code recovers via `unwrap_or_else`,
1018        // so this test does the same.
1019        //
1020        // Use a per-run unique key so this test cannot race with the other
1021        // tests that touch LOOKUP_INFLIGHT. Previously we `clear()`ed the
1022        // whole map, which raced with peer tests' entries.
1023        let domain = unique_test_key("__coalesce");
1024
1025        // Defensive: ensure our specific key is not present.
1026        {
1027            let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1028            m.remove(&domain);
1029        }
1030
1031        // First caller: no entry → becomes owner.
1032        let owner_notify = Arc::new(Notify::new());
1033        {
1034            let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1035            assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1036            m.insert(domain.clone(), Arc::downgrade(&owner_notify));
1037        }
1038
1039        // Second caller: sees the existing Weak and upgrades.
1040        let waiter = {
1041            let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1042            m.get(&domain)
1043                .and_then(|w| w.upgrade())
1044                .expect("Second caller must observe in-flight entry")
1045        };
1046
1047        // Waiter listens in the background.
1048        let waiter_clone = waiter.clone();
1049        let handle = tokio::spawn(async move {
1050            waiter_clone.notified().await;
1051        });
1052
1053        // Simulate owner completing.
1054        tokio::time::sleep(Duration::from_millis(20)).await;
1055        {
1056            let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1057            m.remove(&domain);
1058        }
1059        owner_notify.notify_waiters();
1060
1061        // Waiter should unblock quickly.
1062        tokio::time::timeout(Duration::from_secs(1), handle)
1063            .await
1064            .expect("waiter must unblock after notify")
1065            .expect("waiter task joined cleanly");
1066
1067        // After owner removes entry and drops its Arc, the Weak is dead.
1068        drop(owner_notify);
1069        drop(waiter);
1070        let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1071        assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1072    }
1073
1074    /// Builds a domain key guaranteed unique per test invocation, so that
1075    /// tests touching the shared LOOKUP_INFLIGHT static never collide when
1076    /// `cargo test` runs them in parallel. We include a nanosecond timestamp
1077    /// plus an atomic counter to defeat even hash-identical calls within the
1078    /// same nanosecond.
1079    fn unique_test_key(prefix: &str) -> String {
1080        use std::sync::atomic::{AtomicU64, Ordering};
1081        use std::time::{SystemTime, UNIX_EPOCH};
1082        static COUNTER: AtomicU64 = AtomicU64::new(0);
1083        let nanos = SystemTime::now()
1084            .duration_since(UNIX_EPOCH)
1085            .map(|d| d.as_nanos())
1086            .unwrap_or(0);
1087        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1088        format!("{}_{}_{}.example.", prefix, nanos, n)
1089    }
1090
1091    // Demonstrates that the `sanitize_error_for_public` helper is applied
1092    // to the rdap_error / whois_error fields written into the `Available`
1093    // variant. We check the call site indirectly: construct a Available
1094    // manually and then verify a raw error with an IP becomes redacted.
1095    // (Integration via real clients would require network.)
1096    #[test]
1097    fn test_sanitize_applied_to_available_fields() {
1098        let rdap_raw = "RDAP URL resolves to reserved IP 10.0.0.1";
1099        let whois_raw = "connection refused at 192.168.0.5";
1100        let sanitized_rdap = sanitize_error_for_public(rdap_raw);
1101        let sanitized_whois = sanitize_error_for_public(whois_raw);
1102        let result = LookupResult::Available {
1103            data: Box::new(AvailabilityResult {
1104                domain: "unreg.test".to_string(),
1105                available: true,
1106                confidence: "low".to_string(),
1107                method: "heuristic".to_string(),
1108                details: None,
1109            }),
1110            rdap_error: sanitized_rdap,
1111            whois_error: sanitized_whois,
1112            whois_data: None,
1113        };
1114        if let LookupResult::Available {
1115            rdap_error,
1116            whois_error,
1117            ..
1118        } = result
1119        {
1120            assert!(!rdap_error.contains("10.0.0.1"));
1121            assert!(!whois_error.contains("192.168.0.5"));
1122            assert!(rdap_error.contains("[ip-redacted]"));
1123            assert!(whois_error.contains("[ip-redacted]"));
1124        } else {
1125            panic!("expected Available variant");
1126        }
1127    }
1128
1129    #[test]
1130    fn rdap_error_is_404_matches_standard_404() {
1131        let e = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1132        assert!(rdap_error_is_404(&e));
1133    }
1134
1135    #[test]
1136    fn rdap_error_is_404_matches_without_reason_phrase() {
1137        let e = SeerError::RdapError("query failed with status 404".to_string());
1138        assert!(rdap_error_is_404(&e));
1139    }
1140
1141    #[test]
1142    fn rdap_error_is_404_rejects_other_statuses() {
1143        let e = SeerError::RdapError("query failed with status 500 Server Error".to_string());
1144        assert!(!rdap_error_is_404(&e));
1145        let e = SeerError::RdapError("query failed with status 400 Bad Request".to_string());
1146        assert!(!rdap_error_is_404(&e));
1147    }
1148
1149    #[test]
1150    fn rdap_error_is_404_rejects_non_http_errors() {
1151        let e = SeerError::RdapError("connection timeout".to_string());
1152        assert!(!rdap_error_is_404(&e));
1153        let e = SeerError::Timeout("rdap".to_string());
1154        assert!(!rdap_error_is_404(&e));
1155    }
1156
1157    #[test]
1158    fn rdap_error_is_404_rejects_incidental_404_in_message() {
1159        // A 404 substring inside a non-status context must not match.
1160        let e = SeerError::RdapError("error 40404: database corruption".to_string());
1161        assert!(!rdap_error_is_404(&e));
1162    }
1163
1164    // ---------------- whois_response_is_thin ----------------
1165
1166    fn empty_whois(domain: &str) -> WhoisResponse {
1167        WhoisResponse {
1168            domain: domain.to_string(),
1169            registrar: None,
1170            registrant: None,
1171            organization: None,
1172            registrant_email: None,
1173            registrant_phone: None,
1174            registrant_address: None,
1175            registrant_country: None,
1176            admin_name: None,
1177            admin_organization: None,
1178            admin_email: None,
1179            admin_phone: None,
1180            tech_name: None,
1181            tech_organization: None,
1182            tech_email: None,
1183            tech_phone: None,
1184            creation_date: None,
1185            expiration_date: None,
1186            updated_date: None,
1187            nameservers: vec![],
1188            status: vec![],
1189            dnssec: None,
1190            whois_server: String::new(),
1191            raw_response: String::new(),
1192        }
1193    }
1194
1195    #[test]
1196    fn whois_response_is_thin_when_all_key_fields_missing() {
1197        let w = empty_whois("example.com");
1198        assert!(whois_response_is_thin(&w));
1199    }
1200
1201    #[test]
1202    fn whois_response_is_not_thin_when_registrar_present() {
1203        let mut w = empty_whois("example.com");
1204        w.registrar = Some("Test Registrar".to_string());
1205        assert!(!whois_response_is_thin(&w));
1206    }
1207
1208    #[test]
1209    fn whois_response_is_not_thin_when_creation_date_present() {
1210        let mut w = empty_whois("example.com");
1211        w.creation_date = Some(Utc::now());
1212        assert!(!whois_response_is_thin(&w));
1213    }
1214
1215    #[test]
1216    fn whois_response_is_not_thin_when_expiration_date_present() {
1217        let mut w = empty_whois("example.com");
1218        w.expiration_date = Some(Utc::now());
1219        assert!(!whois_response_is_thin(&w));
1220    }
1221
1222    #[test]
1223    fn whois_response_is_thin_even_with_nameservers_alone() {
1224        let mut w = empty_whois("example.com");
1225        w.nameservers = vec!["ns1.example.net".to_string()];
1226        assert!(whois_response_is_thin(&w));
1227    }
1228
1229    // ---------------- classify_whois_leg ----------------
1230
1231    use crate::rdap::RdapResponse;
1232
1233    #[allow(dead_code)]
1234    fn make_empty_rdap_response() -> RdapResponse {
1235        serde_json::from_value(serde_json::json!({
1236            "objectClassName": "domain",
1237        }))
1238        .expect("valid minimal RDAP response")
1239    }
1240
1241    #[test]
1242    fn classify_whois_leg_case_a_high_confidence() {
1243        let mut w = empty_whois("zaccodes.com");
1244        w.raw_response = "No match for \"ZACCODES.COM\".".to_string();
1245        assert!(w.is_available());
1246        let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1247        let (verdict, method) =
1248            classify_whois_leg(&w, &rdap_err).expect("expected a routing decision");
1249        assert_eq!(verdict, "high");
1250        assert_eq!(method, "whois");
1251    }
1252
1253    #[test]
1254    fn classify_whois_leg_case_b_medium_confidence() {
1255        let w = empty_whois("example.xyz");
1256        assert!(!w.is_available(), "this WHOIS body has no 'no match' text");
1257        let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1258        let (verdict, method) =
1259            classify_whois_leg(&w, &rdap_err).expect("expected a routing decision");
1260        assert_eq!(verdict, "medium");
1261        assert_eq!(method, "whois_thin_response");
1262    }
1263
1264    #[test]
1265    fn classify_whois_leg_rejects_thin_whois_without_404() {
1266        let w = empty_whois("example.xyz");
1267        let rdap_err = SeerError::RdapError("connection timeout".to_string());
1268        assert!(classify_whois_leg(&w, &rdap_err).is_none());
1269    }
1270
1271    #[test]
1272    fn classify_whois_leg_rejects_whois_with_real_data() {
1273        let mut w = empty_whois("legacy.tld");
1274        w.registrar = Some("Legacy Registry".to_string());
1275        w.creation_date = Some(Utc::now());
1276        let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1277        assert!(classify_whois_leg(&w, &rdap_err).is_none());
1278    }
1279
1280    #[test]
1281    fn classify_whois_leg_case_a_wins_over_case_b() {
1282        let mut w = empty_whois("example.com");
1283        w.raw_response = "No match for \"EXAMPLE.COM\".".to_string();
1284        let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1285        let (verdict, _) = classify_whois_leg(&w, &rdap_err).unwrap();
1286        assert_eq!(verdict, "high");
1287    }
1288
1289    // ---------------- should_route_to_availability ----------------
1290    //
1291    // Regression coverage for the v0.26.6 fix: when RDAP returned an HTTP 200
1292    // (even with thin body), a WHOIS "no match" must NOT be treated as
1293    // evidence of availability — that would let propagation lag flip the
1294    // verdict for a domain the registry has already provisioned.
1295
1296    #[test]
1297    fn rdap_200_vetoes_whois_no_match() {
1298        let mut w = empty_whois("freshly-registered.com");
1299        w.raw_response = "No match for \"FRESHLY-REGISTERED.COM\".".to_string();
1300        // rdap_returned_200 = true, no rdap_seer_error (NoData has no error).
1301        assert!(
1302            should_route_to_availability(true, None, &w).is_none(),
1303            "RDAP 200 must veto WHOIS-only availability claim",
1304        );
1305    }
1306
1307    #[test]
1308    fn rdap_200_vetoes_even_with_thin_whois() {
1309        let w = empty_whois("freshly-registered.com");
1310        // Thin WHOIS without is_available() patterns.
1311        assert!(
1312            should_route_to_availability(true, None, &w).is_none(),
1313            "RDAP 200 must veto even when WHOIS is thin",
1314        );
1315    }
1316
1317    #[test]
1318    fn rdap_404_with_whois_no_match_routes_to_available() {
1319        let mut w = empty_whois("genuinely-free.com");
1320        w.raw_response = "No match for \"GENUINELY-FREE.COM\".".to_string();
1321        let rdap_err = SeerError::RdapError("query failed with status 404".to_string());
1322        let result = should_route_to_availability(false, Some(&rdap_err), &w);
1323        assert_eq!(result, Some(("high", "whois")));
1324    }
1325
1326    #[test]
1327    fn rdap_error_with_whois_is_available_still_routes_case_a() {
1328        let mut w = empty_whois("genuinely-free.com");
1329        w.raw_response = "Domain not found".to_string();
1330        // RDAP errored for a non-404 reason (e.g. bootstrap failure); WHOIS
1331        // signal alone should still route to availability.
1332        let rdap_err = SeerError::RdapBootstrapError("all registries failed".to_string());
1333        let result = should_route_to_availability(false, Some(&rdap_err), &w);
1334        assert_eq!(result, Some(("high", "whois")));
1335    }
1336
1337    #[test]
1338    fn rdap_grace_timeout_with_whois_is_available_routes_case_a() {
1339        // GraceTimeout path: rdap_returned_200 = false, rdap_seer_error = None.
1340        let mut w = empty_whois("genuinely-free.com");
1341        w.raw_response = "No match".to_string();
1342        let result = should_route_to_availability(false, None, &w);
1343        assert_eq!(result, Some(("high", "whois")));
1344    }
1345
1346    #[test]
1347    fn no_rdap_200_no_error_thick_whois_stays_in_whois_path() {
1348        let mut w = empty_whois("registered.com");
1349        w.registrar = Some("Example Registrar Ltd".to_string());
1350        // GraceTimeout-like: rdap_returned_200=false, no error, and WHOIS
1351        // does not look free. Must return None so the caller picks
1352        // `LookupResult::Whois`.
1353        assert!(should_route_to_availability(false, None, &w).is_none());
1354    }
1355
1356    // ---------------- Mutex poisoning recovery ----------------
1357
1358    /// Regression: a panic inside `LOOKUP_INFLIGHT.lock()` must not wedge
1359    /// the tracker forever. After the mutex is poisoned, subsequent
1360    /// acquisition attempts must still succeed via `unwrap_or_else`.
1361    ///
1362    /// This isolates the lookup_with_progress acquisition site (formerly a
1363    /// `.expect("LOOKUP_INFLIGHT mutex poisoned")`) by exercising the same
1364    /// `.lock().unwrap_or_else(|p| p.into_inner())` pattern directly.
1365    #[test]
1366    fn lookup_inflight_recovers_from_poisoned_mutex() {
1367        use std::panic::{catch_unwind, AssertUnwindSafe};
1368
1369        // Serialize with sibling tests that also touch LOOKUP_INFLIGHT.
1370        let _serial = INFLIGHT_TEST_SERIAL
1371            .lock()
1372            .unwrap_or_else(|p| p.into_inner());
1373
1374        // Poison the real static by panicking while holding the guard.
1375        let _ = catch_unwind(AssertUnwindSafe(|| {
1376            let _guard = LOOKUP_INFLIGHT.lock().unwrap();
1377            panic!("poisoning LOOKUP_INFLIGHT for test");
1378        }));
1379
1380        // At this point LOOKUP_INFLIGHT is poisoned. Plain .lock() would
1381        // return Err(PoisonError). The recovery pattern used in
1382        // lookup_with_progress must still yield a usable guard.
1383        let mut guard = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1384        // Use a per-run unique canary so parallel tests cannot collide.
1385        let canary = unique_test_key("__poison_recovery");
1386        guard.insert(canary.clone(), Weak::new());
1387        assert!(guard.contains_key(&canary));
1388        guard.remove(&canary);
1389    }
1390
1391    /// Regression: InflightGuard::drop must also tolerate mutex poisoning
1392    /// without panicking — the Poisoned arm should still remove the entry.
1393    #[test]
1394    fn inflight_guard_drop_recovers_from_poisoned_mutex() {
1395        use std::panic::{catch_unwind, AssertUnwindSafe};
1396
1397        // Serialize with sibling tests that also touch LOOKUP_INFLIGHT —
1398        // the critical race was `InflightGuard::drop` using `try_lock`
1399        // and silently skipping cleanup when a parallel test held the
1400        // mutex, leaving this test's entry in the map and failing the
1401        // final assertion.
1402        let _serial = INFLIGHT_TEST_SERIAL
1403            .lock()
1404            .unwrap_or_else(|p| p.into_inner());
1405
1406        // Seed an entry and arm a guard for it. Use a per-run unique key
1407        // so this test can never collide with siblings under parallel
1408        // `cargo test` — previously a hard-coded key raced with the peer
1409        // coalescing test's `m.clear()` call.
1410        let key = unique_test_key("__drop_poison");
1411        let notify = Arc::new(Notify::new());
1412        {
1413            let mut map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1414            map.insert(key.clone(), Arc::downgrade(&notify));
1415        }
1416        let guard = InflightGuard {
1417            key: key.clone(),
1418            notify: notify.clone(),
1419        };
1420
1421        // Poison the mutex.
1422        let _ = catch_unwind(AssertUnwindSafe(|| {
1423            let _g = LOOKUP_INFLIGHT.lock().unwrap();
1424            panic!("poisoning LOOKUP_INFLIGHT for drop test");
1425        }));
1426
1427        // Dropping the guard must not panic and must remove the entry via
1428        // the Poisoned branch of the new try_lock match.
1429        drop(guard);
1430
1431        let map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1432        assert!(
1433            !map.contains_key(&key),
1434            "poisoned-mutex drop path should still remove the in-flight entry"
1435        );
1436    }
1437}