use chrono::{DateTime, Duration, FixedOffset, NaiveDate, NaiveDateTime, NaiveTime, 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()
}
pub(super) fn line_field_patterns(labels: &[&str]) -> Vec<Regex> {
labels
.iter()
.map(|label| compile(&format!(r"(?im)^[ \t]*{label}:[ \t]*(.+)$")))
.collect()
}
pub(super) fn normalize_label_padding(raw: &str) -> std::borrow::Cow<'_, str> {
static_regex! {
LABEL_PADDING = r"(?m)^([ \t]*[A-Za-z][A-Za-z0-9 /()_-]*?)(?:\.{2,}[ \t]*|[ \t]+):";
TRAILING_LEADER = r"(?m)^([ \t]*[A-Za-z][A-Za-z0-9 /()_-]*?):\.{2,}";
}
match LABEL_PADDING.replace_all(raw, "$1:") {
std::borrow::Cow::Borrowed(b) => TRAILING_LEADER.replace_all(b, "$1: "),
std::borrow::Cow::Owned(o) => {
std::borrow::Cow::Owned(TRAILING_LEADER.replace_all(&o, "$1: ").into_owned())
}
}
}
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",
"registrar-name",
"Registration Service Provider",
])
});
static REGISTRANT_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
let mut patterns = field_patterns(&[
"Registrant Name",
"Registrant",
"Registrant Contact Name",
"registrant-name",
]);
patterns.extend(line_field_patterns(&["owner"]));
patterns
});
const REGISTRANT_HEADINGS: &[&str] = &[
"registrant",
"holder",
"domain holder",
"owner",
"owner contact",
];
static ORGANIZATION_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
let mut patterns = field_patterns(&[
"Registrant Organization",
"Registrant Contact Organisation",
"Registrant Organisation",
"registrant-organization",
"Domain Holder Organization",
]);
patterns.extend(line_field_patterns(&[
"Organization",
"org-name",
"Org Name",
r"Company English Name(?: \([^)]*\))?",
"Org",
]));
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",
"Commencement Date",
"Fecha de registro",
]);
patterns.extend(line_field_patterns(&[
"Registered",
"Date de création",
"Registered On",
"Activation",
]));
patterns.push(compile(r"(?im)^[ \t]*Record created on[ \t]+(.+)$"));
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",
"Fecha de vencimiento",
]);
patterns.extend(line_field_patterns(&[
"Date d'expiration",
"Valid Until",
"Exp date",
"Expiration",
"expire",
]));
patterns.push(compile(r"(?im)^[ \t]*Record expires on[ \t]+(.+)$"));
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",
"Modified Date",
"Update Date",
]);
patterns.extend(line_field_patterns(&[
"Dernière modification",
"Last updated",
"Information Last Updated",
"Modification date",
"Updated",
"changed",
]));
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",
r"Name Server \(DB\)",
r"NS \d+",
]));
patterns
});
static REGISTRANT_EMAIL_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
field_patterns(&[
"Registrant Email",
"Registrant E-mail",
"Registrant Contact Email",
])
});
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(|| line_field_patterns(&["Admin Name", "Administrative Contact Name"]));
static ADMIN_ORG_PATTERNS: LazyLock<Vec<Regex>> =
LazyLock::new(|| line_field_patterns(&["Admin Organization"]));
static ADMIN_EMAIL_PATTERNS: LazyLock<Vec<Regex>> =
LazyLock::new(|| line_field_patterns(&["Admin Email", "Admin E-mail"]));
static ADMIN_PHONE_PATTERNS: LazyLock<Vec<Regex>> =
LazyLock::new(|| line_field_patterns(&["Admin Phone", "Admin Tel"]));
static TECH_NAME_PATTERNS: LazyLock<Vec<Regex>> =
LazyLock::new(|| line_field_patterns(&["Tech Name", "Technical Contact Name"]));
static TECH_ORG_PATTERNS: LazyLock<Vec<Regex>> =
LazyLock::new(|| line_field_patterns(&["Tech Organization"]));
static TECH_EMAIL_PATTERNS: LazyLock<Vec<Regex>> =
LazyLock::new(|| line_field_patterns(&["Tech Email", "Tech E-mail"]));
static TECH_PHONE_PATTERNS: LazyLock<Vec<Regex>> =
LazyLock::new(|| line_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 original = raw;
let normalized = normalize_label_padding(raw);
let raw: &str = &normalized;
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_HEADINGS));
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: original.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 tld has no whois server",
"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",
];
pub(super) 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(decode_html_entities(value).into_owned());
}
}
}
None
}
const HTML_ENTITIES: &[(&str, &str)] = &[
("amp", "&"),
("lt", "<"),
("gt", ">"),
("quot", "\""),
("apos", "'"),
("nbsp", " "),
("aacute", "á"),
("agrave", "à"),
("acirc", "â"),
("atilde", "ã"),
("auml", "ä"),
("eacute", "é"),
("egrave", "è"),
("ecirc", "ê"),
("euml", "ë"),
("iacute", "í"),
("icirc", "î"),
("iuml", "ï"),
("oacute", "ó"),
("ocirc", "ô"),
("otilde", "õ"),
("ouml", "ö"),
("uacute", "ú"),
("ugrave", "ù"),
("ucirc", "û"),
("uuml", "ü"),
("ccedil", "ç"),
("ntilde", "ñ"),
("Aacute", "Á"),
("Agrave", "À"),
("Eacute", "É"),
("Egrave", "È"),
("Ccedil", "Ç"),
("Ntilde", "Ñ"),
];
fn decode_html_entities(value: &str) -> std::borrow::Cow<'_, str> {
static_regex! {
ENTITY = r"&(#[0-9]{1,7}|#[xX][0-9A-Fa-f]{1,6}|[A-Za-z]{2,8});";
}
if !value.contains('&') {
return std::borrow::Cow::Borrowed(value);
}
ENTITY.replace_all(value, |caps: ®ex::Captures<'_>| {
let name = &caps[1];
let decoded = if let Some(num) = name.strip_prefix('#') {
let code = match num.strip_prefix(['x', 'X']) {
Some(hex) => u32::from_str_radix(hex, 16).ok(),
None => num.parse().ok(),
};
code.and_then(char::from_u32).map(String::from)
} else {
HTML_ENTITIES
.iter()
.find(|(n, _)| *n == name)
.map(|(_, c)| (*c).to_string())
};
decoded.unwrap_or_else(|| caps[0].to_string())
})
}
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"
|| lower == "personal information"
|| lower == "no object found!"
}
const SECTION_NAME_KEYS: &[&str] = &["name", "organization name", "organisation name", "nom"];
fn extract_section_value(text: &str, labels: &[&str]) -> Option<String> {
let mut lines = text.lines();
while let Some(line) = lines.next() {
if !is_section_heading(line, labels) {
continue;
}
let mut first = true;
let mut skipped_blank = false;
for body in lines.by_ref() {
let body = body.trim();
if body.is_empty() {
if first && !skipped_blank {
skipped_blank = true;
continue;
}
break;
}
let value = match body.split_once(':') {
None if first => Some(body),
None => None,
Some((key, value)) => SECTION_NAME_KEYS
.iter()
.any(|k| key.trim().eq_ignore_ascii_case(k))
.then(|| value.trim()),
};
first = false;
if let Some(value) = value.filter(|v| !v.is_empty() && !is_placeholder_value(v)) {
return Some(value.to_string());
}
}
}
None
}
fn is_section_heading(line: &str, labels: &[&str]) -> bool {
let heading = line
.trim()
.trim_start_matches('*')
.trim()
.trim_end_matches(':')
.trim_end();
let heading = heading
.strip_prefix('[')
.and_then(|h| h.strip_suffix(']'))
.unwrap_or(heading);
labels.iter().any(|l| heading.eq_ignore_ascii_case(l))
}
#[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_fixed_offset_date(
value: &str,
date_fmt: &str,
utc_offset_hours: i64,
) -> Option<DateTime<Utc>> {
let value = value.trim();
if let Ok(local) = NaiveDateTime::parse_from_str(value, &format!("{date_fmt} %H:%M:%S")) {
return local
.and_utc()
.checked_sub_signed(Duration::hours(utc_offset_hours));
}
NaiveDate::parse_from_str(value, date_fmt)
.ok()
.map(|date| date.and_time(NaiveTime::MIN).and_utc())
}
fn split_utc_offset(value: &str) -> Option<(&str, Duration)> {
static_regex! {
UTC_OFFSET = r"^(.*\d:\d{2}(?::\d{2})?(?:\.\d+)?)[ \t]*\(?(?:UTC|GMT)?[ \t]*([+-])(\d{1,2})(?::?(\d{2}))?\)?$";
}
let caps = UTC_OFFSET.captures(value)?;
let hours: i64 = caps.get(3)?.as_str().parse().ok()?;
let minutes: i64 = caps.get(4).map_or(Some(0), |m| m.as_str().parse().ok())?;
if hours > 14 || minutes > 59 {
return None;
}
let offset = Duration::minutes(hours * 60 + minutes);
let offset = if &caps[2] == "-" { -offset } else { offset };
Some((caps.get(1)?.as_str(), offset))
}
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>> {
static_regex! {
TICKET_SUFFIX = r"\s+#\d+$";
}
const MAX_DATE_LEN: usize = 128;
let trimmed = date_str.trim();
if trimmed.len() > MAX_DATE_LEN {
return None;
}
let zulu = trimmed.strip_suffix(" UTC").map(|d| format!("{d}Z"));
let cleaned = zulu
.as_deref()
.unwrap_or(trimmed)
.replace(" (UTC)", "")
.replace(" (GMT+0:00)", "")
.replace(" +0000", "Z");
let cleaned = TICKET_SUFFIX.replace(&cleaned, "");
let cleaned = cleaned.trim_end_matches('.');
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));
}
if let Some((local, offset)) = split_utc_offset(cleaned) {
return parse_naive_date(local, order)?.checked_sub_signed(offset);
}
parse_naive_date(cleaned, order).or_else(|| cleaned.parse::<DateTime<Utc>>().ok())
}
fn parse_naive_date(cleaned: &str, order: DateOrder) -> Option<DateTime<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-%dT%H:%M:%S%.f",
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%d %H:%M:%S%.f",
"%d-%b-%Y %H:%M:%S",
"%d-%b-%Y %H:%M:%S%.f",
"%d-%b-%Y %H:%M:%SZ",
"%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",
"%B %d %Y",
"%a %b %d %Y",
"%a %b %d %H:%M:%S %Y",
"%Y%m%d",
"%Y%m%d %H:%M:%S",
"%d %b %Y",
"%b %d %Y %I:%M%p",
"%Y-%b-%d",
];
const DAY_FIRST: &[&str] = &[
"%d.%m.%Y %H:%M:%S",
"%d.%m.%Y",
"%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",
"%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) = NaiveDateTime::parse_from_str(cleaned, fmt) {
return Some(dt.and_utc());
}
if let Ok(d) = NaiveDate::parse_from_str(cleaned, fmt) {
return Some(d.and_hms_opt(0, 0, 0)?.and_utc());
}
}
None
}
fn extract_nameservers(text: &str) -> Vec<String> {
let nameservers = extract_labelled_nameservers(text);
if nameservers.is_empty() {
extract_nameserver_block(text)
} else {
nameservers
}
}
const NAMESERVER_HEADINGS: &[&str] = &[
"name servers", "dns servers", "name server information", "name servers information", "name servers in the listed order", "nameservers", "domain nameservers", "domain servers in listed order", "domain servers", ];
const NAMESERVER_ENTRY_PREFIXES: &[&str] = &["dns:", "name:"];
fn extract_nameserver_block(text: &str) -> Vec<String> {
let mut nameservers = Vec::new();
let mut lines = text.lines();
while let Some(line) = lines.next() {
let heading = line
.trim()
.trim_start_matches('*')
.trim_start()
.trim_end_matches(':')
.to_lowercase();
if !NAMESERVER_HEADINGS.contains(&heading.as_str()) {
continue;
}
let mut started = false;
for line in lines.by_ref() {
let entry = line.trim_start();
let entry = NAMESERVER_ENTRY_PREFIXES
.iter()
.find_map(|p| {
entry
.get(..p.len())
.filter(|head| head.eq_ignore_ascii_case(p))
.map(|_| &entry[p.len()..])
})
.unwrap_or(entry);
let host = entry.split_whitespace().next().unwrap_or("");
if host.is_empty() && !started {
continue;
}
if !is_hostname(host) {
break;
}
started = true;
if let Some(ns) = clean_nameserver(host) {
push_bounded(&mut nameservers, ns, MAX_NAMESERVERS);
}
}
if !nameservers.is_empty() {
break;
}
}
nameservers
}
fn is_hostname(s: &str) -> bool {
static_regex! {
HOSTNAME = r"^(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z][A-Za-z0-9-]{0,62}\.?$";
}
HOSTNAME.is_match(s)
}
const NAMESERVER_PLACEHOLDERS: &[&str] = &["not.defined"];
pub(crate) fn is_nameserver_placeholder(name: &str) -> bool {
let name = name.trim_end_matches('.');
NAMESERVER_PLACEHOLDERS
.iter()
.any(|p| name.eq_ignore_ascii_case(p))
}
fn clean_nameserver(token: &str) -> Option<String> {
let ns = token.trim_end_matches('.').to_lowercase();
(!ns.is_empty() && !is_nameserver_placeholder(&ns)).then_some(ns)
}
fn extract_labelled_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();
if let Some(ns) = clean_nameserver(raw.split_whitespace().next().unwrap_or(raw)) {
push_bounded(&mut nameservers, ns, MAX_NAMESERVERS);
}
}
}
}
nameservers
}
const CONTACT_HEADINGS: &[&str] = &[
"registrant",
"registrant contact",
"owner contact",
"holder",
"domain holder",
"admin contact",
"administrative contact",
"administrative contacts",
"tech contact",
"technical contact",
"technical contacts",
"billing contact",
"billing contacts",
];
fn is_contact_heading(line: &str) -> bool {
let heading = line
.trim()
.trim_start_matches(['%', '#'])
.trim()
.trim_end_matches(':')
.trim_end();
CONTACT_HEADINGS
.iter()
.any(|h| heading.eq_ignore_ascii_case(h))
}
const STATUS_LABELS: &[&str] = &[
"domain status:",
"registration status:",
"status:",
"state:",
"statut:",
"domain state:",
"domaintype:",
];
static_regex! {
KG_DOMAIN_STATUS = r"^Domain\s+\S+\s+\(([A-Za-z][A-Za-z ]*)\)\s*$";
}
fn extract_status_top_level(raw: &str) -> Vec<String> {
let mut statuses = Vec::new();
let mut in_contact = false;
for line in raw.lines() {
if statuses.len() >= MAX_STATUSES {
break;
}
let trimmed = line.trim_start();
if trimmed.starts_with('[') && trimmed.contains(']') {
break;
}
if is_contact_heading(trimmed) {
in_contact = true;
continue;
}
if trimmed.trim_end().is_empty() {
in_contact = false;
continue;
}
if in_contact || trimmed.starts_with('%') || trimmed.starts_with('#') {
continue;
}
if let Some(caps) = KG_DOMAIN_STATUS.captures(trimmed) {
if let Some(m) = caps.get(1) {
push_bounded(&mut statuses, m.as_str().to_string(), MAX_STATUSES);
}
continue;
}
let lower = trimmed.to_lowercase();
let value_opt = STATUS_LABELS
.iter()
.find(|label| lower.starts_with(*label))
.map(|label| &trimmed[label.len()..]);
if let Some(rest) = value_opt {
for entry in rest.split(',') {
let phrase = entry
.split_whitespace()
.take_while(|word| {
*word != "-"
&& !word.starts_with("http://")
&& !word.starts_with("https://")
})
.collect::<Vec<_>>()
.join(" ");
push_bounded(
&mut statuses,
decode_html_entities(&phrase).into_owned(),
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 fixed_offset_date_shifts_datetimes_but_not_bare_dates() {
let fmt = |d: Option<DateTime<Utc>>| d.map(|d| d.to_rfc3339());
assert_eq!(
fmt(parse_fixed_offset_date(
" 2026-11-04 00:17:02 ",
"%Y-%m-%d",
8
))
.as_deref(),
Some("2026-11-03T16:17:02+00:00")
);
assert_eq!(
fmt(parse_fixed_offset_date("2026-11-04", "%Y-%m-%d", 8)).as_deref(),
Some("2026-11-04T00:00:00+00:00")
);
assert_eq!(parse_fixed_offset_date("garbage", "%Y-%m-%d", 8), None);
}
#[test]
fn fixed_offset_date_at_chrono_min_does_not_panic() {
let min = NaiveDateTime::MIN.format("%Y-%m-%d %H:%M:%S").to_string();
assert!(
NaiveDateTime::parse_from_str(&min, "%Y-%m-%d %H:%M:%S").is_ok(),
"fixture must reach the offset subtraction: {min}"
);
assert_eq!(parse_fixed_offset_date(&min, "%Y-%m-%d", 8), None);
}
#[test]
fn registrant_contact_email_label_is_read() {
let r = make_response("Registrant Contact Email: hostmaster@example.cn\n");
assert_eq!(r.registrant_email.as_deref(), Some("hostmaster@example.cn"));
}
#[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 comma_separated_state_keeps_every_status() {
let raw = "domain: MICROSOFT.RU\n\
state: REGISTERED, DELEGATED, VERIFIED\n\
registrar: RU-CENTER-RU\n";
assert_eq!(
extract_status_top_level(raw),
vec!["REGISTERED", "DELEGATED", "VERIFIED"]
);
let raw = "Domain Status: clientHold https://icann.org/epp#clientHold\n";
assert_eq!(extract_status_top_level(raw), vec!["clientHold"]);
}
#[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_freenom_gq_reply() {
let raw = "The domain you requested is not known in Freenoms database.\n\
This TLD has no whois server.\n";
let r = WhoisResponse::parse_internal("nic.gq", "whois.dominio.gq", raw);
assert!(r.registry_unavailable());
assert!(!r.is_available());
}
#[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_splits_at_most_one_utc_offset() {
let one = parse_date("2024-01-15 10:30:00 +01").expect("single offset");
assert_eq!(one.to_rfc3339(), "2024-01-15T09:30:00+00:00");
assert_eq!(parse_date("2024-01-15 10:30:00+01:00+01:00"), None);
}
#[test]
fn parse_date_rejects_long_offset_chains_without_deep_recursion() {
let hostile = format!("1:00{}", "-1:00".repeat(20_000));
let started = std::time::Instant::now();
let parsed = std::thread::Builder::new()
.stack_size(2 * 1024 * 1024)
.spawn(move || parse_date(&hostile))
.expect("spawn")
.join()
.expect("no stack overflow");
assert_eq!(parsed, None);
assert!(started.elapsed() < std::time::Duration::from_secs(1));
}
#[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());
}
fn ymd(dt: Option<DateTime<Utc>>) -> Option<String> {
dt.map(|d| d.format("%Y-%m-%d").to_string())
}
#[test]
fn traficom_dot_leader_labels_parse() {
let raw = "domain.............: nic.fi\n\
status.............: Registered\n\
created............: 16.2.1999 00:00:00\n\
expires............: 2.3.2027 14:08:22\n\
modified...........: 31.1.2026 16:08:34\n\
Nameservers\n\
nserver............: ns-se.elisa.net [OK]\n\
nserver............: ns-fi.elisa.net [OK]\n\
Registrar\n\
registrar..........: Ascio Technologies\n\
www................: www.ascio.com\n";
let r = WhoisResponse::parse_internal("nic.fi", "whois.fi", raw);
assert_eq!(r.registrar.as_deref(), Some("Ascio Technologies"));
assert_eq!(ymd(r.creation_date).as_deref(), Some("1999-02-16"));
assert_eq!(ymd(r.expiration_date).as_deref(), Some("2027-03-02"));
assert_eq!(ymd(r.updated_date).as_deref(), Some("2026-01-31"));
assert_eq!(r.nameservers, vec!["ns-se.elisa.net", "ns-fi.elisa.net"]);
assert_eq!(r.status, vec!["Registered"]);
assert!(r.has_core_data());
assert_eq!(r.raw_response, raw);
}
#[test]
fn kaznic_dot_leaders_and_gmt_suffix_parse() {
let raw = "Domain Name............: nic.kz\n\
Name Server list\n\
Name Server............: ns.nic.kz\n\
IP Address.............: 194.0.21.5, 2001:678:98:1::5\n\
Name Server............: ns1.nic.kz\n\
Domain created.........: 1999-08-17 21:34:57 (GMT+0:00)\n\
Last modified..........: 2022-12-05 17:28:31 (GMT+0:00)\n\
Domain status..........: ok - Normal state.\n\
Registrar created......: KAZNIC\n\
Current Registrar......: KAZNIC\n";
let r = WhoisResponse::parse_internal("nic.kz", "whois.nic.kz", raw);
assert_eq!(r.registrar.as_deref(), Some("KAZNIC"));
assert_eq!(ymd(r.creation_date).as_deref(), Some("1999-08-17"));
assert_eq!(ymd(r.updated_date).as_deref(), Some("2022-12-05"));
assert_eq!(r.nameservers, vec!["ns.nic.kz", "ns1.nic.kz"]);
assert_eq!(r.status, vec!["ok"]);
}
#[test]
fn nic_monaco_space_padded_labels_parse() {
let raw = "Domain : NIC.MC\n\
Status : ACTIVE\n\
Registrar : NIC Monaco\n\
nserver : ns1.nic.mc\n\
nserver : ns2.nic.mc\n\
Created : 2007-01-08T11:06:00Z\n\
Expires on : 2027-01-08\n\
Last update : 2026-01-08T00:10:01Z\n";
let r = WhoisResponse::parse_internal("nic.mc", "whois.nic.mc", raw);
assert_eq!(r.registrar.as_deref(), Some("NIC Monaco"));
assert_eq!(ymd(r.creation_date).as_deref(), Some("2007-01-08"));
assert_eq!(ymd(r.expiration_date).as_deref(), Some("2027-01-08"));
assert_eq!(r.nameservers, vec!["ns1.nic.mc", "ns2.nic.mc"]);
assert_eq!(r.status, vec!["ACTIVE"]);
}
#[test]
fn label_padding_leaves_values_and_prose_alone() {
let raw = "Registrar URL: https://example..test/a b :x\n\
This line has a colon : in prose.\n";
let normalized = normalize_label_padding(raw);
assert!(normalized.contains("Registrar URL: https://example..test/a b :x"));
assert!(normalized.contains("This line has a colon: in prose."));
}
#[test]
fn kg_ctime_dates_and_nameserver_block_parse() {
let raw = "Domain NIC.KG (ACTIVE)\n\
Record created: Tue Jan 16 10:31:46 2001\n\
Record last updated on: Tue Mar 3 21:02:44 2026\n\
Record expires on: Mon Mar 22 23:59:00 2027\n\
Name servers in the listed order:\n\
\n\
NS1.WEBSITE.ORG\n\
NS2.WEBSITE.ORG\n\
\n\
Some trailing.notice line\n";
let r = WhoisResponse::parse_internal("nic.kg", "whois.kg", raw);
assert_eq!(ymd(r.creation_date).as_deref(), Some("2001-01-16"));
assert_eq!(ymd(r.updated_date).as_deref(), Some("2026-03-03"));
assert_eq!(ymd(r.expiration_date).as_deref(), Some("2027-03-22"));
assert_eq!(r.nameservers, vec!["ns1.website.org", "ns2.website.org"]);
}
#[test]
fn nameserver_block_stops_at_non_host_line() {
let raw = "registration status: busy, active\n\
NAME SERVER INFORMATION:\n\
ns.digsys.bg \n\
a.nic.bg 192.92.129.99 2a02:6a80::192:92:129:99 \n\
DNSSEC: active\n";
let r = WhoisResponse::parse_internal("nic.bg", "whois.register.bg", raw);
assert_eq!(r.nameservers, vec!["ns.digsys.bg", "a.nic.bg"]);
assert_eq!(r.status, vec!["busy", "active"]);
}
#[test]
fn amnic_indented_dns_servers_block_parses() {
let raw = " Domain name: google.am\n\
\x20 Registrar: abcdomain (ABCDomain LLC)\n\
\x20 DNS servers:\n\
\x20 ns1.google.com\n\
\x20 ns2.google.com\n\
\n\
\x20 Registered: 1999-06-05\n\
\x20 Expires: 2027-04-15\n";
let r = WhoisResponse::parse_internal("google.am", "whois.amnic.net", raw);
assert_eq!(r.nameservers, vec!["ns1.google.com", "ns2.google.com"]);
assert!(r.has_core_data());
}
#[test]
fn nic_mexico_dns_lines_parse() {
let raw = "Domain Name: nic.mx\r\n\
Created On: 1997-01-15\r\n\
Expiration Date: 2027-01-14\r\n\
Registrar: Registry .MX\r\n\
Name Servers:\r\n\
\x20 DNS: a.nic.mx 2001:1250:a000:0:0:0:0:1, 207.248.65.1\r\n\
\x20 DNS: b.nic.mx 2001:1250:b000:0:0:0:0:1, 201.131.250.1\r\n\
DNSSEC DS Records:\r\n";
let r = WhoisResponse::parse_internal("nic.mx", "whois.mx", raw);
assert_eq!(r.nameservers, vec!["a.nic.mx", "b.nic.mx"]);
}
#[test]
fn hkirc_dash_dates_and_spaced_nameserver_block_parse() {
let raw = "Domain Name Commencement Date: 06-04-2004\n\
Expiry Date: 31-03-2027 \n\
Domain Status: Active\n\
Registrar Name: MARKMONITOR INC.\n\
Name Servers Information:\n\
\n\
NS1.GOOGLE.COM\n\
NS2.GOOGLE.COM\n\
\n\
Status Information:\n";
let r = WhoisResponse::parse_internal("google.hk", "whois.hkirc.hk", raw);
assert_eq!(ymd(r.creation_date).as_deref(), Some("2004-04-06"));
assert_eq!(ymd(r.expiration_date).as_deref(), Some("2027-03-31"));
assert_eq!(r.nameservers, vec!["ns1.google.com", "ns2.google.com"]);
assert!(r.has_core_data());
}
#[test]
fn registro_br_ticket_suffixed_compact_date_parses() {
let raw = "domain: nic.br\n\
nserver: a.dns.br\n\
created: 19970711 #46903\n\
changed: 20180327\n";
let r = WhoisResponse::parse_internal("nic.br", "whois.registro.br", raw);
assert_eq!(ymd(r.creation_date).as_deref(), Some("1997-07-11"));
}
#[test]
fn isnic_full_month_name_date_parses() {
let raw = "domain: nic.is\n\
created: September 5 2000\n\
expires: September 5 2027\n";
let r = WhoisResponse::parse_internal("nic.is", "whois.isnic.is", raw);
assert_eq!(ymd(r.creation_date).as_deref(), Some("2000-09-05"));
assert_eq!(ymd(r.expiration_date).as_deref(), Some("2027-09-05"));
}
#[test]
fn restena_registrar_name_and_nic_bo_spanish_dates_parse() {
let lu = "domainname: nic.lu\nregistrar-name: Fondation Restena\n";
let r = WhoisResponse::parse_internal("nic.lu", "whois.dns.lu", lu);
assert_eq!(r.registrar.as_deref(), Some("Fondation Restena"));
let bo = "Dominio: google.bo\r\n\
Fecha de registro: 2019-01-15\r\n\
Fecha de vencimiento: 2027-01-15\r\n";
let r = WhoisResponse::parse_internal("google.bo", "whois.nic.bo", bo);
assert_eq!(ymd(r.creation_date).as_deref(), Some("2019-01-15"));
assert_eq!(ymd(r.expiration_date).as_deref(), Some("2027-01-15"));
}
fn generic(domain: &str, raw: &str) -> WhoisResponse {
WhoisResponse::parse_internal(domain, "whois.example", raw)
}
#[test]
fn status_phrases_are_kept_whole_and_glosses_dropped() {
let r = generic("nic.am", "Status: active, registrar locked\n");
assert_eq!(r.status, vec!["active", "registrar locked"]);
let r = generic("google.pk", "Status: Domain is Registered\n");
assert_eq!(r.status, vec!["Domain is Registered"]);
let r = generic(
"example.com",
"Domain Status: clientHold https://icann.org/epp#clientHold\n",
);
assert_eq!(r.status, vec!["clientHold"]);
let r = generic("nic.kz", "Domain status: ok - Normal state.\n");
assert_eq!(r.status, vec!["ok"]);
}
#[test]
fn contact_section_state_and_status_are_not_domain_status() {
let mx = "Domain Name: nic.mx\n\nRegistrant:\n Name: NIC México\n State: Nuevo Leon\n Country: Mexico\n";
assert!(generic("nic.mx", mx).status.is_empty());
let kz = "Domain Name: nic.kz\n\nRegistrant\nName: KazNIC\nState: VKO\n\nDomain status: ok - Normal state.\n";
assert_eq!(generic("nic.kz", kz).status, vec!["ok"]);
let ua = "domain: nic.ua\nstatus: clientTransferProhibited\n\n% Registrant:\n% ===========\nperson: NIC.UA LLC\nstatus: ok\nstatus: linked\n";
assert_eq!(
generic("nic.ua", ua).status,
vec!["clientTransferProhibited"]
);
}
#[test]
fn nameservers_drop_the_root_dot_and_placeholders() {
let r = generic(
"nic.ru",
"nserver: ns5.nic.ru. 31.177.67.100\nnserver: NS6.nic.ru.\n",
);
assert_eq!(r.nameservers, vec!["ns5.nic.ru", "ns6.nic.ru"]);
let r = generic(
"nic.uz",
" Name Server: ns.uz. 91.212.89.8\n Name Server: not.defined. \n",
);
assert_eq!(r.nameservers, vec!["ns.uz"]);
}
#[test]
fn registrar_admin_email_is_not_the_admin_email() {
let raw =
"Registrar Admin Email: CCOPS@markmonitor.com\nAdmin Email: dns-admin@google.com\n";
assert_eq!(
generic("google.ps", raw).admin_email.as_deref(),
Some("dns-admin@google.com")
);
let raw = "Registrar Admin Email: registre@arcep.bf\n";
assert!(generic("nic.bf", raw).admin_email.is_none());
}
#[test]
fn dot_leaders_after_the_colon_are_padding() {
let raw = "Domain:.............nic.tg\nRegistrar:..........NETMASTER SARL\n";
assert_eq!(
generic("nic.tg", raw).registrar.as_deref(),
Some("NETMASTER SARL")
);
}
#[test]
fn personal_information_is_a_placeholder() {
let raw = "Tech Organization: Personal Information\n";
assert!(generic("nic.th", raw).tech_organization.is_none());
}
#[test]
fn space_separated_fractional_seconds_parse() {
let d = parse_date("2023-07-17 17:23:05.989470").map(|d| d.to_rfc3339());
assert_eq!(d.as_deref(), Some("2023-07-17T17:23:05.989470+00:00"));
}
fn dates(domain: &str, raw: &str) -> [Option<String>; 3] {
let r = generic(domain, raw);
[r.creation_date, r.expiration_date, r.updated_date]
.map(|d| d.map(|d| d.format("%Y-%m-%d %H:%M").to_string()))
}
fn some(s: &str) -> Option<String> {
Some(s.to_string())
}
#[test]
fn registry_date_labels_are_read() {
let raw = "Created: 2003-09-30\nValid Until: 2027-09-30\nUpdated: 2026-09-08\n";
assert_eq!(
dates("google.sk", raw),
[
some("2003-09-30 00:00"),
some("2027-09-30 00:00"),
some("2026-09-08 00:00")
]
);
let raw = "Expires On: 2027-03-18\nRegistered On: 2004-08-03\nRenewed On: 2026-03-03\n\nInformation Last Updated: 2026-05-18T03:10:41.000+03:00\n";
assert_eq!(
dates("google.ug", raw),
[
some("2004-08-03 00:00"),
some("2027-03-18 00:00"),
some("2026-05-18 00:10")
]
);
let raw = "Activation:.........2023-03-02\nExpiration:.........2030-06-26\n";
assert_eq!(
dates("nic.tg", raw),
[some("2023-03-02 00:00"), some("2030-06-26 00:00"), None]
);
assert_eq!(
dates("nic.no", "Last updated: 2026-01-27\n")[2],
some("2026-01-27 00:00")
);
assert_eq!(
dates("nic.bn", "Modified Date:\t\t02-Mar-2026 14:08:42\n")[2],
some("2026-03-02 14:08")
);
assert_eq!(
dates("nic.by", "Update Date: 2026-08-19\n")[2],
some("2026-08-19 00:00")
);
assert_eq!(
dates("nic.rs", "Modification date: 17.03.2025 14:55:15\n")[2],
some("2025-03-17 14:55")
);
assert_eq!(
dates("nic.si", "created:\t2008-10-28\nexpire:\t\t2027-10-28\n")[1],
some("2027-10-28 00:00")
);
}
#[test]
fn whois_server_clock_is_not_the_updated_date() {
let raw = "Created on..............: 2001-Aug-23.\nExpires on..............: 2027-Aug-22.\n\nLast Update Time: 2026-10-13T19:16:43+03:00\n";
assert_eq!(
dates("google.com.tr", raw),
[some("2001-08-23 00:00"), some("2027-08-22 00:00"), None]
);
}
#[test]
fn record_created_and_expires_sentences_are_read() {
let raw = " Record expires on 2027-10-31 12:47:54 (UTC+8)\n Record created on 2005-10-27 17:02:34 (UTC+8)\n";
assert_eq!(
dates("google.tw", raw),
[some("2005-10-27 09:02"), some("2027-10-31 04:47"), None]
);
}
#[test]
fn domain_changed_line_is_the_updated_date() {
let raw = "domain: nic.at\nchanged: 20200427 16:03:40\nregistered: 20000825 20:16:35\n\nnic-hdl: X\nchanged: 20121116 16:58:21\n";
assert_eq!(
dates("nic.at", raw),
[some("2000-08-25 20:16"), None, some("2020-04-27 16:03")]
);
}
#[test]
fn named_month_and_twelve_hour_formats_parse() {
let raw = "Updated date: 23 Jun 2026\nCreated date: 17 Jul 2014\nExp date: 16 Jul 2027\n";
assert_eq!(
dates("nic.th", raw),
[
some("2014-07-17 00:00"),
some("2027-07-16 00:00"),
some("2026-06-23 00:00")
]
);
assert_eq!(
dates("nic.jo", "Updated Date: Jul 26 2005 12:00AM\n")[2],
some("2005-07-26 00:00")
);
}
#[test]
fn trailing_utc_offsets_convert_to_utc() {
let fmt = |s: &str| parse_date(s).map(|d| d.format("%Y-%m-%d %H:%M").to_string());
assert_eq!(fmt("2007-10-04 13:40:19+03"), some("2007-10-04 10:40"));
assert_eq!(fmt("14-07-2009 00:00:00 GMT+1"), some("2009-07-13 23:00"));
assert_eq!(fmt("2024-01-15 10:30:00 UTC-5"), some("2024-01-15 15:30"));
assert_eq!(
fmt("2024-01-15 10:30:00 (UTC+5:30)"),
some("2024-01-15 05:00")
);
assert_eq!(
fmt("2026-05-18T03:10:41.000+03:00"),
some("2026-05-18 00:10")
);
assert_eq!(fmt("2024-01-15 10:30:00+99"), None);
}
#[test]
fn nameserver_heading_blocks_are_read() {
let ns = |domain: &str, raw: &str| generic(domain, raw).nameservers;
assert_eq!(
ns(
"nic.be",
"Nameservers:\n\tns3.combell.net\n\tns4.combell.net\n\nKeys:\n"
),
vec!["ns3.combell.net", "ns4.combell.net"]
);
assert_eq!(
ns("nic.aw", "Domain nameservers:\n ns3.sidn.nl\n ns1.sidn.nl\n\nCreation Date: 2014-02-25\n"),
vec!["ns3.sidn.nl", "ns1.sidn.nl"]
);
assert_eq!(
ns("google.tw", " Domain servers in listed order:\n ns1.google.com \n ns2.google.com \n"),
vec!["ns1.google.com", "ns2.google.com"]
);
assert_eq!(
ns(
"google.com.tr",
"** Domain Servers:\nns4.google.com\nns1.google.com\n\n** Additional Info:\n"
),
vec!["ns4.google.com", "ns1.google.com"]
);
assert_eq!(
ns("nic.tn", "DNS servers\nName................: ns4.ati.tn.\nName................: ns6.ati.tn.\n"),
vec!["ns4.ati.tn", "ns6.ati.tn"]
);
}
#[test]
fn numbered_and_annotated_nameserver_labels_are_read() {
let tm = "NS 1 : dns01.nic.tm\n : 74.116.178.11,74.116.179.11\n : 2001:678:4::b\nNS 2 : dns1.nic.tm\n : 194.0.1.11\n";
assert_eq!(
generic("nic.tm", tm).nameservers,
vec!["dns01.nic.tm", "dns1.nic.tm"]
);
let tg = "Name Server (DB):...ns1.nic.tg\nName Server (DB):...ns2.nic.tg\n";
assert_eq!(
generic("nic.tg", tg).nameservers,
vec!["ns1.nic.tg", "ns2.nic.tg"]
);
}
#[test]
fn registrant_sections_are_read() {
let reg = |domain: &str, raw: &str| generic(domain, raw).registrant;
assert_eq!(
reg(
"google.tw",
" Registrant:\n \n Google LLC\n dns-admin@google.com\n"
)
.as_deref(),
Some("Google LLC")
);
assert_eq!(
reg(
"google.com.tr",
"** Registrant:\nGoogle LLC\nHidden upon user request\n"
)
.as_deref(),
Some("Google LLC")
);
assert_eq!(
reg("nic.fi", "Holder\nname...............: Elisa Oyj\n").as_deref(),
Some("Elisa Oyj")
);
assert_eq!(
reg("nic.kz", "Registrant\nName...................: KazNIC\n").as_deref(),
Some("KazNIC")
);
assert_eq!(
reg("nic.tn", "Owner Contact\nName................: ATI\n").as_deref(),
Some("ATI")
);
assert_eq!(
reg(
"nic.sm",
"Owner:\nTelecom Italia San Marino S.p.A.\nVia 28 Luglio, 212\n"
)
.as_deref(),
Some("Telecom Italia San Marino S.p.A.")
);
assert_eq!(reg("nic.sn", "[HOLDER]\nID Contact: NICS100\nType: Organisation\nNom: Network Information Center Sénégal\n").as_deref(), Some("Network Information Center Sénégal"));
}
#[test]
fn section_value_never_takes_another_field_or_crosses_a_blank_line() {
let raw = "Registrar:\nRegistrar IANA ID: 376\n\nSome Other Heading\n";
assert!(generic("example.com", raw).registrar.is_none());
assert!(
generic("nic.mq", "[holder]\nNO OBJECT FOUND!\nobject: 2068011865\n")
.registrant
.is_none()
);
}
#[test]
fn registrar_from_section_or_service_provider_label() {
let raw =
"** Registrar:\nNIC Handle\t\t: ogv40\nOrganization Name\t: ODTÜ GELİŞTİRME VAKFI\n";
assert_eq!(
generic("google.com.tr", raw).registrar.as_deref(),
Some("ODTÜ GELİŞTİRME VAKFI")
);
let raw = "Registration Service Provider: Markmonitor, Inc.\n";
assert_eq!(
generic("google.tw", raw).registrar.as_deref(),
Some("Markmonitor, Inc.")
);
}
#[test]
fn registrant_and_organization_labels_are_read() {
let r = generic("nic.ae", "Registrant Contact Name: Etisalat\nRegistrant Contact Organisation: Etisalat Corp\n");
assert_eq!(
(r.registrant.as_deref(), r.organization.as_deref()),
(Some("Etisalat"), Some("Etisalat Corp"))
);
let r = generic(
"nic.st",
"registrant-organization: ST Registry\nregistrant-name: ST Registry (TEM1)\n",
);
assert_eq!(
(r.registrant.as_deref(), r.organization.as_deref()),
(Some("ST Registry (TEM1)"), Some("ST Registry"))
);
assert_eq!(
generic("nic.br", "owner-c: FAN\nowner: NIC.BR\n")
.registrant
.as_deref(),
Some("NIC.BR")
);
assert_eq!(
generic("nic.th", "Domain Holder Organization: Google LLC\n")
.organization
.as_deref(),
Some("Google LLC")
);
let hk = "Company English Name (It should be the same as the registered/corporation name on your Business Register Certificate or relevant documents): GOOGLE LLC\n";
assert_eq!(
generic("google.hk", hk).organization.as_deref(),
Some("GOOGLE LLC")
);
assert_eq!(
generic("nic.by", "Org: OOO Example\nCountry: BY\n")
.organization
.as_deref(),
Some("OOO Example")
);
}
#[test]
fn plain_registrant_outranks_registrant_contact_name() {
let raw = "Registrant: INSTRA CORPORATION PTY LTD\nRegistrant Contact Name: Domain Administrator\n";
assert_eq!(
generic("nic.au", raw).registrant.as_deref(),
Some("INSTRA CORPORATION PTY LTD")
);
}
#[test]
fn registry_status_labels_are_read() {
let st = |domain: &str, raw: &str| generic(domain, raw).status;
assert_eq!(
st("nic.ga", "Statut: actif\n"),
vec!["actif"]
);
assert_eq!(st("nic.md", "Domain state: OK\n"), vec!["OK"]);
assert_eq!(
st("nic.lu", "domainname: nic.lu\ndomaintype: ACTIVE\n"),
vec!["ACTIVE"]
);
assert_eq!(
st(
"nic.kg",
"% banner\n\nDomain NIC.KG (ACTIVE)\n\nAdministrative Contact:\n"
),
vec!["ACTIVE"]
);
assert!(st("nic.kg", "Domain NIC.KG\n").is_empty());
}
#[test]
fn html_entities_in_values_are_decoded() {
assert_eq!(
generic("nic.tg", "Status:.............Activé\n").status,
vec!["Activé"]
);
assert_eq!(
decode_html_entities("Société & Fils"),
"Société & Fils"
);
assert_eq!(decode_html_entities("A & B &bogus;"), "A & B &bogus;");
assert_eq!(
generic("x.tg", "Registrar: Netmaster & Co\n")
.registrar
.as_deref(),
Some("Netmaster & Co")
);
}
#[test]
fn isle_of_man_reply_parses_with_redacted_contacts() {
let raw = "\n This information has been redacted to comply with European Union General Data Protection Regulations (GDPR).\n\nDomain Name:\tgoogle.im\nDomain Managers\nName: Redacted\nAddress\nRedacted\nDomain Owners / Registrant\nName: Redacted\nAddress\nRedacted\nDomain Details\nExpiry Date: 16/08/2027 23:59:39\nName Server:ns1.google.com.\nName Server:ns2.google.com.\n";
let r = generic("google.im", raw);
assert_eq!(dates("google.im", raw)[1], some("2027-08-16 23:59"));
assert_eq!(r.nameservers, vec!["ns1.google.com", "ns2.google.com"]);
assert!(r.registrar.is_none() && r.registrant.is_none());
}
#[test]
fn t_separated_fractional_seconds_parse() {
let want = Some("2024-01-15T10:30:00".to_string());
let fmt = |d: Option<DateTime<Utc>>| d.map(|d| d.format("%Y-%m-%dT%H:%M:%S").to_string());
assert_eq!(
fmt(parse_date("2024-01-15T10:30:00.000+0000")),
want,
"+0000"
);
assert_eq!(fmt(parse_date("2024-01-15T13:30:00.000+03")), want, "+03");
assert_eq!(fmt(parse_date("2024-01-15T10:30:00.000")), want, "naive");
}
}