use std::fmt;
use crate::domain::Availability;
use crate::error::{Error, Refusal, Result};
mod evidence;
pub mod patterns;
mod rules;
pub use evidence::Evidence;
pub use rules::{
NotFoundRule, RdapRule, RecordlessRule, RefusalRule, RegisteredRule, RegistryMarkerRule,
TldPatternRule, WithheldRule, WrongServerRule,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Confidence {
Low,
Medium,
High,
Definitive,
}
impl Confidence {
pub fn as_str(self) -> &'static str {
match self {
Confidence::Low => "low",
Confidence::Medium => "medium",
Confidence::High => "high",
Confidence::Definitive => "definitive",
}
}
}
impl fmt::Display for Confidence {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Judgement {
Decided {
availability: Availability,
confidence: Confidence,
because: String,
},
Refused {
reason: Refusal,
because: String,
},
WrongServer {
because: String,
},
Abstain,
}
impl Judgement {
pub fn decided(
availability: Availability,
confidence: Confidence,
because: impl Into<String>,
) -> Self {
Judgement::Decided {
availability,
confidence,
because: because.into(),
}
}
pub fn is_conclusive(&self) -> bool {
!matches!(self, Judgement::Abstain)
}
}
pub trait AvailabilityRule: fmt::Debug + Send + Sync {
fn name(&self) -> &'static str;
fn evaluate(&self, evidence: &Evidence<'_>) -> Judgement;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Verdict {
pub availability: Availability,
pub confidence: Confidence,
pub rule: &'static str,
pub because: String,
}
impl fmt::Display for Verdict {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} ({} confidence, {}: {})",
self.availability, self.confidence, self.rule, self.because
)
}
}
#[derive(Debug, Clone)]
pub struct DetectionReport {
pub judgements: Vec<(&'static str, Judgement)>,
pub outcome: std::result::Result<Verdict, String>,
pub response_len: usize,
pub preview: String,
}
impl DetectionReport {
pub fn conclusive(&self) -> impl Iterator<Item = (&'static str, &Judgement)> {
self.judgements
.iter()
.filter(|(_, judgement)| judgement.is_conclusive())
.map(|(name, judgement)| (*name, judgement))
}
pub fn has_disagreement(&self) -> bool {
let mut availabilities = self
.conclusive()
.filter_map(|(_, judgement)| match judgement {
Judgement::Decided { availability, .. } => Some(*availability),
_ => None,
});
let Some(first) = availabilities.next() else {
return false;
};
availabilities.any(|other| other != first)
}
}
impl fmt::Display for DetectionReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.outcome {
Ok(verdict) => writeln!(f, "verdict: {verdict}")?,
Err(problem) => writeln!(f, "verdict: none ({problem})")?,
}
writeln!(f, "response: {} bytes", self.response_len)?;
for (name, judgement) in &self.judgements {
match judgement {
Judgement::Abstain => writeln!(f, " {name:<16} abstained")?,
Judgement::Decided {
availability,
confidence,
because,
} => writeln!(f, " {name:<16} {availability} [{confidence}] {because}")?,
Judgement::Refused { reason, because } => {
writeln!(f, " {name:<16} refused: {reason} ({because})")?
}
Judgement::WrongServer { because } => {
writeln!(f, " {name:<16} wrong server ({because})")?
}
}
}
Ok(())
}
}
#[derive(Debug)]
pub struct DetectionEngine {
rules: Vec<Box<dyn AvailabilityRule>>,
}
impl DetectionEngine {
pub fn new() -> Self {
DetectionEngine { rules: Vec::new() }
}
pub fn standard() -> Self {
DetectionEngine::new()
.rule(WrongServerRule)
.rule(RefusalRule)
.rule(RdapRule)
.rule(RegistryMarkerRule)
.rule(WithheldRule)
.rule(RegisteredRule::new())
.rule(NotFoundRule)
.rule(TldPatternRule)
.rule(RecordlessRule)
}
pub fn rule(mut self, rule: impl AvailabilityRule + 'static) -> Self {
self.rules.push(Box::new(rule));
self
}
pub fn boxed_rule(mut self, rule: Box<dyn AvailabilityRule>) -> Self {
self.rules.push(rule);
self
}
pub fn len(&self) -> usize {
self.rules.len()
}
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
pub fn rule_names(&self) -> Vec<&'static str> {
self.rules.iter().map(|rule| rule.name()).collect()
}
pub fn decide(&self, evidence: &Evidence<'_>) -> Result<Verdict> {
for rule in &self.rules {
match rule.evaluate(evidence) {
Judgement::Abstain => continue,
Judgement::Decided {
availability,
confidence,
because,
} => {
return Ok(Verdict {
availability,
confidence,
rule: rule.name(),
because,
})
}
Judgement::Refused { reason, .. } => {
return Err(Error::Refused {
server: endpoint_label(evidence),
reason,
})
}
Judgement::WrongServer { because } => {
return Err(Error::NoEndpoint {
tld: evidence.tld().clone(),
detail: format!(
"the server answered about a different namespace: {because}"
),
})
}
}
}
Err(Error::Inconclusive {
domain: format!("a .{} name", evidence.tld()),
consulted: endpoint_label(evidence),
detail: format!(
"no rule recognised the response ({} bytes): {}",
evidence.len(),
evidence.preview(120)
),
})
}
pub fn report(&self, evidence: &Evidence<'_>) -> DetectionReport {
let judgements: Vec<(&'static str, Judgement)> = self
.rules
.iter()
.map(|rule| (rule.name(), rule.evaluate(evidence)))
.collect();
let outcome = self.decide(evidence).map_err(|error| error.to_string());
DetectionReport {
judgements,
outcome,
response_len: evidence.len(),
preview: evidence.preview(200),
}
}
}
impl Default for DetectionEngine {
fn default() -> Self {
DetectionEngine::standard()
}
}
fn endpoint_label(evidence: &Evidence<'_>) -> String {
match evidence.registry() {
Some(registry) => registry
.endpoints()
.first()
.map(|endpoint| endpoint.address())
.unwrap_or_else(|| format!(".{} registry", evidence.tld())),
None => format!("the .{} server", evidence.tld()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::Tld;
use crate::registry::Registry;
use crate::transport::ResponseKind;
fn decide(text: &str, tld: &str) -> Result<Verdict> {
let tld = Tld::parse(tld).unwrap();
let evidence = Evidence::new(text, ResponseKind::WhoisText, &tld, None);
DetectionEngine::standard().decide(&evidence)
}
#[test]
fn the_standard_chain_runs_the_documented_rules_in_order() {
assert_eq!(
DetectionEngine::standard().rule_names(),
[
"wrong-server",
"refusal",
"rdap",
"registry-marker",
"withheld",
"registered",
"not-found",
"tld-pattern",
"recordless",
]
);
}
#[test]
fn an_available_response_is_recognised() {
let verdict = decide("No match for \"NOTHERE.COM\".", "com").unwrap();
assert_eq!(verdict.availability, Availability::Available);
}
#[test]
fn a_registered_record_is_recognised() {
let record = "\
Domain Name: EXAMPLE.COM
Registrar: Example Registrar, LLC
Domain Status: clientTransferProhibited
Name Server: NS1.EXAMPLE.COM
";
let verdict = decide(record, "com").unwrap();
assert_eq!(verdict.availability, Availability::Registered);
assert_eq!(verdict.confidence, Confidence::High);
}
#[test]
fn a_refusal_is_an_error_not_an_availability() {
for response in [
"%% queries limit exceeded",
"Requests of this client are not permitted",
"The WHOIS service has been retired; please use our RDAP service",
] {
let error = decide(response, "com").unwrap_err();
assert!(
matches!(error, Error::Refused { .. }),
"for {response:?}: got {error:?}"
);
}
}
#[test]
fn the_wrong_server_is_an_error_not_an_availability() {
let response = "% This is the RIPE Database query service.\n%ERROR:101: no entries found\n";
let error = decide(response, "example").unwrap_err();
assert!(matches!(error, Error::NoEndpoint { .. }), "got {error:?}");
}
#[test]
fn an_unrecognisable_response_is_inconclusive() {
let error = decide("something nobody has ever written", "com").unwrap_err();
match error {
Error::Inconclusive { detail, .. } => {
assert!(detail.contains("something nobody"), "{detail}")
}
other => panic!("got {other:?}"),
}
}
#[test]
fn an_empty_chain_concludes_nothing() {
let tld = Tld::parse("com").unwrap();
let evidence = Evidence::new("No match for x", ResponseKind::WhoisText, &tld, None);
let engine = DetectionEngine::new();
assert!(engine.is_empty());
assert!(engine.decide(&evidence).is_err());
}
#[test]
fn a_curated_marker_beats_the_generic_tables() {
let tld = Tld::parse("example").unwrap();
let registry = Registry::builder([tld.clone()])
.available_marker("nothing here")
.build();
let evidence = Evidence::new(
"Nothing here for that name",
ResponseKind::WhoisText,
&tld,
Some(®istry),
);
let verdict = DetectionEngine::standard().decide(&evidence).unwrap();
assert_eq!(verdict.rule, "registry-marker");
assert_eq!(verdict.confidence, Confidence::High);
}
#[test]
fn rdap_beats_the_text_rules() {
let tld = Tld::parse("com").unwrap();
let json = r#"{"objectClassName":"domain","ldhName":"example.com"}"#;
let evidence = Evidence::new(json, ResponseKind::RdapJson, &tld, None);
let verdict = DetectionEngine::standard().decide(&evidence).unwrap();
assert_eq!(verdict.rule, "rdap");
assert_eq!(verdict.confidence, Confidence::Definitive);
}
#[test]
fn a_report_shows_the_shadowed_rules_too() {
let tld = Tld::parse("com").unwrap();
let record = "Domain Name: EXAMPLE.COM\nRegistrar: Example\nStatus: active\n";
let evidence = Evidence::new(record, ResponseKind::WhoisText, &tld, None);
let report = DetectionEngine::standard().report(&evidence);
assert_eq!(report.judgements.len(), 9);
assert!(report.outcome.is_ok());
assert!(report.conclusive().count() >= 1);
assert!(report.to_string().contains("registered"));
}
#[test]
fn a_report_survives_an_inconclusive_response() {
let tld = Tld::parse("com").unwrap();
let evidence = Evidence::new("???", ResponseKind::WhoisText, &tld, None);
let report = DetectionEngine::standard().report(&evidence);
assert!(report.outcome.is_err());
assert_eq!(report.conclusive().count(), 0);
assert!(!report.has_disagreement());
assert!(report.to_string().contains("verdict: none"));
}
#[test]
fn confidence_is_ordered_from_guess_to_fact() {
assert!(Confidence::Definitive > Confidence::High);
assert!(Confidence::High > Confidence::Medium);
assert!(Confidence::Medium > Confidence::Low);
}
#[test]
fn a_verdict_explains_itself() {
let verdict = decide("No match for \"NOTHERE.COM\".", "com").unwrap();
let rendered = verdict.to_string();
assert!(rendered.contains("available"), "{rendered}");
assert!(rendered.contains(verdict.rule), "{rendered}");
assert!(!verdict.because.is_empty());
}
}