nautalid 0.1.0

Scratch container substrate — TLS 1.3 HTTP/2+3 kernel, LID/AetherDB, optional filter bus (GPL-3.0-or-later).
//! KDL filter commands: `reload`, `status`, `ping`.

use kdl::KdlNode;
use thiserror::Error;

use crate::filter::{FilterParseError, FilterStore, FilterStoreError, policy_from_rules};

use super::response;

#[derive(Debug, Error)]
pub enum KdlCommandError {
    #[error("unknown node `{0}`")]
    UnknownNode(String),
    #[error(transparent)]
    Filter(#[from] FilterParseError),
    #[error(transparent)]
    Store(#[from] FilterStoreError),
    #[error("filter reload requires at least one deny or allow entry")]
    EmptyReload,
}

/// Dispatch one filter command node (`reload`, `status`, or `ping`).
pub fn dispatch_node(store: &FilterStore, node: &KdlNode) -> Result<String, KdlCommandError> {
    match node.name().value() {
        "ping" => Ok(response::ok("pong")),
        "status" => Ok(response::status(store)),
        "reload" => reload_node(store, node),
        other => Err(KdlCommandError::UnknownNode(other.to_string())),
    }
}

fn reload_node(store: &FilterStore, node: &KdlNode) -> Result<String, KdlCommandError> {
    let (deny, allow) = collect_reload_rules(node)?;
    apply_reload(store, &deny, &allow)
}

fn apply_reload(
    store: &FilterStore,
    deny: &[String],
    allow: &[String],
) -> Result<String, KdlCommandError> {
    if deny.is_empty() && allow.is_empty() {
        return Err(KdlCommandError::EmptyReload);
    }
    let policy = policy_from_rules(deny, allow)?;
    store.replace(policy)?;
    Ok(response::ok("reloaded"))
}

fn collect_reload_rules(node: &KdlNode) -> Result<(Vec<String>, Vec<String>), KdlCommandError> {
    let mut deny = Vec::new();
    let mut allow = Vec::new();

    if let Some(raw) = node.get("deny").and_then(value_string) {
        deny.extend(split_csv(&raw));
    }
    if let Some(raw) = node.get("allow").and_then(value_string) {
        allow.extend(split_csv(&raw));
    }

    if let Some(children) = node.children() {
        for child in children.nodes() {
            match child.name().value() {
                "deny" => deny.extend(entry_strings(child)),
                "allow" => allow.extend(entry_strings(child)),
                other => return Err(KdlCommandError::UnknownNode(other.to_string())),
            }
        }
    }

    Ok((deny, allow))
}

fn value_string(value: &kdl::KdlValue) -> Option<String> {
    value.as_string().map(str::to_string)
}

fn entry_strings(node: &KdlNode) -> Vec<String> {
    node.entries()
        .iter()
        .filter_map(|entry| entry.value().as_string().map(str::to_string))
        .collect()
}

fn split_csv(raw: &str) -> Vec<String> {
    raw.split(',')
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .map(str::to_string)
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::filter::{FilterRule, policy_allows};
    use kdl::KdlDocument;
    use std::net::{IpAddr, Ipv4Addr};

    fn first_node(input: &str) -> KdlNode {
        let doc: KdlDocument = input.parse().unwrap();
        doc.nodes().first().unwrap().clone()
    }

    #[test]
    fn ping_kdl() {
        let store = FilterStore::default();
        assert_eq!(
            dispatch_node(&store, &first_node("ping")).unwrap(),
            "ok \"pong\""
        );
    }

    #[test]
    fn reload_children_kdl() {
        let store = FilterStore::default();
        let node = first_node(
            r#"reload {
    allow "/public"
}"#,
        );
        assert_eq!(dispatch_node(&store, &node).unwrap(), "ok \"reloaded\"");
        let ip = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 50));
        assert!(policy_allows(&store.snapshot(), Some(ip), "/public/x"));
        assert!(!policy_allows(&store.snapshot(), Some(ip), "/health"));
    }

    #[test]
    fn reload_rejects_empty_rules() {
        let store = FilterStore::default();
        let node = first_node("reload");
        assert!(matches!(
            dispatch_node(&store, &node),
            Err(KdlCommandError::EmptyReload)
        ));
    }

    #[test]
    fn reload_properties_kdl() {
        let store = FilterStore::default();
        let node = first_node(r#"reload deny="203.0.113.50" allow="/public""#);
        assert_eq!(dispatch_node(&store, &node).unwrap(), "ok \"reloaded\"");
        let denied = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 50));
        assert!(store.read(|p| p.deny.contains(&FilterRule::Ip(denied))));
    }
}