Skip to main content

zerodds_security_logging/
from_properties.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Build a security-event [`LoggingPlugin`] from DDS-Security `dds.sec.log.*`
5//! properties.
6//!
7//! This is the spec-style wireup: instead of constructing a logger object and
8//! handing it to the runtime directly, the participant carries `dds.sec.log.*`
9//! name/value properties on its QoS, and the runtime materializes the logger
10//! from them. Multiple sinks fan out automatically.
11//!
12//! | Property | Meaning |
13//! |---|---|
14//! | `dds.sec.log.plugin` | comma-separated sinks: `stderr`, `jsonl`, `syslog` |
15//! | `dds.sec.log.level` | minimum level (default `Informational`) |
16//! | `dds.sec.log.jsonl.path` | output file for the `jsonl` sink |
17//! | `dds.sec.log.syslog.addr` | `host:port` for the `syslog` sink |
18//! | `dds.sec.log.syslog.app` | app name (default `zerodds`) |
19//! | `dds.sec.log.syslog.host` | hostname field (default `localhost`) |
20
21// Returns a `Box<dyn LoggingPlugin>` built at runtime from properties — the concrete sink type is unknown until the `dds.sec.log.*` values are parsed.
22// zerodds-lint: allow no_dyn_in_safe
23
24use 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
33/// `dds.sec.log.plugin` — comma-separated sink list (`stderr,jsonl,syslog`).
34pub const PROP_LOG_PLUGIN: &str = "dds.sec.log.plugin";
35/// `dds.sec.log.level` — minimum level. Default: `Informational`.
36pub const PROP_LOG_LEVEL: &str = "dds.sec.log.level";
37/// `dds.sec.log.jsonl.path` — output file for the `jsonl` sink.
38pub const PROP_LOG_JSONL_PATH: &str = "dds.sec.log.jsonl.path";
39/// `dds.sec.log.syslog.addr` — `host:port` target for the `syslog` sink.
40pub const PROP_LOG_SYSLOG_ADDR: &str = "dds.sec.log.syslog.addr";
41/// `dds.sec.log.syslog.app` — RFC-5424 app name (default `zerodds`).
42pub const PROP_LOG_SYSLOG_APP: &str = "dds.sec.log.syslog.app";
43/// `dds.sec.log.syslog.host` — RFC-5424 hostname (default `localhost`).
44pub const PROP_LOG_SYSLOG_HOST: &str = "dds.sec.log.syslog.host";
45
46/// Error materializing a logger from `dds.sec.log.*` properties.
47#[derive(Debug)]
48pub enum LogConfigError {
49    /// Unknown sink name in `dds.sec.log.plugin`.
50    UnknownSink(String),
51    /// Unparseable `dds.sec.log.level`.
52    UnknownLevel(String),
53    /// A required property for a selected sink is missing.
54    MissingProperty(&'static str),
55    /// Malformed `dds.sec.log.syslog.addr`.
56    BadAddress(String),
57    /// I/O error opening a sink (e.g. the jsonl file or syslog socket).
58    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
75/// Parse a DDS-Security log-level name (case-insensitive; `info` aliases
76/// `informational`).
77pub 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
95/// Build a [`LoggingPlugin`] from `dds.sec.log.*` properties, fanning out to
96/// every named sink. Returns `Ok(None)` if `dds.sec.log.plugin` is absent or
97/// names no sinks.
98pub 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        // `Box<dyn LoggingPlugin>` isn't Debug, so match instead of unwrap_err.
175        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        // Flush is implicit per-line; the file must now contain our event.
196        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}