ragfs_fuse/
safety.rs

1//! Safety layer for agent file operations.
2//!
3//! This module provides protection against destructive operations through:
4//! - Soft delete with trash (files can be recovered)
5//! - Audit history logging
6//! - Undo support for reversible operations
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use std::fs::{self, File, OpenOptions};
11use std::io::{BufRead, BufReader, Write};
12use std::path::PathBuf;
13use std::sync::Arc;
14use tokio::sync::RwLock;
15use tracing::{debug, info, warn};
16use uuid::Uuid;
17
18/// Entry in the trash directory.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct TrashEntry {
21    /// Unique identifier for this trash entry
22    pub id: Uuid,
23    /// Original path of the file
24    pub original_path: PathBuf,
25    /// Path in trash storage
26    pub trash_path: PathBuf,
27    /// When the file was deleted
28    pub deleted_at: DateTime<Utc>,
29    /// When the trash entry expires (auto-purge)
30    pub expires_at: DateTime<Utc>,
31    /// Blake3 hash of the content
32    pub content_hash: String,
33    /// Original file size in bytes
34    pub size: u64,
35}
36
37/// Type of operation for history logging.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum HistoryOperation {
41    Create {
42        path: PathBuf,
43    },
44    Delete {
45        path: PathBuf,
46        trash_id: Option<Uuid>,
47    },
48    Move {
49        src: PathBuf,
50        dst: PathBuf,
51    },
52    Copy {
53        src: PathBuf,
54        dst: PathBuf,
55    },
56    Write {
57        path: PathBuf,
58        append: bool,
59    },
60    Restore {
61        trash_id: Uuid,
62        path: PathBuf,
63    },
64}
65
66/// Entry in the history log.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct HistoryEntry {
69    /// Unique identifier for this operation
70    pub id: Uuid,
71    /// Type of operation
72    pub operation: HistoryOperation,
73    /// When the operation occurred
74    pub timestamp: DateTime<Utc>,
75    /// Whether the operation succeeded
76    pub success: bool,
77    /// Whether this operation can be undone
78    pub reversible: bool,
79    /// Data needed to undo this operation
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub undo_data: Option<UndoData>,
82    /// Error message if the operation failed
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub error: Option<String>,
85}
86
87/// Data needed to undo an operation.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum UndoData {
91    /// Undo a create by deleting the file
92    Create { path: PathBuf },
93    /// Undo a delete by restoring from trash
94    Delete { trash_id: Uuid },
95    /// Undo a move by moving back
96    Move { src: PathBuf, dst: PathBuf },
97    /// Undo a copy by deleting the copy
98    Copy { path: PathBuf },
99}
100
101/// Configuration for the safety manager.
102#[derive(Debug, Clone)]
103pub struct SafetyConfig {
104    /// Base directory for safety data
105    pub data_dir: PathBuf,
106    /// How long to keep trash entries (in days)
107    pub trash_retention_days: u32,
108    /// Whether to enable soft delete (move to trash instead of delete)
109    pub soft_delete: bool,
110}
111
112impl Default for SafetyConfig {
113    fn default() -> Self {
114        let data_dir = dirs::data_local_dir()
115            .unwrap_or_else(|| PathBuf::from("."))
116            .join("ragfs");
117
118        Self {
119            data_dir,
120            trash_retention_days: 7,
121            soft_delete: true,
122        }
123    }
124}
125
126/// Safety manager for protecting file operations.
127pub struct SafetyManager {
128    /// Configuration
129    config: SafetyConfig,
130    /// Index hash (used for separating trash/history by index)
131    index_hash: String,
132    /// Trash directory
133    trash_dir: PathBuf,
134    /// History file
135    history_file: PathBuf,
136    /// In-memory cache of trash entries
137    trash_cache: Arc<RwLock<Vec<TrashEntry>>>,
138}
139
140impl SafetyManager {
141    /// Create a new safety manager.
142    pub fn new(source: &PathBuf, config: Option<SafetyConfig>) -> Self {
143        let config = config.unwrap_or_default();
144
145        // Create a hash of the source path for isolation
146        let index_hash = blake3::hash(source.to_string_lossy().as_bytes())
147            .to_hex()
148            .chars()
149            .take(16)
150            .collect::<String>();
151
152        let trash_dir = config.data_dir.join("trash").join(&index_hash);
153        let history_file = config
154            .data_dir
155            .join("history")
156            .join(format!("{index_hash}.jsonl"));
157
158        // Ensure directories exist
159        if let Err(e) = fs::create_dir_all(&trash_dir) {
160            warn!("Failed to create trash directory: {e}");
161        }
162        if let Some(parent) = history_file.parent()
163            && let Err(e) = fs::create_dir_all(parent)
164        {
165            warn!("Failed to create history directory: {e}");
166        }
167
168        // Load existing trash entries synchronously
169        let entries = Self::load_trash_entries(&trash_dir).unwrap_or_default();
170
171        Self {
172            config,
173            index_hash,
174            trash_dir,
175            history_file,
176            trash_cache: Arc::new(RwLock::new(entries)),
177        }
178    }
179
180    /// Get the index hash.
181    #[must_use]
182    pub fn index_hash(&self) -> &str {
183        &self.index_hash
184    }
185
186    /// Load trash entries from disk.
187    fn load_trash_entries(trash_dir: &PathBuf) -> std::io::Result<Vec<TrashEntry>> {
188        let manifest_path = trash_dir.join("manifest.json");
189        if !manifest_path.exists() {
190            return Ok(Vec::new());
191        }
192
193        let content = fs::read_to_string(&manifest_path)?;
194        let entries: Vec<TrashEntry> = serde_json::from_str(&content).unwrap_or_default();
195        Ok(entries)
196    }
197
198    /// Save trash entries to disk.
199    async fn save_trash_entries(&self) -> std::io::Result<()> {
200        let entries = self.trash_cache.read().await;
201        let manifest_path = self.trash_dir.join("manifest.json");
202        let content = serde_json::to_string_pretty(&*entries)?;
203        fs::write(&manifest_path, content)?;
204        Ok(())
205    }
206
207    /// Move a file to trash (soft delete).
208    pub async fn soft_delete(&self, path: &PathBuf) -> Result<TrashEntry, String> {
209        if !path.exists() {
210            return Err("File not found".into());
211        }
212
213        if path.is_dir() {
214            return Err("Cannot soft delete directories".into());
215        }
216
217        // Read file content for hash
218        let content = fs::read(path).map_err(|e| format!("Failed to read file: {e}"))?;
219        let content_hash = blake3::hash(&content).to_hex().to_string();
220        let size = content.len() as u64;
221
222        // Create trash entry
223        let id = Uuid::new_v4();
224        let trash_entry_dir = self.trash_dir.join(id.to_string());
225        fs::create_dir_all(&trash_entry_dir)
226            .map_err(|e| format!("Failed to create trash entry dir: {e}"))?;
227
228        let trash_content_path = trash_entry_dir.join("content");
229        let trash_meta_path = trash_entry_dir.join("meta.json");
230
231        // Move content to trash
232        fs::rename(path, &trash_content_path)
233            .or_else(|_| {
234                // If rename fails (cross-device), copy and delete
235                fs::copy(path, &trash_content_path)?;
236                fs::remove_file(path)
237            })
238            .map_err(|e| format!("Failed to move file to trash: {e}"))?;
239
240        let now = Utc::now();
241        let expires_at = now + chrono::Duration::days(i64::from(self.config.trash_retention_days));
242
243        let entry = TrashEntry {
244            id,
245            original_path: path.clone(),
246            trash_path: trash_content_path,
247            deleted_at: now,
248            expires_at,
249            content_hash,
250            size,
251        };
252
253        // Save metadata
254        let meta_content = serde_json::to_string_pretty(&entry)
255            .map_err(|e| format!("Failed to serialize meta: {e}"))?;
256        fs::write(&trash_meta_path, meta_content)
257            .map_err(|e| format!("Failed to write meta: {e}"))?;
258
259        // Update cache
260        {
261            let mut cache = self.trash_cache.write().await;
262            cache.push(entry.clone());
263        }
264
265        // Save manifest
266        if let Err(e) = self.save_trash_entries().await {
267            warn!("Failed to save trash manifest: {e}");
268        }
269
270        info!("Soft deleted {:?} -> trash/{}", path, id);
271        Ok(entry)
272    }
273
274    /// Restore a file from trash.
275    pub async fn restore(&self, trash_id: Uuid) -> Result<PathBuf, String> {
276        let entry = {
277            let cache = self.trash_cache.read().await;
278            cache.iter().find(|e| e.id == trash_id).cloned()
279        };
280
281        let entry = entry.ok_or_else(|| "Trash entry not found".to_string())?;
282
283        if !entry.trash_path.exists() {
284            return Err("Trash content not found".into());
285        }
286
287        // Restore to original path
288        let restore_path = &entry.original_path;
289
290        // Ensure parent directory exists
291        if let Some(parent) = restore_path.parent() {
292            fs::create_dir_all(parent)
293                .map_err(|e| format!("Failed to create parent directory: {e}"))?;
294        }
295
296        // Check if destination already exists
297        if restore_path.exists() {
298            return Err("Destination already exists".into());
299        }
300
301        // Move content back
302        fs::rename(&entry.trash_path, restore_path)
303            .or_else(|_| {
304                fs::copy(&entry.trash_path, restore_path)?;
305                fs::remove_file(&entry.trash_path)
306            })
307            .map_err(|e| format!("Failed to restore file: {e}"))?;
308
309        // Remove trash entry directory
310        let trash_entry_dir = self.trash_dir.join(trash_id.to_string());
311        if let Err(e) = fs::remove_dir_all(&trash_entry_dir) {
312            warn!("Failed to remove trash entry dir: {e}");
313        }
314
315        // Update cache
316        {
317            let mut cache = self.trash_cache.write().await;
318            cache.retain(|e| e.id != trash_id);
319        }
320
321        // Save manifest
322        if let Err(e) = self.save_trash_entries().await {
323            warn!("Failed to save trash manifest: {e}");
324        }
325
326        info!("Restored {:?} from trash/{}", restore_path, trash_id);
327        Ok(restore_path.clone())
328    }
329
330    /// List all trash entries.
331    pub async fn list_trash(&self) -> Vec<TrashEntry> {
332        self.trash_cache.read().await.clone()
333    }
334
335    /// Get a specific trash entry.
336    pub async fn get_trash_entry(&self, id: Uuid) -> Option<TrashEntry> {
337        self.trash_cache
338            .read()
339            .await
340            .iter()
341            .find(|e| e.id == id)
342            .cloned()
343    }
344
345    /// Get trash content by ID.
346    pub fn get_trash_content(&self, id: Uuid) -> Result<Vec<u8>, String> {
347        let trash_content_path = self.trash_dir.join(id.to_string()).join("content");
348        if !trash_content_path.exists() {
349            return Err("Trash content not found".into());
350        }
351        fs::read(&trash_content_path).map_err(|e| format!("Failed to read trash content: {e}"))
352    }
353
354    /// Purge expired trash entries.
355    pub async fn purge_expired(&self) -> usize {
356        let now = Utc::now();
357        let expired: Vec<Uuid> = {
358            let cache = self.trash_cache.read().await;
359            cache
360                .iter()
361                .filter(|e| e.expires_at < now)
362                .map(|e| e.id)
363                .collect()
364        };
365
366        let mut purged = 0;
367        for id in expired {
368            let trash_entry_dir = self.trash_dir.join(id.to_string());
369            if let Err(e) = fs::remove_dir_all(&trash_entry_dir) {
370                warn!("Failed to purge trash entry {}: {e}", id);
371            } else {
372                purged += 1;
373            }
374        }
375
376        // Update cache
377        {
378            let mut cache = self.trash_cache.write().await;
379            cache.retain(|e| e.expires_at >= now);
380        }
381
382        // Save manifest
383        if let Err(e) = self.save_trash_entries().await {
384            warn!("Failed to save trash manifest: {e}");
385        }
386
387        if purged > 0 {
388            info!("Purged {} expired trash entries", purged);
389        }
390
391        purged
392    }
393
394    /// Log an operation to history.
395    pub fn log(&self, entry: HistoryEntry) -> std::io::Result<()> {
396        let line = serde_json::to_string(&entry)?;
397
398        let mut file = OpenOptions::new()
399            .create(true)
400            .append(true)
401            .open(&self.history_file)?;
402
403        writeln!(file, "{line}")?;
404        debug!("Logged history entry: {:?}", entry.operation);
405        Ok(())
406    }
407
408    /// Log a successful operation.
409    pub fn log_success(&self, operation: HistoryOperation, undo_data: Option<UndoData>) {
410        let entry = HistoryEntry {
411            id: Uuid::new_v4(),
412            operation,
413            timestamp: Utc::now(),
414            success: true,
415            reversible: undo_data.is_some(),
416            undo_data,
417            error: None,
418        };
419
420        if let Err(e) = self.log(entry) {
421            warn!("Failed to log history: {e}");
422        }
423    }
424
425    /// Log a failed operation.
426    pub fn log_failure(&self, operation: HistoryOperation, error: String) {
427        let entry = HistoryEntry {
428            id: Uuid::new_v4(),
429            operation,
430            timestamp: Utc::now(),
431            success: false,
432            reversible: false,
433            undo_data: None,
434            error: Some(error),
435        };
436
437        if let Err(e) = self.log(entry) {
438            warn!("Failed to log history: {e}");
439        }
440    }
441
442    /// Read history entries.
443    pub fn read_history(&self, limit: Option<usize>) -> Vec<HistoryEntry> {
444        let file = match File::open(&self.history_file) {
445            Ok(f) => f,
446            Err(_) => return Vec::new(),
447        };
448
449        let reader = BufReader::new(file);
450        let mut entries: Vec<HistoryEntry> = reader
451            .lines()
452            .map_while(Result::ok)
453            .filter_map(|line| serde_json::from_str(&line).ok())
454            .collect();
455
456        // Return most recent first
457        entries.reverse();
458
459        if let Some(limit) = limit {
460            entries.truncate(limit);
461        }
462
463        entries
464    }
465
466    /// Get history as JSON bytes (for FUSE read).
467    pub fn get_history_json(&self, limit: Option<usize>) -> Vec<u8> {
468        let entries = self.read_history(limit);
469        serde_json::to_string_pretty(&entries)
470            .unwrap_or_else(|_| "[]".to_string())
471            .into_bytes()
472    }
473
474    /// Find an operation by ID for undo.
475    pub fn find_operation(&self, id: Uuid) -> Option<HistoryEntry> {
476        self.read_history(None).into_iter().find(|e| e.id == id)
477    }
478
479    /// Undo an operation.
480    pub async fn undo(&self, operation_id: Uuid) -> Result<String, String> {
481        let entry = self
482            .find_operation(operation_id)
483            .ok_or_else(|| "Operation not found".to_string())?;
484
485        if !entry.reversible {
486            return Err("Operation is not reversible".into());
487        }
488
489        let undo_data = entry
490            .undo_data
491            .ok_or_else(|| "No undo data available".to_string())?;
492
493        match undo_data {
494            UndoData::Create { path } => {
495                // Undo create by deleting the file
496                if path.exists() {
497                    fs::remove_file(&path).map_err(|e| format!("Failed to undo create: {e}"))?;
498                    self.log_success(
499                        HistoryOperation::Delete {
500                            path: path.clone(),
501                            trash_id: None,
502                        },
503                        None,
504                    );
505                    Ok(format!("Undone: deleted {}", path.display()))
506                } else {
507                    Err("File no longer exists".into())
508                }
509            }
510            UndoData::Delete { trash_id } => {
511                // Undo delete by restoring from trash
512                let restored = self.restore(trash_id).await?;
513                Ok(format!("Undone: restored {}", restored.display()))
514            }
515            UndoData::Move { src, dst } => {
516                // Undo move by moving back
517                if dst.exists() {
518                    fs::rename(&dst, &src).map_err(|e| format!("Failed to undo move: {e}"))?;
519                    self.log_success(
520                        HistoryOperation::Move {
521                            src: dst.clone(),
522                            dst: src.clone(),
523                        },
524                        Some(UndoData::Move {
525                            src: src.clone(),
526                            dst,
527                        }),
528                    );
529                    Ok(format!("Undone: moved back to {}", src.display()))
530                } else {
531                    Err("Destination file no longer exists".into())
532                }
533            }
534            UndoData::Copy { path } => {
535                // Undo copy by deleting the copy
536                if path.exists() {
537                    fs::remove_file(&path).map_err(|e| format!("Failed to undo copy: {e}"))?;
538                    self.log_success(
539                        HistoryOperation::Delete {
540                            path: path.clone(),
541                            trash_id: None,
542                        },
543                        None,
544                    );
545                    Ok(format!("Undone: deleted copy {}", path.display()))
546                } else {
547                    Err("Copy file no longer exists".into())
548                }
549            }
550        }
551    }
552
553    /// Check if soft delete is enabled.
554    #[must_use]
555    pub fn soft_delete_enabled(&self) -> bool {
556        self.config.soft_delete
557    }
558
559    /// Get trash directory path.
560    #[must_use]
561    pub fn trash_dir(&self) -> &PathBuf {
562        &self.trash_dir
563    }
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569    use tempfile::TempDir;
570
571    fn create_test_manager() -> (SafetyManager, TempDir, TempDir) {
572        let source_dir = TempDir::new().unwrap();
573        let data_dir = TempDir::new().unwrap();
574
575        let config = SafetyConfig {
576            data_dir: data_dir.path().to_path_buf(),
577            trash_retention_days: 7,
578            soft_delete: true,
579        };
580
581        let manager = SafetyManager::new(&source_dir.path().to_path_buf(), Some(config));
582        (manager, source_dir, data_dir)
583    }
584
585    #[tokio::test]
586    async fn test_soft_delete_and_restore() {
587        let (manager, source_dir, _data_dir) = create_test_manager();
588
589        // Create a test file
590        let test_file = source_dir.path().join("test.txt");
591        fs::write(&test_file, "Hello, World!").unwrap();
592        assert!(test_file.exists());
593
594        // Soft delete
595        let entry = manager.soft_delete(&test_file).await.unwrap();
596        assert!(!test_file.exists());
597        assert!(entry.trash_path.exists());
598
599        // Verify in trash list
600        let trash = manager.list_trash().await;
601        assert_eq!(trash.len(), 1);
602        assert_eq!(trash[0].id, entry.id);
603
604        // Restore
605        let restored = manager.restore(entry.id).await.unwrap();
606        assert_eq!(restored, test_file);
607        assert!(test_file.exists());
608
609        // Verify content
610        let content = fs::read_to_string(&test_file).unwrap();
611        assert_eq!(content, "Hello, World!");
612
613        // Trash should be empty
614        let trash = manager.list_trash().await;
615        assert!(trash.is_empty());
616    }
617
618    #[tokio::test]
619    async fn test_soft_delete_nonexistent() {
620        let (manager, _source_dir, _data_dir) = create_test_manager();
621
622        let result = manager
623            .soft_delete(&PathBuf::from("/nonexistent.txt"))
624            .await;
625        assert!(result.is_err());
626        assert!(result.unwrap_err().contains("not found"));
627    }
628
629    #[tokio::test]
630    async fn test_restore_nonexistent() {
631        let (manager, _source_dir, _data_dir) = create_test_manager();
632
633        let result = manager.restore(Uuid::new_v4()).await;
634        assert!(result.is_err());
635    }
636
637    #[test]
638    fn test_history_logging() {
639        let (manager, _source_dir, _data_dir) = create_test_manager();
640
641        // Log some operations
642        manager.log_success(
643            HistoryOperation::Create {
644                path: PathBuf::from("/test.txt"),
645            },
646            Some(UndoData::Create {
647                path: PathBuf::from("/test.txt"),
648            }),
649        );
650
651        manager.log_failure(
652            HistoryOperation::Delete {
653                path: PathBuf::from("/fail.txt"),
654                trash_id: None,
655            },
656            "Permission denied".to_string(),
657        );
658
659        // Read history
660        let history = manager.read_history(None);
661        assert_eq!(history.len(), 2);
662
663        // Most recent first
664        assert!(!history[0].success);
665        assert!(history[1].success);
666    }
667
668    #[test]
669    fn test_get_history_json() {
670        let (manager, _source_dir, _data_dir) = create_test_manager();
671
672        manager.log_success(
673            HistoryOperation::Create {
674                path: PathBuf::from("/test.txt"),
675            },
676            None,
677        );
678
679        let json = manager.get_history_json(None);
680        let json_str = String::from_utf8(json).unwrap();
681        assert!(json_str.contains("create"));
682        assert!(json_str.contains("/test.txt"));
683    }
684
685    #[tokio::test]
686    async fn test_get_trash_content() {
687        let (manager, source_dir, _data_dir) = create_test_manager();
688
689        let test_file = source_dir.path().join("content_test.txt");
690        fs::write(&test_file, "Test content for trash").unwrap();
691
692        let entry = manager.soft_delete(&test_file).await.unwrap();
693
694        let content = manager.get_trash_content(entry.id).unwrap();
695        assert_eq!(
696            String::from_utf8(content).unwrap(),
697            "Test content for trash"
698        );
699    }
700
701    #[test]
702    fn test_safety_config_default() {
703        let config = SafetyConfig::default();
704        assert_eq!(config.trash_retention_days, 7);
705        assert!(config.soft_delete);
706    }
707
708    #[test]
709    fn test_trash_entry_serialization() {
710        let entry = TrashEntry {
711            id: Uuid::new_v4(),
712            original_path: PathBuf::from("/test.txt"),
713            trash_path: PathBuf::from("/trash/test.txt"),
714            deleted_at: Utc::now(),
715            expires_at: Utc::now(),
716            content_hash: "abc123".to_string(),
717            size: 1024,
718        };
719
720        let json = serde_json::to_string(&entry).unwrap();
721        let parsed: TrashEntry = serde_json::from_str(&json).unwrap();
722        assert_eq!(parsed.id, entry.id);
723        assert_eq!(parsed.original_path, entry.original_path);
724    }
725
726    #[test]
727    fn test_history_entry_serialization() {
728        let entry = HistoryEntry {
729            id: Uuid::new_v4(),
730            operation: HistoryOperation::Create {
731                path: PathBuf::from("/test.txt"),
732            },
733            timestamp: Utc::now(),
734            success: true,
735            reversible: true,
736            undo_data: Some(UndoData::Create {
737                path: PathBuf::from("/test.txt"),
738            }),
739            error: None,
740        };
741
742        let json = serde_json::to_string(&entry).unwrap();
743        let parsed: HistoryEntry = serde_json::from_str(&json).unwrap();
744        assert_eq!(parsed.id, entry.id);
745        assert!(parsed.success);
746    }
747}