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    /// 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(crate::error::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(crate::error::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::new().with_timeout(config.rdap_timeout()),
103            whois_client: WhoisClient::new().with_timeout(config.whois_timeout()),
104            dns_resolver: DnsResolver::new().with_timeout(config.dns_timeout()),
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    /// `decide_fallback`) guarantees an identical verdict to `check` for the
180    /// same protocol outcomes — this path only removes redundant network calls,
181    /// so the two availability ladders stay in lockstep.
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.
239fn 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/// Pure decision function: build an `AvailabilityResult` when RDAP failed
292/// and WHOIS (plus a DNS presence probe) is the fallback. Extracted from
293/// `check()` for table-testing. `dns_presence` is only consulted when the
294/// registry signals are inconclusive — a thin/blocked WHOIS body or a
295/// transport-failed WHOIS leg, with a non-404 RDAP failure. An apex with no
296/// DNS presence (NXDOMAIN) then reads as likely-available at medium
297/// confidence; a delegated apex behind two transport failures reads as
298/// likely-registered at medium confidence.
299fn decide_fallback(
300    domain: &str,
301    rdap_err: &crate::error::SeerError,
302    whois_result: Result<crate::whois::WhoisResponse>,
303    dns_presence: DnsPresence,
304) -> AvailabilityResult {
305    match whois_result {
306        Ok(whois_response) => {
307            // "Thin" = no positive registration signal at all (see
308            // `WhoisResponse::is_thin`).
309            let thin = whois_response.is_thin();
310
311            if whois_response.is_available() {
312                AvailabilityResult::new(domain, true, "high", "whois")
313                    .with_details("WHOIS indicates domain is not registered")
314            } else if !thin {
315                // A concrete registration signal (registrar / dates /
316                // nameservers) is present → the domain is registered.
317                AvailabilityResult {
318                    details: whois_response
319                        .registrar
320                        .map(|r| format!("Registered with {}", r)),
321                    ..AvailabilityResult::new(domain, false, "high", "whois")
322                }
323            } else if rdap_error_is_404(rdap_err) {
324                // Thin WHOIS — often an access-blocked refusal like SWITCH's
325                // ".ch" — but the registry's own RDAP authoritatively 404'd.
326                AvailabilityResult::new(domain, true, "high", "rdap")
327                    .with_details("Registry RDAP reports no such domain (HTTP 404)")
328            } else if whois_response.indicates_registry_refusal() {
329                // Thin WHOIS that explicitly refused / throttled / negated the
330                // query (rate limit, access denied, reserved, "not available
331                // for registration") while RDAP did not authoritatively 404.
332                // There is no usable registration signal — report inconclusive
333                // rather than inverting the refusal into "available" or guessing
334                // from DNS presence / fail-safing to "registered" (issue #45).
335                AvailabilityResult::new(domain, false, "none", "inconclusive")
336                    .with_details(REFUSED_DETAILS)
337            } else if dns_presence == DnsPresence::Absent {
338                // Thin WHOIS, RDAP did not 404, and the apex is NXDOMAIN —
339                // corroborating evidence the domain is unregistered.
340                AvailabilityResult::new(domain, true, "medium", "dns_nxdomain")
341                    .with_details(THIN_NXDOMAIN_DETAILS)
342            } else {
343                // Thin WHOIS we could not interpret and no corroborating
344                // NXDOMAIN — fail safe toward "registered".
345                AvailabilityResult::new(domain, false, "high", "whois")
346            }
347        }
348        Err(whois_err) => {
349            // RDAP 404 is authoritative even when the WHOIS leg errored: the
350            // registry's RDAP server reports no such object, so the domain is
351            // unregistered regardless of why WHOIS failed.
352            if rdap_error_is_404(rdap_err) {
353                return AvailabilityResult::new(domain, true, "high", "rdap")
354                    .with_details("Registry RDAP reports no such domain (HTTP 404)");
355            }
356            // Both registry legs failed. Only a WHOIS-*protocol* error can
357            // carry a registry "no match" signal; a transport failure
358            // (timeout, connection reset, DNS, SSRF refusal) tells us nothing
359            // about registration, so we must not infer availability from its
360            // text even if it incidentally contains a phrase like "no match".
361            let likely_available = matches!(whois_err, crate::error::SeerError::WhoisError(_)) && {
362                let whois_msg = whois_err.to_string().to_lowercase();
363                whois_msg.contains("no match")
364                    || whois_msg.contains("not found")
365                    || whois_msg.contains("no data found")
366                    || whois_msg.contains("no entries found")
367            };
368
369            if likely_available {
370                AvailabilityResult::new(domain, true, "medium", "whois_error")
371                    .with_details("WHOIS server indicates no matching records")
372            } else if dns_presence == DnsPresence::Absent {
373                // Both registry legs failed, but the apex is NXDOMAIN — the
374                // domain has no DNS presence, so it is likely unregistered.
375                AvailabilityResult::new(domain, true, "medium", "dns_nxdomain")
376                    .with_details("Registry lookups failed; domain has no DNS presence (NXDOMAIN)")
377            } else if dns_presence == DnsPresence::Present {
378                // Both registry legs failed with transport errors, but the
379                // apex IS delegated in DNS. Delegation in the TLD zone is
380                // strong evidence of registration (mirrors the
381                // `classify_thin_fallback` Present → Registered route), so
382                // report likely_registered instead of a blank "unknown".
383                // This is the safe direction: it can never call a taken
384                // domain free. Note this arm is a transport failure, not a
385                // registry refusal — refusals arrive as an Ok body and are
386                // kept inconclusive above (issue #45).
387                AvailabilityResult::new(domain, false, "medium", "dns_present").with_details(
388                    "Registry lookups failed, but the apex is delegated in DNS \
389                     (NS records present) — the domain is almost certainly registered",
390                )
391            } else {
392                // Both queries failed with non-"not found" errors and DNS was
393                // unknown. We genuinely don't know — could be registered,
394                // blocked, or servers down. Default to available=false so we
395                // never tell the user a taken domain is free. The details use
396                // the sanitized error projection so this string — which flows
397                // into JSON / CSV / MCP output paths — never carries raw ANSI
398                // escapes or internal IPs from a third-party WHOIS/RDAP
399                // server's error message.
400                AvailabilityResult::new(domain, false, "none", "inconclusive").with_details(
401                    format!(
402                        "Could not determine availability. RDAP: {}. WHOIS: {}",
403                        rdap_err.sanitized_message(),
404                        whois_err.sanitized_message()
405                    ),
406                )
407            }
408        }
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use crate::error::SeerError;
416    use crate::rdap::RdapResponse;
417    use crate::whois::WhoisResponse;
418
419    #[test]
420    fn verdict_matrix() {
421        let make = |available, confidence: &str| {
422            AvailabilityResult::new("example.test", available, confidence, "whois")
423        };
424        assert_eq!(make(true, "high").verdict(), "available");
425        assert_eq!(make(true, "medium").verdict(), "likely_available");
426        assert_eq!(make(false, "high").verdict(), "registered");
427        assert_eq!(make(false, "medium").verdict(), "likely_registered");
428        assert_eq!(make(false, "none").verdict(), "unknown");
429        assert_eq!(make(true, "low").verdict(), "unknown");
430    }
431
432    #[test]
433    fn test_availability_result_serialization() {
434        let result = AvailabilityResult::new("example.com", false, "high", "rdap")
435            .with_details("Domain is registered");
436        let json = serde_json::to_string(&result).unwrap();
437        assert!(json.contains("\"available\":false"));
438        assert!(json.contains("\"confidence\":\"high\""));
439    }
440
441    // ------------------------------------------------------------------
442    // M11: Decision matrix coverage for `check()`.
443    //
444    // Tests the pure decision helpers — `decide_from_rdap` and
445    // `decide_fallback` — that were extracted from `check()` for
446    // hermetic testing. Each case asserts (available, confidence, method)
447    // against a realistic input shape.
448    // ------------------------------------------------------------------
449
450    /// Small helper to build an empty WhoisResponse with the given fields
451    /// populated; used to keep the test table concise.
452    fn whois_with(raw: &str, registrar: Option<&str>) -> WhoisResponse {
453        WhoisResponse {
454            domain: "example.test".to_string(),
455            registrar: registrar.map(str::to_string),
456            whois_server: "whois.test".to_string(),
457            raw_response: raw.to_string(),
458            ..Default::default()
459        }
460    }
461
462    fn rdap_with(statuses: &[&str]) -> RdapResponse {
463        RdapResponse {
464            status: statuses.iter().map(|s| s.to_string()).collect(),
465            ldh_name: Some("example.test".to_string()),
466            ..Default::default()
467        }
468    }
469
470    // --- RDAP success branches ---------------------------------------
471
472    #[test]
473    fn rdap_success_registered_marks_taken_high_confidence() {
474        let rdap = rdap_with(&["active"]);
475        let r = decide_from_rdap("example.test", rdap);
476        assert!(!r.available, "registered domain must be marked taken");
477        assert_eq!(r.confidence, "high");
478        assert_eq!(r.method, "rdap");
479        assert!(
480            r.details.as_deref().unwrap().contains("active"),
481            "details should include status list"
482        );
483    }
484
485    #[test]
486    fn rdap_success_empty_status_marks_taken_high_confidence() {
487        // Some RDAP servers return 200 with no status array populated; the
488        // existence of the object still means the domain is registered.
489        let rdap = rdap_with(&[]);
490        let r = decide_from_rdap("example.test", rdap);
491        assert!(!r.available);
492        assert_eq!(r.confidence, "high");
493        assert_eq!(r.method, "rdap");
494    }
495
496    #[test]
497    fn rdap_success_redemption_period_marks_taken_medium_confidence() {
498        let rdap = rdap_with(&["redemption period"]);
499        let r = decide_from_rdap("example.test", rdap);
500        assert!(!r.available, "redemption period still means taken");
501        assert_eq!(r.confidence, "medium", "redemption drops confidence");
502        assert_eq!(r.method, "rdap");
503        assert!(r.details.as_deref().unwrap().contains("redemption"));
504    }
505
506    #[test]
507    fn rdap_success_pending_delete_marks_taken_medium_confidence() {
508        let rdap = rdap_with(&["pending delete"]);
509        let r = decide_from_rdap("example.test", rdap);
510        assert!(!r.available);
511        assert_eq!(r.confidence, "medium");
512        assert!(r.details.as_deref().unwrap().contains("redemption"));
513    }
514
515    #[test]
516    fn rdap_status_substring_redemption_not_misclassified() {
517        // A non-standard verbose status that merely CONTAINS the word
518        // "redemption" must not be misread as the redemption-period state
519        // (which would wrongly drop confidence to medium).
520        let rdap = rdap_with(&["clientHold (no redemption requested)"]);
521        let r = decide_from_rdap("example.test", rdap);
522        assert!(!r.available, "still registered");
523        assert_eq!(
524            r.confidence, "high",
525            "verbose status must not be downgraded to redemption/medium"
526        );
527    }
528
529    #[test]
530    fn rdap_status_redemption_detected_case_insensitively() {
531        // A capitalized standard token must still be detected (the old
532        // case-sensitive `contains` missed "Redemption Period").
533        let rdap = rdap_with(&["Redemption Period"]);
534        let r = decide_from_rdap("example.test", rdap);
535        assert_eq!(
536            r.confidence, "medium",
537            "standard token detected regardless of case"
538        );
539    }
540
541    // --- WHOIS fallback branches -------------------------------------
542
543    #[test]
544    fn rdap_fail_whois_says_available_high_confidence() {
545        // is_available() reads raw_response and looks for the patterns
546        // that every TLD uses to signal unregistered.
547        let whois = whois_with("No match for \"example.test\".\n", None);
548        let rdap_err = SeerError::RdapError("404 not found".to_string());
549        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
550        assert!(r.available, "WHOIS 'no match' must mark available");
551        assert_eq!(r.confidence, "high");
552        assert_eq!(r.method, "whois");
553    }
554
555    #[test]
556    fn rdap_fail_whois_says_registered_high_confidence() {
557        let whois = whois_with("Domain Name: example.test\n", Some("Test Registrar"));
558        let rdap_err = SeerError::RdapError("404 not found".to_string());
559        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
560        assert!(!r.available);
561        assert_eq!(r.confidence, "high");
562        assert_eq!(r.method, "whois");
563        assert!(r.details.as_deref().unwrap().contains("Test Registrar"));
564    }
565
566    #[test]
567    fn rdap_fail_whois_registered_without_registrar_no_detail() {
568        // Corner case: has_core_data is false but not-available, so the
569        // details string is None (registrar field is None).
570        let whois = whois_with("Domain Name: example.test\n", None);
571        let rdap_err = SeerError::RdapError("404".to_string());
572        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
573        assert!(!r.available);
574        assert_eq!(r.confidence, "high");
575        assert!(
576            r.details.is_none(),
577            "no registrar means no details string, got: {:?}",
578            r.details
579        );
580    }
581
582    // --- Both-fail branches ------------------------------------------
583
584    #[test]
585    fn rdap_fail_whois_error_contains_no_match_marks_available_medium() {
586        let rdap_err = SeerError::RdapError("500".to_string());
587        let whois_err =
588            SeerError::WhoisError("whois server returned 'No match for this domain'".to_string());
589        let r = decide_fallback(
590            "example.test",
591            &rdap_err,
592            Err(whois_err),
593            DnsPresence::Unknown,
594        );
595        assert!(
596            r.available,
597            "whois error containing 'no match' is available"
598        );
599        assert_eq!(r.confidence, "medium");
600        assert_eq!(r.method, "whois_error");
601    }
602
603    #[test]
604    fn rdap_fail_whois_error_not_found_marks_available_medium() {
605        let rdap_err = SeerError::RdapError("500".to_string());
606        let whois_err = SeerError::WhoisError("Domain not found".to_string());
607        let r = decide_fallback(
608            "example.test",
609            &rdap_err,
610            Err(whois_err),
611            DnsPresence::Unknown,
612        );
613        assert!(r.available);
614        assert_eq!(r.confidence, "medium");
615        assert_eq!(r.method, "whois_error");
616    }
617
618    #[test]
619    fn rdap_fail_whois_error_no_data_found_marks_available_medium() {
620        let rdap_err = SeerError::RdapError("no".to_string());
621        let whois_err = SeerError::WhoisError("No Data Found for query".to_string());
622        let r = decide_fallback(
623            "example.test",
624            &rdap_err,
625            Err(whois_err),
626            DnsPresence::Unknown,
627        );
628        assert!(r.available);
629        assert_eq!(r.confidence, "medium");
630    }
631
632    #[test]
633    fn rdap_fail_whois_error_no_entries_marks_available_medium() {
634        let rdap_err = SeerError::RdapError("no".to_string());
635        let whois_err =
636            SeerError::WhoisError("No entries found for the selected source".to_string());
637        let r = decide_fallback(
638            "example.test",
639            &rdap_err,
640            Err(whois_err),
641            DnsPresence::Unknown,
642        );
643        assert!(r.available);
644        assert_eq!(r.confidence, "medium");
645    }
646
647    #[test]
648    fn rdap_fail_whois_timeout_marks_inconclusive_none_confidence() {
649        let rdap_err = SeerError::Timeout("rdap timed out".to_string());
650        let whois_err = SeerError::Timeout("whois timed out".to_string());
651        let r = decide_fallback(
652            "example.test",
653            &rdap_err,
654            Err(whois_err),
655            DnsPresence::Unknown,
656        );
657        assert!(
658            !r.available,
659            "inconclusive means NOT available (fail-safe default)"
660        );
661        assert_eq!(r.confidence, "none");
662        assert_eq!(r.method, "inconclusive");
663        assert!(r.details.as_deref().unwrap().contains("RDAP:"));
664        assert!(r.details.as_deref().unwrap().contains("WHOIS:"));
665    }
666
667    #[test]
668    fn rdap_fail_whois_transport_error_with_phrase_not_available() {
669        // A transport-level WHOIS failure (here a timeout) whose message
670        // merely contains "no match" must NOT be read as available — only a
671        // WHOIS-*protocol* error (WhoisError) can carry a registry no-match
672        // signal. Otherwise an error string that incidentally quotes the
673        // phrase flips a possibly-registered domain to "available".
674        let rdap_err = SeerError::RdapError("503 service unavailable".to_string());
675        let whois_err =
676            SeerError::Timeout("no match within deadline querying whois.nic.test".to_string());
677        let r = decide_fallback(
678            "example.test",
679            &rdap_err,
680            Err(whois_err),
681            DnsPresence::Present,
682        );
683        assert!(
684            !r.available,
685            "transport error text must not infer availability"
686        );
687    }
688
689    #[test]
690    fn rdap_fail_whois_connection_error_marks_inconclusive_none_confidence() {
691        let rdap_err = SeerError::RdapError("connection refused".to_string());
692        let whois_err = SeerError::WhoisError(
693            "failed to connect to whois.example: connection refused".to_string(),
694        );
695        let r = decide_fallback(
696            "example.test",
697            &rdap_err,
698            Err(whois_err),
699            DnsPresence::Unknown,
700        );
701        assert!(!r.available);
702        assert_eq!(r.confidence, "none");
703        assert_eq!(r.method, "inconclusive");
704    }
705
706    #[test]
707    fn rdap_fail_whois_error_case_insensitive_not_found() {
708        // The real code lowercases before matching; verify the Uppercase
709        // form still classifies correctly.
710        let rdap_err = SeerError::RdapError("500".to_string());
711        let whois_err = SeerError::WhoisError("NOT FOUND in registry".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, "'NOT FOUND' should classify as available");
719        assert_eq!(r.confidence, "medium");
720    }
721
722    // --- RDAP-404-is-authoritative branches (Fix #4) -----------------
723
724    #[test]
725    fn rdap_404_with_blocked_whois_marks_available() {
726        // SWITCH (.ch) blocks port-43 WHOIS with a refusal carrying no
727        // registration data and no availability phrase. The registry's own
728        // RDAP authoritatively 404s for an unregistered domain — that 404 is
729        // the signal and must win over the unhelpful WHOIS body.
730        let whois = whois_with("Requests of this client are not permitted.\n", None);
731        let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
732        let r = decide_fallback("example.ch", &rdap_err, Ok(whois), DnsPresence::Unknown);
733        assert!(
734            r.available,
735            "RDAP 404 must mark available even with blocked WHOIS"
736        );
737        assert_eq!(r.confidence, "high");
738        assert_eq!(r.method, "rdap");
739    }
740
741    #[test]
742    fn rdap_404_with_whois_error_marks_available() {
743        // RDAP 404 is authoritative even when WHOIS itself errored out.
744        let rdap_err = SeerError::RdapError("query failed with status 404".to_string());
745        let whois_err = SeerError::WhoisError("connection refused".to_string());
746        let r = decide_fallback(
747            "example.test",
748            &rdap_err,
749            Err(whois_err),
750            DnsPresence::Unknown,
751        );
752        assert!(r.available);
753        assert_eq!(r.confidence, "high");
754        assert_eq!(r.method, "rdap");
755    }
756
757    #[test]
758    fn rdap_404_but_whois_has_full_registration_marks_registered() {
759        // Conflict case: RDAP 404 but WHOIS returns real registration data
760        // (registrar + dates + nameservers). Prefer the concrete registration
761        // so we never tell the user a registered domain is free.
762        let mut whois = whois_with("Domain Name: example.test\n", Some("Real Registrar"));
763        whois.creation_date = Some(chrono::Utc::now());
764        whois.nameservers = vec!["ns1.example.net".to_string()];
765        let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
766        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
767        assert!(
768            !r.available,
769            "concrete WHOIS registration must win over RDAP 404"
770        );
771        assert_eq!(r.confidence, "high");
772        assert_eq!(r.method, "whois");
773    }
774
775    // --- DNS-NXDOMAIN safety net (Fix #2) ----------------------------
776
777    #[test]
778    fn thin_whois_non404_dns_absent_marks_likely_available() {
779        // Red.es (.es) returns a port-43 "Conditions of use" banner that
780        // parses to nothing, and .es has no RDAP server (a non-404 failure).
781        // The apex is NXDOMAIN, so the domain is likely available.
782        let whois = whois_with(
783            "Conditions of use for the whois service via port 43\n",
784            None,
785        );
786        let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.es".to_string());
787        let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Absent);
788        assert!(r.available);
789        assert_eq!(r.confidence, "medium");
790        assert_eq!(r.method, "dns_nxdomain");
791    }
792
793    #[test]
794    fn thin_whois_non404_dns_present_stays_unavailable() {
795        // Same thin WHOIS + non-404 RDAP failure, but the apex resolves — we
796        // must not claim availability.
797        let whois = whois_with(
798            "Conditions of use for the whois service via port 43\n",
799            None,
800        );
801        let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.es".to_string());
802        let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Present);
803        assert!(!r.available);
804        assert_ne!(r.method, "dns_nxdomain");
805    }
806
807    #[test]
808    fn thin_whois_non404_dns_unknown_stays_unavailable_failsafe() {
809        // Thin WHOIS, non-404 RDAP failure, DNS itself failed → genuinely
810        // unknown; fail safe to not-available so we never call a taken domain
811        // free on a transient DNS blip.
812        let whois = whois_with(
813            "Conditions of use for the whois service via port 43\n",
814            None,
815        );
816        let rdap_err = SeerError::RdapBootstrapError("no RDAP server".to_string());
817        let r = decide_fallback("example.es", &rdap_err, Ok(whois), DnsPresence::Unknown);
818        assert!(!r.available);
819    }
820
821    #[test]
822    fn both_legs_failed_dns_absent_marks_likely_available() {
823        // RDAP errored (non-404), WHOIS errored (not a "not found" message),
824        // but the apex is NXDOMAIN.
825        let rdap_err = SeerError::Timeout("rdap timed out".to_string());
826        let whois_err = SeerError::WhoisError("connection refused".to_string());
827        let r = decide_fallback(
828            "example.test",
829            &rdap_err,
830            Err(whois_err),
831            DnsPresence::Absent,
832        );
833        assert!(r.available);
834        assert_eq!(r.confidence, "medium");
835        assert_eq!(r.method, "dns_nxdomain");
836    }
837
838    #[test]
839    fn both_legs_failed_dns_present_marks_likely_registered() {
840        // The .ru-behind-a-firewall case: the TLD has no RDAP server at all
841        // (bootstrap miss) and WHOIS is unreachable (transport timeout), but
842        // the apex IS delegated in DNS. Delegation in the TLD zone is strong
843        // evidence of registration — report likely_registered rather than a
844        // blank "unknown".
845        let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.ru".to_string());
846        let whois_err = SeerError::Timeout(
847            "Operation failed after 3 attempts: Operation timed out".to_string(),
848        );
849        let r = decide_fallback(
850            "example.ru",
851            &rdap_err,
852            Err(whois_err),
853            DnsPresence::Present,
854        );
855        assert!(
856            !r.available,
857            "a delegated apex must never read as available"
858        );
859        assert_eq!(r.confidence, "medium");
860        assert_eq!(r.method, "dns_present");
861        assert_eq!(r.verdict(), "likely_registered");
862        assert!(
863            r.details.as_deref().unwrap().contains("delegated"),
864            "details should explain the DNS-delegation evidence"
865        );
866    }
867
868    // --- #45: refusal/throttle bodies route to inconclusive --------------
869
870    #[test]
871    fn rdap_fail_thin_whois_refusal_marks_inconclusive() {
872        // A throttled thin WHOIS body (no registration data) with a non-404
873        // RDAP failure must route to an inconclusive verdict — even with DNS
874        // absent, which would otherwise read as likely-available — instead of
875        // inverting a rate-limit banner ("no data found") into "available".
876        let whois = whois_with(
877            "Access rate limited; no data found for unauthenticated clients\n",
878            None,
879        );
880        let rdap_err = SeerError::RdapError("503 service unavailable".to_string());
881        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Absent);
882        assert!(
883            !r.available,
884            "refusal/throttle must not be called available"
885        );
886        assert_eq!(r.confidence, "none");
887        assert_eq!(r.method, "inconclusive");
888    }
889
890    #[test]
891    fn rdap_fail_thin_whois_not_available_for_registration_marks_inconclusive() {
892        // "not available for registration" (reserved/premium) previously
893        // inverted to available; with RDAP also failing non-404 it must be
894        // inconclusive, not a DNS-presence guess.
895        let whois = whois_with(
896            "This domain name is not available for registration.\n",
897            None,
898        );
899        let rdap_err = SeerError::RdapBootstrapError("no RDAP server".to_string());
900        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Absent);
901        assert!(!r.available);
902        assert_eq!(r.confidence, "none");
903        assert_eq!(r.method, "inconclusive");
904    }
905
906    #[test]
907    fn rdap_404_with_refusal_whois_still_marks_available() {
908        // The refusal routing must NOT override an authoritative RDAP 404: a
909        // refused/throttled port-43 body with an RDAP 404 is still available.
910        let whois = whois_with("Access rate limited; please try again later.\n", None);
911        let rdap_err = SeerError::RdapError("query failed with status 404 Not Found".to_string());
912        let r = decide_fallback("example.test", &rdap_err, Ok(whois), DnsPresence::Unknown);
913        assert!(r.available, "RDAP 404 is authoritative over a refusal body");
914        assert_eq!(r.confidence, "high");
915        assert_eq!(r.method, "rdap");
916    }
917
918    // --- check_with_prior: reuse without re-querying ------------------
919
920    #[tokio::test]
921    async fn check_with_prior_reuses_rdap_response_without_network() {
922        // A prior successful RDAP response short-circuits to `decide_from_rdap`
923        // with no WHOIS or DNS I/O — proving the in-hand outcome is reused
924        // rather than re-queried, and yielding the same verdict `check` would
925        // for that response.
926        let checker = AvailabilityChecker::new();
927        let rdap = rdap_with(&["active"]);
928        let r = checker
929            .check_with_prior(
930                "example.test",
931                PriorRdap::Response(Box::new(rdap)),
932                PriorWhois::Missing,
933            )
934            .await
935            .expect("prior-response path must not error");
936        assert!(!r.available, "an existing RDAP object means registered");
937        assert_eq!(r.confidence, "high");
938        assert_eq!(r.method, "rdap");
939    }
940
941    #[tokio::test]
942    async fn check_with_prior_reuses_redemption_status_verdict() {
943        // The reused response flows through the identical redemption decision as
944        // `decide_from_rdap`, so a redemption status still drops to medium.
945        let checker = AvailabilityChecker::new();
946        let rdap = rdap_with(&["redemption period"]);
947        let r = checker
948            .check_with_prior(
949                "example.test",
950                PriorRdap::Response(Box::new(rdap)),
951                PriorWhois::Missing,
952            )
953            .await
954            .expect("prior-response path must not error");
955        assert!(!r.available);
956        assert_eq!(r.confidence, "medium");
957        assert_eq!(r.method, "rdap");
958    }
959
960    // --- nameservers count as registration data (DENIC, .de) ----------
961
962    /// A DENIC-shaped parsed body: nameservers + status + changed date, but
963    /// DENIC never publishes a registrar or creation/expiry dates.
964    fn denic_whois() -> WhoisResponse {
965        let mut w = whois_with(
966            "Domain: example.de\nNserver: ns1.example.net\nStatus: connect\n",
967            None,
968        );
969        w.nameservers = vec!["ns1.example.net".to_string()];
970        w.status = vec!["active".to_string()];
971        w
972    }
973
974    #[test]
975    fn whois_with_nameservers_is_not_thin() {
976        assert!(whois_with("", None).is_thin());
977        assert!(!denic_whois().is_thin());
978    }
979
980    #[test]
981    fn denic_style_whois_without_registrar_or_dates_is_registered() {
982        // .de has no RDAP (a non-404 bootstrap miss). Before nameservers
983        // counted, this body was "thin" and an NXDOMAIN apex flipped a
984        // registered .de domain to likely-available.
985        let rdap_err = SeerError::RdapBootstrapError("no RDAP server for example.de".to_string());
986        let r = decide_fallback(
987            "example.de",
988            &rdap_err,
989            Ok(denic_whois()),
990            DnsPresence::Absent,
991        );
992        assert!(!r.available, "a delegated DENIC record is registered");
993        assert_eq!(r.confidence, "high");
994        assert_eq!(r.method, "whois");
995    }
996
997    // --- subdomain guard (names below the registrable domain) ---------
998
999    fn avail(domain: &str, available: bool, confidence: &str) -> AvailabilityResult {
1000        AvailabilityResult::new(domain, available, confidence, "rdap")
1001    }
1002
1003    #[test]
1004    fn subdomain_under_registered_parent_is_registered_not_available() {
1005        let r = subdomain_verdict("mail.google.com", &avail("google.com", false, "high"));
1006        assert!(!r.available);
1007        assert_eq!(r.domain, "mail.google.com");
1008        assert_eq!(r.verdict(), "registered");
1009        assert_eq!(r.method, "registrable_parent");
1010        let details = r.details.unwrap();
1011        assert!(details.contains("google.com is registered"), "{details}");
1012    }
1013
1014    #[test]
1015    fn subdomain_under_available_parent_is_never_claimed_available() {
1016        let r = subdomain_verdict("mail.freebrand.com", &avail("freebrand.com", true, "high"));
1017        assert!(!r.available, "a subdomain itself is never registrable");
1018        assert_eq!(r.verdict(), "unknown");
1019        assert!(r.details.unwrap().contains("register freebrand.com"));
1020
1021        let r = subdomain_verdict("a.b.co.uk", &avail("b.co.uk", false, "none"));
1022        assert!(!r.available);
1023        assert_eq!(r.verdict(), "unknown");
1024        assert!(r.details.unwrap().contains("could not be determined"));
1025    }
1026
1027    #[tokio::test]
1028    async fn subdomain_guard_leaves_registrable_names_and_negative_results_alone() {
1029        // Both cases return before any network: a registrable name has no
1030        // parent to consult, and a not-available verdict is never rewritten.
1031        let checker = AvailabilityChecker::new();
1032        let r = checker
1033            .guard_subdomain_claim(avail("example.co.uk", true, "high"))
1034            .await;
1035        assert!(r.available);
1036        assert_eq!(r.method, "rdap");
1037
1038        let r = checker
1039            .guard_subdomain_claim(avail("mail.example.com", false, "high"))
1040            .await;
1041        assert!(!r.available);
1042        assert_eq!(r.method, "rdap");
1043    }
1044}