use std::collections::HashMap;
use std::net::Ipv6Addr;
use std::str::FromStr;
use std::sync::{Arc, Mutex, Weak};
use std::time::Duration;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::sync::LazyLock;
use tokio::sync::Notify;
use tracing::{debug, instrument, warn};
use tokio::time::timeout as tokio_timeout;
use crate::availability::{AvailabilityChecker, AvailabilityResult, PriorRdap, PriorWhois};
use crate::cache::TtlCache;
use crate::dns::{DnsPresence, DnsResolver};
use crate::error::{Result, SeerError};
use crate::rdap::{rdap_error_is_404, RdapClient, RdapResponse};
use crate::whois::{get_registry_url, get_tld, WhoisClient, WhoisResponse};
const LOOKUP_CACHE_TTL: Duration = Duration::from_secs(5 * 60);
const PROTOCOL_GRACE_PERIOD: Duration = Duration::from_secs(5);
const MAX_PUBLIC_ERROR_LEN: usize = 256;
const DEFAULT_INFLIGHT_WAIT: Duration = Duration::from_secs(30);
static LOOKUP_CACHE: LazyLock<TtlCache<String, LookupResult>> =
LazyLock::new(|| TtlCache::new(LOOKUP_CACHE_TTL));
static LOOKUP_INFLIGHT: LazyLock<Mutex<HashMap<String, Weak<Notify>>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
static_regex! {
IPV4_RE = r"\b(?:\d{1,3}\.){3}\d{1,3}\b";
IPV6_CANDIDATE_RE = r"\b[0-9a-fA-F:]*(?:::|(?:[0-9a-fA-F]{1,4}:){3,})[0-9a-fA-F:]*\b";
}
fn strip_ipv6(msg: &str) -> String {
IPV6_CANDIDATE_RE
.replace_all(msg, |caps: ®ex::Captures| {
let candidate = &caps[0];
if Ipv6Addr::from_str(candidate).is_ok() {
"[ip-redacted]".to_string()
} else {
candidate.to_string()
}
})
.into_owned()
}
#[cfg(test)]
static LOOKUP_CONCURRENT_CALLS: LazyLock<std::sync::atomic::AtomicUsize> =
LazyLock::new(|| std::sync::atomic::AtomicUsize::new(0));
const DEGRADED_LOOKUP_CACHE_TTL: Duration = Duration::from_secs(30);
fn is_degraded_result(result: &LookupResult) -> bool {
match result {
LookupResult::Available { data, .. } => matches!(
data.method.as_str(),
"inconclusive" | "dns_present" | "dns_nxdomain" | "whois_error"
),
LookupResult::Rdap { .. } | LookupResult::Whois { .. } => false,
}
}
fn available_with_whois(
avail: AvailabilityResult,
rdap_error: &str,
whois_data: WhoisResponse,
) -> LookupResult {
LookupResult::Available {
data: Box::new(avail),
rdap_error: sanitize_error_for_public(rdap_error),
whois_error: String::new(),
whois_data: Some(whois_data),
}
}
fn rdap_response_is_useful(response: &RdapResponse) -> bool {
let has_name = response.ldh_name.is_some() || response.unicode_name.is_some();
let has_dates = response
.events
.iter()
.any(|e| e.event_action == "registration" || e.event_action == "expiration");
let has_entities = !response.entities.is_empty();
let has_nameservers = !response.nameservers.is_empty();
let has_status = !response.status.is_empty();
has_name && (has_dates || has_entities || has_nameservers || has_status)
}
fn whois_leg_has_data(w: &Result<WhoisResponse>) -> bool {
matches!(w, Ok(data) if !data.is_thin())
}
fn classify_whois_leg(
w: &WhoisResponse,
rdap_err: &SeerError,
) -> Option<(&'static str, &'static str)> {
if w.is_available() {
return Some(("high", "whois"));
}
if w.is_thin() && rdap_error_is_404(rdap_err) {
return Some(("high", "rdap"));
}
None
}
fn should_route_to_availability(
rdap_returned_200: bool,
rdap_seer_error: Option<&SeerError>,
whois_data: &WhoisResponse,
) -> Option<(&'static str, &'static str)> {
if rdap_returned_200 {
return None;
}
if whois_data.is_available() {
return Some(("high", "whois"));
}
rdap_seer_error.and_then(|e| {
classify_whois_leg(whois_data, e)
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ThinFallback {
Inconclusive,
Available,
Registered,
UseWhois,
}
fn classify_thin_fallback(
is_thin: bool,
rdap_returned_200: bool,
whois_refuses: bool,
dns: DnsPresence,
) -> ThinFallback {
if !is_thin || rdap_returned_200 {
return ThinFallback::UseWhois;
}
if whois_refuses {
return ThinFallback::Inconclusive;
}
match dns {
DnsPresence::Absent => ThinFallback::Available,
DnsPresence::Present => ThinFallback::Registered,
DnsPresence::Unknown => ThinFallback::UseWhois,
}
}
fn sanitize_error_for_public(msg: &str) -> String {
let s = IPV4_RE.replace_all(msg, "[ip-redacted]");
let s = strip_ipv6(&s);
if s.chars().count() > MAX_PUBLIC_ERROR_LEN {
let mut trunc: String = s.chars().take(MAX_PUBLIC_ERROR_LEN).collect();
trunc.push('…');
trunc
} else {
s
}
}
struct InflightGuard {
key: String,
notify: Arc<Notify>,
}
impl Drop for InflightGuard {
fn drop(&mut self) {
let mut inflight = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
inflight.remove(&self.key);
drop(inflight);
self.notify.notify_waiters();
}
}
enum LegOutcome<T> {
Completed(T),
GraceTruncated,
}
async fn race_with_grace<R, W>(
rdap_fut: impl std::future::Future<Output = R>,
whois_fut: impl std::future::Future<Output = W>,
rdap_has_data: impl Fn(&R) -> bool,
whois_has_data: impl Fn(&W) -> bool,
) -> (LegOutcome<R>, LegOutcome<W>) {
tokio::pin!(rdap_fut);
tokio::pin!(whois_fut);
tokio::select! {
rdap_res = &mut rdap_fut => {
let whois_leg = if rdap_has_data(&rdap_res) {
match tokio_timeout(PROTOCOL_GRACE_PERIOD, whois_fut).await {
Ok(res) => LegOutcome::Completed(res),
Err(_) => LegOutcome::GraceTruncated,
}
} else {
LegOutcome::Completed(whois_fut.await)
};
(LegOutcome::Completed(rdap_res), whois_leg)
}
whois_res = &mut whois_fut => {
let rdap_leg = if whois_has_data(&whois_res) {
match tokio_timeout(PROTOCOL_GRACE_PERIOD, rdap_fut).await {
Ok(res) => LegOutcome::Completed(res),
Err(_) => LegOutcome::GraceTruncated,
}
} else {
LegOutcome::Completed(rdap_fut.await)
};
(rdap_leg, LegOutcome::Completed(whois_res))
}
}
}
fn grace_truncated_error(truncated: &str, winner: &str) -> String {
format!(
"{} did not respond within the {}s grace period after {} answered",
truncated,
PROTOCOL_GRACE_PERIOD.as_secs(),
winner
)
}
enum RdapOutcome {
Useful(RdapResponse),
NoData(RdapResponse),
Error(SeerError),
GraceTimeout,
}
pub type LookupProgressCallback = Arc<dyn Fn(&str) + Send + Sync>;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "source", rename_all = "lowercase")]
pub enum LookupResult {
Rdap {
data: Box<RdapResponse>,
#[serde(skip_serializing_if = "Option::is_none")]
whois_fallback: Option<WhoisResponse>,
},
Whois {
data: WhoisResponse,
rdap_error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
rdap_fallback: Option<Box<RdapResponse>>,
},
Available {
data: Box<AvailabilityResult>,
rdap_error: String,
whois_error: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
whois_data: Option<WhoisResponse>,
},
}
impl LookupResult {
pub fn domain_name(&self) -> Option<String> {
match self {
LookupResult::Rdap { data, .. } => data.domain_name().map(String::from),
LookupResult::Whois { data, .. } => Some(data.domain.clone()),
LookupResult::Available { data, .. } => Some(data.domain.clone()),
}
}
fn rdap_or_whois<T>(
&self,
rdap: impl FnOnce(&RdapResponse) -> Option<T>,
whois: impl FnOnce(&WhoisResponse) -> Option<T>,
) -> Option<T> {
match self {
LookupResult::Rdap {
data,
whois_fallback,
} => rdap(data).or_else(|| whois_fallback.as_ref().and_then(whois)),
LookupResult::Whois { data, .. } => whois(data),
LookupResult::Available { .. } => None,
}
}
pub fn registrar(&self) -> Option<String> {
self.rdap_or_whois(RdapResponse::get_registrar, |w| w.registrar.clone())
}
pub fn organization(&self) -> Option<String> {
self.rdap_or_whois(RdapResponse::get_registrant_organization, |w| {
w.organization.clone()
})
}
pub fn creation_date(&self) -> Option<DateTime<Utc>> {
self.rdap_or_whois(RdapResponse::creation_date, |w| w.creation_date)
}
pub fn expiration_date(&self) -> Option<DateTime<Utc>> {
self.rdap_or_whois(RdapResponse::expiration_date, |w| w.expiration_date)
}
pub fn is_rdap(&self) -> bool {
matches!(self, LookupResult::Rdap { .. })
}
pub fn is_whois(&self) -> bool {
matches!(self, LookupResult::Whois { .. })
}
pub fn is_available(&self) -> bool {
matches!(self, LookupResult::Available { .. })
}
pub fn expiration_info(&self) -> (Option<DateTime<Utc>>, Option<String>) {
(self.expiration_date(), self.registrar())
}
}
fn truncate_on_char_boundary(s: &mut String, max: usize) {
if s.len() > max {
let mut end = max;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
s.truncate(end);
}
}
pub(crate) fn trim_whois_raw(whois: &mut WhoisResponse) {
const MAX_RAW: usize = 32 * 1024;
if whois.raw_response.len() > MAX_RAW {
truncate_on_char_boundary(&mut whois.raw_response, MAX_RAW);
whois.raw_response.push_str("\n... [truncated]");
}
}
pub(crate) fn trim_raw_response(mut result: LookupResult) -> LookupResult {
match result {
LookupResult::Whois { ref mut data, .. } => trim_whois_raw(data),
LookupResult::Rdap {
ref mut whois_fallback,
..
} => {
if let Some(w) = whois_fallback {
trim_whois_raw(w);
}
}
LookupResult::Available {
ref mut whois_data, ..
} => {
if let Some(w) = whois_data {
trim_whois_raw(w);
}
}
}
result
}
#[derive(Debug, Clone, Default)]
pub struct SmartLookup {
rdap_client: RdapClient,
whois_client: WhoisClient,
availability_checker: AvailabilityChecker,
dns_resolver: DnsResolver,
}
impl SmartLookup {
pub fn new() -> Self {
Self::default()
}
pub fn from_config(config: &crate::config::SeerConfig) -> Self {
Self {
rdap_client: RdapClient::new().with_timeout(config.rdap_timeout()),
whois_client: WhoisClient::new().with_timeout(config.whois_timeout()),
availability_checker: AvailabilityChecker::from_config(config),
dns_resolver: DnsResolver::new().with_timeout(config.dns_timeout()),
}
}
#[instrument(skip(self), fields(domain = %domain))]
pub async fn lookup(&self, domain: &str) -> Result<LookupResult> {
self.lookup_with_progress(domain, None).await
}
#[instrument(skip(self, progress), fields(domain = %domain))]
pub async fn lookup_with_progress(
&self,
domain: &str,
progress: Option<LookupProgressCallback>,
) -> Result<LookupResult> {
let normalized = crate::validation::normalize_domain(domain)?;
if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
debug!(domain = %normalized, "Returning cached lookup result");
return Ok(cached);
}
let _guard = loop {
let existing: Arc<Notify>;
let notified = {
let mut inflight = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
match inflight.get(&normalized).and_then(|w| w.upgrade()) {
Some(n) => existing = n,
None => {
let n = Arc::new(Notify::new());
inflight.insert(normalized.clone(), Arc::downgrade(&n));
break InflightGuard {
key: normalized.clone(),
notify: n,
};
}
}
existing.notified()
};
tokio::pin!(notified);
notified.as_mut().enable();
debug!(domain = %normalized, "Waiting for in-flight lookup to complete");
if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
return Ok(cached);
}
let _ = tokio_timeout(DEFAULT_INFLIGHT_WAIT, notified.as_mut()).await;
if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
return Ok(cached);
}
};
if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
debug!(domain = %normalized, "Returning result cached by previous owner");
return Ok(cached);
}
let result = self.lookup_concurrent(&normalized, progress).await?;
let ttl = if is_degraded_result(&result) {
DEGRADED_LOOKUP_CACHE_TTL
} else {
LOOKUP_CACHE_TTL
};
LOOKUP_CACHE.insert_with_ttl(normalized.clone(), trim_raw_response(result.clone()), ttl);
Ok(result)
}
#[instrument(skip(self, progress), fields(domain = %domain))]
async fn lookup_concurrent(
&self,
domain: &str,
progress: Option<LookupProgressCallback>,
) -> Result<LookupResult> {
#[cfg(test)]
LOOKUP_CONCURRENT_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
debug!(domain = %domain, "Attempting RDAP and WHOIS concurrently");
if let Some(ref cb) = progress {
cb("Querying RDAP and WHOIS concurrently");
}
let rdap_fut = self.rdap_client.lookup_domain(domain);
let whois_fut = self.whois_client.lookup(domain);
let (rdap_leg, whois_leg) = race_with_grace(
rdap_fut,
whois_fut,
|r| matches!(r, Ok(data) if rdap_response_is_useful(data)),
whois_leg_has_data,
)
.await;
if matches!(whois_leg, LegOutcome::GraceTruncated) {
debug!("WHOIS did not finish within grace period after RDAP answered, proceeding with RDAP only");
}
if matches!(rdap_leg, LegOutcome::GraceTruncated) {
debug!("RDAP did not finish within grace period after WHOIS answered, proceeding with WHOIS only");
}
let rdap_outcome = match rdap_leg {
LegOutcome::Completed(Ok(data)) => {
if rdap_response_is_useful(&data) {
RdapOutcome::Useful(data)
} else {
RdapOutcome::NoData(data)
}
}
LegOutcome::Completed(Err(e)) => RdapOutcome::Error(e),
LegOutcome::GraceTruncated => RdapOutcome::GraceTimeout,
};
if let RdapOutcome::Useful(rdap_data) = rdap_outcome {
debug!("RDAP lookup successful");
let whois_fallback = match whois_leg {
LegOutcome::Completed(Ok(w)) => Some(w),
_ => None,
};
return Ok(LookupResult::Rdap {
data: Box::new(rdap_data),
whois_fallback,
});
}
let rdap_returned_200 = matches!(rdap_outcome, RdapOutcome::NoData(_));
let (rdap_error_str, rdap_fallback_data, rdap_seer_error) = match rdap_outcome {
RdapOutcome::Useful(_) => {
debug!("Unexpected RdapOutcome::Useful in fallback branch");
(String::from("RDAP ok"), None, None)
}
RdapOutcome::NoData(data) => (
"RDAP response incomplete".to_string(),
Some(Box::new(data)),
None,
),
RdapOutcome::Error(e) => (e.to_string(), None, Some(e)),
RdapOutcome::GraceTimeout => (grace_truncated_error("RDAP", "WHOIS"), None, None),
};
if let LegOutcome::Completed(Ok(whois_data)) = whois_leg {
let availability_match = should_route_to_availability(
rdap_returned_200,
rdap_seer_error.as_ref(),
&whois_data,
);
if let Some((confidence, method)) = availability_match {
debug!(
domain = %domain,
confidence = %confidence,
"Reclassifying WHOIS as availability signal"
);
let details = match method {
"whois" => Some("WHOIS indicates domain is not registered".to_string()),
"rdap" => Some("Registry RDAP reports no such domain (HTTP 404)".to_string()),
_ => None,
};
let avail = AvailabilityResult {
details,
..AvailabilityResult::new(domain, true, confidence, method)
};
let avail = self.availability_checker.guard_subdomain_claim(avail).await;
if let Some(ref cb) = progress {
cb(if avail.available {
"Domain appears unregistered"
} else {
"Name is below a registrable domain (checked its parent)"
});
}
return Ok(available_with_whois(avail, &rdap_error_str, whois_data));
}
let whois_is_thin = whois_data.is_thin();
if whois_is_thin && !rdap_returned_200 {
let whois_refuses = whois_data.indicates_registry_refusal();
let dns_presence = if whois_refuses {
DnsPresence::Unknown
} else {
self.dns_resolver.presence(domain).await
};
match classify_thin_fallback(
whois_is_thin,
rdap_returned_200,
whois_refuses,
dns_presence,
) {
ThinFallback::Inconclusive => {
debug!(domain = %domain, "Thin WHOIS is a registry refusal/throttle; availability inconclusive (issue #45)");
if let Some(ref cb) = progress {
cb("Registry refused or throttled the query (availability inconclusive)");
}
let avail = AvailabilityResult::new(domain, false, "none", "inconclusive")
.with_details(crate::availability::REFUSED_DETAILS);
return Ok(available_with_whois(avail, &rdap_error_str, whois_data));
}
ThinFallback::Available => {
debug!(domain = %domain, "Thin WHOIS + NXDOMAIN, reclassifying as available");
let avail = AvailabilityResult::new(domain, true, "medium", "dns_nxdomain")
.with_details(crate::availability::THIN_NXDOMAIN_DETAILS);
let avail = self.availability_checker.guard_subdomain_claim(avail).await;
if let Some(ref cb) = progress {
cb(if avail.available {
"Domain appears unregistered (no DNS presence)"
} else {
"Name is below a registrable domain (checked its parent)"
});
}
return Ok(available_with_whois(avail, &rdap_error_str, whois_data));
}
ThinFallback::Registered => {
debug!(domain = %domain, "Thin/no-service WHOIS + DNS delegation, reporting registered");
if let Some(ref cb) = progress {
cb("Domain is registered (registry detail unavailable)");
}
let details = if whois_data.registry_unavailable() {
"Domain is registered (the apex is delegated in DNS). This TLD's \
registry provides no port-43 WHOIS data and RDAP was unavailable \
(rate-limited or unreachable); retry shortly for full RDAP detail."
} else {
"Domain is registered (the apex is delegated in DNS). Registry detail \
was unavailable (RDAP rate-limited or unreachable and WHOIS returned \
no data); retry shortly for full detail."
};
let avail = AvailabilityResult::new(domain, false, "high", "dns_present")
.with_details(details);
return Ok(available_with_whois(avail, &rdap_error_str, whois_data));
}
ThinFallback::UseWhois => {}
}
}
debug!("Using WHOIS result (RDAP not useful)");
if let Some(ref cb) = progress {
cb("RDAP not available (using WHOIS)");
}
return Ok(LookupResult::Whois {
data: whois_data,
rdap_error: Some(sanitize_error_for_public(&rdap_error_str)),
rdap_fallback: rdap_fallback_data,
});
}
let whois_error_str = match &whois_leg {
LegOutcome::Completed(Err(e)) => e.to_string(),
LegOutcome::Completed(Ok(_)) => {
debug!("Unexpected completed-Ok WHOIS in availability fallback branch");
"WHOIS returned but was not used".to_string()
}
LegOutcome::GraceTruncated => grace_truncated_error("WHOIS", "RDAP"),
};
let prior_rdap = if rdap_returned_200 {
match rdap_fallback_data {
Some(resp) => PriorRdap::Response(resp),
None => PriorRdap::Missing,
}
} else if let Some(e) = rdap_seer_error {
PriorRdap::Failed(e)
} else {
PriorRdap::Missing
};
let prior_whois = match whois_leg {
LegOutcome::Completed(Err(e)) => PriorWhois::Failed(e),
LegOutcome::Completed(Ok(_)) => PriorWhois::Missing,
LegOutcome::GraceTruncated => PriorWhois::Missing,
};
self.availability_fallback(
domain,
prior_rdap,
prior_whois,
rdap_error_str,
whois_error_str,
progress,
)
.await
}
async fn availability_fallback(
&self,
domain: &str,
prior_rdap: PriorRdap,
prior_whois: PriorWhois,
rdap_error: String,
whois_error: String,
progress: Option<LookupProgressCallback>,
) -> Result<LookupResult> {
if let Some(ref cb) = progress {
cb("RDAP and WHOIS unavailable (checking availability)");
}
warn!(
domain = %domain,
rdap_error = %rdap_error,
whois_error = %whois_error,
"Both RDAP and WHOIS failed, falling back to availability check"
);
match self
.availability_checker
.check_with_prior(domain, prior_rdap, prior_whois)
.await
{
Ok(avail) => Ok(LookupResult::Available {
data: Box::new(avail),
rdap_error: sanitize_error_for_public(&rdap_error),
whois_error: sanitize_error_for_public(&whois_error),
whois_data: None,
}),
Err(avail_err) => {
let tld = get_tld(domain).unwrap_or("unknown");
let registry_url = get_registry_url(tld).unwrap_or_else(|| {
format!("https://www.iana.org/domains/root/db/{}.html", tld)
});
Err(SeerError::LookupFailed {
domain: domain.to_string(),
details: format!(
"RDAP failed ({}), WHOIS failed ({}), availability check failed ({})",
rdap_error, whois_error, avail_err
),
registry_url,
})
}
}
}
pub(crate) async fn presence(&self, domain: &str) -> DnsPresence {
self.dns_resolver.presence(domain).await
}
}
#[cfg(test)]
mod tests {
use super::*;
static INFLIGHT_TEST_SERIAL: Mutex<()> = Mutex::new(());
#[test]
fn test_lookup_result_domain_name_whois() {
let result = LookupResult::Whois {
data: WhoisResponse {
domain: "example.com".to_string(),
registrar: Some("Test Registrar".to_string()),
whois_server: "whois.example.com".to_string(),
..Default::default()
},
rdap_error: None,
rdap_fallback: None,
};
assert_eq!(result.domain_name(), Some("example.com".to_string()));
assert_eq!(result.registrar(), Some("Test Registrar".to_string()));
assert!(result.is_whois());
assert!(!result.is_rdap());
assert!(!result.is_available());
}
#[test]
fn test_lookup_result_serialization() {
let result = LookupResult::Whois {
data: WhoisResponse {
domain: "test.com".to_string(),
..Default::default()
},
rdap_error: Some("RDAP failed".to_string()),
rdap_fallback: None,
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"source\":\"whois\""));
assert!(json.contains("RDAP failed"));
}
#[test]
fn test_lookup_result_available_serialization() {
let result = LookupResult::Available {
data: Box::new(
AvailabilityResult::new("test123.xyz", true, "medium", "whois_error")
.with_details("WHOIS server indicates no matching records"),
),
rdap_error: "RDAP failed".to_string(),
whois_error: "WHOIS failed".to_string(),
whois_data: None,
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"source\":\"available\""));
assert!(json.contains("\"available\":true"));
assert!(json.contains("test123.xyz"));
assert_eq!(result.domain_name(), Some("test123.xyz".to_string()));
assert!(result.is_available());
assert!(!result.is_rdap());
assert!(!result.is_whois());
assert!(result.registrar().is_none());
assert_eq!(result.expiration_info(), (None, None));
}
#[test]
fn truncate_on_char_boundary_does_not_panic_on_multibyte_straddle() {
const MAX_RAW: usize = 32 * 1024;
let mut s = "a".repeat(MAX_RAW - 1);
s.push('€'); s.push_str(&"b".repeat(100));
assert!(!s.is_char_boundary(MAX_RAW));
truncate_on_char_boundary(&mut s, MAX_RAW);
assert!(s.len() <= MAX_RAW);
assert_eq!(s.len(), MAX_RAW - 1);
}
#[test]
fn trim_raw_response_truncates_multibyte_whois_without_panic() {
const MAX_RAW: usize = 32 * 1024;
let mut raw = "a".repeat(MAX_RAW - 1);
raw.push('€');
raw.push_str(&"b".repeat(1000));
let mut w = empty_whois("example.com");
w.raw_response = raw;
let result = trim_raw_response(LookupResult::Whois {
data: w,
rdap_error: None,
rdap_fallback: None,
});
if let LookupResult::Whois { data, .. } = result {
assert!(data.raw_response.ends_with("[truncated]"));
} else {
panic!("expected Whois variant");
}
}
#[test]
fn test_sanitize_strips_ipv4() {
let msg = "RDAP URL resolves to reserved IP 10.0.0.1 which is forbidden";
let sanitized = sanitize_error_for_public(msg);
assert!(
!sanitized.contains("10.0.0.1"),
"IPv4 should be stripped, got: {}",
sanitized
);
assert!(sanitized.contains("[ip-redacted]"));
}
#[test]
fn test_sanitize_strips_multiple_ipv4() {
let msg = "Could not connect to 192.168.1.1 after trying 127.0.0.1";
let sanitized = sanitize_error_for_public(msg);
assert!(!sanitized.contains("192.168.1.1"));
assert!(!sanitized.contains("127.0.0.1"));
assert_eq!(sanitized.matches("[ip-redacted]").count(), 2);
}
#[test]
fn test_sanitize_strips_ipv6() {
let msg = "RDAP URL resolves to reserved IP fe80::1 which is forbidden";
let sanitized = sanitize_error_for_public(msg);
assert!(!sanitized.contains("fe80::1"));
assert!(sanitized.contains("[ip-redacted]"));
}
#[test]
fn sanitize_leaves_mac_address_like_tokens_alone() {
let msg = "error code af:ba:12 at line 5";
let out = sanitize_error_for_public(msg);
assert!(
out.contains("af:ba:12"),
"MAC fragment should not be stripped: {}",
out
);
}
#[test]
fn sanitize_strips_real_ipv6() {
let msg = "cannot reach 2001:db8::1 — timeout";
let out = sanitize_error_for_public(msg);
assert!(!out.contains("2001:db8::1"));
assert!(out.contains("[ip-redacted]"));
}
#[test]
fn sanitize_strips_fe80_link_local() {
let msg = "peer at fe80::1 unreachable";
let out = sanitize_error_for_public(msg);
assert!(out.contains("[ip-redacted]"));
}
#[test]
fn test_sanitize_truncates_long_message() {
let long = "a".repeat(500);
let sanitized = sanitize_error_for_public(&long);
let char_count = sanitized.chars().count();
assert_eq!(char_count, MAX_PUBLIC_ERROR_LEN + 1);
assert!(sanitized.ends_with('…'));
}
#[test]
fn test_sanitize_preserves_short_messages() {
let msg = "RDAP timed out after 15s";
let sanitized = sanitize_error_for_public(msg);
assert_eq!(sanitized, msg);
}
#[test]
fn test_is_rdap_response_useful_detects_no_data() {
use crate::rdap::RdapResponse;
let resp = RdapResponse {
ldh_name: Some("example.com".to_string()),
..Default::default()
};
assert!(
!rdap_response_is_useful(&resp),
"Response with only a name should be classified as NoData"
);
let useful = RdapResponse {
ldh_name: Some("example.com".to_string()),
status: vec!["active".to_string()],
..Default::default()
};
assert!(rdap_response_is_useful(&useful));
}
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn test_inflight_coalescing_map() {
let _serial = INFLIGHT_TEST_SERIAL
.lock()
.unwrap_or_else(|p| p.into_inner());
let domain = unique_test_key("__coalesce");
{
let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
m.remove(&domain);
}
let owner_notify = Arc::new(Notify::new());
{
let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
m.insert(domain.clone(), Arc::downgrade(&owner_notify));
}
let waiter = {
let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
m.get(&domain)
.and_then(|w| w.upgrade())
.expect("Second caller must observe in-flight entry")
};
let waiter_clone = waiter.clone();
let handle = tokio::spawn(async move {
waiter_clone.notified().await;
});
tokio::time::sleep(Duration::from_millis(20)).await;
{
let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
m.remove(&domain);
}
owner_notify.notify_waiters();
tokio::time::timeout(Duration::from_secs(1), handle)
.await
.expect("waiter must unblock after notify")
.expect("waiter task joined cleanly");
drop(owner_notify);
drop(waiter);
let m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
}
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn waiters_coalesce_on_inflight_lookup_and_read_owners_cache() {
use std::sync::atomic::Ordering;
let _serial = INFLIGHT_TEST_SERIAL
.lock()
.unwrap_or_else(|p| p.into_inner());
let raw = unique_test_key("coalesce-waiter");
let normalized = crate::validation::normalize_domain(&raw).expect("test key normalizes");
LOOKUP_CACHE.remove(&normalized);
let owner_notify = Arc::new(Notify::new());
{
let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
m.insert(normalized.clone(), Arc::downgrade(&owner_notify));
}
let calls_before = LOOKUP_CONCURRENT_CALLS.load(Ordering::SeqCst);
let lookup = SmartLookup::new();
let spawn_waiter = |l: SmartLookup, d: String| {
tokio::spawn(async move { l.lookup_with_progress(&d, None).await })
};
let h1 = spawn_waiter(lookup.clone(), raw.clone());
let h2 = spawn_waiter(lookup, raw);
tokio::time::sleep(Duration::from_millis(50)).await;
let canned = LookupResult::Whois {
data: empty_whois(&normalized),
rdap_error: None,
rdap_fallback: None,
};
LOOKUP_CACHE.insert(normalized.clone(), canned);
{
let mut m = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
m.remove(&normalized);
}
owner_notify.notify_waiters();
for handle in [h1, h2] {
let result = tokio::time::timeout(Duration::from_secs(5), handle)
.await
.expect("waiter must wake via notify, not the bounded-wait timeout")
.expect("waiter task joined cleanly")
.expect("waiter must read the owner's cached result");
assert!(result.is_whois());
assert_eq!(result.domain_name(), Some(normalized.clone()));
}
let calls_after = LOOKUP_CONCURRENT_CALLS.load(Ordering::SeqCst);
assert_eq!(
calls_before, calls_after,
"coalesced waiters must not start a second network race"
);
LOOKUP_CACHE.remove(&normalized);
}
fn unique_test_key(prefix: &str) -> String {
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
format!("{}_{}_{}.example.", prefix, nanos, n)
}
#[test]
fn available_with_whois_sanitizes_rdap_error() {
let avail = AvailabilityResult::new("unreg.test", false, "none", "inconclusive");
let result = available_with_whois(
avail,
"RDAP URL resolves to reserved IP 10.0.0.1",
empty_whois("unreg.test"),
);
let LookupResult::Available {
rdap_error,
whois_error,
whois_data,
..
} = result
else {
panic!("expected Available variant");
};
assert!(!rdap_error.contains("10.0.0.1"), "{rdap_error}");
assert!(rdap_error.contains("[ip-redacted]"));
assert!(whois_error.is_empty());
assert!(whois_data.is_some());
}
#[tokio::test]
async fn availability_fallback_sanitizes_both_error_fields() {
let lookup = SmartLookup::new();
let prior = RdapResponse {
ldh_name: Some("example.com".to_string()),
status: vec!["active".to_string()],
..Default::default()
};
let result = lookup
.availability_fallback(
"example.com",
PriorRdap::Response(Box::new(prior)),
PriorWhois::Missing,
"RDAP URL resolves to reserved IP 10.0.0.1".to_string(),
"connection refused at 192.168.0.5 and fe80::1".to_string(),
None,
)
.await
.expect("prior-response fallback must not error");
let LookupResult::Available {
rdap_error,
whois_error,
..
} = result
else {
panic!("expected Available variant");
};
assert!(!rdap_error.contains("10.0.0.1"), "{rdap_error}");
assert!(!whois_error.contains("192.168.0.5"), "{whois_error}");
assert!(!whois_error.contains("fe80::1"), "{whois_error}");
assert!(rdap_error.contains("[ip-redacted]"));
assert!(whois_error.contains("[ip-redacted]"));
}
fn available_via(method: &str, available: bool, confidence: &str) -> LookupResult {
LookupResult::Available {
data: Box::new(AvailabilityResult::new(
"example.test",
available,
confidence,
method,
)),
rdap_error: String::new(),
whois_error: String::new(),
whois_data: None,
}
}
#[test]
fn degraded_results_are_identified_for_short_caching() {
assert!(is_degraded_result(&available_via(
"inconclusive",
false,
"none"
)));
assert!(is_degraded_result(&available_via(
"dns_present",
false,
"high"
)));
assert!(is_degraded_result(&available_via(
"dns_nxdomain",
true,
"medium"
)));
assert!(is_degraded_result(&available_via(
"whois_error",
true,
"medium"
)));
assert!(!is_degraded_result(&available_via("rdap", true, "high")));
assert!(!is_degraded_result(&available_via("whois", true, "high")));
assert!(!is_degraded_result(&LookupResult::Whois {
data: empty_whois("example.test"),
rdap_error: None,
rdap_fallback: None,
}));
const { assert!(DEGRADED_LOOKUP_CACHE_TTL.as_secs() < LOOKUP_CACHE_TTL.as_secs()) };
}
#[test]
fn rdap_error_is_404_matches_standard_404() {
let e = SeerError::RdapError("query failed with status 404 Not Found".to_string());
assert!(rdap_error_is_404(&e));
}
#[test]
fn rdap_error_is_404_matches_without_reason_phrase() {
let e = SeerError::RdapError("query failed with status 404".to_string());
assert!(rdap_error_is_404(&e));
}
#[test]
fn rdap_error_is_404_rejects_other_statuses() {
let e = SeerError::RdapError("query failed with status 500 Server Error".to_string());
assert!(!rdap_error_is_404(&e));
let e = SeerError::RdapError("query failed with status 400 Bad Request".to_string());
assert!(!rdap_error_is_404(&e));
}
#[test]
fn rdap_error_is_404_rejects_non_http_errors() {
let e = SeerError::RdapError("connection timeout".to_string());
assert!(!rdap_error_is_404(&e));
let e = SeerError::Timeout("rdap".to_string());
assert!(!rdap_error_is_404(&e));
}
#[test]
fn rdap_error_is_404_rejects_incidental_404_in_message() {
let e = SeerError::RdapError("error 40404: database corruption".to_string());
assert!(!rdap_error_is_404(&e));
}
fn empty_whois(domain: &str) -> WhoisResponse {
WhoisResponse {
domain: domain.to_string(),
..Default::default()
}
}
#[test]
fn whois_response_is_thin_when_all_key_fields_missing() {
let w = empty_whois("example.com");
assert!(w.is_thin());
}
#[test]
fn whois_response_is_not_thin_when_registrar_present() {
let mut w = empty_whois("example.com");
w.registrar = Some("Test Registrar".to_string());
assert!(!w.is_thin());
}
#[test]
fn whois_response_is_not_thin_when_creation_date_present() {
let mut w = empty_whois("example.com");
w.creation_date = Some(Utc::now());
assert!(!w.is_thin());
}
#[test]
fn whois_response_is_not_thin_when_expiration_date_present() {
let mut w = empty_whois("example.com");
w.expiration_date = Some(Utc::now());
assert!(!w.is_thin());
}
fn denic_whois() -> WhoisResponse {
let mut w = empty_whois("example.de");
w.nameservers = vec!["ns1.example.net".to_string()];
w.status = vec!["active".to_string()];
w.updated_date = Some(Utc::now());
w.raw_response = "Domain: example.de\nNserver: ns1.example.net\nStatus: connect\n".into();
w
}
#[test]
fn whois_response_with_nameservers_is_not_thin() {
let mut w = empty_whois("example.com");
w.nameservers = vec!["ns1.example.net".to_string()];
assert!(!w.is_thin());
}
#[test]
fn denic_whois_stays_on_the_whois_path() {
let w = denic_whois();
let bootstrap_miss =
SeerError::RdapBootstrapError("no RDAP server for example.de".to_string());
assert!(!w.is_thin());
assert!(whois_leg_has_data(&Ok(w.clone())));
assert_eq!(
should_route_to_availability(false, Some(&bootstrap_miss), &w),
None
);
assert_eq!(
classify_thin_fallback(
w.is_thin(),
false,
w.indicates_registry_refusal(),
DnsPresence::Present,
),
ThinFallback::UseWhois
);
}
use crate::rdap::RdapResponse;
#[allow(dead_code)]
fn make_empty_rdap_response() -> RdapResponse {
serde_json::from_value(serde_json::json!({
"objectClassName": "domain",
}))
.expect("valid minimal RDAP response")
}
#[test]
fn classify_whois_leg_case_a_high_confidence() {
let mut w = empty_whois("zaccodes.com");
w.raw_response = "No match for \"ZACCODES.COM\".".to_string();
assert!(w.is_available());
let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
let (verdict, method) =
classify_whois_leg(&w, &rdap_err).expect("expected a routing decision");
assert_eq!(verdict, "high");
assert_eq!(method, "whois");
}
#[test]
fn classify_whois_leg_case_b_matches_decide_fallback() {
let w = empty_whois("example.xyz");
assert!(!w.is_available(), "this WHOIS body has no 'no match' text");
let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
let (verdict, method) =
classify_whois_leg(&w, &rdap_err).expect("expected a routing decision");
assert_eq!(verdict, "high");
assert_eq!(method, "rdap");
}
#[test]
fn classify_whois_leg_rejects_thin_whois_without_404() {
let w = empty_whois("example.xyz");
let rdap_err = SeerError::RdapError("connection timeout".to_string());
assert!(classify_whois_leg(&w, &rdap_err).is_none());
}
#[test]
fn classify_whois_leg_rejects_whois_with_real_data() {
let mut w = empty_whois("legacy.tld");
w.registrar = Some("Legacy Registry".to_string());
w.creation_date = Some(Utc::now());
let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
assert!(classify_whois_leg(&w, &rdap_err).is_none());
}
#[test]
fn classify_whois_leg_case_a_wins_over_case_b() {
let mut w = empty_whois("example.com");
w.raw_response = "No match for \"EXAMPLE.COM\".".to_string();
let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
let (verdict, _) = classify_whois_leg(&w, &rdap_err).unwrap();
assert_eq!(verdict, "high");
}
#[test]
fn rdap_200_vetoes_whois_no_match() {
let mut w = empty_whois("freshly-registered.com");
w.raw_response = "No match for \"FRESHLY-REGISTERED.COM\".".to_string();
assert!(
should_route_to_availability(true, None, &w).is_none(),
"RDAP 200 must veto WHOIS-only availability claim",
);
}
#[test]
fn rdap_200_vetoes_even_with_thin_whois() {
let w = empty_whois("freshly-registered.com");
assert!(
should_route_to_availability(true, None, &w).is_none(),
"RDAP 200 must veto even when WHOIS is thin",
);
}
#[test]
fn rdap_404_with_whois_no_match_routes_to_available() {
let mut w = empty_whois("genuinely-free.com");
w.raw_response = "No match for \"GENUINELY-FREE.COM\".".to_string();
let rdap_err = SeerError::RdapError("query failed with status 404".to_string());
let result = should_route_to_availability(false, Some(&rdap_err), &w);
assert_eq!(result, Some(("high", "whois")));
}
#[test]
fn rdap_error_with_whois_is_available_still_routes_case_a() {
let mut w = empty_whois("genuinely-free.com");
w.raw_response = "Domain not found".to_string();
let rdap_err = SeerError::RdapBootstrapError("all registries failed".to_string());
let result = should_route_to_availability(false, Some(&rdap_err), &w);
assert_eq!(result, Some(("high", "whois")));
}
#[test]
fn rdap_grace_timeout_with_whois_is_available_routes_case_a() {
let mut w = empty_whois("genuinely-free.com");
w.raw_response = "No match".to_string();
let result = should_route_to_availability(false, None, &w);
assert_eq!(result, Some(("high", "whois")));
}
#[test]
fn no_rdap_200_no_error_thick_whois_stays_in_whois_path() {
let mut w = empty_whois("registered.com");
w.registrar = Some("Example Registrar Ltd".to_string());
assert!(should_route_to_availability(false, None, &w).is_none());
}
#[test]
fn thin_fallback_refusal_is_inconclusive_regardless_of_dns() {
for dns in [
DnsPresence::Absent,
DnsPresence::Present,
DnsPresence::Unknown,
] {
assert_eq!(
classify_thin_fallback(true, false, true, dns),
ThinFallback::Inconclusive
);
}
}
#[test]
fn thin_fallback_nxdomain_is_available() {
assert_eq!(
classify_thin_fallback(true, false, false, DnsPresence::Absent),
ThinFallback::Available
);
}
#[test]
fn thin_fallback_delegated_is_registered() {
assert_eq!(
classify_thin_fallback(true, false, false, DnsPresence::Present),
ThinFallback::Registered
);
}
#[test]
fn thin_fallback_unknown_dns_uses_whois() {
assert_eq!(
classify_thin_fallback(true, false, false, DnsPresence::Unknown),
ThinFallback::UseWhois
);
}
#[test]
fn thin_fallback_requires_thin_whois() {
assert_eq!(
classify_thin_fallback(false, false, false, DnsPresence::Absent),
ThinFallback::UseWhois
);
}
#[test]
fn thin_fallback_vetoed_by_rdap_200() {
assert_eq!(
classify_thin_fallback(true, true, false, DnsPresence::Absent),
ThinFallback::UseWhois
);
assert_eq!(
classify_thin_fallback(true, true, true, DnsPresence::Present),
ThinFallback::UseWhois
);
}
async fn leg<T>(secs: u64, value: T) -> T {
tokio::time::sleep(Duration::from_secs(secs)).await;
value
}
#[tokio::test(start_paused = true)]
async fn race_awaits_whois_fully_when_rdap_errors_first() {
let rdap = leg(0, Err::<&str, &str>("no RDAP server for example.ru"));
let whois = leg(20, Ok::<&str, &str>("whois data"));
let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
assert!(matches!(r, LegOutcome::Completed(Err(_))));
assert!(
matches!(w, LegOutcome::Completed(Ok("whois data"))),
"WHOIS must not be grace-truncated behind an RDAP failure"
);
}
#[tokio::test(start_paused = true)]
async fn race_awaits_rdap_fully_when_whois_errors_first() {
let rdap = leg(20, Ok::<&str, &str>("rdap data"));
let whois = leg(0, Err::<&str, &str>("connection refused"));
let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
assert!(
matches!(r, LegOutcome::Completed(Ok("rdap data"))),
"RDAP must not be grace-truncated behind a WHOIS failure"
);
assert!(matches!(w, LegOutcome::Completed(Err(_))));
}
#[tokio::test(start_paused = true)]
async fn race_truncates_whois_when_rdap_answers_with_data() {
let rdap = leg(0, Ok::<&str, &str>("useful rdap"));
let whois = leg(20, Ok::<&str, &str>("whois data"));
let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
assert!(matches!(r, LegOutcome::Completed(Ok(_))));
assert!(
matches!(w, LegOutcome::GraceTruncated),
"a data-bearing RDAP winner only owes WHOIS the grace period"
);
}
#[tokio::test(start_paused = true)]
async fn race_truncates_rdap_when_whois_answers_with_data() {
let rdap = leg(20, Ok::<&str, &str>("rdap data"));
let whois = leg(0, Ok::<&str, &str>("whois data"));
let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
assert!(matches!(r, LegOutcome::GraceTruncated));
assert!(matches!(w, LegOutcome::Completed(Ok(_))));
}
#[tokio::test(start_paused = true)]
async fn race_loser_inside_grace_period_still_completes() {
let rdap = leg(0, Ok::<&str, &str>("useful rdap"));
let whois = leg(2, Ok::<&str, &str>("whois data"));
let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), |w| w.is_ok()).await;
assert!(matches!(r, LegOutcome::Completed(Ok(_))));
assert!(matches!(w, LegOutcome::Completed(Ok("whois data"))));
}
#[tokio::test(start_paused = true)]
async fn race_awaits_whois_fully_when_rdap_wins_with_unusable_data() {
let rdap = leg(0, Ok::<&str, &str>("thin"));
let whois = leg(20, Ok::<&str, &str>("whois data"));
let (r, w) = race_with_grace(
rdap,
whois,
|r| matches!(r, Ok(s) if *s == "useful"),
|w| w.is_ok(),
)
.await;
assert!(matches!(r, LegOutcome::Completed(Ok("thin"))));
assert!(matches!(w, LegOutcome::Completed(Ok("whois data"))));
}
#[tokio::test(start_paused = true)]
async fn race_awaits_rdap_fully_when_whois_wins_with_thin_body() {
let rdap = leg(20, Ok::<&str, &str>("rdap data"));
let whois = leg(0, Ok::<_, SeerError>(empty_whois("zac.email")));
let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), whois_leg_has_data).await;
assert!(
matches!(r, LegOutcome::Completed(Ok("rdap data"))),
"RDAP must not be grace-truncated behind a thin WHOIS answer"
);
assert!(matches!(w, LegOutcome::Completed(Ok(_))));
}
#[tokio::test(start_paused = true)]
async fn race_truncates_rdap_when_whois_wins_with_real_data() {
let mut whois_data = empty_whois("example.com");
whois_data.registrar = Some("Mock Registrar".to_string());
let rdap = leg(20, Ok::<&str, &str>("rdap data"));
let whois = leg(0, Ok::<_, SeerError>(whois_data));
let (r, w) = race_with_grace(rdap, whois, |r| r.is_ok(), whois_leg_has_data).await;
assert!(
matches!(r, LegOutcome::GraceTruncated),
"a data-bearing WHOIS winner only owes RDAP the grace period"
);
assert!(matches!(w, LegOutcome::Completed(Ok(_))));
}
#[test]
fn whois_leg_has_data_rejects_thin_ok_and_errors() {
assert!(!whois_leg_has_data(&Ok(empty_whois("example.email"))));
let mut no_match = empty_whois("example.com");
no_match.raw_response = "No match for \"EXAMPLE.COM\".".to_string();
assert!(!whois_leg_has_data(&Ok(no_match)));
assert!(!whois_leg_has_data(&Err(SeerError::WhoisError(
"connection refused".to_string()
))));
}
#[test]
fn whois_leg_has_data_accepts_registration_data() {
let mut w = empty_whois("example.com");
w.registrar = Some("Mock Registrar".to_string());
assert!(whois_leg_has_data(&Ok(w)));
let mut w = empty_whois("example.com");
w.expiration_date = Some(Utc::now());
assert!(whois_leg_has_data(&Ok(w)));
}
#[test]
fn grace_truncated_error_says_answered_not_won() {
let msg = grace_truncated_error("WHOIS", "RDAP");
assert_eq!(
msg,
format!(
"WHOIS did not respond within the {}s grace period after RDAP answered",
PROTOCOL_GRACE_PERIOD.as_secs()
)
);
assert!(!msg.contains("won"));
}
#[test]
fn grace_truncated_error_is_symmetric() {
let msg = grace_truncated_error("RDAP", "WHOIS");
assert!(msg.starts_with("RDAP did not respond"));
assert!(msg.ends_with("after WHOIS answered"));
}
#[test]
fn lookup_inflight_recovers_from_poisoned_mutex() {
use std::panic::{catch_unwind, AssertUnwindSafe};
let _serial = INFLIGHT_TEST_SERIAL
.lock()
.unwrap_or_else(|p| p.into_inner());
let _ = catch_unwind(AssertUnwindSafe(|| {
let _guard = LOOKUP_INFLIGHT.lock().unwrap();
panic!("poisoning LOOKUP_INFLIGHT for test");
}));
let mut guard = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
let canary = unique_test_key("__poison_recovery");
guard.insert(canary.clone(), Weak::new());
assert!(guard.contains_key(&canary));
guard.remove(&canary);
}
#[test]
fn inflight_guard_drop_recovers_from_poisoned_mutex() {
use std::panic::{catch_unwind, AssertUnwindSafe};
let _serial = INFLIGHT_TEST_SERIAL
.lock()
.unwrap_or_else(|p| p.into_inner());
let key = unique_test_key("__drop_poison");
let notify = Arc::new(Notify::new());
{
let mut map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
map.insert(key.clone(), Arc::downgrade(¬ify));
}
let guard = InflightGuard {
key: key.clone(),
notify: notify.clone(),
};
let _ = catch_unwind(AssertUnwindSafe(|| {
let _g = LOOKUP_INFLIGHT.lock().unwrap();
panic!("poisoning LOOKUP_INFLIGHT for drop test");
}));
drop(guard);
let map = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
assert!(
!map.contains_key(&key),
"poisoned-mutex drop path should still remove the in-flight entry"
);
}
}