use serde::Serialize;
use sha2::{Digest, Sha256};
use std::io::Write;
use super::events::AuditEvent;
use crate::error::Error;
#[derive(Serialize)]
struct AuditEntry<'a> {
ts: String,
pid: u32,
chain: String,
#[serde(flatten)]
event: &'a AuditEvent,
}
pub fn log(event: &AuditEvent) -> Result<(), Error> {
let dir = default_audit_dir()?;
log_at(&dir, event)
}
pub fn log_at(root: &std::path::Path, event: &AuditEvent) -> Result<(), Error> {
std::fs::create_dir_all(root)
.map_err(|e| Error::AuditLogFailed(format!("failed to create audit directory: {e}")))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(root, std::fs::Permissions::from_mode(0o700)).map_err(|e| {
Error::AuditLogFailed(format!("failed to set audit directory permissions: {e}"))
})?;
}
let log_path = root.join("audit.log");
crate::guard::verify_not_symlink(&log_path)?;
let payload = canonical_event_json(event);
verify_chain_if_exists(&log_path)?;
let prev = last_chain(&log_path).unwrap_or_else(|| "genesis".to_string());
let chain = compute_chain(&prev, &payload);
let entry = AuditEntry {
ts: now_iso8601(),
pid: std::process::id(),
chain,
event,
};
let mut line = serde_json::to_string(&entry)
.map_err(|e| Error::AuditLogFailed(format!("failed to serialize audit entry: {e}")))?;
line.push('\n');
let mut file = {
let mut opts = std::fs::OpenOptions::new();
opts.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600)
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
}
opts.open(&log_path)
.map_err(|e| Error::AuditLogFailed(format!("failed to open audit log: {e}")))?
};
#[cfg(target_os = "linux")]
{
let _ = std::process::Command::new("chattr")
.args(["+a", "--", log_path.to_string_lossy().as_ref()])
.output();
}
file.write_all(line.as_bytes())
.map_err(|e| Error::AuditLogFailed(format!("failed to write audit log: {e}")))?;
Ok(())
}
pub fn log_required(event: &AuditEvent) -> Result<(), Error> {
log(event)
}
pub fn log_required_at(root: &std::path::Path, event: &AuditEvent) -> Result<(), Error> {
log_at(root, event)
}
#[must_use]
pub fn extract_json_field(json: &str, key: &str) -> Option<String> {
let pattern = format!("\"{key}\":\"");
let start = json.find(&pattern)? + pattern.len();
let mut end = start;
while end < json.len() {
let b = json.as_bytes()[end];
if b == b'\\' && end + 1 < json.len() {
if json.as_bytes()[end + 1] == b'u' && end + 5 < json.len() {
end += 6; } else {
end += 2; }
} else if b == b'"' {
break;
} else {
end += 1;
}
}
if end >= json.len() || json.as_bytes()[end] != b'"' {
return None;
}
Some(json[start..end].to_string())
}
#[derive(Debug, serde::Deserialize)]
pub struct ParsedEntry {
pub ts: String,
pub pid: u32,
pub chain: String,
#[serde(flatten)]
pub event: super::AuditEvent,
}
#[must_use]
pub fn parse_entry(line: &str) -> Option<ParsedEntry> {
serde_json::from_str(line).ok()
}
#[must_use]
pub fn read_last_parsed_at(root: &std::path::Path, n: usize) -> ParsedReadResult {
let raw = read_last_at(root, n);
let mut entries = Vec::with_capacity(raw.len());
let mut dropped = 0_usize;
for line in &raw {
match parse_entry(line) {
Some(p) => entries.push(p),
None => dropped += 1,
}
}
ParsedReadResult {
entries,
dropped_lines: dropped,
}
}
#[derive(Debug, Default)]
pub struct ParsedReadResult {
pub entries: Vec<ParsedEntry>,
pub dropped_lines: usize,
}
#[must_use]
pub fn read_last(n: usize) -> Vec<String> {
let Ok(dir) = default_audit_dir() else {
return Vec::new();
};
read_last_at(&dir, n)
}
#[must_use]
pub fn read_last_at(root: &std::path::Path, n: usize) -> Vec<String> {
let log_path = root.join("audit.log");
let Ok(contents) = std::fs::read_to_string(&log_path) else {
return Vec::new();
};
contents.lines().rev().take(n).map(String::from).collect()
}
#[derive(Debug, Default, Clone)]
pub struct AuditFilter {
pub query: Option<String>,
pub event_type: Option<String>,
}
impl AuditFilter {
#[must_use]
pub fn is_empty(&self) -> bool {
self.query.is_none() && self.event_type.is_none()
}
#[must_use]
pub fn matches_line(&self, line: &str) -> bool {
if let Some(q) = &self.query {
let q_lower = q.to_ascii_lowercase();
if !line.to_ascii_lowercase().contains(&q_lower) {
return false;
}
}
if let Some(et) = &self.event_type {
let et_lower = et.to_ascii_lowercase();
let pattern = format!("\"event\":\"{et_lower}\"");
if !line.to_ascii_lowercase().contains(&pattern) {
return false;
}
}
true
}
}
#[must_use]
pub fn read_last_filtered(n: usize, filter: &AuditFilter) -> Vec<String> {
let Ok(dir) = default_audit_dir() else {
return Vec::new();
};
read_last_filtered_at(&dir, n, filter)
}
#[must_use]
pub fn read_last_filtered_at(
root: &std::path::Path,
n: usize,
filter: &AuditFilter,
) -> Vec<String> {
if filter.is_empty() {
return read_last_at(root, n);
}
let log_path = root.join("audit.log");
let Ok(contents) = std::fs::read_to_string(&log_path) else {
return Vec::new();
};
contents
.lines()
.rev()
.filter(|line| filter.matches_line(line))
.take(n)
.map(String::from)
.collect()
}
#[must_use]
pub fn read_last_parsed_filtered_at(
root: &std::path::Path,
n: usize,
filter: &AuditFilter,
) -> ParsedReadResult {
let raw = read_last_filtered_at(root, n, filter);
let mut entries = Vec::with_capacity(raw.len());
let mut dropped = 0_usize;
for line in &raw {
match parse_entry(line) {
Some(p) => entries.push(p),
None => dropped += 1,
}
}
ParsedReadResult {
entries,
dropped_lines: dropped,
}
}
pub(crate) fn default_audit_dir() -> Result<std::path::PathBuf, Error> {
crate::vault::store::default_vault_root()
.map_err(|e| Error::AuditLogFailed(format!("cannot determine audit directory: {e}")))
}
fn now_iso8601() -> String {
let duration = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let secs = duration.as_secs();
let days = secs / 86400;
let time_of_day = secs % 86400;
let hours = time_of_day / 3600;
let minutes = (time_of_day % 3600) / 60;
let seconds = time_of_day % 60;
let mut y = 1970_i64;
#[allow(clippy::cast_possible_wrap)]
let mut remaining = days as i64;
loop {
let year_days = if is_leap(y) { 366 } else { 365 };
if remaining < year_days {
break;
}
remaining -= year_days;
y += 1;
}
let leap = is_leap(y);
let month_days: [i64; 12] = [
31,
if leap { 29 } else { 28 },
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
let mut m = 0;
for days_in_month in &month_days {
if remaining < *days_in_month {
break;
}
remaining -= days_in_month;
m += 1;
}
format!(
"{y:04}-{:02}-{:02}T{hours:02}:{minutes:02}:{seconds:02}Z",
m + 1,
remaining + 1,
)
}
fn is_leap(y: i64) -> bool {
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
}
fn canonical_event_json(event: &super::AuditEvent) -> String {
let raw =
serde_json::to_string(event).unwrap_or_else(|_| String::from("{\"error\":\"serialize\"}"));
let Ok(value): Result<serde_json::Value, _> = serde_json::from_str(&raw) else {
return raw;
};
serde_json::to_string(&value).unwrap_or(raw)
}
fn compute_chain(prev: &str, payload: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(prev.as_bytes());
hasher.update(payload.as_bytes());
format!("{:x}", hasher.finalize())
}
fn last_chain(path: &std::path::Path) -> Option<String> {
let content = std::fs::read_to_string(path).ok()?;
content
.lines()
.rev()
.find_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.and_then(|v| {
v.get("chain")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
})
}
fn verify_chain_if_exists(path: &std::path::Path) -> Result<(), Error> {
if !path.exists() {
return Ok(());
}
let content = std::fs::read_to_string(path)?;
let mut prev = "genesis".to_string();
for (line_no, line) in content.lines().enumerate() {
let bad = match check_one(&prev, line) {
Ok(next_chain) => {
prev = next_chain;
None
}
Err(reason) => Some(reason),
};
if let Some(reason) = bad {
rotate_corrupted(path, line_no + 1, &reason)?;
return Ok(());
}
}
Ok(())
}
fn check_one(prev: &str, line: &str) -> Result<String, String> {
let value: serde_json::Value =
serde_json::from_str(line).map_err(|e| format!("parse failure: {e}"))?;
let chain = value
.get("chain")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "chain field missing".to_string())?
.to_string();
let mut obj = value
.as_object()
.ok_or_else(|| "entry is not a JSON object".to_string())?
.clone();
obj.remove("chain");
obj.remove("ts");
obj.remove("pid");
let payload = serde_json::to_string(&serde_json::Value::Object(obj)).unwrap_or_default();
let expected = compute_chain(prev, &payload);
if crate::guard::constant_time_eq(expected.as_bytes(), chain.as_bytes()) {
Ok(chain)
} else {
Err("hash-chain mismatch".to_string())
}
}
fn rotate_corrupted(path: &std::path::Path, bad_line: usize, reason: &str) -> Result<(), Error> {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let corrupted = path.with_file_name(format!("audit.log.corrupted-{ts}"));
std::fs::rename(path, &corrupted)
.map_err(|e| Error::AuditLogFailed(format!("failed to rotate corrupted audit log: {e}")))?;
#[cfg(target_os = "linux")]
{
let _ = std::process::Command::new("chattr")
.args(["+a", "--", corrupted.to_string_lossy().as_ref()])
.output();
}
let _ = crate::guard::emit_signal_inline(
crate::guard::Signal::new(
crate::guard::SignalId::new("audit.chain.rotated_corruption"),
crate::guard::Category::AuditFailure,
crate::guard::Severity::Warn,
"audit log corruption rotated",
format!(
"audit log corruption detected at line {bad_line} ({reason}). \
rotated to {corrupted_path} and started a fresh chain — the \
corrupted file is preserved for hand inspection; new entries \
continue under {original_path}",
corrupted_path = corrupted.display(),
original_path = path.display()
),
"inspect the rotated file for forensic evidence; the chain itself is back to clean",
),
&crate::security_config::load_system_defaults(),
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn now_iso8601_format() {
let ts = now_iso8601();
assert!(
ts.starts_with("20"),
"timestamp should start with 20xx: {ts}"
);
assert!(ts.ends_with('Z'), "timestamp should end with Z: {ts}");
assert!(ts.contains('T'), "timestamp should contain T: {ts}");
}
}