use alloc::boxed::Box;
use alloc::string::{String, ToString};
use core::fmt;
use crate::{
FanOutLoggingPlugin, JsonLinesLoggingPlugin, StderrLoggingPlugin, SyslogLoggingPlugin,
};
use zerodds_security::logging::{LogLevel, LoggingPlugin};
pub const PROP_LOG_PLUGIN: &str = "dds.sec.log.plugin";
pub const PROP_LOG_LEVEL: &str = "dds.sec.log.level";
pub const PROP_LOG_JSONL_PATH: &str = "dds.sec.log.jsonl.path";
pub const PROP_LOG_SYSLOG_ADDR: &str = "dds.sec.log.syslog.addr";
pub const PROP_LOG_SYSLOG_APP: &str = "dds.sec.log.syslog.app";
pub const PROP_LOG_SYSLOG_HOST: &str = "dds.sec.log.syslog.host";
#[derive(Debug)]
pub enum LogConfigError {
UnknownSink(String),
UnknownLevel(String),
MissingProperty(&'static str),
BadAddress(String),
Io(std::io::Error),
}
impl fmt::Display for LogConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnknownSink(s) => write!(f, "unknown log sink '{s}'"),
Self::UnknownLevel(s) => write!(f, "unknown log level '{s}'"),
Self::MissingProperty(p) => write!(f, "missing required property '{p}'"),
Self::BadAddress(s) => write!(f, "malformed syslog address '{s}'"),
Self::Io(e) => write!(f, "log sink i/o error: {e}"),
}
}
}
impl std::error::Error for LogConfigError {}
pub fn parse_log_level(s: &str) -> Result<LogLevel, LogConfigError> {
Ok(match s.trim().to_ascii_lowercase().as_str() {
"emergency" => LogLevel::Emergency,
"alert" => LogLevel::Alert,
"critical" => LogLevel::Critical,
"error" => LogLevel::Error,
"warning" => LogLevel::Warning,
"notice" => LogLevel::Notice,
"informational" | "info" => LogLevel::Informational,
"debug" => LogLevel::Debug,
_ => return Err(LogConfigError::UnknownLevel(s.to_string())),
})
}
fn lookup<'a>(props: &'a [(&'a str, &'a str)], key: &str) -> Option<&'a str> {
props.iter().find(|(k, _)| *k == key).map(|(_, v)| *v)
}
pub fn logging_plugin_from_properties(
props: &[(&str, &str)],
) -> Result<Option<Box<dyn LoggingPlugin>>, LogConfigError> {
let Some(sinks) = lookup(props, PROP_LOG_PLUGIN) else {
return Ok(None);
};
let level = match lookup(props, PROP_LOG_LEVEL) {
Some(s) => parse_log_level(s)?,
None => LogLevel::Informational,
};
let mut fanout = FanOutLoggingPlugin::new();
let mut configured = 0usize;
for sink in sinks.split(',').map(str::trim).filter(|s| !s.is_empty()) {
match sink {
"stderr" => {
fanout = fanout.with(StderrLoggingPlugin::with_level(level));
}
"jsonl" => {
let path = lookup(props, PROP_LOG_JSONL_PATH)
.ok_or(LogConfigError::MissingProperty(PROP_LOG_JSONL_PATH))?;
let jsonl =
JsonLinesLoggingPlugin::open(path, level).map_err(LogConfigError::Io)?;
fanout = fanout.with(jsonl);
}
"syslog" => {
let addr = lookup(props, PROP_LOG_SYSLOG_ADDR)
.ok_or(LogConfigError::MissingProperty(PROP_LOG_SYSLOG_ADDR))?;
let target = addr
.parse()
.map_err(|_| LogConfigError::BadAddress(addr.to_string()))?;
let app = lookup(props, PROP_LOG_SYSLOG_APP).unwrap_or("zerodds");
let host = lookup(props, PROP_LOG_SYSLOG_HOST).unwrap_or("localhost");
let syslog = SyslogLoggingPlugin::connect(target, app, host, level)
.map_err(LogConfigError::Io)?;
fanout = fanout.with(syslog);
}
other => return Err(LogConfigError::UnknownSink(other.to_string())),
}
configured += 1;
}
if configured == 0 {
return Ok(None);
}
Ok(Some(Box::new(fanout)))
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn absent_plugin_property_yields_none() {
assert!(logging_plugin_from_properties(&[]).unwrap().is_none());
assert!(
logging_plugin_from_properties(&[("dds.sec.log.level", "Warning")])
.unwrap()
.is_none()
);
}
#[test]
fn stderr_sink_builds() {
let p = logging_plugin_from_properties(&[
(PROP_LOG_PLUGIN, "stderr"),
(PROP_LOG_LEVEL, "Warning"),
])
.unwrap();
assert!(p.is_some());
assert_eq!(p.unwrap().plugin_class_id(), "DDS:Logging:fanout");
}
#[test]
fn jsonl_without_path_errors() {
match logging_plugin_from_properties(&[(PROP_LOG_PLUGIN, "jsonl")]) {
Err(LogConfigError::MissingProperty(p)) => assert_eq!(p, PROP_LOG_JSONL_PATH),
_ => panic!("expected MissingProperty error"),
}
}
#[test]
fn jsonl_sink_writes_to_file() {
let dir = std::env::temp_dir().join(format!("zerodds-log-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("audit.ndjson");
let path_s = path.to_str().unwrap();
let plugin = logging_plugin_from_properties(&[
(PROP_LOG_PLUGIN, "stderr,jsonl"),
(PROP_LOG_LEVEL, "Notice"),
(PROP_LOG_JSONL_PATH, path_s),
])
.unwrap()
.expect("fanout built");
plugin.log(LogLevel::Error, [0u8; 16], "access_control", "denied");
let contents = std::fs::read_to_string(&path).unwrap();
assert!(contents.contains("access_control"), "got: {contents}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn unknown_sink_errors() {
match logging_plugin_from_properties(&[(PROP_LOG_PLUGIN, "carrier-pigeon")]) {
Err(LogConfigError::UnknownSink(s)) => assert_eq!(s, "carrier-pigeon"),
_ => panic!("expected UnknownSink error"),
}
}
#[test]
fn level_parsing() {
assert!(matches!(parse_log_level("Warning"), Ok(LogLevel::Warning)));
assert!(matches!(
parse_log_level("info"),
Ok(LogLevel::Informational)
));
assert!(parse_log_level("nonsense").is_err());
}
}