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