seer-core 0.57.0

Core library for Seer domain name utilities
Documentation
//! Parser for .kr domains (KISA/KRNIC format).
//!
//! KISA uses a bilingual format (Korean + English) with nameservers in
//! nested sub-sections. The English section has:
//! ```text
//! Primary Name Server
//!    Host Name                : ns1.google.com
//!
//! Secondary Name Server
//!    Host Name                : ns2.google.com
//! ```
//!
//! Some domains have more than two nameservers, listed as additional
//! "Secondary Name Server" sections or numbered entries.

use chrono::{DateTime, NaiveDate, Utc};

use super::{push_bounded, MAX_NAMESERVERS, MAX_STATUSES};
use crate::whois::parser::WhoisResponse;

static_regex! {
    /// Matches nameserver host lines in both Korean and English sections.
    /// Korean: `   호스트이름               : ns1.google.com`
    /// English: `   Host Name                : ns1.google.com`
    HOST_NAME_PATTERN = r"(?im)^[ \t]+(?:Host Name|호스트이름)[ \t]*:[ \t]*(.+)$";

    /// Inline fields in the English section
    REGISTRANT_PATTERN = r"(?im)^Registrant[ \t]*:[ \t]*(.+)$";
    ADMIN_PATTERN = r"(?im)^Administrative Contact\(AC\)[ \t]*:[ \t]*(.+)$";
    AC_EMAIL_PATTERN = r"(?im)^AC E-Mail[ \t]*:[ \t]*(.+)$";
    AC_PHONE_PATTERN = r"(?im)^AC Phone Number[ \t]*:[ \t]*(.+)$";
    REGISTERED_DATE_PATTERN = r"(?im)^Registered Date[ \t]*:[ \t]*(.+)$";
    EXPIRATION_DATE_PATTERN = r"(?im)^Expiration Date[ \t]*:[ \t]*(.+)$";
    LAST_UPDATED_PATTERN = r"(?im)^Last Updated Date[ \t]*:[ \t]*(.+)$";
    AUTHORIZED_AGENCY_PATTERN = r"(?im)^Authorized Agency[ \t]*:[ \t]*(.+)$";
    DNSSEC_PATTERN = r"(?im)^DNSSEC[ \t]*:[ \t]*(.+)$";
    /// EPP statuses: `Domain Status` (English), `등록정보 보호` (Korean).
    STATUS_PATTERN = r"(?im)^(?:Domain Status|등록정보 보호)[ \t]*:[ \t]*(.+)$";
}

/// TLDs this parser handles.
/// whois.kr also serves the IDN TLDs .한국 (ccTLD) and .삼성; the
/// client sends IDN domains to the wire as A-labels, so they reach
/// the registry (and this dispatch) as `xn--3e0b707e` / `xn--cg4bki`.
pub(super) const TLDS: &[&str] = &["kr", "xn--3e0b707e", "xn--cg4bki"];

/// Parses .kr domains using the KISA/KRNIC format.
pub(super) fn parse(domain: &str, server: &str, raw: &str) -> WhoisResponse {
    let mut nameservers = Vec::new();
    let mut registrant = None;
    let mut registrar = None;
    let mut admin_name = None;
    let mut admin_email = None;
    let mut admin_phone = None;
    let mut creation_date = None;
    let mut expiration_date = None;
    let mut updated_date = None;
    let mut dnssec = None;

    // Extract all nameservers from Host Name fields
    // Use only the English section to avoid duplicates
    let english_section = if let Some(pos) = raw.find("# ENGLISH") {
        &raw[pos..]
    } else {
        raw
    };

    for caps in HOST_NAME_PATTERN.captures_iter(english_section) {
        if let Some(m) = caps.get(1) {
            let ns = m.as_str().trim().to_lowercase();
            push_bounded(&mut nameservers, ns, MAX_NAMESERVERS);
        }
    }

    // If no English section, try the full text
    if nameservers.is_empty() {
        for caps in HOST_NAME_PATTERN.captures_iter(raw) {
            if let Some(m) = caps.get(1) {
                let ns = m.as_str().trim().to_lowercase();
                push_bounded(&mut nameservers, ns, MAX_NAMESERVERS);
            }
        }
    }

    // Statuses from the English section (or the whole reply when there is
    // none; the Korean and English lines carry the same codes and dedupe).
    let mut status = Vec::new();
    for caps in STATUS_PATTERN.captures_iter(english_section) {
        if let Some(m) = caps.get(1) {
            push_bounded(&mut status, m.as_str().trim().to_string(), MAX_STATUSES);
        }
    }

    // Extract inline fields from English section
    if let Some(caps) = REGISTRANT_PATTERN.captures(raw) {
        if let Some(m) = caps.get(1) {
            let val = m.as_str().trim().to_string();
            if !val.is_empty() {
                registrant = Some(val);
            }
        }
    }

    if let Some(caps) = ADMIN_PATTERN.captures(raw) {
        if let Some(m) = caps.get(1) {
            let val = m.as_str().trim().to_string();
            if !val.is_empty() {
                admin_name = Some(val);
            }
        }
    }

    if let Some(caps) = AC_EMAIL_PATTERN.captures(raw) {
        if let Some(m) = caps.get(1) {
            let val = m.as_str().trim().to_string();
            if !val.is_empty() {
                admin_email = Some(val);
            }
        }
    }

    if let Some(caps) = AC_PHONE_PATTERN.captures(raw) {
        if let Some(m) = caps.get(1) {
            let val = m.as_str().trim().to_string();
            if !val.is_empty() {
                admin_phone = Some(val);
            }
        }
    }

    if let Some(caps) = REGISTERED_DATE_PATTERN.captures(raw) {
        if let Some(m) = caps.get(1) {
            creation_date = parse_kisa_date(m.as_str());
        }
    }

    if let Some(caps) = EXPIRATION_DATE_PATTERN.captures(raw) {
        if let Some(m) = caps.get(1) {
            expiration_date = parse_kisa_date(m.as_str());
        }
    }

    if let Some(caps) = LAST_UPDATED_PATTERN.captures(raw) {
        if let Some(m) = caps.get(1) {
            updated_date = parse_kisa_date(m.as_str());
        }
    }

    if let Some(caps) = AUTHORIZED_AGENCY_PATTERN.captures(raw) {
        if let Some(m) = caps.get(1) {
            let val = m.as_str().trim().to_string();
            // Strip URL in parens: "Whois Corp.(http://whois.co.kr)" → "Whois Corp."
            let name = val.split('(').next().unwrap_or(&val).trim().to_string();
            if !name.is_empty() {
                registrar = Some(name);
            }
        }
    }

    if let Some(caps) = DNSSEC_PATTERN.captures(raw) {
        if let Some(m) = caps.get(1) {
            let val = m.as_str().trim();
            dnssec = Some(
                if val.eq_ignore_ascii_case("unsigned") || val.eq_ignore_ascii_case("미서명") {
                    "unsigned".to_string()
                } else {
                    "signedDelegation".to_string()
                },
            );
        }
    }

    WhoisResponse {
        domain: domain.to_string(),
        registrar,
        registrant: registrant.clone(),
        organization: registrant,
        admin_name,
        admin_email,
        admin_phone,
        creation_date,
        expiration_date,
        updated_date,
        nameservers,
        status,
        dnssec,
        whois_server: server.to_string(),
        raw_response: raw.to_string(),
        // registrant_country is not inferred from the TLD: KISA does not print
        // the holder country, and a "no match" body must not report one.
        ..Default::default()
    }
}

/// Parses KISA date format: "YYYY. MM. DD." (with dots and spaces)
fn parse_kisa_date(date_str: &str) -> Option<DateTime<Utc>> {
    let cleaned = date_str.trim().trim_end_matches('.');

    // KISA uses "YYYY. MM. DD." format
    let normalized = cleaned.replace(". ", "-").replace('.', "");
    if let Ok(d) = NaiveDate::parse_from_str(&normalized, "%Y-%m-%d") {
        return Some(d.and_hms_opt(0, 0, 0)?.and_utc());
    }

    // Also try standard format
    if let Ok(d) = NaiveDate::parse_from_str(cleaned, "%Y-%m-%d") {
        return Some(d.and_hms_opt(0, 0, 0)?.and_utc());
    }

    None
}

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

    const SAMPLE_KISA_RESPONSE: &str = r#"query : google.kr


# KOREAN(UTF8)

도메인이름                  : google.kr
등록인                      : 구글코리아유한회사
등록인 주소                 : 서울시 강남구 역삼동 737 강남파이낸스센터 22층
등록인 우편번호             : 135984
책임자                      : Domain Administrator
책임자 전자우편             : dns-admin@google.com
책임자 전화번호             : 82.25319000
등록일                      : 2007. 03. 02.
최근 정보 변경일            : 2010. 10. 04.
사용 종료일                 : 2027. 03. 02.
정보공개여부                : Y
등록대행자                  : (주)후이즈(http://whois.co.kr)
DNSSEC                      : 미서명

1차 네임서버 정보
   호스트이름               : ns1.google.com

2차 네임서버 정보
   호스트이름               : ns2.google.com

네임서버 이름이 .kr이 아닌 경우는 IP주소가 보이지 않습니다.


# ENGLISH

Domain Name                 : google.kr
Registrant                  : Google Korea, LLC
Registrant Address          : 22nd Floor Gangnam Finance Center, 737 Yeoksam-dong Kangnam-ku Seoul
Registrant Zip Code         : 135984
Administrative Contact(AC)  : Domain Administrator
AC E-Mail                   : dns-admin@google.com
AC Phone Number             : 82.25319000
Registered Date             : 2007. 03. 02.
Last Updated Date           : 2010. 10. 04.
Expiration Date             : 2027. 03. 02.
Publishes                   : Y
Authorized Agency           : Whois Corp.(http://whois.co.kr)
DNSSEC                      : unsigned

Primary Name Server
   Host Name                : ns1.google.com

Secondary Name Server
   Host Name                : ns2.google.com


- KISA/KRNIC WHOIS Service -"#;

    #[test]
    fn test_kisa_nameservers() {
        let result = parse("google.kr", "whois.kr", SAMPLE_KISA_RESPONSE);

        assert_eq!(result.nameservers.len(), 2);
        assert!(result.nameservers.contains(&"ns1.google.com".to_string()));
        assert!(result.nameservers.contains(&"ns2.google.com".to_string()));
    }

    #[test]
    fn test_kisa_registrant() {
        let result = parse("google.kr", "whois.kr", SAMPLE_KISA_RESPONSE);

        assert_eq!(result.registrant, Some("Google Korea, LLC".to_string()));
    }

    #[test]
    fn test_kisa_registrar() {
        let result = parse("google.kr", "whois.kr", SAMPLE_KISA_RESPONSE);

        assert_eq!(result.registrar, Some("Whois Corp.".to_string()));
    }

    #[test]
    fn test_kisa_admin_contact() {
        let result = parse("google.kr", "whois.kr", SAMPLE_KISA_RESPONSE);

        assert_eq!(result.admin_name, Some("Domain Administrator".to_string()));
        assert_eq!(result.admin_email, Some("dns-admin@google.com".to_string()));
        assert_eq!(result.admin_phone, Some("82.25319000".to_string()));
    }

    #[test]
    fn test_kisa_dates() {
        let result = parse("google.kr", "whois.kr", SAMPLE_KISA_RESPONSE);

        assert!(result.creation_date.is_some());
        let creation = result.creation_date.unwrap();
        assert_eq!(creation.year(), 2007);
        assert_eq!(creation.month(), 3);
        assert_eq!(creation.day(), 2);

        assert!(result.expiration_date.is_some());
        let expiry = result.expiration_date.unwrap();
        assert_eq!(expiry.year(), 2027);
        assert_eq!(expiry.month(), 3);
        assert_eq!(expiry.day(), 2);

        assert!(result.updated_date.is_some());
        let updated = result.updated_date.unwrap();
        assert_eq!(updated.year(), 2010);
        assert_eq!(updated.month(), 10);
        assert_eq!(updated.day(), 4);
    }

    #[test]
    fn test_kisa_dnssec() {
        let result = parse("google.kr", "whois.kr", SAMPLE_KISA_RESPONSE);

        assert_eq!(result.dnssec, Some("unsigned".to_string()));
    }

    /// KISA does not print the holder's country, so none is reported — in
    /// particular not for an unregistered domain.
    #[test]
    fn test_kisa_country() {
        let result = parse("google.kr", "whois.kr", SAMPLE_KISA_RESPONSE);
        assert_eq!(result.registrant_country, None);

        let raw = "query : nosuch-xyz.kr\n\n\
                   The requested domain was not found in the Registry or Registrar\u{2019}s WHOIS Server.\n";
        let result = parse("nosuch-xyz.kr", "whois.kr", raw);
        assert_eq!(result.registrant_country, None);
        assert!(result.is_available());
    }

    #[test]
    fn test_kisa_date_parsing() {
        assert!(parse_kisa_date("2007. 03. 02.").is_some());
        assert!(parse_kisa_date("2010. 10. 04.").is_some());
    }

    /// An EMPTY `Registrant :` field must not capture the following line
    /// (`\s*` after the colon used to match the line break).
    #[test]
    fn test_kisa_empty_field_does_not_capture_next_line() {
        let raw = "# ENGLISH\n\
                   \n\
                   Domain Name                 : example.kr\n\
                   Registrant                  : \n\
                   Registrant Address          : 22nd Floor, Seoul\n\
                   Authorized Agency           : \n\
                   DNSSEC                      : unsigned\n";
        let result = parse("example.kr", "whois.kr", raw);
        assert_eq!(result.registrant, None);
        assert_eq!(result.registrar, None);
        assert_eq!(result.dnssec.as_deref(), Some("unsigned"));
    }

    #[test]
    fn domain_status_is_read_once_from_both_sections() {
        // Live whois.kr reply for google.co.kr (2026-10-13), trimmed: the
        // Korean section repeats the codes under `등록정보 보호`.
        let raw = "query : google.co.kr\n\n# KOREAN(UTF8)\n\n도메인이름                  : google.co.kr\n등록정보 보호               : clientTransferProhibited\n등록정보 보호               : clientUpdateProhibited\n\n# ENGLISH\n\nDomain Name                 : google.co.kr\nDomain Status               : clientTransferProhibited\nDomain Status               : clientUpdateProhibited\n";
        let r = parse("google.co.kr", "whois.kr", raw);
        assert_eq!(
            r.status,
            vec!["clientTransferProhibited", "clientUpdateProhibited"]
        );
        // Without an English section the Korean label is read.
        let korean = raw.split("# ENGLISH").next().unwrap_or(raw);
        assert_eq!(
            parse("google.co.kr", "whois.kr", korean).status,
            vec!["clientTransferProhibited", "clientUpdateProhibited"]
        );
    }
}