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,
}
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))));
}
}