use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
const LEAST_TIMEOUT: Duration = Duration::from_secs(2);
use futures::stream::{FuturesUnordered, StreamExt};
use crate::error::Result;
use crate::limit::{Pacer, PacingLimits};
use crate::lookup::outcome::{Attempt, AttemptOutcome, Finding, Reason, Source, Status};
use crate::lookup::referral;
use crate::lookup::registry::{Freshness, ServiceMap};
use crate::lookup::resolve::{self, DnsVerdict};
use crate::lookup::whois::{self, Server, Servers};
use crate::lookup::{rdap, registration};
use crate::tld::Suffix;
use crate::user_agent;
#[derive(Debug, Clone)]
pub struct Settings {
pub pacing: PacingLimits,
pub timeout: Duration,
pub cache_path: PathBuf,
pub refresh: bool,
pub source_policy: SourcePolicy,
pub registry_servers: Option<PathBuf>,
pub text_servers: Option<PathBuf>,
pub replace_servers: bool,
pub allow_referrals: bool,
pub explain: bool,
pub raw: bool,
pub cache_ttl: Option<Duration>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SourcePolicy {
#[default]
Auto,
Registry,
Text,
Dns,
}
use crate::limit::RESOLVER_HOST;
#[derive(Debug)]
pub struct Engine {
client: reqwest::Client,
resolver: crate::lookup::resolve::Resolvers,
freshness: Freshness,
services: ServiceMap,
servers: Servers,
referral_cache: tokio::sync::Mutex<
std::collections::HashMap<String, Arc<tokio::sync::OnceCell<Option<String>>>>,
>,
ttl_cache: tokio::sync::Mutex<std::collections::HashMap<String, (Instant, Finding)>>,
pacer: Arc<Pacer>,
settings: Settings,
}
fn text_detail(raw: &str) -> Option<crate::lookup::Registration> {
let record = crate::lookup::registration::parse_text(raw);
(!record.is_empty()).then_some(record)
}
fn more_informative(candidate: &Reason, than: &Reason) -> bool {
const fn rank(reason: &Reason) -> u8 {
if matches!(reason, Reason::NoService) {
0
} else if reason.is_retryable() {
2
} else {
1
}
}
rank(candidate) > rank(than)
}
const fn cacheable(status: &Status) -> bool {
matches!(status, Status::Taken | Status::Unknown(_))
}
impl Engine {
pub async fn build(settings: Settings) -> Result<Self> {
let timeout = settings.timeout.max(LEAST_TIMEOUT);
let resolver = resolve::build(timeout)?;
let client = reqwest::Client::builder()
.user_agent(user_agent())
.timeout(timeout)
.connect_timeout(timeout)
.dns_resolver(resolve::HttpResolver::new(&resolver))
.redirect(reqwest::redirect::Policy::none())
.https_only(true)
.build()
.map_err(|source| crate::Error::NetworkUnreachable {
source: Box::new(source),
})?;
let skip_published = !matches!(
settings.source_policy,
SourcePolicy::Auto | SourcePolicy::Registry
) || (settings.replace_servers && settings.registry_servers.is_some());
let (mut services, freshness) = if skip_published {
(ServiceMap::default(), Freshness::Cached)
} else {
ServiceMap::load(&client, &settings.cache_path, settings.refresh).await?
};
if let Some(path) = &settings.registry_servers {
services.merge(ServiceMap::from_file(path)?);
}
let text_possible = matches!(
settings.source_policy,
SourcePolicy::Auto | SourcePolicy::Text
);
let skip_bundled_servers =
!text_possible || (settings.replace_servers && settings.text_servers.is_some());
let mut servers = if skip_bundled_servers {
Servers::default()
} else {
Servers::bundled()?
};
if let Some(path) = &settings.text_servers {
servers.merge(Servers::from_file(path)?);
}
Ok(Self {
pacer: Arc::new(Pacer::new(settings.pacing.clone())),
servers,
referral_cache: tokio::sync::Mutex::new(std::collections::HashMap::new()),
ttl_cache: tokio::sync::Mutex::new(std::collections::HashMap::new()),
client,
resolver,
freshness,
services,
settings,
})
}
pub async fn check_name(&self, name: &str, suffix: &Suffix) -> Finding {
let domain = format!("{name}.{suffix}");
if domain.len() > 253 {
return Finding::unknown(name, suffix, Reason::NotRegistrable, Duration::ZERO);
}
if let Some(ttl) = self.settings.cache_ttl
&& let Some(cached) = self.cached_finding(&domain, ttl).await
{
return cached;
}
let finding = self.check_name_live(name, suffix, domain.clone()).await;
if self.settings.cache_ttl.is_some() && cacheable(&finding.status) {
self.remember(domain, finding.clone()).await;
}
finding
}
async fn cached_finding(&self, domain: &str, ttl: Duration) -> Option<Finding> {
let mut cache = self.ttl_cache.lock().await;
match cache.get(domain) {
Some((at, finding)) if at.elapsed() < ttl => {
let mut hit = finding.clone();
hit.cached = true;
Some(hit)
}
Some(_) => {
cache.remove(domain);
None
}
None => None,
}
}
async fn remember(&self, domain: String, finding: Finding) {
self.ttl_cache
.lock()
.await
.insert(domain, (Instant::now(), finding));
}
fn record(
&self,
attempts: &mut Vec<Attempt>,
source: Source,
responder: Option<String>,
outcome: AttemptOutcome,
) {
if self.settings.explain {
attempts.push(Attempt {
source,
responder,
outcome,
});
}
}
fn unknown_with(
&self,
name: &str,
suffix: &Suffix,
reason: Reason,
elapsed: Duration,
attempts: Vec<Attempt>,
) -> Finding {
let mut finding = Finding::unknown(name, suffix, reason, elapsed);
if self.settings.explain {
finding.attempts = Some(attempts);
}
finding
}
async fn check_name_live(&self, name: &str, suffix: &Suffix, domain: String) -> Finding {
let started = Instant::now();
let mut reason = Reason::NoService;
let mut attempts: Vec<Attempt> = Vec::new();
let registry_allowed = matches!(
self.settings.source_policy,
SourcePolicy::Auto | SourcePolicy::Registry
);
if registry_allowed && let Some(services) = self.services.for_suffix(suffix.as_str()) {
let (answer, responder) = rdap::query(
&self.client,
&self.pacer,
services,
&domain,
self.settings.timeout,
)
.await;
match answer {
rdap::Verdict::Available(body) => {
self.record(
&mut attempts,
Source::Registry,
responder.clone(),
AttemptOutcome::Decisive(Status::Available),
);
return Finding {
domain,
name: name.to_owned(),
suffix: suffix.clone(),
status: Status::Available,
source: Some(Source::Registry),
elapsed: started.elapsed(),
responder,
registration: None,
attempts: self.settings.explain.then_some(attempts),
raw: if self.settings.raw {
body.as_deref().map(rdap::raw_text)
} else {
None
},
cached: false,
};
}
rdap::Verdict::Taken(body) => {
self.record(
&mut attempts,
Source::Registry,
responder.clone(),
AttemptOutcome::Decisive(Status::Taken),
);
return Finding {
domain,
name: name.to_owned(),
suffix: suffix.clone(),
status: Status::Taken,
source: Some(Source::Registry),
elapsed: started.elapsed(),
responder,
registration: Some(registration::parse(&body)),
attempts: self.settings.explain.then_some(attempts),
raw: self.settings.raw.then(|| rdap::raw_text(&body)),
cached: false,
};
}
rdap::Verdict::Unknown(refused) => {
self.record(
&mut attempts,
Source::Registry,
None,
AttemptOutcome::Inconclusive(refused.clone()),
);
if self.settings.source_policy == SourcePolicy::Registry {
return self.unknown_with(
name,
suffix,
refused,
started.elapsed(),
attempts,
);
}
if more_informative(&refused, &reason) {
reason = refused;
}
}
}
} else if self.settings.source_policy == SourcePolicy::Registry {
self.record(
&mut attempts,
Source::Registry,
None,
AttemptOutcome::Skipped,
);
return self.unknown_with(name, suffix, Reason::NoService, started.elapsed(), attempts);
} else if registry_allowed {
self.record(
&mut attempts,
Source::Registry,
None,
AttemptOutcome::Skipped,
);
}
let text_allowed = matches!(
self.settings.source_policy,
SourcePolicy::Auto | SourcePolicy::Text
);
if text_allowed && let Some(server) = self.servers.for_suffix(suffix.as_str()) {
match whois::query(
&self.resolver,
&self.pacer,
server,
&domain,
self.settings.timeout,
self.table_guard(suffix.as_str()),
)
.await
{
whois::Verdict::Available { raw } => {
let responder = crate::lookup::outcome::scrub(&server.host);
self.record(
&mut attempts,
Source::Text,
Some(responder.clone()),
AttemptOutcome::Decisive(Status::Available),
);
return Finding {
domain,
name: name.to_owned(),
suffix: suffix.clone(),
status: Status::Available,
source: Some(Source::Text),
elapsed: started.elapsed(),
responder: Some(responder),
registration: None,
attempts: self.settings.explain.then_some(attempts),
raw: self
.settings
.raw
.then(|| crate::lookup::outcome::scrub_unbounded(&raw)),
cached: false,
};
}
whois::Verdict::Taken { raw } => {
let responder = crate::lookup::outcome::scrub(&server.host);
self.record(
&mut attempts,
Source::Text,
Some(responder.clone()),
AttemptOutcome::Decisive(Status::Taken),
);
return Finding {
domain,
name: name.to_owned(),
suffix: suffix.clone(),
status: Status::Taken,
source: Some(Source::Text),
elapsed: started.elapsed(),
responder: Some(responder),
registration: text_detail(&raw),
attempts: self.settings.explain.then_some(attempts),
raw: self
.settings
.raw
.then(|| crate::lookup::outcome::scrub_unbounded(&raw)),
cached: false,
};
}
whois::Verdict::Unknown(refused) => {
self.record(
&mut attempts,
Source::Text,
Some(crate::lookup::outcome::scrub(&server.host)),
AttemptOutcome::Inconclusive(refused.clone()),
);
if self.settings.source_policy == SourcePolicy::Text {
return self.unknown_with(
name,
suffix,
refused,
started.elapsed(),
attempts,
);
}
if more_informative(&refused, &reason) {
reason = refused;
}
}
}
} else if text_allowed {
self.record(&mut attempts, Source::Text, None, AttemptOutcome::Skipped);
}
if text_allowed
&& self.settings.allow_referrals
&& let Some(found) = self
.referred_server(suffix)
.await
.filter(|host| crate::lookup::registry::is_public_host(host))
{
let server = Server {
host: found,
available_phrase: String::new(),
};
match whois::query(
&self.resolver,
&self.pacer,
&server,
&domain,
self.settings.timeout,
whois::HostGuard::Enforce,
)
.await
{
whois::Verdict::Available { raw } => {
let responder = crate::lookup::outcome::scrub(&server.host);
self.record(
&mut attempts,
Source::Text,
Some(responder.clone()),
AttemptOutcome::Decisive(Status::Available),
);
return Finding {
domain,
name: name.to_owned(),
suffix: suffix.clone(),
status: Status::Available,
source: Some(Source::Text),
elapsed: started.elapsed(),
responder: Some(responder),
registration: None,
attempts: self.settings.explain.then_some(attempts),
raw: self
.settings
.raw
.then(|| crate::lookup::outcome::scrub_unbounded(&raw)),
cached: false,
};
}
whois::Verdict::Taken { raw } => {
let responder = crate::lookup::outcome::scrub(&server.host);
self.record(
&mut attempts,
Source::Text,
Some(responder.clone()),
AttemptOutcome::Decisive(Status::Taken),
);
return Finding {
domain,
name: name.to_owned(),
suffix: suffix.clone(),
status: Status::Taken,
source: Some(Source::Text),
elapsed: started.elapsed(),
responder: Some(responder),
registration: text_detail(&raw),
attempts: self.settings.explain.then_some(attempts),
raw: self
.settings
.raw
.then(|| crate::lookup::outcome::scrub_unbounded(&raw)),
cached: false,
};
}
whois::Verdict::Unknown(refused) => {
self.record(
&mut attempts,
Source::Text,
Some(crate::lookup::outcome::scrub(&server.host)),
AttemptOutcome::Inconclusive(refused.clone()),
);
if more_informative(&refused, &reason) {
reason = refused;
}
}
}
}
if self.settings.source_policy == SourcePolicy::Text {
return self.unknown_with(name, suffix, reason, started.elapsed(), attempts);
}
let dns_permit = self.pacer.acquire(RESOLVER_HOST).await.ok();
let dns_verdict = resolve::query(&self.resolver, &domain).await;
drop(dns_permit);
match dns_verdict {
DnsVerdict::InUse => {
self.record(
&mut attempts,
Source::Dns,
None,
AttemptOutcome::Decisive(Status::Taken),
);
Finding {
domain,
name: name.to_owned(),
suffix: suffix.clone(),
status: Status::Taken,
source: Some(Source::Dns),
elapsed: started.elapsed(),
responder: None,
registration: None,
attempts: self.settings.explain.then_some(attempts),
raw: None,
cached: false,
}
}
DnsVerdict::Absent => {
self.record(
&mut attempts,
Source::Dns,
None,
AttemptOutcome::Inconclusive(reason.clone()),
);
self.unknown_with(name, suffix, reason, started.elapsed(), attempts)
}
DnsVerdict::NoAnswer => {
if matches!(reason, Reason::NoService) {
reason = Reason::Unreachable;
}
self.record(
&mut attempts,
Source::Dns,
None,
AttemptOutcome::Inconclusive(reason.clone()),
);
self.unknown_with(name, suffix, reason, started.elapsed(), attempts)
}
}
}
pub async fn check_domain(
&self,
catalog: &crate::tld::Catalog,
domain: &str,
) -> Option<Finding> {
let (name, suffix) = catalog.split_domain(domain)?;
Some(self.check_name(&name, &suffix).await)
}
const PRUNE_SETTLED_EVERY: usize = 128;
fn already_answered_for(
already_answered: &std::collections::HashMap<String, Finding>,
name: &str,
suffix: &Suffix,
) -> Option<Finding> {
already_answered.get(&format!("{name}.{suffix}")).cloned()
}
pub async fn sweep(
&self,
names: &[String],
suffixes: &[Suffix],
mut answered: impl FnMut(&Finding),
) -> Vec<Finding> {
let window = self
.settings
.pacing
.total_concurrency
.max(1)
.saturating_mul(2);
let mut pending = names
.iter()
.flat_map(|name| suffixes.iter().map(move |suffix| (name, suffix)));
let mut work = FuturesUnordered::new();
let mut findings = Vec::new();
let mut already_answered: std::collections::HashMap<String, Finding> =
std::collections::HashMap::new();
while work.len() < window {
let Some((name, suffix)) = pending.next() else {
break;
};
if let Some(dup) = Self::already_answered_for(&already_answered, name, suffix) {
answered(&dup);
findings.push(dup);
continue;
}
work.push(self.check_name(name, suffix));
}
while let Some(finding) = work.next().await {
already_answered
.entry(finding.domain.clone())
.or_insert_with(|| finding.clone());
answered(&finding);
findings.push(finding);
if findings.len() % Self::PRUNE_SETTLED_EVERY == 0 {
self.pacer.prune_settled_hosts().await;
}
while work.len() < window {
let Some((name, suffix)) = pending.next() else {
break;
};
if let Some(dup) = Self::already_answered_for(&already_answered, name, suffix) {
answered(&dup);
findings.push(dup);
continue;
}
work.push(self.check_name(name, suffix));
break;
}
}
findings.sort_by(|a, b| a.domain.cmp(&b.domain));
findings
}
async fn referred_server(&self, suffix: &Suffix) -> Option<String> {
let cell = {
let mut cache = self.referral_cache.lock().await;
Arc::clone(
cache
.entry(suffix.as_str().to_owned())
.or_insert_with(|| Arc::new(tokio::sync::OnceCell::new())),
)
};
cell.get_or_init(|| async {
referral::query(
&self.resolver,
&self.pacer,
suffix.as_str(),
self.settings.timeout,
)
.await
.text_host
})
.await
.clone()
}
fn table_guard(&self, suffix: &str) -> whois::HostGuard {
if self.servers.was_supplied(suffix) {
whois::HostGuard::Trusted
} else {
whois::HostGuard::Enforce
}
}
pub async fn dns_records(&self, domain: &str) -> crate::lookup::DnsRecords {
resolve::dns_records(&self.resolver, domain).await
}
#[must_use]
pub const fn registry_list_freshness(&self) -> Freshness {
self.freshness
}
pub async fn paused_hosts(&self) -> Vec<crate::limit::PausedHost> {
self.pacer.paused_hosts().await
}
}
#[cfg(test)]
mod tests {
use tempfile::tempdir;
use super::*;
use crate::lookup::outcome::Tally;
use crate::tld::Catalog;
const UNSERVED: &str = "zzzz-no-such-extension";
const ALSO_UNSERVED: &str = "yyyy-no-such-extension";
fn suffix(value: &str) -> Suffix {
Suffix::parse(value).expect("the test suffix parses")
}
fn grounded_settings(source_policy: SourcePolicy, dir: &std::path::Path) -> Settings {
Settings {
pacing: PacingLimits::default(),
timeout: Duration::from_secs(1),
cache_path: dir.join("servers.json"),
refresh: false,
source_policy,
registry_servers: None,
text_servers: None,
replace_servers: false,
allow_referrals: false,
explain: false,
raw: false,
cache_ttl: None,
}
}
async fn text_only_engine(dir: &std::path::Path) -> Engine {
Engine::build(grounded_settings(SourcePolicy::Text, dir))
.await
.expect("an engine that never leaves the machine")
}
async fn registry_only_engine(dir: &std::path::Path) -> Engine {
let list = dir.join("services.json");
std::fs::write(
&list,
r#"{"services":[[["com"],["https://rdap.example.test/com/"]]]}"#,
)
.expect("the service list is written");
Engine::build(Settings {
registry_servers: Some(list),
replace_servers: true,
..grounded_settings(SourcePolicy::Registry, dir)
})
.await
.expect("an engine that never leaves the machine")
}
#[tokio::test]
async fn asking_the_registry_only_reports_unknown_when_the_extension_has_no_service() {
let dir = tempdir().expect("temp dir");
let engine = registry_only_engine(dir.path()).await;
let finding = engine.check_name("example", &suffix(UNSERVED)).await;
assert_eq!(finding.status, Status::Unknown(Reason::NoService));
assert!(!finding.is_available());
assert_eq!(finding.source, None);
assert_eq!(finding.responder, None);
assert_eq!(finding.domain, format!("example.{UNSERVED}"));
assert_eq!(finding.name, "example");
}
#[tokio::test]
async fn the_text_protocol_alone_reports_unknown_when_no_server_answers_for_the_extension() {
let dir = tempdir().expect("temp dir");
let engine = text_only_engine(dir.path()).await;
let finding = engine.check_name("example", &suffix(UNSERVED)).await;
assert_eq!(finding.status, Status::Unknown(Reason::NoService));
assert!(!finding.is_available());
assert_eq!(finding.source, None);
}
#[tokio::test]
async fn a_sweep_nothing_can_answer_reports_every_row_unknown_and_none_free() {
let dir = tempdir().expect("temp dir");
let engine = text_only_engine(dir.path()).await;
let mut answered = 0_usize;
let findings = engine
.sweep(
&["beta".to_owned(), "alpha".to_owned()],
&[suffix(UNSERVED), suffix(ALSO_UNSERVED)],
|_| answered += 1,
)
.await;
assert_eq!(
answered,
findings.len(),
"every answer is handed to the caller as it lands"
);
assert_eq!(findings.len(), 4);
assert!(findings.iter().all(|finding| finding.status.is_unknown()));
let tally = Tally::of(&findings);
assert_eq!(tally.available, 0);
assert_eq!(tally.unknown, 4);
}
#[tokio::test]
async fn a_name_repeated_in_the_input_still_gets_one_row_per_occurrence() {
let dir = tempdir().expect("temp dir");
let engine = text_only_engine(dir.path()).await;
let mut answered = 0_usize;
let findings = engine
.sweep(
&["beta".to_owned(), "beta".to_owned(), "alpha".to_owned()],
&[suffix(UNSERVED)],
|_| answered += 1,
)
.await;
assert_eq!(findings.len(), 3, "every requested pair still gets a row");
assert_eq!(answered, 3, "duplicates are still reported to the caller");
let betas: Vec<&Finding> = findings.iter().filter(|f| f.name == "beta").collect();
assert_eq!(betas.len(), 2);
assert_eq!(betas[0].status, betas[1].status);
assert_eq!(betas[0].domain, betas[1].domain);
}
#[test]
fn only_a_taken_or_unknown_answer_is_ever_eligible_for_the_ttl_cache() {
assert!(cacheable(&Status::Taken));
assert!(cacheable(&Status::Unknown(Reason::NoService)));
assert!(
!cacheable(&Status::Available),
"a stale free reading is the one failure this tool must never hand back"
);
}
#[tokio::test]
async fn a_ttl_cache_reuses_an_unknown_answer_within_its_window_and_marks_it_cached() {
let dir = tempdir().expect("temp dir");
let engine = Engine::build(Settings {
cache_ttl: Some(Duration::from_secs(60)),
..grounded_settings(SourcePolicy::Text, dir.path())
})
.await
.expect("an engine that never leaves the machine");
let first = engine.check_name("example", &suffix(UNSERVED)).await;
assert!(!first.cached);
assert_eq!(first.status, Status::Unknown(Reason::NoService));
let second = engine.check_name("example", &suffix(UNSERVED)).await;
assert!(
second.cached,
"a repeat lookup inside the TTL is served from the cache"
);
assert_eq!(second.status, first.status);
}
#[tokio::test]
async fn a_ttl_cache_entry_past_its_window_is_asked_again_rather_than_reused() {
let dir = tempdir().expect("temp dir");
let engine = Engine::build(Settings {
cache_ttl: Some(Duration::from_millis(1)),
..grounded_settings(SourcePolicy::Text, dir.path())
})
.await
.expect("an engine that never leaves the machine");
let first = engine.check_name("example", &suffix(UNSERVED)).await;
assert!(!first.cached);
tokio::time::sleep(Duration::from_millis(20)).await;
let second = engine.check_name("example", &suffix(UNSERVED)).await;
assert!(
!second.cached,
"an expired entry must not be served as if it were fresh"
);
}
#[tokio::test]
async fn no_cache_ttl_means_no_reuse_at_all() {
let dir = tempdir().expect("temp dir");
let engine = text_only_engine(dir.path()).await;
let first = engine.check_name("example", &suffix(UNSERVED)).await;
let second = engine.check_name("example", &suffix(UNSERVED)).await;
assert!(!first.cached && !second.cached);
}
#[tokio::test]
async fn explain_records_a_skipped_registry_attempt_when_no_service_is_known() {
let dir = tempdir().expect("temp dir");
let list = dir.path().join("services.json");
std::fs::write(
&list,
r#"{"services":[[["com"],["https://rdap.example.test/com/"]]]}"#,
)
.expect("the service list is written");
let engine = Engine::build(Settings {
registry_servers: Some(list),
replace_servers: true,
explain: true,
..grounded_settings(SourcePolicy::Registry, dir.path())
})
.await
.expect("an engine that never leaves the machine");
let finding = engine.check_name("example", &suffix(UNSERVED)).await;
let attempts = finding.attempts.expect("explain fills in the trail");
assert_eq!(attempts.len(), 1, "{attempts:?}");
assert_eq!(attempts[0].source, Source::Registry);
assert_eq!(attempts[0].outcome, AttemptOutcome::Skipped);
}
#[tokio::test]
async fn without_explain_the_trail_is_never_built() {
let dir = tempdir().expect("temp dir");
let engine = text_only_engine(dir.path()).await;
let finding = engine.check_name("example", &suffix(UNSERVED)).await;
assert_eq!(finding.attempts, None);
}
#[tokio::test]
async fn a_sweep_hands_back_one_row_per_pair_in_domain_order() {
let dir = tempdir().expect("temp dir");
let engine = text_only_engine(dir.path()).await;
let findings = engine
.sweep(
&["beta".to_owned(), "alpha".to_owned()],
&[suffix(UNSERVED), suffix(ALSO_UNSERVED)],
|_| {},
)
.await;
let domains: Vec<&str> = findings
.iter()
.map(|finding| finding.domain.as_str())
.collect();
let mut expected = domains.clone();
expected.sort_unstable();
assert_eq!(domains, expected);
assert_eq!(
domains.first(),
Some(&format!("alpha.{ALSO_UNSERVED}").as_str())
);
}
#[tokio::test]
async fn an_empty_sweep_asks_nothing_and_returns_nothing() {
let dir = tempdir().expect("temp dir");
let engine = text_only_engine(dir.path()).await;
let mut answered = 0_usize;
assert!(
engine
.sweep(&[], &[suffix(UNSERVED)], |_| answered += 1)
.await
.is_empty()
);
assert!(
engine
.sweep(&["alpha".to_owned()], &[], |_| answered += 1)
.await
.is_empty()
);
assert_eq!(answered, 0, "nothing to check means nothing is reported");
}
#[tokio::test]
async fn a_name_typed_in_full_is_checked_exactly_as_given() {
let dir = tempdir().expect("temp dir");
let engine = text_only_engine(dir.path()).await;
let catalog = Catalog::bundled().expect("the bundled catalog parses");
let finding = engine
.check_domain(&catalog, &format!("example.{UNSERVED}"))
.await
.expect("a domain with an extension splits");
assert_eq!(finding.domain, format!("example.{UNSERVED}"));
assert_eq!(finding.suffix.as_str(), UNSERVED);
assert!(finding.status.is_unknown());
}
#[tokio::test]
async fn a_bare_name_with_no_extension_is_not_checked_as_a_domain() {
let dir = tempdir().expect("temp dir");
let engine = text_only_engine(dir.path()).await;
let catalog = Catalog::bundled().expect("the bundled catalog parses");
assert!(engine.check_domain(&catalog, "example").await.is_none());
}
#[tokio::test]
async fn a_fresh_engine_holds_no_registry_back() {
let dir = tempdir().expect("temp dir");
let engine = text_only_engine(dir.path()).await;
assert!(engine.paused_hosts().await.is_empty());
}
#[test]
fn the_default_source_uses_the_registry_first() {
assert_eq!(SourcePolicy::default(), SourcePolicy::Auto);
}
#[test]
fn a_retryable_reason_is_not_buried_by_a_merely_different_one() {
assert!(more_informative(&Reason::RateLimited, &Reason::NoService));
assert!(!more_informative(
&Reason::Malformed {
detail: String::new()
},
&Reason::RateLimited
));
assert!(more_informative(
&Reason::RateLimited,
&Reason::Malformed {
detail: String::new()
}
));
assert!(!more_informative(&Reason::NoService, &Reason::RateLimited));
}
#[test]
fn settings_carry_everything_a_run_needs() {
let settings = Settings {
pacing: PacingLimits::default(),
timeout: Duration::from_secs(10),
cache_path: PathBuf::from("/tmp/reserve/servers.json"),
refresh: false,
source_policy: SourcePolicy::Auto,
registry_servers: None,
text_servers: None,
replace_servers: false,
allow_referrals: true,
explain: false,
raw: false,
cache_ttl: Some(Duration::from_secs(60)),
};
assert_eq!(settings.timeout, Duration::from_secs(10));
assert!(!settings.refresh);
assert_eq!(settings.cache_ttl, Some(Duration::from_secs(60)));
}
}