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 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);
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,
},
}
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 { .. } => {}
}
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()
.expect("LOOKUP_INFLIGHT mutex poisoned");
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) = match rdap_outcome {
RdapOutcome::Useful(_) => {
debug!("Unexpected RdapOutcome::Useful in fallback branch");
(String::from("RDAP ok"), None)
}
RdapOutcome::NoData(data) => {
("RDAP response incomplete".to_string(), Some(Box::new(data)))
}
RdapOutcome::Error(e) => (e.to_string(), None),
RdapOutcome::GraceTimeout => (
format!(
"RDAP did not return within {}s grace period after WHOIS won",
PROTOCOL_GRACE_PERIOD.as_secs()
),
None,
),
};
if let LegOutcome::Completed(Ok(whois_data)) = whois_leg {
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),
}),
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::*;
#[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(),
};
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 mut m = LOOKUP_INFLIGHT.lock().unwrap();
m.clear();
}
let domain = "__test_coalesce.example.".to_string();
let owner_notify = Arc::new(Notify::new());
{
let mut m = LOOKUP_INFLIGHT.lock().unwrap();
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();
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();
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();
assert!(m.get(&domain).and_then(|w| w.upgrade()).is_none());
}
#[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,
};
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");
}
}
}