use std::fs::{self, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use crate::core::errors::{Result, VaulticError};
use crate::core::models::audit_entry::AuditEntry;
use crate::core::traits::audit::AuditLogger;
pub struct JsonAuditLogger {
log_path: PathBuf,
}
impl JsonAuditLogger {
pub fn new(vaultic_dir: &Path, log_file: &str) -> Self {
Self {
log_path: vaultic_dir.join(log_file),
}
}
pub fn from_config(
vaultic_dir: &Path,
audit_section: Option<&crate::config::app_config::AuditSection>,
) -> Self {
let log_file = audit_section
.map(|a| a.log_file.as_str())
.unwrap_or("audit.log");
Self::new(vaultic_dir, log_file)
}
pub fn is_enabled(audit_section: Option<&crate::config::app_config::AuditSection>) -> bool {
audit_section.map(|a| a.enabled).unwrap_or(true)
}
}
impl AuditLogger for JsonAuditLogger {
fn log_event(&self, entry: &AuditEntry) -> Result<()> {
let line = serde_json::to_string(entry).map_err(|e| VaulticError::AuditError {
detail: format!("Failed to serialize audit entry: {e}"),
})?;
if let Some(parent) = self.log_path.parent()
&& !parent.exists()
{
fs::create_dir_all(parent)?;
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.log_path)
.map_err(|e| VaulticError::AuditError {
detail: format!("Cannot open audit log at {}: {e}", self.log_path.display()),
})?;
writeln!(file, "{line}").map_err(|e| VaulticError::AuditError {
detail: format!("Failed to write audit entry: {e}"),
})?;
Ok(())
}
fn query(&self, author: Option<&str>, since: Option<DateTime<Utc>>) -> Result<Vec<AuditEntry>> {
if !self.log_path.exists() {
return Ok(Vec::new());
}
let file = fs::File::open(&self.log_path).map_err(|e| VaulticError::AuditError {
detail: format!("Cannot read audit log: {e}"),
})?;
let reader = BufReader::new(file);
let mut entries = Vec::new();
for (line_num, line) in reader.lines().enumerate() {
let line = line.map_err(|e| VaulticError::AuditError {
detail: format!("Error reading audit log line {}: {e}", line_num + 1),
})?;
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let entry: AuditEntry =
serde_json::from_str(trimmed).map_err(|e| VaulticError::AuditError {
detail: format!("Malformed audit entry at line {}: {e}", line_num + 1),
})?;
if let Some(author_filter) = author {
let author_lower = author_filter.to_lowercase();
let matches_name = entry.author.to_lowercase().contains(&author_lower);
let matches_email = entry
.email
.as_ref()
.is_some_and(|e| e.to_lowercase().contains(&author_lower));
if !matches_name && !matches_email {
continue;
}
}
if let Some(since_date) = since
&& entry.timestamp < since_date
{
continue;
}
entries.push(entry);
}
Ok(entries)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::models::audit_entry::AuditAction;
use chrono::TimeZone;
use tempfile::TempDir;
fn sample_entry(author: &str, action: AuditAction) -> AuditEntry {
AuditEntry {
timestamp: Utc::now(),
author: author.to_string(),
email: Some(format!("{author}@test.com")),
action,
files: vec!["dev.env".to_string()],
detail: None,
state_hash: None,
}
}
#[test]
fn log_and_query_round_trip() {
let tmp = TempDir::new().unwrap();
let logger = JsonAuditLogger::new(tmp.path(), "audit.log");
let entry = sample_entry("Alice", AuditAction::Encrypt);
logger.log_event(&entry).unwrap();
let results = logger.query(None, None).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].author, "Alice");
assert_eq!(results[0].action, AuditAction::Encrypt);
}
#[test]
fn multiple_entries_appended() {
let tmp = TempDir::new().unwrap();
let logger = JsonAuditLogger::new(tmp.path(), "audit.log");
logger
.log_event(&sample_entry("Alice", AuditAction::Encrypt))
.unwrap();
logger
.log_event(&sample_entry("Bob", AuditAction::Decrypt))
.unwrap();
logger
.log_event(&sample_entry("Alice", AuditAction::Resolve))
.unwrap();
let results = logger.query(None, None).unwrap();
assert_eq!(results.len(), 3);
}
#[test]
fn filter_by_author() {
let tmp = TempDir::new().unwrap();
let logger = JsonAuditLogger::new(tmp.path(), "audit.log");
logger
.log_event(&sample_entry("Alice", AuditAction::Encrypt))
.unwrap();
logger
.log_event(&sample_entry("Bob", AuditAction::Decrypt))
.unwrap();
let results = logger.query(Some("alice"), None).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].author, "Alice");
}
#[test]
fn filter_by_author_email() {
let tmp = TempDir::new().unwrap();
let logger = JsonAuditLogger::new(tmp.path(), "audit.log");
logger
.log_event(&sample_entry("Alice", AuditAction::Init))
.unwrap();
let results = logger.query(Some("alice@test.com"), None).unwrap();
assert_eq!(results.len(), 1);
}
#[test]
fn filter_by_since() {
let tmp = TempDir::new().unwrap();
let logger = JsonAuditLogger::new(tmp.path(), "audit.log");
let old = AuditEntry {
timestamp: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(),
..sample_entry("Alice", AuditAction::Init)
};
let recent = AuditEntry {
timestamp: Utc.with_ymd_and_hms(2026, 6, 1, 0, 0, 0).unwrap(),
..sample_entry("Bob", AuditAction::Encrypt)
};
logger.log_event(&old).unwrap();
logger.log_event(&recent).unwrap();
let cutoff = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
let results = logger.query(None, Some(cutoff)).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].author, "Bob");
}
#[test]
fn query_empty_log_returns_empty() {
let tmp = TempDir::new().unwrap();
let logger = JsonAuditLogger::new(tmp.path(), "audit.log");
let results = logger.query(None, None).unwrap();
assert!(results.is_empty());
}
#[test]
fn query_nonexistent_file_returns_empty() {
let logger = JsonAuditLogger::new(Path::new("/nonexistent"), "audit.log");
let results = logger.query(None, None).unwrap();
assert!(results.is_empty());
}
#[test]
fn is_enabled_defaults_to_true() {
assert!(JsonAuditLogger::is_enabled(None));
}
#[test]
fn is_enabled_respects_config() {
use crate::config::app_config::AuditSection;
let enabled = AuditSection {
enabled: true,
log_file: "audit.log".to_string(),
};
let disabled = AuditSection {
enabled: false,
log_file: "audit.log".to_string(),
};
assert!(JsonAuditLogger::is_enabled(Some(&enabled)));
assert!(!JsonAuditLogger::is_enabled(Some(&disabled)));
}
}