Skip to main content

seer_core/
lookup.rs

1use std::collections::HashMap;
2use std::net::Ipv6Addr;
3use std::str::FromStr;
4use std::sync::{Arc, Mutex, Weak};
5use std::time::Duration;
6
7use chrono::{DateTime, Utc};
8use once_cell::sync::Lazy;
9use regex::Regex;
10use serde::{Deserialize, Serialize};
11use tokio::sync::Notify;
12use tracing::{debug, instrument, warn};
13
14use tokio::time::timeout as tokio_timeout;
15
16use crate::availability::{AvailabilityChecker, AvailabilityResult};
17use crate::cache::TtlCache;
18use crate::error::{Result, SeerError};
19use crate::rdap::{RdapClient, RdapResponse};
20use crate::whois::{get_registry_url, get_tld, WhoisClient, WhoisResponse};
21
22/// Cache TTL for lookup results (5 minutes).
23const LOOKUP_CACHE_TTL: Duration = Duration::from_secs(5 * 60);
24
25/// Grace period for the second protocol after the first one finishes.
26/// If WHOIS finishes and RDAP hasn't responded within this window, we
27/// use the WHOIS result rather than waiting the full RDAP timeout.
28const PROTOCOL_GRACE_PERIOD: Duration = Duration::from_secs(5);
29
30/// Maximum length for public-facing error strings.
31const MAX_PUBLIC_ERROR_LEN: usize = 256;
32
33/// Global cache for lookup results to avoid redundant network calls.
34static LOOKUP_CACHE: Lazy<TtlCache<String, LookupResult>> =
35    Lazy::new(|| TtlCache::new(LOOKUP_CACHE_TTL));
36
37/// In-flight lookup coalescing map: normalized-domain -> Weak<Notify>.
38/// Only one network race runs per unique domain at a time; concurrent callers
39/// wait on the shared Notify and then read the result from LOOKUP_CACHE.
40static LOOKUP_INFLIGHT: Lazy<Mutex<HashMap<String, Weak<Notify>>>> =
41    Lazy::new(|| Mutex::new(HashMap::new()));
42
43/// Regex patterns for stripping IP literals from public error messages.
44static IPV4_RE: Lazy<Regex> =
45    Lazy::new(|| Regex::new(r"\b(?:\d{1,3}\.){3}\d{1,3}\b").expect("IPV4_RE is a valid regex"));
46
47/// Candidate pattern for IPv6 literals: a hex/colon token containing either
48/// a `::` compression or at least three colons. This catches plausible IPv6
49/// addresses cheaply; each match is then validated by `Ipv6Addr::from_str`
50/// before redaction, so MAC fragments, hex hashes, and similar colon-laden
51/// tokens are left alone.
52static IPV6_CANDIDATE_RE: Lazy<Regex> = Lazy::new(|| {
53    Regex::new(r"\b[0-9a-fA-F:]*(?:::|(?:[0-9a-fA-F]{1,4}:){3,})[0-9a-fA-F:]*\b")
54        .expect("IPV6_CANDIDATE_RE is a valid regex")
55});
56
57/// Redact substrings that parse as valid IPv6 addresses, leaving non-IPv6
58/// tokens (e.g. `af:ba:12`) untouched.
59fn strip_ipv6(msg: &str) -> String {
60    IPV6_CANDIDATE_RE
61        .replace_all(msg, |caps: &regex::Captures| {
62            let candidate = &caps[0];
63            if Ipv6Addr::from_str(candidate).is_ok() {
64                "[ip-redacted]".to_string()
65            } else {
66                candidate.to_string()
67            }
68        })
69        .into_owned()
70}
71
72/// Test-only hook: counts the number of times `lookup_concurrent` is actually
73/// invoked (i.e., the underlying network race runs). Used to verify request
74/// coalescing. Not exposed outside the crate.
75#[cfg(test)]
76static LOOKUP_CONCURRENT_CALLS: Lazy<std::sync::atomic::AtomicUsize> =
77    Lazy::new(|| std::sync::atomic::AtomicUsize::new(0));
78
79/// Returns true if the error is an RDAP HTTP 404 response, indicating the
80/// registry's RDAP server has no entry for this domain. Other RDAP errors
81/// (timeouts, 5xx, connection failures, etc.) do NOT match — they mean "we
82/// don't know", not "not registered".
83///
84/// Matches the format produced by `seer-core/src/rdap/client.rs:603`:
85/// `"query failed with status 404 ..."`.
86fn rdap_error_is_404(err: &SeerError) -> bool {
87    if let SeerError::RdapError(msg) = err {
88        msg.contains("query failed with status 404")
89    } else {
90        false
91    }
92}
93
94/// Returns true if the parsed WHOIS response lacks all key registration
95/// signals: no registrar, no creation date, and no expiration date.
96///
97/// This is a necessary-but-not-sufficient signal for domain availability;
98/// `lookup_concurrent` combines it with an RDAP 404 before routing to the
99/// availability path. Nameservers alone don't disqualify thinness — some
100/// registries return placeholder nameservers for unregistered domains.
101fn whois_response_is_thin(w: &WhoisResponse) -> bool {
102    w.registrar.is_none() && w.creation_date.is_none() && w.expiration_date.is_none()
103}
104
105/// Decides whether a WHOIS response + RDAP error combination should route
106/// to the availability path. Returns `(confidence, method)` when routing is
107/// warranted, `None` to keep the existing `LookupResult::Whois` behavior.
108///
109/// Case A: WHOIS explicitly indicates no registration (highest priority).
110/// Case B: WHOIS returned but lacks registration data AND RDAP returned 404.
111fn classify_whois_leg(
112    w: &WhoisResponse,
113    rdap_err: &SeerError,
114) -> Option<(&'static str, &'static str)> {
115    if w.is_available() {
116        return Some(("high", "whois"));
117    }
118    if whois_response_is_thin(w) && rdap_error_is_404(rdap_err) {
119        return Some(("medium", "whois_thin_response"));
120    }
121    None
122}
123
124/// Sanitizes an error message for inclusion in a public-facing response.
125///
126/// Strips IPv4 and IPv6 literals (to avoid leaking internal addresses when
127/// an SSRF guard rejects a resolved URL) and caps the total length to
128/// [`MAX_PUBLIC_ERROR_LEN`] characters.
129fn sanitize_error_for_public(msg: &str) -> String {
130    let s = IPV4_RE.replace_all(msg, "[ip-redacted]");
131    let s = strip_ipv6(&s);
132    if s.chars().count() > MAX_PUBLIC_ERROR_LEN {
133        let mut trunc: String = s.chars().take(MAX_PUBLIC_ERROR_LEN).collect();
134        trunc.push('…');
135        trunc
136    } else {
137        s
138    }
139}
140
141/// RAII guard for the in-flight-lookup slot. On drop, removes the entry
142/// from `LOOKUP_INFLIGHT` and notifies any waiters so they can read the
143/// freshly-populated cache.
144///
145/// NOTE on failed-owner retry semantics:
146/// When the owning task's lookup fails, `InflightGuard::drop` runs, the
147/// `HashMap` entry is removed, and `notify_waiters()` fires. Waiters wake,
148/// observe an empty cache, and one of them becomes the new owner — triggering
149/// a fresh network race. This means transient failures are automatically
150/// retried by any concurrent waiter. Callers that observe a timeout error
151/// should not assume no work is in flight; another concurrent caller may
152/// already be retrying.
153struct InflightGuard {
154    key: String,
155    notify: Arc<Notify>,
156}
157
158impl Drop for InflightGuard {
159    fn drop(&mut self) {
160        // Avoid blocking on the mutex inside Drop. A cancelled future that
161        // drops this guard could otherwise starve the Tokio executor while
162        // it waits for contention to clear. `try_lock` lets us take the
163        // fast path when the map is uncontended, recover from poisoning
164        // explicitly, and simply skip cleanup when another task holds the
165        // mutex — waiters re-contend for ownership on their next wakeup,
166        // so a missed cleanup is self-healing.
167        match LOOKUP_INFLIGHT.try_lock() {
168            Ok(mut inflight) => {
169                inflight.remove(&self.key);
170            }
171            Err(std::sync::TryLockError::Poisoned(p)) => {
172                let mut inflight = p.into_inner();
173                inflight.remove(&self.key);
174            }
175            Err(std::sync::TryLockError::WouldBlock) => {
176                tracing::debug!(
177                    key = %self.key,
178                    "InflightGuard drop: skipping cleanup under contention"
179                );
180            }
181        }
182        self.notify.notify_waiters();
183    }
184}
185
186/// Internal classification of the RDAP leg of a concurrent lookup.
187///
188/// Distinguishing `NoData` (HTTP 200 but response was missing useful fields)
189/// from `Error` lets the orchestrator prefer a thin WHOIS result over the
190/// availability fallback when RDAP silently returned nothing.
191enum RdapOutcome {
192    Useful(RdapResponse),
193    NoData(RdapResponse),
194    Error(SeerError),
195    /// RDAP future did not complete within the grace period after the other
196    /// protocol finished.
197    GraceTimeout,
198}
199
200/// Progress callback for smart lookup operations.
201/// Called with a message describing the current phase of the lookup.
202pub type LookupProgressCallback = Arc<dyn Fn(&str) + Send + Sync>;
203
204#[derive(Debug, Clone, Serialize, Deserialize)]
205#[serde(tag = "source", rename_all = "lowercase")]
206pub enum LookupResult {
207    Rdap {
208        data: Box<RdapResponse>,
209        #[serde(skip_serializing_if = "Option::is_none")]
210        whois_fallback: Option<WhoisResponse>,
211    },
212    Whois {
213        data: WhoisResponse,
214        rdap_error: Option<String>,
215        #[serde(skip_serializing_if = "Option::is_none")]
216        rdap_fallback: Option<Box<RdapResponse>>,
217    },
218    Available {
219        data: Box<AvailabilityResult>,
220        rdap_error: String,
221        whois_error: String,
222        /// Raw WHOIS response, when one was available at routing time
223        /// (Cases A and B in the design spec). `None` preserves the
224        /// pre-existing "both protocols errored" semantics.
225        #[serde(default, skip_serializing_if = "Option::is_none")]
226        whois_data: Option<WhoisResponse>,
227    },
228}
229
230impl LookupResult {
231    /// Returns the domain name from the lookup result.
232    pub fn domain_name(&self) -> Option<String> {
233        match self {
234            LookupResult::Rdap { data, .. } => data.domain_name().map(String::from),
235            LookupResult::Whois { data, .. } => Some(data.domain.clone()),
236            LookupResult::Available { data, .. } => Some(data.domain.clone()),
237        }
238    }
239
240    /// Returns the registrar name, preferring RDAP data with WHOIS fallback.
241    pub fn registrar(&self) -> Option<String> {
242        match self {
243            LookupResult::Rdap {
244                data,
245                whois_fallback,
246            } => data
247                .get_registrar()
248                .or_else(|| whois_fallback.as_ref().and_then(|w| w.registrar.clone())),
249            LookupResult::Whois { data, .. } => data.registrar.clone(),
250            LookupResult::Available { .. } => None,
251        }
252    }
253
254    /// Returns the registrant organization, preferring RDAP data with WHOIS fallback.
255    pub fn organization(&self) -> Option<String> {
256        match self {
257            LookupResult::Rdap {
258                data,
259                whois_fallback,
260            } => data
261                .get_registrant_organization()
262                .or_else(|| whois_fallback.as_ref().and_then(|w| w.organization.clone())),
263            LookupResult::Whois { data, .. } => data.organization.clone(),
264            LookupResult::Available { .. } => None,
265        }
266    }
267
268    /// Returns true if the result came from RDAP.
269    pub fn is_rdap(&self) -> bool {
270        matches!(self, LookupResult::Rdap { .. })
271    }
272
273    /// Returns true if the result came from WHOIS.
274    pub fn is_whois(&self) -> bool {
275        matches!(self, LookupResult::Whois { .. })
276    }
277
278    /// Returns true if the result is an availability check fallback.
279    pub fn is_available(&self) -> bool {
280        matches!(self, LookupResult::Available { .. })
281    }
282
283    /// Returns the expiration date and registrar info from the lookup result.
284    pub fn expiration_info(&self) -> (Option<DateTime<Utc>>, Option<String>) {
285        match self {
286            LookupResult::Rdap {
287                data,
288                whois_fallback,
289            } => {
290                // Try to get expiration from RDAP events
291                let expiration_date = data
292                    .events
293                    .iter()
294                    .find(|e| e.event_action == "expiration")
295                    .and_then(|e| e.parsed_date())
296                    .or_else(|| {
297                        // Fallback to WHOIS if available
298                        whois_fallback.as_ref().and_then(|w| w.expiration_date)
299                    });
300
301                let registrar = data
302                    .get_registrar()
303                    .or_else(|| whois_fallback.as_ref().and_then(|w| w.registrar.clone()));
304
305                (expiration_date, registrar)
306            }
307            LookupResult::Whois { data, .. } => (data.expiration_date, data.registrar.clone()),
308            LookupResult::Available { .. } => (None, None),
309        }
310    }
311}
312
313/// Before caching, trim raw WHOIS response to limit cache memory.
314/// A full WHOIS raw_response can be up to 1 MB; we cap it at 32 KB which is
315/// plenty for the parsed fields while preventing the cache from ballooning.
316fn trim_for_cache(mut result: LookupResult) -> LookupResult {
317    const MAX_RAW: usize = 32 * 1024;
318
319    match result {
320        LookupResult::Whois { ref mut data, .. } => {
321            if data.raw_response.len() > MAX_RAW {
322                data.raw_response.truncate(MAX_RAW);
323                data.raw_response.push_str("\n... [truncated for cache]");
324            }
325        }
326        LookupResult::Rdap {
327            ref mut whois_fallback,
328            ..
329        } => {
330            if let Some(ref mut w) = whois_fallback {
331                if w.raw_response.len() > MAX_RAW {
332                    w.raw_response.truncate(MAX_RAW);
333                    w.raw_response.push_str("\n... [truncated for cache]");
334                }
335            }
336        }
337        LookupResult::Available {
338            ref mut whois_data, ..
339        } => {
340            if let Some(ref mut w) = whois_data {
341                if w.raw_response.len() > MAX_RAW {
342                    w.raw_response.truncate(MAX_RAW);
343                    w.raw_response.push_str("\n... [truncated for cache]");
344                }
345            }
346        }
347    }
348
349    result
350}
351
352#[derive(Debug, Clone)]
353pub struct SmartLookup {
354    rdap_client: RdapClient,
355    whois_client: WhoisClient,
356    availability_checker: AvailabilityChecker,
357    /// Deprecated: both protocols are now always attempted concurrently.
358    prefer_rdap: bool,
359    /// Deprecated: WHOIS data is now always attached when available.
360    include_fallback: bool,
361}
362
363impl Default for SmartLookup {
364    fn default() -> Self {
365        Self::new()
366    }
367}
368
369impl SmartLookup {
370    /// Creates a new SmartLookup that runs RDAP and WHOIS concurrently,
371    /// falling back to an availability check if both fail.
372    pub fn new() -> Self {
373        Self {
374            rdap_client: RdapClient::new(),
375            whois_client: WhoisClient::new(),
376            availability_checker: AvailabilityChecker::new(),
377            prefer_rdap: true,
378            include_fallback: false,
379        }
380    }
381
382    /// Deprecated: both protocols are now always attempted concurrently.
383    /// This method is kept for API compatibility but has no effect.
384    #[deprecated(note = "This field has no effect. RDAP is always tried concurrently with WHOIS.")]
385    pub fn prefer_rdap(mut self, prefer: bool) -> Self {
386        self.prefer_rdap = prefer;
387        self
388    }
389
390    /// Deprecated: WHOIS data is now always attached when available.
391    /// This method is kept for API compatibility but has no effect.
392    #[deprecated(note = "This field has no effect. RDAP is always tried concurrently with WHOIS.")]
393    pub fn include_fallback(mut self, include: bool) -> Self {
394        self.include_fallback = include;
395        self
396    }
397
398    /// Performs a smart lookup for a domain, trying both RDAP and WHOIS concurrently.
399    /// Falls back to an availability check if both fail.
400    /// Results are cached for 5 minutes to avoid redundant network calls.
401    #[instrument(skip(self), fields(domain = %domain))]
402    pub async fn lookup(&self, domain: &str) -> Result<LookupResult> {
403        self.lookup_with_progress(domain, None).await
404    }
405
406    /// Performs a lookup with an optional progress callback.
407    /// The callback is called with messages describing the current phase.
408    /// Results are cached for 5 minutes. Concurrent lookups for the same
409    /// domain are coalesced — only one network race runs per domain at a time.
410    #[instrument(skip(self, progress), fields(domain = %domain))]
411    pub async fn lookup_with_progress(
412        &self,
413        domain: &str,
414        progress: Option<LookupProgressCallback>,
415    ) -> Result<LookupResult> {
416        let normalized = crate::validation::normalize_domain(domain)?;
417
418        // Check cache first
419        if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
420            debug!(domain = %normalized, "Returning cached lookup result");
421            return Ok(cached);
422        }
423
424        // Coalesce in-flight lookups: if another task is already running a
425        // race for this domain, wait on its Notify rather than starting a
426        // second race. Two branches:
427        //   - Waiter: another task owns the slot; await its notify, then
428        //     read the cache. If the cache is still empty (owner failed),
429        //     loop and re-contend for ownership.
430        //   - Owner: no entry exists; insert a Weak handle, hold the Arc
431        //     for the duration of the work, then remove and notify on drop.
432        //
433        // A `loop` with a separate lock-scope per iteration keeps the
434        // `MutexGuard` from being held across any `.await`.
435        let _guard = loop {
436            enum Slot {
437                Waiter(Arc<Notify>),
438                Owner(InflightGuard),
439            }
440
441            let slot = {
442                // Recover from poisoning rather than panicking: a prior
443                // owner's panic should not permanently wedge the in-flight
444                // tracker for every future lookup.
445                let mut inflight = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
446                match inflight.get(&normalized).and_then(|w| w.upgrade()) {
447                    Some(existing) => Slot::Waiter(existing),
448                    None => {
449                        let n = Arc::new(Notify::new());
450                        inflight.insert(normalized.clone(), Arc::downgrade(&n));
451                        Slot::Owner(InflightGuard {
452                            key: normalized.clone(),
453                            notify: n,
454                        })
455                    }
456                }
457            };
458
459            match slot {
460                Slot::Waiter(n) => {
461                    debug!(domain = %normalized, "Waiting for in-flight lookup to complete");
462                    n.notified().await;
463                    if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
464                        return Ok(cached);
465                    }
466                    // Owner finished without populating the cache (failed
467                    // or errored). Re-contend for ownership.
468                    continue;
469                }
470                Slot::Owner(guard) => break guard,
471            }
472        };
473
474        let result = self.lookup_concurrent(&normalized, progress).await?;
475
476        // Cache a trimmed copy to limit memory usage before releasing
477        // waiters (via guard drop) so they observe the cached value.
478        LOOKUP_CACHE.insert(normalized.clone(), trim_for_cache(result.clone()));
479
480        Ok(result)
481    }
482
483    /// Clears the lookup result cache.
484    pub fn clear_cache() {
485        LOOKUP_CACHE.clear();
486    }
487
488    #[instrument(skip(self, progress), fields(domain = %domain))]
489    async fn lookup_concurrent(
490        &self,
491        domain: &str,
492        progress: Option<LookupProgressCallback>,
493    ) -> Result<LookupResult> {
494        #[cfg(test)]
495        LOOKUP_CONCURRENT_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
496
497        debug!(domain = %domain, "Attempting RDAP and WHOIS concurrently");
498
499        if let Some(ref cb) = progress {
500            cb("Querying RDAP and WHOIS concurrently");
501        }
502
503        let rdap_fut = self.rdap_client.lookup_domain(domain);
504        let whois_fut = self.whois_client.lookup(domain);
505
506        tokio::pin!(rdap_fut);
507        tokio::pin!(whois_fut);
508
509        // Race: whichever finishes first gets a grace period for the other.
510        //
511        // We track whether each side completed naturally or was truncated by
512        // the grace period, so downstream error messages can distinguish a
513        // true timeout from a loser-truncation.
514        enum LegOutcome<T> {
515            Completed(T),
516            GraceTruncated,
517        }
518
519        let (rdap_leg, whois_leg) = tokio::select! {
520            rdap_res = &mut rdap_fut => {
521                // RDAP finished first — give WHOIS a grace period
522                let whois_leg = match tokio_timeout(PROTOCOL_GRACE_PERIOD, whois_fut).await {
523                    Ok(res) => LegOutcome::Completed(res),
524                    Err(_) => {
525                        debug!("WHOIS did not finish within grace period, proceeding with RDAP only");
526                        LegOutcome::GraceTruncated
527                    }
528                };
529                (LegOutcome::Completed(rdap_res), whois_leg)
530            }
531            whois_res = &mut whois_fut => {
532                // WHOIS finished first — give RDAP a grace period
533                let rdap_leg = match tokio_timeout(PROTOCOL_GRACE_PERIOD, rdap_fut).await {
534                    Ok(res) => LegOutcome::Completed(res),
535                    Err(_) => {
536                        debug!("RDAP did not finish within grace period, proceeding with WHOIS only");
537                        LegOutcome::GraceTruncated
538                    }
539                };
540                (rdap_leg, LegOutcome::Completed(whois_res))
541            }
542        };
543
544        // Classify the RDAP leg.
545        let rdap_outcome = match rdap_leg {
546            LegOutcome::Completed(Ok(data)) => {
547                if self.is_rdap_response_useful(&data) {
548                    RdapOutcome::Useful(data)
549                } else {
550                    RdapOutcome::NoData(data)
551                }
552            }
553            LegOutcome::Completed(Err(e)) => RdapOutcome::Error(e),
554            LegOutcome::GraceTruncated => RdapOutcome::GraceTimeout,
555        };
556
557        // Phase 1: If RDAP returned useful data, use it as primary.
558        if let RdapOutcome::Useful(rdap_data) = rdap_outcome {
559            debug!("RDAP lookup successful");
560            let whois_fallback = match whois_leg {
561                LegOutcome::Completed(Ok(w)) => Some(w),
562                _ => None,
563            };
564            return Ok(LookupResult::Rdap {
565                data: Box::new(rdap_data),
566                whois_fallback,
567            });
568        }
569
570        // RDAP was not useful (NoData, Error, or GraceTimeout). Prefer WHOIS
571        // if it returned any response, even a thin one — this is safer than
572        // falling back to the availability heuristic when we have actual
573        // registry data in hand.
574        //
575        // We separately track whether RDAP returned an HTTP 200 (NoData):
576        // even a thin RDAP 200 is positive evidence the domain object
577        // exists. In that case we must NOT reclassify a WHOIS "no match"
578        // signal as availability — WHOIS lag against a freshly-provisioned
579        // domain would otherwise produce a false "available" verdict.
580        let rdap_returned_200 = matches!(rdap_outcome, RdapOutcome::NoData(_));
581        let (rdap_error_str, rdap_fallback_data, rdap_seer_error) = match rdap_outcome {
582            RdapOutcome::Useful(_) => {
583                // Unreachable in this branch (we returned above), but handle
584                // defensively rather than panicking across the FFI boundary.
585                debug!("Unexpected RdapOutcome::Useful in fallback branch");
586                (String::from("RDAP ok"), None, None)
587            }
588            RdapOutcome::NoData(data) => (
589                "RDAP response incomplete".to_string(),
590                Some(Box::new(data)),
591                None,
592            ),
593            RdapOutcome::Error(e) => (e.to_string(), None, Some(e)),
594            RdapOutcome::GraceTimeout => (
595                format!(
596                    "RDAP did not return within {}s grace period after WHOIS won",
597                    PROTOCOL_GRACE_PERIOD.as_secs()
598                ),
599                None,
600                None,
601            ),
602        };
603
604        if let LegOutcome::Completed(Ok(whois_data)) = whois_leg {
605            // Check Cases A and B: should we reclassify as Available?
606            //
607            // Skip the entire reclassification path when RDAP returned a 200:
608            // a successful RDAP response (even thin) proves the domain exists,
609            // so a WHOIS "no match" is propagation lag, not availability.
610            let availability_match = if rdap_returned_200 {
611                None
612            } else {
613                rdap_seer_error
614                    .as_ref()
615                    .and_then(|e| classify_whois_leg(&whois_data, e))
616                    .or_else(|| {
617                        // Case A can still fire even when RDAP errored for a
618                        // non-404 reason — the WHOIS signal alone is sufficient.
619                        if whois_data.is_available() {
620                            Some(("high", "whois"))
621                        } else {
622                            None
623                        }
624                    })
625            };
626
627            if let Some((confidence, method)) = availability_match {
628                debug!(
629                    domain = %domain,
630                    confidence = %confidence,
631                    "Reclassifying WHOIS as availability signal"
632                );
633                if let Some(ref cb) = progress {
634                    cb("Domain appears unregistered");
635                }
636                let details = match confidence {
637                    "high" => Some("WHOIS indicates domain is not registered".to_string()),
638                    "medium" => Some(
639                        "WHOIS returned no registrar or registration dates; RDAP returned 404"
640                            .to_string(),
641                    ),
642                    _ => None,
643                };
644                let avail = AvailabilityResult {
645                    domain: domain.to_string(),
646                    available: true,
647                    confidence: confidence.to_string(),
648                    method: method.to_string(),
649                    details,
650                };
651                return Ok(LookupResult::Available {
652                    data: Box::new(avail),
653                    rdap_error: sanitize_error_for_public(&rdap_error_str),
654                    whois_error: String::new(),
655                    whois_data: Some(whois_data),
656                });
657            }
658
659            debug!("Using WHOIS result (RDAP not useful)");
660            if let Some(ref cb) = progress {
661                cb("RDAP not available (using WHOIS)");
662            }
663            return Ok(LookupResult::Whois {
664                data: whois_data,
665                rdap_error: Some(rdap_error_str),
666                rdap_fallback: rdap_fallback_data,
667            });
668        }
669
670        // Both sides failed to provide useful data. Craft a precise WHOIS
671        // error string that distinguishes true errors from grace-period
672        // truncation.
673        let whois_error_str = match whois_leg {
674            LegOutcome::Completed(Err(e)) => e.to_string(),
675            LegOutcome::Completed(Ok(_)) => {
676                // Already handled above; treat defensively.
677                debug!("Unexpected completed-Ok WHOIS in availability fallback branch");
678                "WHOIS returned but was not used".to_string()
679            }
680            LegOutcome::GraceTruncated => format!(
681                "WHOIS did not return within {}s grace period after RDAP won",
682                PROTOCOL_GRACE_PERIOD.as_secs()
683            ),
684        };
685
686        self.availability_fallback(domain, rdap_error_str, whois_error_str, progress)
687            .await
688    }
689
690    async fn availability_fallback(
691        &self,
692        domain: &str,
693        rdap_error: String,
694        whois_error: String,
695        progress: Option<LookupProgressCallback>,
696    ) -> Result<LookupResult> {
697        if let Some(ref cb) = progress {
698            cb("RDAP and WHOIS unavailable (checking availability)");
699        }
700        warn!(
701            domain = %domain,
702            rdap_error = %rdap_error,
703            whois_error = %whois_error,
704            "Both RDAP and WHOIS failed, falling back to availability check"
705        );
706
707        match self.availability_checker.check(domain).await {
708            Ok(avail) => Ok(LookupResult::Available {
709                data: Box::new(avail),
710                rdap_error: sanitize_error_for_public(&rdap_error),
711                whois_error: sanitize_error_for_public(&whois_error),
712                whois_data: None,
713            }),
714            Err(avail_err) => {
715                let tld = get_tld(domain).unwrap_or("unknown");
716                let registry_url = get_registry_url(tld).unwrap_or_else(|| {
717                    format!("https://www.iana.org/domains/root/db/{}.html", tld)
718                });
719                Err(SeerError::LookupFailed {
720                    domain: domain.to_string(),
721                    details: format!(
722                        "RDAP failed ({}), WHOIS failed ({}), availability check failed ({})",
723                        rdap_error, whois_error, avail_err
724                    ),
725                    registry_url,
726                })
727            }
728        }
729    }
730
731    fn is_rdap_response_useful(&self, response: &RdapResponse) -> bool {
732        // Check if we have at least some meaningful data
733        let has_name = response.ldh_name.is_some() || response.unicode_name.is_some();
734        let has_dates = response
735            .events
736            .iter()
737            .any(|e| e.event_action == "registration" || e.event_action == "expiration");
738        let has_entities = !response.entities.is_empty();
739        let has_nameservers = !response.nameservers.is_empty();
740        let has_status = !response.status.is_empty();
741
742        // Consider useful if we have the name plus at least one other piece of info
743        has_name && (has_dates || has_entities || has_nameservers || has_status)
744    }
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750
751    /// Global serialization mutex for the three tests that share
752    /// `LOOKUP_INFLIGHT` state (coalescing, poison recovery, drop recovery).
753    /// Running them in parallel creates two races:
754    ///   1. Guard drop uses `try_lock`; if another test holds the mutex, the
755    ///      Drop path skips cleanup → stale entries fail later assertions.
756    ///   2. Poisoning one test leaves the mutex poisoned for the next test,
757    ///      which is handled by `unwrap_or_else` but still disturbs state.
758    /// Per-test unique keys (see `unique_test_key`) prevent entry-level
759    /// collisions; this mutex prevents lock-contention races on Drop.
760    static INFLIGHT_TEST_SERIAL: Mutex<()> = Mutex::new(());
761
762    #[test]
763    fn test_lookup_result_domain_name_whois() {
764        let result = LookupResult::Whois {
765            data: WhoisResponse {
766                domain: "example.com".to_string(),
767                registrar: Some("Test Registrar".to_string()),
768                registrant: None,
769                organization: None,
770                registrant_email: None,
771                registrant_phone: None,
772                registrant_address: None,
773                registrant_country: None,
774                admin_name: None,
775                admin_organization: None,
776                admin_email: None,
777                admin_phone: None,
778                tech_name: None,
779                tech_organization: None,
780                tech_email: None,
781                tech_phone: None,
782                creation_date: None,
783                expiration_date: None,
784                updated_date: None,
785                status: vec![],
786                nameservers: vec![],
787                dnssec: None,
788                whois_server: "whois.example.com".to_string(),
789                raw_response: String::new(),
790            },
791            rdap_error: None,
792            rdap_fallback: None,
793        };
794
795        assert_eq!(result.domain_name(), Some("example.com".to_string()));
796        assert_eq!(result.registrar(), Some("Test Registrar".to_string()));
797        assert!(result.is_whois());
798        assert!(!result.is_rdap());
799        assert!(!result.is_available());
800    }
801
802    #[test]
803    fn test_lookup_result_serialization() {
804        let result = LookupResult::Whois {
805            data: WhoisResponse {
806                domain: "test.com".to_string(),
807                registrar: None,
808                registrant: None,
809                organization: None,
810                registrant_email: None,
811                registrant_phone: None,
812                registrant_address: None,
813                registrant_country: None,
814                admin_name: None,
815                admin_organization: None,
816                admin_email: None,
817                admin_phone: None,
818                tech_name: None,
819                tech_organization: None,
820                tech_email: None,
821                tech_phone: None,
822                creation_date: None,
823                expiration_date: None,
824                updated_date: None,
825                status: vec![],
826                nameservers: vec![],
827                dnssec: None,
828                whois_server: String::new(),
829                raw_response: String::new(),
830            },
831            rdap_error: Some("RDAP failed".to_string()),
832            rdap_fallback: None,
833        };
834
835        let json = serde_json::to_string(&result).unwrap();
836        assert!(json.contains("\"source\":\"whois\""));
837        assert!(json.contains("RDAP failed"));
838    }
839
840    #[test]
841    fn test_lookup_result_available_serialization() {
842        let result = LookupResult::Available {
843            data: Box::new(AvailabilityResult {
844                domain: "test123.xyz".to_string(),
845                available: true,
846                confidence: "medium".to_string(),
847                method: "whois_error".to_string(),
848                details: Some("WHOIS server indicates no matching records".to_string()),
849            }),
850            rdap_error: "RDAP failed".to_string(),
851            whois_error: "WHOIS failed".to_string(),
852            whois_data: None,
853        };
854
855        let json = serde_json::to_string(&result).unwrap();
856        assert!(json.contains("\"source\":\"available\""));
857        assert!(json.contains("\"available\":true"));
858        assert!(json.contains("test123.xyz"));
859
860        assert_eq!(result.domain_name(), Some("test123.xyz".to_string()));
861        assert!(result.is_available());
862        assert!(!result.is_rdap());
863        assert!(!result.is_whois());
864        assert!(result.registrar().is_none());
865        assert_eq!(result.expiration_info(), (None, None));
866    }
867
868    #[test]
869    #[allow(deprecated)]
870    fn test_smart_lookup_builder() {
871        let lookup = SmartLookup::new().prefer_rdap(false).include_fallback(true);
872        assert!(!lookup.prefer_rdap);
873        assert!(lookup.include_fallback);
874    }
875
876    #[test]
877    fn test_lookup_cache_clear() {
878        SmartLookup::clear_cache();
879        assert!(LOOKUP_CACHE.is_empty());
880    }
881
882    // ---------------- sanitize_error_for_public ----------------
883
884    #[test]
885    fn test_sanitize_strips_ipv4() {
886        let msg = "RDAP URL resolves to reserved IP 10.0.0.1 which is forbidden";
887        let sanitized = sanitize_error_for_public(msg);
888        assert!(
889            !sanitized.contains("10.0.0.1"),
890            "IPv4 should be stripped, got: {}",
891            sanitized
892        );
893        assert!(sanitized.contains("[ip-redacted]"));
894    }
895
896    #[test]
897    fn test_sanitize_strips_multiple_ipv4() {
898        let msg = "Could not connect to 192.168.1.1 after trying 127.0.0.1";
899        let sanitized = sanitize_error_for_public(msg);
900        assert!(!sanitized.contains("192.168.1.1"));
901        assert!(!sanitized.contains("127.0.0.1"));
902        // Two redactions expected.
903        assert_eq!(sanitized.matches("[ip-redacted]").count(), 2);
904    }
905
906    #[test]
907    fn test_sanitize_strips_ipv6() {
908        let msg = "RDAP URL resolves to reserved IP fe80::1 which is forbidden";
909        let sanitized = sanitize_error_for_public(msg);
910        assert!(!sanitized.contains("fe80::1"));
911        assert!(sanitized.contains("[ip-redacted]"));
912    }
913
914    #[test]
915    fn sanitize_leaves_mac_address_like_tokens_alone() {
916        let msg = "error code af:ba:12 at line 5";
917        let out = sanitize_error_for_public(msg);
918        assert!(
919            out.contains("af:ba:12"),
920            "MAC fragment should not be stripped: {}",
921            out
922        );
923    }
924
925    #[test]
926    fn sanitize_strips_real_ipv6() {
927        let msg = "cannot reach 2001:db8::1 — timeout";
928        let out = sanitize_error_for_public(msg);
929        assert!(!out.contains("2001:db8::1"));
930        assert!(out.contains("[ip-redacted]"));
931    }
932
933    #[test]
934    fn sanitize_strips_fe80_link_local() {
935        let msg = "peer at fe80::1 unreachable";
936        let out = sanitize_error_for_public(msg);
937        assert!(out.contains("[ip-redacted]"));
938    }
939
940    #[test]
941    fn test_sanitize_truncates_long_message() {
942        // Build a 500-char message with no IPs.
943        let long = "a".repeat(500);
944        let sanitized = sanitize_error_for_public(&long);
945        // Should cap at MAX_PUBLIC_ERROR_LEN chars + ellipsis.
946        let char_count = sanitized.chars().count();
947        assert_eq!(char_count, MAX_PUBLIC_ERROR_LEN + 1);
948        assert!(sanitized.ends_with('…'));
949    }
950
951    #[test]
952    fn test_sanitize_preserves_short_messages() {
953        let msg = "RDAP timed out after 15s";
954        let sanitized = sanitize_error_for_public(msg);
955        assert_eq!(sanitized, msg);
956    }
957
958    // ---------------- RdapOutcome classification ----------------
959
960    #[test]
961    fn test_is_rdap_response_useful_detects_no_data() {
962        use crate::rdap::RdapResponse;
963        // Construct a response with a name but no events, entities, NS, or status
964        // — this is the "200 OK but no useful fields" case that should be
965        // classified as RdapOutcome::NoData (not Useful, not Error).
966        let resp = RdapResponse {
967            ldh_name: Some("example.com".to_string()),
968            ..Default::default()
969        };
970        let lookup = SmartLookup::new();
971        assert!(
972            !lookup.is_rdap_response_useful(&resp),
973            "Response with only a name should be classified as NoData"
974        );
975
976        // And one with a name + status IS useful (sanity check).
977        let useful = RdapResponse {
978            ldh_name: Some("example.com".to_string()),
979            status: vec!["active".to_string()],
980            ..Default::default()
981        };
982        assert!(lookup.is_rdap_response_useful(&useful));
983    }
984
985    // ---------------- Coalescing ----------------
986
987    // Verifies that when multiple concurrent lookups hit the in-flight map
988    // for the same domain, later arrivals observe the existing Weak<Notify>
989    // and become waiters rather than racing a second lookup. We test the
990    // map-level primitive here because the full SmartLookup pipeline
991    // requires network access to exercise.
992    #[tokio::test]
993    async fn test_inflight_coalescing_map() {
994        // Serialize with sibling poisoning tests: we share LOOKUP_INFLIGHT
995        // state, and `InflightGuard::drop` uses `try_lock` — if a sibling
996        // holds the mutex during drop, cleanup is skipped and assertions
997        // fail.
998        let _serial = INFLIGHT_TEST_SERIAL
999            .lock()
1000            .unwrap_or_else(|p| p.into_inner());
1001        // Poison-tolerant: the sibling poisoning regression tests may run
1002        // earlier under `cargo test` parallelism and leave LOOKUP_INFLIGHT
1003        // poisoned. The production code recovers via `unwrap_or_else`,
1004        // so this test does the same.
1005        //
1006        // Use a per-run unique key so this test cannot race with the other
1007        // tests that touch LOOKUP_INFLIGHT. Previously we `clear()`ed the
1008        // whole map, which raced with peer tests' entries.
1009        let domain = unique_test_key("__coalesce");
1010
1011        // Defensive: ensure our specific key is not present.
1012        {
1013            let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1014            m.remove(&domain);
1015        }
1016
1017        // First caller: no entry → becomes owner.
1018        let owner_notify = Arc::new(Notify::new());
1019        {
1020            let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1021            assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1022            m.insert(domain.clone(), Arc::downgrade(&owner_notify));
1023        }
1024
1025        // Second caller: sees the existing Weak and upgrades.
1026        let waiter = {
1027            let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1028            m.get(&domain)
1029                .and_then(|w| w.upgrade())
1030                .expect("Second caller must observe in-flight entry")
1031        };
1032
1033        // Waiter listens in the background.
1034        let waiter_clone = waiter.clone();
1035        let handle = tokio::spawn(async move {
1036            waiter_clone.notified().await;
1037        });
1038
1039        // Simulate owner completing.
1040        tokio::time::sleep(Duration::from_millis(20)).await;
1041        {
1042            let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1043            m.remove(&domain);
1044        }
1045        owner_notify.notify_waiters();
1046
1047        // Waiter should unblock quickly.
1048        tokio::time::timeout(Duration::from_secs(1), handle)
1049            .await
1050            .expect("waiter must unblock after notify")
1051            .expect("waiter task joined cleanly");
1052
1053        // After owner removes entry and drops its Arc, the Weak is dead.
1054        drop(owner_notify);
1055        drop(waiter);
1056        let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1057        assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
1058    }
1059
1060    /// Builds a domain key guaranteed unique per test invocation, so that
1061    /// tests touching the shared LOOKUP_INFLIGHT static never collide when
1062    /// `cargo test` runs them in parallel. We include a nanosecond timestamp
1063    /// plus an atomic counter to defeat even hash-identical calls within the
1064    /// same nanosecond.
1065    fn unique_test_key(prefix: &str) -> String {
1066        use std::sync::atomic::{AtomicU64, Ordering};
1067        use std::time::{SystemTime, UNIX_EPOCH};
1068        static COUNTER: AtomicU64 = AtomicU64::new(0);
1069        let nanos = SystemTime::now()
1070            .duration_since(UNIX_EPOCH)
1071            .map(|d| d.as_nanos())
1072            .unwrap_or(0);
1073        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1074        format!("{}_{}_{}.example.", prefix, nanos, n)
1075    }
1076
1077    // Demonstrates that the `sanitize_error_for_public` helper is applied
1078    // to the rdap_error / whois_error fields written into the `Available`
1079    // variant. We check the call site indirectly: construct a Available
1080    // manually and then verify a raw error with an IP becomes redacted.
1081    // (Integration via real clients would require network.)
1082    #[test]
1083    fn test_sanitize_applied_to_available_fields() {
1084        let rdap_raw = "RDAP URL resolves to reserved IP 10.0.0.1";
1085        let whois_raw = "connection refused at 192.168.0.5";
1086        let sanitized_rdap = sanitize_error_for_public(rdap_raw);
1087        let sanitized_whois = sanitize_error_for_public(whois_raw);
1088        let result = LookupResult::Available {
1089            data: Box::new(AvailabilityResult {
1090                domain: "unreg.test".to_string(),
1091                available: true,
1092                confidence: "low".to_string(),
1093                method: "heuristic".to_string(),
1094                details: None,
1095            }),
1096            rdap_error: sanitized_rdap,
1097            whois_error: sanitized_whois,
1098            whois_data: None,
1099        };
1100        if let LookupResult::Available {
1101            rdap_error,
1102            whois_error,
1103            ..
1104        } = result
1105        {
1106            assert!(!rdap_error.contains("10.0.0.1"));
1107            assert!(!whois_error.contains("192.168.0.5"));
1108            assert!(rdap_error.contains("[ip-redacted]"));
1109            assert!(whois_error.contains("[ip-redacted]"));
1110        } else {
1111            panic!("expected Available variant");
1112        }
1113    }
1114
1115    #[test]
1116    fn rdap_error_is_404_matches_standard_404() {
1117        let e = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1118        assert!(rdap_error_is_404(&e));
1119    }
1120
1121    #[test]
1122    fn rdap_error_is_404_matches_without_reason_phrase() {
1123        let e = SeerError::RdapError("query failed with status 404".to_string());
1124        assert!(rdap_error_is_404(&e));
1125    }
1126
1127    #[test]
1128    fn rdap_error_is_404_rejects_other_statuses() {
1129        let e = SeerError::RdapError("query failed with status 500 Server Error".to_string());
1130        assert!(!rdap_error_is_404(&e));
1131        let e = SeerError::RdapError("query failed with status 400 Bad Request".to_string());
1132        assert!(!rdap_error_is_404(&e));
1133    }
1134
1135    #[test]
1136    fn rdap_error_is_404_rejects_non_http_errors() {
1137        let e = SeerError::RdapError("connection timeout".to_string());
1138        assert!(!rdap_error_is_404(&e));
1139        let e = SeerError::Timeout("rdap".to_string());
1140        assert!(!rdap_error_is_404(&e));
1141    }
1142
1143    #[test]
1144    fn rdap_error_is_404_rejects_incidental_404_in_message() {
1145        // A 404 substring inside a non-status context must not match.
1146        let e = SeerError::RdapError("error 40404: database corruption".to_string());
1147        assert!(!rdap_error_is_404(&e));
1148    }
1149
1150    // ---------------- whois_response_is_thin ----------------
1151
1152    fn empty_whois(domain: &str) -> WhoisResponse {
1153        WhoisResponse {
1154            domain: domain.to_string(),
1155            registrar: None,
1156            registrant: None,
1157            organization: None,
1158            registrant_email: None,
1159            registrant_phone: None,
1160            registrant_address: None,
1161            registrant_country: None,
1162            admin_name: None,
1163            admin_organization: None,
1164            admin_email: None,
1165            admin_phone: None,
1166            tech_name: None,
1167            tech_organization: None,
1168            tech_email: None,
1169            tech_phone: None,
1170            creation_date: None,
1171            expiration_date: None,
1172            updated_date: None,
1173            nameservers: vec![],
1174            status: vec![],
1175            dnssec: None,
1176            whois_server: String::new(),
1177            raw_response: String::new(),
1178        }
1179    }
1180
1181    #[test]
1182    fn whois_response_is_thin_when_all_key_fields_missing() {
1183        let w = empty_whois("example.com");
1184        assert!(whois_response_is_thin(&w));
1185    }
1186
1187    #[test]
1188    fn whois_response_is_not_thin_when_registrar_present() {
1189        let mut w = empty_whois("example.com");
1190        w.registrar = Some("Test Registrar".to_string());
1191        assert!(!whois_response_is_thin(&w));
1192    }
1193
1194    #[test]
1195    fn whois_response_is_not_thin_when_creation_date_present() {
1196        let mut w = empty_whois("example.com");
1197        w.creation_date = Some(Utc::now());
1198        assert!(!whois_response_is_thin(&w));
1199    }
1200
1201    #[test]
1202    fn whois_response_is_not_thin_when_expiration_date_present() {
1203        let mut w = empty_whois("example.com");
1204        w.expiration_date = Some(Utc::now());
1205        assert!(!whois_response_is_thin(&w));
1206    }
1207
1208    #[test]
1209    fn whois_response_is_thin_even_with_nameservers_alone() {
1210        let mut w = empty_whois("example.com");
1211        w.nameservers = vec!["ns1.example.net".to_string()];
1212        assert!(whois_response_is_thin(&w));
1213    }
1214
1215    // ---------------- classify_whois_leg ----------------
1216
1217    use crate::rdap::RdapResponse;
1218
1219    #[allow(dead_code)]
1220    fn make_empty_rdap_response() -> RdapResponse {
1221        serde_json::from_value(serde_json::json!({
1222            "objectClassName": "domain",
1223        }))
1224        .expect("valid minimal RDAP response")
1225    }
1226
1227    #[test]
1228    fn classify_whois_leg_case_a_high_confidence() {
1229        let mut w = empty_whois("zaccodes.com");
1230        w.raw_response = "No match for \"ZACCODES.COM\".".to_string();
1231        assert!(w.is_available());
1232        let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1233        let (verdict, method) =
1234            classify_whois_leg(&w, &rdap_err).expect("expected a routing decision");
1235        assert_eq!(verdict, "high");
1236        assert_eq!(method, "whois");
1237    }
1238
1239    #[test]
1240    fn classify_whois_leg_case_b_medium_confidence() {
1241        let w = empty_whois("example.xyz");
1242        assert!(!w.is_available(), "this WHOIS body has no 'no match' text");
1243        let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1244        let (verdict, method) =
1245            classify_whois_leg(&w, &rdap_err).expect("expected a routing decision");
1246        assert_eq!(verdict, "medium");
1247        assert_eq!(method, "whois_thin_response");
1248    }
1249
1250    #[test]
1251    fn classify_whois_leg_rejects_thin_whois_without_404() {
1252        let w = empty_whois("example.xyz");
1253        let rdap_err = SeerError::RdapError("connection timeout".to_string());
1254        assert!(classify_whois_leg(&w, &rdap_err).is_none());
1255    }
1256
1257    #[test]
1258    fn classify_whois_leg_rejects_whois_with_real_data() {
1259        let mut w = empty_whois("legacy.tld");
1260        w.registrar = Some("Legacy Registry".to_string());
1261        w.creation_date = Some(Utc::now());
1262        let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1263        assert!(classify_whois_leg(&w, &rdap_err).is_none());
1264    }
1265
1266    #[test]
1267    fn classify_whois_leg_case_a_wins_over_case_b() {
1268        let mut w = empty_whois("example.com");
1269        w.raw_response = "No match for \"EXAMPLE.COM\".".to_string();
1270        let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1271        let (verdict, _) = classify_whois_leg(&w, &rdap_err).unwrap();
1272        assert_eq!(verdict, "high");
1273    }
1274
1275    // ---------------- Mutex poisoning recovery ----------------
1276
1277    /// Regression: a panic inside `LOOKUP_INFLIGHT.lock()` must not wedge
1278    /// the tracker forever. After the mutex is poisoned, subsequent
1279    /// acquisition attempts must still succeed via `unwrap_or_else`.
1280    ///
1281    /// This isolates the lookup_with_progress acquisition site (formerly a
1282    /// `.expect("LOOKUP_INFLIGHT mutex poisoned")`) by exercising the same
1283    /// `.lock().unwrap_or_else(|p| p.into_inner())` pattern directly.
1284    #[test]
1285    fn lookup_inflight_recovers_from_poisoned_mutex() {
1286        use std::panic::{catch_unwind, AssertUnwindSafe};
1287
1288        // Serialize with sibling tests that also touch LOOKUP_INFLIGHT.
1289        let _serial = INFLIGHT_TEST_SERIAL
1290            .lock()
1291            .unwrap_or_else(|p| p.into_inner());
1292
1293        // Poison the real static by panicking while holding the guard.
1294        let _ = catch_unwind(AssertUnwindSafe(|| {
1295            let _guard = LOOKUP_INFLIGHT.lock().unwrap();
1296            panic!("poisoning LOOKUP_INFLIGHT for test");
1297        }));
1298
1299        // At this point LOOKUP_INFLIGHT is poisoned. Plain .lock() would
1300        // return Err(PoisonError). The recovery pattern used in
1301        // lookup_with_progress must still yield a usable guard.
1302        let mut guard = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1303        // Use a per-run unique canary so parallel tests cannot collide.
1304        let canary = unique_test_key("__poison_recovery");
1305        guard.insert(canary.clone(), Weak::new());
1306        assert!(guard.contains_key(&canary));
1307        guard.remove(&canary);
1308    }
1309
1310    /// Regression: InflightGuard::drop must also tolerate mutex poisoning
1311    /// without panicking — the Poisoned arm should still remove the entry.
1312    #[test]
1313    fn inflight_guard_drop_recovers_from_poisoned_mutex() {
1314        use std::panic::{catch_unwind, AssertUnwindSafe};
1315
1316        // Serialize with sibling tests that also touch LOOKUP_INFLIGHT —
1317        // the critical race was `InflightGuard::drop` using `try_lock`
1318        // and silently skipping cleanup when a parallel test held the
1319        // mutex, leaving this test's entry in the map and failing the
1320        // final assertion.
1321        let _serial = INFLIGHT_TEST_SERIAL
1322            .lock()
1323            .unwrap_or_else(|p| p.into_inner());
1324
1325        // Seed an entry and arm a guard for it. Use a per-run unique key
1326        // so this test can never collide with siblings under parallel
1327        // `cargo test` — previously a hard-coded key raced with the peer
1328        // coalescing test's `m.clear()` call.
1329        let key = unique_test_key("__drop_poison");
1330        let notify = Arc::new(Notify::new());
1331        {
1332            let mut map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1333            map.insert(key.clone(), Arc::downgrade(&notify));
1334        }
1335        let guard = InflightGuard {
1336            key: key.clone(),
1337            notify: notify.clone(),
1338        };
1339
1340        // Poison the mutex.
1341        let _ = catch_unwind(AssertUnwindSafe(|| {
1342            let _g = LOOKUP_INFLIGHT.lock().unwrap();
1343            panic!("poisoning LOOKUP_INFLIGHT for drop test");
1344        }));
1345
1346        // Dropping the guard must not panic and must remove the entry via
1347        // the Poisoned branch of the new try_lock match.
1348        drop(guard);
1349
1350        let map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
1351        assert!(
1352            !map.contains_key(&key),
1353            "poisoned-mutex drop path should still remove the in-flight entry"
1354        );
1355    }
1356}