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//! enabling 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    #[cfg(unix)]
107    fn copy_permissions(src: &Path, dst: &Path) -> std::io::Result<()> {
108        let src_meta = std::fs::symlink_metadata(src)?;
109        std::fs::set_permissions(dst, src_meta.permissions())?;
110        Ok(())
111    }
112
113    #[cfg(not(unix))]
114    fn copy_permissions(_src: &Path, _dst: &Path) -> std::io::Result<()> {
115        Ok(())
116    }
117
118    /// Calculates the total size of a file or directory tree.
119    /// Uses symlink_metadata to avoid following symlinks.
120    fn calculate_size(path: &Path) -> std::io::Result<u64> {
121        let meta = path.symlink_metadata()?;
122        if meta.file_type().is_symlink() {
123            return Err(std::io::Error::new(
124                std::io::ErrorKind::InvalidInput,
125                format!(
126                    "symlink detected: {} (symlinks not allowed)",
127                    path.display()
128                ),
129            ));
130        }
131        if meta.is_file() {
132            Ok(meta.len())
133        } else if meta.is_dir() {
134            let mut total: u64 = 0;
135            for entry in std::fs::read_dir(path)? {
136                let entry = entry?;
137                total = total.saturating_add(Self::calculate_size(&entry.path())?);
138            }
139            Ok(total)
140        } else {
141            Ok(0)
142        }
143    }
144
145    /// Verifies backup integrity by comparing source and destination sizes.
146    fn verify_integrity(src: &Path, dst: &Path) -> std::io::Result<()> {
147        let src_size = Self::calculate_size(src)?;
148        let dst_size = Self::calculate_size(dst)?;
149        if src_size != dst_size {
150            return Err(std::io::Error::new(
151                std::io::ErrorKind::InvalidData,
152                format!(
153                    "backup integrity check failed: source={} bytes, backup={} bytes",
154                    src_size, dst_size
155                ),
156            ));
157        }
158        Ok(())
159    }
160
161    fn copy_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
162        let metadata = src.symlink_metadata()?;
163        if metadata.file_type().is_symlink() {
164            return Err(std::io::Error::new(
165                std::io::ErrorKind::InvalidInput,
166                format!(
167                    "symlink detected: {} (symlinks not allowed for security)",
168                    src.display()
169                ),
170            ));
171        }
172
173        if src.is_dir() {
174            std::fs::create_dir_all(dst)?;
175            for entry in std::fs::read_dir(src)? {
176                let entry = entry?;
177                let src_path = entry.path();
178                let dst_path = dst.join(entry.file_name());
179                Self::copy_recursive(&src_path, &dst_path)?;
180            }
181            Self::copy_permissions(src, dst)?;
182        } else {
183            std::fs::copy(src, dst)?;
184        }
185        Ok(())
186    }
187
188    /// Creates a backup of a file or directory before mutation.
189    ///
190    /// Copies `file_path` to `{backup_dir}/{job_id}/{filename}`.
191    /// If a backup already exists, appends a numeric suffix (`.1`, `.2`, etc.)
192    /// to preserve all pre-mutation states. The first backup (no suffix) always
193    /// contains the original file state before any writes in this job.
194    ///
195    /// # Arguments
196    ///
197    /// * `file_path` — Path to the file or directory to back up
198    /// * `job_id` — Job ID used as the backup subdirectory name
199    ///
200    /// # Returns
201    ///
202    /// The path to the backup file or directory.
203    ///
204    /// # Errors
205    ///
206    /// Returns [`crate::Error::BackupError`] if the source
207    /// does not exist, exceeds the 100MB size limit, or the copy fails.
208    pub fn create_backup(&self, file_path: &Path, job_id: &str) -> Result<PathBuf> {
209        if file_path.symlink_metadata().is_err() {
210            return Err(crate::Error::BackupError("File does not exist".to_string()));
211        }
212
213        let size = Self::calculate_size(file_path)
214            .map_err(|e| crate::Error::BackupError(format!("Cannot calculate size: {}", e)))?;
215        if size > MAX_BACKUP_SIZE {
216            return Err(crate::Error::BackupError(format!(
217                "Backup size {} bytes exceeds limit of {} bytes (100MB)",
218                size, MAX_BACKUP_SIZE
219            )));
220        }
221
222        let base_name = file_path
223            .file_name()
224            .ok_or_else(|| crate::Error::BackupError("Invalid filename".to_string()))?;
225
226        let job_dir = self.backup_dir.join(job_id);
227        let parent = job_dir.clone();
228        std::fs::create_dir_all(&parent).map_err(|e| {
229            crate::Error::BackupError(format!("Failed to create backup directory: {}", e))
230        })?;
231
232        let backup_path = {
233            let candidate = job_dir.join(base_name);
234            if candidate.symlink_metadata().is_err() {
235                candidate
236            } else {
237                let mut counter: u32 = 1;
238                loop {
239                    let suffixed =
240                        job_dir.join(format!("{}.{}", base_name.to_string_lossy(), counter));
241                    if suffixed.symlink_metadata().is_err() {
242                        break suffixed;
243                    }
244                    counter = counter.saturating_add(1);
245                }
246            }
247        };
248
249        Self::copy_recursive(file_path, &backup_path)
250            .map_err(|e| crate::Error::BackupError(e.to_string()))?;
251
252        Self::verify_integrity(file_path, &backup_path)
253            .map_err(|e| crate::Error::BackupError(format!("Integrity check failed: {}", e)))?;
254
255        Ok(backup_path)
256    }
257
258    /// Restores a file or directory from a backup.
259    ///
260    /// Before overwriting `target_path`, creates a pre-restore backup so the
261    /// current state can be recovered if the restore itself causes issues.
262    /// Copies `backup_path` to `target_path`, overwriting the target.
263    /// Handles both files and directories recursively.
264    ///
265    /// # Errors
266    ///
267    /// Returns [`crate::Error::BackupError`] if the backup
268    /// does not exist, the pre-restore backup fails, or the copy fails.
269    pub fn restore(&self, backup_path: &Path, target_path: &Path) -> Result<()> {
270        if backup_path.symlink_metadata().is_err() {
271            return Err(crate::Error::BackupError(
272                "Backup does not exist".to_string(),
273            ));
274        }
275
276        if target_path.symlink_metadata().is_ok() {
277            let pre_restore_dir = target_path
278                .parent()
279                .map_or_else(|| PathBuf::from("."), |p| p.to_path_buf())
280                .join(".runtimo_pre_restore");
281            std::fs::create_dir_all(&pre_restore_dir).map_err(|e| {
282                crate::Error::BackupError(format!("Cannot create pre-restore backup dir: {}", e))
283            })?;
284            let target_name = target_path
285                .file_name()
286                .unwrap_or_else(|| std::ffi::OsStr::new("target"));
287            let pre_restore_path = pre_restore_dir.join(target_name);
288            let _ = std::fs::remove_dir_all(&pre_restore_path);
289            let _ = std::fs::remove_file(&pre_restore_path);
290            Self::copy_recursive(target_path, &pre_restore_path).map_err(|e| {
291                crate::Error::BackupError(format!("Pre-restore backup failed: {}", e))
292            })?;
293        }
294
295        Self::copy_recursive(backup_path, target_path)
296            .map_err(|e| crate::Error::BackupError(e.to_string()))?;
297
298        Ok(())
299    }
300
301    /// Deletes old backups older than the given age.
302    ///
303    /// Scans `backup_dir` for job subdirectories and removes those whose
304    /// modification time is older than `older_than_secs`.
305    ///
306    /// # Security
307    ///
308    /// Uses `symlink_metadata()` to avoid following symlinks during cleanup.
309    /// Symlinks are skipped rather than deleted, preventing accidental
310    /// deletion of symlink targets (e.g., `remove_dir_all` on a symlink
311    /// to `/etc` would be catastrophic).
312    ///
313    /// # Errors
314    /// Returns an error if the cleanup directory cannot be read, or if
315    /// symlink metadata queries fail.
316    pub fn cleanup(&self, older_than_secs: u64) -> Result<()> {
317        use std::time::{SystemTime, UNIX_EPOCH};
318
319        let cutoff = SystemTime::now()
320            .duration_since(UNIX_EPOCH)
321            .unwrap_or_default()
322            .as_secs()
323            .saturating_sub(older_than_secs);
324
325        if self.backup_dir.symlink_metadata().is_err() {
326            return Ok(());
327        }
328
329        for entry in std::fs::read_dir(&self.backup_dir)
330            .map_err(|e| crate::Error::BackupError(e.to_string()))?
331        {
332            let entry = entry.map_err(|e| crate::Error::BackupError(e.to_string()))?;
333            let path = entry.path();
334
335            let meta = path
336                .symlink_metadata()
337                .map_err(|e| crate::Error::BackupError(e.to_string()))?;
338            if meta.file_type().is_symlink() {
339                continue;
340            }
341            if !meta.is_dir() {
342                continue;
343            }
344
345            let mtime = meta
346                .modified()
347                .map_err(|e| crate::Error::BackupError(e.to_string()))?
348                .duration_since(UNIX_EPOCH)
349                .unwrap_or_default()
350                .as_secs();
351
352            if mtime < cutoff {
353                std::fs::remove_dir_all(&path)
354                    .map_err(|e| crate::Error::BackupError(e.to_string()))?;
355            }
356        }
357
358        Ok(())
359    }
360}
361
362#[cfg(test)]
363#[allow(clippy::unused_result_ok, clippy::unwrap_used)]
364mod tests {
365    use super::*;
366
367    #[test]
368    fn test_new_creates_directory() {
369        let dir = std::env::temp_dir().join("runtimo_backup_test_new");
370        let _ = std::fs::remove_dir_all(&dir);
371        let result = BackupManager::new(dir.clone());
372        assert!(result.is_ok(), "should create directory");
373        assert!(dir.exists());
374        std::fs::remove_dir_all(&dir).ok();
375    }
376
377    #[test]
378    fn test_rejects_symlink_backup_dir() {
379        let target = std::env::temp_dir().join("runtimo_backup_target");
380        let link = std::env::temp_dir().join("runtimo_backup_link");
381        let _ = std::fs::remove_dir_all(&target);
382        let _ = std::fs::remove_file(&link);
383
384        std::fs::create_dir_all(&target).unwrap();
385
386        #[cfg(unix)]
387        {
388            use std::os::unix::fs::symlink;
389            if symlink(&target, &link).is_ok() {
390                let result = BackupManager::new(link.clone());
391                assert!(
392                    result.is_err(),
393                    "BackupManager should reject symlink backup directory"
394                );
395                let err = result.err().unwrap().to_string();
396                assert!(
397                    err.contains("symlink"),
398                    "error should mention symlink: {}",
399                    err
400                );
401                std::fs::remove_file(&link).ok();
402            }
403        }
404
405        std::fs::remove_dir_all(&target).ok();
406    }
407
408    #[test]
409    fn test_verify_real_directory() {
410        let dir = std::env::temp_dir().join("runtimo_verify_test");
411        let _ = std::fs::remove_dir_all(&dir);
412        std::fs::create_dir_all(&dir).unwrap();
413
414        let result = verify_real_directory(&dir);
415        assert!(result.is_ok(), "real directory should pass: {:?}", result);
416
417        std::fs::remove_dir_all(&dir).ok();
418    }
419
420    #[test]
421    fn test_backup_directory() {
422        use crate::backup::BackupManager;
423
424        let backup_dir = std::env::temp_dir().join("runtimo_backup_dir_test");
425        let source_dir = std::env::temp_dir().join("runtimo_source_dir_test");
426        let _ = std::fs::remove_dir_all(&backup_dir);
427        let _ = std::fs::remove_dir_all(&source_dir);
428
429        std::fs::create_dir_all(&source_dir).unwrap();
430        std::fs::write(source_dir.join("file1.txt"), "content1").unwrap();
431        std::fs::write(source_dir.join("file2.txt"), "content2").unwrap();
432
433        let mgr = BackupManager::new(backup_dir.clone()).unwrap();
434        let result = mgr.create_backup(&source_dir, "job123");
435
436        assert!(result.is_ok(), "should backup directory: {:?}", result);
437        let backup_path = result.unwrap();
438        assert!(backup_path.exists());
439        assert!(backup_path.join("file1.txt").exists());
440        assert!(backup_path.join("file2.txt").exists());
441
442        let content1 = std::fs::read_to_string(backup_path.join("file1.txt")).unwrap();
443        assert_eq!(content1, "content1");
444
445        std::fs::remove_dir_all(&backup_dir).ok();
446        std::fs::remove_dir_all(&source_dir).ok();
447    }
448
449    #[test]
450    fn test_backup_rejects_symlinks() {
451        use crate::backup::BackupManager;
452
453        let backup_dir = std::env::temp_dir().join("runtimo_backup_symlink_test");
454        let source_dir = std::env::temp_dir().join("runtimo_source_symlink_test");
455        let _ = std::fs::remove_dir_all(&backup_dir);
456        let _ = std::fs::remove_dir_all(&source_dir);
457
458        std::fs::create_dir_all(&source_dir).unwrap();
459        std::fs::write(source_dir.join("file.txt"), "content").unwrap();
460
461        #[cfg(unix)]
462        {
463            use std::os::unix::fs::symlink;
464            let symlink_path = source_dir.join("evil_symlink");
465            if symlink("/etc/passwd", &symlink_path).is_ok() {
466                let mgr = BackupManager::new(backup_dir.clone()).unwrap();
467                let result = mgr.create_backup(&source_dir, "job123");
468                assert!(
469                    result.is_err(),
470                    "should reject directory containing symlinks"
471                );
472                let err = result.err().unwrap().to_string();
473                assert!(
474                    err.contains("symlink"),
475                    "error should mention symlink: {}",
476                    err
477                );
478            }
479        }
480
481        std::fs::remove_dir_all(&backup_dir).ok();
482        std::fs::remove_dir_all(&source_dir).ok();
483    }
484
485    #[test]
486    fn test_restore_directory() {
487        use crate::backup::BackupManager;
488
489        let backup_dir = std::env::temp_dir().join("runtimo_restore_backup_test");
490        let source_dir = std::env::temp_dir().join("runtimo_restore_source_test");
491        let restore_dir = std::env::temp_dir().join("runtimo_restore_target_test");
492        let _ = std::fs::remove_dir_all(&backup_dir);
493        let _ = std::fs::remove_dir_all(&source_dir);
494        let _ = std::fs::remove_dir_all(&restore_dir);
495
496        std::fs::create_dir_all(&source_dir).unwrap();
497        std::fs::write(source_dir.join("file1.txt"), "content1").unwrap();
498        std::fs::write(source_dir.join("file2.txt"), "content2").unwrap();
499
500        let mgr = BackupManager::new(backup_dir.clone()).unwrap();
501        let backup_result = mgr.create_backup(&source_dir, "job123");
502        assert!(backup_result.is_ok());
503        let backup_path = backup_result.unwrap();
504
505        let restore_result = mgr.restore(&backup_path, &restore_dir);
506        assert!(
507            restore_result.is_ok(),
508            "should restore directory: {:?}",
509            restore_result
510        );
511        assert!(restore_dir.join("file1.txt").exists());
512        assert!(restore_dir.join("file2.txt").exists());
513
514        let content1 = std::fs::read_to_string(restore_dir.join("file1.txt")).unwrap();
515        assert_eq!(content1, "content1");
516
517        std::fs::remove_dir_all(&backup_dir).ok();
518        std::fs::remove_dir_all(&source_dir).ok();
519        std::fs::remove_dir_all(&restore_dir).ok();
520    }
521
522    #[test]
523    #[cfg(unix)]
524    fn test_backup_preserves_executable_bit() {
525        use crate::backup::BackupManager;
526        use std::os::unix::fs::PermissionsExt;
527
528        let backup_dir = std::env::temp_dir().join("runtimo_backup_exec_test");
529        let source_dir = std::env::temp_dir().join("runtimo_source_exec_test");
530        let _ = std::fs::remove_dir_all(&backup_dir);
531        let _ = std::fs::remove_dir_all(&source_dir);
532
533        std::fs::create_dir_all(&source_dir).unwrap();
534        let script_path = source_dir.join("script.sh");
535        std::fs::write(&script_path, "#!/bin/bash\necho hello").unwrap();
536        let mut perms = std::fs::metadata(&script_path).unwrap().permissions();
537        perms.set_mode(0o755);
538        std::fs::set_permissions(&script_path, perms).unwrap();
539
540        let mgr = BackupManager::new(backup_dir.clone()).unwrap();
541        let result = mgr.create_backup(&source_dir, "job123");
542        assert!(result.is_ok());
543
544        let backup_path = result.unwrap();
545        let backup_script = backup_path.join("script.sh");
546        let backup_perms = std::fs::metadata(backup_script).unwrap().permissions();
547        assert!(
548            backup_perms.mode() & 0o111 == 0o111,
549            "executable bit should be preserved"
550        );
551
552        std::fs::remove_dir_all(&backup_dir).ok();
553        std::fs::remove_dir_all(&source_dir).ok();
554    }
555
556    #[test]
557    fn test_restore_creates_pre_restore_backup() {
558        let backup_dir = std::env::temp_dir().join("runtimo_pre_restore_test");
559        let source_dir = std::env::temp_dir().join("runtimo_pre_restore_source");
560        let target_dir = std::env::temp_dir().join("runtimo_pre_restore_target");
561        let _ = std::fs::remove_dir_all(&backup_dir);
562        let _ = std::fs::remove_dir_all(&source_dir);
563        let _ = std::fs::remove_dir_all(&target_dir);
564
565        std::fs::create_dir_all(&source_dir).unwrap();
566        std::fs::write(source_dir.join("original.txt"), "original").unwrap();
567
568        std::fs::create_dir_all(&target_dir).unwrap();
569        std::fs::write(target_dir.join("original.txt"), "newer_data").unwrap();
570
571        let mgr = BackupManager::new(backup_dir.clone()).unwrap();
572        let backup_path = mgr.create_backup(&source_dir, "job123").unwrap();
573
574        let restore_result = mgr.restore(&backup_path, &target_dir);
575        assert!(restore_result.is_ok());
576
577        let content = std::fs::read_to_string(target_dir.join("original.txt")).unwrap();
578        assert_eq!(content, "original");
579
580        let pre_restore_path = target_dir
581            .parent()
582            .unwrap()
583            .join(".runtimo_pre_restore")
584            .join("runtimo_pre_restore_target");
585        assert!(pre_restore_path.exists(), "pre-restore backup should exist");
586        let pre_restore_content =
587            std::fs::read_to_string(pre_restore_path.join("original.txt")).unwrap();
588        assert_eq!(
589            pre_restore_content, "newer_data",
590            "pre-restore should have the newer data"
591        );
592
593        std::fs::remove_dir_all(&backup_dir).ok();
594        std::fs::remove_dir_all(&source_dir).ok();
595        std::fs::remove_dir_all(&target_dir).ok();
596        let _ = std::fs::remove_dir_all(target_dir.parent().unwrap().join(".runtimo_pre_restore"));
597    }
598
599    #[test]
600    fn test_create_backup_size_limit_constant() {
601        assert_eq!(
602            MAX_BACKUP_SIZE,
603            100 * 1024 * 1024,
604            "MAX_BACKUP_SIZE should be 100MB"
605        );
606
607        let backup_dir = std::env::temp_dir().join("runtimo_size_limit_test");
608        let source_file = std::env::temp_dir().join("runtimo_size_limit_source");
609        let _ = std::fs::remove_dir_all(&backup_dir);
610        let _ = std::fs::remove_file(&source_file);
611
612        let mgr = BackupManager::new(backup_dir.clone()).unwrap();
613        std::fs::write(&source_file, "small content").unwrap();
614        let result = mgr.create_backup(&source_file, "job123");
615        assert!(result.is_ok(), "small file should succeed");
616
617        std::fs::remove_dir_all(&backup_dir).ok();
618        std::fs::remove_file(&source_file).ok();
619    }
620
621    #[test]
622    fn test_cleanup_skips_symlinks() {
623        let backup_dir = std::env::temp_dir().join("runtimo_cleanup_symlink_test");
624        let real_target = std::env::temp_dir().join("runtimo_cleanup_real_target");
625        let _ = std::fs::remove_dir_all(&backup_dir);
626        let _ = std::fs::remove_dir_all(&real_target);
627
628        std::fs::create_dir_all(&backup_dir).unwrap();
629        std::fs::create_dir_all(&real_target).unwrap();
630        std::fs::write(real_target.join("important.txt"), "do not delete").unwrap();
631
632        #[cfg(unix)]
633        {
634            use std::os::unix::fs::symlink;
635            let symlink_path = backup_dir.join("evil_link");
636            if symlink(&real_target, &symlink_path).is_ok() {
637                let mgr = BackupManager::new(backup_dir.clone()).unwrap();
638                let result = mgr.cleanup(0);
639                assert!(result.is_ok(), "cleanup should succeed even with symlinks");
640
641                assert!(
642                    real_target.join("important.txt").exists(),
643                    "symlink target should not be deleted"
644                );
645
646                std::fs::remove_file(&symlink_path).ok();
647            }
648        }
649
650        std::fs::remove_dir_all(&backup_dir).ok();
651        std::fs::remove_dir_all(&real_target).ok();
652    }
653
654    #[test]
655    fn test_backup_integrity_verification() {
656        let backup_dir = std::env::temp_dir().join("runtimo_integrity_test");
657        let source_file = std::env::temp_dir().join("runtimo_integrity_source");
658        let _ = std::fs::remove_dir_all(&backup_dir);
659        let _ = std::fs::remove_file(&source_file);
660
661        std::fs::write(&source_file, "integrity test content").unwrap();
662
663        let mgr = BackupManager::new(backup_dir.clone()).unwrap();
664        let backup_path = mgr.create_backup(&source_file, "job123").unwrap();
665
666        let src_meta = std::fs::symlink_metadata(&source_file).unwrap();
667        let bak_meta = std::fs::symlink_metadata(&backup_path).unwrap();
668        assert_eq!(
669            src_meta.len(),
670            bak_meta.len(),
671            "backup size should match source"
672        );
673
674        std::fs::remove_dir_all(&backup_dir).ok();
675        std::fs::remove_file(&source_file).ok();
676    }
677}