reserve-core 0.1.0

Core lookup, catalog, and rate-limiting engine behind the reserve domain finder
Documentation
//! Asking IANA who runs an extension and which text server answers for it today, repairing a built-in host gone stale.

use std::net::SocketAddr;
use std::time::Duration;

use hickory_resolver::TokioResolver;
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;

/// @docgen A hostile or looping server could otherwise exhaust memory; a real root record is only a few kilobytes.
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()
    }
}

/// @docgen IANA repeats `organisation` for each contact, so only the first line is the registry itself.
#[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(value.to_lowercase()),
            "organisation" | "organization" => {
                if !has_operator {
                    has_operator = true;
                    delegation.operator = Some(value.to_owned());
                }
            }
            "remarks" if delegation.registration_url.is_none() => {
                delegation.registration_url = registration_url(value);
            }
            _ => {}
        }
    }

    delegation
}

/// @docgen IANA writes this as free text, so the only dependable shape is the phrase followed by a link on the same line.
fn registration_url(remark: &str) -> Option<String> {
    if !remark.to_lowercase().contains("registration information") {
        return None;
    }
    remark
        .split_whitespace()
        .find(|token| token.starts_with("http"))
        .map(|token| token.trim_end_matches(['.', ',']).to_owned())
}

pub(crate) async fn query(
    resolver: &TokioResolver,
    pacer: &Pacer,
    suffix: &str,
    timeout: Duration,
) -> Delegation {
    let Some(label) = delegated_label(suffix) else {
        return Delegation::default();
    };

    let Ok(lease) = pacer.acquire_patiently(IANA_HOST, timeout).await else {
        return Delegation::default();
    };

    let answer = ask(resolver, &label, timeout).await;
    drop(lease);

    match answer {
        Some(raw) => {
            pacer.record_success(IANA_HOST).await;
            parse(&raw)
        }
        None => {
            // @docgen A missing referral costs detail, not the run, so the caller gets an empty answer rather than an error.
            pacer.record_refusal(IANA_HOST, &Refusal::Dropped).await;
            Delegation::default()
        }
    }
}

/// @docgen The root zone stops at one label, so `co.za` and `com.bd` must be asked of IANA as `za` and `bd`.
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: &TokioResolver, label: &str, timeout: Duration) -> Option<String> {
    // @docgen Adding an unbounded caller-supplied timeout to an Instant panics on overflow.
    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"))
}

/// @docgen The platform's host-and-port connect is unusable on some targets, so every address is tried by hand inside one deadline.
async fn connect(resolver: &TokioResolver, 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);
    }
}