zerodds_security_logging/
jsonl.rs1use 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
14pub struct JsonLinesLoggingPlugin {
25 min_level: LogLevel,
26 writer: Mutex<BufWriter<File>>,
27}
28
29impl JsonLinesLoggingPlugin {
30 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
57fn 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 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 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 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 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}