use chrono::{DateTime, FixedOffset, Utc};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::sync::LazyLock;
use super::parsers::{push_bounded, MAX_NAMESERVERS, MAX_STATUSES};
pub(super) fn field_patterns(labels: &[&str]) -> Vec<Regex> {
labels
.iter()
.map(|label| compile(&format!(r"(?i){label}:[ \t]*(.+)")))
.collect()
}
fn line_field_patterns(labels: &[&str]) -> Vec<Regex> {
labels
.iter()
.map(|label| compile(&format!(r"(?im)^[ \t]*{label}:[ \t]*(.+)$")))
.collect()
}
fn compile(pattern: &str) -> Regex {
Regex::new(pattern).expect("valid WHOIS field regex")
}
static REGISTRAR_PATTERNS: LazyLock<Vec<Regex>> =
LazyLock::new(|| field_patterns(&["Registrar", "Registrar Name", "Sponsoring Registrar"]));
static REGISTRANT_PATTERNS: LazyLock<Vec<Regex>> =
LazyLock::new(|| field_patterns(&["Registrant Name", "Registrant"]));
static ORGANIZATION_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
let mut patterns = field_patterns(&["Registrant Organization"]);
patterns.extend(line_field_patterns(&[
"Organization",
"org-name",
"Org Name",
]));
patterns
});
static CREATION_DATE_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
let mut patterns = field_patterns(&[
"Creation Date",
"Created Date",
"Created On",
"Created",
"Registration Date",
"Domain Registration Date",
]);
patterns.extend(line_field_patterns(&["Registered", "Date de création"]));
patterns
});
static EXPIRATION_DATE_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
let mut patterns = field_patterns(&[
"(?:Registry )?Expir(?:y|ation) Date",
"Expiration Date",
"Expires On",
"Expires",
"Expiry Date",
"paid-till",
]);
patterns.extend(line_field_patterns(&["Date d'expiration"]));
patterns
});
static UPDATED_DATE_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
let mut patterns = field_patterns(&[
"Updated Date",
"Last Updated On",
"Last Modified",
"Last Update",
"Modified",
]);
patterns.extend(line_field_patterns(&["Dernière modification"]));
patterns
});
static DNSSEC_PATTERNS: LazyLock<Vec<Regex>> =
LazyLock::new(|| field_patterns(&["DNSSEC", "DNSSEC Status"]));
static NAMESERVER_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
let mut patterns = field_patterns(&["Name Server", "Nameserver", "nserver"]);
patterns.push(compile(r"(?im)^NS:[ \t]+(.+)$"));
patterns.extend(line_field_patterns(&["Hostname", "Serveur de noms"]));
patterns
});
static REGISTRANT_EMAIL_PATTERNS: LazyLock<Vec<Regex>> =
LazyLock::new(|| field_patterns(&["Registrant Email", "Registrant E-mail"]));
static REGISTRANT_PHONE_PATTERNS: LazyLock<Vec<Regex>> =
LazyLock::new(|| field_patterns(&["Registrant Phone", "Registrant Tel"]));
static REGISTRANT_ADDRESS_PATTERNS: LazyLock<Vec<Regex>> =
LazyLock::new(|| field_patterns(&["Registrant Street", "Registrant Address"]));
static REGISTRANT_COUNTRY_PATTERNS: LazyLock<Vec<Regex>> =
LazyLock::new(|| field_patterns(&["Registrant Country"]));
static ADMIN_NAME_PATTERNS: LazyLock<Vec<Regex>> =
LazyLock::new(|| field_patterns(&["Admin Name", "Administrative Contact Name"]));
static ADMIN_ORG_PATTERNS: LazyLock<Vec<Regex>> =
LazyLock::new(|| field_patterns(&["Admin Organization"]));
static ADMIN_EMAIL_PATTERNS: LazyLock<Vec<Regex>> =
LazyLock::new(|| field_patterns(&["Admin Email", "Admin E-mail"]));
static ADMIN_PHONE_PATTERNS: LazyLock<Vec<Regex>> =
LazyLock::new(|| field_patterns(&["Admin Phone", "Admin Tel"]));
static TECH_NAME_PATTERNS: LazyLock<Vec<Regex>> =
LazyLock::new(|| field_patterns(&["Tech Name", "Technical Contact Name"]));
static TECH_ORG_PATTERNS: LazyLock<Vec<Regex>> =
LazyLock::new(|| field_patterns(&["Tech Organization"]));
static TECH_EMAIL_PATTERNS: LazyLock<Vec<Regex>> =
LazyLock::new(|| field_patterns(&["Tech Email", "Tech E-mail"]));
static TECH_PHONE_PATTERNS: LazyLock<Vec<Regex>> =
LazyLock::new(|| field_patterns(&["Tech Phone", "Tech Tel"]));
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WhoisResponse {
pub domain: String,
pub registrar: Option<String>,
pub registrant: Option<String>,
pub organization: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub registrant_email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub registrant_phone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub registrant_address: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub registrant_country: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub admin_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub admin_organization: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub admin_email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub admin_phone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tech_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tech_organization: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tech_email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tech_phone: Option<String>,
pub creation_date: Option<DateTime<Utc>>,
pub expiration_date: Option<DateTime<Utc>>,
pub updated_date: Option<DateTime<Utc>>,
pub nameservers: Vec<String>,
pub status: Vec<String>,
pub dnssec: Option<String>,
pub whois_server: String,
#[serde(skip_serializing, default)]
pub raw_response: String,
}
impl WhoisResponse {
pub fn parse(domain: &str, whois_server: &str, raw: &str) -> Self {
super::parsers::parse(domain, whois_server, raw)
}
pub fn parse_internal(domain: &str, whois_server: &str, raw: &str) -> Self {
let registrar = extract_field_with_patterns(raw, ®ISTRAR_PATTERNS)
.or_else(|| extract_section_value(raw, &["registrar"]));
let registrant = extract_field_with_patterns(raw, ®ISTRANT_PATTERNS)
.or_else(|| extract_section_value(raw, &["registrant"]));
let organization = extract_field_with_patterns(raw, &ORGANIZATION_PATTERNS);
let registrant_email = extract_field_with_patterns(raw, ®ISTRANT_EMAIL_PATTERNS);
let registrant_phone = extract_field_with_patterns(raw, ®ISTRANT_PHONE_PATTERNS);
let registrant_address = extract_field_with_patterns(raw, ®ISTRANT_ADDRESS_PATTERNS);
let registrant_country = extract_field_with_patterns(raw, ®ISTRANT_COUNTRY_PATTERNS);
let admin_name = extract_field_with_patterns(raw, &ADMIN_NAME_PATTERNS);
let admin_organization = extract_field_with_patterns(raw, &ADMIN_ORG_PATTERNS);
let admin_email = extract_field_with_patterns(raw, &ADMIN_EMAIL_PATTERNS);
let admin_phone = extract_field_with_patterns(raw, &ADMIN_PHONE_PATTERNS);
let tech_name = extract_field_with_patterns(raw, &TECH_NAME_PATTERNS);
let tech_organization = extract_field_with_patterns(raw, &TECH_ORG_PATTERNS);
let tech_email = extract_field_with_patterns(raw, &TECH_EMAIL_PATTERNS);
let tech_phone = extract_field_with_patterns(raw, &TECH_PHONE_PATTERNS);
let creation_str = extract_field_with_patterns(raw, &CREATION_DATE_PATTERNS)
.or_else(|| extract_section_value(raw, &["first registration date"]));
let expiration_str = extract_field_with_patterns(raw, &EXPIRATION_DATE_PATTERNS);
let updated_str = extract_field_with_patterns(raw, &UPDATED_DATE_PATTERNS);
let date_order = infer_date_order(
[&creation_str, &expiration_str, &updated_str]
.into_iter()
.flatten()
.map(String::as_str),
)
.unwrap_or(DateOrder::DayFirst);
let creation_date = creation_str
.as_deref()
.and_then(|s| parse_date_with_order(s, date_order));
let expiration_date = expiration_str
.as_deref()
.and_then(|s| parse_date_with_order(s, date_order));
let updated_date = updated_str
.as_deref()
.and_then(|s| parse_date_with_order(s, date_order));
let nameservers = extract_nameservers(raw);
let status = extract_status_top_level(raw);
let dnssec = extract_field_with_patterns(raw, &DNSSEC_PATTERNS);
WhoisResponse {
domain: domain.to_string(),
registrar,
registrant,
organization,
registrant_email,
registrant_phone,
registrant_address,
registrant_country,
admin_name,
admin_organization,
admin_email,
admin_phone,
tech_name,
tech_organization,
tech_email,
tech_phone,
creation_date,
expiration_date,
updated_date,
nameservers,
status,
dnssec,
whois_server: whois_server.to_string(),
raw_response: raw.to_string(),
}
}
pub(crate) fn has_registration_fields(&self) -> bool {
self.registrar.is_some() || self.creation_date.is_some() || self.expiration_date.is_some()
}
pub(crate) fn is_thin(&self) -> bool {
!self.has_registration_fields() && self.nameservers.is_empty()
}
pub(crate) fn has_any_registry_field(&self) -> bool {
!self.is_thin() || !self.status.is_empty()
}
pub fn has_core_data(&self) -> bool {
let has_dates_or_status = self.creation_date.is_some()
|| self.expiration_date.is_some()
|| !self.status.is_empty();
self.registrar.is_some() && has_dates_or_status && !self.nameservers.is_empty()
}
pub fn registry_unavailable(&self) -> bool {
if self.has_any_registry_field() {
return false;
}
const SERVICE_ERROR_SENTINELS: &[&str] = &[
"tld is not supported",
"this tld is not",
"malformed request",
"invalid query",
"no whois server is known",
"this server does not",
];
const SERVICE_RETIRED_PHRASES: &[&str] =
&["whois service has been retired", "whois has been retired"];
let lower = self.raw_response.to_lowercase();
lower.lines().any(|line| {
let t = line.trim_start();
SERVICE_ERROR_SENTINELS.iter().any(|s| t.starts_with(s))
|| SERVICE_RETIRED_PHRASES.iter().any(|s| t.contains(s))
})
}
pub fn is_available(&self) -> bool {
if !self.is_thin() {
return false;
}
for line in self.raw_response.lines() {
let content = line.trim().trim_start_matches(['%', '#']).trim_start();
if content.is_empty() {
continue;
}
let lower = content.to_lowercase();
let words: Vec<&str> = lower.split_whitespace().collect();
let normalized = words.join(" ");
if is_non_availability_line(&normalized) {
continue;
}
if AVAILABILITY_SENTENCE_PATTERNS
.iter()
.any(|p| normalized.starts_with(p))
{
return true;
}
if words.len() > MAX_STATUS_LINE_WORDS {
continue;
}
if AVAILABILITY_PATTERNS.iter().any(|p| normalized.contains(p)) {
return true;
}
if normalized.ends_with(" is free") {
return true;
}
}
false
}
pub fn indicates_not_found(&self) -> bool {
let lower = self.raw_response.to_lowercase();
lower.lines().any(|line| {
let t = line.trim_start();
NOT_FOUND_PATTERNS.iter().any(|p| t.starts_with(p))
})
}
pub fn indicates_registry_refusal(&self) -> bool {
for line in self.raw_response.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('%') {
continue;
}
let lower = trimmed.to_lowercase();
let normalized = lower.split_whitespace().collect::<Vec<_>>().join(" ");
if is_non_availability_line(&normalized) {
return true;
}
}
false
}
}
const MAX_STATUS_LINE_WORDS: usize = 10;
const AVAILABILITY_PATTERNS: &[&str] = &[
"no match for",
"no match",
"not found",
"no data found",
"no entries found",
"domain not found",
"available for registration",
"not registered",
"not been registered",
"status: available",
"status: free",
"no object found",
"does not exist",
"nothing found", "no found", "no record found", "no information was found", "object not found", "not find matchingrecord", "no such domain", "object_not_found", "no se encuentra registrado", "no data was found",
];
const AVAILABILITY_SENTENCE_PATTERNS: &[&str] = &[
"the requested domain was not found",
"the domain name you requested was not found",
"no information available about domain name",
];
const NON_AVAILABILITY_PATTERNS: &[&str] = &[
"not available", "reserved -", "is reserved", "been reserved", "reserved by", "reserved for", "status: reserved",
"reserved domain", "reserved name",
"rate limit", "rate exceeded",
"too many requests",
"access denied",
"not permitted", "try again", "temporarily unavailable",
];
const NON_AVAILABILITY_WORDS: &[&str] = &[
"rate-limit", "rate-limited", "quota",
"denied",
"refused",
"blocked",
];
fn is_non_availability_line(normalized: &str) -> bool {
NON_AVAILABILITY_PATTERNS
.iter()
.any(|p| normalized.contains(p))
|| NON_AVAILABILITY_WORDS
.iter()
.any(|w| contains_word(normalized, w))
}
fn is_label_char(c: char) -> bool {
c.is_alphanumeric() || c == '-' || c == '_'
}
fn contains_word(haystack: &str, word: &str) -> bool {
haystack.match_indices(word).any(|(start, _)| {
let before_ok = haystack[..start]
.chars()
.next_back()
.is_none_or(|c| !is_label_char(c) && c != '.');
let mut after = haystack[start + word.len()..].chars();
let after_ok = match after.next() {
None => true,
Some('.') => after.next().is_none_or(|c| !is_label_char(c)),
Some(c) => !is_label_char(c),
};
before_ok && after_ok
})
}
const NOT_FOUND_PATTERNS: &[&str] = &[
"no match for",
"domain not found",
"no data found",
"queried object does not exist",
"object does not exist",
"not found:",
"status: free",
"domain is not registered",
];
fn extract_field_with_patterns(text: &str, patterns: &[Regex]) -> Option<String> {
for re in patterns {
let first_value = re
.captures_iter(text)
.filter_map(|caps| caps.get(1))
.map(|m| m.as_str().trim())
.find(|v| !v.is_empty());
if let Some(value) = first_value {
if !is_placeholder_value(value) {
return Some(value.to_string());
}
}
}
None
}
const PLACEHOLDER_PHRASES: &[&str] = &[
"please query the rdds",
"please query the whois",
"please ask the registrar",
"how to contact the registrant",
"select request email form", "select contact domain holder", ];
fn is_placeholder_value(value: &str) -> bool {
let lower = value.to_lowercase();
lower.contains("redacted")
|| lower.contains("data protected")
|| lower.contains("privacy")
|| lower.contains("not disclosed")
|| lower.contains("not shown")
|| lower.contains("withheld")
|| PLACEHOLDER_PHRASES.iter().any(|p| lower.contains(p))
|| lower == "n/a"
|| lower == "none"
}
fn extract_section_value(text: &str, labels: &[&str]) -> Option<String> {
let mut lines = text.lines();
while let Some(line) = lines.next() {
let Some(label) = line.trim().strip_suffix(':') else {
continue;
};
if !labels.iter().any(|l| label.trim().eq_ignore_ascii_case(l)) {
continue;
}
let next = lines.next()?.trim();
let value = match next.split_once(':') {
None => next,
Some((key, value)) if key.trim().eq_ignore_ascii_case("name") => value.trim(),
Some(_) => continue,
};
if !value.is_empty() && !is_placeholder_value(value) {
return Some(value.to_string());
}
}
None
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DateOrder {
DayFirst,
MonthFirst,
}
fn infer_date_order<'a>(candidates: impl IntoIterator<Item = &'a str>) -> Option<DateOrder> {
static_regex! {
NUMERIC_DATE = r"^\s*(\d{1,2})[/.](\d{1,2})[/.]\d{4}\b";
}
for s in candidates {
if let Some(c) = NUMERIC_DATE.captures(s) {
if let (Ok(a), Ok(b)) = (c[1].parse::<u32>(), c[2].parse::<u32>()) {
if a > 12 && b <= 12 {
return Some(DateOrder::DayFirst); }
if b > 12 && a <= 12 {
return Some(DateOrder::MonthFirst); }
}
}
}
None
}
pub(crate) fn parse_date(date_str: &str) -> Option<DateTime<Utc>> {
parse_date_with_order(date_str, DateOrder::DayFirst)
}
pub(crate) fn parse_date_with_order(date_str: &str, order: DateOrder) -> Option<DateTime<Utc>> {
let cleaned = date_str
.trim()
.replace(" UTC", "Z")
.replace(" (UTC)", "")
.replace(" +0000", "Z");
if let Ok(dt) = DateTime::parse_from_rfc3339(&cleaned) {
return Some(dt.with_timezone(&Utc));
}
if let Ok(dt) = DateTime::<FixedOffset>::parse_from_str(&cleaned, "%Y-%m-%dT%H:%M:%S%z") {
return Some(dt.with_timezone(&Utc));
}
if let Ok(dt) = DateTime::<FixedOffset>::parse_from_str(&cleaned, "%Y-%m-%d %H:%M:%S%z") {
return Some(dt.with_timezone(&Utc));
}
const UNAMBIGUOUS_FORMATS: &[&str] = &[
"%Y-%m-%dT%H:%M:%SZ",
"%Y-%m-%dT%H:%M:%S%.fZ",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%d %H:%M:%S",
"%d-%b-%Y %H:%M:%S",
"%d-%b-%Y %H:%M:%S%.f",
"%d-%b-%Y %H:%M:%SZ",
"%d-%b-%Y %H:%M:%S UTC",
"%Y-%m-%d",
"%d-%b-%Y",
"%d-%B-%Y",
"%Y.%m.%d %H:%M:%S",
"%Y.%m.%d",
"%Y/%m/%d %H:%M:%S",
"%Y/%m/%d",
"%b %d %Y",
"%a %b %d %Y",
];
const DAY_FIRST: &[&str] = &[
"%d.%m.%Y %H:%M:%S",
"%d.%m.%Y",
"%d/%m/%Y %H:%M:%S",
"%d/%m/%Y",
];
const MONTH_FIRST: &[&str] = &[
"%m.%d.%Y %H:%M:%S",
"%m.%d.%Y",
"%m/%d/%Y %H:%M:%S",
"%m/%d/%Y",
];
let (first_pair, second_pair) = match order {
DateOrder::DayFirst => (DAY_FIRST, MONTH_FIRST),
DateOrder::MonthFirst => (MONTH_FIRST, DAY_FIRST),
};
for fmt in UNAMBIGUOUS_FORMATS
.iter()
.chain(first_pair.iter())
.chain(second_pair.iter())
{
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&cleaned, fmt) {
return Some(dt.and_utc());
}
if let Ok(d) = chrono::NaiveDate::parse_from_str(&cleaned, fmt) {
return Some(d.and_hms_opt(0, 0, 0)?.and_utc());
}
}
if let Ok(dt) = cleaned.parse::<DateTime<Utc>>() {
return Some(dt);
}
None
}
fn extract_nameservers(text: &str) -> Vec<String> {
let mut nameservers = Vec::new();
for re in NAMESERVER_PATTERNS.iter() {
for caps in re.captures_iter(text) {
if nameservers.len() >= MAX_NAMESERVERS {
return nameservers;
}
if let Some(m) = caps.get(1) {
let raw = m.as_str().trim();
let ns = raw.split_whitespace().next().unwrap_or(raw).to_lowercase();
push_bounded(&mut nameservers, ns, MAX_NAMESERVERS);
}
}
}
nameservers
}
fn extract_status_top_level(raw: &str) -> Vec<String> {
let mut statuses = Vec::new();
for line in raw.lines() {
if statuses.len() >= MAX_STATUSES {
break;
}
let trimmed = line.trim_start();
if trimmed.starts_with('[') && trimmed.contains(']') {
break;
}
if trimmed.is_empty() || trimmed.starts_with('%') || trimmed.starts_with('#') {
continue;
}
let lower = trimmed.to_lowercase();
let value_opt = if lower.starts_with("domain status:") {
Some(&trimmed["domain status:".len()..])
} else if lower.starts_with("status:") {
Some(&trimmed["status:".len()..])
} else if lower.starts_with("state:") {
Some(&trimmed["state:".len()..])
} else {
None
};
if let Some(rest) = value_opt {
let raw_val = rest.trim();
if let Some(first) = raw_val.split_whitespace().next() {
push_bounded(&mut statuses, first.to_string(), MAX_STATUSES);
}
}
}
statuses
}
#[cfg(test)]
mod tests {
use super::*;
fn make_response(raw: &str) -> WhoisResponse {
WhoisResponse::parse_internal("example.jp", "whois.jprs.jp", raw)
}
#[test]
fn organization_pattern_ignores_admin_and_tech_org_lines() {
let raw = "Domain Name: example.com\n\
Admin Organization: AcmeAdminCorp\n\
Tech Organization: AcmeTechCorp\n";
assert_eq!(
extract_field_with_patterns(raw, &ORGANIZATION_PATTERNS),
None
);
let raw2 = "Domain Name: example.com\n\
Registrant Contact:\n\
\tOrganization: Real Registrant LLC\n\
Admin Organization: AcmeAdminCorp\n";
assert_eq!(
extract_field_with_patterns(raw2, &ORGANIZATION_PATTERNS).as_deref(),
Some("Real Registrant LLC")
);
}
#[test]
fn empty_fields_do_not_capture_the_next_line() {
let raw = concat!(
"Domain Name: EXAMPLE.COM\r\n",
"Registrar WHOIS Server: whois.tucows.com\r\n",
"Creation Date: 2010-01-01T00:00:00Z\r\n",
"Registrar Registration Expiration Date: \r\n",
"Registrar: TUCOWS, INC.\r\n",
"Registrant Organization: \r\n",
"Registrant Street: 123 Main\r\n",
"Name Server: \r\n",
"DNSSEC: \r\n",
"URL of the ICANN Whois Inaccuracy Complaint Form: https://www.icann.org/wicf/\r\n",
);
let r = WhoisResponse::parse("example.com", "whois.tucows.com", raw);
assert!(
r.nameservers.is_empty(),
"empty Name Server must not yield the next label: {:?}",
r.nameservers
);
assert_eq!(r.dnssec, None, "empty DNSSEC must not capture the URL line");
assert_eq!(
r.organization, None,
"empty Registrant Organization must not capture the street line"
);
assert_eq!(r.registrant_address.as_deref(), Some("123 Main"));
assert_eq!(r.registrar.as_deref(), Some("TUCOWS, INC."));
assert_eq!(
r.expiration_date, None,
"empty expiry must stay None, not the registrar line"
);
let raw = "Registrar Registration Expiration Date: \n\
Registrar: TUCOWS, INC.\n\
Name Server: \n\
DNSSEC: unsigned\n\
Registry Expiry Date: 2027-03-01T00:00:00Z\n";
let r = WhoisResponse::parse("example.com", "whois.tucows.com", raw);
assert!(r.nameservers.is_empty(), "got {:?}", r.nameservers);
assert_eq!(r.dnssec.as_deref(), Some("unsigned"));
assert_eq!(
r.expiration_date.map(|d| d.format("%Y-%m-%d").to_string()),
Some("2027-03-01".to_string())
);
}
#[test]
fn dns_belgium_section_registrar_is_extracted() {
let raw = "% .be Whois Server 6.1\n\
%\n\
Domain:\tgoogle.be\n\
Status:\tNOT AVAILABLE\n\
Registered:\tTue Dec 12 2000\n\
\n\
Registrant:\n\
\tNot shown, please visit www.dnsbelgium.be for webbased whois.\n\
\n\
Registrar Technical Contacts:\n\
\tOrganisation:\tMarkMonitor Inc.\n\
\tLanguage:\ten\n\
\tPhone:\t+1.2083895740\n\
\tEmail:\tccops@markmonitor.com\n\
\n\
\n\
Registrar:\n\
\tName:\t MarkMonitor Inc.\n\
\tWebsite: http://www.markmonitor.com\n\
\n\
Nameservers:\n\
\tns2.google.com\n\
\tns1.google.com\n\
\n\
Keys:\n\
\n\
Flags:\n\
\tclientTransferProhibited\n\
\n\
Please visit www.dnsbelgium.be for more info.\n";
let r = WhoisResponse::parse("google.be", "whois.dns.be", raw);
assert_eq!(r.registrar.as_deref(), Some("MarkMonitor Inc."));
assert_eq!(
r.registrant, None,
"the 'Not shown' notice is not a registrant"
);
assert_eq!(
r.creation_date.map(|d| d.format("%Y-%m-%d").to_string()),
Some("2000-12-12".to_string()),
"created from 'Registered: Tue Dec 12 2000'"
);
}
#[test]
fn nask_section_registrar_is_extracted() {
let raw = "DOMAIN NAME: google.pl\n\
registrant type: organization\n\
nameservers: ns1.google.com.\n\
\x20 ns2.google.com.\n\
created: 2002.09.19 13:00:00\n\
last modified: 2025.08.19 13:47:36\n\
renewal date: 2026.09.18 14:00:00\n\
\n\
no option\n\
\n\
dnssec: Unsigned\n\
\n\
\n\
REGISTRAR:\n\
MarkMonitor, Inc.\n\
3540 East Longwing Lane, Suite 300\n\
Meridian, Idaho 83646\n\
United States\n\
+1.2083895740\n\
ccops@markmonitor.com\n\
www.markmonitor.com\n\
\n\
WHOIS database responses: https://dns.pl/en/whois\n";
let r = WhoisResponse::parse("google.pl", "whois.dns.pl", raw);
assert_eq!(r.registrar.as_deref(), Some("MarkMonitor, Inc."));
assert_eq!(r.dnssec.as_deref(), Some("Unsigned"));
let fmt = |d: Option<DateTime<Utc>>| d.map(|d| d.format("%Y-%m-%d %H:%M:%S").to_string());
assert_eq!(
fmt(r.creation_date).as_deref(),
Some("2002-09-19 13:00:00"),
"created from 'created: 2002.09.19 13:00:00'"
);
assert_eq!(
fmt(r.updated_date).as_deref(),
Some("2025-08-19 13:47:36"),
"updated from 'last modified: 2025.08.19 13:47:36'"
);
}
#[test]
fn twnic_section_registrant_is_extracted() {
let raw = "Domain Name: google.com.tw\n\
\x20 Domain Status: clientUpdateProhibited\n\
\x20 Registrant:\n\
\x20 Google LLC\n\
\x20 DNS Admin dns-admin@google.com\n\
\n\
\x20 Record expires on 2026-11-09 00:00:00 (UTC+8)\n\
\n\
Registration Service Provider: Markmonitor, Inc.\n";
let r = WhoisResponse::parse("google.com.tw", "whois.twnic.net.tw", raw);
assert_eq!(r.registrant.as_deref(), Some("Google LLC"));
}
#[test]
fn section_fallback_ignores_a_following_key_value_field() {
let raw = "Domain Name: EXAMPLE.COM\nRegistrar:\nRegistrar IANA ID: 376\n";
let r = WhoisResponse::parse("example.com", "whois.example", raw);
assert_eq!(r.registrar, None);
}
#[test]
fn contact_boilerplate_is_not_stored_as_a_value() {
let raw = "Domain Name: EXAMPLE.COM\r\n\
Registrant Email: Please query the RDDS service of the Registrar of Record identified in this output for information on how to contact the Registrant, Admin, or Tech contact of the queried domain name.\r\n\
Admin Email: Please ask the Registrar of Record identified in this output for information on how to contact the Registrant, Admin, or Tech contact of the queried domain name.\r\n\
Tech Email: Select Request Email Form at https://domains.markmonitor.com/whois/example.com\r\n\
Registrant Phone: +1.5555550100\r\n";
let r = WhoisResponse::parse("example.com", "whois.example", raw);
assert_eq!(r.registrant_email, None);
assert_eq!(r.admin_email, None);
assert_eq!(r.tech_email, None);
assert_eq!(
r.registrant_phone.as_deref(),
Some("+1.5555550100"),
"real values are kept"
);
}
#[test]
fn is_available_jprs_style_with_notice_preamble() {
let raw = "\
Notice: JPRS database is provided for information purposes only.
Notice: Use of this service is subject to the JPRS terms.
Notice: For more info see https://jprs.jp/
Notice: Copyright (C) JPRS.
No match!!
";
assert!(make_response(raw).is_available());
}
#[test]
fn is_available_nic_br_style() {
let raw = "\
% Copyright (c) Nic.br
% The use of the data below is only permitted as described in
% full by the terms of use at https://registro.br/termo/en.html,
% being prohibited its distribution, commercialization or
% reproduction, in particular, to use it for advertising or
% any similar purpose.
No match for domain exemplo.br.
";
assert!(make_response(raw).is_available());
}
#[test]
fn is_available_twnic_style() {
let raw = "\
TWNIC WHOIS Server. This service is free and is provided as is.
Notice: Use of this service is subject to terms.
Notice: Do not use for spam.
Domain not found.
";
assert!(make_response(raw).is_available());
}
#[test]
fn is_available_false_when_registration_data_present_despite_noise() {
let raw = "\
Domain Name: example.com
Registrar: Example Registrar, Inc.
Creation Date: 2020-01-01T00:00:00Z
Name Server: ns1.example.com
Registrar Abuse Contact: please report if the object not found in directory
";
assert!(!make_response(raw).is_available());
}
#[test]
fn is_available_false_for_registered_domain() {
let raw = "\
Domain Name: example.com
Registrar: Example Registrar, Inc.
Creation Date: 2020-01-01T00:00:00Z
Name Server: ns1.example.com
";
assert!(!make_response(raw).is_available());
}
#[test]
fn registry_unavailable_true_for_tld_not_supported_sentinel() {
let raw = "\
TLD is not supported.
>>> Last update of WHOIS database: 2026-06-03T22:45:28Z <<<
Terms of Use: Access to WHOIS information is provided to assist persons ...
";
assert!(make_response(raw).registry_unavailable());
}
#[test]
fn registry_unavailable_true_for_malformed_request_sentinel() {
let raw = "\
Malformed request.
>>> Last update of WHOIS database: 2026-06-03T22:46:35Z <<<
";
assert!(make_response(raw).registry_unavailable());
}
#[test]
fn registry_unavailable_false_for_registered_domain() {
let raw = "\
Domain Name: example.com
Registrar: Example Registrar, Inc.
Creation Date: 2020-01-01T00:00:00Z
Name Server: ns1.example.com
";
assert!(!make_response(raw).registry_unavailable());
}
#[test]
fn registry_unavailable_false_for_available_domain() {
let raw = "No match for domain EXAMPLE.\n";
assert!(!make_response(raw).registry_unavailable());
}
#[test]
fn extract_status_does_not_panic_on_unicode_lowercasing_expansion() {
let raw = format!("Domain Status: {}\n", "İ".repeat(40));
let r = make_response(&raw); assert!(
!r.status.is_empty(),
"status value should still be extracted"
);
}
#[test]
fn is_available_hkirc_has_not_been_registered() {
let raw = "The domain has not been registered.\n";
assert!(make_response(raw).is_available());
}
#[test]
fn is_available_tab_delimited_status_available() {
let raw = "Domain:\tfoo.be\nStatus:\tAVAILABLE\n";
assert!(make_response(raw).is_available());
}
#[test]
fn is_not_available_tab_delimited_status_not_available() {
let raw = "Domain:\tfoo.be\nStatus:\tNOT AVAILABLE\n";
assert!(!make_response(raw).is_available());
}
#[test]
fn is_available_recognizes_additional_registry_phrasings() {
for raw in [
"*** Nothing found for this query.\n", "No Found\n", "No record found for 'example.ls'.\n", "No information was found matching that query.\n", "Object not found\n", "Not find MatchingRecord\n", "% No data was found to match the request criteria.\n", ] {
assert!(
make_response(raw).is_available(),
"should detect available from: {raw:?}"
);
}
}
#[test]
fn is_available_does_not_match_registered_or_blocked_phrasings() {
for raw in [
"Domain Status: clientTransferProhibited\nRegistrar: Example, Inc.\n",
"Status: NOT AVAILABLE\n",
"Requests of this client are not permitted. Please use the web form.\n",
"This WHOIS service is free for personal, non-commercial use.\n",
] {
assert!(
!make_response(raw).is_available(),
"must NOT detect available from: {raw:?}"
);
}
}
#[test]
fn is_available_false_for_negation_and_refusal_lines() {
for raw in [
"This domain name is not available for registration.\n",
"Domain Status: reserved - not registered for public use\n",
"Access rate limited; no data found for unauthenticated clients\n",
] {
assert!(
!make_response(raw).is_available(),
"negation/refusal line must NOT read as available: {raw:?}"
);
}
}
#[test]
fn indicates_registry_refusal_detects_throttle_and_negation() {
for raw in [
"This domain name is not available for registration.\n",
"Domain Status: reserved - not registered for public use\n",
"Access rate limited; no data found for unauthenticated clients\n",
"Requests of this client are not permitted. Please use the web form.\n",
] {
assert!(
make_response(raw).indicates_registry_refusal(),
"should flag refusal/throttle/negation: {raw:?}"
);
}
}
#[test]
fn refusal_words_inside_the_echoed_domain_do_not_veto_availability() {
for (domain, raw) in [
(
"quotations.pl",
"No information available about domain name quotations.pl in the Registry NASK database.\n",
),
("unblocked-games.com", "No match for \"UNBLOCKED-GAMES.COM\".\n"),
("refused.ls", "No record found for 'refused.ls'.\n"),
("denied-access.com", "No match for \"DENIED-ACCESS.COM\".\n"),
("rate-limiter.com", "No match for \"RATE-LIMITER.COM\".\n"),
] {
let r = WhoisResponse::parse(domain, "whois.example", raw);
assert!(
!r.indicates_registry_refusal(),
"{domain}: echoed domain is not a refusal: {raw:?}"
);
assert!(r.is_available(), "{domain}: must read as available: {raw:?}");
}
}
#[test]
fn refusal_words_are_still_detected_as_whole_words() {
for raw in [
"Query refused.\n",
"Your request has been blocked\n",
"Request denied: too many queries from your IP\n",
"Query quota exceeded for this client\n",
"Rate-limit exceeded, no data found\n",
"Your IP has been rate-limited.\n",
] {
let r = make_response(raw);
assert!(r.indicates_registry_refusal(), "must flag: {raw:?}");
assert!(!r.is_available(), "must not read as available: {raw:?}");
}
}
#[test]
fn contains_word_respects_domain_label_boundaries() {
assert!(contains_word("query refused.", "refused"));
assert!(contains_word("refused", "refused"));
assert!(contains_word("(blocked)", "blocked"));
assert!(!contains_word(
"no record found for 'refused.ls'.",
"refused"
));
assert!(!contains_word("www.blocked.example", "blocked"));
assert!(!contains_word("unblocked-games.com", "blocked"));
assert!(!contains_word("quotations.pl", "quota"));
}
#[test]
fn indicates_registry_refusal_false_for_plain_not_found_and_registered() {
for raw in [
"No match for domain EXAMPLE.\n",
"Domain not found.\n",
"Conditions of use for the whois service via port 43\n",
"Domain Name: example.com\nRegistrar: Example, Inc.\nName Server: ns1.example.com\n",
"Copyright (c) 2024 Example Registry Corp. All rights reserved.\n",
"All rights reserved. Use of this service is subject to the terms of use.\n",
] {
assert!(
!make_response(raw).indicates_registry_refusal(),
"must NOT flag as refusal: {raw:?}"
);
}
}
#[test]
fn is_available_ignores_not_found_phrase_buried_in_prose() {
let raw = "\
This is an informational banner from the registry WHOIS service provider.
Note that if the queried object does not exist in our database we simply return an empty result rather than an error response.
Records not found in this directory may still exist upstream in the thick registry, so please check with the sponsoring registrar.
";
assert!(
!make_response(raw).is_available(),
"long prose sentences must not trigger availability"
);
}
#[test]
fn indicates_not_found_true_on_line_start() {
let raw = "\
Domain Name: example.com
queried object does not exist
";
assert!(make_response(raw).indicates_not_found());
}
#[test]
fn indicates_not_found_false_when_phrase_is_in_tos_footer() {
let raw = "\
Domain Name: example.com
Registrar: Example Registrar, Inc.
Creation Date: 2020-01-01T00:00:00Z
Name Server: ns1.example.com
Terms of Service:
Note that if the queried object does not exist in our database we return NXDOMAIN.
This document does not imply anything about specific domains.
";
assert!(!make_response(raw).indicates_not_found());
}
#[test]
fn status_extracted_only_from_top_level_block() {
let raw = "\
Domain Name: example.jp
Status: Active
Registrar: Example Registrar
[Tech-C]
Status: ok
Name: Technical Contact
";
let parsed = make_response(raw);
assert_eq!(parsed.status, vec!["Active".to_string()]);
}
#[test]
fn status_multiple_top_level_values_deduped() {
let raw = "\
Domain Name: example.com
Domain Status: clientTransferProhibited
Domain Status: clientUpdateProhibited
Domain Status: clientTransferProhibited
";
let parsed = make_response(raw);
assert_eq!(
parsed.status,
vec![
"clientTransferProhibited".to_string(),
"clientUpdateProhibited".to_string(),
]
);
}
#[test]
fn parse_date_handles_d_b_y_with_time() {
let parsed = parse_date("15-Jan-2024 10:30:00").expect("should parse");
use chrono::Datelike;
assert_eq!(parsed.year(), 2024);
assert_eq!(parsed.month(), 1);
assert_eq!(parsed.day(), 15);
}
#[test]
fn parse_date_handles_d_b_y_with_time_utc_suffix() {
let parsed = parse_date("15-Jan-2024 10:30:00 UTC").expect("should parse");
use chrono::Datelike;
assert_eq!(parsed.year(), 2024);
}
#[test]
fn parse_date_still_handles_d_b_y_date_only() {
let parsed = parse_date("15-Jan-2024").expect("should parse");
use chrono::Datelike;
assert_eq!(parsed.year(), 2024);
}
#[test]
fn parse_date_recovers_unambiguous_us_mdy() {
use chrono::Datelike;
let parsed = parse_date("02/13/2024").expect("unambiguous MDY should parse");
assert_eq!(parsed.year(), 2024);
assert_eq!(parsed.month(), 2);
assert_eq!(parsed.day(), 13);
}
#[test]
fn parse_date_ambiguous_numeric_is_day_first() {
use chrono::Datelike;
let parsed = parse_date("06/07/2024").expect("should parse");
assert_eq!(parsed.year(), 2024);
assert_eq!(parsed.month(), 7);
assert_eq!(parsed.day(), 6);
}
#[test]
fn parse_date_with_order_disambiguates_month_first() {
use chrono::Datelike;
let dmy = parse_date_with_order("03/04/2024", DateOrder::DayFirst).expect("parse");
assert_eq!(
(dmy.month(), dmy.day()),
(4, 3),
"day-first reads 03/04 as 3 April"
);
let mdy = parse_date_with_order("03/04/2024", DateOrder::MonthFirst).expect("parse");
assert_eq!(
(mdy.month(), mdy.day()),
(3, 4),
"month-first reads 03/04 as 4 March"
);
}
#[test]
fn parse_date_with_order_unambiguous_is_order_independent() {
use chrono::Datelike;
for s in ["13/04/2024", "04/13/2024"] {
for order in [DateOrder::DayFirst, DateOrder::MonthFirst] {
let d = parse_date_with_order(s, order).expect("parse");
assert_eq!(
(d.year(), d.month(), d.day()),
(2024, 4, 13),
"{s:?} under {order:?} must be 2024-04-13"
);
}
}
}
#[test]
fn parse_internal_infers_us_month_first_from_unambiguous_sibling() {
use chrono::Datelike;
let raw = "Creation Date: 03/04/2024\nRegistry Expiry Date: 04/15/2034\n";
let r = make_response(raw);
let creation = r.creation_date.expect("creation parsed");
assert_eq!(
(creation.month(), creation.day()),
(3, 4),
"ambiguous date must follow the registry's revealed month-first order"
);
}
#[test]
fn parse_internal_defaults_day_first_without_disambiguating_sibling() {
use chrono::Datelike;
let raw = "Creation Date: 06/07/2024\n";
let r = make_response(raw);
let creation = r.creation_date.expect("creation parsed");
assert_eq!(
(creation.month(), creation.day()),
(7, 6),
"no disambiguating sibling keeps the day-first default"
);
}
#[test]
fn parse_date_handles_numeric_dates_with_time_of_day() {
let fmt = |s: &str, order| {
parse_date_with_order(s, order).map(|d| d.format("%Y-%m-%d %H:%M:%S").to_string())
};
let day_first = DateOrder::DayFirst;
assert_eq!(
fmt("2002.09.19 13:00:00", day_first).as_deref(),
Some("2002-09-19 13:00:00")
);
assert_eq!(
fmt("2002/09/19 13:00:00", day_first).as_deref(),
Some("2002-09-19 13:00:00")
);
assert_eq!(
fmt("18.09.2000 13:05:00", day_first).as_deref(),
Some("2000-09-18 13:05:00")
);
assert_eq!(
fmt("03/04/2024 10:00:00", DateOrder::DayFirst).as_deref(),
Some("2024-04-03 10:00:00")
);
assert_eq!(
fmt("03/04/2024 10:00:00", DateOrder::MonthFirst).as_deref(),
Some("2024-03-04 10:00:00")
);
for order in [DateOrder::DayFirst, DateOrder::MonthFirst] {
assert_eq!(
fmt("04/13/2024 08:30:00", order).as_deref(),
Some("2024-04-13 08:30:00")
);
assert_eq!(
fmt("13.04.2024 08:30:00", order).as_deref(),
Some("2024-04-13 08:30:00")
);
}
assert_eq!(
fmt("Tue Dec 12 2000", day_first).as_deref(),
Some("2000-12-12 00:00:00")
);
}
#[test]
fn parse_internal_infers_order_from_sibling_with_time_of_day() {
use chrono::Datelike;
let raw = "Creation Date: 03/04/2024 10:00:00\n\
Registry Expiry Date: 04/15/2034 10:00:00\n";
let r = make_response(raw);
let creation = r.creation_date.expect("creation parsed");
assert_eq!((creation.month(), creation.day()), (3, 4));
let expiry = r.expiration_date.expect("expiry parsed");
assert_eq!((expiry.month(), expiry.day()), (4, 15));
}
#[test]
fn raw_response_is_skipped_from_json_output() {
let raw = "Domain Name: example.com\nRegistrar: Example Registrar\n";
let parsed = make_response(raw);
assert!(
!parsed.raw_response.is_empty(),
"raw_response still populated internally"
);
let json = serde_json::to_string(&parsed).expect("serialize");
assert!(
!json.contains("raw_response"),
"raw_response must not appear in serialized JSON output: {}",
json
);
assert!(
!json.contains("Domain Name: example.com"),
"raw response content must not leak in JSON: {}",
json
);
}
#[test]
fn whois_response_survives_json_round_trip() {
let parsed = make_response("Domain Name: example.com\nRegistrar: Example Registrar\n");
let json = serde_json::to_string(&parsed).expect("serialize");
let back: WhoisResponse =
serde_json::from_str(&json).expect("round-trip must not fail (missing field?)");
assert_eq!(back.domain, parsed.domain, "domain survives round-trip");
assert!(
back.raw_response.is_empty(),
"skipped raw_response defaults to empty on load"
);
}
#[test]
fn punktum_dk_hostnames_and_registered_date() {
let raw = "Domain: punktum.dk\n\
DNS: punktum.dk\n\
Registered: 2018-01-25\n\
Expires: 2027-01-31\n\
Registration period: 1 year\n\
DNSSEC: Signed delegation\n\
Status: Active\n\
\n\
Nameservers\n\
Hostname: auth01.ns.dk-hostmaster.dk\n\
Hostname: auth02.ns.dk-hostmaster.dk\n\
Hostname: auth03.ns.dk-hostmaster.dk\n";
let r = WhoisResponse::parse("punktum.dk", "whois.punktum.dk", raw);
assert_eq!(
r.nameservers,
vec![
"auth01.ns.dk-hostmaster.dk",
"auth02.ns.dk-hostmaster.dk",
"auth03.ns.dk-hostmaster.dk"
]
);
assert!(r.creation_date.is_some(), "created from 'Registered:'");
assert!(r.expiration_date.is_some(), "expires from 'Expires:'");
}
#[test]
fn availability_verdicts_on_comment_lines_are_detected() {
let cases: &[(&str, &str)] = &[
(
"fr/AFNIC",
"%% This is the AFNIC Whois server.\n%%\n%% NOT FOUND\n",
),
(
"br/NIC.br",
"% Copyright (c) Nic.br\n% No match for seer-sweep.br\n",
),
("no/NORID", "% Norid AS holds the copyright\n% No match\n"),
(
"at/NIC.AT",
"% Copyright (c)2026 by NIC.AT (1)\n% nothing found\n",
),
(
"cz/CZ.NIC",
"%ERROR:101: no entries found\n% No entries found.\n",
),
("rs/RNIDS", "%ERROR:103: Domain is not registered\n"),
(
"is/ISNIC",
"% Rights restricted by copyright.\n% No entries found for query \"x.is\".\n",
),
("lu/RESTENA", "% WHOIS x.lu\n% No such domain\n"),
("dz/NIC.DZ", "% No match for domain 'X.DZ'.\n"),
("sn/NIC.SN", "%% NOT FOUND\n"),
];
for (name, raw) in cases {
let r = WhoisResponse::parse("example.test", "whois.test", raw);
assert!(
r.is_available(),
"{name}: comment-line verdict must mark available:\n{raw}"
);
}
}
#[test]
fn availability_sentence_phrasings_are_detected() {
let cases: &[(&str, &str)] = &[
(
"kr/KISA",
"query : x.kr\nThe requested domain was not found in the Registry or Registrar\u{2019}s WHOIS Server.\n",
),
(
"edu/EDUCAUSE",
"Domain Name: X.EDU\nThe domain name you requested was not found in our database.\n",
),
(
"pl/NASK",
"No information available about domain name x.pl in the Registry NASK database.\n",
),
];
for (name, raw) in cases {
let r = WhoisResponse::parse("example.test", "whois.test", raw);
assert!(
r.is_available(),
"{name}: sentence-form verdict must mark available:\n{raw}"
);
}
}
#[test]
fn availability_special_short_phrasings_are_detected() {
for (name, raw) in [
("nl/SIDN", "x.nl is free\n"),
("aw/SETAR", "x.aw is free\n"),
(
"mx/NICMexico",
"No_Se_Encontro_El_Objeto/Object_Not_Found\n",
),
(
"ar/NICArgentina",
"El dominio no se encuentra registrado en NIC Argentina\n",
),
] {
let r = WhoisResponse::parse("example.test", "whois.test", raw);
assert!(r.is_available(), "{name} must mark available:\n{raw}");
}
let prose = WhoisResponse::parse(
"example.test",
"whois.test",
"Registration is free of charge today\n",
);
assert!(
!prose.is_available(),
"'is free' inside prose must not mark available"
);
}
#[test]
fn aninf_ga_french_labels_parse() {
let raw = "Domain ID: DOM8624-GA\n\
Nom de domaine: google.ga\n\
Date de création: 2023-06-06T19:15:12.718385Z\n\
Dernière modification: 2026-05-12T17:27:38.344508Z\n\
Date d'expiration: 2027-06-06T19:15:12.694489Z\n\
Registrar: MARKMONITOR Inc.\n\
Statut: actif\n\
\n\
[TECH_C]\n\
Nom: REDACTED_FOR_PRIVACY\n\
Dernière modification: 2023-11-09T18:15:51.973324Z\n\
\n\
Serveur de noms: ns1.google.com\n\
Serveur de noms: ns2.google.com\n\
\n\
DNSSEC: unsigned\n";
let r = WhoisResponse::parse("google.ga", "whois.nic.ga", raw);
assert_eq!(r.nameservers, vec!["ns1.google.com", "ns2.google.com"]);
let created = r.creation_date.expect("creation from Date de création");
assert_eq!(
created.format("%Y-%m-%d").to_string(),
"2023-06-06",
"domain creation date"
);
let expires = r.expiration_date.expect("expiry from Date d'expiration");
assert_eq!(expires.format("%Y-%m-%d").to_string(), "2027-06-06");
let updated = r.updated_date.expect("updated from Dernière modification");
assert_eq!(
updated.format("%Y-%m-%d").to_string(),
"2026-05-12",
"domain-level modification must win over the contact block's"
);
}
#[test]
fn whois_retirement_notice_is_registry_unavailable() {
let raw = "Notice: Effective May 1, 2026, the WHOIS service has been retired in accordance with ICANN's RDAP transition policy. All registration data queries are now served via RDAP. The Registration Data Access Protocol (RDAP) base URL is: https://rdap.gmoregistry.net/rdap/";
let r = WhoisResponse::parse("nic.canon", "whois.nic.canon", raw);
assert!(
r.registry_unavailable(),
"retirement notice must be registry_unavailable"
);
assert!(!r.is_available(), "retirement notice is not 'available'");
}
#[test]
fn switch_first_registration_date_on_the_next_line_is_parsed() {
let raw = "Domain name:\nexample.ch\n\nHolder of domain name:\nRedacted\n\n\
First registration date:\n01.12.2003\n";
let r = WhoisResponse::parse_internal("example.ch", "whois.nic.ch", raw);
assert_eq!(
r.creation_date.map(|d| d.format("%Y-%m-%d").to_string()),
Some("2003-12-01".to_string())
);
let raw = "First registration date:\nregistered before 1st January 1996\n";
let r = WhoisResponse::parse_internal("old.ch", "whois.nic.ch", raw);
assert!(r.creation_date.is_none());
}
#[test]
fn generic_parser_basic() {
let raw = r#"
Domain Name: example.com
Registrar: Example Registrar, Inc.
Creation Date: 2020-01-15T00:00:00Z
Expiration Date: 2025-01-15T00:00:00Z
Name Server: ns1.example.com
Name Server: ns2.example.com
"#;
let result = WhoisResponse::parse_internal("example.com", "whois.example.com", raw);
assert_eq!(result.domain, "example.com");
assert_eq!(
result.registrar,
Some("Example Registrar, Inc.".to_string())
);
assert_eq!(result.nameservers.len(), 2);
}
const VERISIGN_COM_FIXTURE: &str = concat!(
" Domain Name: EXAMPLE.COM\r\n",
" Registry Domain ID: 2336799_DOMAIN_COM-VRSN\r\n",
" Registrar WHOIS Server: whois.iana.org\r\n",
" Registrar URL: http://res-dom.iana.org\r\n",
" Updated Date: 2024-08-14T07:01:34Z\r\n",
" Creation Date: 1995-08-14T04:00:00Z\r\n",
" Registry Expiry Date: 2025-08-13T04:00:00Z\r\n",
" Registrar: RESERVED-Internet Assigned Numbers Authority\r\n",
" Registrar IANA ID: 376\r\n",
" Registrar Abuse Contact Email:\r\n",
" Registrar Abuse Contact Phone:\r\n",
" Domain Status: clientDeleteProhibited https://icann.org/epp#clientDeleteProhibited\r\n",
" Domain Status: clientTransferProhibited https://icann.org/epp#clientTransferProhibited\r\n",
" Domain Status: clientUpdateProhibited https://icann.org/epp#clientUpdateProhibited\r\n",
" Name Server: A.IANA-SERVERS.NET\r\n",
" Name Server: B.IANA-SERVERS.NET\r\n",
" DNSSEC: signedDelegation\r\n",
" DNSSEC DS Data: 370 13 2 BE74359954660069D5C63DA59519AB22E46C7A3AEFAD516946B72A91BC93D46F\r\n",
" URL of the ICANN Whois Inaccuracy Complaint Form: https://www.icann.org/wicf/\r\n",
">>> Last update of whois database: 2026-04-20T12:34:56Z <<<\r\n",
);
#[test]
fn test_verisign_thick_com_fixture() {
let result = WhoisResponse::parse_internal(
"example.com",
"whois.verisign-grs.com",
VERISIGN_COM_FIXTURE,
);
assert_eq!(result.domain, "example.com");
assert_eq!(
result.registrar.as_deref(),
Some("RESERVED-Internet Assigned Numbers Authority"),
"must extract registrar from current Verisign thick-WHOIS"
);
assert!(
result
.nameservers
.iter()
.any(|n| n.to_lowercase().contains("a.iana-servers.net")),
"nameservers missing A: {:?}",
result.nameservers
);
assert!(
result
.nameservers
.iter()
.any(|n| n.to_lowercase().contains("b.iana-servers.net")),
"nameservers missing B: {:?}",
result.nameservers
);
assert_eq!(result.nameservers.len(), 2);
assert!(result.creation_date.is_some(), "creation date must parse");
assert!(
result.expiration_date.is_some(),
"expiration date must parse"
);
assert_eq!(result.dnssec.as_deref(), Some("signedDelegation"));
assert!(
result.status.len() >= 3,
"expected >= 3 statuses, got {:?}",
result.status
);
assert!(!result.is_available());
}
const GDPR_REDACTED_COM_FIXTURE: &str = concat!(
" Domain Name: REDACTED-EXAMPLE.COM\r\n",
" Registry Domain ID: 9999999_DOMAIN_COM-VRSN\r\n",
" Registrar WHOIS Server: whois.markmonitor.com\r\n",
" Registrar URL: http://www.markmonitor.com\r\n",
" Updated Date: 2025-11-08T10:00:00Z\r\n",
" Creation Date: 2010-03-12T17:22:00Z\r\n",
" Registry Expiry Date: 2027-03-12T17:22:00Z\r\n",
" Registrar: MarkMonitor Inc.\r\n",
" Registrar IANA ID: 292\r\n",
" Domain Status: clientTransferProhibited https://icann.org/epp#clientTransferProhibited\r\n",
" Registry Registrant ID: REDACTED FOR PRIVACY\r\n",
" Registrant Name: REDACTED FOR PRIVACY\r\n",
" Registrant Organization: REDACTED FOR PRIVACY\r\n",
" Registrant Street: REDACTED FOR PRIVACY\r\n",
" Registrant City: REDACTED FOR PRIVACY\r\n",
" Registrant State/Province: REDACTED FOR PRIVACY\r\n",
" Registrant Postal Code: REDACTED FOR PRIVACY\r\n",
" Registrant Country: REDACTED FOR PRIVACY\r\n",
" Registrant Phone: REDACTED FOR PRIVACY\r\n",
" Registrant Email: Please query the RDDS service of the Registrar of Record identified in this output for information on how to contact the Registrant, Admin, or Tech contact of the queried domain name.\r\n",
" Admin Name: REDACTED FOR PRIVACY\r\n",
" Admin Email: REDACTED FOR PRIVACY\r\n",
" Tech Name: REDACTED FOR PRIVACY\r\n",
" Tech Email: REDACTED FOR PRIVACY\r\n",
" Name Server: NS1.MARKMONITOR.COM\r\n",
" Name Server: NS2.MARKMONITOR.COM\r\n",
" DNSSEC: unsigned\r\n",
">>> Last update of WHOIS database: 2026-04-20T12:00:00Z <<<\r\n",
);
#[test]
fn test_gdpr_redacted_com_fixture() {
let result = WhoisResponse::parse_internal(
"redacted-example.com",
"whois.verisign-grs.com",
GDPR_REDACTED_COM_FIXTURE,
);
assert_eq!(result.registrar.as_deref(), Some("MarkMonitor Inc."));
assert!(result.creation_date.is_some());
assert!(result.expiration_date.is_some());
assert!(result.updated_date.is_some());
assert_eq!(result.nameservers.len(), 2);
assert_eq!(result.dnssec.as_deref(), Some("unsigned"));
assert!(
result.registrant.is_none(),
"registrant must be None when WHOIS says 'REDACTED FOR PRIVACY' \
(got: {:?})",
result.registrant
);
assert!(
result.admin_name.is_none(),
"admin_name must be None for redacted response"
);
assert!(
result.tech_name.is_none(),
"tech_name must be None for redacted response"
);
assert!(
result.registrant_email.is_none(),
"the 'Please query the RDDS service…' notice is not an email \
(got: {:?})",
result.registrant_email
);
assert!(
result.has_core_data(),
"GDPR-redacted response still has core registration data"
);
assert!(!result.is_available());
}
const CRLF_THIN_REGISTRY_FIXTURE: &str = concat!(
"Domain Name: thin-example.com\r\n",
"Registry Domain ID: 1234567_DOMAIN_COM-VRSN\r\n",
"Registrar WHOIS Server: whois.godaddy.com\r\n",
"Registrar URL: http://www.godaddy.com\r\n",
"Updated Date: 2025-09-01T00:00:00Z\r\n",
"Creation Date: 2015-05-20T00:00:00Z\r\n",
"Registry Expiry Date: 2026-05-20T00:00:00Z\r\n",
"Registrar: GoDaddy.com, LLC\r\n",
"Registrar IANA ID: 146\r\n",
"Domain Status: ok https://icann.org/epp#ok\r\n",
"Name Server: ns1.thin-example.com\r\n",
"Name Server: ns2.thin-example.com\r\n",
"DNSSEC: unsigned\r\n",
);
#[test]
fn test_crlf_thin_registry_fixture() {
let result = WhoisResponse::parse_internal(
"thin-example.com",
"whois.verisign-grs.com",
CRLF_THIN_REGISTRY_FIXTURE,
);
assert_eq!(result.registrar.as_deref(), Some("GoDaddy.com, LLC"));
assert!(
result.creation_date.is_some(),
"creation date must parse under CRLF"
);
assert!(
result.expiration_date.is_some(),
"expiration date must parse under CRLF"
);
assert_eq!(result.nameservers.len(), 2);
assert_eq!(result.dnssec.as_deref(), Some("unsigned"));
assert_eq!(result.status.len(), 1);
assert!(result.has_core_data());
assert!(!result.is_available());
}
const AVAILABLE_COM_FIXTURE: &str = concat!(
"No match for domain \"THIS-IS-UNREGISTERED-FOR-SURE-12345.COM\".\r\n",
">>> Last update of WHOIS database: 2026-04-20T12:00:00Z <<<\r\n",
);
#[test]
fn test_available_com_fixture_is_detected() {
let result = WhoisResponse::parse_internal(
"this-is-unregistered-for-sure-12345.com",
"whois.verisign-grs.com",
AVAILABLE_COM_FIXTURE,
);
assert!(
result.is_available(),
"'No match for domain' response must be detected as available"
);
assert!(result.registrar.is_none());
assert!(result.nameservers.is_empty());
}
}