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