Skip to main content

zoi_audit/
lib.rs

1//! Tamper-evident audit logging for Zoi.
2//!
3//! This crate provides a hash-chained audit log that records all
4//! state-changing operations in Zoi, such as installs and uninstalls.
5//! The use of SHA-256 hashes ensures the integrity and chronological
6//! order of the log entries.
7
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use anyhow::{Result, anyhow};
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15use zoi_core::{config, types, utils};
16
17/// Manages Zoi's tamper-evident audit log.
18///
19/// The audit log records all state-changing operations (Install, Uninstall,
20/// Upgrade). It uses a "Hash Chain" mechanism where each new entry contains a
21/// SHA-256 hash of its contents PLUS the hash of the previous entry. This makes
22/// it mathematically impossible to modify or delete a historical entry without
23/// breaking the chain.
24/// The type of action recorded in the audit log.
25#[derive(Debug, Serialize, Deserialize, Clone)]
26pub enum AuditAction {
27    /// A package was installed.
28    Install,
29    /// A package was uninstalled.
30    Uninstall,
31    /// A package was upgraded.
32    Upgrade
33}
34
35/// A single entry in the audit log.
36#[derive(Debug, Serialize, Deserialize, Clone)]
37pub struct AuditEntry {
38    /// When the action occurred.
39    pub timestamp: DateTime<Utc>,
40    /// The user who performed the action.
41    pub user: String,
42    /// The action performed.
43    pub action: AuditAction,
44    /// Name of the package.
45    pub package_name: String,
46    /// Version of the package.
47    pub version: String,
48    /// Repository handle.
49    pub repo: String,
50    /// Type of the package.
51    pub package_type: types::PackageType,
52    /// Installation scope.
53    pub scope: types::Scope,
54    /// Registry handle.
55    pub registry: String
56}
57
58/// The complete audit log structure.
59#[derive(Debug, Serialize, Deserialize, Clone, Default)]
60pub struct AuditLog {
61    /// Version of the audit log format.
62    pub version: String,
63    /// List of audit log lines.
64    pub entries: Vec<AuditLogLine>
65}
66
67/// A single line in the audit log, including cryptographic hashes.
68#[derive(Debug, Serialize, Deserialize, Clone)]
69pub struct AuditLogLine {
70    /// The core audit entry data.
71    #[serde(flatten)]
72    pub entry: AuditEntry,
73    /// SHA-256 hash of the previous entry.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub prev_hash: Option<String>,
76    /// SHA-256 hash of the current entry (including `prev_hash`).
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub hash: Option<String>
79}
80
81/// Result of an audit log verification.
82#[derive(Debug, Clone)]
83pub struct AuditVerification {
84    /// Whether the audit chain is valid.
85    pub valid: bool,
86    /// Total number of entries checked.
87    pub total_entries: usize,
88    /// Number of entries with valid hashes.
89    pub hashed_entries: usize,
90    /// Number of legacy (non-hashed) entries.
91    pub legacy_entries: usize,
92    /// Descriptive message about the verification result.
93    pub message: String
94}
95
96/// Returns the path to the audit log file.
97fn get_audit_log_path() -> Result<PathBuf> {
98    let home_dir = utils::get_user_home()
99        .ok_or_else(|| anyhow!("Could not find home directory."))?;
100    let zoi_dir = zoi_core::sysroot::apply_sysroot(home_dir.join(".zoi"));
101    if !zoi_dir.exists() {
102        fs::create_dir_all(&zoi_dir)?;
103    }
104    Ok(zoi_dir.join("audit.json"))
105}
106
107/// Returns the current user's name.
108fn get_username() -> String {
109    #[cfg(unix)]
110    {
111        std::env::var("USER").unwrap_or_else(|_| "unknown".to_string())
112    }
113    #[cfg(windows)]
114    {
115        std::env::var("USERNAME").unwrap_or_else(|_| "unknown".to_string())
116    }
117}
118
119/// Calculates the SHA-256 hash for an audit entry.
120fn calculate_entry_hash(
121    entry: &AuditEntry,
122    prev_hash: Option<&str>
123) -> Result<String> {
124    #[derive(Serialize)]
125    struct HashPayload<'a> {
126        entry: &'a AuditEntry,
127        prev_hash: Option<&'a str>
128    }
129
130    let payload = HashPayload { entry, prev_hash };
131    let json = serde_json::to_string(&payload)?;
132    let mut hasher = Sha256::new();
133    hasher.update(json.as_bytes());
134    Ok(hex::encode(hasher.finalize()))
135}
136
137/// Reads the audit log from disk.
138fn read_audit_log() -> Result<AuditLog> {
139    let path = get_audit_log_path()?;
140    if !path.exists() {
141        return Ok(AuditLog {
142            version: env!("CARGO_PKG_VERSION").to_string(),
143            entries: Vec::new()
144        });
145    }
146
147    let content = fs::read_to_string(&path)?;
148    if content.trim().is_empty() {
149        return Ok(AuditLog {
150            version: env!("CARGO_PKG_VERSION").to_string(),
151            entries: Vec::new()
152        });
153    }
154
155    if let Ok(log) = serde_json::from_str::<AuditLog>(&content) {
156        return Ok(log);
157    }
158
159    let mut entries = Vec::new();
160    for line in content.lines() {
161        if !line.trim().is_empty()
162            && let Ok(parsed) = serde_json::from_str::<AuditLogLine>(line)
163        {
164            entries.push(parsed);
165        }
166    }
167
168    if !entries.is_empty() {
169        return Ok(AuditLog {
170            version: env!("CARGO_PKG_VERSION").to_string(),
171            entries
172        });
173    }
174
175    Ok(AuditLog {
176        version: env!("CARGO_PKG_VERSION").to_string(),
177        entries: Vec::new()
178    })
179}
180
181/// Writes the audit log to disk.
182fn write_audit_log(log: &AuditLog) -> Result<()> {
183    let path = get_audit_log_path()?;
184    let content = serde_json::to_string_pretty(log)?;
185    fs::write(path, content)?;
186    Ok(())
187}
188
189/// Logs a new event to the audit log.
190///
191/// # Errors
192///
193/// Returns an error if the configuration or audit log cannot be read,
194/// or if writing the updated log fails.
195pub fn log_event(
196    action: AuditAction,
197    manifest: &types::InstallManifest
198) -> Result<()> {
199    let config = config::read_config()?;
200    if !config.audit_log_enabled {
201        return Ok(());
202    }
203
204    let mut log = read_audit_log()?;
205    let prev_hash = log.entries.last().and_then(|l| l.hash.clone());
206
207    let user = get_username();
208    let entry = AuditEntry {
209        timestamp: Utc::now(),
210        user,
211        action,
212        package_name: manifest.name.clone(),
213        version: manifest.version.clone(),
214        repo: manifest.repo.clone(),
215        package_type: manifest.package_type,
216        scope: manifest.scope,
217        registry: manifest.registry_handle.clone()
218    };
219
220    let hash = Some(calculate_entry_hash(&entry, prev_hash.as_deref())?);
221    log.entries.push(AuditLogLine {
222        entry,
223        prev_hash,
224        hash
225    });
226    log.version = env!("CARGO_PKG_VERSION").to_string();
227
228    write_audit_log(&log)?;
229    Ok(())
230}
231
232/// Returns the full audit history.
233///
234/// # Errors
235///
236/// Returns an error if the audit log cannot be read from disk.
237pub fn get_history() -> Result<Vec<AuditEntry>> {
238    let log = read_audit_log()?;
239    Ok(log.entries.into_iter().map(|l| l.entry).collect())
240}
241
242/// Exports the audit history to a file.
243///
244/// # Errors
245///
246/// Returns an error if the history is empty, the export path is invalid,
247/// or if writing to the export path fails.
248pub fn export_history(export_path: &Path, ndjson: bool) -> Result<usize> {
249    let log = read_audit_log()?;
250    if log.entries.is_empty() {
251        return Err(anyhow!(
252            "No history recorded. Audit logging might be disabled."
253        ));
254    }
255
256    if let Some(parent) = export_path.parent()
257        && !parent.as_os_str().is_empty()
258    {
259        fs::create_dir_all(parent)?;
260    }
261
262    if ndjson {
263        let mut content = String::new();
264        for entry in &log.entries {
265            content.push_str(&serde_json::to_string(entry)?);
266            content.push('\n');
267        }
268        fs::write(export_path, content)?;
269    } else {
270        let json = serde_json::to_string_pretty(&log.entries)?;
271        fs::write(export_path, json)?;
272    }
273
274    Ok(log.entries.len())
275}
276
277/// Verifies the integrity of the audit log hash chain.
278///
279/// # Errors
280///
281/// Returns an error if the audit log cannot be read from disk.
282pub fn verify_chain() -> Result<AuditVerification> {
283    let log = read_audit_log()?;
284    let mut total_entries = 0usize;
285    let mut hashed_entries = 0usize;
286    let mut legacy_entries = 0usize;
287    let mut previous_hash: Option<String> = None;
288    let mut seen_hashed = false;
289
290    for (index, parsed) in log.entries.iter().enumerate() {
291        total_entries += 1;
292
293        if let Some(stored_hash) = parsed.hash.as_deref() {
294            seen_hashed = true;
295            hashed_entries += 1;
296
297            if parsed.prev_hash != previous_hash {
298                return Ok(AuditVerification {
299                    valid: false,
300                    total_entries,
301                    hashed_entries,
302                    legacy_entries,
303                    message: format!(
304                        "Audit hash chain is broken at entry {} (prev_hash \
305                         mismatch).",
306                        index + 1
307                    )
308                });
309            }
310
311            let expected_hash = calculate_entry_hash(
312                &parsed.entry,
313                parsed.prev_hash.as_deref()
314            )?;
315            if stored_hash != expected_hash {
316                return Ok(AuditVerification {
317                    valid: false,
318                    total_entries,
319                    hashed_entries,
320                    legacy_entries,
321                    message: format!(
322                        "Audit hash mismatch at entry {} (entry appears \
323                         modified).",
324                        index + 1
325                    )
326                });
327            }
328
329            previous_hash = Some(stored_hash.to_string());
330        } else {
331            legacy_entries += 1;
332            if seen_hashed {
333                return Ok(AuditVerification {
334                    valid: false,
335                    total_entries,
336                    hashed_entries,
337                    legacy_entries,
338                    message: format!(
339                        "Legacy audit entry detected after chained entries at \
340                         entry {}.",
341                        index + 1
342                    )
343                });
344            }
345        }
346    }
347
348    let message = if total_entries == 0 {
349        "No audit history found.".to_string()
350    } else if hashed_entries == 0 && legacy_entries > 0 {
351        "Audit log is valid but uses legacy non-chained entries.".to_string()
352    } else {
353        "Audit hash chain is valid.".to_string()
354    };
355
356    Ok(AuditVerification {
357        valid: true,
358        total_entries,
359        hashed_entries,
360        legacy_entries,
361        message
362    })
363}