1use std::fs::{File, OpenOptions};
7use std::io::{BufRead, BufReader, Write};
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use anyhow::{Context, Result};
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15use tokio::sync::Mutex;
16use vtcode_commons::VtCodePaths;
17use vtcode_commons::utils::calculate_sha256;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum AuditOutcome {
23 AllowedWithConfirmation,
25 AllowedViaWhitelist,
27 Blocked,
29 Denied,
31 UserRejected,
33 AllowedUnprotected,
35}
36
37impl std::fmt::Display for AuditOutcome {
38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39 match self {
40 AuditOutcome::AllowedWithConfirmation => write!(f, "ALLOWED_WITH_CONFIRMATION"),
41 AuditOutcome::AllowedViaWhitelist => write!(f, "ALLOWED_VIA_WHITELIST"),
42 AuditOutcome::Blocked => write!(f, "BLOCKED"),
43 AuditOutcome::Denied => write!(f, "DENIED"),
44 AuditOutcome::UserRejected => write!(f, "USER_REJECTED"),
45 AuditOutcome::AllowedUnprotected => write!(f, "ALLOWED_UNPROTECTED"),
46 }
47 }
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum AccessType {
54 Read,
56 Write,
58 Create,
60 Delete,
62 Modify,
64 Append,
66}
67
68impl std::fmt::Display for AccessType {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 match self {
71 AccessType::Read => write!(f, "READ"),
72 AccessType::Write => write!(f, "WRITE"),
73 AccessType::Create => write!(f, "CREATE"),
74 AccessType::Delete => write!(f, "DELETE"),
75 AccessType::Modify => write!(f, "MODIFY"),
76 AccessType::Append => write!(f, "APPEND"),
77 }
78 }
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct AuditEntry {
84 pub id: String,
86 pub timestamp: DateTime<Utc>,
88 pub file_path: String,
90 pub access_type: AccessType,
92 pub outcome: AuditOutcome,
94 pub initiator: String,
96 pub session_id: String,
98 pub proposed_changes: Option<String>,
100 pub previous_hash: String,
102 #[serde(skip_serializing_if = "Option::is_none")]
104 pub entry_hash: Option<String>,
105 pub context: Option<String>,
107 pub during_automation: bool,
109}
110
111impl AuditEntry {
112 pub fn new(
114 file_path: impl Into<String>,
115 access_type: AccessType,
116 outcome: AuditOutcome,
117 initiator: impl Into<String>,
118 session_id: impl Into<String>,
119 previous_hash: impl Into<String>,
120 ) -> Self {
121 Self {
122 id: uuid::Uuid::new_v4().to_string(),
123 timestamp: Utc::now(),
124 file_path: file_path.into(),
125 access_type,
126 outcome,
127 initiator: initiator.into(),
128 session_id: session_id.into(),
129 proposed_changes: None,
130 previous_hash: previous_hash.into(),
131 entry_hash: None,
132 context: None,
133 during_automation: false,
134 }
135 }
136
137 pub fn with_proposed_changes(mut self, changes: impl Into<String>) -> Self {
139 self.proposed_changes = Some(changes.into());
140 self
141 }
142
143 pub fn with_context(mut self, context: impl Into<String>) -> Self {
145 self.context = Some(context.into());
146 self
147 }
148
149 pub fn during_automation(mut self) -> Self {
151 self.during_automation = true;
152 self
153 }
154
155 pub fn finalize(mut self) -> Self {
157 self.entry_hash = Some(self.compute_hash());
158 self
159 }
160
161 fn compute_hash(&self) -> String {
163 let mut hasher = Sha256::new();
164 hasher.update(self.id.as_bytes());
165 hasher.update(self.timestamp.to_rfc3339().as_bytes());
166 hasher.update(self.file_path.as_bytes());
167 hasher.update(format!("{:?}", self.access_type).as_bytes());
168 hasher.update(format!("{:?}", self.outcome).as_bytes());
169 hasher.update(self.initiator.as_bytes());
170 hasher.update(self.session_id.as_bytes());
171 hasher.update(self.previous_hash.as_bytes());
172 if let Some(ref changes) = self.proposed_changes {
173 hasher.update(changes.as_bytes());
174 }
175 if let Some(ref ctx) = self.context {
176 hasher.update(ctx.as_bytes());
177 }
178 hasher.update([self.during_automation as u8]);
179 calculate_sha256(&hasher.finalize())
180 }
181
182 pub fn verify(&self) -> bool {
184 self.entry_hash.as_ref().is_some_and(|hash| *hash == self.compute_hash())
185 }
186}
187
188pub struct AuditLog {
197 log_path: PathBuf,
199 write_lock: Arc<Mutex<()>>,
203 last_hash: Arc<Mutex<String>>,
211}
212
213impl AuditLog {
214 pub async fn new(log_path: impl AsRef<Path>) -> Result<Self> {
216 let log_path = log_path.as_ref().to_path_buf();
217
218 if let Some(parent) = log_path.parent() {
220 VtCodePaths::ensure_user_dir(parent)
221 .with_context(|| format!("Failed to create audit log directory: {parent:?}"))?;
222 }
223
224 let last_hash = if log_path.exists() {
227 let path = log_path.clone();
228 tokio::task::spawn_blocking(move || Self::read_last_hash(&path))
229 .await
230 .context("audit log hash read task panicked")??
231 } else {
232 "0000000000000000000000000000000000000000000000000000000000000000".to_string()
234 };
235
236 Ok(Self {
237 log_path,
238 write_lock: Arc::new(Mutex::new(())),
239 last_hash: Arc::new(Mutex::new(last_hash)),
240 })
241 }
242
243 fn read_last_hash(log_path: &Path) -> Result<String> {
250 use std::io::{Read, Seek, SeekFrom};
251
252 const DEFAULT_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000";
253
254 let mut file = File::open(log_path).with_context(|| "Failed to open audit log")?;
255 let len = file.metadata().with_context(|| "Failed to read audit log metadata")?.len();
256 if len == 0 {
257 return Ok(DEFAULT_HASH.to_string());
258 }
259
260 let window: u64 = (1 << 18).min(len); file.seek(SeekFrom::End(-(window as i64)))
264 .with_context(|| "Failed to seek audit log")?;
265 let mut buf = Vec::with_capacity(window as usize);
266 file.read_to_end(&mut buf).with_context(|| "Failed to read audit log tail")?;
267
268 let text = String::from_utf8_lossy(&buf);
269 let mut last_hash = DEFAULT_HASH.to_string();
270 for raw in text.lines().rev() {
271 let raw = raw.trim_end_matches(['\n', '\r']);
272 if raw.trim().is_empty() {
273 continue;
274 }
275 if let Ok(entry) = serde_json::from_str::<AuditEntry>(raw)
276 && let Some(hash) = entry.entry_hash
277 {
278 last_hash = hash;
279 break;
280 }
281 }
282 Ok(last_hash)
283 }
284
285 pub async fn log(&self, mut entry: AuditEntry) -> Result<()> {
300 let write_guard = self.write_lock.clone().lock_owned().await;
301 let mut hash_guard = self.last_hash.clone().lock_owned().await;
302
303 entry.previous_hash = hash_guard.clone();
305 let entry = entry.finalize();
306 let new_hash = entry.entry_hash.clone();
307 let json = serde_json::to_string(&entry).with_context(|| "Failed to serialize audit entry")?;
308
309 let log_path = self.log_path.clone();
314 tokio::task::spawn_blocking(move || -> Result<()> {
315 let _write_guard = write_guard; Self::append_entry_blocking(&log_path, &json)?;
318
319 if let Some(hash) = new_hash {
324 *hash_guard = hash;
325 }
326
327 let sync_result = File::open(&log_path).and_then(|f| f.sync_all());
332
333 if let Err(e) = sync_result {
334 drop(hash_guard);
337 return Err(e).with_context(|| "Failed to sync audit log");
338 }
339
340 drop(hash_guard);
341 Ok(())
342 })
343 .await
344 .context("audit log write task panicked")?
345 }
346
347 fn append_entry_blocking(log_path: &Path, json: &str) -> Result<()> {
359 let mut file = OpenOptions::new()
360 .create(true)
361 .append(true)
362 .open(log_path)
363 .with_context(|| format!("Failed to open audit log: {log_path:?}"))?;
364
365 let pre_len = file.metadata().with_context(|| "Failed to read audit log metadata")?.len();
367
368 let mut line = json.to_string();
371 line.push('\n');
372
373 if let Err(e) = file.write_all(line.as_bytes()) {
374 let _ = file.set_len(pre_len);
377 return Err(e).with_context(|| "Failed to write audit entry");
378 }
379
380 Ok(())
381 }
382
383 pub async fn get_entries(&self) -> Result<Vec<AuditEntry>> {
389 let guard = self.write_lock.clone().lock_owned().await;
390
391 let log_path = self.log_path.clone();
396 tokio::task::spawn_blocking(move || -> Result<Vec<AuditEntry>> {
397 let _guard = guard; if !log_path.exists() {
400 return Ok(Vec::new());
401 }
402
403 let file = File::open(&log_path).with_context(|| "Failed to open audit log")?;
404 let mut reader = BufReader::new(file);
405 let mut entries = Vec::new();
406 let mut line = String::new();
407
408 loop {
409 line.clear();
410 if reader.read_line(&mut line).with_context(|| "Failed to read audit log line")? == 0 {
411 break;
412 }
413 let raw = line.trim_end_matches(['\n', '\r']);
414 if raw.trim().is_empty() {
415 continue;
416 }
417 let entry: AuditEntry = serde_json::from_str(raw).with_context(|| "Failed to parse audit entry")?;
418 entries.push(entry);
419 }
420
421 Ok(entries)
422 })
423 .await
424 .context("audit log read task panicked")?
425 }
426
427 pub async fn verify_integrity(&self) -> Result<bool> {
429 let entries = self.get_entries().await?;
430
431 if entries.is_empty() {
432 return Ok(true);
433 }
434
435 let mut expected_prev_hash = "0000000000000000000000000000000000000000000000000000000000000000".to_string();
436
437 for entry in entries {
438 if !entry.verify() {
440 tracing::warn!("Audit log integrity violation: entry {} has invalid hash", entry.id);
441 return Ok(false);
442 }
443
444 if entry.previous_hash != expected_prev_hash {
446 tracing::warn!("Audit log integrity violation: entry {} has broken chain", entry.id);
447 return Ok(false);
448 }
449
450 expected_prev_hash = entry.entry_hash.unwrap_or_default();
451 }
452
453 Ok(true)
454 }
455
456 pub async fn get_entries_for_file(&self, file_path: &str) -> Result<Vec<AuditEntry>> {
458 let entries = self.get_entries().await?;
459 Ok(entries.into_iter().filter(|e| e.file_path == file_path).collect())
460 }
461
462 pub async fn get_recent_entries(&self, count: usize) -> Result<Vec<AuditEntry>> {
464 let entries = self.get_entries().await?;
465 let len = entries.len();
466 if len <= count {
467 Ok(entries)
468 } else {
469 Ok(entries.into_iter().skip(len - count).collect())
470 }
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477 use tempfile::tempdir;
478
479 #[tokio::test]
480 async fn test_audit_log_creation() {
481 let dir = tempdir().unwrap();
482 let log_path = dir.path().join("audit.log");
483
484 let log = AuditLog::new(&log_path).await.unwrap();
485
486 let entry =
487 AuditEntry::new(".gitignore", AccessType::Write, AuditOutcome::Blocked, "write_file", "test-session", "");
488
489 log.log(entry).await.unwrap();
490
491 let entries = log.get_entries().await.unwrap();
492 assert_eq!(entries.len(), 1);
493 assert_eq!(entries[0].file_path, ".gitignore");
494 }
495
496 #[tokio::test]
497 async fn test_audit_log_integrity() {
498 let dir = tempdir().unwrap();
499 let log_path = dir.path().join("audit.log");
500
501 let log = AuditLog::new(&log_path).await.unwrap();
502
503 for i in 0..5 {
505 let entry = AuditEntry::new(
506 format!(".env.{i}"),
507 AccessType::Modify,
508 AuditOutcome::Blocked,
509 "test_tool",
510 "test-session",
511 "",
512 );
513 log.log(entry).await.unwrap();
514 }
515
516 assert!(log.verify_integrity().await.unwrap());
518
519 let entries = log.get_entries().await.unwrap();
521 assert_eq!(entries.len(), 5);
522
523 for entry in &entries {
524 assert!(entry.verify());
525 }
526 }
527
528 #[test]
529 fn test_entry_hash() {
530 let entry =
531 AuditEntry::new(".bashrc", AccessType::Write, AuditOutcome::UserRejected, "shell", "sess-123", "prev-hash")
532 .finalize();
533
534 assert!(entry.verify());
535 }
536
537 #[cfg(unix)]
542 #[tokio::test]
543 async fn test_failed_write_preserves_chain() {
544 use std::os::unix::fs::PermissionsExt;
545
546 let dir = tempdir().unwrap();
547 let log_path = dir.path().join("audit.log");
548
549 let log = AuditLog::new(&log_path).await.unwrap();
550
551 let entry1 =
553 AuditEntry::new(".gitignore", AccessType::Write, AuditOutcome::Blocked, "write_file", "test-session", "");
554 log.log(entry1).await.unwrap();
555
556 let entries = log.get_entries().await.unwrap();
557 assert_eq!(entries.len(), 1);
558 let good_hash = entries[0].entry_hash.clone().unwrap();
559
560 std::fs::set_permissions(&log_path, std::fs::Permissions::from_mode(0o444)).unwrap();
562
563 let entry2 =
565 AuditEntry::new(".env", AccessType::Modify, AuditOutcome::Blocked, "write_file", "test-session", "");
566 let result = log.log(entry2).await;
567 assert!(result.is_err(), "write to a read-only log should fail");
568
569 std::fs::set_permissions(&log_path, std::fs::Permissions::from_mode(0o644)).unwrap();
571
572 let entry3 =
575 AuditEntry::new(".bashrc", AccessType::Read, AuditOutcome::AllowedWithConfirmation, "read_file", "s2", "");
576 log.log(entry3).await.unwrap();
577
578 let entries = log.get_entries().await.unwrap();
579 assert_eq!(entries.len(), 2);
580 assert_eq!(entries[1].previous_hash, good_hash, "chain must link to the last successful entry");
581 assert!(log.verify_integrity().await.unwrap(), "integrity must be intact");
582 }
583
584 #[tokio::test]
587 async fn test_concurrent_writes_form_valid_chain() {
588 let dir = tempdir().unwrap();
589 let log_path = dir.path().join("audit.log");
590 let log = Arc::new(AuditLog::new(&log_path).await.unwrap());
591
592 let mut handles = Vec::new();
593 for i in 0..8 {
594 let log = log.clone();
595 handles.push(tokio::spawn(async move {
596 let entry = AuditEntry::new(
597 format!(".env.{i}"),
598 AccessType::Modify,
599 AuditOutcome::Blocked,
600 "test_tool",
601 "test-session",
602 "",
603 );
604 log.log(entry).await
605 }));
606 }
607 for handle in handles {
608 handle.await.unwrap().unwrap();
609 }
610
611 let entries = log.get_entries().await.unwrap();
612 assert_eq!(entries.len(), 8);
613 assert!(log.verify_integrity().await.unwrap(), "all 8 concurrent entries must form a valid chain");
614 }
615
616 #[test]
619 fn test_append_entry_blocking_writes_valid_line() {
620 let dir = tempdir().unwrap();
621 let log_path = dir.path().join("audit.log");
622
623 let entry = AuditEntry::new(".env", AccessType::Write, AuditOutcome::Blocked, "test", "s1", "").finalize();
624 let json = serde_json::to_string(&entry).unwrap();
625
626 AuditLog::append_entry_blocking(&log_path, &json).unwrap();
627
628 let content = std::fs::read_to_string(&log_path).unwrap();
629 assert!(content.ends_with('\n'), "line must be newline-terminated");
630 let line = content.trim_end();
631 let parsed: AuditEntry = serde_json::from_str(line).expect("written line must be valid JSON");
632 assert_eq!(parsed.file_path, ".env");
633 }
634
635 #[test]
638 fn test_append_entry_blocking_appends_correctly() {
639 let dir = tempdir().unwrap();
640 let log_path = dir.path().join("audit.log");
641
642 for i in 0..3 {
643 let entry =
644 AuditEntry::new(format!(".env.{i}"), AccessType::Modify, AuditOutcome::Blocked, "test", "s1", "")
645 .finalize();
646 let json = serde_json::to_string(&entry).unwrap();
647 AuditLog::append_entry_blocking(&log_path, &json).unwrap();
648 }
649
650 let content = std::fs::read_to_string(&log_path).unwrap();
651 let lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect();
652 assert_eq!(lines.len(), 3, "must have exactly 3 lines");
653 for (i, line) in lines.iter().enumerate() {
654 let entry: AuditEntry = serde_json::from_str(line).unwrap();
655 assert_eq!(entry.file_path, format!(".env.{i}"));
656 }
657 }
658
659 #[test]
663 fn test_append_entry_blocking_missing_dir_is_clean_failure() {
664 let dir = tempdir().unwrap();
665 let nonexistent = dir.path().join("nonexistent_dir").join("audit.log");
666
667 let entry = AuditEntry::new(".env", AccessType::Write, AuditOutcome::Blocked, "test", "s1", "").finalize();
668 let json = serde_json::to_string(&entry).unwrap();
669
670 let result = AuditLog::append_entry_blocking(&nonexistent, &json);
671 assert!(result.is_err(), "writing to a missing directory must fail");
672 assert!(!nonexistent.exists(), "no file should be created on failure");
673 }
674}