use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::lookup::outcome::scrub;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Registration {
#[serde(skip_serializing_if = "Option::is_none")]
pub registrar: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub registrar_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub statuses: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub nameservers: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_dnssec: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub abuse_email: Option<String>,
}
impl Registration {
#[must_use]
pub fn is_empty(&self) -> bool {
*self == Self::default()
}
}
#[must_use]
pub(crate) fn parse_text(raw: &str) -> Registration {
let mut record = Registration::default();
for line in raw.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('%') || line.starts_with('#') {
continue;
}
let Some((key, value)) = line
.strip_prefix('[')
.and_then(|rest| rest.split_once(']'))
.or_else(|| line.split_once(':'))
else {
continue;
};
let value = scrub(value);
if value.is_empty() {
continue;
}
let key = key.trim().to_lowercase();
let key = key.trim_end_matches('.').trim();
let key = key.strip_prefix("sponsoring ").unwrap_or(key);
let key = key.strip_suffix(" on").unwrap_or(key);
match key {
"registrar" | "sponsoring registrar" | "registrar name" => {
record.registrar.get_or_insert(value);
}
"registrar iana id" | "registrar id" => {
record.registrar_id.get_or_insert(value);
}
"creation date"
| "created on"
| "created"
| "domain registration date"
| "registered on"
| "registered" => {
record.created_at.get_or_insert(value);
}
"updated date"
| "last updated"
| "last modified"
| "changed"
| "domain last updated date" => {
record.updated_at.get_or_insert(value);
}
"registry expiry date"
| "registrar registration expiration date"
| "expiration date"
| "expires on"
| "expiry date"
| "expires"
| "domain expiration date" => {
record.expires_at.get_or_insert(value);
}
"domain status" | "status" | "state" => {
let status = value
.split_once(" http")
.map_or(value.as_str(), |(said, _)| said)
.trim()
.to_owned();
if !status.is_empty() && !record.statuses.contains(&status) {
record.statuses.push(status);
}
}
"name server" | "nameserver" | "nserver" => {
let host = value
.split_whitespace()
.next()
.unwrap_or(&value)
.to_lowercase();
if !host.is_empty() && !record.nameservers.contains(&host) {
record.nameservers.push(host);
}
}
"name servers" | "nameservers" => {
for host in value.split_whitespace() {
let host = host.to_lowercase();
if host.contains('.') && !record.nameservers.contains(&host) {
record.nameservers.push(host);
}
}
}
"dnssec" => {
let lowered = value.to_lowercase();
record
.has_dnssec
.get_or_insert(!(lowered.starts_with("unsigned") || lowered == "no"));
}
"registrar abuse contact email" | "abuse contact email" => {
record.abuse_email.get_or_insert(value);
}
_ => {}
}
}
record
}
#[must_use]
pub(crate) fn parse(body: &Value) -> Registration {
let mut record = Registration::default();
if let Some(statuses) = body.get("status").and_then(Value::as_array) {
record.statuses = statuses
.iter()
.filter_map(Value::as_str)
.map(scrub)
.collect();
}
if let Some(events) = body.get("events").and_then(Value::as_array) {
for event in events {
let action = event
.get("eventAction")
.and_then(Value::as_str)
.unwrap_or_default()
.to_lowercase();
let date = event.get("eventDate").and_then(Value::as_str).map(scrub);
match action.as_str() {
"registration" => record.created_at = date,
"expiration" => record.expires_at = date,
"last changed" => record.updated_at = date,
_ => {}
}
}
}
if let Some(nameservers) = body.get("nameservers").and_then(Value::as_array) {
record.nameservers = nameservers
.iter()
.filter_map(|ns| ns.get("ldhName").and_then(Value::as_str))
.map(|ns| scrub(&ns.to_lowercase()))
.collect();
}
record.has_dnssec = body
.get("secureDNS")
.and_then(|dns| dns.get("delegationSigned"))
.and_then(Value::as_bool);
if let Some(entities) = body.get("entities").and_then(Value::as_array) {
for entity in entities {
if !has_role(entity, "registrar") {
continue;
}
record.registrar = vcard(entity, "fn");
record.registrar_id = entity
.get("publicIds")
.and_then(Value::as_array)
.and_then(|ids| ids.first())
.and_then(|id| id.get("identifier"))
.and_then(Value::as_str)
.map(scrub);
record.abuse_email =
entity
.get("entities")
.and_then(Value::as_array)
.and_then(|nested| {
nested
.iter()
.find(|inner| has_role(inner, "abuse"))
.and_then(|inner| vcard(inner, "email"))
});
}
}
record
}
fn has_role(entity: &Value, role: &str) -> bool {
entity
.get("roles")
.and_then(Value::as_array)
.is_some_and(|roles| {
roles
.iter()
.filter_map(Value::as_str)
.any(|found| found.eq_ignore_ascii_case(role))
})
}
fn vcard(entity: &Value, key: &str) -> Option<String> {
let items = entity
.get("vcardArray")
.and_then(Value::as_array)?
.get(1)?
.as_array()?;
for item in items {
let Some(parts) = item.as_array() else {
continue;
};
if parts.first().and_then(Value::as_str) == Some(key)
&& let Some(value) = parts.get(3).and_then(Value::as_str)
&& !value.is_empty()
{
return Some(scrub(value));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn sample() -> Value {
json!({
"objectClassName": "domain",
"ldhName": "apple.com",
"status": ["client transfer prohibited"],
"events": [
{"eventAction": "registration", "eventDate": "1987-02-19T05:00:00Z"},
{"eventAction": "expiration", "eventDate": "2027-02-20T05:00:00Z"},
{"eventAction": "last changed", "eventDate": "2026-02-09T15:41:53Z"},
{"eventAction": "last update of RDAP database", "eventDate": "2026-08-15T00:00:00Z"}
],
"nameservers": [{"ldhName": "A.NS.APPLE.COM"}],
"secureDNS": {"delegationSigned": false},
"entities": [{
"roles": ["registrar"],
"publicIds": [{"identifier": "470"}],
"vcardArray": ["vcard", [["version", {}, "text", "4.0"], ["fn", {}, "text", "COM LAUDE"]]],
"entities": [{
"roles": ["abuse"],
"vcardArray": ["vcard", [["email", {}, "text", "abuse@example.com"]]]
}]
}]
})
}
#[test]
fn it_reads_the_published_detail() {
let record = parse(&sample());
assert_eq!(record.registrar.as_deref(), Some("COM LAUDE"));
assert_eq!(record.registrar_id.as_deref(), Some("470"));
assert_eq!(record.created_at.as_deref(), Some("1987-02-19T05:00:00Z"));
assert_eq!(record.expires_at.as_deref(), Some("2027-02-20T05:00:00Z"));
assert_eq!(record.nameservers, vec!["a.ns.apple.com"]);
assert_eq!(record.has_dnssec, Some(false));
assert_eq!(record.abuse_email.as_deref(), Some("abuse@example.com"));
}
#[test]
fn the_last_changed_event_wins_over_a_database_stamp() {
assert_eq!(
parse(&sample()).updated_at.as_deref(),
Some("2026-02-09T15:41:53Z")
);
}
#[test]
fn an_empty_body_yields_an_empty_record() {
assert!(parse(&json!({})).is_empty());
assert!(!parse(&sample()).is_empty());
}
#[test]
fn a_missing_contact_card_is_not_an_error() {
let body = json!({"entities": [{"roles": ["registrar"]}]});
let record = parse(&body);
assert!(record.registrar.is_none());
assert!(record.abuse_email.is_none());
}
#[test]
fn a_text_record_yields_the_same_detail_the_structured_one_does() {
let reply = "\
Domain Name: example.com.bd
Registrar: BTCL
Creation Date: 2019-04-01T10:00:00Z
Updated Date: 2024-02-11T08:30:00Z
Expiry Date: 2027-04-01T10:00:00Z
Domain Status: clientTransferProhibited https://icann.org/epp
Name Server: ns1.btcl.net.bd
Name Server: NS2.BTCL.NET.BD
DNSSEC: unsigned
Registrar Abuse Contact Email: abuse@example.test
";
let record = parse_text(reply);
assert_eq!(record.registrar.as_deref(), Some("BTCL"));
assert_eq!(record.created_at.as_deref(), Some("2019-04-01T10:00:00Z"));
assert_eq!(record.updated_at.as_deref(), Some("2024-02-11T08:30:00Z"));
assert_eq!(record.expires_at.as_deref(), Some("2027-04-01T10:00:00Z"));
assert_eq!(record.statuses, vec!["clientTransferProhibited".to_owned()]);
assert_eq!(
parse_text("Status: Registered, success\n").statuses,
vec!["Registered, success".to_owned()],
"a status that is a phrase is not cut at its first space"
);
assert_eq!(
record.nameservers,
vec!["ns1.btcl.net.bd".to_owned(), "ns2.btcl.net.bd".to_owned()],
"a host is one entry however the registry cased it"
);
assert_eq!(record.has_dnssec, Some(false));
assert_eq!(record.abuse_email.as_deref(), Some("abuse@example.test"));
assert!(!record.is_empty());
}
#[test]
fn a_reply_with_nothing_in_it_stays_empty_rather_than_printing_a_bare_heading() {
assert!(parse_text("").is_empty());
assert!(parse_text("% this zone publishes no record\n").is_empty());
}
#[test]
fn a_hostile_text_record_cannot_carry_control_bytes_into_the_detail_block() {
let reply = "Registrar: Evil\u{1b}[2J Ltd\nName Server: ns1\u{202e}.test\n";
let record = parse_text(reply);
assert!(!record.registrar.unwrap_or_default().contains('\u{1b}'));
assert!(!record.nameservers.join(" ").contains('\u{202e}'));
}
#[test]
fn the_older_registrar_wording_fills_the_same_fields() {
let reply = "\
Created On:2003-01-01
Last Updated On:2020-01-01
Sponsoring Registrar:Example Inc.
Sponsoring Registrar IANA ID:292
Expiration Date:2027-01-01
";
let record = parse_text(reply);
assert_eq!(record.created_at.as_deref(), Some("2003-01-01"));
assert_eq!(record.updated_at.as_deref(), Some("2020-01-01"));
assert_eq!(record.registrar.as_deref(), Some("Example Inc."));
assert_eq!(record.registrar_id.as_deref(), Some("292"));
assert_eq!(record.expires_at.as_deref(), Some("2027-01-01"));
}
#[test]
fn a_record_keyed_in_brackets_is_read_rather_than_skipped() {
let reply = "\
[Domain Name] EXAMPLE.JP
[Registrant] Example Company
[Name Server] ns1.example.jp
[Name Server] ns2.example.jp
[Created on] 2001/05/21
[Last Updated] 2026/06/01
";
let record = parse_text(reply);
assert_eq!(
record.nameservers,
vec!["ns1.example.jp".to_owned(), "ns2.example.jp".to_owned()]
);
assert_eq!(record.created_at.as_deref(), Some("2001/05/21"));
assert_eq!(record.updated_at.as_deref(), Some("2026/06/01"));
assert!(!record.is_empty());
}
#[test]
fn a_plural_nameserver_line_keeps_every_host_it_lists() {
let record = parse_text("Name Servers: ns1.example.bd ns2.example.bd ns3.example.bd\n");
assert_eq!(
record.nameservers,
vec![
"ns1.example.bd".to_owned(),
"ns2.example.bd".to_owned(),
"ns3.example.bd".to_owned()
]
);
let singular = parse_text("Name Server: ns1.example.bd 203.0.113.10\n");
assert_eq!(
singular.nameservers,
vec!["ns1.example.bd".to_owned()],
"the singular form puts a glue address after the host"
);
}
}