use std::net::SocketAddr;
use std::time::Duration;
use crate::lookup::resolve::Resolvers;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use crate::limit::{Pacer, Refusal};
const IANA_HOST: &str = "whois.iana.org";
const TEXT_PORT: u16 = 43;
const MAX_REFERRAL_BYTES: u64 = 128 * 1024;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Delegation {
pub text_host: Option<String>,
pub operator: Option<String>,
pub registration_url: Option<String>,
}
impl Delegation {
#[must_use]
pub fn is_empty(&self) -> bool {
self.text_host.is_none() && self.operator.is_none() && self.registration_url.is_none()
}
}
#[must_use]
pub(crate) fn parse(raw: &str) -> Delegation {
let mut delegation = Delegation::default();
let mut has_operator = false;
for line in raw.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('%') {
continue;
}
let Some((key, value)) = line.split_once(':') else {
continue;
};
let value = value.trim();
if value.is_empty() {
continue;
}
match key.trim().to_lowercase().as_str() {
"whois" => {
delegation.text_host = Some(crate::lookup::scrub(&value.to_lowercase()));
}
"organisation" | "organization" => {
if !has_operator {
has_operator = true;
delegation.operator = Some(crate::lookup::scrub(value));
}
}
"remarks" if delegation.registration_url.is_none() => {
delegation.registration_url = registration_url(value);
}
_ => {}
}
}
delegation
}
fn registration_url(remark: &str) -> Option<String> {
if !remark.to_lowercase().contains("registration information") {
return None;
}
remark
.split_whitespace()
.find(|token| token.starts_with("https://") || token.starts_with("http://"))
.map(|token| crate::lookup::scrub(token.trim_end_matches(['.', ','])))
}
pub(crate) async fn query(
resolver: &Resolvers,
pacer: &Pacer,
suffix: &str,
timeout: Duration,
) -> Delegation {
let Some(label) = delegated_label(suffix) else {
return Delegation::default();
};
let Ok(permit) = pacer.acquire_patiently(IANA_HOST, timeout).await else {
return Delegation::default();
};
let answer = ask(resolver, &label, timeout).await;
drop(permit);
match answer {
Some(raw) => {
pacer.record_success(IANA_HOST).await;
parse(&raw)
}
None => {
pacer.record_refusal(IANA_HOST, &Refusal::Dropped).await;
Delegation::default()
}
}
}
fn delegated_label(suffix: &str) -> Option<String> {
let trimmed = suffix.trim().trim_matches('.');
let label = trimmed.rsplit('.').next()?.trim();
if label.is_empty() {
None
} else {
Some(label.to_lowercase())
}
}
async fn ask(resolver: &Resolvers, label: &str, timeout: Duration) -> Option<String> {
let deadline = tokio::time::Instant::now()
.checked_add(timeout)
.unwrap_or_else(|| tokio::time::Instant::now() + Duration::from_secs(3600));
let mut stream = connect(resolver, deadline).await?;
let request = format!("{label}\r\n");
tokio::time::timeout_at(deadline, stream.write_all(request.as_bytes()))
.await
.ok()?
.ok()?;
let _ = stream.flush().await;
let mut buffer = Vec::new();
tokio::time::timeout_at(
deadline,
(&mut stream)
.take(MAX_REFERRAL_BYTES)
.read_to_end(&mut buffer),
)
.await
.ok()?
.ok()?;
Some(String::from_utf8_lossy(&buffer).replace("\r\n", "\n"))
}
async fn connect(resolver: &Resolvers, deadline: tokio::time::Instant) -> Option<TcpStream> {
let addresses = tokio::time::timeout_at(deadline, resolver.lookup_ip(IANA_HOST))
.await
.ok()?
.ok()?;
for ip in addresses.iter() {
let address = SocketAddr::new(ip, TEXT_PORT);
if let Ok(Ok(stream)) = tokio::time::timeout_at(deadline, TcpStream::connect(address)).await
{
return Some(stream);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
const BANGLADESH: &str = "\
% IANA WHOIS server
domain: BD
organisation: Posts and Telecommunications Division
organisation: Bangladesh Telecommunications Company Limited (BTCL)
whois:
status: ACTIVE
";
const SOUTH_AFRICA: &str = "\
% IANA WHOIS server
% for more information on IANA, visit http://www.iana.org
% This query returned 1 object
domain: ZA
organisation: ZA Domain Name Authority
address: COZA House, Gazelle Close
address: South Africa
contact: administrative
name: The Managing Director
organisation: ZA Central Registry
contact: technical
name: The Technical Manager
organisation: UniForum SA
whois: Whois.NIC.ZA
status: ACTIVE
remarks: Registration information: http://www.zadna.org.za/.
created: 1990-11-05
source: IANA
";
#[test]
fn a_real_record_gives_the_server_the_registry_and_the_registration_page() {
let delegation = parse(SOUTH_AFRICA);
assert_eq!(delegation.text_host.as_deref(), Some("whois.nic.za"));
assert_eq!(
delegation.operator.as_deref(),
Some("ZA Domain Name Authority")
);
assert_eq!(
delegation.registration_url.as_deref(),
Some("http://www.zadna.org.za/")
);
assert!(!delegation.is_empty());
}
#[test]
fn the_first_organisation_wins_over_the_contacts_listed_after_it() {
let delegation = parse(BANGLADESH);
assert_eq!(
delegation.operator.as_deref(),
Some("Posts and Telecommunications Division")
);
}
#[test]
fn a_later_organisation_never_overwrites_the_registry() {
let delegation = parse(SOUTH_AFRICA);
assert_eq!(
delegation.operator.as_deref(),
Some("ZA Domain Name Authority")
);
}
#[test]
fn an_empty_whois_value_means_no_server_rather_than_a_blank_one() {
let delegation = parse(BANGLADESH);
assert_eq!(delegation.text_host, None);
assert_eq!(delegation.registration_url, None);
assert!(!delegation.is_empty());
}
#[test]
fn the_server_is_lowercased_however_the_record_writes_it() {
let delegation = parse("whois: WHOIS.NIC.Example\n");
assert_eq!(delegation.text_host.as_deref(), Some("whois.nic.example"));
}
#[test]
fn a_registration_information_remark_yields_the_url_without_its_trailing_dot() {
let delegation =
parse("remarks: Registration information: https://nic.example/register.\n");
assert_eq!(
delegation.registration_url.as_deref(),
Some("https://nic.example/register")
);
}
#[test]
fn a_registration_url_loses_a_trailing_comma_too() {
let delegation =
parse("remarks: Registration information: https://nic.example/register, or by post\n");
assert_eq!(
delegation.registration_url.as_deref(),
Some("https://nic.example/register")
);
}
#[test]
fn the_phrase_is_matched_whatever_its_case() {
let delegation = parse("remarks: REGISTRATION INFORMATION: https://nic.example/go\n");
assert_eq!(
delegation.registration_url.as_deref(),
Some("https://nic.example/go")
);
}
#[test]
fn a_remark_that_is_not_about_registration_is_ignored() {
let delegation = parse("remarks: Sponsoring organisation moved, see https://old.example\n");
assert_eq!(delegation.registration_url, None);
}
#[test]
fn a_registration_remark_with_no_link_yields_nothing() {
let delegation = parse("remarks: Registration information: contact the registry by post\n");
assert_eq!(delegation.registration_url, None);
}
#[test]
fn comment_lines_are_skipped_even_when_they_look_like_fields() {
let delegation = parse(
"% whois: whois.wrong.example\n% organisation: The Comment\nwhois: whois.right.example\n",
);
assert_eq!(delegation.text_host.as_deref(), Some("whois.right.example"));
assert_eq!(delegation.operator, None);
}
#[test]
fn a_line_with_no_colon_is_skipped() {
let delegation = parse("this line has no separator at all\nwhois: whois.nic.example\n");
assert_eq!(delegation.text_host.as_deref(), Some("whois.nic.example"));
}
#[test]
fn a_record_with_nothing_useful_gives_an_empty_delegation() {
let delegation = parse("% no object found\n\nstatus: ACTIVE\ncreated: 1990-11-05\n");
assert_eq!(delegation, Delegation::default());
assert!(delegation.is_empty());
}
#[test]
fn an_empty_record_gives_an_empty_delegation() {
assert!(parse("").is_empty());
}
#[test]
fn keys_are_read_whatever_their_case() {
let delegation = parse(
"WHOIS: whois.nic.example\nOrganisation: The Registry\nREMARKS: Registration information: https://nic.example\n",
);
assert_eq!(delegation.text_host.as_deref(), Some("whois.nic.example"));
assert_eq!(delegation.operator.as_deref(), Some("The Registry"));
assert_eq!(
delegation.registration_url.as_deref(),
Some("https://nic.example")
);
}
#[test]
fn the_american_spelling_of_organisation_is_accepted() {
let delegation = parse("organization: The Registry\n");
assert_eq!(delegation.operator.as_deref(), Some("The Registry"));
}
#[test]
fn only_the_final_label_is_asked_of_the_root() {
assert_eq!(delegated_label("co.za").as_deref(), Some("za"));
assert_eq!(delegated_label("com.bd").as_deref(), Some("bd"));
assert_eq!(delegated_label("bd").as_deref(), Some("bd"));
assert_eq!(delegated_label(".COM.BD.").as_deref(), Some("bd"));
assert_eq!(delegated_label(""), None);
assert_eq!(delegated_label("."), None);
}
#[test]
fn a_hostile_referral_carries_no_control_bytes_and_no_bare_http_token() {
let raw = "whois: whois.\u{1b}[2Kexample\n\
organisation: Evil\u{1b}[2J Registry\n\
remarks: Registration information: httpfoo://not-a-url\n";
let parsed = parse(raw);
assert!(!parsed.text_host.unwrap_or_default().contains('\u{1b}'));
assert!(!parsed.operator.unwrap_or_default().contains('\u{1b}'));
assert_eq!(
parsed.registration_url, None,
"a token merely starting with http is not a URL"
);
}
}