monovm-whois-rust 1.0.0

Domain WHOIS and RDAP lookups with availability detection, structured record parsing, referral chasing, caching and rate limiting.
Documentation
//! [`Evidence`]: a response, prepared once for every rule that will read it.

use crate::domain::Tld;
use crate::registry::Registry;
use crate::transport::{RawResponse, ResponseKind};

/// Line prefixes registries use for banners, disclaimers and separators.
///
/// These lines carry no facts about the domain, and reading them as if they did
/// is a classic source of wrong answers: half the registries in the world print
/// the word "available" in a sentence about their web-based lookup service.
const COMMENT_PREFIXES: [&str; 6] = ["%", "#", ";", ">>>", "---", "--"];

/// Phrases that mark an otherwise ordinary-looking line as boilerplate.
const BOILERPLATE: [&str; 8] = [
    "available on web at",
    "find the terms and conditions",
    "terms of use",
    "by submitting",
    "this whois information is provided",
    "for more information",
    "please see",
    "the data in this",
];

/// One response, with the derived forms every rule needs computed up front.
///
/// Lower-casing a record and splitting it into lines are cheap on their own and
/// expensive when a dozen rules each do it again. Doing it once here also means
/// every rule agrees on what counts as a comment, which matters more than the
/// speed: a rule that reads banner text as data will find "available" in a
/// registry's advertising copy.
#[derive(Debug, Clone)]
pub struct Evidence<'a> {
    text: &'a str,
    lowercase: String,
    /// Non-empty, non-comment, non-boilerplate lines, trimmed and lower-cased.
    significant: Vec<String>,
    /// Comment lines with their prefix stripped, trimmed and lower-cased.
    comments: Vec<String>,
    tld: &'a Tld,
    registry: Option<&'a Registry>,
    kind: ResponseKind,
}

impl<'a> Evidence<'a> {
    /// Prepare a response for the rules.
    pub fn new(
        text: &'a str,
        kind: ResponseKind,
        tld: &'a Tld,
        registry: Option<&'a Registry>,
    ) -> Self {
        let lowercase = text.to_lowercase();
        let is_comment = |line: &&str| {
            COMMENT_PREFIXES
                .iter()
                .any(|prefix| line.starts_with(prefix))
        };

        let significant = lowercase
            .lines()
            .map(str::trim)
            .filter(|line| !line.is_empty())
            .filter(|line| !is_comment(line))
            .filter(|line| !BOILERPLATE.iter().any(|phrase| line.contains(phrase)))
            .map(str::to_string)
            .collect();

        let comments = lowercase
            .lines()
            .map(str::trim)
            .filter(is_comment)
            .map(|line| {
                line.trim_start_matches(['%', '#', ';', '>', '-'])
                    .trim()
                    .to_string()
            })
            .filter(|line| !line.is_empty())
            .collect();

        Evidence {
            text,
            lowercase,
            significant,
            comments,
            tld,
            registry,
            kind,
        }
    }

    /// Prepare a fetched response.
    pub fn from_response(
        response: &'a RawResponse,
        tld: &'a Tld,
        registry: Option<&'a Registry>,
    ) -> Self {
        Evidence::new(response.text(), response.kind(), tld, registry)
    }

    /// The response exactly as received.
    pub fn text(&self) -> &str {
        self.text
    }

    /// The whole response, lower-cased.
    pub fn lowercase(&self) -> &str {
        &self.lowercase
    }

    /// The lines that carry facts, trimmed and lower-cased.
    pub fn significant_lines(&self) -> &[String] {
        &self.significant
    }

    /// Comment lines with their prefix stripped, trimmed and lower-cased.
    ///
    /// Filtering comments out of [`significant_lines`](Evidence::significant_lines) is
    /// what stops a banner line such as `% Available on web at …` being read as
    /// availability. But AFNIC answers `%% NOT FOUND`, NIC.AT answers
    /// `% nothing found` and others answer `%ERROR:101: no entries found` —
    /// registries that put the verdict itself in a comment.
    ///
    /// So these are kept separately, to be matched only against the small anchored
    /// table in [`patterns::comment_answer`](crate::detect::patterns::comment_answer)
    /// and never against the general availability wording.
    pub fn comment_lines(&self) -> &[String] {
        &self.comments
    }

    /// The first `lines` lines, joined.
    ///
    /// A WHOIS server identifies itself at the top of its response. A *mention* of
    /// another server can appear anywhere — JPRS lists every regional IP registry in
    /// its help text — so a rule that reads a banner as identification has to look
    /// only where identification lives.
    pub fn head(&self, lines: usize) -> String {
        self.lowercase
            .lines()
            .take(lines)
            .collect::<Vec<_>>()
            .join("\n")
    }

    /// The suffix being asked about.
    pub fn tld(&self) -> &Tld {
        self.tld
    }

    /// The registry definition, when one was resolved.
    pub fn registry(&self) -> Option<&Registry> {
        self.registry
    }

    /// Which protocol produced this response.
    pub fn kind(&self) -> ResponseKind {
        self.kind
    }

    /// Whether this is an RDAP JSON document.
    pub fn is_rdap(&self) -> bool {
        self.kind == ResponseKind::RdapJson
    }

    /// Whether the lower-cased response contains `needle`, which must itself be
    /// lower case.
    pub fn contains(&self, needle: &str) -> bool {
        self.lowercase.contains(needle)
    }

    /// Whether any of `needles` appears anywhere, banners included.
    ///
    /// Use for refusals and server errors, which registries print in exactly the
    /// places [`significant_lines`](Evidence::significant_lines) filters out.
    pub fn contains_any(&self, needles: &[&str]) -> bool {
        needles.iter().any(|needle| self.lowercase.contains(needle))
    }

    /// Whether any significant line contains `needle`.
    ///
    /// Use for anything that looks like a claim about the domain, so a phrase in a
    /// disclaimer cannot be mistaken for one.
    pub fn any_significant_line(&self, needle: &str) -> bool {
        self.significant.iter().any(|line| line.contains(needle))
    }

    /// The significant lines joined by a space.
    ///
    /// Registries wrap sentences across lines, so a phrase like "domain has not
    /// been registered" may not sit on any single one.
    pub fn significant_text(&self) -> String {
        self.significant.join(" ")
    }

    /// How long the trimmed response is.
    pub fn len(&self) -> usize {
        self.text.trim().len()
    }

    /// Whether the response is entirely whitespace.
    pub fn is_empty(&self) -> bool {
        self.text.trim().is_empty()
    }

    /// The first `limit` characters of the response, for diagnostics.
    pub fn preview(&self, limit: usize) -> String {
        let trimmed = self.text.trim();
        let taken: String = trimmed.chars().take(limit).collect();
        if trimmed.chars().count() > limit {
            format!("{taken}")
        } else {
            taken
        }
    }
}

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

    fn evidence<'a>(text: &'a str, tld: &'a Tld) -> Evidence<'a> {
        Evidence::new(text, ResponseKind::WhoisText, tld, None)
    }

    #[test]
    fn comments_and_boilerplate_are_not_significant() {
        let tld = Tld::parse("com").unwrap();
        let record = "\
% This is a banner
# so is this
>>> Last update of WHOIS database <<<
--- separator
Domain Name: EXAMPLE.COM
This WHOIS information is provided for free by someone
Registrar: Example LLC

";
        let evidence = evidence(record, &tld);
        assert_eq!(
            evidence.significant_lines(),
            ["domain name: example.com", "registrar: example llc"]
        );
    }

    #[test]
    fn banner_text_is_visible_to_contains_but_not_to_lines() {
        let tld = Tld::parse("mc").unwrap();
        // NIC Monaco advertises its web lookup in a comment; a rule that read
        // that as data would call every domain available.
        let record = "% Lookup available on web at http://www.nic.mc\nStatus: registered\n";
        let evidence = evidence(record, &tld);

        assert!(evidence.contains("available on web"));
        assert!(!evidence.any_significant_line("available"));
        assert!(evidence.any_significant_line("registered"));
    }

    #[test]
    fn wrapped_sentences_are_searchable_as_one_string() {
        let tld = Tld::parse("hk").unwrap();
        let record = "The domain has not\nbeen registered.\n";
        let evidence = evidence(record, &tld);

        assert!(!evidence.any_significant_line("has not been registered"));
        assert!(evidence
            .significant_text()
            .contains("has not been registered"));
    }

    #[test]
    fn everything_is_lowercased_once() {
        let tld = Tld::parse("com").unwrap();
        let evidence = evidence("No Match FOR example.COM", &tld);

        assert!(evidence.contains("no match for"));
        assert_eq!(
            evidence.text(),
            "No Match FOR example.COM",
            "raw text is preserved"
        );
    }

    #[test]
    fn preview_is_truncated_on_character_boundaries() {
        let tld = Tld::parse("de").unwrap();
        let evidence = evidence("Müncheners are here", &tld);

        assert_eq!(evidence.preview(3), "Mün…");
        assert_eq!(evidence.preview(1000), "Müncheners are here");
    }

    #[test]
    fn length_ignores_surrounding_whitespace() {
        let tld = Tld::parse("com").unwrap();
        assert_eq!(evidence("  abc  ", &tld).len(), 3);
        assert!(evidence("   \n\t ", &tld).is_empty());
    }
}