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