Skip to main content

seer_core/
domain_info.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use crate::dates::days_until;
5use crate::lookup::LookupResult;
6use crate::rdap::RdapResponse;
7use crate::whois::WhoisResponse;
8
9/// Indicates which protocol sources contributed to a `DomainInfo`.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum DomainInfoSource {
13    /// Both RDAP and WHOIS data were available.
14    Both,
15    /// Only RDAP data was available.
16    Rdap,
17    /// Only WHOIS data was available.
18    Whois,
19    /// The result is an availability verdict (see
20    /// [`DomainInfo::availability_verdict`]) rather than a registration
21    /// record: the registries gave no usable record, and any WHOIS fields
22    /// merged alongside a not-available verdict are partial. Also used when
23    /// neither source is supplied to [`DomainInfo::from_sources`].
24    Available,
25}
26
27impl std::fmt::Display for DomainInfoSource {
28    /// Render the same lowercase form serde uses, so JSON output and any
29    /// non-JSON sink (CSV, logs) carry identical strings.
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        let s = match self {
32            DomainInfoSource::Both => "both",
33            DomainInfoSource::Rdap => "rdap",
34            DomainInfoSource::Whois => "whois",
35            DomainInfoSource::Available => "available",
36        };
37        f.write_str(s)
38    }
39}
40
41/// Number of days before expiration at which a domain is flagged as
42/// "expiring soon" by [`ExpiryStatus`]. Mirrors common registrar grace
43/// windows and the watchlist's near-expiry alerting. The public docs of
44/// [`ExpiryStatus::ExpiringSoon`] state the value; keep them in step.
45const EXPIRING_SOON_DAYS: i64 = 30;
46
47/// A coarse lifecycle band for a domain's registration, derived from its
48/// expiration date and EPP status codes.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "kebab-case")]
51pub enum ExpiryStatus {
52    /// Registered and not near expiry.
53    Active,
54    /// Within 30 days of the expiration date.
55    ExpiringSoon,
56    /// Past the expiration date (may still be recoverable — see `Redemption`).
57    Expired,
58    /// In the post-expiry redemption grace period (recoverable by the registrant).
59    Redemption,
60    /// Scheduled for deletion back to the available pool.
61    PendingDelete,
62}
63
64impl std::fmt::Display for ExpiryStatus {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        let s = match self {
67            ExpiryStatus::Active => "active",
68            ExpiryStatus::ExpiringSoon => "expiring-soon",
69            ExpiryStatus::Expired => "expired",
70            ExpiryStatus::Redemption => "redemption",
71            ExpiryStatus::PendingDelete => "pending-delete",
72        };
73        f.write_str(s)
74    }
75}
76
77/// A decoded EPP/RDAP domain status code paired with a plain-English meaning.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct StatusDescription {
80    /// The status code as reported by the registry (e.g. `clientTransferProhibited`).
81    pub code: String,
82    /// A human-readable description of what the code means.
83    pub description: String,
84}
85
86/// Normalizes an EPP or RDAP status token to a comparison key.
87///
88/// WHOIS reports EPP camelCase (`clientTransferProhibited`) while RDAP reports
89/// space-separated lowercase (`client transfer prohibited`); both collapse to
90/// the same key here so [`describe_epp_status`] and `derive_expiry_status`
91/// handle either source. Any trailing ` (URL)` an RDAP server appends is
92/// dropped because only alphanumerics survive the filter.
93fn normalize_status_key(code: &str) -> String {
94    code.chars()
95        .take_while(|c| *c != '(')
96        .filter(|c| c.is_ascii_alphanumeric())
97        .map(|c| c.to_ascii_lowercase())
98        .collect()
99}
100
101/// Whether any status code (EPP or RDAP form) normalizes to `key`, the
102/// lowercase alphanumeric form (`"clienttransferprohibited"`).
103pub(crate) fn has_status_key(statuses: &[String], key: &str) -> bool {
104    statuses.iter().any(|s| normalize_status_key(s) == key)
105}
106
107/// Whether the registry speaks EPP lock vocabulary: some status normalizes
108/// to a `…prohibited` key (`clientTransferProhibited`,
109/// `serverUpdateProhibited`, …). Without one, a missing lock is no evidence
110/// of an unlocked domain — the registry may not publish locks at all.
111pub(crate) fn has_lock_vocabulary(statuses: &[String]) -> bool {
112    statuses
113        .iter()
114        .any(|s| normalize_status_key(s).ends_with("prohibited"))
115}
116
117/// Decodes a domain status code (EPP or RDAP form) into a plain-English
118/// description, or `None` if the code is unrecognized.
119pub fn describe_epp_status(code: &str) -> Option<&'static str> {
120    let desc = match normalize_status_key(code).as_str() {
121        "ok" | "active" => "no restrictions (standard registered state)",
122        "inactive" => "no nameservers delegated",
123        "addperiod" => "recently registered (add grace period)",
124        "autorenewperiod" => "recently auto-renewed (grace period)",
125        "renewperiod" => "recently renewed (grace period)",
126        "transferperiod" => "recently transferred (grace period)",
127        "redemptionperiod" => "recently expired, recoverable (redemption period)",
128        "pendingrestore" => "restore requested from redemption",
129        "pendingcreate" => "creation requested, pending",
130        "pendingdelete" => "scheduled for deletion",
131        "pendingrenew" => "renewal requested, pending",
132        "pendingtransfer" => "transfer requested, pending",
133        "pendingupdate" => "update requested, pending",
134        "clientdeleteprohibited" => "deletion locked (by registrar)",
135        "clienthold" => "resolution suspended (by registrar)",
136        "clientrenewprohibited" => "renewal locked (by registrar)",
137        "clienttransferprohibited" => "transfer locked (by registrar)",
138        "clientupdateprohibited" => "updates locked (by registrar)",
139        "serverdeleteprohibited" => "deletion locked (by registry)",
140        "serverhold" => "resolution suspended (by registry)",
141        "serverrenewprohibited" => "renewal locked (by registry)",
142        "servertransferprohibited" => "transfer locked (by registry)",
143        "serverupdateprohibited" => "updates locked (by registry)",
144        _ => return None,
145    };
146    Some(desc)
147}
148
149/// Derives a coarse [`ExpiryStatus`] band from the days-until-expiration and the
150/// domain's status codes. Status codes take priority: a `pendingDelete` or
151/// `redemptionPeriod` state describes the lifecycle better than the raw date.
152fn derive_expiry_status(
153    days_until_expiration: Option<i64>,
154    status: &[String],
155) -> Option<ExpiryStatus> {
156    let has = |needle: &str| status.iter().any(|s| normalize_status_key(s) == needle);
157    if has("pendingdelete") {
158        return Some(ExpiryStatus::PendingDelete);
159    }
160    if has("redemptionperiod") {
161        return Some(ExpiryStatus::Redemption);
162    }
163    let days = days_until_expiration?;
164    Some(if days < 0 {
165        ExpiryStatus::Expired
166    } else if days <= EXPIRING_SOON_DAYS {
167        ExpiryStatus::ExpiringSoon
168    } else {
169        ExpiryStatus::Active
170    })
171}
172
173/// A flat, merged view of domain registration data from RDAP and WHOIS.
174///
175/// RDAP fields take priority when both sources are present; WHOIS fills gaps.
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct DomainInfo {
178    pub domain: String,
179    pub source: DomainInfoSource,
180
181    // Registration
182    pub registrar: Option<String>,
183    pub registrant: Option<String>,
184    pub organization: Option<String>,
185
186    // Dates
187    pub creation_date: Option<DateTime<Utc>>,
188    pub expiration_date: Option<DateTime<Utc>>,
189    pub updated_date: Option<DateTime<Utc>>,
190
191    // DNS
192    pub nameservers: Vec<String>,
193    pub status: Vec<String>,
194    pub dnssec: Option<String>,
195
196    // Registrant contact
197    pub registrant_email: Option<String>,
198    pub registrant_phone: Option<String>,
199    pub registrant_address: Option<String>,
200    pub registrant_country: Option<String>,
201
202    // Admin contact
203    pub admin_name: Option<String>,
204    pub admin_organization: Option<String>,
205    pub admin_email: Option<String>,
206    pub admin_phone: Option<String>,
207
208    // Tech contact
209    pub tech_name: Option<String>,
210    pub tech_organization: Option<String>,
211    pub tech_email: Option<String>,
212    pub tech_phone: Option<String>,
213
214    // Protocol metadata
215    pub whois_server: Option<String>,
216    pub rdap_url: Option<String>,
217
218    // Registrar object detail (from the RDAP registrar entity)
219    /// ICANN-required registrar abuse contact email (for takedown reports).
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub registrar_abuse_email: Option<String>,
222    /// Registrar abuse contact phone.
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub registrar_abuse_phone: Option<String>,
225    /// IANA Registrar ID (uniquely identifies the registrar).
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub registrar_iana_id: Option<String>,
228    /// Registrar URL (the `about` link on the registrar entity).
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub registrar_url: Option<String>,
231
232    /// Availability verdict when derived from a `LookupResult::Available` —
233    /// one of [`AvailabilityResult::verdict`](crate::AvailabilityResult::verdict)'s
234    /// `"available"`, `"likely_available"`, `"registered"`,
235    /// `"likely_registered"` or `"unknown"`. `None` otherwise.
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub availability_verdict: Option<String>,
238
239    // --- Derived lifecycle fields (computed at construction) ---
240    /// Days until the registration expires (negative if already past),
241    /// computed from `expiration_date`. `None` when the date is unknown.
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub days_until_expiration: Option<i64>,
244    /// Age of the registration in days, computed from `creation_date`.
245    /// `None` when the creation date is unknown.
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub domain_age_days: Option<i64>,
248    /// Coarse lifecycle band derived from the expiry date and status codes.
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub expiry_status: Option<ExpiryStatus>,
251    /// Plain-English decodings of recognized `status` codes.
252    #[serde(default, skip_serializing_if = "Vec::is_empty")]
253    pub status_descriptions: Vec<StatusDescription>,
254    /// The live DNS delegation checked against WHOIS's nameservers, for a
255    /// WHOIS-only lookup (see [`crate::DnsNameservers`]).
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub dns_nameservers: Option<crate::DnsNameservers>,
258}
259
260impl DomainInfo {
261    /// Whether the domain carries the status `key` (normalized form, e.g.
262    /// `"clienttransferprohibited"`), in either EPP or RDAP spelling.
263    pub fn has_status(&self, key: &str) -> bool {
264        has_status_key(&self.status, key)
265    }
266
267    /// Constructs a `DomainInfo` by merging optional RDAP and WHOIS data.
268    ///
269    /// For each field, RDAP is tried first; WHOIS fills any remaining gaps.
270    /// For vector fields (nameservers, status), RDAP is used if non-empty, else WHOIS.
271    pub fn from_sources(
272        domain: &str,
273        rdap: Option<&RdapResponse>,
274        whois: Option<&WhoisResponse>,
275    ) -> Self {
276        let source = match (rdap.is_some(), whois.is_some()) {
277            (true, true) => DomainInfoSource::Both,
278            (true, false) => DomainInfoSource::Rdap,
279            (false, true) => DomainInfoSource::Whois,
280            (false, false) => DomainInfoSource::Available,
281        };
282
283        // Helper: try RDAP extraction first, then WHOIS field
284        macro_rules! rdap_or_whois {
285            ($rdap_expr:expr, $whois_field:ident) => {
286                $rdap_expr.or_else(|| whois.and_then(|w| w.$whois_field.clone()))
287            };
288        }
289
290        // --- Registration ---
291        let registrar = rdap_or_whois!(rdap.and_then(|r| r.get_registrar()), registrar);
292        let registrant = rdap_or_whois!(rdap.and_then(|r| r.get_registrant()), registrant);
293        let organization = rdap_or_whois!(
294            rdap.and_then(|r| r.get_registrant_organization()),
295            organization
296        );
297
298        // --- Dates ---
299        let creation_date = rdap
300            .and_then(|r| r.creation_date())
301            .or_else(|| whois.and_then(|w| w.creation_date));
302        let expiration_date = rdap
303            .and_then(|r| r.expiration_date())
304            .or_else(|| whois.and_then(|w| w.expiration_date));
305        let updated_date = rdap
306            .and_then(|r| r.last_updated())
307            .or_else(|| whois.and_then(|w| w.updated_date));
308
309        // --- DNS (vec fields: RDAP if non-empty, else WHOIS) ---
310        let rdap_ns = rdap.map(|r| r.nameserver_names()).unwrap_or_default();
311        let nameservers = if !rdap_ns.is_empty() {
312            rdap_ns
313        } else {
314            whois.map(|w| w.nameservers.clone()).unwrap_or_default()
315        };
316
317        let rdap_status = rdap.map(|r| r.status.clone()).unwrap_or_default();
318        let status = if !rdap_status.is_empty() {
319            rdap_status
320        } else {
321            whois.map(|w| w.status.clone()).unwrap_or_default()
322        };
323
324        // DNSSEC: RDAP secure_dns.delegation_signed -> "signed"/"unsigned", else WHOIS
325        let dnssec = rdap
326            .and_then(|r| r.secure_dns.as_ref())
327            .and_then(|sd| sd.delegation_signed)
328            .map(|signed| {
329                if signed {
330                    "signed".to_string()
331                } else {
332                    "unsigned".to_string()
333                }
334            })
335            .or_else(|| whois.and_then(|w| w.dnssec.clone()));
336
337        // --- Contacts: each field from the RDAP entity, else WHOIS ---
338        let rdap_registrant = rdap.and_then(|r| r.get_registrant_contact());
339        let rdap_admin = rdap.and_then(|r| r.get_admin_contact());
340        let rdap_tech = rdap.and_then(|r| r.get_tech_contact());
341        macro_rules! contact_or_whois {
342            ($contact:ident.$field:ident, $whois_field:ident) => {
343                rdap_or_whois!(
344                    $contact.as_ref().and_then(|c| c.$field.clone()),
345                    $whois_field
346                )
347            };
348        }
349        let registrant_email = contact_or_whois!(rdap_registrant.email, registrant_email);
350        let registrant_phone = contact_or_whois!(rdap_registrant.phone, registrant_phone);
351        let registrant_address = contact_or_whois!(rdap_registrant.address, registrant_address);
352        let registrant_country = contact_or_whois!(rdap_registrant.country, registrant_country);
353        let admin_name = contact_or_whois!(rdap_admin.name, admin_name);
354        let admin_organization = contact_or_whois!(rdap_admin.organization, admin_organization);
355        let admin_email = contact_or_whois!(rdap_admin.email, admin_email);
356        let admin_phone = contact_or_whois!(rdap_admin.phone, admin_phone);
357        let tech_name = contact_or_whois!(rdap_tech.name, tech_name);
358        let tech_organization = contact_or_whois!(rdap_tech.organization, tech_organization);
359        let tech_email = contact_or_whois!(rdap_tech.email, tech_email);
360        let tech_phone = contact_or_whois!(rdap_tech.phone, tech_phone);
361
362        // --- Protocol metadata ---
363        let rdap_url = rdap.and_then(|r| {
364            r.links
365                .iter()
366                .find(|l| l.rel.as_deref() == Some("self"))
367                .and_then(|l| l.href.clone())
368        });
369
370        let whois_server = whois
371            .map(|w| w.whois_server.clone())
372            .filter(|s| !s.is_empty());
373
374        // Registrar object detail is RDAP-only structured data (abuse contact,
375        // IANA ID, URL). WHOIS carries no equivalently structured fields.
376        let registrar_detail = rdap.and_then(|r| r.get_registrar_detail());
377        let registrar_abuse_email = registrar_detail
378            .as_ref()
379            .and_then(|d| d.abuse_email.clone());
380        let registrar_abuse_phone = registrar_detail
381            .as_ref()
382            .and_then(|d| d.abuse_phone.clone());
383        let registrar_iana_id = registrar_detail.as_ref().and_then(|d| d.iana_id.clone());
384        let registrar_url = registrar_detail.as_ref().and_then(|d| d.url.clone());
385
386        let mut info = DomainInfo {
387            domain: domain.to_string(),
388            source,
389            registrar,
390            registrant,
391            organization,
392            creation_date,
393            expiration_date,
394            updated_date,
395            nameservers,
396            status,
397            dnssec,
398            registrant_email,
399            registrant_phone,
400            registrant_address,
401            registrant_country,
402            admin_name,
403            admin_organization,
404            admin_email,
405            admin_phone,
406            tech_name,
407            tech_organization,
408            tech_email,
409            tech_phone,
410            whois_server,
411            rdap_url,
412            registrar_abuse_email,
413            registrar_abuse_phone,
414            registrar_iana_id,
415            registrar_url,
416            availability_verdict: None,
417            days_until_expiration: None,
418            domain_age_days: None,
419            expiry_status: None,
420            status_descriptions: Vec::new(),
421            dns_nameservers: None,
422        };
423        info.compute_lifecycle(Utc::now());
424        info
425    }
426
427    /// Populates the computed lifecycle fields (`days_until_expiration`,
428    /// `domain_age_days`, `expiry_status`, `status_descriptions`) relative to
429    /// `now`. Pure given `now`, so it is unit-testable with a fixed clock.
430    fn compute_lifecycle(&mut self, now: DateTime<Utc>) {
431        self.days_until_expiration = self.expiration_date.map(|e| days_until(e, now));
432        self.domain_age_days = self.creation_date.map(|c| (now - c).num_days());
433        self.expiry_status = derive_expiry_status(self.days_until_expiration, &self.status);
434        self.status_descriptions = self
435            .status
436            .iter()
437            .filter_map(|code| {
438                describe_epp_status(code).map(|d| StatusDescription {
439                    code: code.clone(),
440                    description: d.to_string(),
441                })
442            })
443            .collect();
444    }
445
446    /// Constructs a `DomainInfo` from a `LookupResult`, extracting RDAP and WHOIS
447    /// data from whatever variant is present.
448    pub fn from_lookup_result(result: &LookupResult) -> Self {
449        match result {
450            LookupResult::Rdap {
451                data,
452                whois_fallback,
453            } => Self::from_sources(
454                // Normalized like the WHOIS arm's queried name, so `domain`
455                // does not depend on which protocol answered.
456                &result
457                    .domain_name()
458                    .unwrap_or_else(|| "unknown".to_string()),
459                Some(data),
460                whois_fallback.as_ref(),
461            ),
462            LookupResult::Whois {
463                data,
464                rdap_fallback,
465                dns_nameservers,
466                ..
467            } => Self {
468                dns_nameservers: dns_nameservers.clone(),
469                ..Self::from_sources(
470                    &data.domain,
471                    rdap_fallback.as_ref().map(|b| b.as_ref()),
472                    Some(data),
473                )
474            },
475            LookupResult::Available {
476                data, whois_data, ..
477            } => {
478                // When the verdict is "not available" (registered via DNS
479                // presence, inconclusive, ...), whatever WHOIS returned is
480                // real, if partial, registration data — merge it rather than
481                // discarding it. For an available verdict the WHOIS body is
482                // a "no match" banner with nothing worth surfacing.
483                let whois = whois_data.as_ref().filter(|_| !data.available);
484                let mut info = Self::from_sources(&data.domain, None, whois);
485                info.source = DomainInfoSource::Available;
486                info.availability_verdict = Some(data.verdict().to_string());
487                info
488            }
489        }
490    }
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496
497    #[test]
498    fn has_status_key_matches_either_protocol_form() {
499        let statuses = vec![
500            "client transfer prohibited (https://icann.org/epp)".to_string(),
501            "serverDeleteProhibited".to_string(),
502        ];
503        assert!(has_status_key(&statuses, "clienttransferprohibited"));
504        assert!(has_status_key(&statuses, "serverdeleteprohibited"));
505        assert!(!has_status_key(&statuses, "clientupdateprohibited"));
506    }
507
508    /// Build a test RdapResponse with registrar entity, events, nameserver,
509    /// secure_dns, and a self link via JSON deserialization.
510    fn make_test_rdap() -> RdapResponse {
511        serde_json::from_value(serde_json::json!({
512            "objectClassName": "domain",
513            "ldhName": "example.com",
514            "status": ["active"],
515            "events": [
516                {
517                    "eventAction": "registration",
518                    "eventDate": "2020-01-15T00:00:00Z"
519                },
520                {
521                    "eventAction": "expiration",
522                    "eventDate": "2025-01-15T00:00:00Z"
523                },
524                {
525                    "eventAction": "last changed",
526                    "eventDate": "2024-06-01T12:00:00Z"
527                }
528            ],
529            "entities": [
530                {
531                    "objectClassName": "entity",
532                    "roles": ["registrar"],
533                    "vcardArray": [
534                        "vcard",
535                        [
536                            ["version", {}, "text", "4.0"],
537                            ["fn", {}, "text", "RDAP Registrar LLC"]
538                        ]
539                    ]
540                }
541            ],
542            "nameservers": [
543                {
544                    "objectClassName": "nameserver",
545                    "ldhName": "ns1.rdap-example.com"
546                }
547            ],
548            "secureDNS": {
549                "delegationSigned": true,
550                "dsData": [],
551                "keyData": []
552            },
553            "links": [
554                {
555                    "rel": "self",
556                    "href": "https://rdap.example.com/domain/example.com"
557                }
558            ]
559        }))
560        .expect("test RDAP JSON should deserialize")
561    }
562
563    /// Build a test WhoisResponse with all fields populated.
564    fn make_test_whois() -> WhoisResponse {
565        use chrono::TimeZone;
566        WhoisResponse {
567            domain: "example.com".to_string(),
568            registrar: Some("WHOIS Registrar Inc.".to_string()),
569            registrant: Some("John Doe".to_string()),
570            organization: Some("Example Corp".to_string()),
571            registrant_email: Some("john@example.com".to_string()),
572            registrant_phone: Some("+1.5551234567".to_string()),
573            registrant_address: Some("123 Main St".to_string()),
574            registrant_country: Some("US".to_string()),
575            admin_name: Some("Admin Person".to_string()),
576            admin_organization: Some("Admin Org".to_string()),
577            admin_email: Some("admin@example.com".to_string()),
578            admin_phone: Some("+1.5559876543".to_string()),
579            tech_name: Some("Tech Person".to_string()),
580            tech_organization: Some("Tech Org".to_string()),
581            tech_email: Some("tech@example.com".to_string()),
582            tech_phone: Some("+1.5555555555".to_string()),
583            creation_date: Some(Utc.with_ymd_and_hms(2019, 6, 1, 0, 0, 0).unwrap()),
584            expiration_date: Some(Utc.with_ymd_and_hms(2024, 6, 1, 0, 0, 0).unwrap()),
585            updated_date: Some(Utc.with_ymd_and_hms(2023, 12, 15, 0, 0, 0).unwrap()),
586            nameservers: vec![
587                "ns1.whois-example.com".to_string(),
588                "ns2.whois-example.com".to_string(),
589            ],
590            status: vec!["clientTransferProhibited".to_string()],
591            dnssec: Some("signedDelegation".to_string()),
592            whois_server: "whois.example.com".to_string(),
593            raw_response: "raw whois data".to_string(),
594        }
595    }
596
597    #[test]
598    fn test_from_sources_both() {
599        let rdap = make_test_rdap();
600        let whois = make_test_whois();
601        let info = DomainInfo::from_sources("example.com", Some(&rdap), Some(&whois));
602
603        assert_eq!(info.source, DomainInfoSource::Both);
604        assert_eq!(info.domain, "example.com");
605
606        // RDAP takes priority for registrar
607        assert_eq!(info.registrar.as_deref(), Some("RDAP Registrar LLC"));
608
609        // RDAP has no registrant entity, so WHOIS fills in
610        assert_eq!(info.registrant.as_deref(), Some("John Doe"));
611        assert_eq!(info.organization.as_deref(), Some("Example Corp"));
612
613        // RDAP dates take priority
614        assert!(info.creation_date.is_some());
615        assert_eq!(
616            info.creation_date.unwrap().to_rfc3339(),
617            "2020-01-15T00:00:00+00:00"
618        );
619        assert_eq!(
620            info.expiration_date.unwrap().to_rfc3339(),
621            "2025-01-15T00:00:00+00:00"
622        );
623        assert_eq!(
624            info.updated_date.unwrap().to_rfc3339(),
625            "2024-06-01T12:00:00+00:00"
626        );
627
628        // RDAP nameservers non-empty, so used
629        assert_eq!(info.nameservers, vec!["ns1.rdap-example.com"]);
630
631        // RDAP status non-empty, so used
632        assert_eq!(info.status, vec!["active"]);
633
634        // DNSSEC from RDAP secure_dns
635        assert_eq!(info.dnssec.as_deref(), Some("signed"));
636
637        // Contacts: RDAP has no registrant/admin/tech contact entities, so WHOIS fills
638        assert_eq!(info.registrant_email.as_deref(), Some("john@example.com"));
639        assert_eq!(info.registrant_phone.as_deref(), Some("+1.5551234567"));
640        assert_eq!(info.registrant_address.as_deref(), Some("123 Main St"));
641        assert_eq!(info.registrant_country.as_deref(), Some("US"));
642        assert_eq!(info.admin_name.as_deref(), Some("Admin Person"));
643        assert_eq!(info.admin_organization.as_deref(), Some("Admin Org"));
644        assert_eq!(info.admin_email.as_deref(), Some("admin@example.com"));
645        assert_eq!(info.admin_phone.as_deref(), Some("+1.5559876543"));
646        assert_eq!(info.tech_name.as_deref(), Some("Tech Person"));
647        assert_eq!(info.tech_organization.as_deref(), Some("Tech Org"));
648        assert_eq!(info.tech_email.as_deref(), Some("tech@example.com"));
649        assert_eq!(info.tech_phone.as_deref(), Some("+1.5555555555"));
650
651        // Protocol metadata
652        assert_eq!(info.whois_server.as_deref(), Some("whois.example.com"));
653        assert_eq!(
654            info.rdap_url.as_deref(),
655            Some("https://rdap.example.com/domain/example.com")
656        );
657    }
658
659    #[test]
660    fn test_from_sources_whois_only() {
661        let whois = make_test_whois();
662        let info = DomainInfo::from_sources("example.com", None, Some(&whois));
663
664        assert_eq!(info.source, DomainInfoSource::Whois);
665        assert_eq!(info.registrar.as_deref(), Some("WHOIS Registrar Inc."));
666        assert_eq!(info.registrant.as_deref(), Some("John Doe"));
667        assert_eq!(info.organization.as_deref(), Some("Example Corp"));
668        assert_eq!(
669            info.creation_date.unwrap().to_rfc3339(),
670            "2019-06-01T00:00:00+00:00"
671        );
672        assert_eq!(
673            info.expiration_date.unwrap().to_rfc3339(),
674            "2024-06-01T00:00:00+00:00"
675        );
676        assert_eq!(
677            info.updated_date.unwrap().to_rfc3339(),
678            "2023-12-15T00:00:00+00:00"
679        );
680        assert_eq!(
681            info.nameservers,
682            vec!["ns1.whois-example.com", "ns2.whois-example.com"]
683        );
684        assert_eq!(info.status, vec!["clientTransferProhibited"]);
685        assert_eq!(info.dnssec.as_deref(), Some("signedDelegation"));
686        assert_eq!(info.registrant_email.as_deref(), Some("john@example.com"));
687        assert_eq!(info.admin_name.as_deref(), Some("Admin Person"));
688        assert_eq!(info.tech_email.as_deref(), Some("tech@example.com"));
689        assert_eq!(info.whois_server.as_deref(), Some("whois.example.com"));
690        assert!(info.rdap_url.is_none());
691    }
692
693    #[test]
694    fn test_from_sources_rdap_only() {
695        let rdap = make_test_rdap();
696        let info = DomainInfo::from_sources("example.com", Some(&rdap), None);
697
698        assert_eq!(info.source, DomainInfoSource::Rdap);
699        assert_eq!(info.registrar.as_deref(), Some("RDAP Registrar LLC"));
700        // No registrant entity in our test RDAP, no WHOIS to fall back to
701        assert!(info.registrant.is_none());
702        assert!(info.organization.is_none());
703        assert!(info.creation_date.is_some());
704        assert!(info.expiration_date.is_some());
705        assert!(info.updated_date.is_some());
706        assert_eq!(info.nameservers, vec!["ns1.rdap-example.com"]);
707        assert_eq!(info.status, vec!["active"]);
708        assert_eq!(info.dnssec.as_deref(), Some("signed"));
709        // No contact entities in RDAP test data
710        assert!(info.registrant_email.is_none());
711        assert!(info.admin_name.is_none());
712        assert!(info.tech_email.is_none());
713        assert!(info.whois_server.is_none());
714        assert_eq!(
715            info.rdap_url.as_deref(),
716            Some("https://rdap.example.com/domain/example.com")
717        );
718    }
719
720    #[test]
721    fn test_from_sources_neither() {
722        let info = DomainInfo::from_sources("available.com", None, None);
723
724        assert_eq!(info.source, DomainInfoSource::Available);
725        assert_eq!(info.domain, "available.com");
726        assert!(info.registrar.is_none());
727        assert!(info.registrant.is_none());
728        assert!(info.organization.is_none());
729        assert!(info.creation_date.is_none());
730        assert!(info.expiration_date.is_none());
731        assert!(info.updated_date.is_none());
732        assert!(info.nameservers.is_empty());
733        assert!(info.status.is_empty());
734        assert!(info.dnssec.is_none());
735        assert!(info.registrant_email.is_none());
736        assert!(info.admin_name.is_none());
737        assert!(info.tech_email.is_none());
738        assert!(info.whois_server.is_none());
739        assert!(info.rdap_url.is_none());
740    }
741
742    #[test]
743    fn test_from_lookup_result_rdap_variant() {
744        let rdap = make_test_rdap();
745        let whois = make_test_whois();
746        let result = LookupResult::Rdap {
747            data: Box::new(rdap),
748            whois_fallback: Some(whois),
749        };
750
751        let info = DomainInfo::from_lookup_result(&result);
752        assert_eq!(info.source, DomainInfoSource::Both);
753        assert_eq!(info.domain, "example.com");
754        assert_eq!(info.registrar.as_deref(), Some("RDAP Registrar LLC"));
755        // WHOIS fills gaps
756        assert_eq!(info.registrant.as_deref(), Some("John Doe"));
757    }
758
759    #[test]
760    fn from_lookup_result_rdap_domain_is_lowercase() {
761        let mut rdap = make_test_rdap();
762        rdap.ldh_name = Some("EXAMPLE.COM".to_string());
763        let result = LookupResult::Rdap {
764            data: Box::new(rdap),
765            whois_fallback: None,
766        };
767        assert_eq!(
768            DomainInfo::from_lookup_result(&result).domain,
769            "example.com"
770        );
771    }
772
773    #[test]
774    fn test_from_lookup_result_whois_variant() {
775        let whois = make_test_whois();
776        let result = LookupResult::Whois {
777            data: whois,
778            rdap_error: Some("RDAP failed".to_string()),
779            rdap_fallback: None,
780            dns_nameservers: None,
781        };
782
783        let info = DomainInfo::from_lookup_result(&result);
784        assert_eq!(info.source, DomainInfoSource::Whois);
785        assert_eq!(info.domain, "example.com");
786        assert_eq!(info.registrar.as_deref(), Some("WHOIS Registrar Inc."));
787        assert!(info.rdap_url.is_none());
788    }
789
790    #[test]
791    fn from_sources_populates_registrar_detail() {
792        let rdap: RdapResponse = serde_json::from_value(serde_json::json!({
793            "objectClassName": "domain",
794            "ldhName": "example.com",
795            "entities": [{
796                "objectClassName": "entity",
797                "roles": ["registrar"],
798                "publicIds": [{"type": "IANA Registrar ID", "identifier": "292"}],
799                "links": [{"rel": "about", "href": "https://registrar.example"}],
800                "vcardArray": ["vcard", [["fn", {}, "text", "Example Registrar"]]],
801                "entities": [{
802                    "objectClassName": "entity",
803                    "roles": ["abuse"],
804                    "vcardArray": ["vcard", [
805                        ["email", {}, "text", "abuse@registrar.example"],
806                        ["tel", {}, "text", "+1.5551230000"]
807                    ]]
808                }]
809            }]
810        }))
811        .unwrap();
812        let info = DomainInfo::from_sources("example.com", Some(&rdap), None);
813        assert_eq!(info.registrar_iana_id.as_deref(), Some("292"));
814        assert_eq!(
815            info.registrar_url.as_deref(),
816            Some("https://registrar.example")
817        );
818        assert_eq!(
819            info.registrar_abuse_email.as_deref(),
820            Some("abuse@registrar.example")
821        );
822        assert_eq!(info.registrar_abuse_phone.as_deref(), Some("+1.5551230000"));
823    }
824
825    #[test]
826    fn describe_epp_status_decodes_known_codes() {
827        assert_eq!(
828            describe_epp_status("clientTransferProhibited"),
829            Some("transfer locked (by registrar)")
830        );
831        // RDAP space-separated form maps to the same description.
832        assert_eq!(
833            describe_epp_status("client transfer prohibited"),
834            Some("transfer locked (by registrar)")
835        );
836        assert_eq!(
837            describe_epp_status("serverHold"),
838            Some("resolution suspended (by registry)")
839        );
840        assert_eq!(
841            describe_epp_status("redemptionPeriod"),
842            Some("recently expired, recoverable (redemption period)")
843        );
844        // A trailing RDAP URL annotation is ignored.
845        assert_eq!(
846            describe_epp_status("client hold (https://example.com/info)"),
847            Some("resolution suspended (by registrar)")
848        );
849        assert_eq!(describe_epp_status("totally unknown code"), None);
850    }
851
852    #[test]
853    fn derive_expiry_status_bands_by_days_and_codes() {
854        // Status codes win over the date math.
855        assert_eq!(
856            derive_expiry_status(Some(200), &["pendingDelete".into()]),
857            Some(ExpiryStatus::PendingDelete)
858        );
859        assert_eq!(
860            derive_expiry_status(Some(200), &["redemptionPeriod".into()]),
861            Some(ExpiryStatus::Redemption)
862        );
863        // Date bands.
864        assert_eq!(
865            derive_expiry_status(Some(365), &[]),
866            Some(ExpiryStatus::Active)
867        );
868        assert_eq!(
869            derive_expiry_status(Some(30), &[]),
870            Some(ExpiryStatus::ExpiringSoon)
871        );
872        assert_eq!(
873            derive_expiry_status(Some(0), &[]),
874            Some(ExpiryStatus::ExpiringSoon)
875        );
876        assert_eq!(
877            derive_expiry_status(Some(-5), &[]),
878            Some(ExpiryStatus::Expired)
879        );
880        // Unknown date with no informative codes → None.
881        assert_eq!(derive_expiry_status(None, &["ok".into()]), None);
882    }
883
884    #[test]
885    fn compute_lifecycle_uses_fixed_clock() {
886        use chrono::TimeZone;
887        // make_test_whois: creation 2019-06-01, expiration 2024-06-01,
888        // status clientTransferProhibited.
889        let whois = make_test_whois();
890        let mut info = DomainInfo::from_sources("example.com", None, Some(&whois));
891        let now = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
892        info.compute_lifecycle(now);
893
894        let expected_days = (Utc.with_ymd_and_hms(2024, 6, 1, 0, 0, 0).unwrap() - now).num_days();
895        assert_eq!(info.days_until_expiration, Some(expected_days));
896        assert!(info.days_until_expiration.unwrap() > 0);
897        assert!(info.domain_age_days.unwrap() > 0);
898        // ~152 days out (>30) → Active.
899        assert_eq!(info.expiry_status, Some(ExpiryStatus::Active));
900        // clientTransferProhibited is decoded.
901        assert_eq!(info.status_descriptions.len(), 1);
902        assert_eq!(info.status_descriptions[0].code, "clientTransferProhibited");
903        assert_eq!(
904            info.status_descriptions[0].description,
905            "transfer locked (by registrar)"
906        );
907    }
908
909    #[test]
910    fn test_serialization_round_trip() {
911        let rdap = make_test_rdap();
912        let whois = make_test_whois();
913        let info = DomainInfo::from_sources("example.com", Some(&rdap), Some(&whois));
914
915        let json = serde_json::to_string_pretty(&info).expect("serialize");
916        let deserialized: DomainInfo = serde_json::from_str(&json).expect("deserialize");
917
918        assert_eq!(deserialized.domain, info.domain);
919        assert_eq!(deserialized.source, info.source);
920        assert_eq!(deserialized.registrar, info.registrar);
921        assert_eq!(deserialized.registrant, info.registrant);
922        assert_eq!(deserialized.organization, info.organization);
923        assert_eq!(deserialized.creation_date, info.creation_date);
924        assert_eq!(deserialized.expiration_date, info.expiration_date);
925        assert_eq!(deserialized.updated_date, info.updated_date);
926        assert_eq!(deserialized.nameservers, info.nameservers);
927        assert_eq!(deserialized.status, info.status);
928        assert_eq!(deserialized.dnssec, info.dnssec);
929        assert_eq!(deserialized.registrant_email, info.registrant_email);
930        assert_eq!(deserialized.admin_name, info.admin_name);
931        assert_eq!(deserialized.tech_email, info.tech_email);
932        assert_eq!(deserialized.whois_server, info.whois_server);
933        assert_eq!(deserialized.rdap_url, info.rdap_url);
934
935        // Verify source serializes as lowercase
936        assert!(json.contains("\"source\": \"both\""));
937    }
938
939    #[test]
940    fn days_until_is_negative_for_a_just_expired_domain() {
941        use chrono::TimeZone;
942        let now = Utc.with_ymd_and_hms(2026, 1, 10, 12, 0, 0).unwrap();
943        let hours = |h: i64| now + chrono::TimeDelta::hours(h);
944        assert_eq!(days_until(hours(-10), now), -1);
945        assert_eq!(days_until(hours(10), now), 0);
946        assert_eq!(days_until(hours(-48), now), -2);
947        assert_eq!(days_until(hours(-49), now), -2);
948        assert_eq!(days_until(hours(49), now), 2);
949
950        // Expired ten hours ago: previously truncated to 0 days, which
951        // banded as ExpiringSoon.
952        let mut whois = make_test_whois();
953        whois.expiration_date = Some(hours(-10));
954        let mut info = DomainInfo::from_sources("example.com", None, Some(&whois));
955        info.compute_lifecycle(now);
956        assert_eq!(info.days_until_expiration, Some(-1));
957        assert_eq!(info.expiry_status, Some(ExpiryStatus::Expired));
958
959        // Ten hours still to go is expiring soon, not expired.
960        whois.expiration_date = Some(hours(10));
961        let mut info = DomainInfo::from_sources("example.com", None, Some(&whois));
962        info.compute_lifecycle(now);
963        assert_eq!(info.days_until_expiration, Some(0));
964        assert_eq!(info.expiry_status, Some(ExpiryStatus::ExpiringSoon));
965    }
966
967    fn available_lookup(available: bool, confidence: &str, method: &str) -> LookupResult {
968        LookupResult::Available {
969            data: Box::new(crate::availability::AvailabilityResult::new(
970                "example.com",
971                available,
972                confidence,
973                method,
974            )),
975            rdap_error: String::new(),
976            whois_error: String::new(),
977            whois_data: Some(make_test_whois()),
978        }
979    }
980
981    #[test]
982    fn from_lookup_result_merges_whois_data_for_unavailable_verdicts() {
983        // A registered-via-DNS (or inconclusive) verdict still carries real
984        // WHOIS data; `seer info`, bulk CSV, drift and confusables need it.
985        let info = DomainInfo::from_lookup_result(&available_lookup(false, "high", "dns_present"));
986        assert_eq!(info.source, DomainInfoSource::Available);
987        assert_eq!(info.availability_verdict.as_deref(), Some("registered"));
988        assert_eq!(
989            info.nameservers,
990            vec!["ns1.whois-example.com", "ns2.whois-example.com"]
991        );
992        assert_eq!(info.status, vec!["clientTransferProhibited"]);
993        assert_eq!(info.whois_server.as_deref(), Some("whois.example.com"));
994
995        // An available verdict's WHOIS body is a no-match banner: not merged.
996        let info = DomainInfo::from_lookup_result(&available_lookup(true, "high", "whois"));
997        assert_eq!(info.availability_verdict.as_deref(), Some("available"));
998        assert!(info.nameservers.is_empty());
999        assert!(info.registrar.is_none());
1000        assert!(info.whois_server.is_none());
1001    }
1002}