nautalid 0.1.0

Scratch container substrate — TLS 1.3 HTTP/2+3 kernel, LID/AetherDB, optional filter bus (GPL-3.0-or-later).
use axum::http::Request;
use std::net::{IpAddr, SocketAddr};
use thiserror::Error;
use tracing::info;

/// One allow/deny entry: host IP, CIDR, or URI path prefix.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FilterRule {
    Ip(IpAddr),
    Cidr { addr: IpAddr, prefix_len: u8 },
    PathPrefix(String),
}

/// Static filter policy loaded at startup or replaced at runtime (see [`crate::filter::layer`]).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FilterPolicy {
    pub deny: Vec<FilterRule>,
    pub allow: Vec<FilterRule>,
}

#[derive(Debug, Error)]
pub enum FilterParseError {
    #[error("invalid filter entry `{0}`")]
    InvalidEntry(String),
    #[error("invalid CIDR `{0}`")]
    InvalidCidr(String),
}

/// Load policy from **`NAUTALID_FILTER_DENY`** and **`NAUTALID_FILTER_ALLOW`** (optional).
pub fn policy_from_env() -> Result<Option<FilterPolicy>, FilterParseError> {
    let deny = parse_env_list("NAUTALID_FILTER_DENY")?;
    let allow = parse_env_list("NAUTALID_FILTER_ALLOW")?;
    if deny.is_empty() && allow.is_empty() {
        return Ok(None);
    }
    info!(
        deny = deny.len(),
        allow = allow.len(),
        "nautalid filter policy loaded from environment"
    );
    Ok(Some(FilterPolicy { deny, allow }))
}

/// Build policy from rule lists (KDL **`reload`** children or legacy CSV).
pub fn policy_from_rules(
    deny: &[String],
    allow: &[String],
) -> Result<FilterPolicy, FilterParseError> {
    Ok(FilterPolicy {
        deny: deny
            .iter()
            .map(String::as_str)
            .map(parse_rule)
            .collect::<Result<_, _>>()?,
        allow: allow
            .iter()
            .map(String::as_str)
            .map(parse_rule)
            .collect::<Result<_, _>>()?,
    })
}

/// Build policy from comma-separated deny/allow strings (legacy bus **`RELOAD`** command).
pub fn policy_from_lists(
    deny_csv: &str,
    allow_csv: &str,
) -> Result<FilterPolicy, FilterParseError> {
    Ok(FilterPolicy {
        deny: parse_csv_rules(deny_csv)?,
        allow: parse_csv_rules(allow_csv)?,
    })
}

fn parse_csv_rules(raw: &str) -> Result<Vec<FilterRule>, FilterParseError> {
    if raw.trim().is_empty() {
        return Ok(Vec::new());
    }
    raw.split(',')
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .map(parse_rule)
        .collect()
}

fn parse_env_list(key: &str) -> Result<Vec<FilterRule>, FilterParseError> {
    let Ok(raw) = std::env::var(key) else {
        return Ok(Vec::new());
    };
    parse_csv_rules(&raw)
}

fn parse_rule(entry: &str) -> Result<FilterRule, FilterParseError> {
    if entry.starts_with('/') {
        return Ok(FilterRule::PathPrefix(entry.to_string()));
    }
    if let Some((net, prefix)) = entry.split_once('/') {
        let addr: IpAddr = net
            .trim()
            .parse()
            .map_err(|_| FilterParseError::InvalidCidr(entry.to_string()))?;
        let prefix_len: u8 = prefix
            .trim()
            .parse()
            .map_err(|_| FilterParseError::InvalidCidr(entry.to_string()))?;
        let max_prefix = if addr.is_ipv4() { 32 } else { 128 };
        if prefix_len > max_prefix {
            return Err(FilterParseError::InvalidCidr(entry.to_string()));
        }
        return Ok(FilterRule::Cidr { addr, prefix_len });
    }
    entry
        .parse::<IpAddr>()
        .map(FilterRule::Ip)
        .map_err(|_| FilterParseError::InvalidEntry(entry.to_string()))
}

fn trust_xforwarded_for() -> bool {
    matches!(
        std::env::var("NAUTALID_TRUSTED_PROXIES").ok().as_deref(),
        Some("1") | Some("true") | Some("yes")
    )
}

/// Client IP: TCP peer from [`ConnectInfo`]; **`X-Forwarded-For`** only when
/// **`NAUTALID_TRUSTED_PROXIES=1`** (or `true` / `yes`).
pub fn client_ip<B>(req: &Request<B>, peer: Option<SocketAddr>) -> Option<IpAddr> {
    if trust_xforwarded_for() {
        if let Some(xff) = req.headers().get("x-forwarded-for") {
            if let Ok(value) = xff.to_str() {
                if let Some(first) = value.split(',').next() {
                    if let Ok(ip) = first.trim().parse() {
                        return Some(ip);
                    }
                }
            }
        }
    }
    peer.map(|a| a.ip())
}

/// Normalize HTTP path for prefix rules; returns `None` when the path is unsafe to match.
pub fn normalize_request_path(path: &str) -> Option<String> {
    if path.contains("..") || path.contains('\0') {
        return None;
    }
    let mut norm = String::with_capacity(path.len().max(1));
    let mut prev_slash = false;
    for ch in path.chars() {
        if ch == '/' {
            if !prev_slash {
                norm.push('/');
            }
            prev_slash = true;
        } else {
            norm.push(ch);
            prev_slash = false;
        }
    }
    if norm.is_empty() {
        norm.push('/');
    }
    Some(norm)
}

pub fn policy_allows(policy: &FilterPolicy, ip: Option<IpAddr>, path: &str) -> bool {
    let Some(path) = normalize_request_path(path) else {
        return false;
    };
    if policy.deny.iter().any(|rule| rule_matches(rule, ip, &path)) {
        return false;
    }
    if policy.allow.is_empty() {
        return true;
    }
    if ip.is_none()
        && policy
            .allow
            .iter()
            .any(|r| matches!(r, FilterRule::Ip(_) | FilterRule::Cidr { .. }))
    {
        return false;
    }
    policy
        .allow
        .iter()
        .any(|rule| rule_matches(rule, ip, &path))
}

fn rule_matches(rule: &FilterRule, ip: Option<IpAddr>, path: &str) -> bool {
    match rule {
        FilterRule::Ip(expected) => ip == Some(*expected),
        FilterRule::Cidr { addr, prefix_len } => {
            ip.is_some_and(|i| cidr_contains(*addr, *prefix_len, i))
        }
        FilterRule::PathPrefix(prefix) => path.starts_with(prefix.as_str()),
    }
}

fn cidr_contains(network: IpAddr, prefix_len: u8, addr: IpAddr) -> bool {
    match (network, addr) {
        (IpAddr::V4(net), IpAddr::V4(a)) => {
            let net_bits = u32::from_be_bytes(net.octets());
            let addr_bits = u32::from_be_bytes(a.octets());
            let mask = if prefix_len >= 32 {
                u32::MAX
            } else {
                u32::MAX << (32 - prefix_len)
            };
            (net_bits & mask) == (addr_bits & mask)
        }
        (IpAddr::V6(net), IpAddr::V6(a)) => {
            let net_bits = u128::from_be_bytes(net.octets());
            let addr_bits = u128::from_be_bytes(a.octets());
            let mask = if prefix_len >= 128 {
                u128::MAX
            } else {
                u128::MAX << (128 - prefix_len)
            };
            (net_bits & mask) == (addr_bits & mask)
        }
        _ => false,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::net::{IpAddr, Ipv4Addr, SocketAddr};

    #[test]
    fn cidr_v4_match() {
        let net = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 0));
        assert!(cidr_contains(
            net,
            8,
            IpAddr::V4(Ipv4Addr::new(10, 1, 2, 3))
        ));
        assert!(!cidr_contains(
            net,
            8,
            IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1))
        ));
    }

    #[test]
    fn rejects_invalid_cidr_prefix() {
        assert!(matches!(
            parse_rule("10.0.0.0/33"),
            Err(FilterParseError::InvalidCidr(_))
        ));
    }

    #[test]
    fn rejects_path_with_parent_dir() {
        let policy = FilterPolicy::default();
        assert!(!policy_allows(&policy, None, "/public/../admin"));
    }

    #[test]
    fn xff_ignored_without_trusted_proxies() {
        use axum::http::Request;

        let req = Request::builder()
            .header("x-forwarded-for", "203.0.113.50")
            .uri("/")
            .body(())
            .unwrap();
        let peer = SocketAddr::from(([127, 0, 0, 1], 8080));
        assert_eq!(
            client_ip(&req, Some(peer)),
            Some(IpAddr::V4(Ipv4Addr::LOCALHOST))
        );
    }

    #[test]
    fn whitelist_requires_match() {
        let policy = FilterPolicy {
            deny: Vec::new(),
            allow: vec![FilterRule::PathPrefix("/public".into())],
        };
        assert!(policy_allows(
            &policy,
            Some(IpAddr::V4(Ipv4Addr::LOCALHOST)),
            "/public/x"
        ));
        assert!(!policy_allows(
            &policy,
            Some(IpAddr::V4(Ipv4Addr::LOCALHOST)),
            "/secret"
        ));
    }
}