naru-config 0.7.0

A security-first configuration manager with encryption and audit logging
Documentation
use std::fs::{self, OpenOptions};
use std::io::{BufRead, Write};
use std::path::Path;

use crate::core::audit_chain::{verify_log_integrity, AuditChain};
use crate::core::audit_entry::AuditLogEntry;
use crate::core::locking::FileLock;

pub fn log_action(
    action: &str,
    environment: &str,
    key: Option<&str>,
    old_value: Option<&str>,
    new_value: Option<&str>,
    log_path: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    let mut entry = AuditLogEntry::new(
        action.to_string(),
        environment.to_string(),
        key.map(|s| s.to_string()),
        old_value.map(|s| s.to_string()),
        new_value.map(|s| s.to_string()),
    );

    log_entry_to_file(&mut entry, log_path)
}

fn log_entry_to_file(
    entry: &mut AuditLogEntry,
    log_path: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    use crate::core::path_sanitizer::sanitize_file_path_internal;

    let sanitized_log_path = sanitize_file_path_internal(log_path)
        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;

    let log_path_obj = Path::new(&sanitized_log_path);
    let lock_dir = log_path_obj.parent().unwrap_or(Path::new("."));
    let lock_path = lock_dir.join("audit.lock");

    let max_retries = 5;
    let mut last_error: Option<std::io::Error> = None;
    let mut acquired_lock = None;

    for attempt in 0..max_retries {
        match FileLock::acquire_exclusive(&lock_path) {
            Ok(lock) => {
                acquired_lock = Some(lock);
                break;
            }
            Err(e) => {
                last_error = Some(e);
                if attempt < max_retries - 1 {
                    std::thread::sleep(std::time::Duration::from_millis(10 * (1 << attempt)));
                }
            }
        }
    }

    if acquired_lock.is_none() {
        return Err(Box::new(std::io::Error::other(format!(
            "Could not acquire audit lock after {} attempts: {:?}",
            max_retries, last_error
        ))));
    }

    let _lock = acquired_lock.unwrap();

    let prev_hash = if Path::new(&sanitized_log_path).exists() {
        let file = fs::File::open(&sanitized_log_path)?;
        let reader = std::io::BufReader::new(file);
        if let Some(last_line) = reader.lines().map_while(Result::ok).last() {
            if let Ok(last_entry) = serde_json::from_str::<AuditLogEntry>(&last_line) {
                last_entry.hash
            } else {
                None
            }
        } else {
            None
        }
    } else {
        None
    };

    AuditChain::link_entry(entry, prev_hash);

    let mut file = OpenOptions::new()
        .create(true)
        .append(true)
        .open(&sanitized_log_path)?;

    let log_line = serde_json::to_string(entry)?;
    writeln!(file, "{}", log_line)?;

    Ok(())
}

pub fn get_recent_logs(
    log_path: &str,
    count: usize,
) -> Result<Vec<AuditLogEntry>, Box<dyn std::error::Error>> {
    use crate::core::path_sanitizer::sanitize_file_path_internal;
    use std::io::BufRead;

    let sanitized_log_path = sanitize_file_path_internal(log_path)
        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;

    if !Path::new(&sanitized_log_path).exists() {
        return Ok(Vec::new());
    }

    let file = fs::File::open(&sanitized_log_path)?;
    let reader = std::io::BufReader::new(file);
    let mut entries: Vec<AuditLogEntry> = Vec::with_capacity(count);

    for line_str in reader.lines().map_while(Result::ok) {
        if let Ok(entry) = serde_json::from_str::<AuditLogEntry>(&line_str) {
            entries.push(entry);
        }
    }

    let start = if entries.len() > count {
        entries.len() - count
    } else {
        0
    };

    Ok(entries.into_iter().skip(start).collect())
}

pub use crate::core::audit_chain::verify_log_integrity as verify_audit_log;

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    #[test]
    fn test_log_action() {
        let temp_dir = TempDir::new().unwrap();
        let log_path = temp_dir.path().join("audit.log");

        log_action(
            "SET",
            "dev",
            Some("key1"),
            None,
            Some("val1"),
            log_path.to_str().unwrap(),
        )
        .unwrap();

        let logs = get_recent_logs(log_path.to_str().unwrap(), 10).unwrap();
        assert_eq!(logs.len(), 1);
    }

    #[test]
    fn test_log_chain() {
        let temp_dir = TempDir::new().unwrap();
        let log_path = temp_dir.path().join("audit.log");

        log_action(
            "SET",
            "dev",
            Some("key1"),
            None,
            Some("val1"),
            log_path.to_str().unwrap(),
        )
        .unwrap();
        log_action(
            "SET",
            "dev",
            Some("key2"),
            None,
            Some("val2"),
            log_path.to_str().unwrap(),
        )
        .unwrap();

        let logs = get_recent_logs(log_path.to_str().unwrap(), 10).unwrap();
        assert_eq!(logs.len(), 2);
        assert_eq!(logs[1].previous_hash, logs[0].hash);
    }

    #[test]
    fn test_verify_integrity() {
        let temp_dir = TempDir::new().unwrap();
        let log_path = temp_dir.path().join("audit.log");

        log_action(
            "SET",
            "dev",
            Some("key"),
            None,
            Some("val"),
            log_path.to_str().unwrap(),
        )
        .unwrap();

        let result = verify_log_integrity(log_path.to_str().unwrap()).unwrap();
        assert!(result);
    }
}