fix(sync): S-01 — excluded paths no longer deleted by --delete

This commit is contained in:
2026-09-19 00:12:14 +02:00
parent fba7f305e3
commit 374150e5b3
3 changed files with 266 additions and 1 deletions
+8
View File
@@ -226,6 +226,10 @@ enum Commands {
#[arg(long, default_value_t = false)] #[arg(long, default_value_t = false)]
delete: bool, 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 /// Führt eine Simulation aus: Zeigt Änderungen an, ohne Dateien zu schreiben oder zu löschen
#[arg(short = 'n', long, default_value_t = false)] #[arg(short = 'n', long, default_value_t = false)]
dry_run: bool, dry_run: bool,
@@ -1216,6 +1220,7 @@ fn handle_sync(
target: &str, target: &str,
pull: bool, pull: bool,
delete: bool, delete: bool,
delete_excluded: bool,
dry_run: bool, dry_run: bool,
checksum: bool, checksum: bool,
exclude: Vec<String>, exclude: Vec<String>,
@@ -1271,6 +1276,7 @@ fn handle_sync(
let options = sanctum::sync::SyncOptions { let options = sanctum::sync::SyncOptions {
direction, direction,
delete, delete,
delete_excluded,
dry_run, dry_run,
checksum, checksum,
exclude_patterns: exclude, exclude_patterns: exclude,
@@ -1520,6 +1526,7 @@ async fn run() -> Result<()> {
target, target,
pull, pull,
delete, delete,
delete_excluded,
dry_run, dry_run,
checksum, checksum,
exclude, exclude,
@@ -1532,6 +1539,7 @@ async fn run() -> Result<()> {
&target, &target,
pull, pull,
delete, delete,
delete_excluded,
dry_run, dry_run,
checksum, checksum,
exclude, exclude,
+65 -1
View File
@@ -77,6 +77,7 @@ pub enum SyncDirection {
pub struct SyncOptions { pub struct SyncOptions {
pub direction: SyncDirection, pub direction: SyncDirection,
pub delete: bool, pub delete: bool,
pub delete_excluded: bool,
pub dry_run: bool, pub dry_run: bool,
pub checksum: bool, pub checksum: bool,
pub exclude_patterns: Vec<String>, pub exclude_patterns: Vec<String>,
@@ -88,6 +89,7 @@ impl Default for SyncOptions {
Self { Self {
direction: SyncDirection::Push, direction: SyncDirection::Push,
delete: false, delete: false,
delete_excluded: false,
dry_run: false, dry_run: false,
checksum: false, checksum: false,
exclude_patterns: Vec::new(), exclude_patterns: Vec::new(),
@@ -537,6 +539,7 @@ fn sync_push(
} else if local_source.is_dir() { } else if local_source.is_dir() {
let parent_node = ensure_vault_dir_tree(db, vault_id, dek, target_vault_dir)?; let parent_node = ensure_vault_dir_tree(db, vault_id, dek, target_vault_dir)?;
let mut local_relative_paths = HashSet::new(); let mut local_relative_paths = HashSet::new();
let mut excluded_relative_paths = HashSet::new();
// Rekursiv alle lokalen Dateien und Ordner erfassen // Rekursiv alle lokalen Dateien und Ordner erfassen
collect_and_push_dir( collect_and_push_dir(
@@ -550,6 +553,7 @@ fn sync_push(
options, options,
stats, stats,
&mut local_relative_paths, &mut local_relative_paths,
&mut excluded_relative_paths,
)?; )?;
// Spiegelung mit --delete: Im Tresor verwaiste Dateien entfernen // Spiegelung mit --delete: Im Tresor verwaiste Dateien entfernen
@@ -561,6 +565,9 @@ fn sync_push(
parent_node.id, parent_node.id,
"", "",
&local_relative_paths, &local_relative_paths,
&excluded_relative_paths,
&options.exclude_patterns,
options.delete_excluded,
options.dry_run, options.dry_run,
options.quiet, options.quiet,
stats, stats,
@@ -582,6 +589,7 @@ fn collect_and_push_dir(
options: &SyncOptions, options: &SyncOptions,
stats: &mut SyncStats, stats: &mut SyncStats,
local_relative_paths: &mut HashSet<String>, local_relative_paths: &mut HashSet<String>,
excluded_relative_paths: &mut HashSet<String>,
) -> Result<()> { ) -> Result<()> {
for entry in fs::read_dir(current_dir)? { for entry in fs::read_dir(current_dir)? {
let entry = entry?; let entry = entry?;
@@ -596,6 +604,7 @@ fn collect_and_push_dir(
.replace('\\', "/"); .replace('\\', "/");
if is_excluded(&file_name, &rel_path, &options.exclude_patterns) { if is_excluded(&file_name, &rel_path, &options.exclude_patterns) {
excluded_relative_paths.insert(rel_path.clone());
continue; continue;
} }
@@ -644,6 +653,7 @@ fn collect_and_push_dir(
options, options,
stats, stats,
local_relative_paths, local_relative_paths,
excluded_relative_paths,
)?; )?;
} else if path.is_file() { } else if path.is_file() {
stats.files_scanned += 1; stats.files_scanned += 1;
@@ -702,6 +712,9 @@ pub fn delete_orphans_in_vault(
current_vault_id: i64, current_vault_id: i64,
prefix_rel: &str, prefix_rel: &str,
local_relative_paths: &HashSet<String>, local_relative_paths: &HashSet<String>,
excluded_relative_paths: &HashSet<String>,
exclude_patterns: &[String],
delete_excluded: bool,
dry_run: bool, dry_run: bool,
quiet: bool, quiet: bool,
stats: &mut SyncStats, stats: &mut SyncStats,
@@ -724,6 +737,19 @@ pub fn delete_orphans_in_vault(
format!("{}/{}", prefix_rel, child.name) 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) { if !local_relative_paths.contains(&child_rel) {
stats.files_deleted += 1; stats.files_deleted += 1;
if dry_run { if dry_run {
@@ -748,6 +774,9 @@ pub fn delete_orphans_in_vault(
child.id, child.id,
&child_rel, &child_rel,
local_relative_paths, local_relative_paths,
excluded_relative_paths,
exclude_patterns,
delete_excluded,
dry_run, dry_run,
quiet, quiet,
stats, stats,
@@ -836,6 +865,7 @@ fn sync_pull(
} }
let mut vault_relative_paths = HashSet::new(); let mut vault_relative_paths = HashSet::new();
let mut excluded_relative_paths = HashSet::new();
collect_and_pull_dir( collect_and_pull_dir(
db, db,
@@ -848,6 +878,7 @@ fn sync_pull(
options, options,
stats, stats,
&mut vault_relative_paths, &mut vault_relative_paths,
&mut excluded_relative_paths,
)?; )?;
// Spiegelung mit --delete: Lokale verwaiste Dateien entfernen // Spiegelung mit --delete: Lokale verwaiste Dateien entfernen
@@ -856,6 +887,9 @@ fn sync_pull(
local_target_dir, local_target_dir,
local_target_dir, local_target_dir,
&vault_relative_paths, &vault_relative_paths,
&excluded_relative_paths,
&options.exclude_patterns,
options.delete_excluded,
options.dry_run, options.dry_run,
options.quiet, options.quiet,
stats, stats,
@@ -877,6 +911,7 @@ fn collect_and_pull_dir(
options: &SyncOptions, options: &SyncOptions,
stats: &mut SyncStats, stats: &mut SyncStats,
vault_relative_paths: &mut HashSet<String>, vault_relative_paths: &mut HashSet<String>,
excluded_relative_paths: &mut HashSet<String>,
) -> Result<()> { ) -> Result<()> {
let carrier_id = db.find_carrier_node_id()?.unwrap_or(0); let carrier_id = db.find_carrier_node_id()?.unwrap_or(0);
let children = db.list_children_in_vault(vault_node_id, vault_id, dek)?; 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) { if is_excluded(&child.name, &child_rel, &options.exclude_patterns) {
excluded_relative_paths.insert(child_rel.clone());
continue; continue;
} }
@@ -939,6 +975,7 @@ fn collect_and_pull_dir(
options, options,
stats, stats,
vault_relative_paths, vault_relative_paths,
excluded_relative_paths,
)?; )?;
} else { } else {
stats.files_scanned += 1; stats.files_scanned += 1;
@@ -993,6 +1030,9 @@ fn delete_orphans_on_host(
base_dir: &Path, base_dir: &Path,
current_dir: &Path, current_dir: &Path,
vault_relative_paths: &HashSet<String>, vault_relative_paths: &HashSet<String>,
excluded_relative_paths: &HashSet<String>,
exclude_patterns: &[String],
delete_excluded: bool,
dry_run: bool, dry_run: bool,
quiet: bool, quiet: bool,
stats: &mut SyncStats, stats: &mut SyncStats,
@@ -1004,12 +1044,26 @@ fn delete_orphans_on_host(
for entry in fs::read_dir(current_dir)? { for entry in fs::read_dir(current_dir)? {
let entry = entry?; let entry = entry?;
let path = entry.path(); let path = entry.path();
let file_name = entry.file_name().to_string_lossy().to_string();
let rel_path = path let rel_path = path
.strip_prefix(base_dir) .strip_prefix(base_dir)
.unwrap_or(&path) .unwrap_or(&path)
.to_string_lossy() .to_string_lossy()
.replace('\\', "/"); .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) { if !vault_relative_paths.contains(&rel_path) {
stats.files_deleted += 1; stats.files_deleted += 1;
if path.is_dir() { if path.is_dir() {
@@ -1044,7 +1098,17 @@ fn delete_orphans_on_host(
} }
} }
} else if path.is_dir() { } 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,
)?;
} }
} }
+193
View File
@@ -269,3 +269,196 @@ fn test_sync_delete_and_exclude_flags() {
let _ = fs::remove_dir_all(&temp_root); 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::<u64>()));
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::<u64>()));
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);
}