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