Skip to main content

zeph_tools/
audit.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::path::Path;
5
6use crate::config::AuditConfig;
7
8#[derive(Debug)]
9pub struct AuditLogger {
10    destination: AuditDestination,
11}
12
13#[derive(Debug)]
14enum AuditDestination {
15    Stdout,
16    File(tokio::sync::Mutex<tokio::fs::File>),
17}
18
19#[derive(serde::Serialize)]
20pub struct AuditEntry {
21    pub timestamp: String,
22    pub tool: String,
23    pub command: String,
24    pub result: AuditResult,
25    pub duration_ms: u64,
26}
27
28#[derive(serde::Serialize)]
29#[serde(tag = "type")]
30pub enum AuditResult {
31    #[serde(rename = "success")]
32    Success,
33    #[serde(rename = "blocked")]
34    Blocked { reason: String },
35    #[serde(rename = "error")]
36    Error { message: String },
37    #[serde(rename = "timeout")]
38    Timeout,
39}
40
41impl AuditLogger {
42    /// Create a new `AuditLogger` from config.
43    ///
44    /// # Errors
45    ///
46    /// Returns an error if a file destination cannot be opened.
47    pub async fn from_config(config: &AuditConfig) -> Result<Self, std::io::Error> {
48        let destination = if config.destination == "stdout" {
49            AuditDestination::Stdout
50        } else {
51            let file = tokio::fs::OpenOptions::new()
52                .create(true)
53                .append(true)
54                .open(Path::new(&config.destination))
55                .await?;
56            AuditDestination::File(tokio::sync::Mutex::new(file))
57        };
58
59        Ok(Self { destination })
60    }
61
62    pub async fn log(&self, entry: &AuditEntry) {
63        let Ok(json) = serde_json::to_string(entry) else {
64            return;
65        };
66
67        match &self.destination {
68            AuditDestination::Stdout => {
69                tracing::info!(target: "audit", "{json}");
70            }
71            AuditDestination::File(file) => {
72                use tokio::io::AsyncWriteExt;
73                let mut f = file.lock().await;
74                let line = format!("{json}\n");
75                if let Err(e) = f.write_all(line.as_bytes()).await {
76                    tracing::error!("failed to write audit log: {e}");
77                } else if let Err(e) = f.flush().await {
78                    tracing::error!("failed to flush audit log: {e}");
79                }
80            }
81        }
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn audit_entry_serialization() {
91        let entry = AuditEntry {
92            timestamp: "1234567890".into(),
93            tool: "shell".into(),
94            command: "echo hello".into(),
95            result: AuditResult::Success,
96            duration_ms: 42,
97        };
98        let json = serde_json::to_string(&entry).unwrap();
99        assert!(json.contains("\"type\":\"success\""));
100        assert!(json.contains("\"tool\":\"shell\""));
101        assert!(json.contains("\"duration_ms\":42"));
102    }
103
104    #[test]
105    fn audit_result_blocked_serialization() {
106        let entry = AuditEntry {
107            timestamp: "0".into(),
108            tool: "shell".into(),
109            command: "sudo rm".into(),
110            result: AuditResult::Blocked {
111                reason: "blocked command: sudo".into(),
112            },
113            duration_ms: 0,
114        };
115        let json = serde_json::to_string(&entry).unwrap();
116        assert!(json.contains("\"type\":\"blocked\""));
117        assert!(json.contains("\"reason\""));
118    }
119
120    #[test]
121    fn audit_result_error_serialization() {
122        let entry = AuditEntry {
123            timestamp: "0".into(),
124            tool: "shell".into(),
125            command: "bad".into(),
126            result: AuditResult::Error {
127                message: "exec failed".into(),
128            },
129            duration_ms: 0,
130        };
131        let json = serde_json::to_string(&entry).unwrap();
132        assert!(json.contains("\"type\":\"error\""));
133    }
134
135    #[test]
136    fn audit_result_timeout_serialization() {
137        let entry = AuditEntry {
138            timestamp: "0".into(),
139            tool: "shell".into(),
140            command: "sleep 999".into(),
141            result: AuditResult::Timeout,
142            duration_ms: 30000,
143        };
144        let json = serde_json::to_string(&entry).unwrap();
145        assert!(json.contains("\"type\":\"timeout\""));
146    }
147
148    #[tokio::test]
149    async fn audit_logger_stdout() {
150        let config = AuditConfig {
151            enabled: true,
152            destination: "stdout".into(),
153        };
154        let logger = AuditLogger::from_config(&config).await.unwrap();
155        let entry = AuditEntry {
156            timestamp: "0".into(),
157            tool: "shell".into(),
158            command: "echo test".into(),
159            result: AuditResult::Success,
160            duration_ms: 1,
161        };
162        logger.log(&entry).await;
163    }
164
165    #[tokio::test]
166    async fn audit_logger_file() {
167        let dir = tempfile::tempdir().unwrap();
168        let path = dir.path().join("audit.log");
169        let config = AuditConfig {
170            enabled: true,
171            destination: path.display().to_string(),
172        };
173        let logger = AuditLogger::from_config(&config).await.unwrap();
174        let entry = AuditEntry {
175            timestamp: "0".into(),
176            tool: "shell".into(),
177            command: "echo test".into(),
178            result: AuditResult::Success,
179            duration_ms: 1,
180        };
181        logger.log(&entry).await;
182
183        let content = tokio::fs::read_to_string(&path).await.unwrap();
184        assert!(content.contains("\"tool\":\"shell\""));
185    }
186
187    #[tokio::test]
188    async fn audit_logger_file_write_error_logged() {
189        let config = AuditConfig {
190            enabled: true,
191            destination: "/nonexistent/dir/audit.log".into(),
192        };
193        let result = AuditLogger::from_config(&config).await;
194        assert!(result.is_err());
195    }
196
197    #[tokio::test]
198    async fn audit_logger_multiple_entries() {
199        let dir = tempfile::tempdir().unwrap();
200        let path = dir.path().join("audit.log");
201        let config = AuditConfig {
202            enabled: true,
203            destination: path.display().to_string(),
204        };
205        let logger = AuditLogger::from_config(&config).await.unwrap();
206
207        for i in 0..5 {
208            let entry = AuditEntry {
209                timestamp: i.to_string(),
210                tool: "shell".into(),
211                command: format!("cmd{i}"),
212                result: AuditResult::Success,
213                duration_ms: i,
214            };
215            logger.log(&entry).await;
216        }
217
218        let content = tokio::fs::read_to_string(&path).await.unwrap();
219        assert_eq!(content.lines().count(), 5);
220    }
221}