Skip to main content

seer_core/
lookup.rs

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