1use crate::types::{AuditEntry, AuditFilter};
7use sha2::{Digest, Sha256};
8use std::sync::Mutex;
9use std::time::{SystemTime, UNIX_EPOCH};
10
11pub struct AuditLogger {
16 entries: Mutex<Vec<AuditEntry>>,
17 max_entries: Option<usize>,
18}
19
20impl AuditLogger {
21 pub fn new() -> Self {
23 Self {
24 entries: Mutex::new(Vec::new()),
25 max_entries: None,
26 }
27 }
28
29 pub fn with_max_entries(max: usize) -> Self {
32 Self {
33 entries: Mutex::new(Vec::new()),
34 max_entries: Some(max),
35 }
36 }
37
38 pub fn log(&self, agent_id: &str, action: &str, decision: &str) -> AuditEntry {
40 let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
41 let seq = entries.len() as u64;
42 let prev_hash = entries.last().map(|e| e.hash.clone()).unwrap_or_default();
43 let timestamp = iso8601_now();
44
45 let hash_input = format!(
46 "{}|{}|{}|{}|{}|{}",
47 seq, timestamp, agent_id, action, decision, prev_hash
48 );
49 let hash = sha256_hex(&hash_input);
50
51 let entry = AuditEntry {
52 seq,
53 timestamp,
54 agent_id: agent_id.to_string(),
55 action: action.to_string(),
56 decision: decision.to_string(),
57 previous_hash: prev_hash,
58 hash,
59 };
60
61 entries.push(entry.clone());
62
63 if let Some(max) = self.max_entries {
65 if entries.len() > max {
66 let overflow = entries.len() - max;
67 entries.drain(..overflow);
68 }
69 }
70
71 entry
72 }
73
74 pub fn verify(&self) -> bool {
79 let entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
80 for (i, entry) in entries.iter().enumerate() {
81 let expected_prev = if i == 0 {
82 String::new()
83 } else {
84 entries[i - 1].hash.clone()
85 };
86 if entry.previous_hash != expected_prev {
87 return false;
88 }
89 let hash_input = format!(
90 "{}|{}|{}|{}|{}|{}",
91 entry.seq,
92 entry.timestamp,
93 entry.agent_id,
94 entry.action,
95 entry.decision,
96 entry.previous_hash
97 );
98 if entry.hash != sha256_hex(&hash_input) {
99 return false;
100 }
101 }
102 true
103 }
104
105 pub fn entries(&self) -> Vec<AuditEntry> {
107 self.entries
108 .lock()
109 .unwrap_or_else(|e| e.into_inner())
110 .clone()
111 }
112
113 pub fn export_json(&self) -> String {
115 let entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
116 serde_json::to_string(&*entries).unwrap_or_else(|_| "[]".to_string())
117 }
118
119 pub fn get_entries(&self, filter: &AuditFilter) -> Vec<AuditEntry> {
121 self.entries
122 .lock()
123 .unwrap_or_else(|e| e.into_inner())
124 .iter()
125 .filter(|e| {
126 if let Some(ref id) = filter.agent_id {
127 if e.agent_id != *id {
128 return false;
129 }
130 }
131 if let Some(ref action) = filter.action {
132 if e.action != *action {
133 return false;
134 }
135 }
136 if let Some(ref decision) = filter.decision {
137 if e.decision != *decision {
138 return false;
139 }
140 }
141 true
142 })
143 .cloned()
144 .collect()
145 }
146}
147
148impl Default for AuditLogger {
149 fn default() -> Self {
150 Self::new()
151 }
152}
153
154fn sha256_hex(input: &str) -> String {
155 let mut hasher = Sha256::new();
156 hasher.update(input.as_bytes());
157 let result = hasher.finalize();
158 hex_encode(&result)
159}
160
161fn hex_encode(bytes: &[u8]) -> String {
162 bytes.iter().map(|b| format!("{:02x}", b)).collect()
163}
164
165fn iso8601_now() -> String {
166 let d = SystemTime::now()
167 .duration_since(UNIX_EPOCH)
168 .unwrap_or_default();
169 let secs = d.as_secs();
170 let days = secs / 86400;
171 let time_of_day = secs % 86400;
172 let hours = time_of_day / 3600;
173 let minutes = (time_of_day % 3600) / 60;
174 let seconds = time_of_day % 60;
175
176 let z = days as i64 + 719_468;
178 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
179 let doe = (z - era * 146_097) as u64;
180 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
181 let y = yoe as i64 + era * 400;
182 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
183 let mp = (5 * doy + 2) / 153;
184 let d_val = doy - (153 * mp + 2) / 5 + 1;
185 let m_val = if mp < 10 { mp + 3 } else { mp - 9 };
186 let y_val = if m_val <= 2 { y + 1 } else { y };
187
188 format!(
189 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
190 y_val, m_val, d_val, hours, minutes, seconds
191 )
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 #[test]
199 fn test_append_and_verify() {
200 let logger = AuditLogger::new();
201 logger.log("agent-1", "data.read", "allow");
202 logger.log("agent-1", "shell:rm", "deny");
203 logger.log("agent-2", "deploy.prod", "requires_approval");
204 assert!(logger.verify());
205 assert_eq!(logger.entries().len(), 3);
206 }
207
208 #[test]
209 fn test_genesis_has_empty_prev_hash() {
210 let logger = AuditLogger::new();
211 let entry = logger.log("agent-1", "test", "allow");
212 assert!(entry.previous_hash.is_empty());
213 }
214
215 #[test]
216 fn test_chain_links() {
217 let logger = AuditLogger::new();
218 let e1 = logger.log("a", "action1", "allow");
219 let e2 = logger.log("a", "action2", "deny");
220 assert_eq!(e2.previous_hash, e1.hash);
221 }
222
223 #[test]
224 fn test_tamper_detection() {
225 let logger = AuditLogger::new();
226 logger.log("agent-1", "data.read", "allow");
227 logger.log("agent-1", "data.write", "allow");
228
229 {
231 let mut entries = logger.entries.lock().unwrap();
232 entries[0].action = "tampered".to_string();
233 }
234 assert!(!logger.verify());
235 }
236
237 #[test]
238 fn test_filter() {
239 let logger = AuditLogger::new();
240 logger.log("agent-1", "data.read", "allow");
241 logger.log("agent-2", "data.write", "deny");
242 logger.log("agent-1", "shell:ls", "deny");
243
244 let filter = AuditFilter {
245 agent_id: Some("agent-1".to_string()),
246 ..Default::default()
247 };
248 let filtered = logger.get_entries(&filter);
249 assert_eq!(filtered.len(), 2);
250
251 let filter = AuditFilter {
252 decision: Some("deny".to_string()),
253 ..Default::default()
254 };
255 let filtered = logger.get_entries(&filter);
256 assert_eq!(filtered.len(), 2);
257 }
258
259 #[test]
260 fn test_sha256_not_placeholder() {
261 let hash = sha256_hex("test");
262 assert_eq!(hash.len(), 64); assert_eq!(
265 hash,
266 "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
267 );
268 }
269
270 #[test]
271 fn test_export_json() {
272 let logger = AuditLogger::new();
273 logger.log("agent-1", "data.read", "allow");
274 logger.log("agent-2", "shell:rm", "deny");
275 let json = logger.export_json();
276 let parsed: Vec<AuditEntry> = serde_json::from_str(&json).unwrap();
277 assert_eq!(parsed.len(), 2);
278 assert_eq!(parsed[0].agent_id, "agent-1");
279 assert_eq!(parsed[1].agent_id, "agent-2");
280 }
281
282 #[test]
283 fn test_export_json_empty() {
284 let logger = AuditLogger::new();
285 let json = logger.export_json();
286 assert_eq!(json, "[]");
287 }
288
289 #[test]
290 fn test_max_entries_eviction() {
291 let logger = AuditLogger::with_max_entries(3);
292 for i in 0..5 {
293 logger.log("agent", &format!("action-{}", i), "allow");
294 }
295 let entries = logger.entries();
296 assert_eq!(entries.len(), 3);
297 assert_eq!(entries[0].action, "action-2");
299 assert_eq!(entries[1].action, "action-3");
300 assert_eq!(entries[2].action, "action-4");
301 }
302
303 #[test]
304 fn test_max_entries_not_exceeded() {
305 let logger = AuditLogger::with_max_entries(10);
306 logger.log("a", "x", "allow");
307 logger.log("b", "y", "deny");
308 assert_eq!(logger.entries().len(), 2);
309 }
310
311 #[test]
312 fn test_no_limit_grows_unbounded() {
313 let logger = AuditLogger::new();
314 for i in 0..100 {
315 logger.log("a", &format!("act-{}", i), "allow");
316 }
317 assert_eq!(logger.entries().len(), 100);
318 }
319
320 #[test]
321 fn test_empty_log_verify_returns_true() {
322 let logger = AuditLogger::new();
323 assert!(logger.verify());
324 }
325
326 #[test]
327 fn test_large_chain_verifies() {
328 let logger = AuditLogger::new();
329 for i in 0..50 {
330 logger.log("agent", &format!("action.{}", i), "allow");
331 }
332 assert!(logger.verify());
333 assert_eq!(logger.entries().len(), 50);
334 }
335
336 #[test]
337 fn test_tamper_middle_of_chain() {
338 let logger = AuditLogger::new();
339 for i in 0..5 {
340 logger.log("agent", &format!("action.{}", i), "allow");
341 }
342 {
343 let mut entries = logger.entries.lock().unwrap();
344 entries[2].action = "tampered".to_string();
345 }
346 assert!(!logger.verify());
347 }
348
349 #[test]
350 fn test_tamper_previous_hash_field() {
351 let logger = AuditLogger::new();
352 logger.log("agent", "a", "allow");
353 logger.log("agent", "b", "allow");
354 logger.log("agent", "c", "allow");
355 {
356 let mut entries = logger.entries.lock().unwrap();
357 entries[1].previous_hash =
358 "0000000000000000000000000000000000000000000000000000000000000000".to_string();
359 }
360 assert!(!logger.verify());
361 }
362
363 #[test]
364 fn test_filter_by_action_only() {
365 let logger = AuditLogger::new();
366 logger.log("agent-1", "data.read", "allow");
367 logger.log("agent-2", "data.read", "deny");
368 logger.log("agent-1", "data.write", "allow");
369 let filter = AuditFilter {
370 action: Some("data.read".to_string()),
371 ..Default::default()
372 };
373 let filtered = logger.get_entries(&filter);
374 assert_eq!(filtered.len(), 2);
375 assert!(filtered.iter().all(|e| e.action == "data.read"));
376 }
377
378 #[test]
379 fn test_filter_by_decision_only() {
380 let logger = AuditLogger::new();
381 logger.log("agent-1", "data.read", "allow");
382 logger.log("agent-1", "shell:rm", "deny");
383 logger.log("agent-2", "data.write", "allow");
384 let filter = AuditFilter {
385 decision: Some("allow".to_string()),
386 ..Default::default()
387 };
388 let filtered = logger.get_entries(&filter);
389 assert_eq!(filtered.len(), 2);
390 assert!(filtered.iter().all(|e| e.decision == "allow"));
391 }
392
393 #[test]
394 fn test_filter_multiple_criteria_and_logic() {
395 let logger = AuditLogger::new();
396 logger.log("agent-1", "data.read", "allow");
397 logger.log("agent-1", "data.read", "deny");
398 logger.log("agent-2", "data.read", "allow");
399 let filter = AuditFilter {
400 agent_id: Some("agent-1".to_string()),
401 action: Some("data.read".to_string()),
402 decision: Some("allow".to_string()),
403 };
404 let filtered = logger.get_entries(&filter);
405 assert_eq!(filtered.len(), 1);
406 assert_eq!(filtered[0].agent_id, "agent-1");
407 assert_eq!(filtered[0].action, "data.read");
408 assert_eq!(filtered[0].decision, "allow");
409 }
410
411 #[test]
412 fn test_filter_matching_nothing_returns_empty() {
413 let logger = AuditLogger::new();
414 logger.log("agent-1", "data.read", "allow");
415 let filter = AuditFilter {
416 agent_id: Some("nonexistent".to_string()),
417 ..Default::default()
418 };
419 let filtered = logger.get_entries(&filter);
420 assert!(filtered.is_empty());
421 }
422
423 #[test]
424 fn test_sequential_seq_numbers() {
425 let logger = AuditLogger::new();
426 for _ in 0..5 {
427 logger.log("agent", "action", "allow");
428 }
429 let entries = logger.entries();
430 for (i, entry) in entries.iter().enumerate() {
431 assert_eq!(entry.seq, i as u64);
432 }
433 }
434
435 #[test]
436 fn test_hash_is_64_hex_characters() {
437 let logger = AuditLogger::new();
438 let entry = logger.log("agent", "action", "allow");
439 assert_eq!(entry.hash.len(), 64);
440 assert!(entry.hash.chars().all(|c| c.is_ascii_hexdigit()));
441 }
442
443 #[test]
444 fn test_iso8601_now_format() {
445 let ts = iso8601_now();
446 assert_eq!(ts.len(), 20);
448 assert_eq!(&ts[4..5], "-");
449 assert_eq!(&ts[7..8], "-");
450 assert_eq!(&ts[10..11], "T");
451 assert_eq!(&ts[13..14], ":");
452 assert_eq!(&ts[16..17], ":");
453 assert_eq!(&ts[19..20], "Z");
454 }
455
456 #[test]
457 fn test_hex_encode_lowercase() {
458 let bytes = [0xAB, 0xCD, 0xEF, 0x01];
459 let hex = hex_encode(&bytes);
460 assert_eq!(hex, "abcdef01");
461 assert_eq!(hex, hex.to_lowercase());
463 }
464}