1use std::fs::{self, File, OpenOptions};
8use std::io::{BufRead, BufReader, Write};
9use std::path::{Path, PathBuf};
10use std::sync::Mutex;
11
12use a3s_box_core::audit::{AuditAction, AuditConfig, AuditEvent, AuditOutcome};
13use a3s_box_core::error::{BoxError, Result};
14
15pub struct AuditLog {
19 inner: Mutex<AuditLogInner>,
20}
21
22struct AuditLogInner {
23 path: PathBuf,
25 file: Option<File>,
27 current_size: u64,
29 config: AuditConfig,
31}
32
33impl AuditLog {
34 pub fn new(path: impl Into<PathBuf>, config: AuditConfig) -> Result<Self> {
36 let path = path.into();
37
38 if let Some(parent) = path.parent() {
40 fs::create_dir_all(parent).map_err(|e| {
41 BoxError::AuditError(format!(
42 "Failed to create audit log directory {}: {}",
43 parent.display(),
44 e
45 ))
46 })?;
47 }
48
49 let current_size = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
51
52 Ok(Self {
53 inner: Mutex::new(AuditLogInner {
54 path,
55 file: None,
56 current_size,
57 config,
58 }),
59 })
60 }
61
62 pub fn default_path() -> Result<Self> {
64 let path = a3s_box_core::dirs_home().join("audit").join("audit.jsonl");
65
66 Self::new(path, AuditConfig::default())
67 }
68
69 pub fn log(&self, event: &AuditEvent) -> Result<()> {
71 let mut inner = self
72 .inner
73 .lock()
74 .map_err(|_| BoxError::AuditError("Audit log lock poisoned".to_string()))?;
75
76 if !inner.config.enabled {
77 return Ok(());
78 }
79
80 if inner.current_size >= inner.config.max_size {
82 Self::rotate(&mut inner)?;
83 }
84
85 if inner.file.is_none() {
87 let file = OpenOptions::new()
88 .create(true)
89 .append(true)
90 .open(&inner.path)
91 .map_err(|e| {
92 BoxError::AuditError(format!(
93 "Failed to open audit log {}: {}",
94 inner.path.display(),
95 e
96 ))
97 })?;
98 inner.file = Some(file);
99 }
100
101 let mut line = serde_json::to_string(event).map_err(|e| {
103 BoxError::SerializationError(format!("Failed to serialize audit event: {}", e))
104 })?;
105 line.push('\n');
106
107 let bytes = line.as_bytes();
108 if let Some(ref mut file) = inner.file {
109 file.write_all(bytes)
110 .map_err(|e| BoxError::AuditError(format!("Failed to write audit event: {}", e)))?;
111 file.flush()
112 .map_err(|e| BoxError::AuditError(format!("Failed to flush audit log: {}", e)))?;
113 }
114
115 inner.current_size += bytes.len() as u64;
116 Ok(())
117 }
118
119 fn rotate(inner: &mut AuditLogInner) -> Result<()> {
121 inner.file = None;
123
124 let max = inner.config.max_files;
125 let base = &inner.path;
126
127 let oldest = rotated_path(base, max);
129 if oldest.exists() {
130 if let Err(e) = fs::remove_file(&oldest) {
131 tracing::warn!(path = %oldest.display(), error = %e, "Failed to remove oldest audit log during rotation");
132 }
133 }
134
135 for i in (1..max).rev() {
137 let from = rotated_path(base, i);
138 let to = rotated_path(base, i + 1);
139 if from.exists() {
140 if let Err(e) = fs::rename(&from, &to) {
141 tracing::warn!(from = %from.display(), to = %to.display(), error = %e, "Failed to shift audit log during rotation");
142 }
143 }
144 }
145
146 if base.exists() {
148 if let Err(e) = fs::rename(base, rotated_path(base, 1)) {
149 tracing::warn!(path = %base.display(), error = %e, "Failed to rotate current audit log");
150 }
151 }
152
153 inner.current_size = 0;
154 Ok(())
155 }
156
157 pub fn path(&self) -> PathBuf {
159 self.inner
160 .lock()
161 .map(|inner| inner.path.clone())
162 .unwrap_or_default()
163 }
164}
165
166fn rotated_path(base: &Path, index: u32) -> PathBuf {
168 let name = format!(
169 "{}.{}",
170 base.file_name().unwrap_or_default().to_string_lossy(),
171 index
172 );
173 base.with_file_name(name)
174}
175
176#[derive(Debug, Clone, Default)]
178pub struct AuditQuery {
179 pub action: Option<AuditAction>,
181 pub box_id: Option<String>,
183 pub outcome: Option<AuditOutcome>,
185 pub since: Option<chrono::DateTime<chrono::Utc>>,
187 pub until: Option<chrono::DateTime<chrono::Utc>>,
189 pub limit: Option<usize>,
191}
192
193pub fn read_audit_log(path: &Path, query: &AuditQuery) -> Result<Vec<AuditEvent>> {
195 if !path.exists() {
196 return Ok(vec![]);
197 }
198
199 let file = File::open(path).map_err(|e| {
200 BoxError::AuditError(format!(
201 "Failed to open audit log {}: {}",
202 path.display(),
203 e
204 ))
205 })?;
206
207 let reader = BufReader::new(file);
208 let mut events = Vec::new();
209
210 for line in reader.lines() {
211 let line = line
212 .map_err(|e| BoxError::AuditError(format!("Failed to read audit log line: {}", e)))?;
213
214 if line.trim().is_empty() {
215 continue;
216 }
217
218 let event: AuditEvent = match serde_json::from_str(&line) {
219 Ok(e) => e,
220 Err(_) => continue, };
222
223 if let Some(ref action) = query.action {
225 if event.action != *action {
226 continue;
227 }
228 }
229 if let Some(ref box_id) = query.box_id {
230 if event.box_id.as_deref() != Some(box_id.as_str()) {
231 continue;
232 }
233 }
234 if let Some(ref outcome) = query.outcome {
235 if event.outcome != *outcome {
236 continue;
237 }
238 }
239 if let Some(since) = query.since {
240 if event.timestamp < since {
241 continue;
242 }
243 }
244 if let Some(until) = query.until {
245 if event.timestamp > until {
246 continue;
247 }
248 }
249
250 events.push(event);
251
252 if let Some(limit) = query.limit {
253 if events.len() >= limit {
254 break;
255 }
256 }
257 }
258
259 Ok(events)
260}
261
262impl a3s_box_core::traits::AuditSink for AuditLog {
263 fn record(&self, event: &AuditEvent) -> Result<()> {
264 self.log(event)
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use tempfile::TempDir;
272
273 fn test_log(dir: &Path) -> AuditLog {
274 let path = dir.join("audit.jsonl");
275 AuditLog::new(path, AuditConfig::default()).unwrap()
276 }
277
278 #[test]
279 fn test_audit_log_write_and_read() {
280 let dir = TempDir::new().unwrap();
281 let log = test_log(dir.path());
282
283 let event = AuditEvent::new(AuditAction::BoxCreate, AuditOutcome::Success)
284 .with_box_id("box-1")
285 .with_message("Created box");
286
287 log.log(&event).unwrap();
288
289 let events = read_audit_log(&log.path(), &AuditQuery::default()).unwrap();
290 assert_eq!(events.len(), 1);
291 assert_eq!(events[0].action, AuditAction::BoxCreate);
292 assert_eq!(events[0].box_id, Some("box-1".to_string()));
293 }
294
295 #[test]
296 fn test_audit_log_multiple_events() {
297 let dir = TempDir::new().unwrap();
298 let log = test_log(dir.path());
299
300 for i in 0..5 {
301 let event = AuditEvent::new(AuditAction::ExecCommand, AuditOutcome::Success)
302 .with_box_id(format!("box-{}", i));
303 log.log(&event).unwrap();
304 }
305
306 let events = read_audit_log(&log.path(), &AuditQuery::default()).unwrap();
307 assert_eq!(events.len(), 5);
308 }
309
310 #[test]
311 fn test_audit_log_filter_by_action() {
312 let dir = TempDir::new().unwrap();
313 let log = test_log(dir.path());
314
315 log.log(&AuditEvent::new(
316 AuditAction::BoxCreate,
317 AuditOutcome::Success,
318 ))
319 .unwrap();
320 log.log(&AuditEvent::new(
321 AuditAction::BoxStop,
322 AuditOutcome::Success,
323 ))
324 .unwrap();
325 log.log(&AuditEvent::new(
326 AuditAction::BoxCreate,
327 AuditOutcome::Failure,
328 ))
329 .unwrap();
330
331 let query = AuditQuery {
332 action: Some(AuditAction::BoxCreate),
333 ..Default::default()
334 };
335 let events = read_audit_log(&log.path(), &query).unwrap();
336 assert_eq!(events.len(), 2);
337 }
338
339 #[test]
340 fn test_audit_log_filter_by_box_id() {
341 let dir = TempDir::new().unwrap();
342 let log = test_log(dir.path());
343
344 log.log(&AuditEvent::new(AuditAction::BoxCreate, AuditOutcome::Success).with_box_id("a"))
345 .unwrap();
346 log.log(&AuditEvent::new(AuditAction::BoxCreate, AuditOutcome::Success).with_box_id("b"))
347 .unwrap();
348
349 let query = AuditQuery {
350 box_id: Some("a".to_string()),
351 ..Default::default()
352 };
353 let events = read_audit_log(&log.path(), &query).unwrap();
354 assert_eq!(events.len(), 1);
355 assert_eq!(events[0].box_id, Some("a".to_string()));
356 }
357
358 #[test]
359 fn test_audit_log_filter_by_outcome() {
360 let dir = TempDir::new().unwrap();
361 let log = test_log(dir.path());
362
363 log.log(&AuditEvent::new(
364 AuditAction::ImagePull,
365 AuditOutcome::Success,
366 ))
367 .unwrap();
368 log.log(&AuditEvent::new(
369 AuditAction::ImagePull,
370 AuditOutcome::Failure,
371 ))
372 .unwrap();
373 log.log(&AuditEvent::new(
374 AuditAction::ImagePull,
375 AuditOutcome::Denied,
376 ))
377 .unwrap();
378
379 let query = AuditQuery {
380 outcome: Some(AuditOutcome::Failure),
381 ..Default::default()
382 };
383 let events = read_audit_log(&log.path(), &query).unwrap();
384 assert_eq!(events.len(), 1);
385 }
386
387 #[test]
388 fn test_audit_log_limit() {
389 let dir = TempDir::new().unwrap();
390 let log = test_log(dir.path());
391
392 for _ in 0..10 {
393 log.log(&AuditEvent::new(
394 AuditAction::ExecCommand,
395 AuditOutcome::Success,
396 ))
397 .unwrap();
398 }
399
400 let query = AuditQuery {
401 limit: Some(3),
402 ..Default::default()
403 };
404 let events = read_audit_log(&log.path(), &query).unwrap();
405 assert_eq!(events.len(), 3);
406 }
407
408 #[test]
409 fn test_audit_log_empty_file() {
410 let dir = TempDir::new().unwrap();
411 let path = dir.path().join("nonexistent.jsonl");
412 let events = read_audit_log(&path, &AuditQuery::default()).unwrap();
413 assert!(events.is_empty());
414 }
415
416 #[test]
417 fn test_audit_log_disabled() {
418 let dir = TempDir::new().unwrap();
419 let path = dir.path().join("audit.jsonl");
420 let config = AuditConfig {
421 enabled: false,
422 ..Default::default()
423 };
424 let log = AuditLog::new(&path, config).unwrap();
425
426 log.log(&AuditEvent::new(
427 AuditAction::BoxCreate,
428 AuditOutcome::Success,
429 ))
430 .unwrap();
431
432 let events = read_audit_log(&path, &AuditQuery::default()).unwrap();
434 assert!(events.is_empty());
435 }
436
437 #[test]
438 fn test_audit_log_rotation() {
439 let dir = TempDir::new().unwrap();
440 let path = dir.path().join("audit.jsonl");
441 let config = AuditConfig {
442 enabled: true,
443 max_size: 100, max_files: 3,
445 };
446 let log = AuditLog::new(&path, config).unwrap();
447
448 for i in 0..20 {
450 let event = AuditEvent::new(AuditAction::ExecCommand, AuditOutcome::Success)
451 .with_message(format!("Event {}", i));
452 log.log(&event).unwrap();
453 }
454
455 assert!(path.exists());
457 let rotated_1 = dir.path().join("audit.jsonl.1");
458 assert!(rotated_1.exists());
459 }
460
461 #[test]
462 fn test_rotated_path() {
463 let base = PathBuf::from("/var/log/audit.jsonl");
464 assert_eq!(
465 rotated_path(&base, 1),
466 PathBuf::from("/var/log/audit.jsonl.1")
467 );
468 assert_eq!(
469 rotated_path(&base, 10),
470 PathBuf::from("/var/log/audit.jsonl.10")
471 );
472 }
473
474 #[test]
475 fn test_audit_log_path() {
476 let dir = TempDir::new().unwrap();
477 let path = dir.path().join("audit.jsonl");
478 let log = AuditLog::new(&path, AuditConfig::default()).unwrap();
479 assert_eq!(log.path(), path);
480 }
481}