1use std::sync::Arc;
2
3use chrono::{DateTime, Utc};
4use parking_lot::Mutex;
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub enum AuditEventType {
10 CrawlStarted,
11 CrawlCompleted,
12 CrawlFailed,
13 PageFetched,
14 PageAnalysisStarted,
15 PageAnalysisCompleted,
16 FindingStored,
17 ExportGenerated,
18 ConfigChanged,
19 ApiKeyCreated,
20 ApiKeyRevoked,
21 ErrorOccurred,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct AuditEvent {
27 pub id: String,
29 pub timestamp: DateTime<Utc>,
31 pub event_type: AuditEventType,
33 pub actor: String,
35 pub details: String,
37 pub hash: String,
39 pub previous_hash: String,
41}
42
43pub struct AuditTrail {
47 events: Arc<Mutex<Vec<AuditEvent>>>,
48}
49
50impl AuditTrail {
51 #[must_use]
53 pub fn new() -> Self {
54 Self {
55 events: Arc::new(Mutex::new(Vec::new())),
56 }
57 }
58
59 pub fn record(&self, event_type: AuditEventType, actor: &str, details: &str) -> AuditEvent {
61 let mut events = self.events.lock();
62 let previous_hash = events
63 .last()
64 .map(|e| e.hash.clone())
65 .unwrap_or_else(|| "genesis".to_string());
66
67 let event = AuditEvent {
68 id: uuid::Uuid::new_v4().to_string(),
69 timestamp: Utc::now(),
70 event_type,
71 actor: actor.to_string(),
72 details: details.to_string(),
73 hash: compute_hash(details, &previous_hash),
74 previous_hash,
75 };
76
77 events.push(event.clone());
78 event
79 }
80
81 #[must_use]
83 pub fn events(&self) -> Vec<AuditEvent> {
84 self.events.lock().clone()
85 }
86
87 #[must_use]
89 pub fn len(&self) -> usize {
90 self.events.lock().len()
91 }
92
93 #[must_use]
95 pub fn is_empty(&self) -> bool {
96 self.events.lock().is_empty()
97 }
98
99 #[must_use]
101 pub fn verify_integrity(&self) -> bool {
102 let events = self.events.lock();
103 let mut prev_hash = "genesis".to_string();
104
105 for event in events.iter() {
106 if event.previous_hash != prev_hash {
107 return false;
108 }
109 let expected_hash = compute_hash(&event.details, &event.previous_hash);
110 if event.hash != expected_hash {
111 return false;
112 }
113 prev_hash = event.hash.clone();
114 }
115
116 true
117 }
118
119 pub fn clear(&self) {
121 self.events.lock().clear();
122 }
123}
124
125impl Default for AuditTrail {
126 fn default() -> Self {
127 Self::new()
128 }
129}
130
131fn compute_hash(details: &str, previous_hash: &str) -> String {
137 use sha2::{Digest, Sha256};
138
139 let mut hasher = Sha256::new();
140 hasher.update(details.as_bytes());
141 hasher.update(b"\0"); hasher.update(previous_hash.as_bytes());
143 let result = hasher.finalize();
144 result.iter().map(|b| format!("{b:02x}")).collect()
146}
147
148#[cfg(test)]
153mod tests {
154 use super::*;
155
156 #[test]
157 fn test_audit_trail_record() {
158 let trail = AuditTrail::new();
159 let event = trail.record(
160 AuditEventType::CrawlStarted,
161 "cli",
162 "Crawl started for https://example.com",
163 );
164
165 assert_eq!(trail.len(), 1);
166 assert!(!event.id.is_empty());
167 assert_eq!(event.previous_hash, "genesis");
168 }
169
170 #[test]
171 fn test_audit_trail_chain() {
172 let trail = AuditTrail::new();
173 let e1 = trail.record(AuditEventType::CrawlStarted, "cli", "start");
174 let e2 = trail.record(AuditEventType::PageFetched, "cli", "fetched");
175
176 assert_eq!(e2.previous_hash, e1.hash);
177 assert!(trail.verify_integrity());
178 }
179
180 #[test]
181 fn test_audit_trail_integrity() {
182 let trail = AuditTrail::new();
183 trail.record(AuditEventType::CrawlStarted, "cli", "start");
184 trail.record(AuditEventType::PageFetched, "cli", "fetched");
185
186 assert!(trail.verify_integrity());
187 }
188
189 #[test]
190 fn test_audit_trail_empty() {
191 let trail = AuditTrail::new();
192 assert!(trail.is_empty());
193 assert!(trail.verify_integrity());
194 }
195}