use std::collections::HashSet;
use std::time::Duration;
use regex::Regex;
use std::sync::LazyLock;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::time::timeout;
use tracing::{debug, instrument, warn};
use super::parser::{field_patterns, WhoisResponse};
use super::servers::{get_tld, get_whois_server_for_domain, has_no_whois_server};
use crate::cache::TtlCache;
use crate::error::{Result, SeerError};
use crate::retry::{RetryExecutor, RetryPolicy};
use crate::validation::normalize_domain;
static REFERRAL_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
let mut patterns = field_patterns(&["Registrar WHOIS Server", "Whois Server"]);
patterns.push(
Regex::new(r"(?i)ReferralServer:[ \t]*whois://(.+)")
.expect("Invalid regex pattern for ReferralServer"),
);
patterns
});
const WHOIS_PORT: u16 = 43;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15); const MAX_RESPONSE_SIZE: usize = 1024 * 1024; const MAX_REFERRAL_DEPTH: u8 = 3;
const IANA_WHOIS_SERVER: &str = "whois.iana.org";
const SERVER_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
const NO_SERVER_CACHE_TTL: Duration = Duration::from_secs(60 * 60);
#[derive(Debug, Clone)]
enum Discovery {
Server(String),
NoServer(String),
}
static DISCOVERED_SERVERS: LazyLock<TtlCache<String, Discovery>> =
LazyLock::new(|| TtlCache::new(SERVER_CACHE_TTL));
#[derive(Debug, Clone)]
pub struct WhoisClient {
timeout: Duration,
retry_policy: RetryPolicy,
port: u16,
allow_private_hosts: bool,
iana_server: &'static str,
}
impl Default for WhoisClient {
fn default() -> Self {
Self::new()
}
}
impl WhoisClient {
pub fn new() -> Self {
Self {
timeout: DEFAULT_TIMEOUT,
retry_policy: RetryPolicy::default(),
port: WHOIS_PORT,
allow_private_hosts: false,
iana_server: IANA_WHOIS_SERVER,
}
}
pub fn from_config(config: &crate::config::SeerConfig) -> Self {
Self::new().with_timeout(config.whois_timeout())
}
#[cfg(test)]
pub(crate) fn allowing_private_hosts(mut self) -> Self {
self.allow_private_hosts = true;
self
}
#[cfg(test)]
pub(crate) fn with_port(mut self, port: u16) -> Self {
self.port = port;
self
}
#[cfg(test)]
fn with_iana_server(mut self, server: &'static str) -> Self {
self.iana_server = server;
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
self.retry_policy = policy;
self
}
pub fn without_retries(mut self) -> Self {
self.retry_policy = RetryPolicy::no_retry();
self
}
#[instrument(skip(self), fields(domain = %domain))]
pub async fn lookup(&self, domain: &str) -> Result<WhoisResponse> {
let start = std::time::Instant::now();
let domain = normalize_domain(domain)?;
let whois_server = match get_whois_server_for_domain(&domain) {
Some(server) => server.to_string(),
None => self.discover_whois_server(get_tld(&domain)).await?,
};
let mut visited = HashSet::new();
let result = self
.lookup_with_referrals(&domain, &whois_server, 0, &mut visited)
.await;
let elapsed_ms = start.elapsed().as_millis();
debug!(
domain = %domain,
elapsed_ms = elapsed_ms,
"WHOIS lookup complete"
);
result
}
fn lookup_with_referrals<'a>(
&'a self,
domain: &'a str,
whois_server: &'a str,
depth: u8,
visited: &'a mut HashSet<String>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<WhoisResponse>> + Send + 'a>>
{
Box::pin(async move {
if depth >= MAX_REFERRAL_DEPTH {
warn!(depth = depth, server = %whois_server, "Max referral depth reached before query, aborting referral chain");
return Err(SeerError::WhoisError(
"max WHOIS referral depth exceeded".to_string(),
));
}
let server_lower = whois_server.to_lowercase();
if visited.contains(&server_lower) {
warn!(server = %whois_server, "Circular WHOIS referral detected");
return Err(SeerError::WhoisError(
"circular WHOIS referral detected".to_string(),
));
}
visited.insert(server_lower);
debug!(whois_server = %whois_server, depth = depth, "Querying WHOIS server");
let policy = if depth == 0 {
self.retry_policy.clone()
} else {
RetryPolicy::no_retry()
};
let query = format_query_for_server(whois_server, domain);
let raw_response = self
.query_server_with_retry(whois_server, &query, &policy)
.await?;
let current_response = WhoisResponse::parse(domain, whois_server, &raw_response);
if current_response.has_core_data() {
debug!(
server = %whois_server,
"Registry response has core data, skipping registrar referral"
);
return Ok(current_response);
}
if let Some(referral) = extract_referral_with(&raw_response, self.allow_private_hosts) {
if referral != whois_server && !visited.contains(&referral.to_lowercase()) {
debug!(
referral_depth = depth,
"Registry response lacks core data, following referral to {}", referral
);
match self
.lookup_with_referrals(domain, &referral, depth + 1, visited)
.await
{
Ok(referral_response) => {
if referral_response.is_available()
|| referral_response.indicates_not_found()
{
debug!(
referral = %referral,
"Referral server indicates domain not found, using registry response"
);
return Ok(current_response);
}
if !referral_response.has_registration_fields()
&& current_response.has_any_registry_field()
{
debug!(
referral = %referral,
"Referral response carries no registration data, using registry response"
);
return Ok(current_response);
}
return Ok(referral_response);
}
Err(e) => {
debug!(referral = %referral, error = %e, "Referral lookup failed, using registry response");
return Ok(current_response);
}
}
}
}
Ok(current_response)
})
}
#[instrument(skip(self), fields(domain = %domain, server = %server))]
pub async fn lookup_with_server(&self, domain: &str, server: &str) -> Result<WhoisResponse> {
if server.contains('\r') || server.contains('\n') {
return Err(SeerError::WhoisError(format!(
"invalid WHOIS server: contains illegal characters: {}",
server.replace('\r', "\\r").replace('\n', "\\n")
)));
}
if !self.allow_private_hosts && !is_safe_whois_server(server) {
return Err(SeerError::WhoisError(format!(
"invalid WHOIS server: {}",
server
)));
}
let domain = normalize_domain(domain)?;
let query = format_query_for_server(server, &domain);
let raw_response = self
.query_server_with_retry(server, &query, &self.retry_policy)
.await?;
Ok(WhoisResponse::parse(&domain, server, &raw_response))
}
async fn query_server_with_retry(
&self,
server: &str,
query: &str,
policy: &RetryPolicy,
) -> Result<String> {
let executor = RetryExecutor::new(policy.clone());
let server = server.to_string();
let query = query.to_string();
let timeout_duration = self.timeout;
let port = self.port;
let allow_private_hosts = self.allow_private_hosts;
executor
.execute(|| {
let server = server.clone();
let query = query.clone();
async move {
query_server_internal_with(
&server,
&query,
timeout_duration,
port,
allow_private_hosts,
)
.await
}
})
.await
}
async fn discover_whois_server(&self, tld: &str) -> Result<String> {
if has_no_whois_server(tld) {
return Err(SeerError::WhoisServerNotFound(format!(
"No WHOIS server for '.{tld}' (the registry offers no port-43 WHOIS; use RDAP)"
)));
}
let discovery = match DISCOVERED_SERVERS.get(&tld.to_string()) {
Some(cached) => {
debug!(tld = %tld, "Using cached IANA discovery");
cached
}
None => {
debug!(tld = %tld, "Querying IANA for WHOIS server");
let discovery = self.query_iana(tld).await?;
let ttl = match discovery {
Discovery::Server(_) => SERVER_CACHE_TTL,
Discovery::NoServer(_) => NO_SERVER_CACHE_TTL,
};
DISCOVERED_SERVERS.insert_with_ttl(tld.to_string(), discovery.clone(), ttl);
discovery
}
};
match discovery {
Discovery::Server(server) => Ok(server),
Discovery::NoServer(msg) => Err(SeerError::WhoisServerNotFound(msg)),
}
}
async fn query_iana(&self, tld: &str) -> Result<Discovery> {
let response = self
.query_server_with_retry(self.iana_server, tld, &self.retry_policy)
.await?;
if let Some(server) = extract_iana_whois_server(&response) {
if !self.allow_private_hosts && !is_safe_whois_server(&server) {
warn!(server = %server, "IANA returned unsafe WHOIS server, rejecting");
return Err(SeerError::WhoisError(format!(
"IANA returned unsafe WHOIS server: {}",
server
)));
}
if !self.allow_private_hosts {
crate::net::validate_public_host(&server, WHOIS_PORT).await?;
}
return Ok(Discovery::Server(server));
}
let msg = match extract_iana_registration_url(&response) {
Some(url) => format!("No WHOIS server for '.{tld}' - check whois directly via: {url}"),
None => format!("No WHOIS server found for TLD '{tld}'"),
};
if is_iana_record(&response) {
Ok(Discovery::NoServer(msg))
} else {
Err(SeerError::WhoisServerNotFound(msg))
}
}
}
fn is_iana_record(response: &str) -> bool {
response.lines().any(|line| {
let line = line.trim_start().to_ascii_lowercase();
line.starts_with("domain:") || line.contains("returned 0 objects")
})
}
fn format_query_for_server(server: &str, domain: &str) -> String {
match server.to_ascii_lowercase().as_str() {
"whois.denic.de" => format!("-T dn,ace {}", domain),
"whois.jprs.jp" => format!("{}/e", domain),
_ => domain.to_string(),
}
}
#[cfg(test)]
async fn query_server_internal(
server: &str,
query: &str,
timeout_duration: Duration,
) -> Result<String> {
query_server_internal_with(server, query, timeout_duration, WHOIS_PORT, false).await
}
async fn query_server_internal_with(
server: &str,
query: &str,
timeout_duration: Duration,
port: u16,
allow_private_hosts: bool,
) -> Result<String> {
if query.bytes().any(|b| b == 0 || b == b'\r' || b == b'\n') {
return Err(SeerError::WhoisError(
"query string must not contain CR/LF/NUL".into(),
));
}
let addrs: Vec<std::net::SocketAddr> = if allow_private_hosts {
tokio::net::lookup_host((server, port))
.await
.map_err(|e| SeerError::WhoisError(format!("failed to resolve {}: {}", server, e)))?
.collect()
} else {
crate::net::resolve_public_host(server, port).await?
};
debug!("WHOIS query to {}", server);
let mut stream = connect_ipv4_first(server, addrs, timeout_duration).await?;
let query_bytes = format!("{}\r\n", query);
timeout(timeout_duration, stream.write_all(query_bytes.as_bytes()))
.await
.map_err(|_| SeerError::Timeout("write timed out".to_string()))?
.map_err(|e| SeerError::WhoisError(format!("failed to send query: {}", e)))?;
let mut response = Vec::new();
let mut buf = [0u8; 4096];
let deadline = tokio::time::Instant::now() + timeout_duration;
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
if !response.is_empty() {
tracing::warn!(
"WHOIS read deadline reached with partial response ({} bytes)",
response.len()
);
break;
}
return Err(SeerError::Timeout("read timed out".to_string()));
}
let read_result = timeout(remaining, stream.read(&mut buf)).await;
match read_result {
Ok(Ok(0)) => break, Ok(Ok(n)) => {
response.extend_from_slice(&buf[..n]);
if response.len() > MAX_RESPONSE_SIZE {
return Err(SeerError::WhoisError("response too large".to_string()));
}
}
Ok(Err(e)) => {
return Err(SeerError::WhoisError(format!("read error: {}", e)));
}
Err(_) => {
if !response.is_empty() {
tracing::warn!(
"WHOIS per-read timeout with partial response ({} bytes)",
response.len()
);
break;
}
return Err(SeerError::Timeout("read timed out".to_string()));
}
}
}
let _ = stream.shutdown().await;
Ok(decode_whois_body(response))
}
async fn connect_ipv4_first(
server: &str,
addrs: Vec<std::net::SocketAddr>,
budget: Duration,
) -> Result<TcpStream> {
crate::net::connect_any(&addrs, budget, TcpStream::connect)
.await
.map_err(|e| match e {
crate::net::ConnectError::Io(e) => {
SeerError::WhoisConnectionFailed(format!("failed to connect to {server}: {e}"))
}
crate::net::ConnectError::TimedOut => {
SeerError::Timeout(format!("connection to {server} timed out"))
}
})
}
fn decode_whois_body(bytes: Vec<u8>) -> String {
const CP1252_80_9F: [char; 32] = [
'\u{20AC}', '\u{FFFD}', '\u{201A}', '\u{0192}', '\u{201E}', '\u{2026}', '\u{2020}',
'\u{2021}', '\u{02C6}', '\u{2030}', '\u{0160}', '\u{2039}', '\u{0152}', '\u{FFFD}',
'\u{017D}', '\u{FFFD}', '\u{FFFD}', '\u{2018}', '\u{2019}', '\u{201C}', '\u{201D}',
'\u{2022}', '\u{2013}', '\u{2014}', '\u{02DC}', '\u{2122}', '\u{0161}', '\u{203A}',
'\u{0153}', '\u{FFFD}', '\u{017E}', '\u{0178}',
];
match String::from_utf8(bytes) {
Ok(text) => text,
Err(e) => {
let bytes = e.into_bytes();
let mut text = String::with_capacity(bytes.len() + bytes.len() / 2);
for chunk in bytes.utf8_chunks() {
text.push_str(chunk.valid());
text.extend(chunk.invalid().iter().map(|&b| match b {
0x80..=0x9F => CP1252_80_9F[usize::from(b - 0x80)],
_ => char::from(b),
}));
}
text
}
}
}
fn extract_iana_whois_server(response: &str) -> Option<String> {
for line in response.lines() {
let line = line.trim();
if line.to_lowercase().starts_with("whois:") {
let server = line[6..].trim();
if !server.is_empty() {
return Some(server.to_lowercase());
}
}
}
None
}
fn extract_iana_registration_url(response: &str) -> Option<String> {
for line in response.lines() {
let line = line.trim();
if line.to_lowercase().starts_with("remarks:") {
let remarks = line[8..].trim();
if let Some(url_start) = remarks.find("http") {
let url = &remarks[url_start..];
let url_end = url.find(char::is_whitespace).unwrap_or(url.len());
return Some(url[..url_end].to_string());
}
}
}
None
}
fn is_safe_whois_server(server: &str) -> bool {
if server.is_empty() || !server.contains('.') {
return false;
}
if !server
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
{
return false;
}
if let Ok(ip) = server.parse::<std::net::IpAddr>() {
return !crate::net::is_reserved_ip(ip);
}
true
}
#[cfg(test)]
fn extract_referral(response: &str) -> Option<String> {
extract_referral_with(response, false)
}
fn extract_referral_with(response: &str, allow_private_hosts: bool) -> Option<String> {
for re in REFERRAL_PATTERNS.iter() {
if let Some(caps) = re.captures(response) {
if let Some(m) = caps.get(1) {
let server = m.as_str().trim().to_lowercase();
if allow_private_hosts || is_safe_whois_server(&server) {
return Some(server);
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_config_applies_whois_timeout() {
let mut config = crate::config::SeerConfig::default();
config.timeouts.whois_secs = 42;
let client = WhoisClient::from_config(&config);
assert_eq!(client.timeout, Duration::from_secs(42));
}
#[test]
fn test_normalize_domain() {
assert_eq!(normalize_domain("example.com").unwrap(), "example.com");
assert_eq!(normalize_domain("EXAMPLE.COM").unwrap(), "example.com");
assert_eq!(
normalize_domain("https://www.example.com/path").unwrap(),
"example.com"
);
assert!(normalize_domain("invalid").is_err());
}
#[test]
fn test_default_client_has_retry_policy() {
let client = WhoisClient::new();
assert_eq!(client.retry_policy.max_attempts, 3);
}
#[test]
fn extract_referral_same_line_value() {
let response = "Registrar WHOIS Server: whois.registrar.example\n";
assert_eq!(
extract_referral(response),
Some("whois.registrar.example".to_string())
);
}
#[test]
fn extract_referral_empty_key_line_does_not_capture_next_line() {
let response = "Registrar WHOIS Server:\nwhois.victim.example\n";
assert_eq!(
extract_referral(response),
None,
"empty referral key line must not swallow the next line"
);
}
#[test]
fn decode_whois_body_keeps_utf8_and_decodes_legacy_bytes_as_cp1252() {
assert_eq!(
decode_whois_body("Registrant: 東京ドメイン\n".as_bytes().to_vec()),
"Registrant: 東京ドメイン\n"
);
assert_eq!(
decode_whois_body(b"Registrant: M\xfcller Stra\xdfe, K\xf8benhavn\n".to_vec()),
"Registrant: Müller Straße, København\n"
);
let mut mixed = "Name Server: ns1.例え.jp ".as_bytes().to_vec();
mixed.push(0xE9);
mixed.extend_from_slice(" 東京\n".as_bytes());
assert_eq!(
decode_whois_body(mixed),
"Name Server: ns1.例え.jp é 東京\n"
);
let decoded = decode_whois_body(b"\x93quoted\x94 \x80 \x81".to_vec());
assert_eq!(decoded, "\u{201C}quoted\u{201D} \u{20AC} \u{FFFD}");
assert!(!decoded.chars().any(|c| ('\u{80}'..='\u{9F}').contains(&c)));
}
#[test]
fn test_client_without_retries() {
let client = WhoisClient::new().without_retries();
assert_eq!(client.retry_policy.max_attempts, 1);
}
#[test]
fn test_client_custom_retry_policy() {
let policy = RetryPolicy::new().with_max_attempts(5);
let client = WhoisClient::new().with_retry_policy(policy);
assert_eq!(client.retry_policy.max_attempts, 5);
}
#[tokio::test]
async fn query_server_internal_rejects_crlf_in_query() {
let err = query_server_internal(
"whois.example.com",
"example.com\r\nWHOIS evil.example",
Duration::from_secs(1),
)
.await
.expect_err("CRLF in query must be rejected");
match err {
SeerError::WhoisError(msg) => {
assert!(msg.contains("CR/LF/NUL"), "unexpected message: {msg}");
}
other => panic!("expected WhoisError, got {other:?}"),
}
assert!(
query_server_internal("whois.example.com", "a\nb", Duration::from_secs(1))
.await
.is_err()
);
assert!(
query_server_internal("whois.example.com", "a\0b", Duration::from_secs(1))
.await
.is_err()
);
}
#[test]
fn iana_discovery_rejects_unsafe_server() {
let synthetic = "refer: whois.iana.org\nwhois: 127.0.0.1\n";
let extracted = extract_iana_whois_server(synthetic);
assert_eq!(extracted.as_deref(), Some("127.0.0.1"));
assert!(
!is_safe_whois_server(&extracted.unwrap()),
"is_safe_whois_server must reject 127.0.0.1"
);
assert!(!is_safe_whois_server("whois.evil.com\r\nevil"));
assert!(!is_safe_whois_server("whois.evil.com:4444"));
assert!(is_safe_whois_server("whois.nic.xyz"));
}
#[tokio::test]
async fn whois_refuses_loopback_server() {
let err = query_server_internal("127.0.0.1", "example.com", Duration::from_secs(1))
.await
.expect_err("loopback server must be rejected");
match err {
SeerError::InvalidInput(msg) => {
assert!(
msg.contains("reserved") || msg.contains("127.0.0.1"),
"unexpected message: {msg}"
);
}
other => panic!("expected InvalidInput, got {other:?}"),
}
}
#[tokio::test]
async fn whois_refuses_rfc1918_server() {
let err = query_server_internal("10.0.0.1", "example.com", Duration::from_secs(1))
.await
.expect_err("RFC1918 server must be rejected");
match err {
SeerError::InvalidInput(msg) => {
assert!(
msg.contains("reserved") || msg.contains("10.0.0.1"),
"unexpected message: {msg}"
);
}
other => panic!("expected InvalidInput, got {other:?}"),
}
}
#[tokio::test]
async fn whois_refuses_link_local_metadata_server() {
let err = query_server_internal("169.254.169.254", "example.com", Duration::from_secs(1))
.await
.expect_err("link-local metadata server must be rejected");
assert!(matches!(err, SeerError::InvalidInput(_)));
}
async fn spawn_mock_whois(responses: Vec<&'static str>) -> u16 {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
for response in responses {
let Ok((mut sock, _)) = listener.accept().await else {
return;
};
let mut buf = [0u8; 512];
let _ = sock.read(&mut buf).await; let _ = sock.write_all(response.as_bytes()).await;
}
});
port
}
fn mock_client(port: u16) -> WhoisClient {
WhoisClient::new()
.without_retries()
.allowing_private_hosts()
.with_port(port)
}
async fn spawn_mock_whois_capturing(
response: &'static str,
) -> (u16, tokio::sync::oneshot::Receiver<String>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let (tx, rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let Ok((mut sock, _)) = listener.accept().await else {
return;
};
let mut buf = [0u8; 512];
let n = sock.read(&mut buf).await.unwrap_or(0);
let _ = tx.send(String::from_utf8_lossy(&buf[..n]).into_owned());
let _ = sock.write_all(response.as_bytes()).await;
});
(port, rx)
}
#[tokio::test]
async fn mock_idn_domain_is_queried_as_punycode() {
let (port, rx) = spawn_mock_whois_capturing(
"Domain Name: XN--E1AFMKFD.XN--P1AI\nRegistrar: Mock IDN Registrar\n",
)
.await;
let resp = mock_client(port)
.lookup_with_server("пример.рф", "127.0.0.1")
.await
.unwrap();
assert_eq!(
rx.await.unwrap(),
"xn--e1afmkfd.xn--p1ai\r\n",
"wire query must be the A-label form, never raw Unicode"
);
assert_eq!(resp.domain, "xn--e1afmkfd.xn--p1ai");
assert_eq!(resp.registrar.as_deref(), Some("Mock IDN Registrar"));
}
#[tokio::test]
async fn mock_basic_lookup_parses_canned_response() {
let port = spawn_mock_whois(vec![
"Domain Name: EXAMPLE.COM\nRegistrar: Mock Registrar Inc.\nCreation Date: 2020-01-01T00:00:00Z\n",
])
.await;
let resp = mock_client(port)
.lookup_with_server("example.com", "127.0.0.1")
.await
.unwrap();
assert_eq!(resp.registrar.as_deref(), Some("Mock Registrar Inc."));
}
#[tokio::test]
async fn mock_referral_is_followed_when_registry_response_is_thin() {
let port = spawn_mock_whois(vec![
"Registry data follows.\nRegistrar WHOIS Server: localhost\n",
"Domain Name: EXAMPLE.COM\nRegistrar: Mock Registrar Two\nCreation Date: 2021-02-02T00:00:00Z\n",
])
.await;
let client = mock_client(port);
let mut visited = HashSet::new();
let resp = client
.lookup_with_referrals("example.com", "127.0.0.1", 0, &mut visited)
.await
.unwrap();
assert_eq!(resp.registrar.as_deref(), Some("Mock Registrar Two"));
assert_eq!(resp.whois_server, "localhost");
}
#[tokio::test]
async fn mock_refusing_referral_does_not_replace_registry_data() {
let port = spawn_mock_whois(vec![
"Domain Name: EXAMPLE.COM\n\
Registrar WHOIS Server: localhost\n\
Registrar: Registry-Listed Registrar, Inc.\n\
Creation Date: 2020-01-01T00:00:00Z\n\
Registry Expiry Date: 2030-01-01T00:00:00Z\n\
Domain Status: serverHold https://icann.org/epp#serverHold\n",
"Your connection limit exceeded. Please slow down and try again later.\n",
])
.await;
let client = mock_client(port);
let mut visited = HashSet::new();
let resp = client
.lookup_with_referrals("example.com", "127.0.0.1", 0, &mut visited)
.await
.unwrap();
assert_eq!(
resp.registrar.as_deref(),
Some("Registry-Listed Registrar, Inc.")
);
assert!(resp.creation_date.is_some() && resp.expiration_date.is_some());
assert_eq!(resp.status, vec!["serverHold"]);
assert_eq!(resp.whois_server, "127.0.0.1", "registry response kept");
assert!(
visited.contains("localhost"),
"the referral was still consulted"
);
}
#[tokio::test]
async fn mock_circular_referral_terminates_with_last_response() {
let port = spawn_mock_whois(vec![
"Registry data follows.\nRegistrar WHOIS Server: localhost\n",
"Registrar data follows.\nRegistrar WHOIS Server: 127.0.0.1\n",
])
.await;
let client = mock_client(port);
let mut visited = HashSet::new();
let resp = client
.lookup_with_referrals("example.com", "127.0.0.1", 0, &mut visited)
.await
.unwrap();
assert_eq!(resp.whois_server, "localhost");
}
#[tokio::test]
async fn mock_silent_server_times_out() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
let Ok((_sock, _)) = listener.accept().await else {
return;
};
tokio::time::sleep(Duration::from_secs(10)).await;
});
let client = mock_client(port).with_timeout(Duration::from_millis(200));
let err = client
.lookup_with_server("example.com", "127.0.0.1")
.await
.unwrap_err();
assert!(matches!(err, SeerError::Timeout(_)), "got: {err:?}");
}
async fn spawn_counting_whois(
responses: Vec<Option<&'static str>>,
) -> (u16, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
use std::sync::atomic::{AtomicUsize, Ordering};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let accepted = std::sync::Arc::new(AtomicUsize::new(0));
let counter = accepted.clone();
tokio::spawn(async move {
let mut responses = responses.into_iter();
while let Ok((mut sock, _)) = listener.accept().await {
counter.fetch_add(1, Ordering::SeqCst);
let response = responses.next().flatten();
tokio::spawn(async move {
let mut buf = [0u8; 512];
let _ = sock.read(&mut buf).await;
match response {
Some(r) => {
let _ = sock.write_all(r.as_bytes()).await;
}
None => tokio::time::sleep(Duration::from_secs(10)).await,
}
});
}
});
(port, accepted)
}
#[tokio::test]
async fn catalogued_no_whois_tlds_short_circuit_before_discovery() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let client = mock_client(port).with_iana_server("127.0.0.1");
for domain in ["example.app", "example.netflix", "example.mt"] {
let err = client.lookup(domain).await.unwrap_err();
assert!(
matches!(err, SeerError::WhoisServerNotFound(_)),
"{domain}: {err:?}"
);
}
assert_eq!(
get_whois_server_for_domain("example.co.za"),
Some("whois.registry.net.za")
);
}
#[tokio::test]
async fn definitive_iana_no_server_answer_is_negative_cached() {
use std::sync::atomic::Ordering;
let (port, accepted) = spawn_counting_whois(vec![Some(
"% IANA WHOIS server\n\ndomain: SEERNEGCACHE\n\nstatus: ACTIVE\n",
)])
.await;
let client = mock_client(port).with_iana_server("127.0.0.1");
for _ in 0..2 {
let err = client.lookup("example.seernegcache").await.unwrap_err();
assert!(matches!(err, SeerError::WhoisServerNotFound(_)), "{err:?}");
}
assert_eq!(
accepted.load(Ordering::SeqCst),
1,
"second lookup hit the cache"
);
}
#[tokio::test]
async fn non_definitive_iana_reply_is_not_cached() {
use std::sync::atomic::Ordering;
let (port, accepted) = spawn_counting_whois(vec![
Some("% Query rate limit exceeded, try again later\n"),
Some("% IANA WHOIS server\n\ndomain: SEERTRANSIENT\n"),
])
.await;
let client = mock_client(port).with_iana_server("127.0.0.1");
for _ in 0..3 {
let err = client.lookup("example.seertransient").await.unwrap_err();
assert!(matches!(err, SeerError::WhoisServerNotFound(_)), "{err:?}");
}
assert_eq!(
accepted.load(Ordering::SeqCst),
2,
"the throttle reply was retried, the IANA record then cached"
);
}
#[test]
fn iana_record_detection() {
assert!(is_iana_record(
"domain: COM\nwhois: whois.verisign-grs.com\n"
));
assert!(is_iana_record("% This query returned 0 objects.\n"));
assert!(!is_iana_record(""));
assert!(!is_iana_record("% rate limit exceeded\n"));
}
#[tokio::test]
async fn referral_hop_is_a_single_attempt() {
use std::sync::atomic::Ordering;
let (port, accepted) = spawn_counting_whois(vec![
Some("Domain Name: EXAMPLE.COM\nRegistrar WHOIS Server: localhost\n"),
None,
None,
None,
])
.await;
let client = WhoisClient::new()
.with_retry_policy(
RetryPolicy::new()
.with_max_attempts(3)
.with_initial_delay(Duration::from_millis(1))
.with_jitter(false),
)
.allowing_private_hosts()
.with_port(port)
.with_timeout(Duration::from_millis(200));
let mut visited = HashSet::new();
let resp = client
.lookup_with_referrals("example.com", "127.0.0.1", 0, &mut visited)
.await
.unwrap();
assert_eq!(resp.whois_server, "127.0.0.1", "registry response kept");
assert_eq!(
accepted.load(Ordering::SeqCst),
2,
"one registry query plus a single referral attempt"
);
}
#[tokio::test]
async fn connect_moves_past_an_unresponsive_address_within_budget() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let live = listener.local_addr().unwrap();
let addrs = vec![
std::net::SocketAddr::new("192.0.2.1".parse().unwrap(), live.port()),
live,
];
let start = std::time::Instant::now();
let stream = connect_ipv4_first("mock", addrs, Duration::from_secs(2))
.await
.expect("the live address connects");
assert_eq!(stream.peer_addr().unwrap(), live);
assert!(start.elapsed() < Duration::from_secs(2));
}
#[test]
fn format_query_for_server_registry_specific() {
assert_eq!(
format_query_for_server("whois.denic.de", "example.de"),
"-T dn,ace example.de"
);
assert_eq!(
format_query_for_server("WHOIS.DENIC.DE", "example.de"),
"-T dn,ace example.de",
"server hostname match must be case-insensitive"
);
assert_eq!(
format_query_for_server("whois.jprs.jp", "example.jp"),
"example.jp/e"
);
assert_eq!(
format_query_for_server("whois.verisign-grs.com", "example.com"),
"example.com",
"all other servers get the bare domain"
);
}
}