use std::collections::HashMap;
use std::sync::OnceLock;
use regex::RegexSet;
const SEP: &str = r"[\s._\-]*:";
#[derive(Debug)]
pub struct PatternSet {
set: RegexSet,
sources: &'static [&'static str],
}
impl PatternSet {
fn new(sources: &'static [&'static str]) -> Self {
let set = RegexSet::new(sources)
.unwrap_or_else(|error| panic!("invalid detection pattern table: {error}"));
PatternSet { set, sources }
}
pub fn matches(&self, haystack: &str) -> bool {
self.set.is_match(haystack)
}
pub fn first_match(&self, haystack: &str) -> Option<&'static str> {
self.set
.matches(haystack)
.into_iter()
.next()
.map(|index| self.sources[index])
}
pub fn match_count(&self, haystack: &str) -> usize {
self.set.matches(haystack).into_iter().count()
}
pub fn all_matches(&self, haystack: &str) -> Vec<&'static str> {
self.set
.matches(haystack)
.into_iter()
.map(|index| self.sources[index])
.collect()
}
pub fn len(&self) -> usize {
self.sources.len()
}
pub fn is_empty(&self) -> bool {
self.sources.is_empty()
}
}
static WRONG_SERVER_SOURCES: &[&str] = &[
r"tld\s+(?:is\s+)?not\s+supported",
r"extension\s+(?:is\s+)?not\s+supported",
r"unsupported\s+(?:tld|extension|domain)",
r"domain\s+(?:type|extension)\s+not\s+supported",
r"not\s+supported\s+by\s+this\s+whois\s+server",
r"no\s+whois\s+(?:server|service)\s+(?:is\s+)?(?:available|found|known)",
r"whois\s+(?:server\s+)?not\s+(?:known|available|found)",
r"(?:server|service)\s+not\s+(?:available|found)\s+for",
r"this\s+server\s+does\s+not\s+serve",
];
pub fn wrong_server() -> &'static PatternSet {
static SET: OnceLock<PatternSet> = OnceLock::new();
SET.get_or_init(|| PatternSet::new(WRONG_SERVER_SOURCES))
}
static RIR_BANNER_SOURCES: &[&str] = &[
r"this\s+is\s+the\s+ripe\s+database\s+query\s+service",
r"the\s+objects\s+are\s+in\s+rpsl\s+format",
r"\[whois\.apnic\.net\]",
r"apnic\s+whois\s+service",
r"american\s+registry\s+for\s+internet\s+numbers",
r"lacnic\s+whois\s+server",
r"afrinic\s+whois\s+server",
r"joint\s+whois\s+.*\s+arin",
r"we\s+do\s+not\s+have\s+an\s+entry\s+in\s+our\s+database\s+matching\s+your\s+query",
];
pub fn rir_banner() -> &'static PatternSet {
static SET: OnceLock<PatternSet> = OnceLock::new();
SET.get_or_init(|| PatternSet::new(RIR_BANNER_SOURCES))
}
pub const BANNER_LINES: usize = 8;
static RATE_LIMITED_SOURCES: &[&str] = &[
r"(?:request|quer(?:y|ies))\s*(?:s)?\s+limit\s+exceeded",
r"limit\s+exceeded",
r"maximum\s+quer(?:y|ies)\s+rate",
r"quer(?:y|ies)\s+rate\s+exceeded",
r"excessive\s+querying",
r"too\s+many\s+(?:quer(?:y|ies)|requests|connections)",
r"lookup\s+quota",
r"quota\s+exceeded",
r"rate\s+limit(?:ed|ing)?\s+(?:exceeded|applies|in\s+effect)?",
r"you\s+have\s+exceeded\s+(?:your|the)\s+(?:allowed|permitted)",
r"connection\s+refused\s+because\s+of\s+abuse",
];
static BLOCKED_SOURCES: &[&str] = &[
r"requests?\s+of\s+this\s+client\s+(?:are|is)\s+not\s+permitted",
r"access\s+to\s+this\s+whois\s+server\s+is\s+(?:denied|blocked)",
r"your\s+(?:ip|access|host)\s+(?:has\s+been\s+)?(?:blocked|banned|blacklisted)",
r"permission\s+denied",
];
static UNAVAILABLE_SOURCES: &[&str] = &[
r"server\s+(?:is\s+)?busy",
r"(?:please\s+)?try\s+again\s+later",
r"(?:service\s+)?temporarily\s+unavailable",
r"database\s+(?:is\s+)?(?:temporarily\s+)?unavailable",
r"internal\s+(?:server\s+)?error",
r"try\s+again\s+in\s+a\s+(?:few|couple)",
];
static PORT_RETIRED_SOURCES: &[&str] = &[
r"whois\s+(?:service|server)\s+(?:has\s+been\s+)?(?:retired|discontinued|decommissioned)",
r"(?:queries|service)\s+are\s+now\s+served\s+via\s+rdap",
r"rdap\s+base\s+url",
r"please\s+use\s+(?:our\s+)?rdap",
r"this\s+service\s+is\s+no\s+longer\s+available",
];
static ACCESS_RESTRICTED_SOURCES: &[&str] = &[
r"authentication\s+required",
r"please\s+use\s+(?:our\s+)?web(?:-|\s+)?(?:based\s+)?(?:form|interface|lookup)\s+(?:instead|to\s+query)",
r"captcha",
r"only\s+available\s+(?:to|for)\s+(?:registered|accredited)\s+(?:users|registrars)",
];
macro_rules! pattern_set {
($name:ident, $sources:ident) => {
pub fn $name() -> &'static PatternSet {
static SET: OnceLock<PatternSet> = OnceLock::new();
SET.get_or_init(|| PatternSet::new($sources))
}
};
}
pattern_set!(rate_limited, RATE_LIMITED_SOURCES);
pattern_set!(blocked, BLOCKED_SOURCES);
pattern_set!(unavailable, UNAVAILABLE_SOURCES);
pattern_set!(port_retired, PORT_RETIRED_SOURCES);
pattern_set!(access_restricted, ACCESS_RESTRICTED_SOURCES);
macro_rules! concat_field {
($key:literal) => {
concat!(r"(?m)^[ \t]*", $key, r"[\s._\-]*:")
};
}
static REGISTRATION_FIELD_SOURCES: &[&str] = &[
concat_field!("domain name"),
concat_field!("domain"),
concat_field!("ascii"),
concat_field!("registrar"),
concat_field!("sponsoring registrar"),
concat_field!("registrar whois server"),
concat_field!("registry domain id"),
concat_field!("registrant"),
concat_field!("registrant name"),
concat_field!("registrant organization"),
concat_field!("holder"),
concat_field!("nserver"),
concat_field!("nameserver"),
concat_field!("name server"),
concat_field!("dnssec"),
concat_field!("creation date"),
concat_field!("created"),
concat_field!("created on"),
concat_field!("registered on"),
concat_field!("registration date"),
concat_field!("changed"),
concat_field!("last updated"),
concat_field!("updated date"),
concat_field!("modified"),
concat_field!("expiry date"),
concat_field!("expires"),
concat_field!("expiration date"),
concat_field!("paid-till"),
concat_field!("admin contact"),
concat_field!("tech contact"),
concat_field!("technical contact"),
concat_field!("billing contact"),
concat_field!("abuse contact email"),
r"(?m)^[ \t]*(?:[a-z]\.\s*)?\[domain name\]",
r"(?m)^[ \t]*(?:[a-z]\.\s*)?\[registrant\]",
r"(?m)^[ \t]*(?:[a-z]\.\s*)?\[organization\]",
r"(?m)^[ \t]*(?:[a-z]\.\s*)?\[name server\]",
r"(?m)^[ \t]*(?:[a-z]\.\s*)?\[registered date\]",
r"(?m)^[ \t]*(?:[a-z]\.\s*)?\[expires on\]",
r"(?m)^[ \t]*(?:[a-z]\.\s*)?\[status\]",
];
pub fn registration_fields() -> &'static PatternSet {
static SET: OnceLock<PatternSet> = OnceLock::new();
SET.get_or_init(|| PatternSet::new(REGISTRATION_FIELD_SOURCES))
}
static REGISTERED_STATUS_SOURCES: &[&str] = &[
concat!(r"status", r"[\s._\-]*:", r"\s*registered"),
concat!(r"status", r"[\s._\-]*:", r"\s*active"),
concat!(r"status", r"[\s._\-]*:", r"\s*connect"),
concat!(r"status", r"[\s._\-]*:", r"\s*allocated"),
concat!(r"status", r"[\s._\-]*:", r"\s*assigned"),
concat!(r"status", r"[\s._\-]*:", r"\s*in\s+use"),
concat!(r"status", r"[\s._\-]*:", r"\s*ok\b"),
concat!(r"status", r"[\s._\-]*:", r"\s*client"),
concat!(r"status", r"[\s._\-]*:", r"\s*server"),
concat!(r"status", r"[\s._\-]*:", r"\s*redemption"),
concat!(
r"status",
r"[\s._\-]*:",
r"\s*pending\s*(?:delete|transfer|update)"
),
concat!(r"status", r"[\s._\-]*:", r"\s*inactive"),
concat!(r"status", r"[\s._\-]*:", r"\s*suspended"),
concat!(r"status", r"[\s._\-]*:", r"\s*expired"),
concat!(r"status", r"[\s._\-]*:", r"\s*quarantine"),
concat!(
r"registration\s+status",
r"[\s._\-]*:",
r"\s*(?:registered|active)"
),
concat!(r"status", r"[\s._\-]*:", r"\s*invalid"),
r"client(?:transfer|update|delete|hold|renew)prohibited",
r"server(?:transfer|update|delete|hold|renew)prohibited",
r"redemptionperiod",
r"pendingdelete",
r"autorenewperiod",
r"addperiod",
r"transferperiod",
r"renewperiod",
];
pub fn registered_status() -> &'static PatternSet {
static SET: OnceLock<PatternSet> = OnceLock::new();
SET.get_or_init(|| PatternSet::new(REGISTERED_STATUS_SOURCES))
}
static REGISTERED_PHRASE_SOURCES: &[&str] = &[
r"this\s+domain\s+(?:name\s+)?(?:has\s+been|is)\s+registered",
r"domain\s+(?:is\s+)?already\s+registered",
r"(?:is|has\s+been)\s+currently\s+registered",
r"the\s+domain\s+is\s+registered",
r"domain\s+registered",
r"not\s+available\s+for\s+registration",
r"domain\s+(?:is\s+)?not\s+available",
concat!(
r"status",
r"[\s._\-]*:",
r"\s*(?:not\s+available|unavailable|taken)"
),
concat!(
r"domain\s+status",
r"[\s._\-]*:",
r"\s*(?:not\s+available|unavailable)"
),
r"---not\s+available",
];
pub fn registered_phrase() -> &'static PatternSet {
static SET: OnceLock<PatternSet> = OnceLock::new();
SET.get_or_init(|| PatternSet::new(REGISTERED_PHRASE_SOURCES))
}
static RESERVED_SOURCES: &[&str] = &[
r"\busage\s+restrictions?\s+(?:applied|apply)\b",
r"\b(?:name|domain)\s+is\s+reserved\b",
r"\breserved\s+(?:domain|name)\b",
r"\breserved\s+by\s+(?:the\s+)?registry\b",
r"\bregistry[\s-]reserved\b",
r"\b(?:domain|name)\s+is\s+(?:blocked|prohibited|restricted)\b",
r"\b(?:blocked|prohibited|restricted)\s+by\s+(?:the\s+)?registry\b",
r"\bnot\s+allowed\s+to\s+be\s+registered\b",
r"\bregistration\s+(?:is\s+)?not\s+permitted\b",
];
pub fn reserved() -> &'static PatternSet {
static SET: OnceLock<PatternSet> = OnceLock::new();
SET.get_or_init(|| PatternSet::new(RESERVED_SOURCES))
}
static PREMIUM_SOURCES: &[&str] = &[
r"\bpremium\s+(?:domain|name)\b",
r"\bis\s+a\s+premium\b",
concat!(r"\bstatus", r"[\s._\-]*:", r"\s*premium\b"),
r"\bpremium\s+pricing\b",
];
pub fn premium() -> &'static PatternSet {
static SET: OnceLock<PatternSet> = OnceLock::new();
SET.get_or_init(|| PatternSet::new(PREMIUM_SOURCES))
}
static NOT_FOUND_SOURCES: &[&str] = &[
r"no\s+match\s+for",
r"no\s+match!*\s*$",
r"\bno\s+match\b",
r"not\s+found",
r"no\s+data\s+found",
r"no\s+entries\s+found",
r"no\s+matching\s+record",
r"no\s+object\s+found",
r"nothing\s+found",
r"object\s+does\s+not\s+exist",
r"the\s+queried\s+object\s+does\s+not\s+exist",
r"no\s+such\s+domain",
r"domain\s+(?:name\s+)?not\s+found",
r"domain\s+(?:name\s+)?not\s+known",
r"domain\s+(?:name\s+)?(?:does\s+not|doesn't)\s+exist",
r"(?:does\s+not|doesn't)\s+exist\s+in\s+database",
r"was\s+not\s+found",
r"(?:domain|name)\s+(?:has\s+)?not\s+been\s+registered",
r"domain\s+is\s+available",
r"is\s+available\s+for\s+(?:registration|purchase)",
r"available\s+for\s+registration",
concat!(r"status", r"[\s._\-]*:", r"\s*available"),
concat!(r"status", r"[\s._\-]*:", r"\s*free\b"),
concat!(r"registration\s+status", r"[\s._\-]*:", r"\s*available"),
concat!(r"domain\s+status", r"[\s._\-]*:", r"\s*(?:available|free)"),
concat!(r"availability", r"[\s._\-]*:", r"\s*available"),
concat!(r"state", r"[\s._\-]*:", r"\s*available"),
r"\bis\s+free\b",
r"\bcan\s+be\s+registered\b",
r"(?m)^\s*available\s*$",
r"status\s*=\s*available",
r"%error:103",
r"%error:101\s+no\s+entries\s+found",
r"---available",
r"---not\s+found",
r"---domain\s+not\s+found",
r"no\s+se\s+encontro\s+el\s+objeto",
r"no_se_encontro_el_objeto",
r"object_not_found",
r"el\s+dominio\s+no\s+se\s+encuentra\s+registrado",
r"no\s+está\s+registrado",
r"dominio\s+no\s+registrado",
r"aucun\s+objet\s+trouvé",
r"nicht\s+registriert",
r"frei\b",
];
pub fn not_found() -> &'static PatternSet {
static SET: OnceLock<PatternSet> = OnceLock::new();
SET.get_or_init(|| PatternSet::new(NOT_FOUND_SOURCES))
}
static COMMENT_ANSWER_SOURCES: &[&str] = &[
r"^not\s+found\b",
r"^no\s+entries\s+found\b",
r"^error:\s*101:\s*no\s+entries\s+found",
r"^nothing\s+found\b",
r"^no\s+object\s+found\b",
r"^no\s+match\b",
r"^no\s+data\s+found\b",
r"^object\s+does\s+not\s+exist\b",
r"^domain\s+(?:name\s+)?not\s+found\b",
];
pub fn comment_answer() -> &'static PatternSet {
static SET: OnceLock<PatternSet> = OnceLock::new();
SET.get_or_init(|| PatternSet::new(COMMENT_ANSWER_SOURCES))
}
static ERROR_NOTICE_SOURCES: &[&str] = &[
concat!(r"error\s*code", r"[\s._\-]*:"),
concat!(r"error\s*message", r"[\s._\-]*:"),
r"\berror\b",
r"invalid\s+(?:query|request|input|domain|syntax)",
r"malformed",
r"access\s+denied",
r"not\s+authori[sz]ed",
r"unauthori[sz]ed",
r"please\s+contact",
r"please\s+see\s+your\s+registrar",
];
pub fn error_notice() -> &'static PatternSet {
static SET: OnceLock<PatternSet> = OnceLock::new();
SET.get_or_init(|| PatternSet::new(ERROR_NOTICE_SOURCES))
}
static TLD_NOT_FOUND: &[(&str, &[&str])] = &[
("jp", &[r"no\s+match!!"]),
("co.jp", &[r"no\s+match!!"]),
("tw", &[r"no\s+found"]),
("th", &[r"no\s+match\s+found"]),
(
"ch",
&[r"^\s*---\s*1:", r"we\s+do\s+not\s+have\s+an\s+entry"],
),
("li", &[r"we\s+do\s+not\s+have\s+an\s+entry"]),
(
"pl",
&[r"no\s+information\s+available\s+about\s+domain\s+name"],
),
("zw", &[r"no\s+information\s+available"]),
("gr", &[r"not\s+exist"]),
("ir", &[r"no\s+entries\s+found"]),
(
"uk",
&[r"this\s+domain\s+name\s+has\s+not\s+been\s+registered"],
),
(
"co.uk",
&[r"this\s+domain\s+name\s+has\s+not\s+been\s+registered"],
),
("hk", &[r"the\s+domain\s+has\s+not\s+been\s+registered"]),
("my", &[r"does\s+not\s+exist\s+in\s+database"]),
("ws", &[r"the\s+queried\s+object\s+does\s+not\s+exist"]),
("de", &[concat!(r"status", r"[\s._\-]*:", r"\s*free")]),
];
static TLD_REGISTERED: &[(&str, &[&str])] = &[
("uk", &[r"registered\s+on:", r"registrar:"]),
("co.uk", &[r"registered\s+on:", r"registrar:"]),
("de", &[concat!(r"status", r"[\s._\-]*:", r"\s*connect")]),
("au", &[r"---not\s+available"]),
("com.au", &[r"---not\s+available"]),
(
"nl",
&[concat!(r"status", r"[\s._\-]*:", r"\s*(?:active|in\s+use)")],
),
(
"be",
&[concat!(
r"status",
r"[\s._\-]*:",
r"\s*(?:registered|allocated)"
)],
),
("ca", &[r"domain\s+status:\s*registered"]),
];
fn tld_table(
table: &'static [(&'static str, &'static [&'static str])],
) -> HashMap<&'static str, PatternSet> {
table
.iter()
.map(|(tld, sources)| (*tld, PatternSet::new(sources)))
.collect()
}
pub fn tld_not_found(tld: &str) -> Option<&'static PatternSet> {
static TABLE: OnceLock<HashMap<&'static str, PatternSet>> = OnceLock::new();
TABLE.get_or_init(|| tld_table(TLD_NOT_FOUND)).get(tld)
}
pub fn tld_registered(tld: &str) -> Option<&'static PatternSet> {
static TABLE: OnceLock<HashMap<&'static str, PatternSet>> = OnceLock::new();
TABLE.get_or_init(|| tld_table(TLD_REGISTERED)).get(tld)
}
pub fn separator() -> &'static str {
SEP
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_table_compiles() {
let tables: [(&str, &PatternSet); 12] = [
("wrong_server", wrong_server()),
("rate_limited", rate_limited()),
("blocked", blocked()),
("unavailable", unavailable()),
("port_retired", port_retired()),
("access_restricted", access_restricted()),
("registration_fields", registration_fields()),
("registered_status", registered_status()),
("registered_phrase", registered_phrase()),
("reserved", reserved()),
("premium", premium()),
("not_found", not_found()),
];
for (name, table) in tables {
assert!(!table.is_empty(), "{name} is empty");
}
assert!(!error_notice().is_empty());
for (tld, _) in TLD_NOT_FOUND {
assert!(tld_not_found(tld).is_some(), "{tld} missing");
}
for (tld, _) in TLD_REGISTERED {
assert!(tld_registered(tld).is_some(), "{tld} missing");
}
}
#[test]
fn every_pattern_is_written_in_lower_case() {
let has_upper = |source: &str| source.chars().any(|c| c.is_ascii_uppercase());
for source in NOT_FOUND_SOURCES
.iter()
.chain(REGISTERED_STATUS_SOURCES)
.chain(REGISTERED_PHRASE_SOURCES)
.chain(WRONG_SERVER_SOURCES)
.chain(RESERVED_SOURCES)
{
assert!(
!has_upper(source),
"{source:?} contains an upper-case letter"
);
}
}
#[test]
fn field_patterns_are_anchored_to_a_line_start() {
let fields = registration_fields();
assert!(fields.matches("domain name: example.com\n"));
assert!(fields.matches(" registrar: example llc\n"));
assert!(
!fields.matches("the registrar is responsible for this data"),
"prose was counted as a field"
);
assert!(!fields.matches("contact your registrar: they can help"));
}
#[test]
fn field_patterns_tolerate_padded_keys() {
assert!(registration_fields().matches("nserver...........: ns1.example\n"));
assert!(registration_fields().matches("created__________: 2001-01-01\n"));
}
#[test]
fn field_patterns_accept_a_value_on_the_next_line() {
let record =
" domain name:\n example.co.uk\n\n registrar:\n example ltd\n";
assert_eq!(registration_fields().match_count(record), 2);
}
#[test]
fn bracketed_keys_are_fields_too() {
let record = "\
a. [domain name] example.jp
g. [organization] example organisation
[registered date] 2001/01/01
";
assert!(
registration_fields().match_count(record) >= 3,
"matched {:?}",
registration_fields().all_matches(record)
);
}
#[test]
fn field_counting_reports_distinct_keys() {
let record = "domain name: a\nregistrar: b\nname server: ns1\nname server: ns2\n";
assert_eq!(registration_fields().match_count(record), 3);
}
#[test]
fn not_found_matches_the_common_wordings() {
let table = not_found();
for response in [
"no match for \"example.com\"",
"domain not found",
"no entries found",
"status: available",
"status: free",
"the queried object does not exist",
"domain example.com is available for registration",
"el dominio no se encuentra registrado",
] {
assert!(table.matches(response), "missed {response:?}");
}
}
#[test]
fn not_found_ignores_the_words_that_caused_false_positives() {
let table = not_found();
assert!(
!table.matches("this whois information is provided for free by nominet"),
"a disclaimer was read as availability"
);
assert!(!table.matches("registrant address: 404 example street"));
}
#[test]
fn not_available_is_never_read_as_available() {
assert!(registered_phrase().matches("status: not available"));
assert!(registered_phrase().matches("domain is not available"));
}
#[test]
fn registered_statuses_cover_epp_and_registry_dialects() {
let table = registered_status();
for response in [
"status: registered",
"status: active",
"status: connect",
"domain status: clienttransferprohibited",
"status: redemptionperiod",
"status: invalid",
"status..............: registered",
] {
assert!(table.matches(response), "missed {response:?}");
}
}
#[test]
fn an_epp_status_is_not_a_registry_reservation() {
let record = "domain status: clienttransferprohibited name server: ns1.example.com";
assert!(
!reserved().matches(record),
"an EPP status was read as a reservation: {:?}",
reserved().first_match(record)
);
assert!(!premium().matches(record));
assert!(reserved().matches("this domain name has usage restrictions applied"));
assert!(reserved().matches("the name is reserved by the registry"));
}
#[test]
fn rir_banners_are_recognised_as_the_wrong_server() {
let table = rir_banner();
assert!(table.matches("this is the ripe database query service"));
assert!(table.matches("% [whois.apnic.net]"));
assert!(table.matches("american registry for internet numbers"));
assert!(!table.matches(" - arin whois(whois.arin.net)"));
}
#[test]
fn wrong_server_covers_only_the_explicit_refusals() {
let table = wrong_server();
assert!(table.matches("this tld is not supported"));
assert!(table.matches("no whois server is known for that extension"));
assert!(!table.matches("this is the ripe database query service"));
}
#[test]
fn comment_answers_are_anchored() {
let table = comment_answer();
assert!(table.matches("not found"));
assert!(table.matches("error: 101: no entries found"));
assert!(table.matches("nothing found"));
assert!(!table.matches("available on web at http://whois.example-registry.example/"));
assert!(!table.matches(
"find the terms and conditions of use on http://www.example-registry.example/"
));
assert!(!table.matches("if the domain is not found, this server says so"));
}
#[test]
fn the_new_availability_wordings_match_and_stay_narrow() {
let table = not_found();
assert!(table.matches("1: this domain name can be registered."));
assert!(table.matches("available"), "auDA answers with exactly this");
assert!(table.matches(" available \n"));
assert!(!table.matches("this name is not available"));
}
#[test]
fn refusals_are_told_apart() {
assert!(rate_limited().matches("%% queries limit exceeded"));
assert!(rate_limited().matches("your query rate exceeded the limit"));
assert!(blocked().matches("requests of this client are not permitted"));
assert!(unavailable().matches("server is busy, please try again later"));
assert!(port_retired().matches("the whois service has been retired"));
assert!(port_retired().matches("please use our rdap service"));
}
#[test]
fn first_match_reports_which_pattern_fired() {
let matched = not_found()
.first_match("no match for \"example.com\"")
.unwrap();
assert!(matched.contains("no"), "got {matched:?}");
assert!(not_found()
.first_match("domain name: example.com")
.is_none());
}
#[test]
fn per_tld_tables_are_reachable() {
assert!(tld_not_found("jp").unwrap().matches("no match!!"));
assert!(tld_registered("de").unwrap().matches("status: connect"));
assert!(tld_not_found("nonexistent").is_none());
}
}