fix(sync): S-01 — excluded paths no longer deleted by --delete
This commit is contained in:
@@ -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<String>,
|
||||
@@ -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,
|
||||
|
||||
+65
-1
@@ -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<String>,
|
||||
@@ -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<String>,
|
||||
excluded_relative_paths: &mut HashSet<String>,
|
||||
) -> 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<String>,
|
||||
excluded_relative_paths: &HashSet<String>,
|
||||
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<String>,
|
||||
excluded_relative_paths: &mut HashSet<String>,
|
||||
) -> 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<String>,
|
||||
excluded_relative_paths: &HashSet<String>,
|
||||
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,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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::<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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user