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