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 once_cell::sync::Lazy;
use regex::Regex;
use serde::{Deserialize, Serialize};
use tokio::sync::Notify;
use tracing::{debug, instrument, warn};
use tokio::time::timeout as tokio_timeout;
use crate::availability::{AvailabilityChecker, AvailabilityResult};
use crate::cache::TtlCache;
use crate::error::{Result, SeerError};
use crate::rdap::{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;
static LOOKUP_CACHE: Lazy<TtlCache<String, LookupResult>> =
Lazy::new(|| TtlCache::new(LOOKUP_CACHE_TTL));
static LOOKUP_INFLIGHT: Lazy<Mutex<HashMap<String, Weak<Notify>>>> =
Lazy::new(|| Mutex::new(HashMap::new()));
static IPV4_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\b(?:\d{1,3}\.){3}\d{1,3}\b").expect("IPV4_RE is a valid regex"));
static IPV6_CANDIDATE_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"\b[0-9a-fA-F:]*(?:::|(?:[0-9a-fA-F]{1,4}:){3,})[0-9a-fA-F:]*\b")
.expect("IPV6_CANDIDATE_RE is a valid regex")
});
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: Lazy<std::sync::atomic::AtomicUsize> =
Lazy::new(|| std::sync::atomic::AtomicUsize::new(0));
fn rdap_error_is_404(err: &SeerError) -> bool {
if let SeerError::RdapError(msg) = err {
msg.contains("query failed with status 404")
} else {
false
}
}
fn whois_response_is_thin(w: &WhoisResponse) -> bool {
w.registrar.is_none() && w.creation_date.is_none() && w.expiration_date.is_none()
}
fn classify_whois_leg(
w: &WhoisResponse,
rdap_err: &SeerError,
) -> Option<(&'static str, &'static str)> {
if w.is_available() {
return Some(("high", "whois"));
}
if whois_response_is_thin(w) && rdap_error_is_404(rdap_err) {
return Some(("medium", "whois_thin_response"));
}
None
}
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) {
match LOOKUP_INFLIGHT.try_lock() {
Ok(mut inflight) => {
inflight.remove(&self.key);
}
Err(std::sync::TryLockError::Poisoned(p)) => {
let mut inflight = p.into_inner();
inflight.remove(&self.key);
}
Err(std::sync::TryLockError::WouldBlock) => {
tracing::debug!(
key = %self.key,
"InflightGuard drop: skipping cleanup under contention"
);
}
}
self.notify.notify_waiters();
}
}
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()),
}
}
pub fn registrar(&self) -> Option<String> {
match self {
LookupResult::Rdap {
data,
whois_fallback,
} => data
.get_registrar()
.or_else(|| whois_fallback.as_ref().and_then(|w| w.registrar.clone())),
LookupResult::Whois { data, .. } => data.registrar.clone(),
LookupResult::Available { .. } => None,
}
}
pub fn organization(&self) -> Option<String> {
match self {
LookupResult::Rdap {
data,
whois_fallback,
} => data
.get_registrant_organization()
.or_else(|| whois_fallback.as_ref().and_then(|w| w.organization.clone())),
LookupResult::Whois { data, .. } => data.organization.clone(),
LookupResult::Available { .. } => None,
}
}
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>) {
match self {
LookupResult::Rdap {
data,
whois_fallback,
} => {
let expiration_date = data
.events
.iter()
.find(|e| e.event_action == "expiration")
.and_then(|e| e.parsed_date())
.or_else(|| {
whois_fallback.as_ref().and_then(|w| w.expiration_date)
});
let registrar = data
.get_registrar()
.or_else(|| whois_fallback.as_ref().and_then(|w| w.registrar.clone()));
(expiration_date, registrar)
}
LookupResult::Whois { data, .. } => (data.expiration_date, data.registrar.clone()),
LookupResult::Available { .. } => (None, None),
}
}
}
fn trim_for_cache(mut result: LookupResult) -> LookupResult {
const MAX_RAW: usize = 32 * 1024;
match result {
LookupResult::Whois { ref mut data, .. } => {
if data.raw_response.len() > MAX_RAW {
data.raw_response.truncate(MAX_RAW);
data.raw_response.push_str("\n... [truncated for cache]");
}
}
LookupResult::Rdap {
ref mut whois_fallback,
..
} => {
if let Some(ref mut w) = whois_fallback {
if w.raw_response.len() > MAX_RAW {
w.raw_response.truncate(MAX_RAW);
w.raw_response.push_str("\n... [truncated for cache]");
}
}
}
LookupResult::Available {
ref mut whois_data, ..
} => {
if let Some(ref mut w) = whois_data {
if w.raw_response.len() > MAX_RAW {
w.raw_response.truncate(MAX_RAW);
w.raw_response.push_str("\n... [truncated for cache]");
}
}
}
}
result
}
#[derive(Debug, Clone)]
pub struct SmartLookup {
rdap_client: RdapClient,
whois_client: WhoisClient,
availability_checker: AvailabilityChecker,
prefer_rdap: bool,
include_fallback: bool,
}
impl Default for SmartLookup {
fn default() -> Self {
Self::new()
}
}
impl SmartLookup {
pub fn new() -> Self {
Self {
rdap_client: RdapClient::new(),
whois_client: WhoisClient::new(),
availability_checker: AvailabilityChecker::new(),
prefer_rdap: true,
include_fallback: false,
}
}
#[deprecated(note = "This field has no effect. RDAP is always tried concurrently with WHOIS.")]
pub fn prefer_rdap(mut self, prefer: bool) -> Self {
self.prefer_rdap = prefer;
self
}
#[deprecated(note = "This field has no effect. RDAP is always tried concurrently with WHOIS.")]
pub fn include_fallback(mut self, include: bool) -> Self {
self.include_fallback = include;
self
}
#[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 {
enum Slot {
Waiter(Arc<Notify>),
Owner(InflightGuard),
}
let slot = {
let mut inflight = LOOKUP_INFLIGHT.lock().unwrap_or_else(|p| p.into_inner());
match inflight.get(&normalized).and_then(|w| w.upgrade()) {
Some(existing) => Slot::Waiter(existing),
None => {
let n = Arc::new(Notify::new());
inflight.insert(normalized.clone(), Arc::downgrade(&n));
Slot::Owner(InflightGuard {
key: normalized.clone(),
notify: n,
})
}
}
};
match slot {
Slot::Waiter(n) => {
debug!(domain = %normalized, "Waiting for in-flight lookup to complete");
n.notified().await;
if let Some(cached) = LOOKUP_CACHE.get(&normalized) {
return Ok(cached);
}
continue;
}
Slot::Owner(guard) => break guard,
}
};
let result = self.lookup_concurrent(&normalized, progress).await?;
LOOKUP_CACHE.insert(normalized.clone(), trim_for_cache(result.clone()));
Ok(result)
}
pub fn clear_cache() {
LOOKUP_CACHE.clear();
}
#[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);
tokio::pin!(rdap_fut);
tokio::pin!(whois_fut);
enum LegOutcome<T> {
Completed(T),
GraceTruncated,
}
let (rdap_leg, whois_leg) = tokio::select! {
rdap_res = &mut rdap_fut => {
let whois_leg = match tokio_timeout(PROTOCOL_GRACE_PERIOD, whois_fut).await {
Ok(res) => LegOutcome::Completed(res),
Err(_) => {
debug!("WHOIS did not finish within grace period, proceeding with RDAP only");
LegOutcome::GraceTruncated
}
};
(LegOutcome::Completed(rdap_res), whois_leg)
}
whois_res = &mut whois_fut => {
let rdap_leg = match tokio_timeout(PROTOCOL_GRACE_PERIOD, rdap_fut).await {
Ok(res) => LegOutcome::Completed(res),
Err(_) => {
debug!("RDAP did not finish within grace period, proceeding with WHOIS only");
LegOutcome::GraceTruncated
}
};
(rdap_leg, LegOutcome::Completed(whois_res))
}
};
let rdap_outcome = match rdap_leg {
LegOutcome::Completed(Ok(data)) => {
if self.is_rdap_response_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_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 => (
format!(
"RDAP did not return within {}s grace period after WHOIS won",
PROTOCOL_GRACE_PERIOD.as_secs()
),
None,
None,
),
};
if let LegOutcome::Completed(Ok(whois_data)) = whois_leg {
let availability_match = rdap_seer_error
.as_ref()
.and_then(|e| classify_whois_leg(&whois_data, e))
.or_else(|| {
if whois_data.is_available() {
Some(("high", "whois"))
} else {
None
}
});
if let Some((confidence, method)) = availability_match {
debug!(
domain = %domain,
confidence = %confidence,
"Reclassifying WHOIS as availability signal"
);
if let Some(ref cb) = progress {
cb("Domain appears unregistered");
}
let details = match confidence {
"high" => Some("WHOIS indicates domain is not registered".to_string()),
"medium" => Some(
"WHOIS returned no registrar or registration dates; RDAP returned 404"
.to_string(),
),
_ => None,
};
let avail = AvailabilityResult {
domain: domain.to_string(),
available: true,
confidence: confidence.to_string(),
method: method.to_string(),
details,
};
return Ok(LookupResult::Available {
data: Box::new(avail),
rdap_error: sanitize_error_for_public(&rdap_error_str),
whois_error: String::new(),
whois_data: Some(whois_data),
});
}
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(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 => format!(
"WHOIS did not return within {}s grace period after RDAP won",
PROTOCOL_GRACE_PERIOD.as_secs()
),
};
self.availability_fallback(domain, rdap_error_str, whois_error_str, progress)
.await
}
async fn availability_fallback(
&self,
domain: &str,
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(domain).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,
})
}
}
}
fn is_rdap_response_useful(&self, 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)
}
}
#[cfg(test)]
mod tests {
use super::*;
static INFLIGHT_TEST_SERIAL: std::sync::Mutex<()> = std::sync::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()),
registrant: None,
organization: None,
registrant_email: None,
registrant_phone: None,
registrant_address: None,
registrant_country: None,
admin_name: None,
admin_organization: None,
admin_email: None,
admin_phone: None,
tech_name: None,
tech_organization: None,
tech_email: None,
tech_phone: None,
creation_date: None,
expiration_date: None,
updated_date: None,
status: vec![],
nameservers: vec![],
dnssec: None,
whois_server: "whois.example.com".to_string(),
raw_response: String::new(),
},
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(),
registrar: None,
registrant: None,
organization: None,
registrant_email: None,
registrant_phone: None,
registrant_address: None,
registrant_country: None,
admin_name: None,
admin_organization: None,
admin_email: None,
admin_phone: None,
tech_name: None,
tech_organization: None,
tech_email: None,
tech_phone: None,
creation_date: None,
expiration_date: None,
updated_date: None,
status: vec![],
nameservers: vec![],
dnssec: None,
whois_server: String::new(),
raw_response: String::new(),
},
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 {
domain: "test123.xyz".to_string(),
available: true,
confidence: "medium".to_string(),
method: "whois_error".to_string(),
details: Some("WHOIS server indicates no matching records".to_string()),
}),
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]
#[allow(deprecated)]
fn test_smart_lookup_builder() {
let lookup = SmartLookup::new().prefer_rdap(false).include_fallback(true);
assert!(!lookup.prefer_rdap);
assert!(lookup.include_fallback);
}
#[test]
fn test_lookup_cache_clear() {
SmartLookup::clear_cache();
assert!(LOOKUP_CACHE.is_empty());
}
#[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()
};
let lookup = SmartLookup::new();
assert!(
!lookup.is_rdap_response_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!(lookup.is_rdap_response_useful(&useful));
}
#[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());
}
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 test_sanitize_applied_to_available_fields() {
let rdap_raw = "RDAP URL resolves to reserved IP 10.0.0.1";
let whois_raw = "connection refused at 192.168.0.5";
let sanitized_rdap = sanitize_error_for_public(rdap_raw);
let sanitized_whois = sanitize_error_for_public(whois_raw);
let result = LookupResult::Available {
data: Box::new(AvailabilityResult {
domain: "unreg.test".to_string(),
available: true,
confidence: "low".to_string(),
method: "heuristic".to_string(),
details: None,
}),
rdap_error: sanitized_rdap,
whois_error: sanitized_whois,
whois_data: None,
};
if let LookupResult::Available {
rdap_error,
whois_error,
..
} = result
{
assert!(!rdap_error.contains("10.0.0.1"));
assert!(!whois_error.contains("192.168.0.5"));
assert!(rdap_error.contains("[ip-redacted]"));
assert!(whois_error.contains("[ip-redacted]"));
} else {
panic!("expected Available variant");
}
}
#[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(),
registrar: None,
registrant: None,
organization: None,
registrant_email: None,
registrant_phone: None,
registrant_address: None,
registrant_country: None,
admin_name: None,
admin_organization: None,
admin_email: None,
admin_phone: None,
tech_name: None,
tech_organization: None,
tech_email: None,
tech_phone: None,
creation_date: None,
expiration_date: None,
updated_date: None,
nameservers: vec![],
status: vec![],
dnssec: None,
whois_server: String::new(),
raw_response: String::new(),
}
}
#[test]
fn whois_response_is_thin_when_all_key_fields_missing() {
let w = empty_whois("example.com");
assert!(whois_response_is_thin(&w));
}
#[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!(!whois_response_is_thin(&w));
}
#[test]
fn whois_response_is_not_thin_when_creation_date_present() {
let mut w = empty_whois("example.com");
w.creation_date = Some(chrono::Utc::now());
assert!(!whois_response_is_thin(&w));
}
#[test]
fn whois_response_is_not_thin_when_expiration_date_present() {
let mut w = empty_whois("example.com");
w.expiration_date = Some(chrono::Utc::now());
assert!(!whois_response_is_thin(&w));
}
#[test]
fn whois_response_is_thin_even_with_nameservers_alone() {
let mut w = empty_whois("example.com");
w.nameservers = vec!["ns1.example.net".to_string()];
assert!(whois_response_is_thin(&w));
}
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_medium_confidence() {
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, "medium");
assert_eq!(method, "whois_thin_response");
}
#[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(chrono::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 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(), std::sync::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"
);
}
}