use serde_json::Value;
use crate::detect::{patterns, AvailabilityRule, Confidence, Evidence, Judgement};
use crate::domain::Availability;
use crate::error::Refusal;
const MIN_REGISTRATION_FIELDS: usize = 2;
const RECORDLESS_MAX_LEN: usize = 400;
#[derive(Debug, Default, Clone, Copy)]
pub struct WrongServerRule;
impl AvailabilityRule for WrongServerRule {
fn name(&self) -> &'static str {
"wrong-server"
}
fn evaluate(&self, evidence: &Evidence<'_>) -> Judgement {
if let Some(matched) = patterns::wrong_server().first_match(evidence.lowercase()) {
return Judgement::WrongServer {
because: format!("response matched {matched:?}"),
};
}
let banner = evidence.head(patterns::BANNER_LINES);
match patterns::rir_banner().first_match(&banner) {
Some(matched) => Judgement::WrongServer {
because: format!("the server identified itself with {matched:?}"),
},
None => Judgement::Abstain,
}
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct RefusalRule;
impl AvailabilityRule for RefusalRule {
fn name(&self) -> &'static str {
"refusal"
}
fn evaluate(&self, evidence: &Evidence<'_>) -> Judgement {
let text = evidence.lowercase();
type Match = fn(&str) -> Option<&'static str>;
const CHECKS: [(Match, Refusal); 5] = [
(
|text| patterns::port_retired().first_match(text),
Refusal::PortRetired,
),
(
|text| patterns::rate_limited().first_match(text),
Refusal::RateLimited,
),
(
|text| patterns::blocked().first_match(text),
Refusal::Blocked,
),
(
|text| patterns::access_restricted().first_match(text),
Refusal::AccessRestricted,
),
(
|text| patterns::unavailable().first_match(text),
Refusal::Unavailable,
),
];
for (check, reason) in CHECKS {
let Some(matched) = check(text) else { continue };
if looks_like_a_record(evidence) {
continue;
}
return Judgement::Refused {
reason,
because: format!("response matched {matched:?}"),
};
}
Judgement::Abstain
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct RdapRule;
impl AvailabilityRule for RdapRule {
fn name(&self) -> &'static str {
"rdap"
}
fn evaluate(&self, evidence: &Evidence<'_>) -> Judgement {
if !evidence.is_rdap() {
return Judgement::Abstain;
}
let Ok(json) = serde_json::from_str::<Value>(evidence.text()) else {
return Judgement::Abstain;
};
if let Some(code) = json.get("errorCode").and_then(Value::as_u64) {
return match code {
404 => Judgement::decided(
Availability::Available,
Confidence::Definitive,
"RDAP errorCode 404: the domain object does not exist",
),
429 => Judgement::Refused {
reason: Refusal::RateLimited,
because: "RDAP errorCode 429".to_string(),
},
401 | 403 => Judgement::Refused {
reason: Refusal::AccessRestricted,
because: format!("RDAP errorCode {code}"),
},
_ => Judgement::Abstain,
};
}
let is_domain_object = json
.get("objectClassName")
.and_then(Value::as_str)
.is_some_and(|class| class.eq_ignore_ascii_case("domain"));
let has_name = json.get("ldhName").and_then(Value::as_str).is_some();
if is_domain_object || has_name {
let statuses = rdap_statuses(&json);
if statuses
.iter()
.any(|status| status.contains("reserved") || status.contains("blocked"))
{
return Judgement::decided(
Availability::Reserved,
Confidence::Definitive,
format!("RDAP domain object with status {statuses:?}"),
);
}
return Judgement::decided(
Availability::Registered,
Confidence::Definitive,
"RDAP returned a domain object",
);
}
Judgement::Abstain
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct RegistryMarkerRule;
impl AvailabilityRule for RegistryMarkerRule {
fn name(&self) -> &'static str {
"registry-marker"
}
fn evaluate(&self, evidence: &Evidence<'_>) -> Judgement {
let Some(registry) = evidence.registry() else {
return Judgement::Abstain;
};
for marker in registry.premium_markers() {
if !marker.is_empty() && evidence.contains(&marker.to_lowercase()) {
return Judgement::decided(
Availability::Premium,
Confidence::High,
format!("registry premium marker {marker:?}"),
);
}
}
let significant = evidence.significant_text();
for marker in registry.available_markers() {
if marker.is_empty() {
continue;
}
if significant.contains(&marker.to_lowercase()) {
return Judgement::decided(
Availability::Available,
Confidence::High,
format!("registry availability marker {marker:?}"),
);
}
}
Judgement::Abstain
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct WithheldRule;
impl AvailabilityRule for WithheldRule {
fn name(&self) -> &'static str {
"withheld"
}
fn evaluate(&self, evidence: &Evidence<'_>) -> Judgement {
let text = evidence.lowercase();
if let Some(matched) = patterns::premium().first_match(text) {
return Judgement::decided(
Availability::Premium,
Confidence::Medium,
format!("response matched {matched:?}"),
);
}
if let Some(matched) = patterns::reserved().first_match(text) {
return Judgement::decided(
Availability::Reserved,
Confidence::Medium,
format!("response matched {matched:?}"),
);
}
Judgement::Abstain
}
}
#[derive(Debug, Clone, Copy)]
pub struct RegisteredRule {
min_fields: usize,
}
impl RegisteredRule {
pub fn new() -> Self {
RegisteredRule {
min_fields: MIN_REGISTRATION_FIELDS,
}
}
pub fn with_min_fields(min_fields: usize) -> Self {
RegisteredRule {
min_fields: min_fields.max(1),
}
}
}
impl Default for RegisteredRule {
fn default() -> Self {
RegisteredRule::new()
}
}
impl AvailabilityRule for RegisteredRule {
fn name(&self) -> &'static str {
"registered"
}
fn evaluate(&self, evidence: &Evidence<'_>) -> Judgement {
let significant = evidence.significant_text();
if let Some(matched) = patterns::registered_status().first_match(&significant) {
return Judgement::decided(
Availability::Registered,
Confidence::High,
format!("status field matched {matched:?}"),
);
}
if let Some(matched) = patterns::registered_phrase().first_match(&significant) {
return Judgement::decided(
Availability::Registered,
Confidence::High,
format!("response matched {matched:?}"),
);
}
let fields = patterns::registration_fields().match_count(evidence.lowercase());
if fields >= self.min_fields {
return Judgement::decided(
Availability::Registered,
Confidence::Medium,
format!("{fields} distinct registration fields present"),
);
}
Judgement::Abstain
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct NotFoundRule;
impl AvailabilityRule for NotFoundRule {
fn name(&self) -> &'static str {
"not-found"
}
fn evaluate(&self, evidence: &Evidence<'_>) -> Judgement {
if let Some(matched) = patterns::not_found().first_match(&evidence.significant_text()) {
return Judgement::decided(
Availability::Available,
Confidence::Medium,
format!("response matched {matched:?}"),
);
}
for line in evidence.comment_lines() {
if let Some(matched) = patterns::comment_answer().first_match(line) {
return Judgement::decided(
Availability::Available,
Confidence::Medium,
format!("comment line {line:?} matched {matched:?}"),
);
}
}
Judgement::Abstain
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct TldPatternRule;
impl AvailabilityRule for TldPatternRule {
fn name(&self) -> &'static str {
"tld-pattern"
}
fn evaluate(&self, evidence: &Evidence<'_>) -> Judgement {
let tld = evidence.tld().ascii();
let significant = evidence.significant_text();
if let Some(table) = patterns::tld_registered(tld) {
if let Some(matched) = table.first_match(&significant) {
return Judgement::decided(
Availability::Registered,
Confidence::Medium,
format!(".{tld} registered pattern {matched:?}"),
);
}
}
if let Some(table) = patterns::tld_not_found(tld) {
if let Some(matched) = table.first_match(&significant) {
return Judgement::decided(
Availability::Available,
Confidence::Medium,
format!(".{tld} availability pattern {matched:?}"),
);
}
}
Judgement::Abstain
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct RecordlessRule;
impl AvailabilityRule for RecordlessRule {
fn name(&self) -> &'static str {
"recordless"
}
fn evaluate(&self, evidence: &Evidence<'_>) -> Judgement {
let opted_in = evidence
.registry()
.is_some_and(|registry| registry.available_when_empty());
if !opted_in {
return Judgement::Abstain;
}
if patterns::error_notice().matches(evidence.lowercase()) {
return Judgement::Abstain;
}
let fields = patterns::registration_fields().match_count(evidence.lowercase());
if fields > 0 {
return Judgement::Abstain;
}
if evidence.len() > RECORDLESS_MAX_LEN {
return Judgement::Abstain;
}
Judgement::decided(
Availability::Available,
Confidence::Low,
"no record, and this registry answers unregistered names with a banner only",
)
}
}
fn looks_like_a_record(evidence: &Evidence<'_>) -> bool {
patterns::registration_fields().match_count(evidence.lowercase()) >= MIN_REGISTRATION_FIELDS
}
fn rdap_statuses(json: &Value) -> Vec<String> {
json.get("status")
.and_then(Value::as_array)
.map(|values| {
values
.iter()
.filter_map(Value::as_str)
.map(str::to_lowercase)
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::Tld;
use crate::registry::Registry;
use crate::transport::ResponseKind;
fn whois<'a>(text: &'a str, tld: &'a Tld) -> Evidence<'a> {
Evidence::new(text, ResponseKind::WhoisText, tld, None)
}
fn rdap<'a>(json: &'a str, tld: &'a Tld) -> Evidence<'a> {
Evidence::new(json, ResponseKind::RdapJson, tld, None)
}
#[test]
fn a_rir_banner_is_not_an_answer() {
let tld = Tld::parse("example").unwrap();
let response = "% This is the RIPE Database query service.\n%ERROR:101: no entries found\n";
assert!(patterns::not_found().matches(response));
assert!(matches!(
WrongServerRule.evaluate(&whois(response, &tld)),
Judgement::WrongServer { .. }
));
}
#[test]
fn wrong_server_abstains_on_a_normal_record() {
let tld = Tld::parse("com").unwrap();
let evidence = whois("Domain Name: EXAMPLE.COM\nRegistrar: Example\n", &tld);
assert!(matches!(
WrongServerRule.evaluate(&evidence),
Judgement::Abstain
));
}
#[test]
fn refusals_are_classified_by_kind() {
let tld = Tld::parse("com").unwrap();
let cases = [
("%% queries limit exceeded", Refusal::RateLimited),
(
"Requests of this client are not permitted",
Refusal::Blocked,
),
("The WHOIS service has been retired", Refusal::PortRetired),
(
"Server is busy, please try again later",
Refusal::Unavailable,
),
];
for (response, expected) in cases {
match RefusalRule.evaluate(&whois(response, &tld)) {
Judgement::Refused { reason, .. } => {
assert_eq!(reason, expected, "for {response:?}")
}
other => panic!("expected a refusal for {response:?}, got {other:?}"),
}
}
}
#[test]
fn a_real_record_survives_an_unlucky_phrase() {
let tld = Tld::parse("com").unwrap();
let record = "Domain Name: EXAMPLE.COM\n\
Registrant Organization: Try Again Later Ltd\n\
Registrar: Example LLC\n";
assert!(
matches!(
RefusalRule.evaluate(&whois(record, &tld)),
Judgement::Abstain
),
"a record was discarded because of one phrase in a field value"
);
}
#[test]
fn rdap_404_is_definitive_availability() {
let tld = Tld::parse("com").unwrap();
let json = r#"{"errorCode":404,"title":"Not Found"}"#;
match RdapRule.evaluate(&rdap(json, &tld)) {
Judgement::Decided {
availability,
confidence,
..
} => {
assert_eq!(availability, Availability::Available);
assert_eq!(confidence, Confidence::Definitive);
}
other => panic!("got {other:?}"),
}
}
#[test]
fn an_rdap_domain_object_is_definitive_registration() {
let tld = Tld::parse("com").unwrap();
let json = r#"{"objectClassName":"domain","ldhName":"example.com","status":["active"]}"#;
match RdapRule.evaluate(&rdap(json, &tld)) {
Judgement::Decided { availability, .. } => {
assert_eq!(availability, Availability::Registered)
}
other => panic!("got {other:?}"),
}
}
#[test]
fn an_rdap_reserved_status_is_neither() {
let tld = Tld::parse("com").unwrap();
let json = r#"{"objectClassName":"domain","ldhName":"a.com","status":["reserved"]}"#;
match RdapRule.evaluate(&rdap(json, &tld)) {
Judgement::Decided { availability, .. } => {
assert_eq!(availability, Availability::Reserved)
}
other => panic!("got {other:?}"),
}
}
#[test]
fn rdap_rate_limiting_is_a_refusal_not_availability() {
let tld = Tld::parse("com").unwrap();
let json = r#"{"errorCode":429,"title":"Too Many Requests"}"#;
match RdapRule.evaluate(&rdap(json, &tld)) {
Judgement::Refused { reason, .. } => assert_eq!(reason, Refusal::RateLimited),
other => panic!("got {other:?}"),
}
}
#[test]
fn rdap_rule_ignores_whois_text_and_broken_json() {
let tld = Tld::parse("com").unwrap();
assert!(matches!(
RdapRule.evaluate(&whois(r#"{"errorCode":404}"#, &tld)),
Judgement::Abstain
));
assert!(matches!(
RdapRule.evaluate(&rdap("<html>error</html>", &tld)),
Judgement::Abstain
));
}
#[test]
fn a_curated_marker_decides_with_high_confidence() {
let tld = Tld::parse("com").unwrap();
let registry = Registry::builder([tld.clone()])
.available_marker("No match for")
.build();
let evidence = Evidence::new(
"No match for \"NOTHERE.COM\"",
ResponseKind::WhoisText,
&tld,
Some(®istry),
);
match RegistryMarkerRule.evaluate(&evidence) {
Judgement::Decided {
availability,
confidence,
..
} => {
assert_eq!(availability, Availability::Available);
assert_eq!(confidence, Confidence::High);
}
other => panic!("got {other:?}"),
}
}
#[test]
fn a_marker_in_a_banner_does_not_count() {
let tld = Tld::parse("example").unwrap();
let registry = Registry::builder([tld.clone()])
.available_marker("not found")
.build();
let response = "% If a domain is not found, this server says so.\n\
Domain Name: TAKEN.EXAMPLE\n\
Registrar: Someone\n";
let evidence = Evidence::new(response, ResponseKind::WhoisText, &tld, Some(®istry));
assert!(matches!(
RegistryMarkerRule.evaluate(&evidence),
Judgement::Abstain
));
}
#[test]
fn marker_rule_abstains_without_a_registry() {
let tld = Tld::parse("com").unwrap();
assert!(matches!(
RegistryMarkerRule.evaluate(&whois("No match for x", &tld)),
Judgement::Abstain
));
}
#[test]
fn restriction_notices_are_neither_registered_nor_available() {
let tld = Tld::parse("sx").unwrap();
let response = "Error code: 01044\nThis domain name has usage restrictions applied.\n";
match WithheldRule.evaluate(&whois(response, &tld)) {
Judgement::Decided { availability, .. } => {
assert_eq!(availability, Availability::Reserved)
}
other => panic!("got {other:?}"),
}
}
#[test]
fn an_epp_status_decides_registration() {
let tld = Tld::parse("com").unwrap();
let response = "Domain Status: clientTransferProhibited\n";
match RegisteredRule::new().evaluate(&whois(response, &tld)) {
Judgement::Decided {
availability,
confidence,
..
} => {
assert_eq!(availability, Availability::Registered);
assert_eq!(confidence, Confidence::High);
}
other => panic!("got {other:?}"),
}
}
#[test]
fn enough_fields_decide_registration() {
let tld = Tld::parse("com").unwrap();
let response =
"Domain Name: EXAMPLE.COM\nRegistrar: Example LLC\nName Server: ns1.example\n";
match RegisteredRule::new().evaluate(&whois(response, &tld)) {
Judgement::Decided { availability, .. } => {
assert_eq!(availability, Availability::Registered)
}
other => panic!("got {other:?}"),
}
}
#[test]
fn a_no_match_response_has_no_fields_to_count() {
let tld = Tld::parse("com").unwrap();
let response = "No match for \"NOTHERE.COM\".\n\
>>> Last update of whois database: 2026-01-01 <<<\n";
assert!(
matches!(
RegisteredRule::new().evaluate(&whois(response, &tld)),
Judgement::Abstain
),
"an availability response was read as a record"
);
}
#[test]
fn nominets_availability_banner_is_not_a_record() {
let tld = Tld::parse("co.uk").unwrap();
let response = "\
No match for \"NOTREGISTERED.CO.UK\".
This domain name has not been registered.
WHOIS lookup made at 10:00:00 01-Jan-2026
--
This WHOIS information is provided for free by Nominet UK. You may contact a
Registrar to register this domain name.
";
assert!(
matches!(
RegisteredRule::new().evaluate(&whois(response, &tld)),
Judgement::Abstain
),
"prose mentioning a registrar was counted as a registration field"
);
}
#[test]
fn status_not_available_is_registration_not_availability() {
let tld = Tld::parse("example").unwrap();
let response = "Status: not available\n";
match RegisteredRule::new().evaluate(&whois(response, &tld)) {
Judgement::Decided { availability, .. } => {
assert_eq!(availability, Availability::Registered)
}
other => panic!("got {other:?}"),
}
}
#[test]
fn generic_wordings_are_recognised() {
let tld = Tld::parse("com").unwrap();
for response in [
"Domain not found",
"NOT FOUND",
"No entries found",
"Status: AVAILABLE",
] {
match NotFoundRule.evaluate(&whois(response, &tld)) {
Judgement::Decided { availability, .. } => {
assert_eq!(availability, Availability::Available, "for {response:?}")
}
other => panic!("for {response:?}: got {other:?}"),
}
}
}
#[test]
fn a_disclaimer_mentioning_free_is_not_availability() {
let tld = Tld::parse("co.uk").unwrap();
let response = "\
Domain name:
example.co.uk
Registrar:
Example Ltd
--
This WHOIS information is provided for free by Nominet.
";
assert!(matches!(
NotFoundRule.evaluate(&whois(response, &tld)),
Judgement::Abstain
));
}
#[test]
fn per_suffix_wording_is_applied() {
let jp = Tld::parse("jp").unwrap();
match TldPatternRule.evaluate(&whois("No match!!", &jp)) {
Judgement::Decided { availability, .. } => {
assert_eq!(availability, Availability::Available)
}
other => panic!("got {other:?}"),
}
let de = Tld::parse("de").unwrap();
match TldPatternRule.evaluate(&whois("Status: connect", &de)) {
Judgement::Decided { availability, .. } => {
assert_eq!(availability, Availability::Registered)
}
other => panic!("got {other:?}"),
}
}
#[test]
fn tld_rule_abstains_for_suffixes_with_no_table() {
let tld = Tld::parse("com").unwrap();
assert!(matches!(
TldPatternRule.evaluate(&whois("something unusual", &tld)),
Judgement::Abstain
));
}
#[test]
fn recordless_needs_the_registry_to_opt_in() {
let tld = Tld::parse("mc").unwrap();
let banner = "% NIC Monaco whois server\n";
assert!(matches!(
RecordlessRule.evaluate(&whois(banner, &tld)),
Judgement::Abstain
));
let registry = Registry::builder([tld.clone()])
.available_when_empty(true)
.build();
let evidence = Evidence::new(banner, ResponseKind::WhoisText, &tld, Some(®istry));
match RecordlessRule.evaluate(&evidence) {
Judgement::Decided {
availability,
confidence,
..
} => {
assert_eq!(availability, Availability::Available);
assert_eq!(
confidence,
Confidence::Low,
"a guess must not claim confidence"
);
}
other => panic!("got {other:?}"),
}
}
#[test]
fn recordless_never_fires_through_an_error_notice() {
let tld = Tld::parse("mc").unwrap();
let registry = Registry::builder([tld.clone()])
.available_when_empty(true)
.build();
let notice = "Error code: 500\nAccess denied.\n";
let evidence = Evidence::new(notice, ResponseKind::WhoisText, &tld, Some(®istry));
assert!(matches!(
RecordlessRule.evaluate(&evidence),
Judgement::Abstain
));
}
#[test]
fn recordless_declines_a_long_response() {
let tld = Tld::parse("mc").unwrap();
let registry = Registry::builder([tld.clone()])
.available_when_empty(true)
.build();
let long = "% banner line\n".repeat(60);
let evidence = Evidence::new(&long, ResponseKind::WhoisText, &tld, Some(®istry));
assert!(matches!(
RecordlessRule.evaluate(&evidence),
Judgement::Abstain
));
}
}