zerodds_security_logging/
from_properties.rs1use alloc::boxed::Box;
25use alloc::string::{String, ToString};
26use core::fmt;
27
28use crate::{
29 FanOutLoggingPlugin, JsonLinesLoggingPlugin, StderrLoggingPlugin, SyslogLoggingPlugin,
30};
31use zerodds_security::logging::{LogLevel, LoggingPlugin};
32
33pub const PROP_LOG_PLUGIN: &str = "dds.sec.log.plugin";
35pub const PROP_LOG_LEVEL: &str = "dds.sec.log.level";
37pub const PROP_LOG_JSONL_PATH: &str = "dds.sec.log.jsonl.path";
39pub const PROP_LOG_SYSLOG_ADDR: &str = "dds.sec.log.syslog.addr";
41pub const PROP_LOG_SYSLOG_APP: &str = "dds.sec.log.syslog.app";
43pub const PROP_LOG_SYSLOG_HOST: &str = "dds.sec.log.syslog.host";
45
46#[derive(Debug)]
48pub enum LogConfigError {
49 UnknownSink(String),
51 UnknownLevel(String),
53 MissingProperty(&'static str),
55 BadAddress(String),
57 Io(std::io::Error),
59}
60
61impl fmt::Display for LogConfigError {
62 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63 match self {
64 Self::UnknownSink(s) => write!(f, "unknown log sink '{s}'"),
65 Self::UnknownLevel(s) => write!(f, "unknown log level '{s}'"),
66 Self::MissingProperty(p) => write!(f, "missing required property '{p}'"),
67 Self::BadAddress(s) => write!(f, "malformed syslog address '{s}'"),
68 Self::Io(e) => write!(f, "log sink i/o error: {e}"),
69 }
70 }
71}
72
73impl std::error::Error for LogConfigError {}
74
75pub fn parse_log_level(s: &str) -> Result<LogLevel, LogConfigError> {
78 Ok(match s.trim().to_ascii_lowercase().as_str() {
79 "emergency" => LogLevel::Emergency,
80 "alert" => LogLevel::Alert,
81 "critical" => LogLevel::Critical,
82 "error" => LogLevel::Error,
83 "warning" => LogLevel::Warning,
84 "notice" => LogLevel::Notice,
85 "informational" | "info" => LogLevel::Informational,
86 "debug" => LogLevel::Debug,
87 _ => return Err(LogConfigError::UnknownLevel(s.to_string())),
88 })
89}
90
91fn lookup<'a>(props: &'a [(&'a str, &'a str)], key: &str) -> Option<&'a str> {
92 props.iter().find(|(k, _)| *k == key).map(|(_, v)| *v)
93}
94
95pub fn logging_plugin_from_properties(
99 props: &[(&str, &str)],
100) -> Result<Option<Box<dyn LoggingPlugin>>, LogConfigError> {
101 let Some(sinks) = lookup(props, PROP_LOG_PLUGIN) else {
102 return Ok(None);
103 };
104 let level = match lookup(props, PROP_LOG_LEVEL) {
105 Some(s) => parse_log_level(s)?,
106 None => LogLevel::Informational,
107 };
108
109 let mut fanout = FanOutLoggingPlugin::new();
110 let mut configured = 0usize;
111 for sink in sinks.split(',').map(str::trim).filter(|s| !s.is_empty()) {
112 match sink {
113 "stderr" => {
114 fanout = fanout.with(StderrLoggingPlugin::with_level(level));
115 }
116 "jsonl" => {
117 let path = lookup(props, PROP_LOG_JSONL_PATH)
118 .ok_or(LogConfigError::MissingProperty(PROP_LOG_JSONL_PATH))?;
119 let jsonl =
120 JsonLinesLoggingPlugin::open(path, level).map_err(LogConfigError::Io)?;
121 fanout = fanout.with(jsonl);
122 }
123 "syslog" => {
124 let addr = lookup(props, PROP_LOG_SYSLOG_ADDR)
125 .ok_or(LogConfigError::MissingProperty(PROP_LOG_SYSLOG_ADDR))?;
126 let target = addr
127 .parse()
128 .map_err(|_| LogConfigError::BadAddress(addr.to_string()))?;
129 let app = lookup(props, PROP_LOG_SYSLOG_APP).unwrap_or("zerodds");
130 let host = lookup(props, PROP_LOG_SYSLOG_HOST).unwrap_or("localhost");
131 let syslog = SyslogLoggingPlugin::connect(target, app, host, level)
132 .map_err(LogConfigError::Io)?;
133 fanout = fanout.with(syslog);
134 }
135 other => return Err(LogConfigError::UnknownSink(other.to_string())),
136 }
137 configured += 1;
138 }
139
140 if configured == 0 {
141 return Ok(None);
142 }
143 Ok(Some(Box::new(fanout)))
144}
145
146#[cfg(test)]
147#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
148mod tests {
149 use super::*;
150
151 #[test]
152 fn absent_plugin_property_yields_none() {
153 assert!(logging_plugin_from_properties(&[]).unwrap().is_none());
154 assert!(
155 logging_plugin_from_properties(&[("dds.sec.log.level", "Warning")])
156 .unwrap()
157 .is_none()
158 );
159 }
160
161 #[test]
162 fn stderr_sink_builds() {
163 let p = logging_plugin_from_properties(&[
164 (PROP_LOG_PLUGIN, "stderr"),
165 (PROP_LOG_LEVEL, "Warning"),
166 ])
167 .unwrap();
168 assert!(p.is_some());
169 assert_eq!(p.unwrap().plugin_class_id(), "DDS:Logging:fanout");
170 }
171
172 #[test]
173 fn jsonl_without_path_errors() {
174 match logging_plugin_from_properties(&[(PROP_LOG_PLUGIN, "jsonl")]) {
176 Err(LogConfigError::MissingProperty(p)) => assert_eq!(p, PROP_LOG_JSONL_PATH),
177 _ => panic!("expected MissingProperty error"),
178 }
179 }
180
181 #[test]
182 fn jsonl_sink_writes_to_file() {
183 let dir = std::env::temp_dir().join(format!("zerodds-log-test-{}", std::process::id()));
184 std::fs::create_dir_all(&dir).unwrap();
185 let path = dir.join("audit.ndjson");
186 let path_s = path.to_str().unwrap();
187 let plugin = logging_plugin_from_properties(&[
188 (PROP_LOG_PLUGIN, "stderr,jsonl"),
189 (PROP_LOG_LEVEL, "Notice"),
190 (PROP_LOG_JSONL_PATH, path_s),
191 ])
192 .unwrap()
193 .expect("fanout built");
194 plugin.log(LogLevel::Error, [0u8; 16], "access_control", "denied");
195 let contents = std::fs::read_to_string(&path).unwrap();
197 assert!(contents.contains("access_control"), "got: {contents}");
198 let _ = std::fs::remove_dir_all(&dir);
199 }
200
201 #[test]
202 fn unknown_sink_errors() {
203 match logging_plugin_from_properties(&[(PROP_LOG_PLUGIN, "carrier-pigeon")]) {
204 Err(LogConfigError::UnknownSink(s)) => assert_eq!(s, "carrier-pigeon"),
205 _ => panic!("expected UnknownSink error"),
206 }
207 }
208
209 #[test]
210 fn level_parsing() {
211 assert!(matches!(parse_log_level("Warning"), Ok(LogLevel::Warning)));
212 assert!(matches!(
213 parse_log_level("info"),
214 Ok(LogLevel::Informational)
215 ));
216 assert!(parse_log_level("nonsense").is_err());
217 }
218}