ricecoder_files/
audit.rs

1//! Audit logging for comprehensive audit trails
2
3use crate::error::FileError;
4use crate::models::AuditEntry;
5use std::fs;
6use std::path::{Path, PathBuf};
7use tracing::{debug, error, info};
8
9/// Manages audit trails for all file operations
10///
11/// Logs all file operations persistently in JSON format and provides
12/// access to change history with timestamps.
13#[derive(Debug)]
14pub struct AuditLogger {
15    /// Base directory for storing audit logs
16    audit_dir: PathBuf,
17}
18
19impl AuditLogger {
20    /// Creates a new AuditLogger instance
21    ///
22    /// # Arguments
23    ///
24    /// * `audit_dir` - Directory where audit entries will be stored
25    ///
26    /// # Returns
27    ///
28    /// A new AuditLogger instance
29    pub fn new(audit_dir: PathBuf) -> Self {
30        AuditLogger { audit_dir }
31    }
32
33    /// Creates a new AuditLogger with default audit directory
34    ///
35    /// Uses `.ricecoder/audit/` as the default audit directory
36    pub fn with_default_dir() -> Self {
37        let audit_dir = PathBuf::from(".ricecoder/audit");
38        AuditLogger { audit_dir }
39    }
40
41    /// Logs a file operation to the audit trail
42    ///
43    /// Stores the audit entry persistently in JSON format.
44    /// Creates the audit directory if it doesn't exist.
45    ///
46    /// # Arguments
47    ///
48    /// * `entry` - The audit entry to log
49    ///
50    /// # Returns
51    ///
52    /// Result indicating success or failure
53    pub fn log_operation(&self, entry: AuditEntry) -> Result<(), FileError> {
54        // Ensure audit directory exists
55        fs::create_dir_all(&self.audit_dir).map_err(|e| {
56            error!("Failed to create audit directory: {}", e);
57            FileError::IoError(e)
58        })?;
59
60        // Generate filename based on timestamp and path
61        let filename = self.generate_audit_filename(&entry);
62        let filepath = self.audit_dir.join(&filename);
63
64        // Serialize entry to JSON
65        let json = serde_json::to_string_pretty(&entry).map_err(|e| {
66            error!("Failed to serialize audit entry: {}", e);
67            FileError::InvalidContent(format!("Failed to serialize audit entry: {}", e))
68        })?;
69
70        // Write to file
71        fs::write(&filepath, json).map_err(|e| {
72            error!(
73                "Failed to write audit entry to {}: {}",
74                filepath.display(),
75                e
76            );
77            FileError::IoError(e)
78        })?;
79
80        debug!(
81            "Logged audit entry for {:?} at {}",
82            entry.path,
83            filepath.display()
84        );
85        Ok(())
86    }
87
88    /// Retrieves the change history for a specific file
89    ///
90    /// Returns all audit entries for the given path, ordered by timestamp.
91    ///
92    /// # Arguments
93    ///
94    /// * `path` - The file path to get history for
95    ///
96    /// # Returns
97    ///
98    /// A vector of audit entries ordered by timestamp (oldest first)
99    pub fn get_change_history(&self, path: &Path) -> Result<Vec<AuditEntry>, FileError> {
100        // Check if audit directory exists
101        if !self.audit_dir.exists() {
102            debug!("Audit directory does not exist, returning empty history");
103            return Ok(Vec::new());
104        }
105
106        let mut entries = Vec::new();
107
108        // Read all files in audit directory
109        let entries_iter = fs::read_dir(&self.audit_dir).map_err(|e| {
110            error!("Failed to read audit directory: {}", e);
111            FileError::IoError(e)
112        })?;
113
114        for entry_result in entries_iter {
115            let entry = entry_result.map_err(|e| {
116                error!("Failed to read audit entry: {}", e);
117                FileError::IoError(e)
118            })?;
119
120            let file_path = entry.path();
121
122            // Skip directories
123            if file_path.is_dir() {
124                continue;
125            }
126
127            // Read and parse JSON file
128            let content = fs::read_to_string(&file_path).map_err(|e| {
129                error!("Failed to read audit file {}: {}", file_path.display(), e);
130                FileError::IoError(e)
131            })?;
132
133            match serde_json::from_str::<AuditEntry>(&content) {
134                Ok(audit_entry) => {
135                    // Only include entries for the requested path
136                    if audit_entry.path == path {
137                        entries.push(audit_entry);
138                    }
139                }
140                Err(e) => {
141                    error!(
142                        "Failed to parse audit entry from {}: {}",
143                        file_path.display(),
144                        e
145                    );
146                    // Continue processing other files
147                }
148            }
149        }
150
151        // Sort by timestamp (oldest first)
152        entries.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
153
154        info!("Retrieved {} audit entries for {:?}", entries.len(), path);
155        Ok(entries)
156    }
157
158    /// Retrieves all audit entries
159    ///
160    /// Returns all audit entries in the audit trail, ordered by timestamp.
161    ///
162    /// # Returns
163    ///
164    /// A vector of all audit entries ordered by timestamp (oldest first)
165    pub fn get_all_entries(&self) -> Result<Vec<AuditEntry>, FileError> {
166        // Check if audit directory exists
167        if !self.audit_dir.exists() {
168            debug!("Audit directory does not exist, returning empty entries");
169            return Ok(Vec::new());
170        }
171
172        let mut entries = Vec::new();
173
174        // Read all files in audit directory
175        let entries_iter = fs::read_dir(&self.audit_dir).map_err(|e| {
176            error!("Failed to read audit directory: {}", e);
177            FileError::IoError(e)
178        })?;
179
180        for entry_result in entries_iter {
181            let entry = entry_result.map_err(|e| {
182                error!("Failed to read audit entry: {}", e);
183                FileError::IoError(e)
184            })?;
185
186            let file_path = entry.path();
187
188            // Skip directories
189            if file_path.is_dir() {
190                continue;
191            }
192
193            // Read and parse JSON file
194            let content = fs::read_to_string(&file_path).map_err(|e| {
195                error!("Failed to read audit file {}: {}", file_path.display(), e);
196                FileError::IoError(e)
197            })?;
198
199            match serde_json::from_str::<AuditEntry>(&content) {
200                Ok(audit_entry) => {
201                    entries.push(audit_entry);
202                }
203                Err(e) => {
204                    error!(
205                        "Failed to parse audit entry from {}: {}",
206                        file_path.display(),
207                        e
208                    );
209                    // Continue processing other files
210                }
211            }
212        }
213
214        // Sort by timestamp (oldest first)
215        entries.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
216
217        info!("Retrieved {} total audit entries", entries.len());
218        Ok(entries)
219    }
220
221    /// Generates a unique filename for an audit entry
222    ///
223    /// Uses timestamp and path hash to create a unique filename
224    fn generate_audit_filename(&self, entry: &AuditEntry) -> String {
225        // Use timestamp and a hash of the path to create unique filename
226        let timestamp = entry.timestamp.format("%Y%m%d_%H%M%S_%3f");
227        let path_hash = format!("{:x}", fxhash::hash64(&entry.path.to_string_lossy()));
228        format!("audit_{}_{}.json", timestamp, path_hash)
229    }
230}
231
232impl Default for AuditLogger {
233    fn default() -> Self {
234        Self::with_default_dir()
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use crate::models::OperationType;
242    use chrono::Utc;
243    use tempfile::TempDir;
244
245    #[test]
246    fn test_audit_logger_creation() {
247        let temp_dir = TempDir::new().unwrap();
248        let logger = AuditLogger::new(temp_dir.path().to_path_buf());
249        assert_eq!(logger.audit_dir, temp_dir.path());
250    }
251
252    #[test]
253    fn test_log_operation_creates_directory() {
254        let temp_dir = TempDir::new().unwrap();
255        let audit_dir = temp_dir.path().join("audit");
256        let logger = AuditLogger::new(audit_dir.clone());
257
258        let entry = AuditEntry {
259            timestamp: Utc::now(),
260            path: PathBuf::from("test.txt"),
261            operation_type: OperationType::Create,
262            content_hash: "abc123".to_string(),
263            transaction_id: None,
264        };
265
266        logger.log_operation(entry).unwrap();
267        assert!(audit_dir.exists());
268    }
269
270    #[test]
271    fn test_log_operation_writes_json() {
272        let temp_dir = TempDir::new().unwrap();
273        let logger = AuditLogger::new(temp_dir.path().to_path_buf());
274
275        let entry = AuditEntry {
276            timestamp: Utc::now(),
277            path: PathBuf::from("test.txt"),
278            operation_type: OperationType::Create,
279            content_hash: "abc123".to_string(),
280            transaction_id: None,
281        };
282
283        logger.log_operation(entry.clone()).unwrap();
284
285        // Verify file was created
286        let files: Vec<_> = fs::read_dir(temp_dir.path())
287            .unwrap()
288            .filter_map(|e| e.ok())
289            .collect();
290        assert!(!files.is_empty());
291    }
292
293    #[test]
294    fn test_get_change_history_empty_directory() {
295        let temp_dir = TempDir::new().unwrap();
296        let logger = AuditLogger::new(temp_dir.path().to_path_buf());
297
298        let history = logger.get_change_history(Path::new("test.txt")).unwrap();
299        assert!(history.is_empty());
300    }
301
302    #[test]
303    fn test_get_change_history_filters_by_path() {
304        let temp_dir = TempDir::new().unwrap();
305        let logger = AuditLogger::new(temp_dir.path().to_path_buf());
306
307        let entry1 = AuditEntry {
308            timestamp: Utc::now(),
309            path: PathBuf::from("file1.txt"),
310            operation_type: OperationType::Create,
311            content_hash: "hash1".to_string(),
312            transaction_id: None,
313        };
314
315        let entry2 = AuditEntry {
316            timestamp: Utc::now(),
317            path: PathBuf::from("file2.txt"),
318            operation_type: OperationType::Update,
319            content_hash: "hash2".to_string(),
320            transaction_id: None,
321        };
322
323        logger.log_operation(entry1).unwrap();
324        logger.log_operation(entry2).unwrap();
325
326        let history = logger.get_change_history(Path::new("file1.txt")).unwrap();
327        assert_eq!(history.len(), 1);
328        assert_eq!(history[0].path, PathBuf::from("file1.txt"));
329    }
330
331    #[test]
332    fn test_get_change_history_ordered_by_timestamp() {
333        let temp_dir = TempDir::new().unwrap();
334        let logger = AuditLogger::new(temp_dir.path().to_path_buf());
335
336        let now = Utc::now();
337        let entry1 = AuditEntry {
338            timestamp: now,
339            path: PathBuf::from("test.txt"),
340            operation_type: OperationType::Create,
341            content_hash: "hash1".to_string(),
342            transaction_id: None,
343        };
344
345        let entry2 = AuditEntry {
346            timestamp: now + chrono::Duration::seconds(1),
347            path: PathBuf::from("test.txt"),
348            operation_type: OperationType::Update,
349            content_hash: "hash2".to_string(),
350            transaction_id: None,
351        };
352
353        logger.log_operation(entry1).unwrap();
354        logger.log_operation(entry2).unwrap();
355
356        let history = logger.get_change_history(Path::new("test.txt")).unwrap();
357        assert_eq!(history.len(), 2);
358        assert!(history[0].timestamp <= history[1].timestamp);
359    }
360
361    #[test]
362    fn test_get_all_entries() {
363        let temp_dir = TempDir::new().unwrap();
364        let logger = AuditLogger::new(temp_dir.path().to_path_buf());
365
366        let entry1 = AuditEntry {
367            timestamp: Utc::now(),
368            path: PathBuf::from("file1.txt"),
369            operation_type: OperationType::Create,
370            content_hash: "hash1".to_string(),
371            transaction_id: None,
372        };
373
374        let entry2 = AuditEntry {
375            timestamp: Utc::now(),
376            path: PathBuf::from("file2.txt"),
377            operation_type: OperationType::Update,
378            content_hash: "hash2".to_string(),
379            transaction_id: None,
380        };
381
382        logger.log_operation(entry1).unwrap();
383        logger.log_operation(entry2).unwrap();
384
385        let all_entries = logger.get_all_entries().unwrap();
386        assert_eq!(all_entries.len(), 2);
387    }
388}