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::utils::calculate_sha256;
17
18use crate::utils::file_utils::ensure_dir_exists;
19
20/// Outcome of a dotfile access attempt.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum AuditOutcome {
24    /// Access was allowed after user confirmation.
25    AllowedWithConfirmation,
26    /// Access was allowed via whitelist (with secondary auth).
27    AllowedViaWhitelist,
28    /// Access was blocked (no confirmation given).
29    Blocked,
30    /// Access was denied (policy violation).
31    Denied,
32    /// User explicitly rejected the modification.
33    UserRejected,
34    /// Access was allowed without confirmation (protection disabled).
35    AllowedUnprotected,
36}
37
38impl std::fmt::Display for AuditOutcome {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        match self {
41            AuditOutcome::AllowedWithConfirmation => write!(f, "ALLOWED_WITH_CONFIRMATION"),
42            AuditOutcome::AllowedViaWhitelist => write!(f, "ALLOWED_VIA_WHITELIST"),
43            AuditOutcome::Blocked => write!(f, "BLOCKED"),
44            AuditOutcome::Denied => write!(f, "DENIED"),
45            AuditOutcome::UserRejected => write!(f, "USER_REJECTED"),
46            AuditOutcome::AllowedUnprotected => write!(f, "ALLOWED_UNPROTECTED"),
47        }
48    }
49}
50
51/// Type of access being attempted.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum AccessType {
55    Read,
56    Write,
57    Create,
58    Delete,
59    Modify,
60    Append,
61}
62
63impl std::fmt::Display for AccessType {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        match self {
66            AccessType::Read => write!(f, "READ"),
67            AccessType::Write => write!(f, "WRITE"),
68            AccessType::Create => write!(f, "CREATE"),
69            AccessType::Delete => write!(f, "DELETE"),
70            AccessType::Modify => write!(f, "MODIFY"),
71            AccessType::Append => write!(f, "APPEND"),
72        }
73    }
74}
75
76/// A single audit log entry.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct AuditEntry {
79    /// Unique identifier for this entry.
80    pub id: String,
81    /// Timestamp of the access attempt (UTC).
82    pub timestamp: DateTime<Utc>,
83    /// Path to the dotfile being accessed.
84    pub file_path: String,
85    /// Type of access attempted.
86    pub access_type: AccessType,
87    /// Outcome of the access attempt.
88    pub outcome: AuditOutcome,
89    /// Tool or operation that initiated the access.
90    pub initiator: String,
91    /// Session identifier.
92    pub session_id: String,
93    /// Description of proposed changes (if applicable).
94    pub proposed_changes: Option<String>,
95    /// Hash of the previous entry (for tamper detection).
96    pub previous_hash: String,
97    /// Hash of this entry (computed after creation).
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub entry_hash: Option<String>,
100    /// Additional context or reason.
101    pub context: Option<String>,
102    /// Whether this was during an automated operation.
103    pub during_automation: bool,
104}
105
106impl AuditEntry {
107    /// Create a new audit entry.
108    pub fn new(
109        file_path: impl Into<String>,
110        access_type: AccessType,
111        outcome: AuditOutcome,
112        initiator: impl Into<String>,
113        session_id: impl Into<String>,
114        previous_hash: impl Into<String>,
115    ) -> Self {
116        Self {
117            id: uuid::Uuid::new_v4().to_string(),
118            timestamp: Utc::now(),
119            file_path: file_path.into(),
120            access_type,
121            outcome,
122            initiator: initiator.into(),
123            session_id: session_id.into(),
124            proposed_changes: None,
125            previous_hash: previous_hash.into(),
126            entry_hash: None,
127            context: None,
128            during_automation: false,
129        }
130    }
131
132    /// Set proposed changes description.
133    pub fn with_proposed_changes(mut self, changes: impl Into<String>) -> Self {
134        self.proposed_changes = Some(changes.into());
135        self
136    }
137
138    /// Set context/reason.
139    pub fn with_context(mut self, context: impl Into<String>) -> Self {
140        self.context = Some(context.into());
141        self
142    }
143
144    /// Mark as during automation.
145    pub fn during_automation(mut self) -> Self {
146        self.during_automation = true;
147        self
148    }
149
150    /// Compute and set the entry hash.
151    pub fn finalize(mut self) -> Self {
152        self.entry_hash = Some(self.compute_hash());
153        self
154    }
155
156    /// Compute SHA-256 hash of the entry (excluding entry_hash field).
157    fn compute_hash(&self) -> String {
158        let mut hasher = Sha256::new();
159        hasher.update(self.id.as_bytes());
160        hasher.update(self.timestamp.to_rfc3339().as_bytes());
161        hasher.update(self.file_path.as_bytes());
162        hasher.update(format!("{:?}", self.access_type).as_bytes());
163        hasher.update(format!("{:?}", self.outcome).as_bytes());
164        hasher.update(self.initiator.as_bytes());
165        hasher.update(self.session_id.as_bytes());
166        hasher.update(self.previous_hash.as_bytes());
167        if let Some(ref changes) = self.proposed_changes {
168            hasher.update(changes.as_bytes());
169        }
170        if let Some(ref ctx) = self.context {
171            hasher.update(ctx.as_bytes());
172        }
173        hasher.update([self.during_automation as u8]);
174        calculate_sha256(&hasher.finalize())
175    }
176
177    /// Verify the entry hash is valid.
178    pub fn verify(&self) -> bool {
179        self.entry_hash
180            .as_ref()
181            .is_some_and(|hash| *hash == self.compute_hash())
182    }
183}
184
185/// Immutable audit log for dotfile access.
186pub struct AuditLog {
187    /// Path to the log file.
188    log_path: PathBuf,
189    /// Lock for thread-safe writes.
190    write_lock: Arc<Mutex<()>>,
191    /// Hash of the last entry (for chaining).
192    last_hash: Arc<Mutex<String>>,
193}
194
195impl AuditLog {
196    /// Create or open an audit log at the specified path.
197    pub async fn new(log_path: impl AsRef<Path>) -> Result<Self> {
198        let log_path = log_path.as_ref().to_path_buf();
199
200        // Create parent directories if needed
201        if let Some(parent) = log_path.parent() {
202            ensure_dir_exists(parent)
203                .await
204                .with_context(|| format!("Failed to create audit log directory: {:?}", parent))?;
205        }
206
207        // Read the last hash from the log if it exists
208        let last_hash = if log_path.exists() {
209            Self::read_last_hash(&log_path)?
210        } else {
211            // Genesis hash
212            "0000000000000000000000000000000000000000000000000000000000000000".to_string()
213        };
214
215        Ok(Self {
216            log_path,
217            write_lock: Arc::new(Mutex::new(())),
218            last_hash: Arc::new(Mutex::new(last_hash)),
219        })
220    }
221
222    /// Read the last entry's hash from the log file.
223    fn read_last_hash(log_path: &Path) -> Result<String> {
224        let file = File::open(log_path).with_context(|| "Failed to open audit log")?;
225        let mut reader = BufReader::new(file);
226
227        let mut last_hash =
228            "0000000000000000000000000000000000000000000000000000000000000000".to_string();
229        let mut line = String::new();
230
231        loop {
232            line.clear();
233            if reader
234                .read_line(&mut line)
235                .with_context(|| "Failed to read audit log line")?
236                == 0
237            {
238                break;
239            }
240            let raw = line.trim_end_matches(['\n', '\r']);
241            if raw.trim().is_empty() {
242                continue;
243            }
244            if let Ok(entry) = serde_json::from_str::<AuditEntry>(raw)
245                && let Some(hash) = entry.entry_hash
246            {
247                last_hash = hash;
248            }
249        }
250
251        Ok(last_hash)
252    }
253
254    /// Log an access attempt.
255    pub async fn log(&self, mut entry: AuditEntry) -> Result<()> {
256        let _guard = self.write_lock.lock().await;
257
258        // Set the previous hash
259        let mut last_hash = self.last_hash.lock().await;
260        entry.previous_hash = last_hash.clone();
261
262        // Finalize the entry with its hash
263        let entry = entry.finalize();
264
265        // Update the last hash
266        if let Some(ref hash) = entry.entry_hash {
267            *last_hash = hash.clone();
268        }
269
270        // Serialize and append to log
271        let json =
272            serde_json::to_string(&entry).with_context(|| "Failed to serialize audit entry")?;
273
274        let mut file = OpenOptions::new()
275            .create(true)
276            .append(true)
277            .open(&self.log_path)
278            .with_context(|| format!("Failed to open audit log: {:?}", self.log_path))?;
279
280        writeln!(file, "{}", json).with_context(|| "Failed to write audit entry")?;
281
282        // Ensure data is flushed to disk
283        file.sync_all()
284            .with_context(|| "Failed to sync audit log")?;
285
286        Ok(())
287    }
288
289    /// Get all entries from the log.
290    pub async fn get_entries(&self) -> Result<Vec<AuditEntry>> {
291        let _guard = self.write_lock.lock().await;
292
293        if !self.log_path.exists() {
294            return Ok(Vec::new());
295        }
296
297        let file = File::open(&self.log_path).with_context(|| "Failed to open audit log")?;
298        let mut reader = BufReader::new(file);
299        let mut entries = Vec::new();
300        let mut line = String::new();
301
302        loop {
303            line.clear();
304            if reader
305                .read_line(&mut line)
306                .with_context(|| "Failed to read audit log line")?
307                == 0
308            {
309                break;
310            }
311            let raw = line.trim_end_matches(['\n', '\r']);
312            if raw.trim().is_empty() {
313                continue;
314            }
315            let entry: AuditEntry =
316                serde_json::from_str(raw).with_context(|| "Failed to parse audit entry")?;
317            entries.push(entry);
318        }
319
320        Ok(entries)
321    }
322
323    /// Verify the integrity of the entire audit log.
324    pub async fn verify_integrity(&self) -> Result<bool> {
325        let entries = self.get_entries().await?;
326
327        if entries.is_empty() {
328            return Ok(true);
329        }
330
331        let mut expected_prev_hash =
332            "0000000000000000000000000000000000000000000000000000000000000000".to_string();
333
334        for entry in entries {
335            // Verify entry hash
336            if !entry.verify() {
337                tracing::warn!(
338                    "Audit log integrity violation: entry {} has invalid hash",
339                    entry.id
340                );
341                return Ok(false);
342            }
343
344            // Verify chain
345            if entry.previous_hash != expected_prev_hash {
346                tracing::warn!(
347                    "Audit log integrity violation: entry {} has broken chain",
348                    entry.id
349                );
350                return Ok(false);
351            }
352
353            expected_prev_hash = entry.entry_hash.unwrap_or_default();
354        }
355
356        Ok(true)
357    }
358
359    /// Get entries for a specific file.
360    pub async fn get_entries_for_file(&self, file_path: &str) -> Result<Vec<AuditEntry>> {
361        let entries = self.get_entries().await?;
362        Ok(entries
363            .into_iter()
364            .filter(|e| e.file_path == file_path)
365            .collect())
366    }
367
368    /// Get recent entries (last N).
369    pub async fn get_recent_entries(&self, count: usize) -> Result<Vec<AuditEntry>> {
370        let entries = self.get_entries().await?;
371        let len = entries.len();
372        if len <= count {
373            Ok(entries)
374        } else {
375            Ok(entries.into_iter().skip(len - count).collect())
376        }
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use tempfile::tempdir;
384
385    #[tokio::test]
386    async fn test_audit_log_creation() {
387        let dir = tempdir().unwrap();
388        let log_path = dir.path().join("audit.log");
389
390        let log = AuditLog::new(&log_path).await.unwrap();
391
392        let entry = AuditEntry::new(
393            ".gitignore",
394            AccessType::Write,
395            AuditOutcome::Blocked,
396            "write_file",
397            "test-session",
398            "",
399        );
400
401        log.log(entry).await.unwrap();
402
403        let entries = log.get_entries().await.unwrap();
404        assert_eq!(entries.len(), 1);
405        assert_eq!(entries[0].file_path, ".gitignore");
406    }
407
408    #[tokio::test]
409    async fn test_audit_log_integrity() {
410        let dir = tempdir().unwrap();
411        let log_path = dir.path().join("audit.log");
412
413        let log = AuditLog::new(&log_path).await.unwrap();
414
415        // Add multiple entries
416        for i in 0..5 {
417            let entry = AuditEntry::new(
418                format!(".env.{}", i),
419                AccessType::Modify,
420                AuditOutcome::Blocked,
421                "test_tool",
422                "test-session",
423                "",
424            );
425            log.log(entry).await.unwrap();
426        }
427
428        // Verify integrity
429        assert!(log.verify_integrity().await.unwrap());
430
431        // Entries should be chainable
432        let entries = log.get_entries().await.unwrap();
433        assert_eq!(entries.len(), 5);
434
435        for entry in &entries {
436            assert!(entry.verify());
437        }
438    }
439
440    #[test]
441    fn test_entry_hash() {
442        let entry = AuditEntry::new(
443            ".bashrc",
444            AccessType::Write,
445            AuditOutcome::UserRejected,
446            "shell",
447            "sess-123",
448            "prev-hash",
449        )
450        .finalize();
451
452        assert!(entry.verify());
453    }
454}