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