monovm-whois-rust 1.0.0

Domain WHOIS and RDAP lookups with availability detection, structured record parsing, referral chasing, caching and rate limiting.
Documentation
//! `monovm-whois` — check domain availability and fetch WHOIS records.
//!
//! ```text
//! monovm-whois example.com                 # availability
//! monovm-whois monovm --tlds com,net,ir    # a bare name across several suffixes
//! monovm-whois example.com --record        # the parsed registration
//! monovm-whois example.com --raw           # the server's own text
//! monovm-whois example.com --explain       # why the verdict is what it is
//! monovm-whois example.com --json          # machine-readable
//! ```

use std::io::{self, Write};
use std::process::ExitCode;
use std::time::Duration;

use clap::{ArgAction, Parser, ValueEnum};

use monovm_whois::client::Preference;
use monovm_whois::{Checker, Error, ReferralPolicy, WhoisClient};

/// Check domain availability and fetch WHOIS/RDAP records.
#[derive(Debug, Parser)]
#[command(
    name = "monovm-whois",
    version,
    about,
    long_about = None,
    after_help = "A domain whose lookup could not be answered exits non-zero. \
                  That is deliberate: an unanswered query is not the same as a free domain."
)]
struct Cli {
    /// Domains to check. A name with no suffix is checked under --tlds.
    #[arg(required = true, value_name = "DOMAIN")]
    domains: Vec<String>,

    /// Suffixes to try for a name given without one.
    #[arg(short, long, value_delimiter = ',', default_value = "com,net,org,info")]
    tlds: Vec<String>,

    /// Print the parsed registration record.
    #[arg(short, long, action = ArgAction::SetTrue)]
    record: bool,

    /// Print the server's response verbatim.
    #[arg(long, action = ArgAction::SetTrue)]
    raw: bool,

    /// Print what every detection rule made of the response.
    #[arg(short, long, action = ArgAction::SetTrue)]
    explain: bool,

    /// Print results as JSON.
    #[arg(long, action = ArgAction::SetTrue)]
    json: bool,

    /// Which protocol to try first.
    #[arg(long, value_enum, default_value_t = Protocol::Whois)]
    protocol: Protocol,

    /// Seconds to wait for a connection.
    #[arg(long, value_name = "SECONDS", default_value_t = 5.0)]
    connect_timeout: f64,

    /// Seconds to wait for a response.
    #[arg(long, value_name = "SECONDS", default_value_t = 10.0)]
    read_timeout: f64,

    /// Minimum seconds between queries to the same server.
    #[arg(long, value_name = "SECONDS", default_value_t = 1.0)]
    throttle: f64,

    /// Attempts per endpoint, including the first.
    #[arg(long, value_name = "N", default_value_t = 3)]
    attempts: u32,

    /// Do not follow a thin registry's referral to the registrar.
    #[arg(long, action = ArgAction::SetTrue)]
    no_referrals: bool,

    /// List every suffix this build can look up, and exit.
    #[arg(long, action = ArgAction::SetTrue)]
    list_tlds: bool,
}

#[derive(Debug, Clone, Copy, ValueEnum)]
enum Protocol {
    /// Port 43 first, RDAP as a fallback.
    Whois,
    /// RDAP first, port 43 as a fallback.
    Rdap,
    /// Port 43 only.
    WhoisOnly,
    /// RDAP only.
    RdapOnly,
}

impl From<Protocol> for Preference {
    fn from(protocol: Protocol) -> Self {
        match protocol {
            Protocol::Whois => Preference::Whois,
            Protocol::Rdap => Preference::Rdap,
            Protocol::WhoisOnly => Preference::WhoisOnly,
            Protocol::RdapOnly => Preference::RdapOnly,
        }
    }
}

fn main() -> ExitCode {
    let cli = Cli::parse();

    match run(cli) {
        Ok(code) => code,
        Err(error) => {
            eprintln!("monovm-whois: {error}");
            ExitCode::FAILURE
        }
    }
}

fn run(cli: Cli) -> Result<ExitCode, Error> {
    let client = WhoisClient::builder()
        .prefer(cli.protocol.into())
        .connect_timeout(seconds(cli.connect_timeout))
        .read_timeout(seconds(cli.read_timeout))
        .throttle_per_host(seconds(cli.throttle))
        .retry(monovm_whois::transport::RetryPolicy::fixed(
            cli.attempts.max(1),
            Duration::from_millis(250),
        ))
        .referrals(if cli.no_referrals {
            ReferralPolicy::NONE
        } else {
            ReferralPolicy::DEFAULT
        })
        .build()?;

    if cli.list_tlds {
        return list_tlds(&client);
    }

    if cli.explain {
        return explain(&client, &cli);
    }
    if cli.record || cli.raw {
        return details(&client, &cli);
    }

    availability(&client, &cli)
}

fn seconds(value: f64) -> Duration {
    Duration::from_secs_f64(value.max(0.001))
}

fn list_tlds(client: &WhoisClient) -> Result<ExitCode, Error> {
    let tlds = client.supported_tlds();
    let mut out = io::BufWriter::new(io::stdout().lock());

    for tld in &tlds {
        // Ignore a closed pipe: `| head` is a normal way to use this.
        if writeln!(out, "{tld}").is_err() {
            return Ok(ExitCode::SUCCESS);
        }
    }
    let _ = writeln!(out, "\n{} suffixes", tlds.len());

    Ok(ExitCode::SUCCESS)
}

/// Availability for every name, expanding bare ones across `--tlds`.
fn availability(client: &WhoisClient, cli: &Cli) -> Result<ExitCode, Error> {
    let checker = Checker::new(client.clone()).with_popular_tlds(&cli.tlds)?;
    let report = checker.check(&cli.domains);

    if cli.json {
        let entries: Vec<serde_json::Value> = report
            .entries()
            .iter()
            .map(|(domain, outcome)| match outcome {
                Ok(availability) => serde_json::json!({
                    "domain": domain,
                    "availability": availability.as_str(),
                }),
                Err(error) => serde_json::json!({
                    "domain": domain,
                    "error": error.to_string(),
                }),
            })
            .collect();
        println!("{}", serde_json::to_string_pretty(&entries).unwrap());
    } else {
        println!("{report}");
    }

    Ok(exit_code(report.failures().is_empty()))
}

/// The parsed record, the raw text, or both.
fn details(client: &WhoisClient, cli: &Cli) -> Result<ExitCode, Error> {
    let mut all_ok = true;
    let mut documents = Vec::new();

    for input in &cli.domains {
        match client.lookup(input) {
            Ok(lookup) => {
                if cli.json {
                    let mut document = serde_json::json!({
                        "domain": lookup.domain.as_ascii(),
                        "tld": lookup.tld.ascii(),
                        "availability": lookup.availability().as_str(),
                        "confidence": lookup.verdict.confidence.as_str(),
                        "rule": lookup.verdict.rule,
                        "because": lookup.verdict.because,
                        "consulted": lookup
                            .consulted()
                            .iter()
                            .map(|endpoint| endpoint.address())
                            .collect::<Vec<_>>(),
                    });
                    if cli.record {
                        document["record"] = serde_json::to_value(&lookup.record).unwrap();
                    }
                    if cli.raw {
                        document["raw"] = serde_json::Value::String(lookup.raw_text());
                    }
                    documents.push(document);
                } else {
                    println!("=== {} ({}) ===", lookup.domain, lookup.availability());
                    if cli.record {
                        match &lookup.record {
                            Some(record) => println!("{record}"),
                            None => println!("(no registration record)"),
                        }
                    }
                    if cli.raw {
                        let text = lookup.raw_text();
                        println!(
                            "{}",
                            if text.trim().is_empty() {
                                "(empty response)"
                            } else {
                                text.trim()
                            }
                        );
                    }
                    println!();
                }
            }
            Err(error) => {
                all_ok = false;
                if cli.json {
                    documents
                        .push(serde_json::json!({ "domain": input, "error": error.to_string() }));
                } else {
                    eprintln!("=== {input} ===\nerror: {error}\n");
                }
            }
        }
    }

    if cli.json {
        println!("{}", serde_json::to_string_pretty(&documents).unwrap());
    }

    Ok(exit_code(all_ok))
}

/// Why each verdict is what it is.
fn explain(client: &WhoisClient, cli: &Cli) -> Result<ExitCode, Error> {
    let mut all_ok = true;

    for input in &cli.domains {
        match client.explain(input) {
            Ok(explanation) => print!("{explanation}"),
            Err(error) => {
                all_ok = false;
                eprintln!("{input}: {error}");
            }
        }
        println!();
    }

    Ok(exit_code(all_ok))
}

/// Exit status reflects whether the questions were *answered*, not what the answers
/// were.
///
/// A domain that turns out to be taken is a successful lookup and exits zero. A
/// domain whose registry refused to answer exits non-zero, because a script that
/// treats those two the same will end up offering registered names for sale.
fn exit_code(ok: bool) -> ExitCode {
    if ok {
        ExitCode::SUCCESS
    } else {
        ExitCode::FAILURE
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use clap::CommandFactory;

    #[test]
    fn the_command_definition_is_valid() {
        Cli::command().debug_assert();
    }

    #[test]
    fn a_bare_domain_parses() {
        let cli = Cli::parse_from(["monovm-whois", "example.com"]);
        assert_eq!(cli.domains, ["example.com"]);
        assert_eq!(cli.tlds, ["com", "net", "org", "info"]);
        assert!(!cli.json);
    }

    #[test]
    fn tlds_accept_a_comma_separated_list() {
        let cli = Cli::parse_from(["monovm-whois", "monovm", "--tlds", "com,ir,co.uk"]);
        assert_eq!(cli.tlds, ["com", "ir", "co.uk"]);
    }

    #[test]
    fn several_domains_are_accepted() {
        let cli = Cli::parse_from(["monovm-whois", "a.com", "b.net", "c"]);
        assert_eq!(cli.domains.len(), 3);
    }

    #[test]
    fn the_output_flags_parse() {
        let cli = Cli::parse_from(["monovm-whois", "a.com", "--record", "--raw", "--json"]);
        assert!(cli.record && cli.raw && cli.json);
    }

    #[test]
    fn protocol_maps_onto_the_library_preference() {
        for (flag, expected) in [
            ("whois", Preference::Whois),
            ("rdap", Preference::Rdap),
            ("whois-only", Preference::WhoisOnly),
            ("rdap-only", Preference::RdapOnly),
        ] {
            let cli = Cli::parse_from(["monovm-whois", "a.com", "--protocol", flag]);
            assert_eq!(Preference::from(cli.protocol), expected, "for {flag}");
        }
    }

    #[test]
    fn timeouts_are_clamped_above_zero() {
        // A zero timeout would fail every lookup instantly rather than meaning
        // "no limit", so it is clamped to something a socket can actually use.
        assert!(seconds(0.0) > Duration::ZERO);
        assert_eq!(seconds(2.5), Duration::from_millis(2500));
    }

    #[test]
    fn at_least_one_domain_is_required() {
        assert!(Cli::try_parse_from(["monovm-whois"]).is_err());
    }

    #[test]
    fn exit_code_reflects_whether_lookups_were_answered() {
        assert_eq!(
            format!("{:?}", exit_code(true)),
            format!("{:?}", ExitCode::SUCCESS)
        );
        assert_eq!(
            format!("{:?}", exit_code(false)),
            format!("{:?}", ExitCode::FAILURE)
        );
    }
}