1use 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#[derive(Debug, Serialize, Deserialize, Clone)]
26pub enum AuditAction {
27 Install,
29 Uninstall,
31 Upgrade
33}
34
35#[derive(Debug, Serialize, Deserialize, Clone)]
37pub struct AuditEntry {
38 pub timestamp: DateTime<Utc>,
40 pub user: String,
42 pub action: AuditAction,
44 pub package_name: String,
46 pub version: String,
48 pub repo: String,
50 pub package_type: types::PackageType,
52 pub scope: types::Scope,
54 pub registry: String
56}
57
58#[derive(Debug, Serialize, Deserialize, Clone, Default)]
60pub struct AuditLog {
61 pub version: String,
63 pub entries: Vec<AuditLogLine>
65}
66
67#[derive(Debug, Serialize, Deserialize, Clone)]
69pub struct AuditLogLine {
70 #[serde(flatten)]
72 pub entry: AuditEntry,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub prev_hash: Option<String>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub hash: Option<String>
79}
80
81#[derive(Debug, Clone)]
83pub struct AuditVerification {
84 pub valid: bool,
86 pub total_entries: usize,
88 pub hashed_entries: usize,
90 pub legacy_entries: usize,
92 pub message: String
94}
95
96fn 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
107fn 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
119fn 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
137fn 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
181fn 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
189pub 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
232pub 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
242pub 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
277pub 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}