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