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
85pub(crate) fn chrono_now() -> String {
86    use std::time::{SystemTime, UNIX_EPOCH};
87    let secs = SystemTime::now()
88        .duration_since(UNIX_EPOCH)
89        .unwrap_or_default()
90        .as_secs();
91    format!("{secs}")
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn audit_entry_serialization() {
100        let entry = AuditEntry {
101            timestamp: "1234567890".into(),
102            tool: "shell".into(),
103            command: "echo hello".into(),
104            result: AuditResult::Success,
105            duration_ms: 42,
106        };
107        let json = serde_json::to_string(&entry).unwrap();
108        assert!(json.contains("\"type\":\"success\""));
109        assert!(json.contains("\"tool\":\"shell\""));
110        assert!(json.contains("\"duration_ms\":42"));
111    }
112
113    #[test]
114    fn audit_result_blocked_serialization() {
115        let entry = AuditEntry {
116            timestamp: "0".into(),
117            tool: "shell".into(),
118            command: "sudo rm".into(),
119            result: AuditResult::Blocked {
120                reason: "blocked command: sudo".into(),
121            },
122            duration_ms: 0,
123        };
124        let json = serde_json::to_string(&entry).unwrap();
125        assert!(json.contains("\"type\":\"blocked\""));
126        assert!(json.contains("\"reason\""));
127    }
128
129    #[test]
130    fn audit_result_error_serialization() {
131        let entry = AuditEntry {
132            timestamp: "0".into(),
133            tool: "shell".into(),
134            command: "bad".into(),
135            result: AuditResult::Error {
136                message: "exec failed".into(),
137            },
138            duration_ms: 0,
139        };
140        let json = serde_json::to_string(&entry).unwrap();
141        assert!(json.contains("\"type\":\"error\""));
142    }
143
144    #[test]
145    fn audit_result_timeout_serialization() {
146        let entry = AuditEntry {
147            timestamp: "0".into(),
148            tool: "shell".into(),
149            command: "sleep 999".into(),
150            result: AuditResult::Timeout,
151            duration_ms: 30000,
152        };
153        let json = serde_json::to_string(&entry).unwrap();
154        assert!(json.contains("\"type\":\"timeout\""));
155    }
156
157    #[tokio::test]
158    async fn audit_logger_stdout() {
159        let config = AuditConfig {
160            enabled: true,
161            destination: "stdout".into(),
162        };
163        let logger = AuditLogger::from_config(&config).await.unwrap();
164        let entry = AuditEntry {
165            timestamp: "0".into(),
166            tool: "shell".into(),
167            command: "echo test".into(),
168            result: AuditResult::Success,
169            duration_ms: 1,
170        };
171        logger.log(&entry).await;
172    }
173
174    #[tokio::test]
175    async fn audit_logger_file() {
176        let dir = tempfile::tempdir().unwrap();
177        let path = dir.path().join("audit.log");
178        let config = AuditConfig {
179            enabled: true,
180            destination: path.display().to_string(),
181        };
182        let logger = AuditLogger::from_config(&config).await.unwrap();
183        let entry = AuditEntry {
184            timestamp: "0".into(),
185            tool: "shell".into(),
186            command: "echo test".into(),
187            result: AuditResult::Success,
188            duration_ms: 1,
189        };
190        logger.log(&entry).await;
191
192        let content = tokio::fs::read_to_string(&path).await.unwrap();
193        assert!(content.contains("\"tool\":\"shell\""));
194    }
195
196    #[tokio::test]
197    async fn audit_logger_file_write_error_logged() {
198        let config = AuditConfig {
199            enabled: true,
200            destination: "/nonexistent/dir/audit.log".into(),
201        };
202        let result = AuditLogger::from_config(&config).await;
203        assert!(result.is_err());
204    }
205
206    #[tokio::test]
207    async fn audit_logger_multiple_entries() {
208        let dir = tempfile::tempdir().unwrap();
209        let path = dir.path().join("audit.log");
210        let config = AuditConfig {
211            enabled: true,
212            destination: path.display().to_string(),
213        };
214        let logger = AuditLogger::from_config(&config).await.unwrap();
215
216        for i in 0..5 {
217            let entry = AuditEntry {
218                timestamp: i.to_string(),
219                tool: "shell".into(),
220                command: format!("cmd{i}"),
221                result: AuditResult::Success,
222                duration_ms: i,
223            };
224            logger.log(&entry).await;
225        }
226
227        let content = tokio::fs::read_to_string(&path).await.unwrap();
228        assert_eq!(content.lines().count(), 5);
229    }
230}