// Copyright (c) 2026 Volkov Pavel | DevITWay // SPDX-License-Identifier: MIT //! Persistent audit log — append-only JSONL file //! //! Records who/when/what for every registry operation. //! File: {storage_path}/audit.jsonl use chrono::{DateTime, Utc}; use parking_lot::Mutex; use serde::Serialize; use std::fs::{self, OpenOptions}; use std::io::Write; use std::path::PathBuf; use tracing::{info, warn}; #[derive(Debug, Clone, Serialize)] pub struct AuditEntry { pub ts: DateTime, pub action: String, pub actor: String, pub artifact: String, pub registry: String, pub detail: String, } impl AuditEntry { pub fn new(action: &str, actor: &str, artifact: &str, registry: &str, detail: &str) -> Self { Self { ts: Utc::now(), action: action.to_string(), actor: actor.to_string(), artifact: artifact.to_string(), registry: registry.to_string(), detail: detail.to_string(), } } } pub struct AuditLog { path: PathBuf, writer: Mutex>, } impl AuditLog { pub fn new(storage_path: &str) -> Self { let path = PathBuf::from(storage_path).join("audit.jsonl"); let writer = match OpenOptions::new().create(true).append(true).open(&path) { Ok(f) => { info!(path = %path.display(), "Audit log initialized"); Mutex::new(Some(f)) } Err(e) => { warn!(path = %path.display(), error = %e, "Failed to open audit log, auditing disabled"); Mutex::new(None) } }; Self { path, writer } } pub fn log(&self, entry: AuditEntry) { if let Some(ref mut file) = *self.writer.lock() { if let Ok(json) = serde_json::to_string(&entry) { let _ = writeln!(file, "{}", json); let _ = file.flush(); } } } pub fn path(&self) -> &PathBuf { &self.path } } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; #[test] fn test_audit_entry_new() { let entry = AuditEntry::new( "push", "admin", "nginx:latest", "docker", "uploaded manifest", ); assert_eq!(entry.action, "push"); assert_eq!(entry.actor, "admin"); assert_eq!(entry.artifact, "nginx:latest"); assert_eq!(entry.registry, "docker"); assert_eq!(entry.detail, "uploaded manifest"); } #[test] fn test_audit_log_new_and_path() { let tmp = TempDir::new().unwrap(); let log = AuditLog::new(tmp.path().to_str().unwrap()); assert!(log.path().ends_with("audit.jsonl")); } #[test] fn test_audit_log_write_entry() { let tmp = TempDir::new().unwrap(); let log = AuditLog::new(tmp.path().to_str().unwrap()); let entry = AuditEntry::new("pull", "user1", "lodash", "npm", "downloaded"); log.log(entry); // Verify file contains the entry let content = std::fs::read_to_string(log.path()).unwrap(); assert!(content.contains(r#""action":"pull""#)); assert!(content.contains(r#""actor":"user1""#)); assert!(content.contains(r#""artifact":"lodash""#)); } #[test] fn test_audit_log_multiple_entries() { let tmp = TempDir::new().unwrap(); let log = AuditLog::new(tmp.path().to_str().unwrap()); log.log(AuditEntry::new("push", "admin", "a", "docker", "")); log.log(AuditEntry::new("pull", "user", "b", "npm", "")); log.log(AuditEntry::new("delete", "admin", "c", "maven", "")); let content = std::fs::read_to_string(log.path()).unwrap(); let lines: Vec<&str> = content.lines().collect(); assert_eq!(lines.len(), 3); } #[test] fn test_audit_entry_serialization() { let entry = AuditEntry::new("push", "ci", "app:v1", "docker", "ci build"); let json = serde_json::to_string(&entry).unwrap(); assert!(json.contains(r#""action":"push""#)); assert!(json.contains(r#""ts":""#)); } }