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 #[serde(skip_serializing_if = "Option::is_none")]
28 pub error_category: Option<String>,
29}
30
31#[derive(serde::Serialize)]
32#[serde(tag = "type")]
33pub enum AuditResult {
34 #[serde(rename = "success")]
35 Success,
36 #[serde(rename = "blocked")]
37 Blocked { reason: String },
38 #[serde(rename = "error")]
39 Error { message: String },
40 #[serde(rename = "timeout")]
41 Timeout,
42}
43
44impl AuditLogger {
45 pub async fn from_config(config: &AuditConfig) -> Result<Self, std::io::Error> {
51 let destination = if config.destination == "stdout" {
52 AuditDestination::Stdout
53 } else {
54 let file = tokio::fs::OpenOptions::new()
55 .create(true)
56 .append(true)
57 .open(Path::new(&config.destination))
58 .await?;
59 AuditDestination::File(tokio::sync::Mutex::new(file))
60 };
61
62 Ok(Self { destination })
63 }
64
65 pub async fn log(&self, entry: &AuditEntry) {
66 let Ok(json) = serde_json::to_string(entry) else {
67 return;
68 };
69
70 match &self.destination {
71 AuditDestination::Stdout => {
72 tracing::info!(target: "audit", "{json}");
73 }
74 AuditDestination::File(file) => {
75 use tokio::io::AsyncWriteExt;
76 let mut f = file.lock().await;
77 let line = format!("{json}\n");
78 if let Err(e) = f.write_all(line.as_bytes()).await {
79 tracing::error!("failed to write audit log: {e}");
80 } else if let Err(e) = f.flush().await {
81 tracing::error!("failed to flush audit log: {e}");
82 }
83 }
84 }
85 }
86}
87
88pub(crate) fn chrono_now() -> String {
89 use std::time::{SystemTime, UNIX_EPOCH};
90 let secs = SystemTime::now()
91 .duration_since(UNIX_EPOCH)
92 .unwrap_or_default()
93 .as_secs();
94 format!("{secs}")
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100
101 #[test]
102 fn audit_entry_serialization() {
103 let entry = AuditEntry {
104 timestamp: "1234567890".into(),
105 tool: "shell".into(),
106 command: "echo hello".into(),
107 result: AuditResult::Success,
108 duration_ms: 42,
109 error_category: None,
110 };
111 let json = serde_json::to_string(&entry).unwrap();
112 assert!(json.contains("\"type\":\"success\""));
113 assert!(json.contains("\"tool\":\"shell\""));
114 assert!(json.contains("\"duration_ms\":42"));
115 }
116
117 #[test]
118 fn audit_result_blocked_serialization() {
119 let entry = AuditEntry {
120 timestamp: "0".into(),
121 tool: "shell".into(),
122 command: "sudo rm".into(),
123 result: AuditResult::Blocked {
124 reason: "blocked command: sudo".into(),
125 },
126 duration_ms: 0,
127 error_category: Some("policy_blocked".to_owned()),
128 };
129 let json = serde_json::to_string(&entry).unwrap();
130 assert!(json.contains("\"type\":\"blocked\""));
131 assert!(json.contains("\"reason\""));
132 }
133
134 #[test]
135 fn audit_result_error_serialization() {
136 let entry = AuditEntry {
137 timestamp: "0".into(),
138 tool: "shell".into(),
139 command: "bad".into(),
140 result: AuditResult::Error {
141 message: "exec failed".into(),
142 },
143 duration_ms: 0,
144 error_category: None,
145 };
146 let json = serde_json::to_string(&entry).unwrap();
147 assert!(json.contains("\"type\":\"error\""));
148 }
149
150 #[test]
151 fn audit_result_timeout_serialization() {
152 let entry = AuditEntry {
153 timestamp: "0".into(),
154 tool: "shell".into(),
155 command: "sleep 999".into(),
156 result: AuditResult::Timeout,
157 duration_ms: 30000,
158 error_category: Some("timeout".to_owned()),
159 };
160 let json = serde_json::to_string(&entry).unwrap();
161 assert!(json.contains("\"type\":\"timeout\""));
162 }
163
164 #[tokio::test]
165 async fn audit_logger_stdout() {
166 let config = AuditConfig {
167 enabled: true,
168 destination: "stdout".into(),
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 error_category: None,
178 };
179 logger.log(&entry).await;
180 }
181
182 #[tokio::test]
183 async fn audit_logger_file() {
184 let dir = tempfile::tempdir().unwrap();
185 let path = dir.path().join("audit.log");
186 let config = AuditConfig {
187 enabled: true,
188 destination: path.display().to_string(),
189 };
190 let logger = AuditLogger::from_config(&config).await.unwrap();
191 let entry = AuditEntry {
192 timestamp: "0".into(),
193 tool: "shell".into(),
194 command: "echo test".into(),
195 result: AuditResult::Success,
196 duration_ms: 1,
197 error_category: None,
198 };
199 logger.log(&entry).await;
200
201 let content = tokio::fs::read_to_string(&path).await.unwrap();
202 assert!(content.contains("\"tool\":\"shell\""));
203 }
204
205 #[tokio::test]
206 async fn audit_logger_file_write_error_logged() {
207 let config = AuditConfig {
208 enabled: true,
209 destination: "/nonexistent/dir/audit.log".into(),
210 };
211 let result = AuditLogger::from_config(&config).await;
212 assert!(result.is_err());
213 }
214
215 #[tokio::test]
216 async fn audit_logger_multiple_entries() {
217 let dir = tempfile::tempdir().unwrap();
218 let path = dir.path().join("audit.log");
219 let config = AuditConfig {
220 enabled: true,
221 destination: path.display().to_string(),
222 };
223 let logger = AuditLogger::from_config(&config).await.unwrap();
224
225 for i in 0..5 {
226 let entry = AuditEntry {
227 timestamp: i.to_string(),
228 tool: "shell".into(),
229 command: format!("cmd{i}"),
230 result: AuditResult::Success,
231 duration_ms: i,
232 error_category: None,
233 };
234 logger.log(&entry).await;
235 }
236
237 let content = tokio::fs::read_to_string(&path).await.unwrap();
238 assert_eq!(content.lines().count(), 5);
239 }
240}