From 888c86ca5e99372967b966cefd74eee1f2652440 Mon Sep 17 00:00:00 2001 From: harald Date: Sat, 19 Sep 2026 08:56:43 +0200 Subject: [PATCH] =?UTF-8?q?fix(sync):=20S-03=20=E2=80=94=20symlink=20detec?= =?UTF-8?q?tion=20and=20cycle=20protection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main.rs | 12 +++++++ src/sync.rs | 41 +++++++++++++++++++++-- tests/sync_test.rs | 81 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 9fb8dfe..8d3c171 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1481,6 +1481,12 @@ fn handle_sync( " • Bereits aktuell: {} Dateien (übersprungen)", stats.files_skipped ); + if stats.files_skipped_symlinks > 0 { + println!( + " • Symlinks: {} übersprungen", + stats.files_skipped_symlinks + ); + } if delete { println!( " • Zu löschen: {} Dateien/Ordner", @@ -1508,6 +1514,12 @@ fn handle_sync( " • Übersprungen: {} Dateien (bereits aktuell)", stats.files_skipped ); + if stats.files_skipped_symlinks > 0 { + println!( + " • Symlinks: {} übersprungen", + stats.files_skipped_symlinks + ); + } if delete { println!( " • Gelöscht: {} verwaiste Dateien/Ordner", diff --git a/src/sync.rs b/src/sync.rs index da5d94e..c3fac9f 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -105,6 +105,7 @@ pub struct SyncStats { pub files_scanned: usize, pub files_transferred: usize, pub files_skipped: usize, + pub files_skipped_symlinks: usize, pub files_deleted: usize, pub bytes_transferred: u64, pub elapsed: Duration, @@ -485,6 +486,21 @@ fn sync_push( target_str }; + let sym_meta = fs::symlink_metadata(local_source)?; + if sym_meta.file_type().is_symlink() { + stats.files_scanned += 1; + stats.files_skipped += 1; + stats.files_skipped_symlinks += 1; + if !options.quiet { + println!( + " {} Symlink übersprungen: {}", + ui::yellow("[!]"), + source_str + ); + } + return Ok(()); + } + if local_source.is_file() { let file_name = local_source .file_name() @@ -615,6 +631,22 @@ fn collect_and_push_dir( continue; } + // S-03: Symlinks standardmäßig überspringen (Schutz vor Zyklen und Rekursion) + let sym_meta = match fs::symlink_metadata(&path) { + Ok(m) => m, + Err(_) => continue, + }; + + if sym_meta.file_type().is_symlink() { + stats.files_scanned += 1; + stats.files_skipped += 1; + stats.files_skipped_symlinks += 1; + if !options.quiet { + println!(" {} Symlink übersprungen: {}", ui::yellow("[!]"), rel_path); + } + continue; + } + local_relative_paths.insert(rel_path.clone()); if path.is_dir() { @@ -1071,9 +1103,14 @@ fn delete_orphans_on_host( } } + let is_symlink = match fs::symlink_metadata(&path) { + Ok(m) => m.file_type().is_symlink(), + Err(_) => false, + }; + if !vault_relative_paths.contains(&rel_path) { stats.files_deleted += 1; - if path.is_dir() { + if path.is_dir() && !is_symlink { if dry_run { if !quiet { println!( @@ -1104,7 +1141,7 @@ fn delete_orphans_on_host( } } } - } else if path.is_dir() { + } else if path.is_dir() && !is_symlink { delete_orphans_on_host( base_dir, &path, diff --git a/tests/sync_test.rs b/tests/sync_test.rs index 9dc5c80..5744aba 100644 --- a/tests/sync_test.rs +++ b/tests/sync_test.rs @@ -562,3 +562,84 @@ fn test_s02_platform_independent_path_construction_and_traversal_rejection() { // Aufräumen let _ = fs::remove_dir_all(&temp_root); } + +#[test] +fn test_s03_symlink_skipping_and_cycle_protection() { + let temp_root = + std::env::temp_dir().join(format!("sanctum_s03_test_{}", rand::random::())); + let container_path = temp_root.join("test.sanctum"); + let source_dir = temp_root.join("source"); + let sub_dir = source_dir.join("sub"); + + fs::create_dir_all(&sub_dir).unwrap(); + fs::write(source_dir.join("regular.txt"), b"Regular content").unwrap(); + + // Erzeuge eine rekursive Verknüpfung (Junction unter Windows / Symlink unter Unix) + let loop_path = sub_dir.join("cycle"); + #[cfg(windows)] + { + let status = std::process::Command::new("powershell") + .args([ + "-Command", + &format!( + "New-Item -ItemType Junction -Path '{}' -Target '{}'", + loop_path.display(), + source_dir.display() + ), + ]) + .status(); + if status.is_err() || !status.unwrap().success() { + eprintln!("Junction creation skipped (not supported in current environment)"); + let _ = fs::remove_dir_all(&temp_root); + return; + } + } + #[cfg(unix)] + { + if let Err(e) = std::os::unix::fs::symlink(&source_dir, &loop_path) { + eprintln!("Symlink creation skipped: {e}"); + let _ = fs::remove_dir_all(&temp_root); + return; + } + } + + let (db, dek) = create_test_container(&container_path); + + let mut opts = SyncOptions::default(); + opts.direction = SyncDirection::Push; + opts.quiet = true; + + // Ohne S-03 Fix würde der Sync endlos in die Schleife laufen und mit Stack-Overflow abstürzen. + // Mit Fix wird der Symlink übersprungen und gezählt. + let stats = run_sync( + &db, + 0, + &dek, + FORMAT_VERSION, + source_dir.to_str().unwrap(), + "/SafePush", + &opts, + ) + .expect("Sync push must succeed despite symlink cycle"); + + assert_eq!( + stats.files_transferred, 1, + "Nur reguläre Datei darf übertragen werden" + ); + assert!( + stats.files_skipped_symlinks >= 1, + "Symlink/Junction muss erkannt und in files_skipped_symlinks gezählt werden" + ); + + // Aufräumen: Unter Windows Junction vor dem remove_dir_all entfernen + #[cfg(windows)] + { + let _ = std::process::Command::new("powershell") + .args([ + "-Command", + &format!("[System.IO.Directory]::Delete('{}')", loop_path.display()), + ]) + .status(); + } + let _ = fs::remove_dir_all(&temp_root); +}