Skip to main content

seer_core/
lookup.rs

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