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::{
classify_fallback, AvailabilityChecker, AvailabilityResult, Fallback, PriorRdap, PriorWhois,
};
use crate::cache::TtlCache;
use crate::dns::{DelegationChecker, DnsNameservers, DnsPresence, DnsResolver};
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;
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"
),
LookupResult::Whois { data, .. } => data.is_thin(),
LookupResult::Rdap { .. } => 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 whois_leg_fallback(
domain: &str,
rdap_returned_200: bool,
rdap_err: Option<&SeerError>,
whois: &WhoisResponse,
) -> Fallback {
if rdap_returned_200 {
return Fallback::Registered;
}
classify_fallback(domain, rdap_err, Ok(whois))
}
fn verdict_progress(avail: &AvailabilityResult) -> &'static str {
match avail.method.as_str() {
"registrable_parent" => "Name is below a registrable domain (checked its parent)",
"inconclusive" => "Registry gave no usable answer (availability inconclusive)",
"dns_present" => "Domain is registered (registry detail unavailable)",
"dns_nxdomain" => "Domain appears unregistered (no DNS presence)",
_ if avail.available => "Domain appears unregistered",
_ => "Domain is registered",
}
}
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>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
dns_nameservers: Option<DnsNameservers>,
},
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 protocol(&self) -> Option<&'static str> {
match self {
LookupResult::Rdap { .. } => Some("RDAP"),
LookupResult::Whois { .. } => Some("WHOIS"),
LookupResult::Available { .. } => None,
}
}
pub fn domain_name(&self) -> Option<String> {
match self {
LookupResult::Rdap { data, .. } => data.domain_name().map(normalize_rdap_name),
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 normalize_rdap_name(name: &str) -> String {
let name = name.trim_end_matches('.').to_lowercase();
crate::validation::domain_to_ascii(&name).unwrap_or(name)
}
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
}
fn wants_dns_nameservers(result: &LookupResult) -> bool {
matches!(
result,
LookupResult::Whois {
rdap_fallback: None,
..
}
)
}
fn with_dns_nameservers(mut result: LookupResult, delegated: Result<Vec<String>>) -> LookupResult {
if !wants_dns_nameservers(&result) {
return result;
}
if let LookupResult::Whois {
data,
dns_nameservers,
..
} = &mut result
{
match delegated {
Ok(delegated) => {
*dns_nameservers = Some(DnsNameservers::compare(&data.nameservers, delegated));
}
Err(e) => debug!(error = %e, "DNS delegation unavailable for the WHOIS cross-check"),
}
}
result
}
#[derive(Debug, Clone, Default)]
pub struct SmartLookup {
rdap_client: RdapClient,
whois_client: WhoisClient,
availability_checker: AvailabilityChecker,
dns_resolver: DnsResolver,
delegation_checker: DelegationChecker,
}
impl SmartLookup {
pub fn new() -> Self {
Self::default()
}
pub fn from_config(config: &crate::config::SeerConfig) -> Self {
Self {
rdap_client: RdapClient::from_config(config),
whois_client: WhoisClient::from_config(config),
availability_checker: AvailabilityChecker::from_config(config),
dns_resolver: DnsResolver::from_config(config),
delegation_checker: DelegationChecker::from_config(config),
}
}
#[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.clone())
.await?;
let result = self
.attach_dns_nameservers(&normalized, result, 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)
}
async fn attach_dns_nameservers(
&self,
domain: &str,
result: LookupResult,
progress: Option<LookupProgressCallback>,
) -> LookupResult {
if !wants_dns_nameservers(&result) {
return result;
}
if let Some(ref cb) = progress {
cb("Checking the DNS delegation");
}
let delegated = self.delegation_checker.delegated_ns(domain).await;
with_dns_nameservers(result, delegated)
}
#[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 fallback = whois_leg_fallback(
domain,
rdap_returned_200,
rdap_seer_error.as_ref(),
&whois_data,
);
let verdict = match fallback {
Fallback::Registered => None,
Fallback::Verdict(avail) => Some(avail),
Fallback::NeedsDns(tie_break) => {
Some(tie_break.decide(self.dns_resolver.presence(domain).await))
}
};
if let Some(avail) = verdict {
debug!(
domain = %domain,
method = %avail.method,
confidence = %avail.confidence,
"Reading WHOIS leg as an availability verdict"
);
let avail = self.availability_checker.guard_subdomain_claim(avail).await;
if let Some(ref cb) = progress {
cb(verdict_progress(&avail));
}
return Ok(available_with_whois(avail, &rdap_error_str, 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(sanitize_error_for_public(&rdap_error_str)),
rdap_fallback: rdap_fallback_data,
dns_nameservers: None,
});
}
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) => Err(SeerError::LookupFailed {
domain: domain.to_string(),
details: format!(
"RDAP failed ({}), WHOIS failed ({}), availability check failed ({})",
rdap_error, whois_error, avail_err
),
registry_url: get_registry_url(get_tld(domain)),
}),
}
}
pub(crate) async fn presence(&self, domain: &str) -> DnsPresence {
self.dns_resolver.presence(domain).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rdap::rdap_error_is_404;
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,
dns_nameservers: 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 rdap_domain_name_is_normalized_like_the_other_protocols() {
for (ldh, unicode) in [
(Some("EXAMPLE.COM"), None),
(Some("Example.Com."), None),
(None, Some("пример.рф")),
] {
let result = LookupResult::Rdap {
data: Box::new(RdapResponse {
ldh_name: ldh.map(str::to_string),
unicode_name: unicode.map(str::to_string),
..Default::default()
}),
whois_fallback: None,
};
let expected = if unicode.is_some() {
"xn--e1afmkfd.xn--p1ai"
} else {
"example.com"
};
assert_eq!(result.domain_name().as_deref(), Some(expected));
}
}
#[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,
dns_nameservers: 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", "dns_nxdomain")
.with_details("Registry lookups failed; domain has no DNS presence (NXDOMAIN)"),
),
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,
dns_nameservers: 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,
dns_nameservers: 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(&LookupResult::Whois {
data: empty_whois("example.test"),
rdap_error: Some("RDAP response incomplete".to_string()),
rdap_fallback: None,
dns_nameservers: None,
}));
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: denic_whois(),
rdap_error: None,
rdap_fallback: None,
dns_nameservers: 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!(matches!(
whois_leg_fallback("example.de", false, Some(&bootstrap_miss), &w),
Fallback::Registered
));
}
fn verdict_of(fallback: Fallback, dns: DnsPresence) -> Option<(bool, String)> {
let avail = match fallback {
Fallback::Registered => return None,
Fallback::Verdict(v) => v,
Fallback::NeedsDns(t) => t.decide(dns),
};
Some((avail.available, avail.method))
}
#[test]
fn rdap_200_vetoes_whois_no_match_and_thin_bodies() {
let mut no_match = empty_whois("freshly-registered.com");
no_match.raw_response = "No match for \"FRESHLY-REGISTERED.COM\".".to_string();
for w in [no_match, empty_whois("freshly-registered.com")] {
assert!(matches!(
whois_leg_fallback("freshly-registered.com", true, None, &w),
Fallback::Registered
));
}
}
#[test]
fn whois_no_match_routes_to_available_for_any_rdap_failure() {
let mut w = empty_whois("genuinely-free.com");
w.raw_response = "No match for \"GENUINELY-FREE.COM\".".to_string();
let r404 = SeerError::RdapError("query failed with status 404".to_string());
let bootstrap = SeerError::RdapBootstrapError("all registries failed".to_string());
for rdap in [Some(&r404), Some(&bootstrap), None] {
assert_eq!(
verdict_of(
whois_leg_fallback("genuinely-free.com", false, rdap, &w),
DnsPresence::Unknown
),
Some((true, "whois".to_string()))
);
}
}
#[test]
fn thin_whois_with_rdap_404_is_available_via_rdap() {
let r404 = SeerError::RdapError("query failed with status 404 Not Found".to_string());
let fallback = whois_leg_fallback("example.xyz", false, Some(&r404), &empty_whois("x"));
let Fallback::Verdict(v) = fallback else {
panic!("expected a verdict");
};
assert_eq!(
(v.available, v.confidence.as_str(), v.method.as_str()),
(true, "high", "rdap")
);
}
#[test]
fn thin_whois_with_silent_rdap_is_decided_by_dns() {
let rdap = SeerError::RdapError("query failed with status 429".to_string());
let w = empty_whois("zac.email");
let read = |dns| verdict_of(whois_leg_fallback("zac.email", false, Some(&rdap), &w), dns);
assert_eq!(
read(DnsPresence::Present),
Some((false, "dns_present".to_string()))
);
assert_eq!(
read(DnsPresence::Absent),
Some((true, "dns_nxdomain".to_string()))
);
assert_eq!(
read(DnsPresence::Unknown),
Some((false, "inconclusive".to_string()))
);
}
#[test]
fn thin_whois_refusal_is_inconclusive_regardless_of_dns() {
let rdap = SeerError::RdapError("query failed with status 503".to_string());
let mut w = empty_whois("example.test");
w.raw_response = "Access rate limited; please try again later.\n".to_string();
for dns in [
DnsPresence::Absent,
DnsPresence::Present,
DnsPresence::Unknown,
] {
assert_eq!(
verdict_of(
whois_leg_fallback("example.test", false, Some(&rdap), &w),
dns
),
Some((false, "inconclusive".to_string()))
);
}
}
#[test]
fn whois_with_registration_data_stays_a_whois_record() {
let mut w = empty_whois("registered.com");
w.registrar = Some("Example Registrar Ltd".to_string());
let rdap = SeerError::RdapError("connection timeout".to_string());
for rdap in [Some(&rdap), None] {
assert!(matches!(
whois_leg_fallback("registered.com", false, rdap, &w),
Fallback::Registered
));
}
}
#[test]
fn verdict_progress_names_the_deciding_signal() {
let v =
|available, method: &str| AvailabilityResult::new("x.test", available, "high", method);
assert_eq!(
verdict_progress(&v(true, "rdap")),
"Domain appears unregistered"
);
assert!(verdict_progress(&v(false, "inconclusive")).contains("inconclusive"));
assert!(verdict_progress(&v(false, "dns_present")).contains("registered"));
assert!(verdict_progress(&v(false, "registrable_parent")).contains("parent"));
}
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"
);
}
fn whois_result(
nameservers: &[&str],
rdap_fallback: Option<Box<RdapResponse>>,
) -> LookupResult {
LookupResult::Whois {
data: WhoisResponse {
domain: "nic.tg".to_string(),
nameservers: nameservers.iter().map(|s| s.to_string()).collect(),
..Default::default()
},
rdap_error: None,
rdap_fallback,
dns_nameservers: None,
}
}
fn delegated(hosts: &[&str]) -> Result<Vec<String>> {
Ok(hosts.iter().map(|s| s.to_string()).collect())
}
#[test]
fn whois_only_result_gets_the_dns_delegation() {
let result = with_dns_nameservers(
whois_result(&["ns1.nic.tg", "ns2.nic.tg"], None),
delegated(&["ns1.nic.tg", "ns2.nic.tg", "ns4.nic.tg"]),
);
let LookupResult::Whois {
dns_nameservers: Some(ns),
..
} = result
else {
panic!("expected a DNS delegation on {result:?}");
};
assert!(!ns.in_sync);
assert_eq!(ns.missing_from_whois, vec!["ns4.nic.tg"]);
}
#[test]
fn result_with_an_rdap_record_is_not_cross_checked() {
assert!(!wants_dns_nameservers(&whois_result(
&[],
Some(Box::default())
)));
let rdap = LookupResult::Rdap {
data: Box::default(),
whois_fallback: None,
};
assert!(!wants_dns_nameservers(&rdap));
let result = with_dns_nameservers(
whois_result(&[], Some(Box::default())),
delegated(&["ns1.x"]),
);
assert!(matches!(
result,
LookupResult::Whois {
dns_nameservers: None,
..
}
));
}
#[test]
fn failed_dns_query_leaves_the_result_unchanged() {
let result = with_dns_nameservers(
whois_result(&["ns1.nic.tg"], None),
Err(SeerError::DnsError("no parent server answered".into())),
);
assert!(matches!(
result,
LookupResult::Whois {
dns_nameservers: None,
..
}
));
}
#[test]
fn dns_nameservers_round_trip_and_stay_optional() {
let old = r#"{"source":"whois","data":{"domain":"nic.tg","registrar":null,"registrant":null,"organization":null,"creation_date":null,"expiration_date":null,"updated_date":null,"nameservers":[],"status":[],"dnssec":null,"whois_server":"whois.nic.tg"},"rdap_error":null}"#;
let parsed: LookupResult = serde_json::from_str(old).expect("old shape deserializes");
assert!(matches!(
parsed,
LookupResult::Whois {
dns_nameservers: None,
..
}
));
let with = with_dns_nameservers(whois_result(&["a.x"], None), delegated(&["a.x"]));
let json = serde_json::to_string(&with).unwrap();
assert!(json.contains("\"dns_nameservers\""), "{json}");
let back: LookupResult = serde_json::from_str(&json).unwrap();
assert!(matches!(
back,
LookupResult::Whois {
dns_nameservers: Some(_),
..
}
));
}
}