Skip to main content

seer_core/
lookup.rs

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