zoi-rs 1.12.0

Universal Package Manager & Environment Setup Tool
Documentation
use crate::pkg::{config, types};
use anyhow::{Result, anyhow};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;

#[derive(Debug, Serialize, Deserialize, Clone)]
pub enum AuditAction {
    Install,
    Uninstall,
    Upgrade,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AuditEntry {
    pub timestamp: DateTime<Utc>,
    pub user: String,
    pub action: AuditAction,
    pub package_name: String,
    pub version: String,
    pub repo: String,
    pub package_type: types::PackageType,
    pub scope: types::Scope,
    pub registry: String,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
struct AuditLogLine {
    #[serde(flatten)]
    entry: AuditEntry,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    prev_hash: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    hash: Option<String>,
}

#[derive(Debug, Clone)]
pub struct AuditVerification {
    pub valid: bool,
    pub total_entries: usize,
    pub hashed_entries: usize,
    pub legacy_entries: usize,
    pub message: String,
}

fn get_audit_log_path() -> Result<PathBuf> {
    let home_dir = home::home_dir().ok_or_else(|| anyhow!("Could not find home directory."))?;
    let zoi_dir = home_dir.join(".zoi");
    if !zoi_dir.exists() {
        fs::create_dir_all(&zoi_dir)?;
    }
    Ok(zoi_dir.join("audit.json"))
}

fn get_username() -> String {
    #[cfg(unix)]
    {
        std::env::var("USER").unwrap_or_else(|_| "unknown".to_string())
    }
    #[cfg(windows)]
    {
        std::env::var("USERNAME").unwrap_or_else(|_| "unknown".to_string())
    }
}

fn calculate_entry_hash(entry: &AuditEntry, prev_hash: Option<&str>) -> Result<String> {
    #[derive(Serialize)]
    struct HashPayload<'a> {
        entry: &'a AuditEntry,
        prev_hash: Option<&'a str>,
    }

    let payload = HashPayload { entry, prev_hash };
    let json = serde_json::to_string(&payload)?;
    let mut hasher = Sha256::new();
    hasher.update(json.as_bytes());
    Ok(hex::encode(hasher.finalize()))
}

fn get_last_hashed_entry(log_path: &PathBuf) -> Result<Option<AuditLogLine>> {
    if !log_path.exists() {
        return Ok(None);
    }

    let content = fs::read_to_string(log_path)?;
    for line in content.lines().rev() {
        if line.trim().is_empty() {
            continue;
        }
        let parsed: AuditLogLine = serde_json::from_str(line)?;
        if parsed.hash.is_some() {
            return Ok(Some(parsed));
        }
    }

    Ok(None)
}

pub fn log_event(action: AuditAction, manifest: &types::InstallManifest) -> Result<()> {
    let config = config::read_config()?;
    if !config.audit_log_enabled {
        return Ok(());
    }

    let user = get_username();
    let entry = AuditEntry {
        timestamp: Utc::now(),
        user,
        action,
        package_name: manifest.name.clone(),
        version: manifest.version.clone(),
        repo: manifest.repo.clone(),
        package_type: manifest.package_type,
        scope: manifest.scope,
        registry: manifest.registry_handle.clone(),
    };

    let log_path = get_audit_log_path()?;
    let prev_hash = get_last_hashed_entry(&log_path)?.and_then(|line| line.hash);
    let mut file = OpenOptions::new()
        .create(true)
        .append(true)
        .open(log_path)?;

    let hash = Some(calculate_entry_hash(&entry, prev_hash.as_deref())?);
    let line = AuditLogLine {
        entry,
        prev_hash,
        hash,
    };
    let json = serde_json::to_string(&line)?;
    writeln!(file, "{}", json)?;

    Ok(())
}

pub fn get_history() -> Result<Vec<AuditEntry>> {
    let log_path = get_audit_log_path()?;
    if !log_path.exists() {
        return Ok(Vec::new());
    }

    let content = fs::read_to_string(log_path)?;
    let mut entries = Vec::new();
    for line in content.lines() {
        if !line.trim().is_empty() {
            let parsed: AuditLogLine = serde_json::from_str(line)?;
            entries.push(parsed.entry);
        }
    }
    Ok(entries)
}

pub fn export_history(export_path: &Path, ndjson: bool) -> Result<usize> {
    let log_path = get_audit_log_path()?;
    if !log_path.exists() {
        return Err(anyhow!(
            "No history recorded. Audit logging might be disabled."
        ));
    }

    let content = fs::read_to_string(log_path)?;
    let raw_lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect();
    if raw_lines.is_empty() {
        return Err(anyhow!(
            "No history recorded. Audit logging might be disabled."
        ));
    }

    if let Some(parent) = export_path.parent()
        && !parent.as_os_str().is_empty()
    {
        fs::create_dir_all(parent)?;
    }

    if ndjson {
        fs::write(export_path, format!("{}\n", raw_lines.join("\n")))?;
        return Ok(raw_lines.len());
    }

    let mut entries = Vec::new();
    for (index, line) in raw_lines.iter().enumerate() {
        let parsed: AuditLogLine = serde_json::from_str(line)
            .map_err(|e| anyhow!("Invalid audit log JSON at line {}: {}", index + 1, e))?;
        entries.push(parsed);
    }

    let json = serde_json::to_string_pretty(&entries)?;
    fs::write(export_path, json)?;
    Ok(entries.len())
}

pub fn verify_chain() -> Result<AuditVerification> {
    let log_path = get_audit_log_path()?;
    if !log_path.exists() {
        return Ok(AuditVerification {
            valid: true,
            total_entries: 0,
            hashed_entries: 0,
            legacy_entries: 0,
            message: "No audit history found.".to_string(),
        });
    }

    let content = fs::read_to_string(log_path)?;
    let mut total_entries = 0usize;
    let mut hashed_entries = 0usize;
    let mut legacy_entries = 0usize;
    let mut previous_hash: Option<String> = None;
    let mut seen_hashed = false;

    for (index, line) in content.lines().enumerate() {
        if line.trim().is_empty() {
            continue;
        }
        total_entries += 1;

        let parsed: AuditLogLine = serde_json::from_str(line)
            .map_err(|e| anyhow!("Invalid audit log JSON at line {}: {}", index + 1, e))?;

        if let Some(stored_hash) = parsed.hash.as_deref() {
            seen_hashed = true;
            hashed_entries += 1;

            if parsed.prev_hash != previous_hash {
                return Ok(AuditVerification {
                    valid: false,
                    total_entries,
                    hashed_entries,
                    legacy_entries,
                    message: format!(
                        "Audit hash chain is broken at line {} (prev_hash mismatch).",
                        index + 1
                    ),
                });
            }

            let expected_hash = calculate_entry_hash(&parsed.entry, parsed.prev_hash.as_deref())?;
            if stored_hash != expected_hash {
                return Ok(AuditVerification {
                    valid: false,
                    total_entries,
                    hashed_entries,
                    legacy_entries,
                    message: format!(
                        "Audit hash mismatch at line {} (entry appears modified).",
                        index + 1
                    ),
                });
            }

            previous_hash = Some(stored_hash.to_string());
        } else {
            legacy_entries += 1;
            if seen_hashed {
                return Ok(AuditVerification {
                    valid: false,
                    total_entries,
                    hashed_entries,
                    legacy_entries,
                    message: format!(
                        "Legacy audit entry detected after chained entries at line {}.",
                        index + 1
                    ),
                });
            }
        }
    }

    let message = if hashed_entries == 0 && legacy_entries > 0 {
        "Audit log is valid but uses legacy non-chained entries.".to_string()
    } else {
        "Audit hash chain is valid.".to_string()
    };

    Ok(AuditVerification {
        valid: true,
        total_entries,
        hashed_entries,
        legacy_entries,
        message,
    })
}