nautalid 0.1.0

Scratch container substrate — TLS 1.3 HTTP/2+3 kernel, LID/AetherDB, optional filter bus (GPL-3.0-or-later).
//! Runtime filter control on the bus (feature **`filter-control`**).

mod commands;
mod response;

use kdl::KdlNode;

use crate::bus::BusModule;
use crate::filter::{FilterParseError, FilterStore, policy_from_lists};

/// Filter module for [`crate::bus::BusRegistry`].
pub struct FilterModule {
    store: FilterStore,
}

impl FilterModule {
    #[must_use]
    pub fn new(store: FilterStore) -> Self {
        Self { store }
    }

    fn command_node<'a>(
        module_node: &'a KdlNode,
        command_nodes: &[&'a KdlNode],
    ) -> Option<&'a KdlNode> {
        if let Some(cmd) = command_nodes.first() {
            return Some(cmd);
        }
        module_node.children()?.nodes().first()
    }
}

impl BusModule for FilterModule {
    fn name(&self) -> &'static str {
        "filter"
    }

    fn handle(&self, module_node: &KdlNode, command_nodes: &[&KdlNode]) -> String {
        let Some(cmd) = Self::command_node(module_node, command_nodes) else {
            return response::err("filter commands: ping, status, reload { … }");
        };
        handle_node(&self.store, cmd)
    }
}

/// Dispatch one filter command KDL node (also used for legacy top-level `reload` / `status`).
pub fn handle_node(store: &FilterStore, node: &KdlNode) -> String {
    match commands::dispatch_node(store, node) {
        Ok(body) => body,
        Err(e) => response::err(&e.to_string()),
    }
}

/// Legacy text commands: `PING`, `STATUS`, `RELOAD deny=… allow=…`.
pub fn handle_legacy(store: &FilterStore, request: &str) -> String {
    if request.eq_ignore_ascii_case("PING") {
        return response::ok("pong");
    }
    if request.eq_ignore_ascii_case("STATUS") {
        return response::status(store);
    }
    if let Some(rest) = request.strip_prefix("RELOAD") {
        return reload_from_text(store, rest.trim());
    }
    response::err(&format!("unknown command: {request}"))
}

fn reload_from_text(store: &FilterStore, rest: &str) -> String {
    let mut deny_raw = String::new();
    let mut allow_raw = String::new();
    for part in rest.split_whitespace() {
        if let Some(v) = part.strip_prefix("deny=") {
            deny_raw = v.to_string();
        } else if let Some(v) = part.strip_prefix("allow=") {
            allow_raw = v.to_string();
        }
    }
    if deny_raw.is_empty() && allow_raw.is_empty() {
        return response::err("filter reload requires at least one deny or allow entry");
    }
    match policy_from_lists(&deny_raw, &allow_raw) {
        Ok(policy) => match store.replace(policy) {
            Ok(()) => response::ok("reloaded"),
            Err(e) => response::err(&e.to_string()),
        },
        Err(FilterParseError::InvalidEntry(e) | FilterParseError::InvalidCidr(e)) => {
            response::err(&format!("parse error: {e}"))
        }
    }
}

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

    #[test]
    fn ping_pong_legacy() {
        let store = FilterStore::default();
        assert_eq!(handle_legacy(&store, "PING"), "ok \"pong\"");
    }

    #[test]
    fn reload_rejects_empty_legacy() {
        let store = FilterStore::default();
        let out = handle_legacy(&store, "RELOAD");
        assert!(out.starts_with("err "));
        assert!(out.contains("filter reload requires at least one deny or allow entry"));
    }

    #[test]
    fn reload_deny_legacy() {
        let store = FilterStore::default();
        assert_eq!(
            handle_legacy(&store, "RELOAD deny=203.0.113.50"),
            "ok \"reloaded\""
        );
        let denied = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 50));
        assert!(store.read(|p| p.deny.contains(&FilterRule::Ip(denied))));
        assert!(!policy_allows(&store.snapshot(), Some(denied), "/health"));
    }
}