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