1use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use crate::lookup::outcome::scrub;
7
8#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
9pub struct Registration {
10 #[serde(skip_serializing_if = "Option::is_none")]
11 pub registrar: Option<String>,
12 #[serde(skip_serializing_if = "Option::is_none")]
13 pub registrar_id: Option<String>,
14 #[serde(skip_serializing_if = "Option::is_none")]
15 pub created_at: Option<String>,
16 #[serde(skip_serializing_if = "Option::is_none")]
17 pub updated_at: Option<String>,
18 #[serde(skip_serializing_if = "Option::is_none")]
19 pub expires_at: Option<String>,
20 #[serde(skip_serializing_if = "Vec::is_empty")]
21 pub statuses: Vec<String>,
22 #[serde(skip_serializing_if = "Vec::is_empty")]
23 pub nameservers: Vec<String>,
24 #[serde(skip_serializing_if = "Option::is_none")]
25 pub has_dnssec: Option<bool>,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub abuse_email: Option<String>,
28}
29
30impl Registration {
31 #[must_use]
32 pub fn is_empty(&self) -> bool {
33 *self == Self::default()
34 }
35}
36
37#[must_use]
39pub(crate) fn parse_text(raw: &str) -> Registration {
40 let mut record = Registration::default();
41
42 for line in raw.lines() {
43 let line = line.trim();
44 if line.is_empty() || line.starts_with('%') || line.starts_with('#') {
45 continue;
46 }
47 let Some((key, value)) = line
49 .strip_prefix('[')
50 .and_then(|rest| rest.split_once(']'))
51 .or_else(|| line.split_once(':'))
52 else {
53 continue;
54 };
55 let value = scrub(value);
56 if value.is_empty() {
57 continue;
58 }
59 let key = key.trim().to_lowercase();
60 let key = key.trim_end_matches('.').trim();
61 let key = key.strip_prefix("sponsoring ").unwrap_or(key);
63 let key = key.strip_suffix(" on").unwrap_or(key);
64
65 match key {
66 "registrar" | "sponsoring registrar" | "registrar name" => {
67 record.registrar.get_or_insert(value);
68 }
69 "registrar iana id" | "registrar id" => {
70 record.registrar_id.get_or_insert(value);
71 }
72 "creation date"
73 | "created on"
74 | "created"
75 | "domain registration date"
76 | "registered on"
77 | "registered" => {
78 record.created_at.get_or_insert(value);
79 }
80 "updated date"
81 | "last updated"
82 | "last modified"
83 | "changed"
84 | "domain last updated date" => {
85 record.updated_at.get_or_insert(value);
86 }
87 "registry expiry date"
88 | "registrar registration expiration date"
89 | "expiration date"
90 | "expires on"
91 | "expiry date"
92 | "expires"
93 | "domain expiration date" => {
94 record.expires_at.get_or_insert(value);
95 }
96 "domain status" | "status" | "state" => {
97 let status = value
99 .split_once(" http")
100 .map_or(value.as_str(), |(said, _)| said)
101 .trim()
102 .to_owned();
103 if !status.is_empty() && !record.statuses.contains(&status) {
104 record.statuses.push(status);
105 }
106 }
107 "name server" | "nameserver" | "nserver" => {
108 let host = value
110 .split_whitespace()
111 .next()
112 .unwrap_or(&value)
113 .to_lowercase();
114 if !host.is_empty() && !record.nameservers.contains(&host) {
115 record.nameservers.push(host);
116 }
117 }
118 "name servers" | "nameservers" => {
119 for host in value.split_whitespace() {
121 let host = host.to_lowercase();
122 if host.contains('.') && !record.nameservers.contains(&host) {
123 record.nameservers.push(host);
124 }
125 }
126 }
127 "dnssec" => {
128 let lowered = value.to_lowercase();
129 record
130 .has_dnssec
131 .get_or_insert(!(lowered.starts_with("unsigned") || lowered == "no"));
132 }
133 "registrar abuse contact email" | "abuse contact email" => {
134 record.abuse_email.get_or_insert(value);
135 }
136 _ => {}
137 }
138 }
139
140 record
141}
142
143#[must_use]
144pub(crate) fn parse(body: &Value) -> Registration {
145 let mut record = Registration::default();
146
147 if let Some(statuses) = body.get("status").and_then(Value::as_array) {
148 record.statuses = statuses
149 .iter()
150 .filter_map(Value::as_str)
151 .map(scrub)
152 .collect();
153 }
154
155 if let Some(events) = body.get("events").and_then(Value::as_array) {
156 for event in events {
157 let action = event
158 .get("eventAction")
159 .and_then(Value::as_str)
160 .unwrap_or_default()
161 .to_lowercase();
162 let date = event.get("eventDate").and_then(Value::as_str).map(scrub);
163 match action.as_str() {
164 "registration" => record.created_at = date,
165 "expiration" => record.expires_at = date,
166 "last changed" => record.updated_at = date,
167 _ => {}
168 }
169 }
170 }
171
172 if let Some(nameservers) = body.get("nameservers").and_then(Value::as_array) {
173 record.nameservers = nameservers
174 .iter()
175 .filter_map(|ns| ns.get("ldhName").and_then(Value::as_str))
176 .map(|ns| scrub(&ns.to_lowercase()))
177 .collect();
178 }
179
180 record.has_dnssec = body
181 .get("secureDNS")
182 .and_then(|dns| dns.get("delegationSigned"))
183 .and_then(Value::as_bool);
184
185 if let Some(entities) = body.get("entities").and_then(Value::as_array) {
186 for entity in entities {
187 if !has_role(entity, "registrar") {
188 continue;
189 }
190 record.registrar = vcard(entity, "fn");
191 record.registrar_id = entity
192 .get("publicIds")
193 .and_then(Value::as_array)
194 .and_then(|ids| ids.first())
195 .and_then(|id| id.get("identifier"))
196 .and_then(Value::as_str)
197 .map(scrub);
198 record.abuse_email =
199 entity
200 .get("entities")
201 .and_then(Value::as_array)
202 .and_then(|nested| {
203 nested
204 .iter()
205 .find(|inner| has_role(inner, "abuse"))
206 .and_then(|inner| vcard(inner, "email"))
207 });
208 }
209 }
210
211 record
212}
213
214fn has_role(entity: &Value, role: &str) -> bool {
215 entity
216 .get("roles")
217 .and_then(Value::as_array)
218 .is_some_and(|roles| {
219 roles
220 .iter()
221 .filter_map(Value::as_str)
222 .any(|found| found.eq_ignore_ascii_case(role))
223 })
224}
225
226fn vcard(entity: &Value, key: &str) -> Option<String> {
228 let items = entity
229 .get("vcardArray")
230 .and_then(Value::as_array)?
231 .get(1)?
232 .as_array()?;
233 for item in items {
234 let Some(parts) = item.as_array() else {
235 continue;
236 };
237 if parts.first().and_then(Value::as_str) == Some(key)
238 && let Some(value) = parts.get(3).and_then(Value::as_str)
239 && !value.is_empty()
240 {
241 return Some(scrub(value));
242 }
243 }
244 None
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250 use serde_json::json;
251
252 fn sample() -> Value {
253 json!({
254 "objectClassName": "domain",
255 "ldhName": "apple.com",
256 "status": ["client transfer prohibited"],
257 "events": [
258 {"eventAction": "registration", "eventDate": "1987-02-19T05:00:00Z"},
259 {"eventAction": "expiration", "eventDate": "2027-02-20T05:00:00Z"},
260 {"eventAction": "last changed", "eventDate": "2026-02-09T15:41:53Z"},
261 {"eventAction": "last update of RDAP database", "eventDate": "2026-08-15T00:00:00Z"}
262 ],
263 "nameservers": [{"ldhName": "A.NS.APPLE.COM"}],
264 "secureDNS": {"delegationSigned": false},
265 "entities": [{
266 "roles": ["registrar"],
267 "publicIds": [{"identifier": "470"}],
268 "vcardArray": ["vcard", [["version", {}, "text", "4.0"], ["fn", {}, "text", "COM LAUDE"]]],
269 "entities": [{
270 "roles": ["abuse"],
271 "vcardArray": ["vcard", [["email", {}, "text", "abuse@example.com"]]]
272 }]
273 }]
274 })
275 }
276
277 #[test]
278 fn it_reads_the_published_detail() {
279 let record = parse(&sample());
280 assert_eq!(record.registrar.as_deref(), Some("COM LAUDE"));
281 assert_eq!(record.registrar_id.as_deref(), Some("470"));
282 assert_eq!(record.created_at.as_deref(), Some("1987-02-19T05:00:00Z"));
283 assert_eq!(record.expires_at.as_deref(), Some("2027-02-20T05:00:00Z"));
284 assert_eq!(record.nameservers, vec!["a.ns.apple.com"]);
285 assert_eq!(record.has_dnssec, Some(false));
286 assert_eq!(record.abuse_email.as_deref(), Some("abuse@example.com"));
287 }
288
289 #[test]
290 fn the_last_changed_event_wins_over_a_database_stamp() {
291 assert_eq!(
292 parse(&sample()).updated_at.as_deref(),
293 Some("2026-02-09T15:41:53Z")
294 );
295 }
296
297 #[test]
298 fn an_empty_body_yields_an_empty_record() {
299 assert!(parse(&json!({})).is_empty());
300 assert!(!parse(&sample()).is_empty());
301 }
302
303 #[test]
304 fn a_missing_contact_card_is_not_an_error() {
305 let body = json!({"entities": [{"roles": ["registrar"]}]});
306 let record = parse(&body);
307 assert!(record.registrar.is_none());
308 assert!(record.abuse_email.is_none());
309 }
310
311 #[test]
312 fn a_text_record_yields_the_same_detail_the_structured_one_does() {
313 let reply = "\
314Domain Name: example.com.bd
315Registrar: BTCL
316Creation Date: 2019-04-01T10:00:00Z
317Updated Date: 2024-02-11T08:30:00Z
318Expiry Date: 2027-04-01T10:00:00Z
319Domain Status: clientTransferProhibited https://icann.org/epp
320Name Server: ns1.btcl.net.bd
321Name Server: NS2.BTCL.NET.BD
322DNSSEC: unsigned
323Registrar Abuse Contact Email: abuse@example.test
324";
325 let record = parse_text(reply);
326 assert_eq!(record.registrar.as_deref(), Some("BTCL"));
327 assert_eq!(record.created_at.as_deref(), Some("2019-04-01T10:00:00Z"));
328 assert_eq!(record.updated_at.as_deref(), Some("2024-02-11T08:30:00Z"));
329 assert_eq!(record.expires_at.as_deref(), Some("2027-04-01T10:00:00Z"));
330 assert_eq!(record.statuses, vec!["clientTransferProhibited".to_owned()]);
331 assert_eq!(
332 parse_text("Status: Registered, success\n").statuses,
333 vec!["Registered, success".to_owned()],
334 "a status that is a phrase is not cut at its first space"
335 );
336 assert_eq!(
337 record.nameservers,
338 vec!["ns1.btcl.net.bd".to_owned(), "ns2.btcl.net.bd".to_owned()],
339 "a host is one entry however the registry cased it"
340 );
341 assert_eq!(record.has_dnssec, Some(false));
342 assert_eq!(record.abuse_email.as_deref(), Some("abuse@example.test"));
343 assert!(!record.is_empty());
344 }
345
346 #[test]
347 fn a_reply_with_nothing_in_it_stays_empty_rather_than_printing_a_bare_heading() {
348 assert!(parse_text("").is_empty());
349 assert!(parse_text("% this zone publishes no record\n").is_empty());
350 }
351
352 #[test]
353 fn a_hostile_text_record_cannot_carry_control_bytes_into_the_detail_block() {
354 let reply = "Registrar: Evil\u{1b}[2J Ltd\nName Server: ns1\u{202e}.test\n";
355 let record = parse_text(reply);
356 assert!(!record.registrar.unwrap_or_default().contains('\u{1b}'));
357 assert!(!record.nameservers.join(" ").contains('\u{202e}'));
358 }
359
360 #[test]
361 fn the_older_registrar_wording_fills_the_same_fields() {
362 let reply = "\
363Created On:2003-01-01
364Last Updated On:2020-01-01
365Sponsoring Registrar:Example Inc.
366Sponsoring Registrar IANA ID:292
367Expiration Date:2027-01-01
368";
369 let record = parse_text(reply);
370 assert_eq!(record.created_at.as_deref(), Some("2003-01-01"));
371 assert_eq!(record.updated_at.as_deref(), Some("2020-01-01"));
372 assert_eq!(record.registrar.as_deref(), Some("Example Inc."));
373 assert_eq!(record.registrar_id.as_deref(), Some("292"));
374 assert_eq!(record.expires_at.as_deref(), Some("2027-01-01"));
375 }
376
377 #[test]
378 fn a_record_keyed_in_brackets_is_read_rather_than_skipped() {
379 let reply = "\
380[Domain Name] EXAMPLE.JP
381[Registrant] Example Company
382[Name Server] ns1.example.jp
383[Name Server] ns2.example.jp
384[Created on] 2001/05/21
385[Last Updated] 2026/06/01
386";
387 let record = parse_text(reply);
388 assert_eq!(
389 record.nameservers,
390 vec!["ns1.example.jp".to_owned(), "ns2.example.jp".to_owned()]
391 );
392 assert_eq!(record.created_at.as_deref(), Some("2001/05/21"));
393 assert_eq!(record.updated_at.as_deref(), Some("2026/06/01"));
394 assert!(!record.is_empty());
395 }
396
397 #[test]
398 fn a_plural_nameserver_line_keeps_every_host_it_lists() {
399 let record = parse_text("Name Servers: ns1.example.bd ns2.example.bd ns3.example.bd\n");
400 assert_eq!(
401 record.nameservers,
402 vec![
403 "ns1.example.bd".to_owned(),
404 "ns2.example.bd".to_owned(),
405 "ns3.example.bd".to_owned()
406 ]
407 );
408
409 let singular = parse_text("Name Server: ns1.example.bd 203.0.113.10\n");
410 assert_eq!(
411 singular.nameservers,
412 vec!["ns1.example.bd".to_owned()],
413 "the singular form puts a glue address after the host"
414 );
415 }
416}