Skip to main content

zeph_tools/
audit.rs

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