whoizz 0.1.0

Tiny, dependency-light RFC 3912 whois client with best-effort field extraction
Documentation
//! whoizz — tiny RFC 3912 whois client with best-effort field extraction.
//!
//! Supports both **domain whois** (via TLD registries) and **IP whois**
//! (via the Regional Internet Registries — ARIN, RIPE, APNIC, LACNIC,
//! AFRINIC). Both flows use the same IANA-referral mechanism: query
//! `whois.iana.org`, read the `refer:` line, then query the referred
//! server for the actual record.
//!
//! ## Domain lookup
//!
//! ```no_run
//! let r = whoizz::lookup("rust-lang.org")?;
//! println!("registrar:   {:?}", r.registrar);
//! println!("created:     {:?}", r.created);
//! println!("expires:     {:?}", r.expires);
//! println!("nameservers: {:?}", r.nameservers);
//! # Ok::<(), whoizz::WhoisError>(())
//! ```
//!
//! ## IP lookup
//!
//! ```no_run
//! use std::net::IpAddr;
//! let ip: IpAddr = "8.8.8.8".parse().unwrap();
//! let r = whoizz::lookup_ip(ip)?;
//! println!("server: {}", r.server);  // e.g. "whois.arin.net"
//! println!("--- raw ---\n{}", r.raw);
//! # Ok::<(), whoizz::WhoisError>(())
//! ```
//!
//! See the crate [README](https://bleaksec.dev/libraries/rust/whoizz)
//! for limitations and details.

use std::net::IpAddr;
use std::time::Duration;

mod client;
mod error;
mod parse;
mod referral;

pub use error::WhoisError;

/// Default connect/read/write timeout for whois queries.
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);

/// Parsed result of a whois lookup.
///
/// [`raw`](Self::raw) always contains the complete server response.
/// The other fields are populated on a best-effort basis from the most
/// common whois-response patterns.
#[derive(Debug, Clone)]
pub struct WhoisResponse {
    /// The authoritative whois server that produced this response.
    pub server: String,
    /// The raw response body.
    pub raw: String,
    /// Registrar name, if the server exposed one.
    pub registrar: Option<String>,
    /// Domain creation / registration timestamp (as string, unparsed).
    pub created: Option<String>,
    /// Domain expiry timestamp (as string, unparsed).
    pub expires: Option<String>,
    /// Authoritative name servers listed in the response, lowercased.
    pub nameservers: Vec<String>,
}

/// Look up whois information for a domain with the default timeout.
///
/// This performs two network calls: one to IANA to discover the
/// authoritative server for the domain's TLD, and one to the referred
/// server to fetch the actual record.
pub fn lookup(domain: &str) -> Result<WhoisResponse, WhoisError> {
    lookup_with_timeout(domain, DEFAULT_TIMEOUT)
}

/// Like [`lookup`] with a caller-specified timeout.
pub fn lookup_with_timeout(
    domain: &str,
    timeout: Duration,
) -> Result<WhoisResponse, WhoisError> {
    let domain = normalize_domain(domain)?;
    let tld = tld_of(&domain).ok_or_else(|| WhoisError::NoTld(domain.clone()))?;

    let server = referral::lookup_server(tld, timeout)?;
    finalize(server, &domain, timeout)
}

/// Look up whois information for an IP address.
///
/// IANA's whois server handles IP queries by referring to the
/// appropriate Regional Internet Registry (RIR) — typically ARIN,
/// RIPE, APNIC, LACNIC, or AFRINIC depending on which RIR administers
/// the block.
///
/// Private / reserved ranges (RFC 1918, CGNAT, link-local, loopback,
/// documentation prefixes) are still accepted, but the RIR response
/// will usually indicate the reservation rather than registration
/// details. Filter them client-side if you only care about public IPs.
pub fn lookup_ip(ip: IpAddr) -> Result<WhoisResponse, WhoisError> {
    lookup_ip_with_timeout(ip, DEFAULT_TIMEOUT)
}

/// Like [`lookup_ip`] with a caller-specified timeout.
pub fn lookup_ip_with_timeout(
    ip: IpAddr,
    timeout: Duration,
) -> Result<WhoisResponse, WhoisError> {
    let query = ip.to_string();
    let server = referral::lookup_server(&query, timeout)?;
    finalize(server, &query, timeout)
}

/// Fetch the actual record from the referred server and parse fields.
/// Shared tail of the domain and IP lookup flows.
fn finalize(
    server: String,
    target_query: &str,
    timeout: Duration,
) -> Result<WhoisResponse, WhoisError> {
    let raw = client::query(&server, client::WHOIS_PORT, target_query, timeout)?;
    let fields = parse::extract(&raw);

    Ok(WhoisResponse {
        server,
        raw,
        registrar: fields.registrar,
        created: fields.created,
        expires: fields.expires,
        nameservers: fields.nameservers,
    })
}

fn normalize_domain(input: &str) -> Result<String, WhoisError> {
    let s = input.trim().to_ascii_lowercase();
    let s = s.strip_prefix("www.").unwrap_or(&s).to_string();
    if s.is_empty() {
        return Err(WhoisError::EmptyDomain);
    }
    Ok(s)
}

fn tld_of(domain: &str) -> Option<&str> {
    domain.rsplit('.').next().filter(|t| !t.is_empty())
}

#[cfg(test)]
mod tests {
    use super::{normalize_domain, tld_of};

    #[test]
    fn strips_www_and_lowercases() {
        assert_eq!(normalize_domain("  WWW.Rust-Lang.ORG ").unwrap(), "rust-lang.org");
    }

    #[test]
    fn rejects_empty() {
        assert!(normalize_domain("   ").is_err());
    }

    #[test]
    fn extracts_tld() {
        assert_eq!(tld_of("rust-lang.org"), Some("org"));
        assert_eq!(tld_of("bbc.co.uk"), Some("uk"));
    }
}