nautalid 0.1.0

Scratch container substrate — TLS 1.3 HTTP/2+3 kernel, LID/AetherDB, optional filter bus (GPL-3.0-or-later).
//! Resolve **`tcp://`** endpoints for ZeroMQ / AetherDB wire peers without libc **`getaddrinfo`**.
//! peers without libc **`getaddrinfo`**.
//!
//! In-process **`rustis`** (`transport local`) needs no DNS. Remote wire presets
//! (`transport zeromq connect="tcp://host:5555"`) and future Nautalid→Aether clients
//! should call [`resolve_tcp_connect`] before `zeromq` connect.

use std::net::IpAddr;

use super::error::{DnsError, TcpEndpointError};
use super::runtime::{install_from_env, lookup_ip, source};

/// Parse **`tcp://host:port`** (IPv4, bracketed IPv6, or domain).
pub fn parse_tcp_host_port(endpoint: &str) -> Result<(String, u16), TcpEndpointError> {
    let rest = endpoint
        .strip_prefix("tcp://")
        .ok_or_else(|| TcpEndpointError::NotTcp(endpoint.to_string()))?;

    if rest.is_empty() {
        return Err(TcpEndpointError::Syntax(endpoint.to_string()));
    }

    if rest.starts_with('[') {
        let close = rest
            .find(']')
            .ok_or_else(|| TcpEndpointError::Syntax(endpoint.to_string()))?;
        let host = rest[1..close].to_string();
        let port_str = rest[close + 1..]
            .strip_prefix(':')
            .ok_or_else(|| TcpEndpointError::Syntax(endpoint.to_string()))?;
        let port = parse_port(port_str, endpoint)?;
        return Ok((host, port));
    }

    let colon = rest
        .rfind(':')
        .ok_or_else(|| TcpEndpointError::Syntax(endpoint.to_string()))?;
    if colon == 0 {
        return Err(TcpEndpointError::Syntax(endpoint.to_string()));
    }
    let host = rest[..colon].to_string();
    let port = parse_port(&rest[colon + 1..], endpoint)?;
    Ok((host, port))
}

fn parse_port(raw: &str, endpoint: &str) -> Result<u16, TcpEndpointError> {
    raw.parse::<u16>()
        .map_err(|_| TcpEndpointError::Syntax(endpoint.to_string()))
}

/// Whether the host is already a connect/bind literal (no Hickory lookup).
pub fn host_is_literal(host: &str) -> bool {
    if host.is_empty() || host == "*" {
        return true;
    }
    host.parse::<IpAddr>().is_ok()
}

pub fn format_tcp_endpoint(host: &str, port: u16) -> String {
    if host.parse::<IpAddr>().is_ok_and(|ip| ip.is_ipv6()) {
        format!("tcp://[{host}]:{port}")
    } else {
        format!("tcp://{host}:{port}")
    }
}

/// Resolve a domain in a **`tcp://`** connect URL to a literal IP endpoint (first A/AAAA).
pub async fn resolve_tcp_connect(endpoint: &str) -> Result<String, DnsError> {
    let (host, port) = parse_tcp_host_port(endpoint)?;
    if host_is_literal(&host) {
        return Ok(format_tcp_endpoint(&host, port));
    }
    install_from_env()?;
    let fqdn = if host.ends_with('.') {
        host.clone()
    } else {
        format!("{host}.")
    };
    let ips = lookup_ip(&fqdn).await?;
    let ip = ips.first().copied().ok_or(TcpEndpointError::NoAddresses)?;
    Ok(format_tcp_endpoint(&ip.to_string(), port))
}

/// All resolved **`tcp://`** endpoints for a connect URL (A/AAAA round-robin / failover).
pub async fn resolve_tcp_connect_all(endpoint: &str) -> Result<Vec<String>, DnsError> {
    let (host, port) = parse_tcp_host_port(endpoint)?;
    if host_is_literal(&host) {
        return Ok(vec![format_tcp_endpoint(&host, port)]);
    }
    install_from_env()?;
    let fqdn = if host.ends_with('.') {
        host.clone()
    } else {
        format!("{host}.")
    };
    let ips = lookup_ip(&fqdn).await?;
    if ips.is_empty() {
        return Err(TcpEndpointError::NoAddresses.into());
    }
    Ok(ips
        .into_iter()
        .map(|ip| format_tcp_endpoint(&ip.to_string(), port))
        .collect())
}

/// Warm-up: resolve **`NAUTALID_AETHER_CONNECT`** when set (Aether wire peer).
pub async fn log_aether_connect_resolution() {
    let Some(raw) = std::env::var("NAUTALID_AETHER_CONNECT")
        .ok()
        .filter(|s| !s.trim().is_empty())
    else {
        return;
    };

    match resolve_tcp_connect(raw.trim()).await {
        Ok(resolved) => {
            tracing::info!(
                configured = %raw.trim(),
                %resolved,
                source = ?source(),
                "hickory resolved Aether wire endpoint"
            );
        }
        Err(e) => {
            tracing::warn!(configured = %raw.trim(), %e, "Aether wire DNS lookup failed (non-fatal)")
        }
    }
}

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

    #[test]
    fn parses_ipv4_tcp() {
        let (host, port) = parse_tcp_host_port("tcp://10.0.0.5:5555").unwrap();
        assert_eq!(host, "10.0.0.5");
        assert_eq!(port, 5555);
    }

    #[test]
    fn parses_bracket_ipv6_tcp() {
        let (host, port) = parse_tcp_host_port("tcp://[2001:db8::1]:5555").unwrap();
        assert_eq!(host, "2001:db8::1");
        assert_eq!(port, 5555);
    }

    #[test]
    fn parses_domain_tcp() {
        let (host, port) = parse_tcp_host_port("tcp://rustis.internal:5555").unwrap();
        assert_eq!(host, "rustis.internal");
        assert_eq!(port, 5555);
    }

    #[test]
    fn literal_host_skips_lookup() {
        assert!(host_is_literal("10.0.0.5"));
        assert!(host_is_literal("*"));
        assert!(!host_is_literal("rustis.internal"));
    }

    #[tokio::test]
    async fn literal_endpoint_unchanged() {
        let out = resolve_tcp_connect("tcp://127.0.0.1:5555").await.unwrap();
        assert_eq!(out, "tcp://127.0.0.1:5555");
    }
}