1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct TrashEntry {
21 pub id: Uuid,
23 pub original_path: PathBuf,
25 pub trash_path: PathBuf,
27 pub deleted_at: DateTime<Utc>,
29 pub expires_at: DateTime<Utc>,
31 pub content_hash: String,
33 pub size: u64,
35}
36
37#[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#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct HistoryEntry {
69 pub id: Uuid,
71 pub operation: HistoryOperation,
73 pub timestamp: DateTime<Utc>,
75 pub success: bool,
77 pub reversible: bool,
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub undo_data: Option<UndoData>,
82 #[serde(skip_serializing_if = "Option::is_none")]
84 pub error: Option<String>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum UndoData {
91 Create { path: PathBuf },
93 Delete { trash_id: Uuid },
95 Move { src: PathBuf, dst: PathBuf },
97 Copy { path: PathBuf },
99}
100
101#[derive(Debug, Clone)]
103pub struct SafetyConfig {
104 pub data_dir: PathBuf,
106 pub trash_retention_days: u32,
108 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
126pub struct SafetyManager {
128 source: PathBuf,
130 config: SafetyConfig,
132 index_hash: String,
134 trash_dir: PathBuf,
136 history_file: PathBuf,
138 trash_cache: Arc<RwLock<Vec<TrashEntry>>>,
140}
141
142impl SafetyManager {
143 pub fn new(source: &PathBuf, config: Option<SafetyConfig>) -> Self {
145 let config = config.unwrap_or_default();
146
147 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 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 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 fn jail(&self, path: &Path) -> Result<PathBuf, String> {
185 ragfs_core::resolve_under_root(&self.source, path)
186 }
187
188 #[must_use]
190 pub fn index_hash(&self) -> &str {
191 &self.index_hash
192 }
193
194 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 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 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 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 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 fs::rename(&path, &trash_content_path)
242 .or_else(|_| {
243 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 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 {
270 let mut cache = self.trash_cache.write().await;
271 cache.push(entry.clone());
272 }
273
274 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 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 let restore_path = self.jail(&entry.original_path)?;
298
299 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 if restore_path.exists() {
307 return Err("Destination already exists".into());
308 }
309
310 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 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 {
326 let mut cache = self.trash_cache.write().await;
327 cache.retain(|e| e.id != trash_id);
328 }
329
330 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 pub async fn list_trash(&self) -> Vec<TrashEntry> {
341 self.trash_cache.read().await.clone()
342 }
343
344 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 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 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 {
387 let mut cache = self.trash_cache.write().await;
388 cache.retain(|e| e.expires_at >= now);
389 }
390
391 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 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 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 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 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 entries.reverse();
471
472 if let Some(limit) = limit {
473 entries.truncate(limit);
474 }
475
476 entries
477 }
478
479 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 pub fn find_operation(&self, id: Uuid) -> Option<HistoryEntry> {
489 self.read_history(None).into_iter().find(|e| e.id == id)
490 }
491
492 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 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 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 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 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 #[must_use]
571 pub fn soft_delete_enabled(&self) -> bool {
572 self.config.soft_delete
573 }
574
575 #[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 let test_file = source_dir.path().join("test.txt");
607 fs::write(&test_file, "Hello, World!").unwrap();
608 assert!(test_file.exists());
609
610 let entry = manager.soft_delete(&test_file).await.unwrap();
612 assert!(!test_file.exists());
613 assert!(entry.trash_path.exists());
614
615 let trash = manager.list_trash().await;
617 assert_eq!(trash.len(), 1);
618 assert_eq!(trash[0].id, entry.id);
619
620 let restored = manager.restore(entry.id).await.unwrap();
622 assert_eq!(restored, test_file.canonicalize().unwrap());
623 assert!(test_file.exists());
624
625 let content = fs::read_to_string(&test_file).unwrap();
627 assert_eq!(content, "Hello, World!");
628
629 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 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 let history = manager.read_history(None);
721 assert_eq!(history.len(), 2);
722
723 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}