From 374150e5b3b8e17d802131180fd5ecd99190476a Mon Sep 17 00:00:00 2001 From: harald Date: Sat, 19 Sep 2026 00:12:14 +0200 Subject: [PATCH] =?UTF-8?q?fix(sync):=20S-01=20=E2=80=94=20excluded=20path?= =?UTF-8?q?s=20no=20longer=20deleted=20by=20--delete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main.rs | 8 ++ src/sync.rs | 66 +++++++++++++++- tests/sync_test.rs | 193 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 266 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 3389594..61e6d59 100644 --- a/src/main.rs +++ b/src/main.rs @@ -226,6 +226,10 @@ enum Commands { #[arg(long, default_value_t = false)] delete: bool, + /// Löscht auch ausgeschlossene Dateien im Ziel bei aktiver --delete Spiegelung + #[arg(long, default_value_t = false)] + delete_excluded: bool, + /// Führt eine Simulation aus: Zeigt Änderungen an, ohne Dateien zu schreiben oder zu löschen #[arg(short = 'n', long, default_value_t = false)] dry_run: bool, @@ -1216,6 +1220,7 @@ fn handle_sync( target: &str, pull: bool, delete: bool, + delete_excluded: bool, dry_run: bool, checksum: bool, exclude: Vec, @@ -1271,6 +1276,7 @@ fn handle_sync( let options = sanctum::sync::SyncOptions { direction, delete, + delete_excluded, dry_run, checksum, exclude_patterns: exclude, @@ -1520,6 +1526,7 @@ async fn run() -> Result<()> { target, pull, delete, + delete_excluded, dry_run, checksum, exclude, @@ -1532,6 +1539,7 @@ async fn run() -> Result<()> { &target, pull, delete, + delete_excluded, dry_run, checksum, exclude, diff --git a/src/sync.rs b/src/sync.rs index 1be8134..ef239dc 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -77,6 +77,7 @@ pub enum SyncDirection { pub struct SyncOptions { pub direction: SyncDirection, pub delete: bool, + pub delete_excluded: bool, pub dry_run: bool, pub checksum: bool, pub exclude_patterns: Vec, @@ -88,6 +89,7 @@ impl Default for SyncOptions { Self { direction: SyncDirection::Push, delete: false, + delete_excluded: false, dry_run: false, checksum: false, exclude_patterns: Vec::new(), @@ -537,6 +539,7 @@ fn sync_push( } else if local_source.is_dir() { let parent_node = ensure_vault_dir_tree(db, vault_id, dek, target_vault_dir)?; let mut local_relative_paths = HashSet::new(); + let mut excluded_relative_paths = HashSet::new(); // Rekursiv alle lokalen Dateien und Ordner erfassen collect_and_push_dir( @@ -550,6 +553,7 @@ fn sync_push( options, stats, &mut local_relative_paths, + &mut excluded_relative_paths, )?; // Spiegelung mit --delete: Im Tresor verwaiste Dateien entfernen @@ -561,6 +565,9 @@ fn sync_push( parent_node.id, "", &local_relative_paths, + &excluded_relative_paths, + &options.exclude_patterns, + options.delete_excluded, options.dry_run, options.quiet, stats, @@ -582,6 +589,7 @@ fn collect_and_push_dir( options: &SyncOptions, stats: &mut SyncStats, local_relative_paths: &mut HashSet, + excluded_relative_paths: &mut HashSet, ) -> Result<()> { for entry in fs::read_dir(current_dir)? { let entry = entry?; @@ -596,6 +604,7 @@ fn collect_and_push_dir( .replace('\\', "/"); if is_excluded(&file_name, &rel_path, &options.exclude_patterns) { + excluded_relative_paths.insert(rel_path.clone()); continue; } @@ -644,6 +653,7 @@ fn collect_and_push_dir( options, stats, local_relative_paths, + excluded_relative_paths, )?; } else if path.is_file() { stats.files_scanned += 1; @@ -702,6 +712,9 @@ pub fn delete_orphans_in_vault( current_vault_id: i64, prefix_rel: &str, local_relative_paths: &HashSet, + excluded_relative_paths: &HashSet, + exclude_patterns: &[String], + delete_excluded: bool, dry_run: bool, quiet: bool, stats: &mut SyncStats, @@ -724,6 +737,19 @@ pub fn delete_orphans_in_vault( format!("{}/{}", prefix_rel, child.name) }; + // S-01: Ausgeschlossene Dateien und ganze Teilbäume vor dem Löschen schützen + if !delete_excluded { + if is_excluded(&child.name, &child_rel, exclude_patterns) { + continue; + } + if excluded_relative_paths + .iter() + .any(|ex| child_rel == *ex || child_rel.starts_with(&format!("{}/", ex))) + { + continue; + } + } + if !local_relative_paths.contains(&child_rel) { stats.files_deleted += 1; if dry_run { @@ -748,6 +774,9 @@ pub fn delete_orphans_in_vault( child.id, &child_rel, local_relative_paths, + excluded_relative_paths, + exclude_patterns, + delete_excluded, dry_run, quiet, stats, @@ -836,6 +865,7 @@ fn sync_pull( } let mut vault_relative_paths = HashSet::new(); + let mut excluded_relative_paths = HashSet::new(); collect_and_pull_dir( db, @@ -848,6 +878,7 @@ fn sync_pull( options, stats, &mut vault_relative_paths, + &mut excluded_relative_paths, )?; // Spiegelung mit --delete: Lokale verwaiste Dateien entfernen @@ -856,6 +887,9 @@ fn sync_pull( local_target_dir, local_target_dir, &vault_relative_paths, + &excluded_relative_paths, + &options.exclude_patterns, + options.delete_excluded, options.dry_run, options.quiet, stats, @@ -877,6 +911,7 @@ fn collect_and_pull_dir( options: &SyncOptions, stats: &mut SyncStats, vault_relative_paths: &mut HashSet, + excluded_relative_paths: &mut HashSet, ) -> Result<()> { let carrier_id = db.find_carrier_node_id()?.unwrap_or(0); let children = db.list_children_in_vault(vault_node_id, vault_id, dek)?; @@ -899,6 +934,7 @@ fn collect_and_pull_dir( }; if is_excluded(&child.name, &child_rel, &options.exclude_patterns) { + excluded_relative_paths.insert(child_rel.clone()); continue; } @@ -939,6 +975,7 @@ fn collect_and_pull_dir( options, stats, vault_relative_paths, + excluded_relative_paths, )?; } else { stats.files_scanned += 1; @@ -993,6 +1030,9 @@ fn delete_orphans_on_host( base_dir: &Path, current_dir: &Path, vault_relative_paths: &HashSet, + excluded_relative_paths: &HashSet, + exclude_patterns: &[String], + delete_excluded: bool, dry_run: bool, quiet: bool, stats: &mut SyncStats, @@ -1004,12 +1044,26 @@ fn delete_orphans_on_host( for entry in fs::read_dir(current_dir)? { let entry = entry?; let path = entry.path(); + let file_name = entry.file_name().to_string_lossy().to_string(); let rel_path = path .strip_prefix(base_dir) .unwrap_or(&path) .to_string_lossy() .replace('\\', "/"); + // S-01: Ausgeschlossene Dateien und ganze Teilbäume vor dem Löschen schützen + if !delete_excluded { + if is_excluded(&file_name, &rel_path, exclude_patterns) { + continue; + } + if excluded_relative_paths + .iter() + .any(|ex| rel_path == *ex || rel_path.starts_with(&format!("{}/", ex))) + { + continue; + } + } + if !vault_relative_paths.contains(&rel_path) { stats.files_deleted += 1; if path.is_dir() { @@ -1044,7 +1098,17 @@ fn delete_orphans_on_host( } } } else if path.is_dir() { - delete_orphans_on_host(base_dir, &path, vault_relative_paths, dry_run, quiet, stats)?; + delete_orphans_on_host( + base_dir, + &path, + vault_relative_paths, + excluded_relative_paths, + exclude_patterns, + delete_excluded, + dry_run, + quiet, + stats, + )?; } } diff --git a/tests/sync_test.rs b/tests/sync_test.rs index 9ecc892..da51925 100644 --- a/tests/sync_test.rs +++ b/tests/sync_test.rs @@ -269,3 +269,196 @@ fn test_sync_delete_and_exclude_flags() { let _ = fs::remove_dir_all(&temp_root); } + +#[test] +fn test_s01_delete_preserves_excluded_files_and_directories() { + let temp_root = + std::env::temp_dir().join(format!("sanctum_s01_test_{}", rand::random::())); + let container_path = temp_root.join("test_s01.sanctum"); + let source_dir = temp_root.join("source"); + + fs::create_dir_all(&source_dir).unwrap(); + fs::create_dir_all(source_dir.join("excluded_dir")).unwrap(); + fs::write(source_dir.join("normal.txt"), b"Normal file").unwrap(); + fs::write(source_dir.join("document.pdf"), b"Important PDF").unwrap(); + fs::write( + source_dir.join("excluded_dir").join("subfile.txt"), + b"Subfile in excluded dir", + ) + .unwrap(); + + let (db, dek) = create_test_container(&container_path); + + // 1. Initialer Push ALLER Dateien (ohne Exclude) + let mut opts = SyncOptions::default(); + opts.direction = SyncDirection::Push; + opts.quiet = true; + + run_sync( + &db, + 0, + &dek, + FORMAT_VERSION, + source_dir.to_str().unwrap(), + "/Data", + &opts, + ) + .unwrap(); + assert!(db + .resolve_path_in_vault("/Data/document.pdf", 0, &dek) + .unwrap() + .is_some()); + assert!(db + .resolve_path_in_vault("/Data/excluded_dir/subfile.txt", 0, &dek) + .unwrap() + .is_some()); + + // 2. Lokale Kopie von document.pdf und excluded_dir entfernen + fs::remove_file(source_dir.join("document.pdf")).unwrap(); + fs::remove_dir_all(source_dir.join("excluded_dir")).unwrap(); + + // 3. Sync Push mit --delete aber MIT --exclude "*.pdf" und --exclude "excluded_dir*" + opts.delete = true; + opts.delete_excluded = false; + opts.exclude_patterns = vec!["*.pdf".to_string(), "excluded_dir*".to_string()]; + + let stats = run_sync( + &db, + 0, + &dek, + FORMAT_VERSION, + source_dir.to_str().unwrap(), + "/Data", + &opts, + ) + .unwrap(); + assert_eq!( + stats.files_deleted, 0, + "Ausgeschlossene Dateien/Ordner dürfen NICHT gelöscht werden!" + ); + + // PDF und excluded_dir Teilbaum müssen im Tresor überlebt haben! + assert!( + db.resolve_path_in_vault("/Data/document.pdf", 0, &dek) + .unwrap() + .is_some(), + "PDF im Tresor muss überleben" + ); + assert!( + db.resolve_path_in_vault("/Data/excluded_dir/subfile.txt", 0, &dek) + .unwrap() + .is_some(), + "Teilbaum in excluded_dir muss überleben" + ); + + // 4. Jetzt Push mit --delete UND --delete-excluded + opts.delete_excluded = true; + let stats_del = run_sync( + &db, + 0, + &dek, + FORMAT_VERSION, + source_dir.to_str().unwrap(), + "/Data", + &opts, + ) + .unwrap(); + assert!( + stats_del.files_deleted >= 2, + "Mit --delete-excluded müssen die verwaisten ausgeschlossenen Dateien gelöscht werden" + ); + assert!( + db.resolve_path_in_vault("/Data/document.pdf", 0, &dek) + .unwrap() + .is_none(), + "PDF muss mit --delete-excluded gelöscht sein" + ); + assert!( + db.resolve_path_in_vault("/Data/excluded_dir/subfile.txt", 0, &dek) + .unwrap() + .is_none(), + "excluded_dir Inhalt muss gelöscht sein" + ); + + let _ = fs::remove_dir_all(&temp_root); +} + +#[test] +fn test_s01_pull_delete_preserves_local_excluded_and_leak_files() { + let temp_root = + std::env::temp_dir().join(format!("sanctum_s01_pull_{}", rand::random::())); + let container_path = temp_root.join("test_s01_pull.sanctum"); + let restore_dir = temp_root.join("restored"); + + fs::create_dir_all(&restore_dir).unwrap(); + // Lokale Dateien anlegen, die im Vault NICHT existieren: folder.jpg (Leak file) und local_notes.pdf (Ausschluss) + fs::write( + restore_dir.join("folder.jpg"), + b"Album Art or Explorer Cache", + ) + .unwrap(); + fs::write( + restore_dir.join("local_notes.pdf"), + b"Private Local Notes", + ) + .unwrap(); + + let (db, dek) = create_test_container(&container_path); + + // Eine Datei im Vault anlegen + let root_node = db.get_node_by_id_in_vault(1, 0, &dek).unwrap().unwrap(); + let _ = db + .create_node_in_vault(0, root_node.id, "vault_file.txt", false, &dek) + .unwrap(); + + // Pull mit --delete und --exclude "*.pdf" + let mut opts = SyncOptions::default(); + opts.direction = SyncDirection::Pull; + opts.delete = true; + opts.delete_excluded = false; + opts.exclude_patterns = vec!["*.pdf".to_string()]; + opts.quiet = true; + + let stats = run_sync( + &db, + 0, + &dek, + FORMAT_VERSION, + "/", + restore_dir.to_str().unwrap(), + &opts, + ) + .unwrap(); + assert_eq!( + stats.files_deleted, 0, + "folder.jpg und local_notes.pdf dürfen bei Pull mit --delete NICHT gelöscht werden!" + ); + assert!( + restore_dir.join("folder.jpg").exists(), + "folder.jpg muss auf dem Host erhalten bleiben" + ); + assert!( + restore_dir.join("local_notes.pdf").exists(), + "local_notes.pdf muss auf dem Host erhalten bleiben" + ); + + // Pull mit --delete UND --delete-excluded + opts.delete_excluded = true; + run_sync( + &db, + 0, + &dek, + FORMAT_VERSION, + "/", + restore_dir.to_str().unwrap(), + &opts, + ) + .unwrap(); + assert!( + !restore_dir.join("local_notes.pdf").exists(), + "local_notes.pdf muss mit --delete-excluded gelöscht werden" + ); + + let _ = fs::remove_dir_all(&temp_root); +} +