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.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
69pub struct BackupManager {
74 backup_dir: PathBuf,
75}
76
77impl BackupManager {
78 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 #[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 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 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 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 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 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}