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 zoi_dir = utils::get_user_state_dir()?.join("audit");
99 if !zoi_dir.exists() {
100 fs::create_dir_all(&zoi_dir)?;
101 }
102 Ok(zoi_dir.join("audit.json"))
103}
104
105fn get_username() -> String {
107 #[cfg(unix)]
108 {
109 std::env::var("USER").unwrap_or_else(|_| "unknown".to_string())
110 }
111 #[cfg(windows)]
112 {
113 std::env::var("USERNAME").unwrap_or_else(|_| "unknown".to_string())
114 }
115}
116
117fn calculate_entry_hash(
119 entry: &AuditEntry,
120 prev_hash: Option<&str>
121) -> Result<String> {
122 #[derive(Serialize)]
123 struct HashPayload<'a> {
124 entry: &'a AuditEntry,
125 prev_hash: Option<&'a str>
126 }
127
128 let payload = HashPayload { entry, prev_hash };
129 let json = serde_json::to_string(&payload)?;
130 let mut hasher = Sha256::new();
131 hasher.update(json.as_bytes());
132 Ok(hex::encode(hasher.finalize()))
133}
134
135fn read_audit_log() -> Result<AuditLog> {
137 let path = get_audit_log_path()?;
138 if !path.exists() {
139 return Ok(AuditLog {
140 version: env!("CARGO_PKG_VERSION").to_string(),
141 entries: Vec::new()
142 });
143 }
144
145 let content = fs::read_to_string(&path)?;
146 if content.trim().is_empty() {
147 return Ok(AuditLog {
148 version: env!("CARGO_PKG_VERSION").to_string(),
149 entries: Vec::new()
150 });
151 }
152
153 if let Ok(log) = serde_json::from_str::<AuditLog>(&content) {
154 return Ok(log);
155 }
156
157 let mut entries = Vec::new();
158 for line in content.lines() {
159 if !line.trim().is_empty()
160 && let Ok(parsed) = serde_json::from_str::<AuditLogLine>(line)
161 {
162 entries.push(parsed);
163 }
164 }
165
166 if !entries.is_empty() {
167 return Ok(AuditLog {
168 version: env!("CARGO_PKG_VERSION").to_string(),
169 entries
170 });
171 }
172
173 Ok(AuditLog {
174 version: env!("CARGO_PKG_VERSION").to_string(),
175 entries: Vec::new()
176 })
177}
178
179fn write_audit_log(log: &AuditLog) -> Result<()> {
181 let path = get_audit_log_path()?;
182 let content = serde_json::to_string_pretty(log)?;
183 fs::write(path, content)?;
184 Ok(())
185}
186
187pub fn log_event(
194 action: AuditAction,
195 manifest: &types::InstallManifest
196) -> Result<()> {
197 let config = config::read_config()?;
198 if !config.audit_log_enabled {
199 return Ok(());
200 }
201
202 let mut log = read_audit_log()?;
203 let prev_hash = log.entries.last().and_then(|l| l.hash.clone());
204
205 let user = get_username();
206 let entry = AuditEntry {
207 timestamp: Utc::now(),
208 user,
209 action,
210 package_name: manifest.name.clone(),
211 version: manifest.version.clone(),
212 repo: manifest.repo.clone(),
213 package_type: manifest.package_type,
214 scope: manifest.scope,
215 registry: manifest.registry_handle.clone()
216 };
217
218 let hash = Some(calculate_entry_hash(&entry, prev_hash.as_deref())?);
219 log.entries.push(AuditLogLine {
220 entry,
221 prev_hash,
222 hash
223 });
224 log.version = env!("CARGO_PKG_VERSION").to_string();
225
226 write_audit_log(&log)?;
227 Ok(())
228}
229
230pub fn get_history() -> Result<Vec<AuditEntry>> {
236 let log = read_audit_log()?;
237 Ok(log.entries.into_iter().map(|l| l.entry).collect())
238}
239
240pub fn export_history(export_path: &Path, ndjson: bool) -> Result<usize> {
247 let log = read_audit_log()?;
248 if log.entries.is_empty() {
249 return Err(anyhow!(
250 "No history recorded. Audit logging might be disabled."
251 ));
252 }
253
254 if let Some(parent) = export_path.parent()
255 && !parent.as_os_str().is_empty()
256 {
257 fs::create_dir_all(parent)?;
258 }
259
260 if ndjson {
261 let mut content = String::new();
262 for entry in &log.entries {
263 content.push_str(&serde_json::to_string(entry)?);
264 content.push('\n');
265 }
266 fs::write(export_path, content)?;
267 } else {
268 let json = serde_json::to_string_pretty(&log.entries)?;
269 fs::write(export_path, json)?;
270 }
271
272 Ok(log.entries.len())
273}
274
275pub fn verify_chain() -> Result<AuditVerification> {
281 let log = read_audit_log()?;
282 let mut total_entries = 0usize;
283 let mut hashed_entries = 0usize;
284 let mut legacy_entries = 0usize;
285 let mut previous_hash: Option<String> = None;
286 let mut seen_hashed = false;
287
288 for (index, parsed) in log.entries.iter().enumerate() {
289 total_entries += 1;
290
291 if let Some(stored_hash) = parsed.hash.as_deref() {
292 seen_hashed = true;
293 hashed_entries += 1;
294
295 if parsed.prev_hash != previous_hash {
296 return Ok(AuditVerification {
297 valid: false,
298 total_entries,
299 hashed_entries,
300 legacy_entries,
301 message: format!(
302 "Audit hash chain is broken at entry {} (prev_hash \
303 mismatch).",
304 index + 1
305 )
306 });
307 }
308
309 let expected_hash = calculate_entry_hash(
310 &parsed.entry,
311 parsed.prev_hash.as_deref()
312 )?;
313 if stored_hash != expected_hash {
314 return Ok(AuditVerification {
315 valid: false,
316 total_entries,
317 hashed_entries,
318 legacy_entries,
319 message: format!(
320 "Audit hash mismatch at entry {} (entry appears \
321 modified).",
322 index + 1
323 )
324 });
325 }
326
327 previous_hash = Some(stored_hash.to_string());
328 } else {
329 legacy_entries += 1;
330 if seen_hashed {
331 return Ok(AuditVerification {
332 valid: false,
333 total_entries,
334 hashed_entries,
335 legacy_entries,
336 message: format!(
337 "Legacy audit entry detected after chained entries at \
338 entry {}.",
339 index + 1
340 )
341 });
342 }
343 }
344 }
345
346 let message = if total_entries == 0 {
347 "No audit history found.".to_string()
348 } else if hashed_entries == 0 && legacy_entries > 0 {
349 "Audit log is valid but uses legacy non-chained entries.".to_string()
350 } else {
351 "Audit hash chain is valid.".to_string()
352 };
353
354 Ok(AuditVerification {
355 valid: true,
356 total_entries,
357 hashed_entries,
358 legacy_entries,
359 message
360 })
361}