use std::fmt;
use std::time::Duration;
use serde::{Deserialize, Serialize};
fn strip_hostile(raw: &str) -> String {
raw.chars()
.filter(|c| !c.is_control() && !reorders_text(*c))
.collect()
}
pub(crate) fn scrub(raw: &str) -> String {
strip_hostile(raw)
.chars()
.take(200)
.collect::<String>()
.trim()
.to_owned()
}
pub(crate) fn scrub_unbounded(raw: &str) -> String {
raw.chars()
.filter(|c| *c == '\n' || (!c.is_control() && !reorders_text(*c)))
.collect::<String>()
.trim()
.to_owned()
}
pub const fn shapes_letters(c: char) -> bool {
matches!(c, '\u{200c}' | '\u{200d}')
}
pub const fn reorders_text(c: char) -> bool {
matches!(c,
'\u{00ad}'
| '\u{200b}'..='\u{200f}'
| '\u{202a}'..='\u{202e}'
| '\u{2060}'..='\u{2064}'
| '\u{2066}'..='\u{2069}'
| '\u{feff}')
}
use crate::tld::Suffix;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Source {
Registry,
Text,
Dns,
}
impl Source {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Registry => "registry",
Self::Text => "text",
Self::Dns => "dns",
}
}
#[must_use]
pub const fn is_authoritative(self) -> bool {
matches!(self, Self::Registry)
}
}
impl fmt::Display for Source {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.label())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "reason", rename_all = "kebab-case")]
pub enum Reason {
NoService,
RateLimited,
ServerError {
status: u16,
},
Declined {
detail: String,
},
Blocked,
TimedOut,
Unreachable,
Malformed {
detail: String,
},
WrongSubject {
answered_about: String,
},
NotRegistrable,
}
impl Reason {
#[must_use]
pub const fn label(&self) -> &'static str {
match self {
Self::NoService => "no registry service",
Self::RateLimited => "rate limited",
Self::ServerError { .. } => "registry error",
Self::Declined { .. } => "registry declined",
Self::Blocked => "access refused",
Self::TimedOut => "timed out",
Self::Unreachable => "could not connect",
Self::Malformed { .. } => "answer not understood",
Self::WrongSubject { .. } => "answered about another name",
Self::NotRegistrable => "not publicly registrable",
}
}
#[must_use]
pub const fn is_retryable(&self) -> bool {
matches!(
self,
Self::RateLimited
| Self::ServerError { .. }
| Self::Declined { .. }
| Self::TimedOut
| Self::Unreachable
| Self::Blocked
)
}
}
impl fmt::Display for Reason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Malformed { detail } | Self::Declined { detail } => {
write!(f, "{}: {detail}", self.label())
}
Self::WrongSubject { answered_about } => {
write!(f, "{}: {answered_about}", self.label())
}
other => f.write_str(other.label()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "kebab-case")]
pub enum Status {
Available,
Taken,
Unknown(Reason),
}
impl Status {
#[must_use]
pub const fn is_available(&self) -> bool {
matches!(self, Self::Available)
}
#[must_use]
pub const fn is_unknown(&self) -> bool {
matches!(self, Self::Unknown(_))
}
#[must_use]
pub const fn mark(&self) -> char {
match self {
Self::Available => '+',
Self::Taken => '-',
Self::Unknown(_) => '?',
}
}
#[must_use]
pub const fn label(&self) -> &'static str {
match self {
Self::Available => "AVAILABLE",
Self::Taken => "TAKEN",
Self::Unknown(_) => "UNKNOWN",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Finding {
pub domain: String,
pub name: String,
pub suffix: Suffix,
pub status: Status,
pub source: Option<Source>,
pub elapsed: Duration,
#[serde(skip_serializing_if = "Option::is_none")]
pub responder: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub registration: Option<crate::lookup::registration::Registration>,
#[serde(skip_serializing_if = "Option::is_none")]
pub attempts: Option<Vec<Attempt>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub raw: Option<String>,
#[serde(default)]
pub cached: bool,
}
impl Finding {
#[must_use]
pub fn unknown(name: &str, suffix: &Suffix, reason: Reason, elapsed: Duration) -> Self {
Self {
domain: format!("{name}.{suffix}"),
name: name.to_owned(),
suffix: suffix.clone(),
status: Status::Unknown(reason),
source: None,
elapsed,
responder: None,
registration: None,
attempts: None,
raw: None,
cached: false,
}
}
#[must_use]
pub fn unrecognized(domain: &str) -> Self {
let suffix = domain.rsplit_once('.').map_or(domain, |(_, tail)| tail);
Self {
domain: domain.to_owned(),
name: domain.to_owned(),
suffix: Suffix::from_raw(suffix),
status: Status::Unknown(Reason::NoService),
source: None,
elapsed: Duration::ZERO,
responder: None,
registration: None,
attempts: None,
raw: None,
cached: false,
}
}
#[must_use]
pub const fn is_available(&self) -> bool {
self.status.is_available()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Attempt {
pub source: Source,
#[serde(skip_serializing_if = "Option::is_none")]
pub responder: Option<String>,
pub outcome: AttemptOutcome,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "outcome", rename_all = "kebab-case")]
pub enum AttemptOutcome {
Decisive(Status),
Inconclusive(Reason),
Skipped,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Tally {
pub available: usize,
pub taken: usize,
pub unknown: usize,
}
impl Tally {
#[must_use]
pub fn of(findings: &[Finding]) -> Self {
let mut tally = Self::default();
for finding in findings {
match finding.status {
Status::Available => tally.available += 1,
Status::Taken => tally.taken += 1,
Status::Unknown(_) => tally.unknown += 1,
}
}
tally
}
#[must_use]
pub const fn checked(&self) -> usize {
self.available + self.taken + self.unknown
}
#[must_use]
pub const fn has_available(&self) -> bool {
self.available > 0
}
}
#[cfg(test)]
mod tests {
use super::*;
fn suffix() -> Suffix {
Suffix::parse("com").expect("com parses")
}
#[test]
fn only_the_registry_settles_the_question_on_its_own() {
assert!(Source::Registry.is_authoritative());
assert!(!Source::Dns.is_authoritative());
assert!(!Source::Text.is_authoritative());
}
#[test]
fn every_unanswered_lookup_is_unknown_and_never_available() {
let reasons = [
Reason::NoService,
Reason::RateLimited,
Reason::ServerError { status: 500 },
Reason::Blocked,
Reason::TimedOut,
Reason::Unreachable,
Reason::NotRegistrable,
Reason::Malformed {
detail: "bad json".to_owned(),
},
Reason::WrongSubject {
answered_about: "other.com".to_owned(),
},
];
for reason in reasons {
let status = Status::Unknown(reason);
assert!(!status.is_available(), "{status:?} must not read as free");
assert!(status.is_unknown());
assert_eq!(status.mark(), '?');
}
}
#[test]
fn a_throttled_lookup_is_retryable_but_a_missing_service_is_not() {
assert!(Reason::RateLimited.is_retryable());
assert!(Reason::ServerError { status: 503 }.is_retryable());
assert!(Reason::TimedOut.is_retryable());
assert!(Reason::Unreachable.is_retryable());
assert!(!Reason::NoService.is_retryable());
assert!(!Reason::NotRegistrable.is_retryable());
assert!(
!Reason::Malformed {
detail: String::new()
}
.is_retryable()
);
}
#[test]
fn the_status_mark_carries_meaning_without_color() {
assert_eq!(Status::Available.mark(), '+');
assert_eq!(Status::Taken.mark(), '-');
assert_eq!(Status::Unknown(Reason::TimedOut).mark(), '?');
assert_ne!(Status::Available.label(), Status::Taken.label());
}
#[test]
fn an_unknown_finding_reports_the_reason_in_its_text() {
let finding = Finding::unknown(
"example",
&suffix(),
Reason::RateLimited,
Duration::from_millis(20),
);
assert_eq!(finding.domain, "example.com");
assert!(!finding.is_available());
match finding.status {
Status::Unknown(reason) => assert_eq!(reason.to_string(), "rate limited"),
other => panic!("expected unknown, got {other:?}"),
}
}
#[test]
fn an_unreadable_answer_keeps_its_detail_in_the_message() {
let reason = Reason::Malformed {
detail: "truncated body".to_owned(),
};
assert!(reason.to_string().contains("truncated body"));
}
#[test]
fn the_tally_counts_each_class_and_never_double_counts() {
let findings = vec![
Finding {
domain: "a.com".to_owned(),
name: "a".to_owned(),
suffix: suffix(),
status: Status::Available,
source: Some(Source::Registry),
elapsed: Duration::ZERO,
responder: None,
registration: None,
attempts: None,
raw: None,
cached: false,
},
Finding {
domain: "b.com".to_owned(),
name: "b".to_owned(),
suffix: suffix(),
status: Status::Taken,
source: Some(Source::Registry),
elapsed: Duration::ZERO,
responder: None,
registration: None,
attempts: None,
raw: None,
cached: false,
},
Finding::unknown("c", &suffix(), Reason::TimedOut, Duration::ZERO),
];
let tally = Tally::of(&findings);
assert_eq!(tally.available, 1);
assert_eq!(tally.taken, 1);
assert_eq!(tally.unknown, 1);
assert_eq!(tally.checked(), 3);
assert!(tally.has_available());
}
#[test]
fn a_run_with_no_free_names_reports_nothing_found() {
let tally = Tally {
available: 0,
taken: 5,
unknown: 2,
};
assert!(!tally.has_available());
assert_eq!(tally.checked(), 7);
}
#[test]
fn an_empty_run_tallies_to_zero() {
let tally = Tally::of(&[]);
assert_eq!(tally, Tally::default());
assert_eq!(tally.checked(), 0);
assert!(!tally.has_available());
}
#[test]
fn a_bidi_override_cannot_reverse_a_line_the_way_a_control_byte_would() {
let hostile = "evil\u{202e}moc.elpmaxe\u{202c}";
let clean = scrub(hostile);
assert!(!clean.contains('\u{202e}'), "an override must not survive");
assert!(!clean.contains('\u{202c}'));
assert!(clean.contains("evil"), "the readable text still shows");
for mark in ['\u{200b}', '\u{200f}', '\u{2066}', '\u{feff}', '\u{00ad}'] {
let raw = format!("a{mark}b");
assert_eq!(scrub(&raw), "ab", "{mark:?} must not survive");
}
}
#[test]
fn the_unbounded_scrub_keeps_the_lines_a_record_is_meant_to_have() {
let record = "Domain Name: EXAMPLE.COM\nRegistrar: COM LAUDE\n\nStatus: active";
assert_eq!(scrub_unbounded(record), record);
}
#[test]
fn the_unbounded_scrub_still_strips_control_bytes_and_bidi_overrides() {
let hostile = "line one\nevil\u{202e}owt enil\u{202c}\nline three";
let clean = scrub_unbounded(hostile);
assert!(!clean.contains('\u{202e}'));
assert!(!clean.contains('\u{202c}'));
assert!(clean.contains("line one\n"));
assert!(clean.contains("line three"));
let with_escape = "before\x1bafter";
assert_eq!(scrub_unbounded(with_escape), "beforeafter");
}
#[test]
fn the_unbounded_scrub_never_cuts_a_long_record_the_way_the_bounded_one_does() {
let long = "x".repeat(400);
assert_eq!(scrub_unbounded(&long).len(), 400);
assert_eq!(scrub(&long).len(), 200);
}
}