Skip to main content

reserve_core/lookup/
registration.rs

1//! Reading the published detail out of a registry record.
2
3use 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/// @docgen The text protocol is the only source that answers for many country zones, so dropping its record left those zones with no detail at all.
38#[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        // @docgen One registry we already speak to keys its record in brackets and carries no colon, so its record read as empty.
48        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        // @docgen The same field arrives as `Registrar`, `Sponsoring Registrar`, and `Created On`, so the wrappers are peeled once here.
62        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                // @docgen Registries append an explanatory link to the status, and only the words before it are the status.
98                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                // @docgen The singular form puts the glue address after the host, so only the first token is a name.
109                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                // @docgen The plural form lists further hosts rather than a glue address, so taking only the first dropped the rest.
120                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
226/// @docgen A contact card is `["vcard", [[key, {}, "text", value], ...]]`, which is where the index juggling comes from.
227fn 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}