1use crate::Result;
46use std::path::{Path, PathBuf};
47
48const MAX_BACKUP_SIZE: u64 = 100 * 1024 * 1024;
50
51fn 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
70pub struct BackupManager {
75 backup_dir: PathBuf,
76}
77
78impl BackupManager {
79 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 #[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 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 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 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 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 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}