Skip to main content

idb/util/
audit.rs

1//! Audit logging for write operations.
2//!
3//! Provides [`AuditLogger`] which writes NDJSON events to a log file for
4//! compliance audit trails. Every write operation (repair, defrag, transplant,
5//! corrupt) emits structured events recording what was changed, when, and by
6//! which invocation.
7
8use std::fs::{File, OpenOptions};
9use std::io::Write;
10use std::sync::Mutex;
11use std::time::Instant;
12
13use chrono::Local;
14use fs2::FileExt;
15use serde::Serialize;
16
17use crate::IdbError;
18
19/// A single audit log event, serialized as tagged NDJSON.
20#[derive(Serialize)]
21#[serde(tag = "event")]
22pub enum AuditEvent {
23    /// Emitted once at the start of a CLI invocation.
24    #[serde(rename = "session_start")]
25    SessionStart {
26        timestamp: String,
27        args: Vec<String>,
28        version: String,
29    },
30
31    /// Emitted when a single page is written (repair, transplant, corrupt).
32    #[serde(rename = "page_write")]
33    PageWrite {
34        timestamp: String,
35        file: String,
36        page_number: u64,
37        operation: String,
38        #[serde(skip_serializing_if = "Option::is_none")]
39        old_checksum: Option<u32>,
40        #[serde(skip_serializing_if = "Option::is_none")]
41        new_checksum: Option<u32>,
42    },
43
44    /// Emitted when a whole file is written (defrag).
45    #[serde(rename = "file_write")]
46    FileWrite {
47        timestamp: String,
48        file: String,
49        operation: String,
50        pages_written: u64,
51    },
52
53    /// Emitted when a backup file is created.
54    #[serde(rename = "backup_created")]
55    BackupCreated {
56        timestamp: String,
57        source: String,
58        backup_path: String,
59    },
60
61    /// Emitted once at the end of a CLI invocation.
62    #[serde(rename = "session_end")]
63    SessionEnd {
64        timestamp: String,
65        duration_ms: u64,
66        pages_written: u64,
67        files_written: u64,
68    },
69}
70
71struct AuditLoggerInner {
72    file: File,
73    pages_written: u64,
74    files_written: u64,
75}
76
77/// Thread-safe audit logger that appends NDJSON events to a file.
78///
79/// File-level locking (via `fs2`) ensures safe concurrent access from
80/// multiple processes.
81pub struct AuditLogger {
82    inner: Mutex<AuditLoggerInner>,
83    start: Instant,
84}
85
86impl AuditLogger {
87    /// Open (or create) the audit log file in append mode.
88    pub fn open(path: &str) -> Result<Self, IdbError> {
89        let file = OpenOptions::new()
90            .create(true)
91            .append(true)
92            .open(path)
93            .map_err(|e| IdbError::Io(format!("Cannot open audit log {}: {}", path, e)))?;
94
95        Ok(Self {
96            inner: Mutex::new(AuditLoggerInner {
97                file,
98                pages_written: 0,
99                files_written: 0,
100            }),
101            start: Instant::now(),
102        })
103    }
104
105    /// Emit a single audit event as one NDJSON line.
106    pub fn emit(&self, event: &AuditEvent) -> Result<(), IdbError> {
107        let line = serde_json::to_string(event)
108            .map_err(|e| IdbError::Parse(format!("Audit JSON error: {}", e)))?;
109
110        let mut inner = self.inner.lock().unwrap();
111        inner
112            .file
113            .lock_exclusive()
114            .map_err(|e| IdbError::Io(format!("Audit log lock error: {}", e)))?;
115        writeln!(inner.file, "{}", line)
116            .map_err(|e| IdbError::Io(format!("Audit log write error: {}", e)))?;
117        inner
118            .file
119            .flush()
120            .map_err(|e| IdbError::Io(format!("Audit log flush error: {}", e)))?;
121        inner
122            .file
123            .unlock()
124            .map_err(|e| IdbError::Io(format!("Audit log unlock error: {}", e)))?;
125
126        Ok(())
127    }
128
129    /// Emit a `session_start` event.
130    pub fn start_session(&self, args: Vec<String>) -> Result<(), IdbError> {
131        self.emit(&AuditEvent::SessionStart {
132            timestamp: now(),
133            args,
134            version: env!("CARGO_PKG_VERSION").to_string(),
135        })
136    }
137
138    /// Emit a `session_end` event with accumulated counters.
139    pub fn end_session(&self) -> Result<(), IdbError> {
140        let inner = self.inner.lock().unwrap();
141        let duration_ms = self.start.elapsed().as_millis() as u64;
142        let event = AuditEvent::SessionEnd {
143            timestamp: now(),
144            duration_ms,
145            pages_written: inner.pages_written,
146            files_written: inner.files_written,
147        };
148        drop(inner);
149        self.emit(&event)
150    }
151
152    /// Log a page-level write operation.
153    pub fn log_page_write(
154        &self,
155        file: &str,
156        page_number: u64,
157        operation: &str,
158        old_checksum: Option<u32>,
159        new_checksum: Option<u32>,
160    ) -> Result<(), IdbError> {
161        self.emit(&AuditEvent::PageWrite {
162            timestamp: now(),
163            file: file.to_string(),
164            page_number,
165            operation: operation.to_string(),
166            old_checksum,
167            new_checksum,
168        })?;
169        self.inner.lock().unwrap().pages_written += 1;
170        Ok(())
171    }
172
173    /// Log a whole-file write operation (e.g., defrag output).
174    pub fn log_file_write(
175        &self,
176        file: &str,
177        operation: &str,
178        pages_written: u64,
179    ) -> Result<(), IdbError> {
180        self.emit(&AuditEvent::FileWrite {
181            timestamp: now(),
182            file: file.to_string(),
183            operation: operation.to_string(),
184            pages_written,
185        })?;
186        self.inner.lock().unwrap().files_written += 1;
187        Ok(())
188    }
189
190    /// Log a backup file creation.
191    pub fn log_backup(&self, source: &str, backup_path: &str) -> Result<(), IdbError> {
192        self.emit(&AuditEvent::BackupCreated {
193            timestamp: now(),
194            source: source.to_string(),
195            backup_path: backup_path.to_string(),
196        })
197    }
198}
199
200fn now() -> String {
201    Local::now().to_rfc3339()
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use std::io::{BufRead, BufReader};
208    use tempfile::NamedTempFile;
209
210    fn temp_logger() -> (AuditLogger, String) {
211        let tmp = NamedTempFile::new().unwrap();
212        let path = tmp.path().to_str().unwrap().to_string();
213        // Keep the file handle alive via the path; logger opens independently
214        drop(tmp);
215        let logger = AuditLogger::open(&path).unwrap();
216        (logger, path)
217    }
218
219    #[test]
220    fn test_writes_ndjson_lines() {
221        let (logger, path) = temp_logger();
222        logger
223            .start_session(vec!["inno".into(), "repair".into()])
224            .unwrap();
225        logger
226            .log_page_write("test.ibd", 1, "repair", Some(0xDEAD), Some(0xBEEF))
227            .unwrap();
228        logger.end_session().unwrap();
229
230        let file = File::open(&path).unwrap();
231        let lines: Vec<String> = BufReader::new(file).lines().map(|l| l.unwrap()).collect();
232        assert_eq!(lines.len(), 3);
233
234        // Verify each line is valid JSON with expected event tag
235        let v0: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
236        assert_eq!(v0["event"], "session_start");
237
238        let v1: serde_json::Value = serde_json::from_str(&lines[1]).unwrap();
239        assert_eq!(v1["event"], "page_write");
240        assert_eq!(v1["page_number"], 1);
241
242        let v2: serde_json::Value = serde_json::from_str(&lines[2]).unwrap();
243        assert_eq!(v2["event"], "session_end");
244        assert_eq!(v2["pages_written"], 1);
245        assert_eq!(v2["files_written"], 0);
246    }
247
248    #[test]
249    fn test_append_mode() {
250        let tmp = NamedTempFile::new().unwrap();
251        let path = tmp.path().to_str().unwrap().to_string();
252        drop(tmp);
253
254        // First logger writes some events
255        {
256            let logger = AuditLogger::open(&path).unwrap();
257            logger.start_session(vec!["session1".into()]).unwrap();
258        }
259
260        // Second logger appends
261        {
262            let logger = AuditLogger::open(&path).unwrap();
263            logger.start_session(vec!["session2".into()]).unwrap();
264        }
265
266        let file = File::open(&path).unwrap();
267        let lines: Vec<String> = BufReader::new(file).lines().map(|l| l.unwrap()).collect();
268        assert_eq!(lines.len(), 2);
269    }
270
271    #[test]
272    fn test_session_counters() {
273        let (logger, path) = temp_logger();
274        logger.start_session(vec![]).unwrap();
275        logger
276            .log_page_write("a.ibd", 0, "repair", None, None)
277            .unwrap();
278        logger
279            .log_page_write("a.ibd", 1, "repair", None, None)
280            .unwrap();
281        logger.log_file_write("out.ibd", "defrag", 10).unwrap();
282        logger.end_session().unwrap();
283
284        let file = File::open(&path).unwrap();
285        let lines: Vec<String> = BufReader::new(file).lines().map(|l| l.unwrap()).collect();
286        let last: serde_json::Value = serde_json::from_str(lines.last().unwrap()).unwrap();
287        assert_eq!(last["pages_written"], 2);
288        assert_eq!(last["files_written"], 1);
289    }
290
291    #[test]
292    fn test_thread_safety() {
293        use std::sync::Arc;
294        use std::thread;
295
296        let (logger, path) = temp_logger();
297        let logger = Arc::new(logger);
298
299        let mut handles = Vec::new();
300        for i in 0..10 {
301            let lg = Arc::clone(&logger);
302            handles.push(thread::spawn(move || {
303                lg.log_page_write("test.ibd", i, "repair", None, None)
304                    .unwrap();
305            }));
306        }
307
308        for h in handles {
309            h.join().unwrap();
310        }
311
312        let file = File::open(&path).unwrap();
313        let lines: Vec<String> = BufReader::new(file).lines().map(|l| l.unwrap()).collect();
314        assert_eq!(lines.len(), 10);
315
316        // Every line should be valid JSON
317        for line in &lines {
318            let _: serde_json::Value = serde_json::from_str(line).unwrap();
319        }
320    }
321
322    #[test]
323    fn test_backup_event() {
324        let (logger, path) = temp_logger();
325        logger.log_backup("test.ibd", "test.ibd.bak").unwrap();
326
327        let file = File::open(&path).unwrap();
328        let lines: Vec<String> = BufReader::new(file).lines().map(|l| l.unwrap()).collect();
329        assert_eq!(lines.len(), 1);
330
331        let v: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
332        assert_eq!(v["event"], "backup_created");
333        assert_eq!(v["source"], "test.ibd");
334    }
335
336    #[test]
337    fn test_file_write_event() {
338        let (logger, path) = temp_logger();
339        logger.log_file_write("output.ibd", "defrag", 42).unwrap();
340
341        let file = File::open(&path).unwrap();
342        let lines: Vec<String> = BufReader::new(file).lines().map(|l| l.unwrap()).collect();
343        assert_eq!(lines.len(), 1);
344
345        let v: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
346        assert_eq!(v["event"], "file_write");
347        assert_eq!(v["pages_written"], 42);
348    }
349}