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