use crate::error::DomainCheckError;
use crate::types::{CheckMethod, DomainResult};
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::sync::Semaphore;
const WHOIS_PORT: u16 = 43;
const MAX_QUERIES_PER_SERVER: usize = 2;
const MAX_RESPONSE_BYTES: u64 = 1024 * 1024;
const IANA_WHOIS_SERVER: &str = "whois.iana.org";
const UNDETERMINED: &str = "Unable to determine domain status from WHOIS response";
const NO_WHOIS_SERVICE: &str = "the registry offers no WHOIS service";
#[derive(Clone)]
pub struct WhoisClient {
timeout: Duration,
port: u16,
}
impl WhoisClient {
pub fn new() -> Self {
Self::with_timeout(Duration::from_secs(5))
}
pub fn with_timeout(timeout: Duration) -> Self {
Self {
timeout,
port: WHOIS_PORT,
}
}
#[cfg(test)]
fn with_port(timeout: Duration, port: u16) -> Self {
Self { timeout, port }
}
pub async fn check_domain(&self, domain: &str) -> Result<DomainResult, DomainCheckError> {
let tld = crate::protocols::registry::extract_tld(domain)?;
match crate::protocols::registry::whois_server_status(&tld).await {
WhoisServer::Known(server) => self.check_domain_with_server(domain, &server).await,
WhoisServer::NoneListed => Err(no_whois_service_error(domain)),
WhoisServer::Unreachable => Err(DomainCheckError::whois(
domain,
format!("could not ask IANA for the .{tld} WHOIS server"),
)),
}
}
pub async fn check_domain_with_server(
&self,
domain: &str,
server: &str,
) -> Result<DomainResult, DomainCheckError> {
let start_time = Instant::now();
let query = query_for(server, domain);
let slots = server_slots(server, self.port);
let _slot = slots.acquire().await.ok();
let mut verdict = classify(&self.query(server, &query, domain).await?, domain);
if verdict == Verdict::RateLimited {
tokio::time::sleep(Duration::from_secs(1)).await;
verdict = classify(&self.query(server, &query, domain).await?, domain);
}
let available = match verdict {
Verdict::Taken => false,
Verdict::Available => true,
Verdict::RateLimited => {
return Err(DomainCheckError::whois(
domain,
format!("{server} rate limited the query"),
))
}
Verdict::Unknown(reason) => return Err(DomainCheckError::whois(domain, reason)),
};
Ok(DomainResult {
domain: domain.to_string(),
available: Some(available),
info: None,
check_duration: Some(start_time.elapsed()),
method_used: CheckMethod::Whois,
error_message: None,
for_sale: None,
})
}
async fn query(
&self,
server: &str,
query: &str,
domain: &str,
) -> Result<String, DomainCheckError> {
match tokio::time::timeout(self.timeout, raw_query(server, self.port, query)).await {
Ok(Ok(text)) => Ok(text),
Ok(Err(e)) => Err(DomainCheckError::whois(
domain,
format!("WHOIS query to {server} failed: {e}"),
)),
Err(_) => Err(DomainCheckError::timeout("WHOIS query", self.timeout)),
}
}
}
impl Default for WhoisClient {
fn default() -> Self {
Self::new()
}
}
fn server_slots(server: &str, port: u16) -> Arc<Semaphore> {
static SLOTS: OnceLock<Mutex<HashMap<String, Arc<Semaphore>>>> = OnceLock::new();
let mut slots = SLOTS
.get_or_init(Default::default)
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
slots
.entry(format!("{}:{port}", server.to_ascii_lowercase()))
.or_insert_with(|| Arc::new(Semaphore::new(MAX_QUERIES_PER_SERVER)))
.clone()
}
async fn raw_query(server: &str, port: u16, query: &str) -> std::io::Result<String> {
let attempts: Vec<_> = tokio::net::lookup_host((server, port))
.await?
.map(|addr| Box::pin(TcpStream::connect(addr)))
.collect();
if attempts.is_empty() {
return Err(std::io::Error::other(format!("{server} has no addresses")));
}
let (mut stream, _) = futures_util::future::select_ok(attempts).await?;
stream.write_all(format!("{query}\r\n").as_bytes()).await?;
let mut reply = Vec::new();
stream
.take(MAX_RESPONSE_BYTES)
.read_to_end(&mut reply)
.await?;
Ok(String::from_utf8_lossy(&reply).into_owned())
}
fn query_for(server: &str, domain: &str) -> String {
if server.eq_ignore_ascii_case("whois.jprs.jp") {
format!("{domain}/e")
} else {
domain.to_string()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum WhoisServer {
Known(String),
NoneListed,
Unreachable,
}
pub(crate) async fn discover(tld: &str) -> WhoisServer {
match tokio::time::timeout(
Duration::from_secs(10),
raw_query(IANA_WHOIS_SERVER, WHOIS_PORT, tld),
)
.await
{
Ok(Ok(reply)) => parse_iana_discovery(&reply),
_ => WhoisServer::Unreachable,
}
}
fn parse_iana_discovery(reply: &str) -> WhoisServer {
if let Some(server) = parse_iana_refer_response(reply) {
return WhoisServer::Known(server);
}
let is_record = reply
.lines()
.any(|l| l.trim_start().to_lowercase().starts_with("domain:"));
if is_record {
WhoisServer::NoneListed
} else {
WhoisServer::Unreachable
}
}
pub(crate) fn no_whois_service_error(domain: &str) -> DomainCheckError {
DomainCheckError::whois(domain, NO_WHOIS_SERVICE)
}
pub(crate) fn is_no_whois_service(error: &DomainCheckError) -> bool {
matches!(error, DomainCheckError::WhoisError { message, .. } if message == NO_WHOIS_SERVICE)
}
fn parse_iana_refer_response(response: &str) -> Option<String> {
let mut whois_server = None;
for line in response.lines() {
let line_trimmed = line.trim();
if let Some(server) = line_trimmed.strip_prefix("refer:") {
let server = server.trim();
if !server.is_empty() {
return Some(server.to_string());
}
} else if let Some(server) = line_trimmed.strip_prefix("whois:") {
let server = server.trim();
if !server.is_empty() {
whois_server = Some(server.to_string());
}
}
}
whois_server
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Verdict {
Taken,
Available,
RateLimited,
Unknown(&'static str),
}
const CREATED: u8 = 1;
const EXPIRES: u8 = 1 << 1;
const REGISTRAR: u8 = 1 << 3;
const REGISTRANT: u8 = 1 << 4;
const NAME_SERVERS: u8 = 1 << 5;
const STATUS: u8 = 1 << 6;
const FREE_PHRASES: &[&str] = &[
"no match",
"not found",
"no matching record",
"no matching entry",
"no entries found",
"no data found",
"no data was found",
"no information available about",
"nothing found",
"no such domain",
"does not exist",
"not registered",
"has not been registered",
"is available for registration",
"domain available",
"object_not_found",
"no object found",
];
const RATE_LIMIT_PHRASES: &[&str] = &[
"rate limit exceeded",
"query rate limit",
"maximum query rate",
"query rate reached",
"too many queries",
"too many requests",
"quota exceeded",
"limit exceeded",
"queries exceeded",
"number of allowed queries",
"try again later",
"rate-limited",
];
const REFUSED_PHRASES: &[&str] = &[
"not permitted",
"access denied",
"not authorised",
"not authorized",
"refused",
];
const MAX_MESSAGE_LINE: usize = 100;
pub(crate) fn classify(response: &str, domain: &str) -> Verdict {
let text = response.to_lowercase().replace('\t', " ");
let domain = domain.trim_end_matches('.').to_lowercase();
let mut evidence = 0u8;
let mut echoed = false;
let mut free_status = false;
for line in text.lines().map(str::trim) {
if is_comment(line) {
continue;
}
let Some((key, value)) = split_field(line) else {
continue;
};
if is_status_key(key) && is_free_value(value) {
free_status = true;
} else if is_domain_key(key) {
echoed |= value
.split_whitespace()
.next()
.map(|v| v.trim_end_matches('.'))
== Some(domain.as_str());
} else {
evidence |= category(key, value);
}
}
if evidence.count_ones() >= 2 || (echoed && evidence != 0) {
return Verdict::Taken;
}
let messages: Vec<&str> = text
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && l.len() <= MAX_MESSAGE_LINE)
.collect();
let says = |phrases: &[&str]| {
messages
.iter()
.any(|l| phrases.iter().any(|p| l.contains(p)))
};
if says(RATE_LIMIT_PHRASES) {
return Verdict::RateLimited;
}
let free_phrase = format!("{domain} is free");
if evidence == 0
&& (free_status || says(FREE_PHRASES) || messages.iter().any(|l| l.contains(&free_phrase)))
{
return Verdict::Available;
}
let refused = messages
.iter()
.filter(|l| !is_comment(l))
.any(|l| REFUSED_PHRASES.iter().any(|p| l.contains(p)));
if refused {
return Verdict::Unknown("WHOIS server refused the query");
}
if text.trim().is_empty() {
return Verdict::Unknown("WHOIS server sent an empty reply");
}
Verdict::Unknown(UNDETERMINED)
}
fn is_comment(line: &str) -> bool {
line.starts_with('%') || line.starts_with('#') || line.starts_with(">>>")
}
fn split_field(line: &str) -> Option<(&str, &str)> {
let (key, value) = if let Some(rest) = line.strip_prefix('[') {
rest.split_once(']')?
} else {
line.split_once(':')?
};
let key = key.trim();
if key.is_empty() || key.len() > 40 || key.starts_with("http") {
return None;
}
Some((key, value.trim()))
}
fn is_domain_key(key: &str) -> bool {
matches!(key, "domain" | "domain name" | "domainname" | "domain-name")
}
fn is_status_key(key: &str) -> bool {
key == "status" || key.ends_with(" status") || key == "domaintype"
}
fn is_free_value(value: &str) -> bool {
value.starts_with("available")
|| value.starts_with("free")
|| value.starts_with("not registered")
|| value.starts_with("no object found")
}
fn category(key: &str, value: &str) -> u8 {
let has_value = !value.is_empty();
let header = |k: &str| key == k;
if is_status_key(key) && has_value {
STATUS
} else if (key.contains("creat")
|| key == "registered"
|| key.contains("registration date")
|| key.contains("registration time"))
&& has_value
{
CREATED
} else if (key.contains("expir") || key.contains("paid-till") || key.contains("renewal"))
&& has_value
{
EXPIRES
} else if key.starts_with("registrar") || key.starts_with("sponsoring registrar") {
if has_value || header("registrar") {
REGISTRAR
} else {
0
}
} else if key.contains("registrant") || key == "holder" || key.starts_with("owner") {
if has_value || header("registrant") {
REGISTRANT
} else {
0
}
} else if key.contains("nserver") || key.contains("name server") || key.contains("nameserver") {
NAME_SERVERS
} else {
0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_whois_client_new() {
let client = WhoisClient::new();
assert_eq!(client.timeout, Duration::from_secs(5));
}
#[test]
fn test_whois_client_with_timeout() {
let client = WhoisClient::with_timeout(Duration::from_secs(10));
assert_eq!(client.timeout, Duration::from_secs(10));
}
#[test]
fn test_whois_client_default() {
let client = WhoisClient::default();
assert_eq!(client.timeout, Duration::from_secs(5));
}
macro_rules! fixture {
($file:literal) => {
include_str!(concat!("../../tests/fixtures/whois/", $file))
};
}
fn taken_domain(tld: &str) -> String {
match tld {
"uk" => "google.co.uk".into(),
"il" => "google.co.il".into(),
"nz" => "stuff.co.nz".into(),
_ => format!("google.{tld}"),
}
}
fn free_domain(tld: &str) -> String {
match tld {
"il" => "zq7x9k2m4ptest.co.il".into(),
_ => format!("zq7x9k2m4ptest.{tld}"),
}
}
const FIXTURES: &[(&str, &str, &str)] = &[
("ai", fixture!("ai.taken.txt"), fixture!("ai.free.txt")),
("at", fixture!("at.taken.txt"), fixture!("at.free.txt")),
("be", fixture!("be.taken.txt"), fixture!("be.free.txt")),
("bg", fixture!("bg.taken.txt"), fixture!("bg.free.txt")),
("cl", fixture!("cl.taken.txt"), fixture!("cl.free.txt")),
("cn", fixture!("cn.taken.txt"), fixture!("cn.free.txt")),
("co", fixture!("co.taken.txt"), fixture!("co.free.txt")),
("com", fixture!("com.taken.txt"), fixture!("com.free.txt")),
("cz", fixture!("cz.taken.txt"), fixture!("cz.free.txt")),
("de", fixture!("de.taken.txt"), fixture!("de.free.txt")),
("dk", fixture!("dk.taken.txt"), fixture!("dk.free.txt")),
("eu", fixture!("eu.taken.txt"), fixture!("eu.free.txt")),
("fi", fixture!("fi.taken.txt"), fixture!("fi.free.txt")),
("fr", fixture!("fr.taken.txt"), fixture!("fr.free.txt")),
("gg", fixture!("gg.taken.txt"), fixture!("gg.free.txt")),
("hk", fixture!("hk.taken.txt"), fixture!("hk.free.txt")),
("hr", fixture!("hr.taken.txt"), fixture!("hr.free.txt")),
("hu", fixture!("hu.taken.txt"), fixture!("hu.free.txt")),
("ie", fixture!("ie.taken.txt"), fixture!("ie.free.txt")),
("il", fixture!("il.taken.txt"), fixture!("il.free.txt")),
("io", fixture!("io.taken.txt"), fixture!("io.free.txt")),
("it", fixture!("it.taken.txt"), fixture!("it.free.txt")),
("je", fixture!("je.taken.txt"), fixture!("je.free.txt")),
("jp", fixture!("jp.taken.txt"), fixture!("jp.free.txt")),
("lu", fixture!("lu.taken.txt"), fixture!("lu.free.txt")),
("me", fixture!("me.taken.txt"), fixture!("me.free.txt")),
("mx", fixture!("mx.taken.txt"), fixture!("mx.free.txt")),
("my", fixture!("my.taken.txt"), fixture!("my.free.txt")),
("net", fixture!("net.taken.txt"), fixture!("net.free.txt")),
("nl", fixture!("nl.taken.txt"), fixture!("nl.free.txt")),
("no", fixture!("no.taken.txt"), fixture!("no.free.txt")),
("nz", fixture!("nz.taken.txt"), fixture!("nz.free.txt")),
("org", fixture!("org.taken.txt"), fixture!("org.free.txt")),
("pl", fixture!("pl.taken.txt"), fixture!("pl.free.txt")),
("pt", fixture!("pt.taken.txt"), fixture!("pt.free.txt")),
("ro", fixture!("ro.taken.txt"), fixture!("ro.free.txt")),
("ru", fixture!("ru.taken.txt"), fixture!("ru.free.txt")),
("se", fixture!("se.taken.txt"), fixture!("se.free.txt")),
("sk", fixture!("sk.taken.txt"), fixture!("sk.free.txt")),
("tr", fixture!("tr.taken.txt"), fixture!("tr.free.txt")),
("uk", fixture!("uk.taken.txt"), fixture!("uk.free.txt")),
("us", fixture!("us.taken.txt"), fixture!("us.free.txt")),
("xyz", fixture!("xyz.taken.txt"), fixture!("xyz.free.txt")),
];
#[test]
fn real_taken_replies_are_taken() {
let wrong: Vec<_> = FIXTURES
.iter()
.filter_map(|(tld, taken, _)| {
let v = classify(taken, &taken_domain(tld));
(v != Verdict::Taken).then(|| format!("{tld}: {v:?}"))
})
.collect();
assert!(wrong.is_empty(), "misread taken replies: {wrong:?}");
}
#[test]
fn real_free_replies_are_available() {
let wrong: Vec<_> = FIXTURES
.iter()
.filter_map(|(tld, _, free)| {
let v = classify(free, &free_domain(tld));
(v != Verdict::Available).then(|| format!("{tld}: {v:?}"))
})
.collect();
assert!(wrong.is_empty(), "misread free replies: {wrong:?}");
}
#[test]
fn taken_reply_is_never_available_for_another_name() {
for (tld, taken, _) in FIXTURES {
assert_ne!(
classify(taken, "other-name.example"),
Verdict::Available,
"{tld}"
);
}
}
#[test]
fn refused_replies_are_unknown() {
for reply in [fixture!("ch.taken.txt"), fixture!("li.free.txt")] {
assert_eq!(
classify(reply, "google.ch"),
Verdict::Unknown("WHOIS server refused the query")
);
}
}
#[test]
fn empty_reply_is_unknown_not_available() {
assert!(matches!(classify("", "x.com"), Verdict::Unknown(_)));
assert!(matches!(classify(" \r\n", "x.com"), Verdict::Unknown(_)));
assert!(matches!(
classify("Some short text", "x.com"),
Verdict::Unknown(_)
));
}
#[test]
fn rate_limit_in_comment_is_detected() {
let lu = "% WHOIS zq7x9k2m4ptest.lu\n%% Maximum query rate reached\n";
assert_eq!(classify(lu, "zq7x9k2m4ptest.lu"), Verdict::RateLimited);
assert_eq!(
classify("Rate limit exceeded. Try again later.", "x.com"),
Verdict::RateLimited
);
}
#[test]
fn disclaimer_phrases_do_not_override_registration_data() {
let reply = "Domain Name: x.com\nRegistrar: Example\nCreation Date: 2020-01-01\n\
% If a domain is not found, it may be available.";
assert_eq!(classify(reply, "x.com"), Verdict::Taken);
}
#[test]
fn not_available_status_is_taken() {
let be = "Domain:\tx.be\nStatus:\tNOT AVAILABLE\n";
assert_eq!(classify(be, "x.be"), Verdict::Taken);
}
#[test]
fn free_status_with_echo_is_available() {
assert_eq!(
classify("Domain: x.de\nStatus: free\n", "x.de"),
Verdict::Available
);
assert_eq!(
classify(
"Domain: x.it\nStatus: AVAILABLE\n",
"x.it"
),
Verdict::Available
);
}
#[test]
fn single_field_without_echo_is_unknown() {
let reply = "Registrar: SomeRegistrar\nSome other random text that is long enough";
assert_eq!(classify(reply, "x.com"), Verdict::Unknown(UNDETERMINED));
}
#[test]
fn whois_database_timestamp_is_not_evidence() {
let reply = "No match for \"X.COM\".\n>>> Last update of whois database: 2026-10-10 <<<";
assert_eq!(classify(reply, "x.com"), Verdict::Available);
}
#[test]
fn registration_data_blocks_available_even_without_echo() {
let reply = "Domain Name: EXAMPLE.TLD\nDomain Status: ok\n\nIf the domain does not exist, contact us";
assert_ne!(classify(reply, "other.tld"), Verdict::Available);
let reply = "Registrar:\nNo match for nameserver ns1.example";
assert_ne!(classify(reply, "other.tld"), Verdict::Available);
}
#[test]
fn rate_limit_wins_over_free_phrase() {
assert_eq!(
classify(
"Your query rate limit was exceeded; object not found",
"x.com"
),
Verdict::RateLimited
);
assert_eq!(
classify(
"% Error: 101: no entries found\n% Too many queries",
"x.com"
),
Verdict::RateLimited
);
}
#[test]
fn free_status_with_update_timestamp_is_available() {
let reply = "Domain: example.be\nStatus: AVAILABLE\nLast update: 2026";
assert_eq!(classify(reply, "example.be"), Verdict::Available);
}
#[test]
fn disclaimer_rate_limit_wording_is_not_a_rate_limit() {
let reply = "Queries to the Whois services are throttled. If too many queries are received from a single IP address within a specified time, the service will begin to reject further queries for a period of time to prevent disruption of Whois service access. Abuse of the Whois system through data mining is mitigated by detecting and limiting bulk query access from single sources.";
assert_ne!(classify(reply, "x.ai"), Verdict::RateLimited);
}
#[test]
fn jprs_query_uses_english_suffix() {
assert_eq!(query_for("whois.jprs.jp", "google.jp"), "google.jp/e");
assert_eq!(query_for("whois.nic.it", "google.it"), "google.it");
}
#[tokio::test]
async fn queries_to_one_server_are_capped() {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let active = Arc::new(AtomicUsize::new(0));
let peak = Arc::new(AtomicUsize::new(0));
let (a, p) = (active.clone(), peak.clone());
tokio::spawn(async move {
loop {
let (mut sock, _) = listener.accept().await.unwrap();
let (a, p) = (a.clone(), p.clone());
tokio::spawn(async move {
let now = a.fetch_add(1, Ordering::SeqCst) + 1;
p.fetch_max(now, Ordering::SeqCst);
let mut buf = [0u8; 256];
let _ = sock.read(&mut buf).await;
tokio::time::sleep(Duration::from_millis(150)).await;
let reply = "Domain: x.test\nStatus: ok\nCreated: 2020-01-01\n";
let _ = sock.write_all(reply.as_bytes()).await;
a.fetch_sub(1, Ordering::SeqCst);
});
}
});
let client = WhoisClient::with_port(Duration::from_secs(5), port);
let checks = (0..6).map(|_| client.check_domain_with_server("x.test", "127.0.0.1"));
for result in futures_util::future::join_all(checks).await {
assert_eq!(result.unwrap().available, Some(false));
}
let peak = peak.load(Ordering::SeqCst);
assert!(
peak <= MAX_QUERIES_PER_SERVER,
"{peak} concurrent queries to one server"
);
}
#[test]
fn iana_reply_without_whois_means_none_listed() {
let dev = "% IANA WHOIS server\n\ndomain: DEV\norganisation: Charleston Road Registry Inc.\nstatus: ACTIVE\n";
assert_eq!(parse_iana_discovery(dev), WhoisServer::NoneListed);
assert_eq!(
parse_iana_discovery("refer: whois.nic.it\n\ndomain: IT\n"),
WhoisServer::Known("whois.nic.it".into())
);
}
#[test]
fn unusable_iana_reply_is_unreachable_not_none_listed() {
assert_eq!(parse_iana_discovery(""), WhoisServer::Unreachable);
assert_eq!(
parse_iana_discovery("% rate limit exceeded\n"),
WhoisServer::Unreachable
);
}
#[test]
fn no_whois_service_error_is_recognised() {
assert!(is_no_whois_service(&no_whois_service_error("x.dev")));
assert!(!is_no_whois_service(&DomainCheckError::whois(
"x.dev",
UNDETERMINED
)));
assert!(!is_no_whois_service(&DomainCheckError::timeout(
"WHOIS query",
Duration::from_secs(5)
)));
}
#[test]
fn test_iana_refer_standard() {
let response =
"% IANA WHOIS server\n\nrefer: whois.verisign-grs.com\n\ndomain: COM\n";
assert_eq!(
parse_iana_refer_response(response),
Some("whois.verisign-grs.com".to_string())
);
}
#[test]
fn test_iana_refer_none() {
let response = "% IANA WHOIS server\ndomain: TEST\nstatus: ACTIVE\n";
assert_eq!(parse_iana_refer_response(response), None);
}
#[test]
fn test_iana_refer_empty_value() {
let response = "refer: \ndomain: COM\n";
assert_eq!(parse_iana_refer_response(response), None);
}
#[test]
fn test_iana_whois_field_fallback() {
let response = "whois: whois.verisign-grs.com\ndomain: COM\n";
assert_eq!(
parse_iana_refer_response(response),
Some("whois.verisign-grs.com".to_string())
);
}
#[test]
fn test_iana_refer_takes_precedence_over_whois() {
let response =
"whois: whois.old-server.com\nrefer: whois.correct-server.com\n";
assert_eq!(
parse_iana_refer_response(response),
Some("whois.correct-server.com".to_string())
);
}
#[test]
fn test_iana_empty_whois_field() {
let response = "whois: \ndomain: COM\n";
assert_eq!(parse_iana_refer_response(response), None);
}
#[test]
fn test_iana_empty_response() {
assert_eq!(parse_iana_refer_response(""), None);
}
#[tokio::test]
async fn live_whois_over_tcp() {
let client = WhoisClient::new();
match client
.check_domain_with_server("google.com", "whois.verisign-grs.com")
.await
{
Ok(result) => assert_eq!(result.available, Some(false)),
Err(e) => eprintln!("skipped: WHOIS over TCP unavailable: {e}"),
}
}
#[tokio::test]
async fn live_iana_discovery() {
match discover("it").await {
WhoisServer::Known(server) => assert_eq!(server, "whois.nic.it"),
WhoisServer::NoneListed => panic!("IANA lists whois.nic.it for .it"),
WhoisServer::Unreachable => eprintln!("skipped: whois.iana.org unreachable"),
}
if let WhoisServer::Known(server) = discover("dev").await {
panic!(".dev runs RDAP only, IANA listed {server}");
}
}
}