Skip to main content

vtcode_core/dotfile_protection/
audit.rs

1//! Immutable audit logging for dotfile access attempts.
2//!
3//! Provides comprehensive, tamper-evident logging of all dotfile
4//! access attempts with timestamps, outcomes, and contextual information.
5
6use 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/// Outcome of a dotfile access attempt.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum AuditOutcome {
23    /// Access was allowed after user confirmation.
24    AllowedWithConfirmation,
25    /// Access was allowed via whitelist (with secondary auth).
26    AllowedViaWhitelist,
27    /// Access was blocked (no confirmation given).
28    Blocked,
29    /// Access was denied (policy violation).
30    Denied,
31    /// User explicitly rejected the modification.
32    UserRejected,
33    /// Access was allowed without confirmation (protection disabled).
34    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/// Type of access being attempted.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum AccessType {
54    /// Read access to a dotfile.
55    Read,
56    /// Write access to a dotfile.
57    Write,
58    /// Create a new dotfile.
59    Create,
60    /// Delete a dotfile.
61    Delete,
62    /// Modify an existing dotfile.
63    Modify,
64    /// Append to a dotfile.
65    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/// A single audit log entry.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct AuditEntry {
84    /// Unique identifier for this entry.
85    pub id: String,
86    /// Timestamp of the access attempt (UTC).
87    pub timestamp: DateTime<Utc>,
88    /// Path to the dotfile being accessed.
89    pub file_path: String,
90    /// Type of access attempted.
91    pub access_type: AccessType,
92    /// Outcome of the access attempt.
93    pub outcome: AuditOutcome,
94    /// Tool or operation that initiated the access.
95    pub initiator: String,
96    /// Session identifier.
97    pub session_id: String,
98    /// Description of proposed changes (if applicable).
99    pub proposed_changes: Option<String>,
100    /// Hash of the previous entry (for tamper detection).
101    pub previous_hash: String,
102    /// Hash of this entry (computed after creation).
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub entry_hash: Option<String>,
105    /// Additional context or reason.
106    pub context: Option<String>,
107    /// Whether this was during an automated operation.
108    pub during_automation: bool,
109}
110
111impl AuditEntry {
112    /// Create a new audit entry.
113    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    /// Set proposed changes description.
138    pub fn with_proposed_changes(mut self, changes: impl Into<String>) -> Self {
139        self.proposed_changes = Some(changes.into());
140        self
141    }
142
143    /// Set context/reason.
144    pub fn with_context(mut self, context: impl Into<String>) -> Self {
145        self.context = Some(context.into());
146        self
147    }
148
149    /// Mark as during automation.
150    pub fn during_automation(mut self) -> Self {
151        self.during_automation = true;
152        self
153    }
154
155    /// Compute and set the entry hash.
156    pub fn finalize(mut self) -> Self {
157        self.entry_hash = Some(self.compute_hash());
158        self
159    }
160
161    /// Compute SHA-256 hash of the entry (excluding entry_hash field).
162    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    /// Verify the entry hash is valid.
183    pub fn verify(&self) -> bool {
184        self.entry_hash.as_ref().is_some_and(|hash| *hash == self.compute_hash())
185    }
186}
187
188/// Immutable audit log for dotfile access.
189///
190/// **Single-instance invariant**: exactly one `AuditLog` should exist per log
191/// file path. The `write_lock` and `last_hash` are in-process `Arc<Mutex>`
192/// fields — two separate instances for the same path would have independent
193/// locks and chain-hash caches, allowing interleaved appends that corrupt the
194/// tamper-evident chain. The sole production caller (`DotfileGuardian::new`)
195/// respects this by constructing one instance and sharing it via `Arc`.
196pub struct AuditLog {
197    /// Path to the log file.
198    log_path: PathBuf,
199    /// Lock for serializing writes and reads. Uses `tokio::sync::Mutex` so an
200    /// `OwnedMutexGuard` can be moved into `spawn_blocking` closures, keeping
201    /// operations serialized even if the calling task is cancelled.
202    write_lock: Arc<Mutex<()>>,
203    /// Hash of the last entry (for chaining). Uses `tokio::sync::Mutex` so an
204    /// `OwnedMutexGuard<String>` can be moved into `spawn_blocking` alongside
205    /// the write-lock guard — both survive task cancellation inside the
206    /// blocking closure, and no `std::sync::Mutex` poisoning can occur.
207    ///
208    /// Lock ordering: always acquire `write_lock` before `last_hash`. No
209    /// method acquires `last_hash` without first holding `write_lock`.
210    last_hash: Arc<Mutex<String>>,
211}
212
213impl AuditLog {
214    /// Create or open an audit log at the specified path.
215    pub async fn new(log_path: impl AsRef<Path>) -> Result<Self> {
216        let log_path = log_path.as_ref().to_path_buf();
217
218        // Create parent directories if needed
219        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        // `read_last_hash` does a blocking file seek+read; run it off the async
225        // executor. See `# Blocking` docs in `src/agent/runloop/git.rs`.
226        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            // Genesis hash
233            "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    /// Read the last entry's hash from the log file.
244    ///
245    /// Only the tail of the file is scanned (bounded window) so startup cost is
246    /// `O(window)` rather than `O(file size)` — the audit log grows unbounded
247    /// over the tool's lifetime, and the prior full-file scan made launch time
248    /// scale with historical audit volume.
249    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        // Window large enough to contain the final (and longest plausible)
261        // entry; capped so the scan cost is bounded regardless of log size.
262        let window: u64 = (1 << 18).min(len); // 256 KiB cap
263        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    /// Log an access attempt.
286    ///
287    /// Both `write_lock` and `last_hash` are acquired as `OwnedMutexGuard`s
288    /// and moved into the `spawn_blocking` closure so that:
289    ///
290    /// 1. Appends stay serialized even if the calling task is cancelled —
291    ///    both guards survive in the blocking closure until the IO finishes.
292    /// 2. `last_hash` is updated only after the full append succeeds (the
293    ///    line is in the OS page cache, visible to subsequent reads) but
294    ///    before `sync_all()`. If `sync_all()` fails, the entry is still
295    ///    readable and the chain is consistent. If `open` or `write_all`
296    ///    fails, `last_hash` is not updated **and** any partial bytes are
297    ///    truncated back to the pre-append length, so the file never has a
298    ///    malformed tail that would break future reads.
299    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        // Build the finalized entry under serialization.
304        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        // File open + write + fsync are blocking; run them off the async
310        // executor. Both owned guards are moved into the closure so operations
311        // stay serialized even if the caller is cancelled. See `# Blocking`
312        // docs in `src/agent/runloop/git.rs`.
313        let log_path = self.log_path.clone();
314        tokio::task::spawn_blocking(move || -> Result<()> {
315            let _write_guard = write_guard; // held until closure returns
316
317            Self::append_entry_blocking(&log_path, &json)?;
318
319            // The append succeeded — the full line is in the OS page cache and
320            // visible to subsequent reads. Update the in-memory chain hash now,
321            // before fsync. An fsync failure does NOT undo the page-cache
322            // write, so the chain stays consistent with the readable file.
323            if let Some(hash) = new_hash {
324                *hash_guard = hash;
325            }
326
327            // Best-effort durability. A failure here does not roll back the
328            // append; the entry is readable and the chain is consistent.
329            // Reopen for sync_all to avoid keeping the append handle open
330            // longer than necessary.
331            let sync_result = File::open(&log_path).and_then(|f| f.sync_all());
332
333            if let Err(e) = sync_result {
334                // Chain hash already matches the readable entry. Report the
335                // durability error but keep in-memory state consistent.
336                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    /// Append one finalized JSON line to the audit log file.
348    ///
349    /// Records the pre-append file length and truncates back to it on any
350    /// `write_all` failure, ensuring the file never has a partial/malformed
351    /// tail that would break `get_entries()`. The append uses a single
352    /// `write_all` of the pre-built line (including `\n`) to minimize the
353    /// chance of a partial write.
354    ///
355    /// # Blocking
356    /// Performs synchronous file open/write. Must not be called on a Tokio
357    /// worker thread.
358    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        // Record the pre-append length so we can roll back on write failure.
366        let pre_len = file.metadata().with_context(|| "Failed to read audit log metadata")?.len();
367
368        // Build the full line in memory so the append is a single write_all,
369        // minimizing the chance of a partial line on failure.
370        let mut line = json.to_string();
371        line.push('\n');
372
373        if let Err(e) = file.write_all(line.as_bytes()) {
374            // Truncate any partial bytes back to the pre-append length so
375            // the file never has a malformed tail.
376            let _ = file.set_len(pre_len);
377            return Err(e).with_context(|| "Failed to write audit entry");
378        }
379
380        Ok(())
381    }
382
383    /// Get all entries from the log.
384    ///
385    /// Uses `lock_owned()` so the `write_lock` guard is moved into the
386    /// `spawn_blocking` closure — reads stay consistent with in-flight writes
387    /// even if the calling task is cancelled.
388    pub async fn get_entries(&self) -> Result<Vec<AuditEntry>> {
389        let guard = self.write_lock.clone().lock_owned().await;
390
391        // File open + read_line loop are blocking; run them off the async
392        // executor. The owned `write_lock` guard is moved into the closure so
393        // reads stay serialized even if the caller is cancelled. See `# Blocking`
394        // docs in `src/agent/runloop/git.rs`.
395        let log_path = self.log_path.clone();
396        tokio::task::spawn_blocking(move || -> Result<Vec<AuditEntry>> {
397            let _guard = guard; // held until closure returns
398
399            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    /// Verify the integrity of the entire audit log.
428    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            // Verify entry hash
439            if !entry.verify() {
440                tracing::warn!("Audit log integrity violation: entry {} has invalid hash", entry.id);
441                return Ok(false);
442            }
443
444            // Verify chain
445            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    /// Get entries for a specific file.
457    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    /// Get recent entries (last N).
463    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        // Add multiple entries
504        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        // Verify integrity
517        assert!(log.verify_integrity().await.unwrap());
518
519        // Entries should be chainable
520        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    /// After a failed write (file is read-only), `last_hash` must remain at
538    /// the old value so the next successful entry chains correctly. This
539    /// verifies the fix for the chain-corruption bug where `last_hash` was
540    /// updated before the durable write.
541    #[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        // Write one entry successfully.
552        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        // Make the log file read-only so append-mode open fails.
561        std::fs::set_permissions(&log_path, std::fs::Permissions::from_mode(0o444)).unwrap();
562
563        // Attempt a write — it must fail.
564        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        // Restore write permission.
570        std::fs::set_permissions(&log_path, std::fs::Permissions::from_mode(0o644)).unwrap();
571
572        // The next successful entry must chain against the first entry's hash,
573        // not the failed entry's hash.
574        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    /// Concurrent log calls must be serialized — entries must form a valid
585    /// chain with no duplicate or broken `previous_hash` links.
586    #[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    /// `append_entry_blocking` must write a valid single JSON line followed
617    /// by `\n`. This verifies the pre-built-line + `write_all` approach.
618    #[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    /// `append_entry_blocking` must append to an existing file without
636    /// corrupting prior entries.
637    #[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    /// `append_entry_blocking` must not leave a malformed tail when writing
660    /// to a directory that doesn't exist (open fails). The file simply won't
661    /// be created — no partial state.
662    #[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}