Skip to main content

zerodds_security_logging/
jsonl.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! JSON-Lines-File-Backend (Audit-tauglich).
5
6use alloc::string::String;
7use std::fs::{File, OpenOptions};
8use std::io::{BufWriter, Write};
9use std::path::Path;
10use std::sync::Mutex;
11
12use zerodds_security::logging::{LogLevel, LoggingPlugin};
13
14/// Schreibt Security-Events als JSON-Lines in eine Datei.
15///
16/// Jede Zeile ist ein eigenstaendiges JSON-Objekt:
17/// ```json
18/// {"ts":"2026-04-24T11:22:33Z","level":"CRITICAL","participant":"aabbcc...","category":"auth.handshake.failed","message":"bad cert"}
19/// ```
20///
21/// Typische Nutzung: `auditd`/`filebeat`/`promtail` kollektiert das
22/// File. Dieses Plugin selbst fuehrt **keine** Log-Rotation durch โ€”
23/// das ist Aufgabe von `logrotate(8)` oder systemd-journald.
24pub struct JsonLinesLoggingPlugin {
25    min_level: LogLevel,
26    writer: Mutex<BufWriter<File>>,
27}
28
29impl JsonLinesLoggingPlugin {
30    /// Oeffnet / legt die Datei an und wrap in BufWriter.
31    ///
32    /// # Errors
33    /// `io::Error` wenn die Datei nicht geoeffnet werden kann
34    /// (Permissions, Parent-Dir fehlt).
35    pub fn open<P: AsRef<Path>>(path: P, min_level: LogLevel) -> std::io::Result<Self> {
36        let file = OpenOptions::new().create(true).append(true).open(path)?;
37        Ok(Self {
38            min_level,
39            writer: Mutex::new(BufWriter::new(file)),
40        })
41    }
42}
43
44fn level_name(l: LogLevel) -> &'static str {
45    match l {
46        LogLevel::Emergency => "EMERGENCY",
47        LogLevel::Alert => "ALERT",
48        LogLevel::Critical => "CRITICAL",
49        LogLevel::Error => "ERROR",
50        LogLevel::Warning => "WARNING",
51        LogLevel::Notice => "NOTICE",
52        LogLevel::Informational => "INFORMATIONAL",
53        LogLevel::Debug => "DEBUG",
54    }
55}
56
57/// Simple JSON-Escaping: nur die Control-Chars + `"` + `\`.
58/// Ausreichend fuer Log-Messages; wir brauchen kein serde fuer
59/// einen 5-Feld-Emit.
60fn escape_json(s: &str) -> String {
61    let mut out = String::with_capacity(s.len() + 2);
62    for c in s.chars() {
63        match c {
64            '"' => out.push_str("\\\""),
65            '\\' => out.push_str("\\\\"),
66            '\n' => out.push_str("\\n"),
67            '\r' => out.push_str("\\r"),
68            '\t' => out.push_str("\\t"),
69            c if (c as u32) < 0x20 => {
70                out.push_str(&alloc::format!("\\u{:04x}", c as u32));
71            }
72            c => out.push(c),
73        }
74    }
75    out
76}
77
78fn hex16(bytes: [u8; 16]) -> String {
79    let mut s = String::with_capacity(32);
80    for b in bytes {
81        s.push_str(&alloc::format!("{b:02x}"));
82    }
83    s
84}
85
86impl LoggingPlugin for JsonLinesLoggingPlugin {
87    fn log(&self, level: LogLevel, participant: [u8; 16], category: &str, message: &str) {
88        if level > self.min_level {
89            return;
90        }
91        let line = alloc::format!(
92            r#"{{"level":"{lvl}","participant":"{pid}","category":"{cat}","message":"{msg}"}}"#,
93            lvl = level_name(level),
94            pid = hex16(participant),
95            cat = escape_json(category),
96            msg = escape_json(message),
97        );
98        if let Ok(mut w) = self.writer.lock() {
99            // `writeln!` + `flush` โ€” BufWriter wuerde sonst bei
100            // Crashes die letzten Events verlieren.
101            let _ = writeln!(w, "{line}");
102            let _ = w.flush();
103        }
104    }
105
106    fn plugin_class_id(&self) -> &str {
107        "DDS:Logging:jsonl"
108    }
109}
110
111#[cfg(test)]
112#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
113mod tests {
114    use super::*;
115    use std::io::Read;
116
117    fn tmp_path(name: &str) -> std::path::PathBuf {
118        let mut p = std::env::temp_dir();
119        p.push(alloc::format!(
120            "zerodds_sec_log_{}_{name}.jsonl",
121            std::process::id()
122        ));
123        // clean leftovers
124        let _ = std::fs::remove_file(&p);
125        p
126    }
127
128    #[test]
129    fn writes_json_lines_for_events_above_threshold() {
130        let path = tmp_path("threshold");
131        let plugin = JsonLinesLoggingPlugin::open(&path, LogLevel::Warning).expect("open");
132        plugin.log(LogLevel::Critical, [0xAA; 16], "auth.fail", "bad cert");
133        plugin.log(LogLevel::Informational, [0xBB; 16], "debug", "ignored");
134        // flush durch Drop.
135        drop(plugin);
136
137        let mut content = String::new();
138        std::fs::File::open(&path)
139            .unwrap()
140            .read_to_string(&mut content)
141            .unwrap();
142        let lines: Vec<&str> = content.lines().collect();
143        assert_eq!(lines.len(), 1, "nur Critical sollte durchkommen");
144        assert!(lines[0].contains("\"level\":\"CRITICAL\""));
145        assert!(lines[0].contains("\"category\":\"auth.fail\""));
146        assert!(lines[0].contains("\"message\":\"bad cert\""));
147
148        let _ = std::fs::remove_file(&path);
149    }
150
151    #[test]
152    fn json_escapes_quotes_and_backslash() {
153        let path = tmp_path("escape");
154        let plugin = JsonLinesLoggingPlugin::open(&path, LogLevel::Debug).expect("open");
155        plugin.log(LogLevel::Warning, [0u8; 16], "raw", "he said \"hi\\hello\"");
156        drop(plugin);
157
158        let content = std::fs::read_to_string(&path).unwrap();
159        assert!(content.contains(r#"\"hi\\hello\""#));
160        let _ = std::fs::remove_file(&path);
161    }
162
163    #[test]
164    fn json_escapes_newline_as_backslash_n() {
165        let path = tmp_path("nl");
166        let plugin = JsonLinesLoggingPlugin::open(&path, LogLevel::Debug).expect("open");
167        plugin.log(LogLevel::Warning, [0u8; 16], "multi\nline", "a\nb");
168        drop(plugin);
169
170        let content = std::fs::read_to_string(&path).unwrap();
171        assert!(content.contains(r"multi\nline"));
172        assert!(content.contains(r#""message":"a\nb""#));
173        // Genau EINE Zeile โ€” kein echter LF im Output.
174        assert_eq!(content.lines().count(), 1);
175        let _ = std::fs::remove_file(&path);
176    }
177
178    #[test]
179    fn plugin_class_id_stable() {
180        let path = tmp_path("class");
181        let plugin = JsonLinesLoggingPlugin::open(&path, LogLevel::Warning).expect("open");
182        assert_eq!(plugin.plugin_class_id(), "DDS:Logging:jsonl");
183        drop(plugin);
184        let _ = std::fs::remove_file(&path);
185    }
186}