Skip to main content

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::{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    /// Source root; agent paths must stay inside this tree.
129    source: PathBuf,
130    /// Configuration
131    config: SafetyConfig,
132    /// Index hash (used for separating trash/history by index)
133    index_hash: String,
134    /// Trash directory
135    trash_dir: PathBuf,
136    /// History file
137    history_file: PathBuf,
138    /// In-memory cache of trash entries
139    trash_cache: Arc<RwLock<Vec<TrashEntry>>>,
140}
141
142impl SafetyManager {
143    /// Create a new safety manager.
144    pub fn new(source: &PathBuf, config: Option<SafetyConfig>) -> Self {
145        let config = config.unwrap_or_default();
146
147        // Create a hash of the source path for isolation
148        let index_hash = blake3::hash(source.to_string_lossy().as_bytes())
149            .to_hex()
150            .chars()
151            .take(16)
152            .collect::<String>();
153
154        let trash_dir = config.data_dir.join("trash").join(&index_hash);
155        let history_file = config
156            .data_dir
157            .join("history")
158            .join(format!("{index_hash}.jsonl"));
159
160        // Ensure directories exist
161        if let Err(e) = fs::create_dir_all(&trash_dir) {
162            warn!("Failed to create trash directory: {e}");
163        }
164        if let Some(parent) = history_file.parent()
165            && let Err(e) = fs::create_dir_all(parent)
166        {
167            warn!("Failed to create history directory: {e}");
168        }
169
170        // Load existing trash entries synchronously
171        let entries = Self::load_trash_entries(&trash_dir).unwrap_or_default();
172
173        Self {
174            source: source.clone(),
175            config,
176            index_hash,
177            trash_dir,
178            history_file,
179            trash_cache: Arc::new(RwLock::new(entries)),
180        }
181    }
182
183    /// Resolve `path` so it stays inside the source root.
184    fn jail(&self, path: &Path) -> Result<PathBuf, String> {
185        ragfs_core::resolve_under_root(&self.source, path)
186    }
187
188    /// Get the index hash.
189    #[must_use]
190    pub fn index_hash(&self) -> &str {
191        &self.index_hash
192    }
193
194    /// Load trash entries from disk.
195    fn load_trash_entries(trash_dir: &PathBuf) -> std::io::Result<Vec<TrashEntry>> {
196        let manifest_path = trash_dir.join("manifest.json");
197        if !manifest_path.exists() {
198            return Ok(Vec::new());
199        }
200
201        let content = fs::read_to_string(&manifest_path)?;
202        let entries: Vec<TrashEntry> = serde_json::from_str(&content).unwrap_or_default();
203        Ok(entries)
204    }
205
206    /// Save trash entries to disk.
207    async fn save_trash_entries(&self) -> std::io::Result<()> {
208        let entries = self.trash_cache.read().await;
209        let manifest_path = self.trash_dir.join("manifest.json");
210        let content = serde_json::to_string_pretty(&*entries)?;
211        fs::write(&manifest_path, content)?;
212        Ok(())
213    }
214
215    /// Move a file to trash (soft delete).
216    pub async fn soft_delete(&self, path: &PathBuf) -> Result<TrashEntry, String> {
217        let path = self.jail(path)?;
218        if !path.exists() {
219            return Err("File not found".into());
220        }
221
222        if path.is_dir() {
223            return Err("Cannot soft delete directories".into());
224        }
225
226        // Read file content for hash
227        let content = fs::read(&path).map_err(|e| format!("Failed to read file: {e}"))?;
228        let content_hash = blake3::hash(&content).to_hex().to_string();
229        let size = content.len() as u64;
230
231        // Create trash entry
232        let id = Uuid::new_v4();
233        let trash_entry_dir = self.trash_dir.join(id.to_string());
234        fs::create_dir_all(&trash_entry_dir)
235            .map_err(|e| format!("Failed to create trash entry dir: {e}"))?;
236
237        let trash_content_path = trash_entry_dir.join("content");
238        let trash_meta_path = trash_entry_dir.join("meta.json");
239
240        // Move content to trash
241        fs::rename(&path, &trash_content_path)
242            .or_else(|_| {
243                // If rename fails (cross-device), copy and delete
244                fs::copy(&path, &trash_content_path)?;
245                fs::remove_file(&path)
246            })
247            .map_err(|e| format!("Failed to move file to trash: {e}"))?;
248
249        let now = Utc::now();
250        let expires_at = now + chrono::Duration::days(i64::from(self.config.trash_retention_days));
251
252        let entry = TrashEntry {
253            id,
254            original_path: path.clone(),
255            trash_path: trash_content_path,
256            deleted_at: now,
257            expires_at,
258            content_hash,
259            size,
260        };
261
262        // Save metadata
263        let meta_content = serde_json::to_string_pretty(&entry)
264            .map_err(|e| format!("Failed to serialize meta: {e}"))?;
265        fs::write(&trash_meta_path, meta_content)
266            .map_err(|e| format!("Failed to write meta: {e}"))?;
267
268        // Update cache
269        {
270            let mut cache = self.trash_cache.write().await;
271            cache.push(entry.clone());
272        }
273
274        // Save manifest
275        if let Err(e) = self.save_trash_entries().await {
276            warn!("Failed to save trash manifest: {e}");
277        }
278
279        info!("Soft deleted {:?} -> trash/{}", path, id);
280        Ok(entry)
281    }
282
283    /// Restore a file from trash.
284    pub async fn restore(&self, trash_id: Uuid) -> Result<PathBuf, String> {
285        let entry = {
286            let cache = self.trash_cache.read().await;
287            cache.iter().find(|e| e.id == trash_id).cloned()
288        };
289
290        let entry = entry.ok_or_else(|| "Trash entry not found".to_string())?;
291
292        if !entry.trash_path.exists() {
293            return Err("Trash content not found".into());
294        }
295
296        // Restore only inside the source root (rejects a tampered original_path).
297        let restore_path = self.jail(&entry.original_path)?;
298
299        // Ensure parent directory exists
300        if let Some(parent) = restore_path.parent() {
301            fs::create_dir_all(parent)
302                .map_err(|e| format!("Failed to create parent directory: {e}"))?;
303        }
304
305        // Check if destination already exists
306        if restore_path.exists() {
307            return Err("Destination already exists".into());
308        }
309
310        // Move content back
311        fs::rename(&entry.trash_path, &restore_path)
312            .or_else(|_| {
313                fs::copy(&entry.trash_path, &restore_path)?;
314                fs::remove_file(&entry.trash_path)
315            })
316            .map_err(|e| format!("Failed to restore file: {e}"))?;
317
318        // Remove trash entry directory
319        let trash_entry_dir = self.trash_dir.join(trash_id.to_string());
320        if let Err(e) = fs::remove_dir_all(&trash_entry_dir) {
321            warn!("Failed to remove trash entry dir: {e}");
322        }
323
324        // Update cache
325        {
326            let mut cache = self.trash_cache.write().await;
327            cache.retain(|e| e.id != trash_id);
328        }
329
330        // Save manifest
331        if let Err(e) = self.save_trash_entries().await {
332            warn!("Failed to save trash manifest: {e}");
333        }
334
335        info!("Restored {:?} from trash/{}", restore_path, trash_id);
336        Ok(restore_path)
337    }
338
339    /// List all trash entries.
340    pub async fn list_trash(&self) -> Vec<TrashEntry> {
341        self.trash_cache.read().await.clone()
342    }
343
344    /// Get a specific trash entry.
345    pub async fn get_trash_entry(&self, id: Uuid) -> Option<TrashEntry> {
346        self.trash_cache
347            .read()
348            .await
349            .iter()
350            .find(|e| e.id == id)
351            .cloned()
352    }
353
354    /// Get trash content by ID.
355    pub fn get_trash_content(&self, id: Uuid) -> Result<Vec<u8>, String> {
356        let trash_content_path = self.trash_dir.join(id.to_string()).join("content");
357        if !trash_content_path.exists() {
358            return Err("Trash content not found".into());
359        }
360        fs::read(&trash_content_path).map_err(|e| format!("Failed to read trash content: {e}"))
361    }
362
363    /// Purge expired trash entries.
364    pub async fn purge_expired(&self) -> usize {
365        let now = Utc::now();
366        let expired: Vec<Uuid> = {
367            let cache = self.trash_cache.read().await;
368            cache
369                .iter()
370                .filter(|e| e.expires_at < now)
371                .map(|e| e.id)
372                .collect()
373        };
374
375        let mut purged = 0;
376        for id in expired {
377            let trash_entry_dir = self.trash_dir.join(id.to_string());
378            if let Err(e) = fs::remove_dir_all(&trash_entry_dir) {
379                warn!("Failed to purge trash entry {}: {e}", id);
380            } else {
381                purged += 1;
382            }
383        }
384
385        // Update cache
386        {
387            let mut cache = self.trash_cache.write().await;
388            cache.retain(|e| e.expires_at >= now);
389        }
390
391        // Save manifest
392        if let Err(e) = self.save_trash_entries().await {
393            warn!("Failed to save trash manifest: {e}");
394        }
395
396        if purged > 0 {
397            info!("Purged {} expired trash entries", purged);
398        }
399
400        purged
401    }
402
403    /// Log an operation to history.
404    pub fn log(&self, entry: HistoryEntry) -> std::io::Result<()> {
405        let line = serde_json::to_string(&entry)?;
406
407        let mut file = OpenOptions::new()
408            .create(true)
409            .append(true)
410            .open(&self.history_file)?;
411
412        writeln!(file, "{line}")?;
413        debug!("Logged history entry: {:?}", entry.operation);
414        Ok(())
415    }
416
417    /// Log a successful operation and return the history entry id.
418    ///
419    /// This id is the `undo_id` written to `.ops/.result`.
420    pub fn log_success(&self, operation: HistoryOperation, undo_data: Option<UndoData>) -> Uuid {
421        let id = Uuid::new_v4();
422        let entry = HistoryEntry {
423            id,
424            operation,
425            timestamp: Utc::now(),
426            success: true,
427            reversible: undo_data.is_some(),
428            undo_data,
429            error: None,
430        };
431
432        if let Err(e) = self.log(entry) {
433            warn!("Failed to log history: {e}");
434        }
435        id
436    }
437
438    /// Log a failed operation.
439    pub fn log_failure(&self, operation: HistoryOperation, error: String) {
440        let entry = HistoryEntry {
441            id: Uuid::new_v4(),
442            operation,
443            timestamp: Utc::now(),
444            success: false,
445            reversible: false,
446            undo_data: None,
447            error: Some(error),
448        };
449
450        if let Err(e) = self.log(entry) {
451            warn!("Failed to log history: {e}");
452        }
453    }
454
455    /// Read history entries.
456    pub fn read_history(&self, limit: Option<usize>) -> Vec<HistoryEntry> {
457        let file = match File::open(&self.history_file) {
458            Ok(f) => f,
459            Err(_) => return Vec::new(),
460        };
461
462        let reader = BufReader::new(file);
463        let mut entries: Vec<HistoryEntry> = reader
464            .lines()
465            .map_while(Result::ok)
466            .filter_map(|line| serde_json::from_str(&line).ok())
467            .collect();
468
469        // Return most recent first
470        entries.reverse();
471
472        if let Some(limit) = limit {
473            entries.truncate(limit);
474        }
475
476        entries
477    }
478
479    /// Get history as JSON bytes (for FUSE read).
480    pub fn get_history_json(&self, limit: Option<usize>) -> Vec<u8> {
481        let entries = self.read_history(limit);
482        serde_json::to_string_pretty(&entries)
483            .unwrap_or_else(|_| "[]".to_string())
484            .into_bytes()
485    }
486
487    /// Find an operation by ID for undo.
488    pub fn find_operation(&self, id: Uuid) -> Option<HistoryEntry> {
489        self.read_history(None).into_iter().find(|e| e.id == id)
490    }
491
492    /// Undo an operation.
493    pub async fn undo(&self, operation_id: Uuid) -> Result<String, String> {
494        let entry = self
495            .find_operation(operation_id)
496            .ok_or_else(|| "Operation not found".to_string())?;
497
498        if !entry.reversible {
499            return Err("Operation is not reversible".into());
500        }
501
502        let undo_data = entry
503            .undo_data
504            .ok_or_else(|| "No undo data available".to_string())?;
505
506        match undo_data {
507            UndoData::Create { path } => {
508                // Undo create by moving the file to trash (recoverable).
509                if path.exists() {
510                    let entry = self.soft_delete(&path).await?;
511                    self.log_success(
512                        HistoryOperation::Delete {
513                            path: path.clone(),
514                            trash_id: Some(entry.id),
515                        },
516                        Some(UndoData::Delete { trash_id: entry.id }),
517                    );
518                    Ok(format!("Undone: moved {} to trash", path.display()))
519                } else {
520                    Err("File no longer exists".into())
521                }
522            }
523            UndoData::Delete { trash_id } => {
524                // Undo delete by restoring from trash
525                let restored = self.restore(trash_id).await?;
526                Ok(format!("Undone: restored {}", restored.display()))
527            }
528            UndoData::Move { src, dst } => {
529                let src = self.jail(&src)?;
530                let dst = self.jail(&dst)?;
531                // Undo move by moving back
532                if dst.exists() {
533                    fs::rename(&dst, &src).map_err(|e| format!("Failed to undo move: {e}"))?;
534                    self.log_success(
535                        HistoryOperation::Move {
536                            src: dst.clone(),
537                            dst: src.clone(),
538                        },
539                        Some(UndoData::Move {
540                            src: src.clone(),
541                            dst,
542                        }),
543                    );
544                    Ok(format!("Undone: moved back to {}", src.display()))
545                } else {
546                    Err("Destination file no longer exists".into())
547                }
548            }
549            UndoData::Copy { path } => {
550                let path = self.jail(&path)?;
551                // Undo copy by deleting the copy
552                if path.exists() {
553                    fs::remove_file(&path).map_err(|e| format!("Failed to undo copy: {e}"))?;
554                    self.log_success(
555                        HistoryOperation::Delete {
556                            path: path.clone(),
557                            trash_id: None,
558                        },
559                        None,
560                    );
561                    Ok(format!("Undone: deleted copy {}", path.display()))
562                } else {
563                    Err("Copy file no longer exists".into())
564                }
565            }
566        }
567    }
568
569    /// Check if soft delete is enabled.
570    #[must_use]
571    pub fn soft_delete_enabled(&self) -> bool {
572        self.config.soft_delete
573    }
574
575    /// Get trash directory path.
576    #[must_use]
577    pub fn trash_dir(&self) -> &PathBuf {
578        &self.trash_dir
579    }
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585    use tempfile::TempDir;
586
587    fn create_test_manager() -> (SafetyManager, TempDir, TempDir) {
588        let source_dir = TempDir::new().unwrap();
589        let data_dir = TempDir::new().unwrap();
590
591        let config = SafetyConfig {
592            data_dir: data_dir.path().to_path_buf(),
593            trash_retention_days: 7,
594            soft_delete: true,
595        };
596
597        let manager = SafetyManager::new(&source_dir.path().to_path_buf(), Some(config));
598        (manager, source_dir, data_dir)
599    }
600
601    #[tokio::test]
602    async fn test_soft_delete_and_restore() {
603        let (manager, source_dir, _data_dir) = create_test_manager();
604
605        // Create a test file
606        let test_file = source_dir.path().join("test.txt");
607        fs::write(&test_file, "Hello, World!").unwrap();
608        assert!(test_file.exists());
609
610        // Soft delete
611        let entry = manager.soft_delete(&test_file).await.unwrap();
612        assert!(!test_file.exists());
613        assert!(entry.trash_path.exists());
614
615        // Verify in trash list
616        let trash = manager.list_trash().await;
617        assert_eq!(trash.len(), 1);
618        assert_eq!(trash[0].id, entry.id);
619
620        // Restore (jail returns a canonical path)
621        let restored = manager.restore(entry.id).await.unwrap();
622        assert_eq!(restored, test_file.canonicalize().unwrap());
623        assert!(test_file.exists());
624
625        // Verify content
626        let content = fs::read_to_string(&test_file).unwrap();
627        assert_eq!(content, "Hello, World!");
628
629        // Trash should be empty
630        let trash = manager.list_trash().await;
631        assert!(trash.is_empty());
632    }
633
634    #[tokio::test]
635    async fn test_soft_delete_nonexistent() {
636        let (manager, source_dir, _data_dir) = create_test_manager();
637
638        let result = manager
639            .soft_delete(&source_dir.path().join("missing.txt"))
640            .await;
641        assert!(result.is_err());
642        assert!(result.unwrap_err().contains("not found"));
643    }
644
645    #[tokio::test]
646    async fn test_soft_delete_rejects_parent_escape() {
647        let (manager, source_dir, _data_dir) = create_test_manager();
648        let outside = source_dir.path().parent().unwrap().join("jailbreak.txt");
649        fs::write(&outside, "secret").unwrap();
650
651        let err = manager
652            .soft_delete(&PathBuf::from("../jailbreak.txt"))
653            .await
654            .unwrap_err();
655        assert!(err.contains("escapes"), "{err}");
656        assert!(outside.exists());
657        let _ = fs::remove_file(&outside);
658    }
659
660    #[tokio::test]
661    async fn test_soft_delete_rejects_absolute_outside_root() {
662        let (manager, _source_dir, _data_dir) = create_test_manager();
663        let outside = TempDir::new().unwrap();
664        let secret = outside.path().join("secret.txt");
665        fs::write(&secret, "secret").unwrap();
666
667        let err = manager.soft_delete(&secret).await.unwrap_err();
668        assert!(err.contains("escapes"), "{err}");
669        assert!(secret.exists());
670    }
671
672    #[tokio::test]
673    #[cfg(unix)]
674    async fn test_soft_delete_rejects_symlink_escape() {
675        let (manager, source_dir, _data_dir) = create_test_manager();
676        let outside = TempDir::new().unwrap();
677        let secret = outside.path().join("secret.txt");
678        fs::write(&secret, "secret").unwrap();
679        std::os::unix::fs::symlink(outside.path(), source_dir.path().join("out")).unwrap();
680
681        let err = manager
682            .soft_delete(&PathBuf::from("out/secret.txt"))
683            .await
684            .unwrap_err();
685        assert!(err.contains("escapes"), "{err}");
686        assert!(secret.exists());
687    }
688
689    #[tokio::test]
690    async fn test_restore_nonexistent() {
691        let (manager, _source_dir, _data_dir) = create_test_manager();
692
693        let result = manager.restore(Uuid::new_v4()).await;
694        assert!(result.is_err());
695    }
696
697    #[test]
698    fn test_history_logging() {
699        let (manager, _source_dir, _data_dir) = create_test_manager();
700
701        // Log some operations
702        manager.log_success(
703            HistoryOperation::Create {
704                path: PathBuf::from("/test.txt"),
705            },
706            Some(UndoData::Create {
707                path: PathBuf::from("/test.txt"),
708            }),
709        );
710
711        manager.log_failure(
712            HistoryOperation::Delete {
713                path: PathBuf::from("/fail.txt"),
714                trash_id: None,
715            },
716            "Permission denied".to_string(),
717        );
718
719        // Read history
720        let history = manager.read_history(None);
721        assert_eq!(history.len(), 2);
722
723        // Most recent first
724        assert!(!history[0].success);
725        assert!(history[1].success);
726    }
727
728    #[test]
729    fn test_get_history_json() {
730        let (manager, _source_dir, _data_dir) = create_test_manager();
731
732        manager.log_success(
733            HistoryOperation::Create {
734                path: PathBuf::from("/test.txt"),
735            },
736            None,
737        );
738
739        let json = manager.get_history_json(None);
740        let json_str = String::from_utf8(json).unwrap();
741        assert!(json_str.contains("create"));
742        assert!(json_str.contains("/test.txt"));
743    }
744
745    #[tokio::test]
746    async fn test_get_trash_content() {
747        let (manager, source_dir, _data_dir) = create_test_manager();
748
749        let test_file = source_dir.path().join("content_test.txt");
750        fs::write(&test_file, "Test content for trash").unwrap();
751
752        let entry = manager.soft_delete(&test_file).await.unwrap();
753
754        let content = manager.get_trash_content(entry.id).unwrap();
755        assert_eq!(
756            String::from_utf8(content).unwrap(),
757            "Test content for trash"
758        );
759    }
760
761    #[test]
762    fn test_safety_config_default() {
763        let config = SafetyConfig::default();
764        assert_eq!(config.trash_retention_days, 7);
765        assert!(config.soft_delete);
766    }
767
768    #[test]
769    fn test_trash_entry_serialization() {
770        let entry = TrashEntry {
771            id: Uuid::new_v4(),
772            original_path: PathBuf::from("/test.txt"),
773            trash_path: PathBuf::from("/trash/test.txt"),
774            deleted_at: Utc::now(),
775            expires_at: Utc::now(),
776            content_hash: "abc123".to_string(),
777            size: 1024,
778        };
779
780        let json = serde_json::to_string(&entry).unwrap();
781        let parsed: TrashEntry = serde_json::from_str(&json).unwrap();
782        assert_eq!(parsed.id, entry.id);
783        assert_eq!(parsed.original_path, entry.original_path);
784    }
785
786    #[test]
787    fn test_history_entry_serialization() {
788        let entry = HistoryEntry {
789            id: Uuid::new_v4(),
790            operation: HistoryOperation::Create {
791                path: PathBuf::from("/test.txt"),
792            },
793            timestamp: Utc::now(),
794            success: true,
795            reversible: true,
796            undo_data: Some(UndoData::Create {
797                path: PathBuf::from("/test.txt"),
798            }),
799            error: None,
800        };
801
802        let json = serde_json::to_string(&entry).unwrap();
803        let parsed: HistoryEntry = serde_json::from_str(&json).unwrap();
804        assert_eq!(parsed.id, entry.id);
805        assert!(parsed.success);
806    }
807
808    #[test]
809    fn test_log_success_returns_history_id() {
810        let (manager, _source_dir, _data_dir) = create_test_manager();
811
812        let id = manager.log_success(
813            HistoryOperation::Create {
814                path: PathBuf::from("/test.txt"),
815            },
816            Some(UndoData::Create {
817                path: PathBuf::from("/test.txt"),
818            }),
819        );
820
821        let history = manager.read_history(None);
822        assert_eq!(history.len(), 1);
823        assert_eq!(history[0].id, id);
824    }
825
826    #[tokio::test]
827    async fn test_undo_create_uses_trash() {
828        let (manager, source_dir, _data_dir) = create_test_manager();
829
830        let test_file = source_dir.path().join("created.txt");
831        fs::write(&test_file, "created content").unwrap();
832
833        let undo_id = manager.log_success(
834            HistoryOperation::Create {
835                path: test_file.clone(),
836            },
837            Some(UndoData::Create {
838                path: test_file.clone(),
839            }),
840        );
841
842        let msg = manager.undo(undo_id).await.unwrap();
843        assert!(msg.contains("trash"), "{msg}");
844        assert!(!test_file.exists(), "undo create must not hard-delete");
845
846        let trash = manager.list_trash().await;
847        assert_eq!(trash.len(), 1);
848        let content = manager.get_trash_content(trash[0].id).unwrap();
849        assert_eq!(content, b"created content");
850    }
851}