Skip to main content

runtimo_core/
backup.rs

1//! Backup manager for undo/rollback functionality.
2//!
3//! Creates timestamped backups of files and directories before mutation,
4//! allowing restoration to the pre-mutation state. Backups are organized
5//! under a root directory with subdirectories per job ID.
6//!
7//! # Security
8//!
9//! ## Backup Directory Symlink Check (FINDING #11)
10//! The backup directory is verified to be a real directory (not a symlink)
11//! during construction. This prevents an attacker from redirecting backups
12//! to an arbitrary location via symlink substitution.
13//!
14//! ## Symlink Rejection in Copy Operations
15//! The `copy_recursive` function explicitly rejects symlinks to prevent
16//! symlink attack vectors. If a symlink is encountered during traversal,
17//! the copy fails with an error.
18//!
19//! # Features
20//!
21//! - Supports both files and directories
22//! - Preserves file permissions (including executable bit) on Unix systems
23//! - Symlink rejection for security
24//! - Automatic backup numbering to preserve multiple versions
25//! - Pre-restore backup to prevent data loss on restore
26//! - 100MB per-file backup size limit
27//! - Post-copy integrity verification
28//!
29//! # Example
30//!
31//! ```rust,ignore
32//! use runtimo_core::BackupManager;
33//! use std::path::PathBuf;
34//!
35//! let mgr = BackupManager::new(PathBuf::from("/tmp/backups"));
36//! let backup = mgr.create_backup(
37//!     &PathBuf::from("/tmp/config.toml"),
38//!     "job-abc123",
39//! ).unwrap();
40//!
41//! // After a failed mutation, restore:
42//! mgr.restore(&backup, &PathBuf::from("/tmp/config.toml")).unwrap();
43//! ```
44
45use crate::Result;
46use std::path::{Path, PathBuf};
47
48/// Maximum backup size per file/directory tree: 100MB.
49const MAX_BACKUP_SIZE: u64 = 100 * 1024 * 1024;
50
51/// Checks that a path is a real directory, not a symlink (FINDING #11).
52///
53/// Returns `Err` if the path is a symlink, even if the symlink target is
54/// a valid directory. This prevents symlink substitution attacks.
55fn verify_real_directory(path: &Path) -> Result<()> {
56    if path
57        .symlink_metadata()
58        .map_err(|e| crate::Error::BackupError(format!("cannot stat {}: {}", path.display(), e)))?
59        .file_type()
60        .is_symlink()
61    {
62        return Err(crate::Error::BackupError(format!(
63            "backup directory is a symlink: {} (symlink attacks not allowed)",
64            path.display()
65        )));
66    }
67    Ok(())
68}
69
70/// Manages file backups for undo/rollback operations.
71///
72/// Backups are stored under `{backup_dir}/{job_id}/{filename}`. The manager
73/// creates directories as needed.
74pub struct BackupManager {
75    backup_dir: PathBuf,
76}
77
78impl BackupManager {
79    /// Creates a new backup manager rooted at `backup_dir`.
80    ///
81    /// The directory is created (recursively) if it does not exist.
82    ///
83    /// # Errors
84    ///
85    /// Returns [`crate::Error::BackupError`] if the backup
86    /// directory cannot be created.
87    pub fn new(backup_dir: PathBuf) -> Result<Self> {
88        std::fs::create_dir_all(&backup_dir).map_err(|e| {
89            crate::Error::BackupError(format!("Failed to create backup directory: {}", e))
90        })?;
91        verify_real_directory(&backup_dir)?;
92        Ok(Self { backup_dir })
93    }
94
95    /// Recursively copies a file or directory, rejecting symlinks for security.
96    ///
97    /// # Security
98    ///
99    /// This function explicitly rejects symlinks to prevent symlink attack vectors.
100    /// If a symlink is encountered during traversal, the copy fails with an error.
101    ///
102    /// # Metadata
103    ///
104    /// Preserves file permissions (including executable bit) on Unix systems.
105    /// Directory permissions are set to platform defaults.
106    /// Copies file permissions from source to destination.
107    ///
108    /// On Unix, preserves the full permission mask (including executable bit).
109    /// On non-Unix, this is a no-op — permissions are inherited from the parent
110    /// directory or platform defaults.
111    #[cfg(unix)]
112    fn copy_permissions(src: &Path, dst: &Path) -> std::io::Result<()> {
113        let src_meta = std::fs::symlink_metadata(src)?;
114        std::fs::set_permissions(dst, src_meta.permissions())?;
115        Ok(())
116    }
117
118    #[cfg(not(unix))]
119    fn copy_permissions(_src: &Path, _dst: &Path) -> std::io::Result<()> {
120        Ok(())
121    }
122
123    /// Calculates the total size of a file or directory tree in bytes.
124    ///
125    /// Uses `symlink_metadata` to avoid following symlinks. Symlinks are
126    /// explicitly rejected (returns an error). For directories, recurses
127    /// through all entries, using saturating addition to prevent overflow.
128    fn calculate_size(path: &Path) -> std::io::Result<u64> {
129        let meta = path.symlink_metadata()?;
130        if meta.file_type().is_symlink() {
131            return Err(std::io::Error::new(
132                std::io::ErrorKind::InvalidInput,
133                format!(
134                    "symlink detected: {} (symlinks not allowed)",
135                    path.display()
136                ),
137            ));
138        }
139        if meta.is_file() {
140            Ok(meta.len())
141        } else if meta.is_dir() {
142            let mut total: u64 = 0;
143            for entry in std::fs::read_dir(path)? {
144                let entry = entry?;
145                total = total.saturating_add(Self::calculate_size(&entry.path())?);
146            }
147            Ok(total)
148        } else {
149            Ok(0)
150        }
151    }
152
153    /// Verifies backup integrity by comparing source and destination sizes.
154    ///
155    /// After a backup copy completes, this function confirms that the total
156    /// byte count of the source and backup match. A mismatch indicates a
157    /// partial copy, filesystem error, or race condition during the backup.
158    fn verify_integrity(src: &Path, dst: &Path) -> std::io::Result<()> {
159        let src_size = Self::calculate_size(src)?;
160        let dst_size = Self::calculate_size(dst)?;
161        if src_size != dst_size {
162            return Err(std::io::Error::new(
163                std::io::ErrorKind::InvalidData,
164                format!(
165                    "backup integrity check failed: source={} bytes, backup={} bytes",
166                    src_size, dst_size
167                ),
168            ));
169        }
170        Ok(())
171    }
172
173    /// Recursively copies a file or directory, rejecting symlinks for security.
174    ///
175    /// # Security
176    ///
177    /// This function explicitly rejects symlinks to prevent symlink attack vectors.
178    /// If a symlink is encountered during traversal, the copy fails with an error.
179    ///
180    /// # Metadata
181    ///
182    /// On Unix, preserves file permissions (including executable bit). On non-Unix,
183    /// uses platform defaults. Directory permissions are set to platform defaults
184    /// on all platforms.
185    fn copy_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
186        let metadata = src.symlink_metadata()?;
187        if metadata.file_type().is_symlink() {
188            return Err(std::io::Error::new(
189                std::io::ErrorKind::InvalidInput,
190                format!(
191                    "symlink detected: {} (symlinks not allowed for security)",
192                    src.display()
193                ),
194            ));
195        }
196
197        if src.is_dir() {
198            std::fs::create_dir_all(dst)?;
199            for entry in std::fs::read_dir(src)? {
200                let entry = entry?;
201                let src_path = entry.path();
202                let dst_path = dst.join(entry.file_name());
203                Self::copy_recursive(&src_path, &dst_path)?;
204            }
205            Self::copy_permissions(src, dst)?;
206        } else {
207            std::fs::copy(src, dst)?;
208        }
209        Ok(())
210    }
211
212    /// Creates a backup of a file or directory before mutation.
213    ///
214    /// Copies `file_path` to `{backup_dir}/{job_id}/{filename}`.
215    /// If a backup already exists, appends a numeric suffix (`.1`, `.2`, etc.)
216    /// to preserve all pre-mutation states. The first backup (no suffix) always
217    /// contains the original file state before any writes in this job.
218    ///
219    /// # Arguments
220    ///
221    /// * `file_path` — Path to the file or directory to back up
222    /// * `job_id` — Job ID used as the backup subdirectory name
223    ///
224    /// # Returns
225    ///
226    /// The path to the backup file or directory.
227    ///
228    /// # Errors
229    ///
230    /// Returns [`crate::Error::BackupError`] if the source
231    /// does not exist, exceeds the 100MB size limit, or the copy fails.
232    pub fn create_backup(&self, file_path: &Path, job_id: &str) -> Result<PathBuf> {
233        if file_path.symlink_metadata().is_err() {
234            return Err(crate::Error::BackupError("File does not exist".to_string()));
235        }
236
237        let size = Self::calculate_size(file_path)
238            .map_err(|e| crate::Error::BackupError(format!("Cannot calculate size: {}", e)))?;
239        if size > MAX_BACKUP_SIZE {
240            return Err(crate::Error::BackupError(format!(
241                "Backup size {} bytes exceeds limit of {} bytes (100MB)",
242                size, MAX_BACKUP_SIZE
243            )));
244        }
245
246        let base_name = file_path
247            .file_name()
248            .ok_or_else(|| crate::Error::BackupError("Invalid filename".to_string()))?;
249
250        let job_dir = self.backup_dir.join(job_id);
251        let parent = job_dir.clone();
252        std::fs::create_dir_all(&parent).map_err(|e| {
253            crate::Error::BackupError(format!("Failed to create backup directory: {}", e))
254        })?;
255
256        let backup_path = {
257            let candidate = job_dir.join(base_name);
258            if candidate.symlink_metadata().is_err() {
259                candidate
260            } else {
261                let mut counter: u32 = 1;
262                loop {
263                    let suffixed =
264                        job_dir.join(format!("{}.{}", base_name.to_string_lossy(), counter));
265                    if suffixed.symlink_metadata().is_err() {
266                        break suffixed;
267                    }
268                    counter = counter.saturating_add(1);
269                }
270            }
271        };
272
273        Self::copy_recursive(file_path, &backup_path)
274            .map_err(|e| crate::Error::BackupError(e.to_string()))?;
275
276        Self::verify_integrity(file_path, &backup_path)
277            .map_err(|e| crate::Error::BackupError(format!("Integrity check failed: {}", e)))?;
278
279        Ok(backup_path)
280    }
281
282    /// Restores a file or directory from a backup.
283    ///
284    /// Before overwriting `target_path`, creates a pre-restore backup so the
285    /// current state can be recovered if the restore itself causes issues.
286    /// Copies `backup_path` to `target_path`, overwriting the target.
287    /// Handles both files and directories recursively.
288    ///
289    /// # Errors
290    ///
291    /// Returns [`crate::Error::BackupError`] if the backup
292    /// does not exist, the pre-restore backup fails, or the copy fails.
293    pub fn restore(&self, backup_path: &Path, target_path: &Path) -> Result<()> {
294        if backup_path.symlink_metadata().is_err() {
295            return Err(crate::Error::BackupError(
296                "Backup does not exist".to_string(),
297            ));
298        }
299
300        if target_path.symlink_metadata().is_ok() {
301            let pre_restore_dir = target_path
302                .parent()
303                .map_or_else(|| PathBuf::from("."), |p| p.to_path_buf())
304                .join(".runtimo_pre_restore");
305            std::fs::create_dir_all(&pre_restore_dir).map_err(|e| {
306                crate::Error::BackupError(format!("Cannot create pre-restore backup dir: {}", e))
307            })?;
308            let target_name = target_path
309                .file_name()
310                .unwrap_or_else(|| std::ffi::OsStr::new("target"));
311            let pre_restore_path = pre_restore_dir.join(target_name);
312            let _ = std::fs::remove_dir_all(&pre_restore_path);
313            let _ = std::fs::remove_file(&pre_restore_path);
314            Self::copy_recursive(target_path, &pre_restore_path).map_err(|e| {
315                crate::Error::BackupError(format!("Pre-restore backup failed: {}", e))
316            })?;
317        }
318
319        Self::copy_recursive(backup_path, target_path)
320            .map_err(|e| crate::Error::BackupError(e.to_string()))?;
321
322        Ok(())
323    }
324
325    /// Deletes old backups older than the given age.
326    ///
327    /// Scans `backup_dir` for job subdirectories and removes those whose
328    /// modification time is older than `older_than_secs`.
329    ///
330    /// # Security
331    ///
332    /// Uses `symlink_metadata()` to avoid following symlinks during cleanup.
333    /// Symlinks are skipped rather than deleted, preventing accidental
334    /// deletion of symlink targets (e.g., `remove_dir_all` on a symlink
335    /// to `/etc` would be catastrophic).
336    ///
337    /// # Errors
338    /// Returns an error if the cleanup directory cannot be read, or if
339    /// symlink metadata queries fail.
340    pub fn cleanup(&self, older_than_secs: u64) -> Result<()> {
341        use std::time::{SystemTime, UNIX_EPOCH};
342
343        let cutoff = SystemTime::now()
344            .duration_since(UNIX_EPOCH)
345            .unwrap_or_default()
346            .as_secs()
347            .saturating_sub(older_than_secs);
348
349        if self.backup_dir.symlink_metadata().is_err() {
350            return Ok(());
351        }
352
353        for entry in std::fs::read_dir(&self.backup_dir)
354            .map_err(|e| crate::Error::BackupError(e.to_string()))?
355        {
356            let entry = entry.map_err(|e| crate::Error::BackupError(e.to_string()))?;
357            let path = entry.path();
358
359            let meta = path
360                .symlink_metadata()
361                .map_err(|e| crate::Error::BackupError(e.to_string()))?;
362            if meta.file_type().is_symlink() {
363                continue;
364            }
365            if !meta.is_dir() {
366                continue;
367            }
368
369            let mtime = meta
370                .modified()
371                .map_err(|e| crate::Error::BackupError(e.to_string()))?
372                .duration_since(UNIX_EPOCH)
373                .unwrap_or_default()
374                .as_secs();
375
376            if mtime < cutoff {
377                std::fs::remove_dir_all(&path)
378                    .map_err(|e| crate::Error::BackupError(e.to_string()))?;
379            }
380        }
381
382        Ok(())
383    }
384}
385
386#[cfg(test)]
387#[allow(clippy::unused_result_ok, clippy::unwrap_used)]
388mod tests {
389    use super::*;
390
391    #[test]
392    fn test_new_creates_directory() {
393        let dir = std::env::temp_dir().join("runtimo_backup_test_new");
394        let _ = std::fs::remove_dir_all(&dir);
395        let result = BackupManager::new(dir.clone());
396        assert!(result.is_ok(), "should create directory");
397        assert!(dir.exists());
398        std::fs::remove_dir_all(&dir).ok();
399    }
400
401    #[test]
402    fn test_rejects_symlink_backup_dir() {
403        let target = std::env::temp_dir().join("runtimo_backup_target");
404        let link = std::env::temp_dir().join("runtimo_backup_link");
405        let _ = std::fs::remove_dir_all(&target);
406        let _ = std::fs::remove_file(&link);
407
408        std::fs::create_dir_all(&target).unwrap();
409
410        #[cfg(unix)]
411        {
412            use std::os::unix::fs::symlink;
413            if symlink(&target, &link).is_ok() {
414                let result = BackupManager::new(link.clone());
415                assert!(
416                    result.is_err(),
417                    "BackupManager should reject symlink backup directory"
418                );
419                let err = result.err().unwrap().to_string();
420                assert!(
421                    err.contains("symlink"),
422                    "error should mention symlink: {}",
423                    err
424                );
425                std::fs::remove_file(&link).ok();
426            }
427        }
428
429        std::fs::remove_dir_all(&target).ok();
430    }
431
432    #[test]
433    fn test_verify_real_directory() {
434        let dir = std::env::temp_dir().join("runtimo_verify_test");
435        let _ = std::fs::remove_dir_all(&dir);
436        std::fs::create_dir_all(&dir).unwrap();
437
438        let result = verify_real_directory(&dir);
439        assert!(result.is_ok(), "real directory should pass: {:?}", result);
440
441        std::fs::remove_dir_all(&dir).ok();
442    }
443
444    #[test]
445    fn test_backup_directory() {
446        use crate::backup::BackupManager;
447
448        let backup_dir = std::env::temp_dir().join("runtimo_backup_dir_test");
449        let source_dir = std::env::temp_dir().join("runtimo_source_dir_test");
450        let _ = std::fs::remove_dir_all(&backup_dir);
451        let _ = std::fs::remove_dir_all(&source_dir);
452
453        std::fs::create_dir_all(&source_dir).unwrap();
454        std::fs::write(source_dir.join("file1.txt"), "content1").unwrap();
455        std::fs::write(source_dir.join("file2.txt"), "content2").unwrap();
456
457        let mgr = BackupManager::new(backup_dir.clone()).unwrap();
458        let result = mgr.create_backup(&source_dir, "job123");
459
460        assert!(result.is_ok(), "should backup directory: {:?}", result);
461        let backup_path = result.unwrap();
462        assert!(backup_path.exists());
463        assert!(backup_path.join("file1.txt").exists());
464        assert!(backup_path.join("file2.txt").exists());
465
466        let content1 = std::fs::read_to_string(backup_path.join("file1.txt")).unwrap();
467        assert_eq!(content1, "content1");
468
469        std::fs::remove_dir_all(&backup_dir).ok();
470        std::fs::remove_dir_all(&source_dir).ok();
471    }
472
473    #[test]
474    fn test_backup_rejects_symlinks() {
475        use crate::backup::BackupManager;
476
477        let backup_dir = std::env::temp_dir().join("runtimo_backup_symlink_test");
478        let source_dir = std::env::temp_dir().join("runtimo_source_symlink_test");
479        let _ = std::fs::remove_dir_all(&backup_dir);
480        let _ = std::fs::remove_dir_all(&source_dir);
481
482        std::fs::create_dir_all(&source_dir).unwrap();
483        std::fs::write(source_dir.join("file.txt"), "content").unwrap();
484
485        #[cfg(unix)]
486        {
487            use std::os::unix::fs::symlink;
488            let symlink_path = source_dir.join("evil_symlink");
489            if symlink("/etc/passwd", &symlink_path).is_ok() {
490                let mgr = BackupManager::new(backup_dir.clone()).unwrap();
491                let result = mgr.create_backup(&source_dir, "job123");
492                assert!(
493                    result.is_err(),
494                    "should reject directory containing symlinks"
495                );
496                let err = result.err().unwrap().to_string();
497                assert!(
498                    err.contains("symlink"),
499                    "error should mention symlink: {}",
500                    err
501                );
502            }
503        }
504
505        std::fs::remove_dir_all(&backup_dir).ok();
506        std::fs::remove_dir_all(&source_dir).ok();
507    }
508
509    #[test]
510    fn test_restore_directory() {
511        use crate::backup::BackupManager;
512
513        let backup_dir = std::env::temp_dir().join("runtimo_restore_backup_test");
514        let source_dir = std::env::temp_dir().join("runtimo_restore_source_test");
515        let restore_dir = std::env::temp_dir().join("runtimo_restore_target_test");
516        let _ = std::fs::remove_dir_all(&backup_dir);
517        let _ = std::fs::remove_dir_all(&source_dir);
518        let _ = std::fs::remove_dir_all(&restore_dir);
519
520        std::fs::create_dir_all(&source_dir).unwrap();
521        std::fs::write(source_dir.join("file1.txt"), "content1").unwrap();
522        std::fs::write(source_dir.join("file2.txt"), "content2").unwrap();
523
524        let mgr = BackupManager::new(backup_dir.clone()).unwrap();
525        let backup_result = mgr.create_backup(&source_dir, "job123");
526        assert!(backup_result.is_ok());
527        let backup_path = backup_result.unwrap();
528
529        let restore_result = mgr.restore(&backup_path, &restore_dir);
530        assert!(
531            restore_result.is_ok(),
532            "should restore directory: {:?}",
533            restore_result
534        );
535        assert!(restore_dir.join("file1.txt").exists());
536        assert!(restore_dir.join("file2.txt").exists());
537
538        let content1 = std::fs::read_to_string(restore_dir.join("file1.txt")).unwrap();
539        assert_eq!(content1, "content1");
540
541        std::fs::remove_dir_all(&backup_dir).ok();
542        std::fs::remove_dir_all(&source_dir).ok();
543        std::fs::remove_dir_all(&restore_dir).ok();
544    }
545
546    #[test]
547    #[cfg(unix)]
548    fn test_backup_preserves_executable_bit() {
549        use crate::backup::BackupManager;
550        use std::os::unix::fs::PermissionsExt;
551
552        let backup_dir = std::env::temp_dir().join("runtimo_backup_exec_test");
553        let source_dir = std::env::temp_dir().join("runtimo_source_exec_test");
554        let _ = std::fs::remove_dir_all(&backup_dir);
555        let _ = std::fs::remove_dir_all(&source_dir);
556
557        std::fs::create_dir_all(&source_dir).unwrap();
558        let script_path = source_dir.join("script.sh");
559        std::fs::write(&script_path, "#!/bin/bash\necho hello").unwrap();
560        let mut perms = std::fs::metadata(&script_path).unwrap().permissions();
561        perms.set_mode(0o755);
562        std::fs::set_permissions(&script_path, perms).unwrap();
563
564        let mgr = BackupManager::new(backup_dir.clone()).unwrap();
565        let result = mgr.create_backup(&source_dir, "job123");
566        assert!(result.is_ok());
567
568        let backup_path = result.unwrap();
569        let backup_script = backup_path.join("script.sh");
570        let backup_perms = std::fs::metadata(backup_script).unwrap().permissions();
571        assert!(
572            backup_perms.mode() & 0o111 == 0o111,
573            "executable bit should be preserved"
574        );
575
576        std::fs::remove_dir_all(&backup_dir).ok();
577        std::fs::remove_dir_all(&source_dir).ok();
578    }
579
580    #[test]
581    fn test_restore_creates_pre_restore_backup() {
582        let backup_dir = std::env::temp_dir().join("runtimo_pre_restore_test");
583        let source_dir = std::env::temp_dir().join("runtimo_pre_restore_source");
584        let target_dir = std::env::temp_dir().join("runtimo_pre_restore_target");
585        let _ = std::fs::remove_dir_all(&backup_dir);
586        let _ = std::fs::remove_dir_all(&source_dir);
587        let _ = std::fs::remove_dir_all(&target_dir);
588
589        std::fs::create_dir_all(&source_dir).unwrap();
590        std::fs::write(source_dir.join("original.txt"), "original").unwrap();
591
592        std::fs::create_dir_all(&target_dir).unwrap();
593        std::fs::write(target_dir.join("original.txt"), "newer_data").unwrap();
594
595        let mgr = BackupManager::new(backup_dir.clone()).unwrap();
596        let backup_path = mgr.create_backup(&source_dir, "job123").unwrap();
597
598        let restore_result = mgr.restore(&backup_path, &target_dir);
599        assert!(restore_result.is_ok());
600
601        let content = std::fs::read_to_string(target_dir.join("original.txt")).unwrap();
602        assert_eq!(content, "original");
603
604        let pre_restore_path = target_dir
605            .parent()
606            .unwrap()
607            .join(".runtimo_pre_restore")
608            .join("runtimo_pre_restore_target");
609        assert!(pre_restore_path.exists(), "pre-restore backup should exist");
610        let pre_restore_content =
611            std::fs::read_to_string(pre_restore_path.join("original.txt")).unwrap();
612        assert_eq!(
613            pre_restore_content, "newer_data",
614            "pre-restore should have the newer data"
615        );
616
617        std::fs::remove_dir_all(&backup_dir).ok();
618        std::fs::remove_dir_all(&source_dir).ok();
619        std::fs::remove_dir_all(&target_dir).ok();
620        let _ = std::fs::remove_dir_all(target_dir.parent().unwrap().join(".runtimo_pre_restore"));
621    }
622
623    #[test]
624    fn test_create_backup_size_limit_constant() {
625        assert_eq!(
626            MAX_BACKUP_SIZE,
627            100 * 1024 * 1024,
628            "MAX_BACKUP_SIZE should be 100MB"
629        );
630
631        let backup_dir = std::env::temp_dir().join("runtimo_size_limit_test");
632        let source_file = std::env::temp_dir().join("runtimo_size_limit_source");
633        let _ = std::fs::remove_dir_all(&backup_dir);
634        let _ = std::fs::remove_file(&source_file);
635
636        let mgr = BackupManager::new(backup_dir.clone()).unwrap();
637        std::fs::write(&source_file, "small content").unwrap();
638        let result = mgr.create_backup(&source_file, "job123");
639        assert!(result.is_ok(), "small file should succeed");
640
641        std::fs::remove_dir_all(&backup_dir).ok();
642        std::fs::remove_file(&source_file).ok();
643    }
644
645    #[test]
646    fn test_cleanup_skips_symlinks() {
647        let backup_dir = std::env::temp_dir().join("runtimo_cleanup_symlink_test");
648        let real_target = std::env::temp_dir().join("runtimo_cleanup_real_target");
649        let _ = std::fs::remove_dir_all(&backup_dir);
650        let _ = std::fs::remove_dir_all(&real_target);
651
652        std::fs::create_dir_all(&backup_dir).unwrap();
653        std::fs::create_dir_all(&real_target).unwrap();
654        std::fs::write(real_target.join("important.txt"), "do not delete").unwrap();
655
656        #[cfg(unix)]
657        {
658            use std::os::unix::fs::symlink;
659            let symlink_path = backup_dir.join("evil_link");
660            if symlink(&real_target, &symlink_path).is_ok() {
661                let mgr = BackupManager::new(backup_dir.clone()).unwrap();
662                let result = mgr.cleanup(0);
663                assert!(result.is_ok(), "cleanup should succeed even with symlinks");
664
665                assert!(
666                    real_target.join("important.txt").exists(),
667                    "symlink target should not be deleted"
668                );
669
670                std::fs::remove_file(&symlink_path).ok();
671            }
672        }
673
674        std::fs::remove_dir_all(&backup_dir).ok();
675        std::fs::remove_dir_all(&real_target).ok();
676    }
677
678    #[test]
679    fn test_backup_integrity_verification() {
680        let backup_dir = std::env::temp_dir().join("runtimo_integrity_test");
681        let source_file = std::env::temp_dir().join("runtimo_integrity_source");
682        let _ = std::fs::remove_dir_all(&backup_dir);
683        let _ = std::fs::remove_file(&source_file);
684
685        std::fs::write(&source_file, "integrity test content").unwrap();
686
687        let mgr = BackupManager::new(backup_dir.clone()).unwrap();
688        let backup_path = mgr.create_backup(&source_file, "job123").unwrap();
689
690        let src_meta = std::fs::symlink_metadata(&source_file).unwrap();
691        let bak_meta = std::fs::symlink_metadata(&backup_path).unwrap();
692        assert_eq!(
693            src_meta.len(),
694            bak_meta.len(),
695            "backup size should match source"
696        );
697
698        std::fs::remove_dir_all(&backup_dir).ok();
699        std::fs::remove_file(&source_file).ok();
700    }
701
702    // ── GAP 10: BackupManager with Unicode filenames ─────────────────
703
704    #[test]
705    fn test_backup_unicode_filename() {
706        let backup_dir = std::env::temp_dir().join("runtimo_backup_unicode_test");
707        let source_dir = std::env::temp_dir().join("runtimo_source_unicode_test");
708        let _ = std::fs::remove_dir_all(&backup_dir);
709        let _ = std::fs::remove_dir_all(&source_dir);
710
711        std::fs::create_dir_all(&source_dir).unwrap();
712
713        // File with emoji in filename
714        let emoji_file = source_dir.join("🚀rocket.txt");
715        std::fs::write(&emoji_file, "emoji content").unwrap();
716
717        // File with CJK characters
718        let cjk_file = source_dir.join("你好世界.txt");
719        std::fs::write(&cjk_file, "cjk content").unwrap();
720
721        // File with Arabic
722        let arabic_file = source_dir.join("مرحبا.txt");
723        std::fs::write(&arabic_file, "arabic content").unwrap();
724
725        let mgr = BackupManager::new(backup_dir.clone()).unwrap();
726
727        // Backup each Unicode-named file individually
728        let emoji_backup = mgr.create_backup(&emoji_file, "unicode-job");
729        assert!(
730            emoji_backup.is_ok(),
731            "emoji backup failed: {:?}",
732            emoji_backup.err()
733        );
734        assert!(emoji_backup.unwrap().exists());
735
736        let cjk_backup = mgr.create_backup(&cjk_file, "unicode-job");
737        assert!(
738            cjk_backup.is_ok(),
739            "CJK backup failed: {:?}",
740            cjk_backup.err()
741        );
742        assert!(cjk_backup.unwrap().exists());
743
744        let arabic_backup = mgr.create_backup(&arabic_file, "unicode-job");
745        assert!(
746            arabic_backup.is_ok(),
747            "Arabic backup failed: {:?}",
748            arabic_backup.err()
749        );
750        assert!(arabic_backup.unwrap().exists());
751
752        // Restore from backup
753        let _ = std::fs::remove_file(&emoji_file);
754        let _ = std::fs::remove_file(&cjk_file);
755        let _ = std::fs::remove_file(&arabic_file);
756
757        let job_dir = backup_dir.join("unicode-job");
758        mgr.restore(&job_dir.join("🚀rocket.txt"), &emoji_file)
759            .unwrap();
760        mgr.restore(&job_dir.join("你好世界.txt"), &cjk_file)
761            .unwrap();
762        mgr.restore(&job_dir.join("مرحبا.txt"), &arabic_file)
763            .unwrap();
764
765        assert_eq!(
766            std::fs::read_to_string(&emoji_file).unwrap(),
767            "emoji content"
768        );
769        assert_eq!(std::fs::read_to_string(&cjk_file).unwrap(), "cjk content");
770        assert_eq!(
771            std::fs::read_to_string(&arabic_file).unwrap(),
772            "arabic content"
773        );
774
775        std::fs::remove_dir_all(&backup_dir).ok();
776        std::fs::remove_dir_all(&source_dir).ok();
777    }
778
779    #[test]
780    fn test_backup_unicode_directory_name() {
781        // Backup of a directory with Unicode name
782        let backup_dir = std::env::temp_dir().join("runtimo_backup_unicode_dir_test");
783        let source_dir = std::env::temp_dir().join("source_🌟_目录");
784        let _ = std::fs::remove_dir_all(&backup_dir);
785        let _ = std::fs::remove_dir_all(&source_dir);
786
787        std::fs::create_dir_all(&source_dir).unwrap();
788        std::fs::write(source_dir.join("inner.txt"), "inside unicode dir").unwrap();
789
790        let mgr = BackupManager::new(backup_dir.clone()).unwrap();
791        let backup = mgr.create_backup(&source_dir, "unicode-dir-job");
792        assert!(
793            backup.is_ok(),
794            "Unicode dir backup failed: {:?}",
795            backup.err()
796        );
797        assert!(backup.unwrap().join("inner.txt").exists());
798
799        std::fs::remove_dir_all(&backup_dir).ok();
800        std::fs::remove_dir_all(&source_dir).ok();
801    }
802}