bindizr-core 0.1.0-beta.4

Core models, configuration, DNS record types, and logging utilities for bindizr
Documentation
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NameError {
    DanglingEscape,
    InvalidEmail,
}

impl std::fmt::Display for NameError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            NameError::DanglingEscape => write!(f, "domain name contains a dangling escape"),
            NameError::InvalidEmail => write!(f, "email must contain exactly one @"),
        }
    }
}

impl std::error::Error for NameError {}

pub fn split_presentation_labels(name: &str) -> Result<Vec<String>, NameError> {
    let mut labels = Vec::new();
    let mut label = String::new();
    let mut escaped = false;

    for c in name.chars() {
        if escaped {
            label.push(c);
            escaped = false;
            continue;
        }

        match c {
            '\\' => escaped = true,
            '.' => {
                labels.push(label);
                label = String::new();
            }
            _ => label.push(c),
        }
    }

    if escaped {
        return Err(NameError::DanglingEscape);
    }

    labels.push(label);
    Ok(labels)
}

pub fn to_fqdn_lowercase(value: &str) -> String {
    format!(
        "{}.",
        value.trim().trim_end_matches('.').to_ascii_lowercase()
    )
}

pub fn to_fqdn(value: &str) -> String {
    format!("{}.", value.trim_end_matches('.'))
}

pub fn is_same_or_subdomain_fqdn(name: &str, zone: &str) -> bool {
    name == zone || name.ends_with(&format!(".{}", zone))
}

pub fn to_relative_domain(fqdn: &str, zone_name: &str) -> String {
    let fqdn = to_fqdn(fqdn);
    let zone = to_fqdn(zone_name);

    if fqdn.eq_ignore_ascii_case(&zone) {
        return "@".to_string();
    }

    let fqdn_lower = fqdn.to_ascii_lowercase();
    let zone_lower = zone.to_ascii_lowercase();

    if is_same_or_subdomain_fqdn(&fqdn_lower, &zone_lower) {
        let relative_part = &fqdn[..fqdn.len() - zone.len()];
        relative_part.trim_end_matches('.').to_string()
    } else {
        fqdn.trim_end_matches('.').to_string()
    }
}

pub fn is_in_bailiwick(name: &str, zone_name: &str) -> bool {
    let name = to_fqdn(name).to_ascii_lowercase();
    let zone = to_fqdn(zone_name).to_ascii_lowercase();

    is_same_or_subdomain_fqdn(&name, &zone)
}

pub fn is_apex_name(name: &str, zone_name: &str) -> bool {
    name == "@" || to_fqdn(name).eq_ignore_ascii_case(&to_fqdn(zone_name))
}

pub fn email_to_soa_mailbox(value: &str) -> Result<String, NameError> {
    if value.matches('@').count() != 1 {
        return Err(NameError::InvalidEmail);
    }

    let (local, domain) = value.split_once('@').ok_or(NameError::InvalidEmail)?;

    Ok(format!(
        "{}.{}.",
        escape_soa_local_part(local),
        domain.trim_end_matches('.')
    ))
}

fn escape_soa_local_part(local: &str) -> String {
    let mut escaped = String::with_capacity(local.len());

    for c in local.chars() {
        if c == '.' || c == '\\' {
            escaped.push('\\');
        }
        escaped.push(c);
    }

    escaped
}

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

    #[test]
    fn is_in_bailiwick_accepts_apex_and_subdomain() {
        assert!(is_in_bailiwick("example.com.", "example.com."));
        assert!(is_in_bailiwick("ns.example.com.", "example.com."));
    }

    #[test]
    fn is_in_bailiwick_rejects_sibling_suffix_match() {
        assert!(!is_in_bailiwick("notexample.com.", "example.com."));
        assert!(!is_in_bailiwick("ns.notexample.com.", "example.com."));
    }

    #[test]
    fn to_relative_domain_converts_only_zone_apex_and_subdomains() {
        assert_eq!(to_relative_domain("example.com.", "example.com."), "@");
        assert_eq!(to_relative_domain("ns.example.com.", "example.com."), "ns");
        assert_eq!(
            to_relative_domain("notexample.com.", "example.com."),
            "notexample.com"
        );
    }
}