use chrono::{DateTime, NaiveDate, Utc};
use super::{push_bounded, MAX_NAMESERVERS, MAX_STATUSES};
use crate::whois::parser::{parse_date, WhoisResponse};
static_regex! {
KEY_VALUE = r"^([A-Za-z][A-Za-z0-9 -]*?):\s*(.*?)\s*$";
CHANGED_LINE = r"(?i)(\d{8})\s*\((assigned|changed)\)\s*$";
}
const MAX_CONTACTS: usize = 8;
#[derive(Clone, Copy, PartialEq, Eq)]
enum Section {
Preamble,
Domain,
Person,
Ignored,
}
#[derive(Default)]
struct Contact {
name: Option<String>,
email: Option<String>,
phone: Option<String>,
handle: Option<String>,
}
pub(super) const TLDS: &[&str] = &["il", "xn--4dbrk0ce"];
pub(super) fn parse(domain: &str, server: &str, raw: &str) -> WhoisResponse {
let mut section = Section::Preamble;
let mut registrar: Option<String> = None;
let mut descr: Vec<String> = Vec::new();
let mut registrant_email: Option<String> = None;
let mut registrant_phone: Option<String> = None;
let mut admin_handle: Option<String> = None;
let mut tech_handle: Option<String> = None;
let mut nameservers: Vec<String> = Vec::new();
let mut status: Vec<String> = Vec::new();
let mut dnssec: Option<String> = None;
let mut creation_date: Option<DateTime<Utc>> = None;
let mut expiration_date: Option<DateTime<Utc>> = None;
let mut updated_date: Option<DateTime<Utc>> = None;
let mut contacts: Vec<Contact> = Vec::new();
for line in raw.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('%') || trimmed.starts_with('#') {
continue;
}
let Some(caps) = KEY_VALUE.captures(trimmed) else {
continue;
};
let key = caps[1].to_ascii_lowercase();
let value = caps[2].trim();
if value.is_empty() {
continue;
}
match key.as_str() {
"domain" => {
section = Section::Domain;
continue;
}
"person" => {
if contacts.len() < MAX_CONTACTS {
section = Section::Person;
contacts.push(Contact {
name: Some(value.to_string()),
..Contact::default()
});
} else {
section = Section::Ignored;
}
continue;
}
"registrar name" if registrar.is_none() => {
registrar = Some(value.to_string());
continue;
}
_ => {}
}
match section {
Section::Preamble | Section::Ignored => {}
Section::Domain => match key.as_str() {
"descr" if descr.len() < 16 => descr.push(value.to_string()),
"e-mail" if registrant_email.is_none() => {
registrant_email = Some(normalize_email(value));
}
"phone" if registrant_phone.is_none() => {
registrant_phone = Some(value.to_string());
}
"admin-c" if admin_handle.is_none() => admin_handle = Some(value.to_string()),
"tech-c" if tech_handle.is_none() => tech_handle = Some(value.to_string()),
"nserver" => {
if let Some(host) = value.split_whitespace().next() {
push_bounded(&mut nameservers, host.to_ascii_lowercase(), MAX_NAMESERVERS);
}
}
"validity" if expiration_date.is_none() => {
expiration_date = parse_validity(value);
}
"dnssec" if dnssec.is_none() => dnssec = Some(value.to_string()),
"status" => push_bounded(&mut status, value.to_string(), MAX_STATUSES),
"changed" => {
if let Some(c) = CHANGED_LINE.captures(value) {
let date = parse_compact_date(&c[1]);
if c[2].eq_ignore_ascii_case("assigned") {
if creation_date.is_none() {
creation_date = date;
}
} else if let Some(d) = date {
updated_date = Some(updated_date.map_or(d, |cur| cur.max(d)));
}
}
}
_ => {}
},
Section::Person => {
let Some(contact) = contacts.last_mut() else {
continue;
};
match key.as_str() {
"e-mail" if contact.email.is_none() => {
contact.email = Some(normalize_email(value));
}
"phone" if contact.phone.is_none() => {
contact.phone = Some(value.to_string());
}
"nic-hdl" if contact.handle.is_none() => {
contact.handle = Some(value.to_string());
}
_ => {}
}
}
}
}
let find_contact = |handle: &Option<String>| -> Option<&Contact> {
let h = handle.as_deref()?;
contacts.iter().find(|c| {
c.handle
.as_deref()
.is_some_and(|ch| ch.eq_ignore_ascii_case(h))
})
};
let admin = find_contact(&admin_handle);
let tech = find_contact(&tech_handle);
let mut descr_iter = descr.into_iter();
let registrant = descr_iter.next();
let address: Vec<String> = descr_iter.collect();
let registrant_address = if address.is_empty() {
None
} else {
Some(address.join(", "))
};
WhoisResponse {
domain: domain.to_string(),
registrar,
registrant,
registrant_email,
registrant_phone,
registrant_address,
admin_name: admin.and_then(|c| c.name.clone()),
admin_email: admin.and_then(|c| c.email.clone()),
admin_phone: admin.and_then(|c| c.phone.clone()),
tech_name: tech.and_then(|c| c.name.clone()),
tech_email: tech.and_then(|c| c.email.clone()),
tech_phone: tech.and_then(|c| c.phone.clone()),
creation_date,
expiration_date,
updated_date,
nameservers,
status,
dnssec,
whois_server: server.to_string(),
raw_response: raw.to_string(),
..Default::default()
}
}
fn parse_validity(value: &str) -> Option<DateTime<Utc>> {
let v = value.trim();
if v.is_empty() || v.eq_ignore_ascii_case("n/a") {
return None;
}
NaiveDate::parse_from_str(v, "%d-%m-%Y")
.ok()
.and_then(|d| d.and_hms_opt(0, 0, 0))
.map(|dt| dt.and_utc())
.or_else(|| parse_date(v))
}
fn parse_compact_date(value: &str) -> Option<DateTime<Utc>> {
NaiveDate::parse_from_str(value, "%Y%m%d")
.ok()
.and_then(|d| d.and_hms_opt(0, 0, 0))
.map(|dt| dt.and_utc())
}
fn normalize_email(value: &str) -> String {
value.replace(" AT ", "@")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::whois::parsers;
use chrono::Datelike;
const DISCLAIMER: &str = "\
% The data in the WHOIS database of the .il registry is provided
% by ISOC-IL for information purposes, and to assist persons in
% obtaining information about or related to a domain name
% registration record. ISOC-IL does not guarantee its accuracy.
% By submitting this query, you agree to abide by this policy.
";
fn registered() -> String {
format!(
"{DISCLAIMER}\
query: example.co.il
reg-name: example
domain: example.co.il
descr: Example Ltd
descr: 1 Example St.
descr: Tel Aviv
descr: 6100000
descr: Israel
phone: +972 3 1234567
fax-no: +972 3 1234568
e-mail: hostmaster AT example.co.il
admin-c: II-EA1234-IL
tech-c: II-ET5678-IL
zone-c: II-ET5678-IL
nserver: ns1.example.com
nserver: ns2.example.com 192.0.2.53
validity: 14-06-2027
DNSSEC: unsigned
status: Transfer Locked
changed: domain-registrar AT isoc.org.il 20100614 (Assigned)
changed: domain-registrar AT isoc.org.il 20220101 (Changed)
changed: domain-registrar AT isoc.org.il 20240502 (Changed)
person: Admin Person
address: Example Ltd
address: 1 Example St.
phone: +972 3 1111111
e-mail: admin AT example.co.il
nic-hdl: II-EA1234-IL
changed: domain-registrar AT isoc.org.il 20250909
person: Tech Person
address: Example Ltd
phone: +972 3 2222222
e-mail: tech AT example.co.il
nic-hdl: II-ET5678-IL
changed: domain-registrar AT isoc.org.il 20250909
registrar name: Example Registrar Ltd
registrar info: https://www.example-registrar.co.il
% Rights to the data above are restricted by copyright.
"
)
}
fn legacy() -> String {
format!(
"{DISCLAIMER}\
query: isoc.org.il
reg-name: isoc
domain: isoc.org.il
descr: Israel Internet Association (ISOC-IL)
descr: 6 Bareket st., POB 7210
descr: Petach Tikva
descr: 49517
descr: Israel
phone: +972 3 9700900
e-mail: info-domains AT isoc.org.il
admin-c: II-DB11403-IL
tech-c: II-DB11403-IL
zone-c: II-DB11403-IL
nserver: ns.isoc.org.il
nserver: grappa.isoc.org.il
validity: N/A
status: Transfer Locked
changed: registrar AT ns.il 19960111 (Assigned)
changed: registrar AT ns.il 19960623 (Changed)
changed: domain-registrar AT isoc.org.il 20140116 (Changed)
person: Dina Beer
address: Israel Internet Association (ISOC-IL)
phone: +972 3 9700900
e-mail: dina.b AT isoc.org.il
nic-hdl: II-DB11403-IL
changed: domain-registrar AT isoc.org.il 20140116
registrar name: Israel Internet Association ISOC-IL
"
)
}
const AVAILABLE: &str = "\
% The data in the WHOIS database of the .il registry is provided
% by ISOC-IL for information purposes, and to assist persons in
% obtaining information about or related to a domain name
% registration record. ISOC-IL does not guarantee its accuracy.
% No data was found to match the request criteria.
% Rights to the data above are restricted by copyright.
";
fn parse(domain: &str, raw: &str) -> WhoisResponse {
super::parse(domain, "whois.isoc.org.il", raw)
}
fn ymd(dt: DateTime<Utc>) -> (i32, u32, u32) {
(dt.year(), dt.month(), dt.day())
}
#[test]
fn registered_extracts_expiration_from_validity() {
let r = parse("example.co.il", ®istered());
let exp = r.expiration_date.expect("validity: should parse");
assert_eq!(ymd(exp), (2027, 6, 14));
}
#[test]
fn registered_extracts_creation_from_assigned_line() {
let r = parse("example.co.il", ®istered());
let created = r.creation_date.expect("(Assigned) line should parse");
assert_eq!(ymd(created), (2010, 6, 14));
}
#[test]
fn registered_updated_is_latest_domain_changed_line() {
let r = parse("example.co.il", ®istered());
let updated = r.updated_date.expect("(Changed) line should parse");
assert_eq!(ymd(updated), (2024, 5, 2));
}
#[test]
fn registered_extracts_registrar_status_dnssec() {
let r = parse("example.co.il", ®istered());
assert_eq!(r.registrar.as_deref(), Some("Example Registrar Ltd"));
assert_eq!(r.status, vec!["Transfer Locked".to_string()]);
assert_eq!(r.dnssec.as_deref(), Some("unsigned"));
}
#[test]
fn registered_extracts_nameservers_without_glue() {
let r = parse("example.co.il", ®istered());
assert_eq!(
r.nameservers,
vec!["ns1.example.com".to_string(), "ns2.example.com".to_string()]
);
}
#[test]
fn registered_extracts_holder_and_contacts() {
let r = parse("example.co.il", ®istered());
assert_eq!(r.registrant.as_deref(), Some("Example Ltd"));
assert_eq!(
r.registrant_address.as_deref(),
Some("1 Example St., Tel Aviv, 6100000, Israel")
);
assert_eq!(
r.registrant_email.as_deref(),
Some("hostmaster@example.co.il")
);
assert_eq!(r.registrant_phone.as_deref(), Some("+972 3 1234567"));
assert_eq!(r.admin_name.as_deref(), Some("Admin Person"));
assert_eq!(r.admin_email.as_deref(), Some("admin@example.co.il"));
assert_eq!(r.admin_phone.as_deref(), Some("+972 3 1111111"));
assert_eq!(r.tech_name.as_deref(), Some("Tech Person"));
assert_eq!(r.tech_email.as_deref(), Some("tech@example.co.il"));
assert_eq!(r.tech_phone.as_deref(), Some("+972 3 2222222"));
}
#[test]
fn registered_has_core_data_and_is_not_available() {
let r = parse("example.co.il", ®istered());
assert!(r.has_core_data(), "registrar + dates + NS present");
assert!(!r.is_available());
assert!(!r.registry_unavailable());
}
#[test]
fn legacy_validity_na_yields_no_expiration_but_keeps_creation() {
let r = parse("isoc.org.il", &legacy());
assert!(
r.expiration_date.is_none(),
"validity: N/A must not produce a date"
);
assert_eq!(ymd(r.creation_date.expect("Assigned")), (1996, 1, 11));
assert_eq!(ymd(r.updated_date.expect("Changed")), (2014, 1, 16));
assert_eq!(
r.registrar.as_deref(),
Some("Israel Internet Association ISOC-IL")
);
assert!(r.dnssec.is_none());
assert_eq!(r.nameservers, vec!["ns.isoc.org.il", "grappa.isoc.org.il"]);
assert!(r.has_core_data());
assert!(!r.is_available());
}
#[test]
fn available_response_is_recognised() {
let r = parse("nosuchname.co.il", AVAILABLE);
assert!(r.creation_date.is_none());
assert!(r.expiration_date.is_none());
assert!(r.registrar.is_none());
assert!(r.nameservers.is_empty());
assert!(
r.is_available(),
"'% No data was found to match the request criteria.' must read as available"
);
}
#[test]
fn registry_routes_il_second_level_domains_here() {
for domain in ["example.co.il", "example.org.il", "example.ac.il"] {
let r = parsers::parse(domain, "whois.isoc.org.il", ®istered());
assert!(
r.expiration_date.is_some(),
"{domain} should be parsed by the ISOC-IL parser"
);
}
let r = parsers::parse(
"xn--4dbqfv.xn--4dbrk0ce",
"whois.isoc.org.il",
®istered(),
);
assert!(r.expiration_date.is_some());
}
#[test]
fn contacts_beyond_cap_do_not_bleed_into_retained_ones() {
let mut raw = String::from("domain: example.co.il\nadmin-c: II-LAST-IL\n\n");
for i in 0..MAX_CONTACTS {
let handle = if i + 1 == MAX_CONTACTS {
"II-LAST-IL".to_string()
} else {
format!("II-P{i}-IL")
};
raw.push_str(&format!("person: Person {i}\nnic-hdl: {handle}\n\n"));
}
raw.push_str("person: Overflow\ne-mail: overflow AT example.co.il\nnic-hdl: II-OVER-IL\n");
let r = parse("example.co.il", &raw);
assert_eq!(
r.admin_name.as_deref(),
Some(&*format!("Person {}", MAX_CONTACTS - 1))
);
assert!(r.admin_email.is_none(), "overflow contact must be dropped");
}
#[test]
fn parse_validity_formats() {
assert!(parse_validity("N/A").is_none());
assert!(parse_validity("n/a").is_none());
assert!(parse_validity("").is_none());
assert_eq!(parse_validity("01-12-2030").map(ymd), Some((2030, 12, 1)));
assert_eq!(parse_validity("2030-12-01").map(ymd), Some((2030, 12, 1)));
}
}