1use 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 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}