use std::collections::BTreeMap;
use crate::domain::DomainName;
use crate::error::Result;
use crate::parser::{parse_datetime, Contact, RecordParser, WhoisRecord};
use crate::transport::{RawResponse, ResponseKind};
const PADDING: [char; 4] = ['.', '_', '-', ' '];
const COMMENT_PREFIXES: [&str; 5] = ["%", "#", ";", ">>>", "--"];
const NULL_VALUES: [&str; 9] = [
"n/a",
"na",
"none",
"not applicable",
"not available",
"unknown",
"-",
"null",
"no data",
];
const MAX_KEY_LEN: usize = 48;
const MAX_KEY_WORDS: usize = 4;
const PROSE_KEYS: [&str; 11] = [
"notice",
"note",
"notes",
"terms of use",
"terms and conditions",
"terms",
"important",
"disclaimer",
"warning",
"attention",
"copyright",
];
const MAX_UNKNOWN_VALUE_WORDS: usize = 12;
#[derive(Debug, Clone, Copy, Default)]
pub struct KeyValueParser {
_private: (),
}
impl KeyValueParser {
pub fn new() -> Self {
KeyValueParser { _private: () }
}
pub fn parse_text(&self, text: &str) -> WhoisRecord {
let mut record = WhoisRecord::new();
let mut extra: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut pending_key: Option<String> = None;
for raw_line in text.lines() {
let line = raw_line.trim_end();
let trimmed = line.trim();
if trimmed.is_empty() {
pending_key = None;
continue;
}
if COMMENT_PREFIXES
.iter()
.any(|prefix| trimmed.starts_with(prefix))
{
pending_key = None;
continue;
}
match split_field(trimmed) {
Some((key, value)) => {
let key = normalise_key(key);
if key.is_empty() {
continue;
}
if value.is_empty() {
pending_key = Some(key);
} else {
pending_key = None;
assign(&mut record, &mut extra, &key, value);
}
}
None => {
if let Some(key) = pending_key.clone() {
assign(&mut record, &mut extra, &key, trimmed);
}
}
}
}
record.extra = extra;
tidy(&mut record);
record
}
}
impl RecordParser for KeyValueParser {
fn name(&self) -> &'static str {
"key-value"
}
fn can_parse(&self, response: &RawResponse) -> bool {
response.kind() == ResponseKind::WhoisText
}
fn parse(&self, response: &RawResponse) -> Result<WhoisRecord> {
Ok(self.parse_text(response.text()))
}
}
fn split_field(line: &str) -> Option<(&str, &str)> {
let (key, value) = line.split_once(':')?;
if value.starts_with("//") {
return None;
}
let key = key.trim();
if key.is_empty() || key.len() > MAX_KEY_LEN {
return None;
}
if key.contains('/') || key.split_whitespace().count() > MAX_KEY_WORDS {
return None;
}
if !key.starts_with(|c: char| c.is_ascii_alphabetic()) {
return None;
}
Some((key, value.trim()))
}
fn normalise_key(key: &str) -> String {
let stripped = key.trim().trim_end_matches(PADDING).trim();
stripped
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase()
}
fn is_null_value(value: &str) -> bool {
let lowered = value.trim().to_lowercase();
lowered.is_empty() || NULL_VALUES.contains(&lowered.as_str())
}
fn assign(
record: &mut WhoisRecord,
extra: &mut BTreeMap<String, Vec<String>>,
key: &str,
value: &str,
) {
if is_null_value(value) {
return;
}
if let Some((role, field)) = split_contact_key(key) {
let contact = match role {
Role::Registrant => record.registrant.get_or_insert_with(Contact::default),
Role::Admin => record.admin.get_or_insert_with(Contact::default),
Role::Tech => record.tech.get_or_insert_with(Contact::default),
Role::Billing => record.billing.get_or_insert_with(Contact::default),
};
if assign_contact(contact, field, value) {
return;
}
}
match key {
"domain" | "domain name" | "domainname" | "ascii" | "idn" | "domain-name" => {
if record.domain.is_none() {
if let Ok(parsed) = DomainName::parse(value) {
record.domain = Some(parsed);
}
}
}
"registry domain id" | "domain id" | "roid" => {
record.registry_id.get_or_insert_with(|| value.to_string());
}
"registrar" | "sponsoring registrar" | "registrar name" | "registrar organization" => {
record.registrar.get_or_insert_with(|| value.to_string());
}
"registrar iana id" | "registrar iana identifier" | "iana id" => {
record
.registrar_iana_id
.get_or_insert_with(|| value.to_string());
}
"registrar whois server" | "whois server" => {
record
.registrar_whois_server
.get_or_insert_with(|| value.to_ascii_lowercase());
}
"registrar url" | "registrar website" | "url" | "website" => {
record
.registrar_url
.get_or_insert_with(|| value.to_string());
}
"registrar abuse contact email" | "abuse contact email" | "abuse-mailbox" => {
record
.abuse_contact_email
.get_or_insert_with(|| value.to_string());
}
"registrar abuse contact phone" | "abuse contact phone" => {
record
.abuse_contact_phone
.get_or_insert_with(|| value.to_string());
}
"creation date"
| "created"
| "created on"
| "created date"
| "registered on"
| "registration date"
| "registration time"
| "domain registration date"
| "registered" => {
set_date(&mut record.created, value);
}
"updated date" | "last updated" | "last update" | "last modified" | "changed"
| "modified" | "last updated on" | "update date" => {
set_date(&mut record.updated, value);
}
"registry expiry date"
| "expiry date"
| "expires"
| "expires on"
| "expiration date"
| "expiration time"
| "paid-till"
| "paid till"
| "renewal date"
| "registrar registration expiration date"
| "expire date" => {
set_date(&mut record.expires, value);
}
"status"
| "domain status"
| "state"
| "eppstatus"
| "epp status"
| "registration status" => {
for status in split_status(value) {
if !record.statuses.contains(&status) {
record.statuses.push(status);
}
}
}
"nserver" | "name server" | "nameserver" | "name servers" | "nameservers" | "ns"
| "host name" => {
for server in split_name_servers(value) {
if !record.name_servers.contains(&server) {
record.name_servers.push(server);
}
}
}
"dnssec" | "dnssec status" | "signed" => {
record.dnssec = Some(parse_dnssec(value));
}
_ => {
if PROSE_KEYS.contains(&key) {
return;
}
if value.split_whitespace().count() > MAX_UNKNOWN_VALUE_WORDS {
return;
}
extra
.entry(key.to_string())
.or_default()
.push(value.to_string());
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Role {
Registrant,
Admin,
Tech,
Billing,
}
fn split_contact_key(key: &str) -> Option<(Role, &str)> {
const PREFIXES: [(&str, Role); 12] = [
("registrant contact", Role::Registrant),
("registrant", Role::Registrant),
("holder", Role::Registrant),
("owner", Role::Registrant),
("administrative contact", Role::Admin),
("admin contact", Role::Admin),
("admin", Role::Admin),
("technical contact", Role::Tech),
("tech contact", Role::Tech),
("tech", Role::Tech),
("billing contact", Role::Billing),
("billing", Role::Billing),
];
for (prefix, role) in PREFIXES {
if let Some(rest) = key.strip_prefix(prefix) {
let field = rest.trim();
return Some((role, if field.is_empty() { "name" } else { field }));
}
}
None
}
fn assign_contact(contact: &mut Contact, field: &str, value: &str) -> bool {
let (slot, normalised): (&mut Option<String>, String) = match field {
"name" | "person" => (&mut contact.name, value.to_string()),
"handle" | "id" | "nic-hdl" => (&mut contact.handle, value.to_string()),
"organization" | "organisation" | "org" | "company" => {
(&mut contact.organization, value.to_string())
}
"city" => (&mut contact.city, value.to_string()),
"state" | "province" | "state/province" | "state or province" => {
(&mut contact.state, value.to_string())
}
"postal code" | "postalcode" | "zip" | "zip code" | "post code" => {
(&mut contact.postal_code, value.to_string())
}
"country" | "country code" | "countrycode" => (&mut contact.country, value.to_uppercase()),
"phone" | "phone number" | "telephone" | "voice" => (&mut contact.phone, value.to_string()),
"fax" | "fax number" | "facsimile" | "fax-no" => (&mut contact.fax, value.to_string()),
"email" | "e-mail" | "email address" | "mail" => (&mut contact.email, value.to_string()),
"street" | "address" | "street address" | "address line 1" | "address line 2" => {
contact.street.push(value.to_string());
return true;
}
_ => return false,
};
if slot.is_none() {
*slot = Some(normalised);
}
true
}
fn set_date(slot: &mut Option<chrono::DateTime<chrono::Utc>>, value: &str) {
if slot.is_none() {
if let Some(parsed) = parse_datetime(value) {
*slot = Some(parsed);
}
}
}
fn split_status(value: &str) -> Vec<String> {
value
.split(',')
.flat_map(|part| part.split_whitespace().take(1))
.filter(|part| !part.is_empty() && !part.starts_with("http"))
.map(str::to_lowercase)
.collect()
}
fn split_name_servers(value: &str) -> Vec<String> {
value
.split([',', ' ', '\t'])
.map(|part| part.trim().trim_end_matches('.').to_lowercase())
.filter(|part| {
part.contains('.')
&& !part.is_empty()
&& part.parse::<std::net::IpAddr>().is_err()
&& !part.starts_with('[')
})
.collect()
}
fn parse_dnssec(value: &str) -> bool {
let lowered = value.trim().to_lowercase();
matches!(
lowered.as_str(),
"signed" | "signeddelegation" | "signed delegation" | "yes" | "true" | "active" | "1"
) || lowered.starts_with("signed")
}
fn tidy(record: &mut WhoisRecord) {
record.name_servers.sort();
record.name_servers.dedup();
record.statuses.dedup();
for contact in [
&mut record.registrant,
&mut record.admin,
&mut record.tech,
&mut record.billing,
] {
if contact.as_ref().is_some_and(Contact::is_empty) {
*contact = None;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(text: &str) -> WhoisRecord {
KeyValueParser::new().parse_text(text)
}
#[test]
fn reads_a_standard_gtld_record() {
let record = parse(
"\
Domain Name: EXAMPLE.COM
Registry Domain ID: 2336799_DOMAIN_COM-VRSN
Registrar WHOIS Server: whois.example-registrar.com
Registrar URL: http://www.example-registrar.com
Updated Date: 2026-08-14T07:01:44Z
Creation Date: 1995-08-14T04:00:00Z
Registry Expiry Date: 2027-08-13T04:00:00Z
Registrar: Example Registrar, LLC
Registrar IANA ID: 376
Registrar Abuse Contact Email: abuse@example-registrar.com
Domain Status: clientDeleteProhibited https://icann.org/epp#clientDeleteProhibited
Domain Status: clientTransferProhibited https://icann.org/epp#clientTransferProhibited
Name Server: A.IANA-SERVERS.NET
Name Server: B.IANA-SERVERS.NET
DNSSEC: signedDelegation
",
);
assert_eq!(record.domain.as_ref().unwrap().as_ascii(), "example.com");
assert_eq!(record.registrar.as_deref(), Some("Example Registrar, LLC"));
assert_eq!(record.registrar_iana_id.as_deref(), Some("376"));
assert_eq!(
record.registrar_whois_server.as_deref(),
Some("whois.example-registrar.com")
);
assert_eq!(
record.registry_id.as_deref(),
Some("2336799_DOMAIN_COM-VRSN")
);
assert_eq!(
record.abuse_contact_email.as_deref(),
Some("abuse@example-registrar.com")
);
assert_eq!(record.dnssec, Some(true));
assert_eq!(
record.name_servers,
["a.iana-servers.net", "b.iana-servers.net"]
);
assert_eq!(
record.statuses,
["clientdeleteprohibited", "clienttransferprohibited"]
);
assert!(record.is_transfer_locked());
assert_eq!(
record.created.unwrap().format("%Y-%m-%d").to_string(),
"1995-08-14"
);
assert_eq!(
record.expires.unwrap().format("%Y-%m-%d").to_string(),
"2027-08-13"
);
}
#[test]
fn reads_nominets_value_on_the_next_line_layout() {
let record = parse(
"\
Domain name:
example.co.uk
Registrar:
Example Registrar Ltd [Tag = EXAMPLE]
Relevant dates:
Registered on: 01-Jan-2001
Expiry date: 01-Jan-2027
Name servers:
ns1.example.co.uk
ns2.example.co.uk
",
);
assert_eq!(record.domain.as_ref().unwrap().as_ascii(), "example.co.uk");
assert_eq!(
record.registrar.as_deref(),
Some("Example Registrar Ltd [Tag = EXAMPLE]")
);
assert_eq!(record.created.unwrap().format("%Y").to_string(), "2001");
assert_eq!(record.expires.unwrap().format("%Y").to_string(), "2027");
assert_eq!(
record.name_servers,
["ns1.example.co.uk", "ns2.example.co.uk"]
);
}
#[test]
fn a_nested_field_is_not_swallowed_by_its_header() {
let record = parse("Relevant dates:\n Registered on: 01-Jan-2001\n");
assert!(record.created.is_some());
assert!(record.extra_field("relevant dates").is_none());
}
#[test]
fn reads_padded_keys() {
let record = parse(
"\
domain.............: example.fi
status.............: Registered
created............: 1.1.2001 00:00:00
nserver............: ns1.example.fi
",
);
assert_eq!(record.domain.as_ref().unwrap().as_ascii(), "example.fi");
assert_eq!(record.statuses, ["registered"]);
assert_eq!(record.name_servers, ["ns1.example.fi"]);
assert!(record.created.is_some());
}
#[test]
fn reads_contacts_by_role() {
let record = parse(
"\
Registrant Name: Ada Lovelace
Registrant Organization: Example Ltd
Registrant Street: 1 Example Street
Registrant City: London
Registrant Postal Code: EC1A 1AA
Registrant Country: gb
Registrant Email: ada@example.com
Admin Name: REDACTED FOR PRIVACY
Tech Email: tech@example.com
Billing Phone: +44.2000000000
",
);
let registrant = record.registrant.as_ref().unwrap();
assert_eq!(registrant.name.as_deref(), Some("Ada Lovelace"));
assert_eq!(registrant.organization.as_deref(), Some("Example Ltd"));
assert_eq!(registrant.street, ["1 Example Street"]);
assert_eq!(registrant.city.as_deref(), Some("London"));
assert_eq!(
registrant.country.as_deref(),
Some("GB"),
"country is upper-cased"
);
assert!(!registrant.is_redacted());
assert!(record.admin.as_ref().unwrap().is_redacted());
assert_eq!(
record.tech.as_ref().unwrap().email.as_deref(),
Some("tech@example.com")
);
assert_eq!(
record.billing.as_ref().unwrap().phone.as_deref(),
Some("+44.2000000000")
);
}
#[test]
fn a_bare_role_key_names_the_contact() {
let record = parse("Registrant: Ada Lovelace\nHolder: Ignored Second\n");
let registrant = record.registrant.as_ref().unwrap();
assert_eq!(registrant.name.as_deref(), Some("Ada Lovelace"));
}
#[test]
fn a_contact_with_nothing_in_it_is_dropped() {
let record = parse("Registrant Fax: n/a\nDomain Name: example.com\n");
assert!(record.registrant.is_none());
}
#[test]
fn urls_are_values_not_fields() {
let record = parse("Registrar URL: https://www.example.com/whois\n");
assert_eq!(
record.registrar_url.as_deref(),
Some("https://www.example.com/whois")
);
assert!(record.extra.is_empty(), "{:?}", record.extra);
}
#[test]
fn a_legal_notice_is_not_a_record() {
let record = parse(
"\
No match for \"NOTHERE.COM\".
>>> Last update of whois database: 2026-08-05T12:00:00Z <<<
NOTICE: The expiration date displayed in this record is the date the
registrar's sponsorship of the domain name registration in the registry is
currently set to expire.
TERMS OF USE: You are not authorized to access or query our WHOIS database
through the use of high-volume, automated, electronic processes.
",
);
assert!(
record.is_empty(),
"a disclaimer parsed into a record: {record:?}"
);
assert!(record.extra.is_empty(), "{:?}", record.extra);
}
#[test]
fn an_unknown_key_with_a_sentence_after_it_is_prose() {
let kept = parse("Eligibility Type: Company\n");
assert_eq!(kept.extra_field("eligibility type"), Some("Company"));
let dropped = parse(
"Something Unknown: this is a full sentence of explanatory text that \
goes on for a while and is plainly not a field value\n",
);
assert!(dropped.extra.is_empty(), "{:?}", dropped.extra);
}
#[test]
fn prose_with_a_colon_is_not_parsed_as_a_field() {
let record = parse(
"NOTICE: The expiration date displayed in this record is the date the\n\
registrar's sponsorship of the domain name registration in the registry\n\
is currently set to expire: this date does not necessarily reflect much.\n",
);
assert!(
record.extra.len() <= 1,
"prose was parsed as fields: {:?}",
record.extra
);
assert!(record.expires.is_none());
}
#[test]
fn placeholder_values_are_treated_as_absent() {
let record = parse(
"\
Domain Name: example.com
Registrar: n/a
Registrant Name: NOT AVAILABLE
Expiry Date: unknown
",
);
assert!(record.registrar.is_none());
assert!(record.registrant.is_none());
assert!(record.expires.is_none());
assert!(record.domain.is_some());
}
#[test]
fn name_servers_drop_their_trailing_ip_addresses() {
let record = parse("nserver: ns1.example.ru 192.0.2.1\nnserver: ns2.example.ru.\n");
assert_eq!(record.name_servers, ["ns1.example.ru", "ns2.example.ru"]);
}
#[test]
fn duplicate_name_servers_and_statuses_collapse() {
let record = parse(
"\
Name Server: NS1.EXAMPLE.COM
Name Server: ns1.example.com
Status: ok
Status: OK
",
);
assert_eq!(record.name_servers, ["ns1.example.com"]);
assert_eq!(record.statuses, ["ok"]);
}
#[test]
fn dnssec_is_read_in_the_forms_registries_use() {
for (value, expected) in [
("signedDelegation", true),
("signed", true),
("yes", true),
("unsigned", false),
("no", false),
] {
let record = parse(&format!("DNSSEC: {value}\n"));
assert_eq!(record.dnssec, Some(expected), "for {value:?}");
}
}
#[test]
fn unrecognised_fields_are_kept() {
let record = parse("Eligibility Type: Company\nEligibility Name: Example Pty Ltd\n");
assert_eq!(record.extra_field("eligibility type"), Some("Company"));
assert_eq!(
record.extra_field("Eligibility Name"),
Some("Example Pty Ltd")
);
}
#[test]
fn banners_are_skipped() {
let record = parse(
"\
% This is a banner
# Another
>>> Last update <<<
-- separator
Domain Name: example.com
",
);
assert!(record.extra.is_empty(), "{:?}", record.extra);
assert!(record.domain.is_some());
}
#[test]
fn a_no_match_response_parses_to_nothing() {
let record = parse("No match for \"NOTHERE.COM\".\n");
assert!(record.is_empty(), "{record:?}");
}
#[test]
fn an_empty_response_parses_to_nothing() {
assert!(parse("").is_empty());
assert!(parse(" \n\n ").is_empty());
}
#[test]
fn punycode_and_unicode_domains_both_land_in_ascii_form() {
let ace = parse("Domain Name: XN--MNCHEN-3YA.DE\n");
let unicode = parse("Domain: münchen.de\n");
assert_eq!(ace.domain.as_ref().unwrap().as_ascii(), "xn--mnchen-3ya.de");
assert_eq!(unicode.domain, ace.domain);
}
}