Skip to main content

seer_core/
availability.rs

1//! Domain availability checking.
2//!
3//! Determines if a domain is available for registration by interpreting
4//! WHOIS/RDAP "not found" responses.
5
6use serde::{Deserialize, Serialize};
7use tracing::{debug, instrument};
8
9use crate::dns::{DnsPresence, DnsResolver};
10use crate::error::Result;
11use crate::rdap::{rdap_error_is_404, RdapClient};
12use crate::whois::WhoisClient;
13
14/// Result of a domain availability check.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct AvailabilityResult {
17    /// The domain that was checked.
18    pub domain: String,
19    /// Whether the domain appears to be available for registration.
20    pub available: bool,
21    /// Confidence level of the result ("high", "medium", "low").
22    pub confidence: String,
23    /// How availability was determined.
24    pub method: String,
25    /// Additional details about the check.
26    pub details: Option<String>,
27}
28
29impl AvailabilityResult {
30    /// Stable verdict string derived from `(available, confidence)`. Use this
31    /// instead of branching on `confidence` alone — a `confidence: "high"`
32    /// result can still mean "registered" when `available == false`.
33    pub fn verdict(&self) -> &'static str {
34        match (self.available, self.confidence.as_str()) {
35            (true, "high") => "available",
36            (true, "medium") => "likely_available",
37            (false, "high") => "registered",
38            (false, "medium") => "likely_registered",
39            _ => "unknown",
40        }
41    }
42}
43
44/// A prior RDAP attempt handed to [`AvailabilityChecker::check_with_prior`], so
45/// the checker can reuse the smart-lookup race's result instead of re-issuing
46/// the identical query that just ran (and re-hammering a throttling registry).
47pub(crate) enum PriorRdap {
48    /// RDAP returned a response (HTTP 200); reuse it rather than re-querying.
49    /// Boxed because an `RdapResponse` dwarfs the other variants.
50    Response(Box<crate::rdap::RdapResponse>),
51    /// RDAP failed with this error; reuse it rather than re-querying.
52    Failed(crate::error::SeerError),
53    /// No usable RDAP result (e.g. grace-truncated); query fresh.
54    Missing,
55}
56
57/// A prior WHOIS attempt handed to [`AvailabilityChecker::check_with_prior`].
58///
59/// There is no `Response` variant: a successful WHOIS is consumed before the
60/// smart-lookup ever reaches the availability fallback, so the checker only
61/// ever reuses a failed leg or re-queries a genuinely missing one.
62pub(crate) enum PriorWhois {
63    /// WHOIS failed with this error; reuse it rather than re-querying.
64    Failed(crate::error::SeerError),
65    /// No usable WHOIS result (e.g. grace-truncated); query fresh.
66    Missing,
67}
68
69/// Checks domain availability by attempting lookups and interpreting failures.
70#[derive(Debug, Clone)]
71pub struct AvailabilityChecker {
72    rdap_client: RdapClient,
73    whois_client: WhoisClient,
74    dns_resolver: DnsResolver,
75}
76
77impl Default for AvailabilityChecker {
78    fn default() -> Self {
79        Self::new()
80    }
81}
82
83impl AvailabilityChecker {
84    pub fn new() -> Self {
85        Self {
86            rdap_client: RdapClient::new(),
87            whois_client: WhoisClient::new(),
88            dns_resolver: DnsResolver::new(),
89        }
90    }
91
92    /// Builds a checker whose sub-clients honor the timeouts in `config`.
93    pub fn from_config(config: &crate::config::SeerConfig) -> Self {
94        Self {
95            rdap_client: RdapClient::new().with_timeout(config.rdap_timeout()),
96            whois_client: WhoisClient::new().with_timeout(config.whois_timeout()),
97            dns_resolver: DnsResolver::new().with_timeout(config.dns_timeout()),
98        }
99    }
100
101    /// Check if a domain is available for registration.
102    ///
103    /// A name below its registrable domain (`mail.google.com`) is never
104    /// reported available on the strength of the registry having no object
105    /// for it; see [`AvailabilityChecker::guard_subdomain_claim`].
106    #[instrument(skip(self), fields(domain = %domain))]
107    pub async fn check(&self, domain: &str) -> Result<AvailabilityResult> {
108        let domain = crate::validation::normalize_domain(domain)?;
109        debug!(domain = %domain, "Checking domain availability");
110        let result = self.check_registry(&domain).await;
111        Ok(self.guard_subdomain_claim(result).await)
112    }
113
114    /// The RDAP → WHOIS + DNS ladder for an already-normalized name, without
115    /// the subdomain guard.
116    async fn check_registry(&self, domain: &str) -> AvailabilityResult {
117        // Try RDAP first - it gives structured error responses.
118        match self.rdap_client.lookup_domain(domain).await {
119            Ok(response) => decide_from_rdap(domain, response),
120            Err(rdap_err) => {
121                debug!(error = %rdap_err, "RDAP lookup failed, falling back to WHOIS + DNS");
122                // Probe WHOIS and the apex DNS presence concurrently. DNS is
123                // only the tie-breaker when WHOIS is thin/blocked/errored and
124                // the RDAP failure was not an authoritative 404, so running it
125                // alongside WHOIS (rather than on demand) adds no extra
126                // wall-clock time.
127                let (whois_result, dns_presence) = tokio::join!(
128                    self.whois_client.lookup(domain),
129                    self.dns_resolver.presence(domain),
130                );
131                decide_fallback(domain, &rdap_err, whois_result, dns_presence)
132            }
133        }
134    }
135
136    /// Re-derives an "available" claim for a name that sits *below* its
137    /// registrable domain.
138    ///
139    /// Registries hold objects only for registrable names, so for
140    /// `mail.google.com` both RDAP (404) and WHOIS ("No match") answer "no
141    /// such domain" — which used to be reported as AVAILABLE with high
142    /// confidence. Only the registrable parent (`google.com`) can be
143    /// registered, so the verdict is taken from a check of the parent (see
144    /// [`subdomain_verdict`]). Results for registrable names, and every
145    /// not-available result, pass through untouched with no extra queries.
146    pub(crate) async fn guard_subdomain_claim(
147        &self,
148        result: AvailabilityResult,
149    ) -> AvailabilityResult {
150        if !result.available {
151            return result;
152        }
153        let Some(parent) = crate::psl::registrable_parent(&result.domain) else {
154            return result;
155        };
156        debug!(
157            domain = %result.domain,
158            parent = %parent,
159            "No registry object for a subdomain; checking its registrable parent"
160        );
161        let parent_result = self.check_registry(parent).await;
162        subdomain_verdict(&result.domain, &parent_result)
163    }
164
165    /// Like [`check`](Self::check), but reuses protocol outcomes already
166    /// gathered by the caller (the smart-lookup RDAP+WHOIS race), issuing only
167    /// the queries that are genuinely missing or were grace-truncated. The DNS
168    /// presence probe runs exactly as in `check`.
169    ///
170    /// Routing through the same pure deciders (`decide_from_rdap` /
171    /// `decide_fallback`) guarantees an identical verdict to `check` for the
172    /// same protocol outcomes — this path only removes redundant network calls,
173    /// so the two availability ladders stay in lockstep.
174    #[instrument(skip(self, prior_rdap, prior_whois), fields(domain = %domain))]
175    pub(crate) async fn check_with_prior(
176        &self,
177        domain: &str,
178        prior_rdap: PriorRdap,
179        prior_whois: PriorWhois,
180    ) -> Result<AvailabilityResult> {
181        let domain = crate::validation::normalize_domain(domain)?;
182        debug!(domain = %domain, "Checking availability (reusing prior protocol outcomes)");
183
184        // Reuse the prior RDAP result; only re-query when it is truly absent
185        // (grace-truncated), never when it already succeeded or errored.
186        let rdap = match prior_rdap {
187            PriorRdap::Response(r) => Ok(*r),
188            PriorRdap::Failed(e) => Err(e),
189            PriorRdap::Missing => self.rdap_client.lookup_domain(&domain).await,
190        };
191
192        let result = match rdap {
193            Ok(response) => decide_from_rdap(&domain, response),
194            Err(rdap_err) => {
195                // Reuse the prior WHOIS result; only re-query when absent. The
196                // DNS presence probe always runs (it is the tie-breaker
197                // `decide_fallback` consults), concurrently with a fresh WHOIS
198                // query when one is needed.
199                let (whois_result, dns_presence) = match prior_whois {
200                    PriorWhois::Failed(e) => (Err(e), self.dns_resolver.presence(&domain).await),
201                    PriorWhois::Missing => tokio::join!(
202                        self.whois_client.lookup(&domain),
203                        self.dns_resolver.presence(&domain),
204                    ),
205                };
206                decide_fallback(&domain, &rdap_err, whois_result, dns_presence)
207            }
208        };
209        Ok(self.guard_subdomain_claim(result).await)
210    }
211}
212
213/// "Thin" WHOIS = no positive registration signal at all: no registrar, no
214/// creation/expiry date, and no delegated nameservers. A thin body is what
215/// blocked or RDAP-first registries return for an unregistered domain.
216///
217/// Nameservers count as registration data because some registries never
218/// publish a registrar or dates over port 43: DENIC (.de, which has no RDAP)
219/// returns only `Nserver`/`Status`/`Changed`, so without them every
220/// registered .de domain read as thin and was reported as "registry detail
221/// unavailable, retry shortly" with its WHOIS data discarded. This matches
222/// [`crate::whois::WhoisResponse::is_available`], which already treats
223/// nameservers as registration data. Shared by this module's fallback ladder
224/// and the smart-lookup routes so the two cannot drift.
225pub(crate) fn whois_is_thin(w: &crate::whois::WhoisResponse) -> bool {
226    w.registrar.is_none()
227        && w.creation_date.is_none()
228        && w.expiration_date.is_none()
229        && w.nameservers.is_empty()
230}
231
232/// Verdict for a name below its registrable domain, derived from the check
233/// of that registrable `parent` (see
234/// [`AvailabilityChecker::guard_subdomain_claim`]).
235///
236/// The name itself is never "available": it cannot be registered on its
237/// own. Under a (likely) registered parent it inherits the parent's
238/// registered verdict and confidence; under an available or undetermined
239/// parent the verdict is unknown (confidence `none`) and the details say
240/// which name to register instead.
241fn subdomain_verdict(domain: &str, parent: &AvailabilityResult) -> AvailabilityResult {
242    let p = &parent.domain;
243    let (confidence, status) = match parent.verdict() {
244        "registered" | "likely_registered" => {
245            (parent.confidence.clone(), format!("{p} is registered"))
246        }
247        "available" | "likely_available" => (
248            "none".to_string(),
249            format!("{p} appears available; register {p} to obtain {domain}"),
250        ),
251        _ => (
252            "none".to_string(),
253            format!("the registration status of {p} could not be determined"),
254        ),
255    };
256    AvailabilityResult {
257        domain: domain.to_string(),
258        available: false,
259        confidence,
260        method: "registrable_parent".to_string(),
261        details: Some(format!(
262            "{domain} is not itself registrable: it is a name under the registrable domain {p}, \
263             and registries hold no object for subdomains. {status}."
264        )),
265    }
266}
267
268/// Pure decision function: build an `AvailabilityResult` from a successful
269/// RDAP lookup. Extracted from `check()` so the decision matrix can be
270/// table-tested without a network stack.
271fn decide_from_rdap(domain: &str, response: crate::rdap::RdapResponse) -> AvailabilityResult {
272    let statuses: Vec<String> = response.status.clone();
273    let is_redemption = statuses.iter().any(|s| {
274        // RDAP/EPP status tokens are a controlled vocabulary; match the
275        // standard redemption / pending-delete tokens exactly (case- and
276        // whitespace-insensitive) rather than by substring, so a verbose
277        // status such as "clientHold (no redemption requested)" is not
278        // misread as the redemption state, and a capitalized "Redemption
279        // Period" is still detected.
280        let norm: String = s
281            .chars()
282            .filter(|c| !c.is_whitespace())
283            .collect::<String>()
284            .to_lowercase();
285        matches!(norm.as_str(), "redemptionperiod" | "pendingdelete")
286    });
287
288    if is_redemption {
289        return AvailabilityResult {
290            domain: domain.to_string(),
291            available: false,
292            confidence: "medium".to_string(),
293            method: "rdap".to_string(),
294            details: Some("Domain is in redemption/pending delete period".to_string()),
295        };
296    }
297
298    AvailabilityResult {
299        domain: domain.to_string(),
300        available: false,
301        confidence: "high".to_string(),
302        method: "rdap".to_string(),
303        details: Some(format!(
304            "Domain is registered (status: {})",
305            statuses.join(", ")
306        )),
307    }
308}
309
310/// Pure decision function: build an `AvailabilityResult` when RDAP failed
311/// and WHOIS (plus a DNS presence probe) is the fallback. Extracted from
312/// `check()` for table-testing. `dns_presence` is only consulted when the
313/// registry signals are inconclusive — a thin/blocked WHOIS body or a
314/// transport-failed WHOIS leg, with a non-404 RDAP failure. An apex with no
315/// DNS presence (NXDOMAIN) then reads as likely-available at medium
316/// confidence; a delegated apex behind two transport failures reads as
317/// likely-registered at medium confidence.
318fn decide_fallback(
319    domain: &str,
320    rdap_err: &crate::error::SeerError,
321    whois_result: Result<crate::whois::WhoisResponse>,
322    dns_presence: DnsPresence,
323) -> AvailabilityResult {
324    match whois_result {
325        Ok(whois_response) => {
326            // "Thin" = no positive registration signal at all (see
327            // `whois_is_thin`).
328            let thin = whois_is_thin(&whois_response);
329
330            if whois_response.is_available() {
331                AvailabilityResult {
332                    domain: domain.to_string(),
333                    available: true,
334                    confidence: "high".to_string(),
335                    method: "whois".to_string(),
336                    details: Some("WHOIS indicates domain is not registered".to_string()),
337                }
338            } else if !thin {
339                // A concrete registration signal (registrar / dates /
340                // nameservers) is present → the domain is registered.
341                AvailabilityResult {
342                    domain: domain.to_string(),
343                    available: false,
344                    confidence: "high".to_string(),
345                    method: "whois".to_string(),
346                    details: whois_response
347                        .registrar
348                        .map(|r| format!("Registered with {}", r)),
349                }
350            } else if rdap_error_is_404(rdap_err) {
351                // Thin WHOIS — often an access-blocked refusal like SWITCH's
352                // ".ch" — but the registry's own RDAP authoritatively 404'd.
353                AvailabilityResult {
354                    domain: domain.to_string(),
355                    available: true,
356                    confidence: "high".to_string(),
357                    method: "rdap".to_string(),
358                    details: Some("Registry RDAP reports no such domain (HTTP 404)".to_string()),
359                }
360            } else if whois_response.indicates_registry_refusal() {
361                // Thin WHOIS that explicitly refused / throttled / negated the
362                // query (rate limit, access denied, reserved, "not available
363                // for registration") while RDAP did not authoritatively 404.
364                // There is no usable registration signal — report inconclusive
365                // rather than inverting the refusal into "available" or guessing
366                // from DNS presence / fail-safing to "registered" (issue #45).
367                AvailabilityResult {
368                    domain: domain.to_string(),
369                    available: false,
370                    confidence: "none".to_string(),
371                    method: "inconclusive".to_string(),
372                    details: Some(
373                        "Registry refused or throttled the query; availability is inconclusive"
374                            .to_string(),
375                    ),
376                }
377            } else if dns_presence == DnsPresence::Absent {
378                // Thin WHOIS, RDAP did not 404, and the apex is NXDOMAIN —
379                // corroborating evidence the domain is unregistered.
380                AvailabilityResult {
381                    domain: domain.to_string(),
382                    available: true,
383                    confidence: "medium".to_string(),
384                    method: "dns_nxdomain".to_string(),
385                    details: Some(
386                        "No registry data available; domain has no DNS presence (NXDOMAIN)"
387                            .to_string(),
388                    ),
389                }
390            } else {
391                // Thin WHOIS we could not interpret and no corroborating
392                // NXDOMAIN — fail safe toward "registered".
393                AvailabilityResult {
394                    domain: domain.to_string(),
395                    available: false,
396                    confidence: "high".to_string(),
397                    method: "whois".to_string(),
398                    details: None,
399                }
400            }
401        }
402        Err(whois_err) => {
403            // RDAP 404 is authoritative even when the WHOIS leg errored: the
404            // registry's RDAP server reports no such object, so the domain is
405            // unregistered regardless of why WHOIS failed.
406            if rdap_error_is_404(rdap_err) {
407                return AvailabilityResult {
408                    domain: domain.to_string(),
409                    available: true,
410                    confidence: "high".to_string(),
411                    method: "rdap".to_string(),
412                    details: Some("Registry RDAP reports no such domain (HTTP 404)".to_string()),
413                };
414            }
415            // Both registry legs failed. Only a WHOIS-*protocol* error can
416            // carry a registry "no match" signal; a transport failure
417            // (timeout, connection reset, DNS, SSRF refusal) tells us nothing
418            // about registration, so we must not infer availability from its
419            // text even if it incidentally contains a phrase like "no match".
420            let likely_available = matches!(whois_err, crate::error::SeerError::WhoisError(_)) && {
421                let whois_msg = whois_err.to_string().to_lowercase();
422                whois_msg.contains("no match")
423                    || whois_msg.contains("not found")
424                    || whois_msg.contains("no data found")
425                    || whois_msg.contains("no entries found")
426            };
427
428            if likely_available {
429                AvailabilityResult {
430                    domain: domain.to_string(),
431                    available: true,
432                    confidence: "medium".to_string(),
433                    method: "whois_error".to_string(),
434                    details: Some("WHOIS server indicates no matching records".to_string()),
435                }
436            } else if dns_presence == DnsPresence::Absent {
437                // Both registry legs failed, but the apex is NXDOMAIN — the
438                // domain has no DNS presence, so it is likely unregistered.
439                AvailabilityResult {
440                    domain: domain.to_string(),
441                    available: true,
442                    confidence: "medium".to_string(),
443                    method: "dns_nxdomain".to_string(),
444                    details: Some(
445                        "Registry lookups failed; domain has no DNS presence (NXDOMAIN)"
446                            .to_string(),
447                    ),
448                }
449            } else if dns_presence == DnsPresence::Present {
450                // Both registry legs failed with transport errors, but the
451                // apex IS delegated in DNS. Delegation in the TLD zone is
452                // strong evidence of registration (mirrors the
453                // `classify_thin_fallback` Present → Registered route), so
454                // report likely_registered instead of a blank "unknown".
455                // This is the safe direction: it can never call a taken
456                // domain free. Note this arm is a transport failure, not a
457                // registry refusal — refusals arrive as an Ok body and are
458                // kept inconclusive above (issue #45).
459                AvailabilityResult {
460                    domain: domain.to_string(),
461                    available: false,
462                    confidence: "medium".to_string(),
463                    method: "dns_present".to_string(),
464                    details: Some(
465                        "Registry lookups failed, but the apex is delegated in DNS \
466                         (NS records present) — the domain is almost certainly registered"
467                            .to_string(),
468                    ),
469                }
470            } else {
471                // Both queries failed with non-"not found" errors and DNS was
472                // unknown. We genuinely don't know — could be registered,
473                // blocked, or servers down. Default to available=false so we
474                // never tell the user a taken domain is free.
475                AvailabilityResult {
476                    domain: domain.to_string(),
477                    available: false,
478                    confidence: "none".to_string(),
479                    method: "inconclusive".to_string(),
480                    // Use the sanitized error projection so this string —
481                    // which flows into JSON / CSV / MCP output paths —
482                    // never carries raw ANSI escapes or internal IPs from
483                    // a third-party WHOIS/RDAP server's error message.
484                    details: Some(format!(
485                        "Could not determine availability. RDAP: {}. WHOIS: {}",
486                        rdap_err.sanitized_message(),
487                        whois_err.sanitized_message()
488                    )),
489                }
490            }
491        }
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use crate::error::SeerError;
499    use crate::rdap::RdapResponse;
500    use crate::whois::WhoisResponse;
501
502    #[test]
503    fn verdict_matrix() {
504        let make = |available, confidence: &str| AvailabilityResult {
505            domain: "example.test".to_string(),
506            available,
507            confidence: confidence.to_string(),
508            method: "whois".to_string(),
509            details: None,
510        };
511        assert_eq!(make(true, "high").verdict(), "available");
512        assert_eq!(make(true, "medium").verdict(), "likely_available");
513        assert_eq!(make(false, "high").verdict(), "registered");
514        assert_eq!(make(false, "medium").verdict(), "likely_registered");
515        assert_eq!(make(false, "none").verdict(), "unknown");
516        assert_eq!(make(true, "low").verdict(), "unknown");
517    }
518
519    #[test]
520    fn test_availability_result_serialization() {
521        let result = AvailabilityResult {
522            domain: "example.com".to_string(),
523            available: false,
524            confidence: "high".to_string(),
525            method: "rdap".to_string(),
526            details: Some("Domain is registered".to_string()),
527        };
528        let json = serde_json::to_string(&result).unwrap();
529        assert!(json.contains("\"available\":false"));
530        assert!(json.contains("\"confidence\":\"high\""));
531    }
532
533    // ------------------------------------------------------------------
534    // M11: Decision matrix coverage for `check()`.
535    //
536    // Tests the pure decision helpers — `decide_from_rdap` and
537    // `decide_fallback` — that were extracted from `check()` for
538    // hermetic testing. Each case asserts (available, confidence, method)
539    // against a realistic input shape.
540    // ------------------------------------------------------------------
541
542    /// Small helper to build an empty WhoisResponse with the given fields
543    /// populated; used to keep the test table concise.
544    fn whois_with(raw: &str, registrar: Option<&str>) -> WhoisResponse {
545        WhoisResponse {
546            domain: "example.test".to_string(),
547            registrar: registrar.map(str::to_string),
548            registrant: None,
549            organization: None,
550            registrant_email: None,
551            registrant_phone: None,
552            registrant_address: None,
553            registrant_country: None,
554            admin_name: None,
555            admin_organization: None,
556            admin_email: None,
557            admin_phone: None,
558            tech_name: None,
559            tech_organization: None,
560            tech_email: None,
561            tech_phone: None,
562            creation_date: None,
563            expiration_date: None,
564            updated_date: None,
565            nameservers: vec![],
566            status: vec![],
567            dnssec: None,
568            whois_server: "whois.test".to_string(),
569            raw_response: raw.to_string(),
570        }
571    }
572
573    fn rdap_with(statuses: &[&str]) -> RdapResponse {
574        RdapResponse {
575            status: statuses.iter().map(|s| s.to_string()).collect(),
576            ldh_name: Some("example.test".to_string()),
577            ..Default::default()
578        }
579    }
580
581    // --- RDAP success branches ---------------------------------------
582
583    #[test]
584    fn rdap_success_registered_marks_taken_high_confidence() {
585        let rdap = rdap_with(&["active"]);
586        let r = decide_from_rdap("example.test", rdap);
587        assert!(!r.available, "registered domain must be marked taken");
588        assert_eq!(r.confidence, "high");
589        assert_eq!(r.method, "rdap");
590        assert!(
591            r.details.as_deref().unwrap().contains("active"),
592            "details should include status list"
593        );
594    }
595
596    #[test]
597    fn rdap_success_empty_status_marks_taken_high_confidence() {
598        // Some RDAP servers return 200 with no status array populated; the
599        // existence of the object still means the domain is registered.
600        let rdap = rdap_with(&[]);
601        let r = decide_from_rdap("example.test", rdap);
602        assert!(!r.available);
603        assert_eq!(r.confidence, "high");
604        assert_eq!(r.method, "rdap");
605    }
606
607    #[test]
608    fn rdap_success_redemption_period_marks_taken_medium_confidence() {
609        let rdap = rdap_with(&["redemption period"]);
610        let r = decide_from_rdap("example.test", rdap);
611        assert!(!r.available, "redemption period still means taken");
612        assert_eq!(r.confidence, "medium", "redemption drops confidence");
613        assert_eq!(r.method, "rdap");
614        assert!(r.details.as_deref().unwrap().contains("redemption"));
615    }
616
617    #[test]
618    fn rdap_success_pending_delete_marks_taken_medium_confidence() {
619        let rdap = rdap_with(&["pending delete"]);
620        let r = decide_from_rdap("example.test", rdap);
621        assert!(!r.available);
622        assert_eq!(r.confidence, "medium");
623        assert!(r.details.as_deref().unwrap().contains("redemption"));
624    }
625
626    #[test]
627    fn rdap_status_substring_redemption_not_misclassified() {
628        // A non-standard verbose status that merely CONTAINS the word
629        // "redemption" must not be misread as the redemption-period state
630        // (which would wrongly drop confidence to medium).
631        let rdap = rdap_with(&["clientHold (no redemption requested)"]);
632        let r = decide_from_rdap("example.test", rdap);
633        assert!(!r.available, "still registered");
634        assert_eq!(
635            r.confidence, "high",
636            "verbose status must not be downgraded to redemption/medium"
637        );
638    }
639
640    #[test]
641    fn rdap_status_redemption_detected_case_insensitively() {
642        // A capitalized standard token must still be detected (the old
643        // case-sensitive `contains` missed "Redemption Period").
644        let rdap = rdap_with(&["Redemption Period"]);
645        let r = decide_from_rdap("example.test", rdap);
646        assert_eq!(
647            r.confidence, "medium",
648            "standard token detected regardless of case"
649        );
650    }
651
652    // --- WHOIS fallback branches -------------------------------------
653
654    #[test]
655    fn rdap_fail_whois_says_available_high_confidence() {
656        // is_available() reads raw_response and looks for the patterns
657        // that every TLD uses to signal unregistered.
658        let whois = whois_with("No match for \"example.test\".\n", None);
659        let rdap_err = SeerError::RdapError("404 not found".to_string());
660        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
661        assert!(r.available, "WHOIS 'no match' must mark available");
662        assert_eq!(r.confidence, "high");
663        assert_eq!(r.method, "whois");
664    }
665
666    #[test]
667    fn rdap_fail_whois_says_registered_high_confidence() {
668        let whois = whois_with("Domain Name: example.test\n", Some("Test Registrar"));
669        let rdap_err = SeerError::RdapError("404 not found".to_string());
670        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
671        assert!(!r.available);
672        assert_eq!(r.confidence, "high");
673        assert_eq!(r.method, "whois");
674        assert!(r.details.as_deref().unwrap().contains("Test Registrar"));
675    }
676
677    #[test]
678    fn rdap_fail_whois_registered_without_registrar_no_detail() {
679        // Corner case: has_core_data is false but not-available, so the
680        // details string is None (registrar field is None).
681        let whois = whois_with("Domain Name: example.test\n", None);
682        let rdap_err = SeerError::RdapError("404".to_string());
683        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
684        assert!(!r.available);
685        assert_eq!(r.confidence, "high");
686        assert!(
687            r.details.is_none(),
688            "no registrar means no details string, got: {:?}",
689            r.details
690        );
691    }
692
693    // --- Both-fail branches ------------------------------------------
694
695    #[test]
696    fn rdap_fail_whois_error_contains_no_match_marks_available_medium() {
697        let rdap_err = SeerError::RdapError("500".to_string());
698        let whois_err =
699            SeerError::WhoisError("whois server returned 'No match for this domain'".to_string());
700        let r = decide_fallback(
701            "example.test",
702            &rdap_err,
703            Err(whois_err),
704            DnsPresence::Unknown,
705        );
706        assert!(
707            r.available,
708            "whois error containing 'no match' is available"
709        );
710        assert_eq!(r.confidence, "medium");
711        assert_eq!(r.method, "whois_error");
712    }
713
714    #[test]
715    fn rdap_fail_whois_error_not_found_marks_available_medium() {
716        let rdap_err = SeerError::RdapError("500".to_string());
717        let whois_err = SeerError::WhoisError("Domain not found".to_string());
718        let r = decide_fallback(
719            "example.test",
720            &rdap_err,
721            Err(whois_err),
722            DnsPresence::Unknown,
723        );
724        assert!(r.available);
725        assert_eq!(r.confidence, "medium");
726        assert_eq!(r.method, "whois_error");
727    }
728
729    #[test]
730    fn rdap_fail_whois_error_no_data_found_marks_available_medium() {
731        let rdap_err = SeerError::RdapError("no".to_string());
732        let whois_err = SeerError::WhoisError("No Data Found for query".to_string());
733        let r = decide_fallback(
734            "example.test",
735            &rdap_err,
736            Err(whois_err),
737            DnsPresence::Unknown,
738        );
739        assert!(r.available);
740        assert_eq!(r.confidence, "medium");
741    }
742
743    #[test]
744    fn rdap_fail_whois_error_no_entries_marks_available_medium() {
745        let rdap_err = SeerError::RdapError("no".to_string());
746        let whois_err =
747            SeerError::WhoisError("No entries found for the selected source".to_string());
748        let r = decide_fallback(
749            "example.test",
750            &rdap_err,
751            Err(whois_err),
752            DnsPresence::Unknown,
753        );
754        assert!(r.available);
755        assert_eq!(r.confidence, "medium");
756    }
757
758    #[test]
759    fn rdap_fail_whois_timeout_marks_inconclusive_none_confidence() {
760        let rdap_err = SeerError::Timeout("rdap timed out".to_string());
761        let whois_err = SeerError::Timeout("whois timed out".to_string());
762        let r = decide_fallback(
763            "example.test",
764            &rdap_err,
765            Err(whois_err),
766            DnsPresence::Unknown,
767        );
768        assert!(
769            !r.available,
770            "inconclusive means NOT available (fail-safe default)"
771        );
772        assert_eq!(r.confidence, "none");
773        assert_eq!(r.method, "inconclusive");
774        assert!(r.details.as_deref().unwrap().contains("RDAP:"));
775        assert!(r.details.as_deref().unwrap().contains("WHOIS:"));
776    }
777
778    #[test]
779    fn rdap_fail_whois_transport_error_with_phrase_not_available() {
780        // A transport-level WHOIS failure (here a timeout) whose message
781        // merely contains "no match" must NOT be read as available — only a
782        // WHOIS-*protocol* error (WhoisError) can carry a registry no-match
783        // signal. Otherwise an error string that incidentally quotes the
784        // phrase flips a possibly-registered domain to "available".
785        let rdap_err = SeerError::RdapError("503 service unavailable".to_string());
786        let whois_err =
787            SeerError::Timeout("no match within deadline querying whois.nic.test".to_string());
788        let r = decide_fallback(
789            "example.test",
790            &rdap_err,
791            Err(whois_err),
792            DnsPresence::Present,
793        );
794        assert!(
795            !r.available,
796            "transport error text must not infer availability"
797        );
798    }
799
800    #[test]
801    fn rdap_fail_whois_connection_error_marks_inconclusive_none_confidence() {
802        let rdap_err = SeerError::RdapError("connection refused".to_string());
803        let whois_err = SeerError::WhoisError(
804            "failed to connect to whois.example: connection refused".to_string(),
805        );
806        let r = decide_fallback(
807            "example.test",
808            &rdap_err,
809            Err(whois_err),
810            DnsPresence::Unknown,
811        );
812        assert!(!r.available);
813        assert_eq!(r.confidence, "none");
814        assert_eq!(r.method, "inconclusive");
815    }
816
817    #[test]
818    fn rdap_fail_whois_error_case_insensitive_not_found() {
819        // The real code lowercases before matching; verify the Uppercase
820        // form still classifies correctly.
821        let rdap_err = SeerError::RdapError("500".to_string());
822        let whois_err = SeerError::WhoisError("NOT FOUND in registry".to_string());
823        let r = decide_fallback(
824            "example.test",
825            &rdap_err,
826            Err(whois_err),
827            DnsPresence::Unknown,
828        );
829        assert!(r.available, "'NOT FOUND' should classify as available");
830        assert_eq!(r.confidence, "medium");
831    }
832
833    // --- RDAP-404-is-authoritative branches (Fix #4) -----------------
834
835    #[test]
836    fn rdap_404_with_blocked_whois_marks_available() {
837        // SWITCH (.ch) blocks port-43 WHOIS with a refusal carrying no
838        // registration data and no availability phrase. The registry's own
839        // RDAP authoritatively 404s for an unregistered domain — that 404 is
840        // the signal and must win over the unhelpful WHOIS body.
841        let whois = whois_with("Requests of this client are not permitted.\n", None);
842        let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
843        let r = decide_fallback("example.ch", &rdap_err, Ok(whois), DnsPresence::Unknown);
844        assert!(
845            r.available,
846            "RDAP 404 must mark available even with blocked WHOIS"
847        );
848        assert_eq!(r.confidence, "high");
849        assert_eq!(r.method, "rdap");
850    }
851
852    #[test]
853    fn rdap_404_with_whois_error_marks_available() {
854        // RDAP 404 is authoritative even when WHOIS itself errored out.
855        let rdap_err = SeerError::RdapError("query failed with status 404".to_string());
856        let whois_err = SeerError::WhoisError("connection refused".to_string());
857        let r = decide_fallback(
858            "example.test",
859            &rdap_err,
860            Err(whois_err),
861            DnsPresence::Unknown,
862        );
863        assert!(r.available);
864        assert_eq!(r.confidence, "high");
865        assert_eq!(r.method, "rdap");
866    }
867
868    #[test]
869    fn rdap_404_but_whois_has_full_registration_marks_registered() {
870        // Conflict case: RDAP 404 but WHOIS returns real registration data
871        // (registrar + dates + nameservers). Prefer the concrete registration
872        // so we never tell the user a registered domain is free.
873        let mut whois = whois_with("Domain Name: example.test\n", Some("Real Registrar"));
874        whois.creation_date = Some(chrono::Utc::now());
875        whois.nameservers = vec!["ns1.example.net".to_string()];
876        let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
877        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
878        assert!(
879            !r.available,
880            "concrete WHOIS registration must win over RDAP 404"
881        );
882        assert_eq!(r.confidence, "high");
883        assert_eq!(r.method, "whois");
884    }
885
886    // --- DNS-NXDOMAIN safety net (Fix #2) ----------------------------
887
888    #[test]
889    fn thin_whois_non404_dns_absent_marks_likely_available() {
890        // Red.es (.es) returns a port-43 "Conditions of use" banner that
891        // parses to nothing, and .es has no RDAP server (a non-404 failure).
892        // The apex is NXDOMAIN, so the domain is likely available.
893        let whois = whois_with(
894            "Conditions of use for the whois service via port 43\n",
895            None,
896        );
897        let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.es".to_string());
898        let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Absent);
899        assert!(r.available);
900        assert_eq!(r.confidence, "medium");
901        assert_eq!(r.method, "dns_nxdomain");
902    }
903
904    #[test]
905    fn thin_whois_non404_dns_present_stays_unavailable() {
906        // Same thin WHOIS + non-404 RDAP failure, but the apex resolves — we
907        // must not claim availability.
908        let whois = whois_with(
909            "Conditions of use for the whois service via port 43\n",
910            None,
911        );
912        let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.es".to_string());
913        let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Present);
914        assert!(!r.available);
915        assert_ne!(r.method, "dns_nxdomain");
916    }
917
918    #[test]
919    fn thin_whois_non404_dns_unknown_stays_unavailable_failsafe() {
920        // Thin WHOIS, non-404 RDAP failure, DNS itself failed → genuinely
921        // unknown; fail safe to not-available so we never call a taken domain
922        // free on a transient DNS blip.
923        let whois = whois_with(
924            "Conditions of use for the whois service via port 43\n",
925            None,
926        );
927        let rdap_err = SeerError::RdapBootstrapError("no RDAP server".to_string());
928        let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Unknown);
929        assert!(!r.available);
930    }
931
932    #[test]
933    fn both_legs_failed_dns_absent_marks_likely_available() {
934        // RDAP errored (non-404), WHOIS errored (not a "not found" message),
935        // but the apex is NXDOMAIN.
936        let rdap_err = SeerError::Timeout("rdap timed out".to_string());
937        let whois_err = SeerError::WhoisError("connection refused".to_string());
938        let r = decide_fallback(
939            "example.test",
940            &rdap_err,
941            Err(whois_err),
942            DnsPresence::Absent,
943        );
944        assert!(r.available);
945        assert_eq!(r.confidence, "medium");
946        assert_eq!(r.method, "dns_nxdomain");
947    }
948
949    #[test]
950    fn both_legs_failed_dns_present_marks_likely_registered() {
951        // The .ru-behind-a-firewall case: the TLD has no RDAP server at all
952        // (bootstrap miss) and WHOIS is unreachable (transport timeout), but
953        // the apex IS delegated in DNS. Delegation in the TLD zone is strong
954        // evidence of registration — report likely_registered rather than a
955        // blank "unknown".
956        let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.ru".to_string());
957        let whois_err = SeerError::Timeout(
958            "Operation failed after 3 attempts: Operation timed out".to_string(),
959        );
960        let r = decide_fallback(
961            "example.ru",
962            &rdap_err,
963            Err(whois_err),
964            DnsPresence::Present,
965        );
966        assert!(
967            !r.available,
968            "a delegated apex must never read as available"
969        );
970        assert_eq!(r.confidence, "medium");
971        assert_eq!(r.method, "dns_present");
972        assert_eq!(r.verdict(), "likely_registered");
973        assert!(
974            r.details.as_deref().unwrap().contains("delegated"),
975            "details should explain the DNS-delegation evidence"
976        );
977    }
978
979    // --- #45: refusal/throttle bodies route to inconclusive --------------
980
981    #[test]
982    fn rdap_fail_thin_whois_refusal_marks_inconclusive() {
983        // A throttled thin WHOIS body (no registration data) with a non-404
984        // RDAP failure must route to an inconclusive verdict — even with DNS
985        // absent, which would otherwise read as likely-available — instead of
986        // inverting a rate-limit banner ("no data found") into "available".
987        let whois = whois_with(
988            "Access rate limited; no data found for unauthenticated clients\n",
989            None,
990        );
991        let rdap_err = SeerError::RdapError("503 service unavailable".to_string());
992        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Absent);
993        assert!(
994            !r.available,
995            "refusal/throttle must not be called available"
996        );
997        assert_eq!(r.confidence, "none");
998        assert_eq!(r.method, "inconclusive");
999    }
1000
1001    #[test]
1002    fn rdap_fail_thin_whois_not_available_for_registration_marks_inconclusive() {
1003        // "not available for registration" (reserved/premium) previously
1004        // inverted to available; with RDAP also failing non-404 it must be
1005        // inconclusive, not a DNS-presence guess.
1006        let whois = whois_with(
1007            "This domain name is not available for registration.\n",
1008            None,
1009        );
1010        let rdap_err = SeerError::RdapBootstrapError("no RDAP server".to_string());
1011        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Absent);
1012        assert!(!r.available);
1013        assert_eq!(r.confidence, "none");
1014        assert_eq!(r.method, "inconclusive");
1015    }
1016
1017    #[test]
1018    fn rdap_404_with_refusal_whois_still_marks_available() {
1019        // The refusal routing must NOT override an authoritative RDAP 404: a
1020        // refused/throttled port-43 body with an RDAP 404 is still available.
1021        let whois = whois_with("Access rate limited; please try again later.\n", None);
1022        let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
1023        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
1024        assert!(r.available, "RDAP 404 is authoritative over a refusal body");
1025        assert_eq!(r.confidence, "high");
1026        assert_eq!(r.method, "rdap");
1027    }
1028
1029    // --- check_with_prior: reuse without re-querying ------------------
1030
1031    #[tokio::test]
1032    async fn check_with_prior_reuses_rdap_response_without_network() {
1033        // A prior successful RDAP response short-circuits to `decide_from_rdap`
1034        // with no WHOIS or DNS I/O — proving the in-hand outcome is reused
1035        // rather than re-queried, and yielding the same verdict `check` would
1036        // for that response.
1037        let checker = AvailabilityChecker::new();
1038        let rdap = rdap_with(&["active"]);
1039        let r = checker
1040            .check_with_prior(
1041                "example.test",
1042                PriorRdap::Response(Box::new(rdap)),
1043                PriorWhois::Missing,
1044            )
1045            .await
1046            .expect("prior-response path must not error");
1047        assert!(!r.available, "an existing RDAP object means registered");
1048        assert_eq!(r.confidence, "high");
1049        assert_eq!(r.method, "rdap");
1050    }
1051
1052    #[tokio::test]
1053    async fn check_with_prior_reuses_redemption_status_verdict() {
1054        // The reused response flows through the identical redemption decision as
1055        // `decide_from_rdap`, so a redemption status still drops to medium.
1056        let checker = AvailabilityChecker::new();
1057        let rdap = rdap_with(&["redemption period"]);
1058        let r = checker
1059            .check_with_prior(
1060                "example.test",
1061                PriorRdap::Response(Box::new(rdap)),
1062                PriorWhois::Missing,
1063            )
1064            .await
1065            .expect("prior-response path must not error");
1066        assert!(!r.available);
1067        assert_eq!(r.confidence, "medium");
1068        assert_eq!(r.method, "rdap");
1069    }
1070
1071    // --- nameservers count as registration data (DENIC, .de) ----------
1072
1073    /// A DENIC-shaped parsed body: nameservers + status + changed date, but
1074    /// DENIC never publishes a registrar or creation/expiry dates.
1075    fn denic_whois() -> WhoisResponse {
1076        let mut w = whois_with(
1077            "Domain: example.de\nNserver: ns1.example.net\nStatus: connect\n",
1078            None,
1079        );
1080        w.nameservers = vec!["ns1.example.net".to_string()];
1081        w.status = vec!["active".to_string()];
1082        w
1083    }
1084
1085    #[test]
1086    fn whois_with_nameservers_is_not_thin() {
1087        assert!(whois_is_thin(&whois_with("", None)));
1088        assert!(!whois_is_thin(&denic_whois()));
1089    }
1090
1091    #[test]
1092    fn denic_style_whois_without_registrar_or_dates_is_registered() {
1093        // .de has no RDAP (a non-404 bootstrap miss). Before nameservers
1094        // counted, this body was "thin" and an NXDOMAIN apex flipped a
1095        // registered .de domain to likely-available.
1096        let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.de".to_string());
1097        let r = decide_fallback(
1098            "example.de",
1099            &rdap_err,
1100            Ok(denic_whois()),
1101            DnsPresence::Absent,
1102        );
1103        assert!(!r.available, "a delegated DENIC record is registered");
1104        assert_eq!(r.confidence, "high");
1105        assert_eq!(r.method, "whois");
1106    }
1107
1108    // --- subdomain guard (names below the registrable domain) ---------
1109
1110    fn avail(domain: &str, available: bool, confidence: &str) -> AvailabilityResult {
1111        AvailabilityResult {
1112            domain: domain.to_string(),
1113            available,
1114            confidence: confidence.to_string(),
1115            method: "rdap".to_string(),
1116            details: None,
1117        }
1118    }
1119
1120    #[test]
1121    fn subdomain_under_registered_parent_is_registered_not_available() {
1122        let r = subdomain_verdict("mail.google.com", &avail("google.com", false, "high"));
1123        assert!(!r.available);
1124        assert_eq!(r.domain, "mail.google.com");
1125        assert_eq!(r.verdict(), "registered");
1126        assert_eq!(r.method, "registrable_parent");
1127        let details = r.details.unwrap();
1128        assert!(details.contains("google.com is registered"), "{details}");
1129    }
1130
1131    #[test]
1132    fn subdomain_under_available_parent_is_never_claimed_available() {
1133        let r = subdomain_verdict("mail.freebrand.com", &avail("freebrand.com", true, "high"));
1134        assert!(!r.available, "a subdomain itself is never registrable");
1135        assert_eq!(r.verdict(), "unknown");
1136        assert!(r.details.unwrap().contains("register freebrand.com"));
1137
1138        let r = subdomain_verdict("a.b.co.uk", &avail("b.co.uk", false, "none"));
1139        assert!(!r.available);
1140        assert_eq!(r.verdict(), "unknown");
1141        assert!(r.details.unwrap().contains("could not be determined"));
1142    }
1143
1144    #[tokio::test]
1145    async fn subdomain_guard_leaves_registrable_names_and_negative_results_alone() {
1146        // Both cases return before any network: a registrable name has no
1147        // parent to consult, and a not-available verdict is never rewritten.
1148        let checker = AvailabilityChecker::new();
1149        let r = checker
1150            .guard_subdomain_claim(avail("example.co.uk", true, "high"))
1151            .await;
1152        assert!(r.available);
1153        assert_eq!(r.method, "rdap");
1154
1155        let r = checker
1156            .guard_subdomain_claim(avail("mail.example.com", false, "high"))
1157            .await;
1158        assert!(!r.available);
1159        assert_eq!(r.method, "rdap");
1160    }
1161}