use std::fs::{self, File}; use std::io::Write; use std::path::Path; use sanctum::crypto::{ derive_kek, generate_dek, generate_salt, wrap_dek, KdfParams, CHUNK_SIZE, FORMAT_VERSION, }; use sanctum::storage::Database; use sanctum::sync::{run_sync, SyncDirection, SyncOptions}; fn create_test_container(path: &Path) -> (Database, [u8; 32]) { if path.exists() { let _ = fs::remove_file(path); } let db = Database::open(path).expect("Open database"); let salt = generate_salt(); let kdf_params = KdfParams { memory_cost: sanctum::crypto::MIN_MEMORY_COST_KIB, time_cost: sanctum::crypto::MIN_TIME_COST, parallelism: 1, }; let kek = derive_kek("sync_password", &salt, &kdf_params).unwrap(); let dek = generate_dek(); let (wrapped_dek, header_nonce, header_tag) = wrap_dek(&kek, &dek).unwrap(); db.init_schema(&salt, &kdf_params, &wrapped_dek, &header_nonce, &header_tag) .unwrap(); (db, *dek) } #[test] fn test_sync_push_and_pull_basic() { let temp_root = std::env::temp_dir().join(format!("sanctum_sync_test_{}", rand::random::())); let container_path = temp_root.join("test.sanctum"); let source_dir = temp_root.join("source"); let target_dir = temp_root.join("restored"); fs::create_dir_all(&source_dir).unwrap(); fs::create_dir_all(source_dir.join("sub")).unwrap(); // Testdateien anlegen fs::write(source_dir.join("file1.txt"), b"Hello Sanctum Sync!").unwrap(); fs::write( source_dir.join("sub").join("file2.bin"), vec![0x42u8; 100_000], ) .unwrap(); let (db, dek) = create_test_container(&container_path); // 1. Push let mut opts = SyncOptions::default(); opts.direction = SyncDirection::Push; opts.quiet = true; let stats = run_sync( &db, 0, &dek, FORMAT_VERSION, source_dir.to_str().unwrap(), "/Backup", &opts, ) .expect("Sync push"); assert_eq!(stats.files_scanned, 2); assert_eq!(stats.files_transferred, 2); assert_eq!(stats.files_skipped, 0); // 2. Erneuter Push (Fast check / Delta): Muss 0 übertragen, 2 überspringen let stats_delta = run_sync( &db, 0, &dek, FORMAT_VERSION, source_dir.to_str().unwrap(), "/Backup", &opts, ) .expect("Sync push delta"); assert_eq!(stats_delta.files_transferred, 0); assert_eq!(stats_delta.files_skipped, 2); // 3. Dry-Run mit neuer Datei fs::write(source_dir.join("new_file.txt"), b"Brand new file").unwrap(); let mut dry_opts = opts.clone(); dry_opts.dry_run = true; let stats_dry = run_sync( &db, 0, &dek, FORMAT_VERSION, source_dir.to_str().unwrap(), "/Backup", &dry_opts, ) .expect("Sync push dry-run"); assert_eq!(stats_dry.files_transferred, 1); assert_eq!(stats_dry.files_skipped, 2); // Verifizieren, dass new_file.txt im Tresor tatsächlich NICHT existiert let node = db .resolve_path_in_vault("/Backup/new_file.txt", 0, &dek) .unwrap(); assert!(node.is_none()); // 4. Pull let mut pull_opts = SyncOptions::default(); pull_opts.direction = SyncDirection::Pull; pull_opts.quiet = true; let stats_pull = run_sync( &db, 0, &dek, FORMAT_VERSION, "/Backup", target_dir.to_str().unwrap(), &pull_opts, ) .expect("Sync pull"); assert_eq!(stats_pull.files_transferred, 2); // Inhalt vergleichen let c1 = fs::read(target_dir.join("file1.txt")).unwrap(); assert_eq!(c1, b"Hello Sanctum Sync!"); let c2 = fs::read(target_dir.join("sub").join("file2.bin")).unwrap(); assert_eq!(c2, vec![0x42u8; 100_000]); // Aufräumen let _ = fs::remove_dir_all(&temp_root); } #[test] fn test_sync_multi_megabyte_large_file() { let temp_root = std::env::temp_dir().join(format!("sanctum_sync_large_{}", rand::random::())); let container_path = temp_root.join("test_large.sanctum"); let source_dir = temp_root.join("source"); let target_dir = temp_root.join("restored"); fs::create_dir_all(&source_dir).unwrap(); // 3.5 MB große Datei erzeugen (geht über 4 Chunks: 0, 1, 2, 3) let large_file_path = source_dir.join("large_payload.bin"); let mut large_file = File::create(&large_file_path).unwrap(); let chunk_sample = vec![0xA5u8; CHUNK_SIZE]; for _ in 0..3 { large_file.write_all(&chunk_sample).unwrap(); } large_file.write_all(&vec![0x5Au8; 512 * 1024]).unwrap(); // 3.5 MB large_file.flush().unwrap(); drop(large_file); let (db, dek) = create_test_container(&container_path); // Push let mut opts = SyncOptions::default(); opts.direction = SyncDirection::Push; opts.quiet = true; let stats = run_sync( &db, 0, &dek, FORMAT_VERSION, source_dir.to_str().unwrap(), "/LargeTest", &opts, ) .expect("Sync push large"); assert_eq!(stats.files_transferred, 1); assert_eq!( stats.bytes_transferred, 3 * (CHUNK_SIZE as u64) + 512 * 1024 ); // Pull let mut pull_opts = SyncOptions::default(); pull_opts.direction = SyncDirection::Pull; pull_opts.quiet = true; run_sync( &db, 0, &dek, FORMAT_VERSION, "/LargeTest", target_dir.to_str().unwrap(), &pull_opts, ) .expect("Sync pull large"); let restored = fs::read(target_dir.join("large_payload.bin")).unwrap(); let original = fs::read(&large_file_path).unwrap(); assert_eq!(restored.len(), original.len()); assert_eq!(restored, original); let _ = fs::remove_dir_all(&temp_root); } #[test] fn test_sync_delete_and_exclude_flags() { let temp_root = std::env::temp_dir().join(format!("sanctum_sync_flags_{}", rand::random::())); let container_path = temp_root.join("test_flags.sanctum"); let source_dir = temp_root.join("source"); fs::create_dir_all(&source_dir).unwrap(); fs::write(source_dir.join("keep.txt"), b"Keep this").unwrap(); fs::write(source_dir.join("remove_me.txt"), b"Will be deleted later").unwrap(); fs::write(source_dir.join("ignore.tmp"), b"Temporary file").unwrap(); let (db, dek) = create_test_container(&container_path); let mut opts = SyncOptions::default(); opts.direction = SyncDirection::Push; opts.exclude_patterns = vec!["*.tmp".to_string()]; opts.quiet = true; // 1. Initialer Push mit Exclude let stats = run_sync( &db, 0, &dek, FORMAT_VERSION, source_dir.to_str().unwrap(), "/Files", &opts, ) .unwrap(); assert_eq!(stats.files_transferred, 2); // keep.txt und remove_me.txt assert!(db .resolve_path_in_vault("/Files/ignore.tmp", 0, &dek) .unwrap() .is_none()); // 2. Lokale Datei löschen und Sync mit --delete ausführen fs::remove_file(source_dir.join("remove_me.txt")).unwrap(); opts.delete = true; let stats_del = run_sync( &db, 0, &dek, FORMAT_VERSION, source_dir.to_str().unwrap(), "/Files", &opts, ) .unwrap(); assert_eq!(stats_del.files_deleted, 1); assert!(db .resolve_path_in_vault("/Files/remove_me.txt", 0, &dek) .unwrap() .is_none()); assert!(db .resolve_path_in_vault("/Files/keep.txt", 0, &dek) .unwrap() .is_some()); 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); } #[test] fn test_s02_platform_independent_path_construction_and_traversal_rejection() { let temp_root = std::env::temp_dir().join(format!("sanctum_s02_test_{}", rand::random::())); let container_path = temp_root.join("test.sanctum"); let source_dir = temp_root.join("source"); let target_dir = temp_root.join("restored"); fs::create_dir_all(&source_dir).unwrap(); let deep_dir = source_dir.join("level1").join("level2").join("level3"); fs::create_dir_all(&deep_dir).unwrap(); fs::write(deep_dir.join("deep_doc.txt"), b"Deeply nested content").unwrap(); let (db, dek) = create_test_container(&container_path); // 1. Push: Übertrage mehrstufige Verzeichnisstruktur in den Tresor let mut push_opts = SyncOptions::default(); push_opts.direction = SyncDirection::Push; push_opts.quiet = true; run_sync( &db, 0, &dek, FORMAT_VERSION, source_dir.to_str().unwrap(), "/MultiLevel", &push_opts, ) .expect("Sync push multi-level"); // 2. Pull: Prüfe, dass mehrstufige Pfade plattformunabhängig rekonstruiert werden let mut pull_opts = SyncOptions::default(); pull_opts.direction = SyncDirection::Pull; pull_opts.quiet = true; run_sync( &db, 0, &dek, FORMAT_VERSION, "/MultiLevel", target_dir.to_str().unwrap(), &pull_opts, ) .expect("Sync pull multi-level"); let restored_file = target_dir .join("level1") .join("level2") .join("level3") .join("deep_doc.txt"); assert!( restored_file.exists(), "Mehrstufige Datei muss korrekt auf dem Host angelegt worden sein" ); assert_eq!(fs::read(restored_file).unwrap(), b"Deeply nested content"); // 3. Traversal-Abwehr: Bösartiger Knoten mit relativem Ausbruchsversuch // Simuliere manipulierten Knoten im Tresor { let conn = rusqlite::Connection::open(&container_path).unwrap(); // Erstelle Knoten mit manipuliertem Namen '../evil.txt' conn.execute( "INSERT INTO nodes (id, parent_id, name, is_dir, size, created_at, modified_at, is_carrier) VALUES (9999, 1, '../evil.txt', 0, 10, 100, 100, 0)", [], ) .unwrap(); } let malicious_target = temp_root.join("malicious_target"); fs::create_dir_all(&malicious_target).unwrap(); let pull_res = run_sync( &db, 0, &dek, FORMAT_VERSION, "/", malicious_target.to_str().unwrap(), &pull_opts, ); assert!( pull_res.is_err(), "Sync Pull muss bei manipuliertem Traversal-Pfad abbrechen" ); let err_msg = pull_res.unwrap_err().to_string(); assert!( err_msg.contains("Path traversal") || err_msg.contains("Ungültiger Dateiname") || err_msg.contains("unzulässige Trennzeichen"), "Fehlermeldung muss Traversal-Erkennung ausweisen: {}", err_msg ); assert!( !temp_root.join("evil.txt").exists(), "Es darf niemals eine Datei außerhalb des Zielverzeichnisses erzeugt werden" ); // 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); } #[test] fn test_s04_toctou_file_size_uses_bytes_written() { let temp_root = std::env::temp_dir().join(format!("sanctum_s04_test_{}", rand::random::())); let container_path = temp_root.join("test.sanctum"); let file_path = temp_root.join("race_file.bin"); fs::create_dir_all(&temp_root).unwrap(); let initial_data = vec![0xABu8; 12345]; fs::write(&file_path, &initial_data).unwrap(); let (db, dek) = create_test_container(&container_path); // Sync file to vault let res = sanctum::sync::sync_single_file_to_vault( &db, 0, &dek, FORMAT_VERSION, &file_path, 1, "race_file.bin", false, false, ) .expect("Sync single file to vault"); match res { sanctum::sync::FileTransferResult::Transferred { size } => { assert_eq!(size, 12345); } _ => panic!("Expected FileTransferResult::Transferred"), } let node = db .resolve_path_in_vault("/race_file.bin", 0, &dek) .unwrap() .expect("Node must exist"); assert_eq!( node.size, 12345, "Knotengröße muss exakt bytes_written entsprechen" ); let _ = fs::remove_dir_all(&temp_root); } #[test] fn test_s05_zero_byte_file_truncate_removes_all_chunks() { let temp_root = std::env::temp_dir().join(format!("sanctum_s05_test_{}", rand::random::())); let container_path = temp_root.join("test.sanctum"); let file_path = temp_root.join("truncate_file.bin"); fs::create_dir_all(&temp_root).unwrap(); // 1. Zuerst große Datei mit 2.5 Chunks (> 2 MB) anlegen let large_payload = vec![0xEEu8; 2_500_000]; fs::write(&file_path, &large_payload).unwrap(); let (db, dek) = create_test_container(&container_path); // Initialer Push sanctum::sync::sync_single_file_to_vault( &db, 0, &dek, FORMAT_VERSION, &file_path, 1, "truncate_file.bin", false, false, ) .expect("Initial push of large file"); let node = db .resolve_path_in_vault("/truncate_file.bin", 0, &dek) .unwrap() .expect("Node must exist"); assert_eq!(node.size, 2_500_000); // Prüfe, dass Chunks existieren (3 Chunks: 0, 1, 2) { let conn = rusqlite::Connection::open(&container_path).unwrap(); let count: i64 = conn .query_row( "SELECT COUNT(*) FROM chunks WHERE node_id = ?1", rusqlite::params![node.id], |r| r.get(0), ) .unwrap(); assert_eq!(count, 3, "Datei muss 3 Chunks besitzen"); } // 2. Jetzt: Lokale Datei durch 0-Byte Datei ersetzen (Truncate auf 0 Bytes) std::thread::sleep(std::time::Duration::from_millis(1100)); // Timestamp ändern fs::write(&file_path, b"").unwrap(); let res = sanctum::sync::sync_single_file_to_vault( &db, 0, &dek, FORMAT_VERSION, &file_path, 1, "truncate_file.bin", false, false, ) .expect("Push of 0-byte file"); match res { sanctum::sync::FileTransferResult::Transferred { size } => { assert_eq!(size, 0); } _ => panic!("Expected Transferred {{ size: 0 }}"), } // 3. Verifiziere: In SQLite darf KEIN EINZIGER Chunk mehr für diesen Knoten existieren! // (Ohne den S-05 Fix blieb Chunk 0 fälschlicherweise in der Tabelle zurück) { let conn = rusqlite::Connection::open(&container_path).unwrap(); let count: i64 = conn .query_row( "SELECT COUNT(*) FROM chunks WHERE node_id = ?1", rusqlite::params![node.id], |r| r.get(0), ) .unwrap(); assert_eq!( count, 0, "Bei 0-Byte Datei müssen ALLE Chunks (inkl. Chunk 0) gelöscht sein (S-05)" ); } let updated_node = db .resolve_path_in_vault("/truncate_file.bin", 0, &dek) .unwrap() .expect("Node must exist"); assert_eq!(updated_node.size, 0); let _ = fs::remove_dir_all(&temp_root); } #[test] fn test_s06_advisory_lock_blocks_sync_and_force_overrides() { let temp_root = std::env::temp_dir().join(format!("sanctum_s06_test_{}", rand::random::())); let container_path = temp_root.join("test.sanctum"); let source_dir = temp_root.join("source"); fs::create_dir_all(&source_dir).unwrap(); fs::write(source_dir.join("test_lock.txt"), b"Locked content").unwrap(); let (db, dek) = create_test_container(&container_path); // 1. Initial: Kein Lock aktiv assert!(db.check_advisory_lock().unwrap().is_none()); // 2. Lock setzen (simuliert laufenden Mount) db.acquire_advisory_lock(false) .expect("Acquire advisory lock"); let active_lock = db.check_advisory_lock().unwrap(); assert!(active_lock.is_some()); let (pid, _host, _time) = active_lock.unwrap(); assert_eq!(pid, std::process::id()); let mut opts = SyncOptions::default(); opts.direction = SyncDirection::Push; opts.quiet = true; opts.force = false; // 3. Sync ohne --force muss mit Lock-Fehler abbrechen let sync_res = run_sync( &db, 0, &dek, FORMAT_VERSION, source_dir.to_str().unwrap(), "/", &opts, ); assert!( sync_res.is_err(), "Sync ohne --force muss bei aktivem Advisory Lock abbrechen" ); let err_msg = sync_res.unwrap_err().to_string(); assert!( err_msg.contains("Container ist gesperrt"), "Fehlermeldung muss Lock-Hinweis enthalten: {}", err_msg ); // 4. Sync mit --force muss trotz Lock erfolgreich durchlaufen opts.force = true; let sync_force_res = run_sync( &db, 0, &dek, FORMAT_VERSION, source_dir.to_str().unwrap(), "/", &opts, ); assert!( sync_force_res.is_ok(), "Sync mit --force muss trotz aktivem Advisory Lock gelingen" ); // 5. Lock freigeben db.release_advisory_lock().expect("Release advisory lock"); assert!(db.check_advisory_lock().unwrap().is_none()); // 6. Sync ohne --force gelingt jetzt wieder opts.force = false; let sync_unlocked = run_sync( &db, 0, &dek, FORMAT_VERSION, source_dir.to_str().unwrap(), "/", &opts, ); assert!(sync_unlocked.is_ok()); // 7. Stale Lock Test: Lock mit nicht mehr existierender PID wird automatisch bereinigt { let conn = rusqlite::Connection::open(&container_path).unwrap(); let current_host = std::env::var("COMPUTERNAME") .or_else(|_| std::env::var("HOSTNAME")) .unwrap_or_else(|_| "localhost".to_string()); conn.execute( "UPDATE meta SET lock_pid = 99999999, lock_host = ?1, lock_time = 1000 WHERE slot_id = 0", rusqlite::params![current_host], ) .unwrap(); } // check_advisory_lock erkennt den toten Prozess und räumt den Lock auf assert!( db.check_advisory_lock().unwrap().is_none(), "Stale Lock eines toten Prozesses muss automatisch als None bewertet werden" ); let sync_stale = run_sync( &db, 0, &dek, FORMAT_VERSION, source_dir.to_str().unwrap(), "/", &opts, ); assert!( sync_stale.is_ok(), "Sync muss bei verwaistem Lock eines toten Prozesses ohne --force gelingen" ); let _ = fs::remove_dir_all(&temp_root); } #[test] fn test_s07_skip_invalid_windows_filenames_push_and_pull() { let temp_root = std::env::temp_dir().join(format!("sanctum_s07_test_{}", rand::random::())); let container_path = temp_root.join("s07.sanctum"); let source_dir = temp_root.join("source"); let restore_dir = temp_root.join("restore"); fs::create_dir_all(&source_dir).unwrap(); fs::create_dir_all(&restore_dir).unwrap(); let (db, dek) = create_test_container(&container_path); // Erstelle valide Dateien fs::write(source_dir.join("valid1.txt"), b"Content 1").unwrap(); fs::write(source_dir.join("valid2.txt"), b"Content 2").unwrap(); // Versuche eine Datei mit ungültigem Namen anzulegen // Unter Windows via extended path r"\\?\" let extended_aux = format!(r"\\?\{}\aux.txt", source_dir.display()); let created_aux = fs::write(&extended_aux, b"reserved aux file").is_ok(); let mut opts = SyncOptions::default(); opts.direction = SyncDirection::Push; opts.quiet = true; // 1. Push: Darf bei Vorhandensein von aux.txt nicht abbrechen let push_stats = run_sync( &db, 0, &dek, FORMAT_VERSION, source_dir.to_str().unwrap(), "/", &opts, ) .expect("Push sync must not abort when encountering invalid filenames"); assert_eq!(push_stats.files_transferred, 2); if created_aux { assert!(push_stats.files_skipped_invalid >= 1); } // 2. Jetzt fügen wir manuell einen ungültigen Knoten in die Datenbank ein // (z. B. von Linux oder externem Container importiert: "aux.txt") { let conn = rusqlite::Connection::open(&container_path).unwrap(); let root_id = Database::get_root_node_id_for_vault(0); let now = 123456789; conn.execute( "INSERT INTO nodes (parent_id, name, is_dir, size, created_at, modified_at) VALUES (?1, ?2, 0, 0, ?3, ?4)", rusqlite::params![root_id, "aux.txt", now, now], ) .unwrap(); } // 3. Pull: Darf bei unzulässigem Knotennamen im Tresor nicht abbrechen opts.direction = SyncDirection::Pull; let pull_stats = run_sync( &db, 0, &dek, FORMAT_VERSION, "/", restore_dir.to_str().unwrap(), &opts, ) .expect("Pull sync must not abort when encountering invalid filenames in vault"); // Valide Dateien müssen wiederhergestellt worden sein assert!(restore_dir.join("valid1.txt").exists()); assert!(restore_dir.join("valid2.txt").exists()); assert_eq!(pull_stats.files_transferred, 2); assert_eq!(pull_stats.files_skipped_invalid, 1); if created_aux { let _ = fs::remove_file(&extended_aux); } let _ = fs::remove_dir_all(&temp_root); } #[test] fn test_s08_refined_glob_and_directory_exclusions() { let temp_root = std::env::temp_dir().join(format!("sanctum_s08_test_{}", rand::random::())); let container_path = temp_root.join("s08.sanctum"); let source_dir = temp_root.join("source"); let restore_dir = temp_root.join("restore"); fs::create_dir_all(&source_dir).unwrap(); fs::create_dir_all(source_dir.join("build")).unwrap(); fs::create_dir_all(source_dir.join("logs")).unwrap(); fs::create_dir_all(source_dir.join("node_modules").join("dep")).unwrap(); fs::create_dir_all(&restore_dir).unwrap(); let (db, dek) = create_test_container(&container_path); // Dateien anlegen fs::write(source_dir.join("main.rs"), b"fn main() {}").unwrap(); fs::write(source_dir.join("temp_1.tmp"), b"junk 1").unwrap(); fs::write(source_dir.join("build").join("output.bin"), b"binary").unwrap(); fs::write(source_dir.join("build").join("notes.txt"), b"keep notes").unwrap(); fs::write(source_dir.join("logs").join("sync.log"), b"log data").unwrap(); fs::write( source_dir.join("node_modules").join("dep").join("lib.js"), b"module code", ) .unwrap(); let mut opts = SyncOptions::default(); opts.direction = SyncDirection::Push; opts.quiet = true; opts.exclude_patterns = vec![ "*.tmp".to_string(), "build/*.bin".to_string(), "logs/".to_string(), "node_modules".to_string(), ]; // 1. Push let push_stats = run_sync( &db, 0, &dek, FORMAT_VERSION, source_dir.to_str().unwrap(), "/", &opts, ) .expect("Push sync with refined glob exclusions"); // Übertragen werden dürfen nur: main.rs und build/notes.txt (2 Dateien) // Ausgeschlossen: temp_1.tmp (*.tmp), build/output.bin (build/*.bin), logs/sync.log (logs/), node_modules/dep/lib.js (node_modules) assert_eq!(push_stats.files_transferred, 2); // 2. Pull opts.direction = SyncDirection::Pull; let pull_stats = run_sync( &db, 0, &dek, FORMAT_VERSION, "/", restore_dir.to_str().unwrap(), &opts, ) .expect("Pull sync with refined glob exclusions"); assert_eq!(pull_stats.files_transferred, 2); assert!(restore_dir.join("main.rs").exists()); assert!(restore_dir.join("build").join("notes.txt").exists()); assert!(!restore_dir.join("temp_1.tmp").exists()); assert!(!restore_dir.join("build").join("output.bin").exists()); assert!(!restore_dir.join("logs").join("sync.log").exists()); assert!(!restore_dir.join("node_modules").exists()); let _ = fs::remove_dir_all(&temp_root); } #[test] fn test_s09_pull_backup_and_update_conflict_safety() { let temp_root = std::env::temp_dir().join(format!("sanctum_s09_test_{}", rand::random::())); let container_path = temp_root.join("s09.sanctum"); let source_dir = temp_root.join("source"); let local_dest_dir = temp_root.join("dest"); fs::create_dir_all(&source_dir).unwrap(); fs::create_dir_all(&local_dest_dir).unwrap(); let (db, dek) = create_test_container(&container_path); // Datei in den Tresor laden fs::write(source_dir.join("document.txt"), b"Vault Version 2.0").unwrap(); fs::write(source_dir.join("notes.txt"), b"Older Vault Notes").unwrap(); let mut push_opts = SyncOptions::default(); push_opts.direction = SyncDirection::Push; push_opts.quiet = true; run_sync( &db, 0, &dek, FORMAT_VERSION, source_dir.to_str().unwrap(), "/", &push_opts, ) .unwrap(); // 1. Test Backup-Erstellung (--backup): // Lokale Datei anlegen mit eigenem Inhalt let local_doc = local_dest_dir.join("document.txt"); fs::write(&local_doc, b"Local Version 1.0 (Must be backed up)").unwrap(); let mut pull_opts = SyncOptions::default(); pull_opts.direction = SyncDirection::Pull; pull_opts.quiet = true; pull_opts.backup = true; let pull_stats = run_sync( &db, 0, &dek, FORMAT_VERSION, "/", local_dest_dir.to_str().unwrap(), &pull_opts, ) .unwrap(); assert_eq!(pull_stats.files_backed_up, 1); assert_eq!(fs::read(&local_doc).unwrap(), b"Vault Version 2.0"); let backup_doc = local_dest_dir.join("document.txt.bak"); assert!( backup_doc.exists(), "Backup-Datei .bak muss angelegt worden sein" ); assert_eq!( fs::read(&backup_doc).unwrap(), b"Local Version 1.0 (Must be backed up)" ); // 2. Test Konfliktschutz (--update): // Erzeuge lokale notes.txt mit neuerem Timestamp let local_notes = local_dest_dir.join("notes.txt"); fs::write(&local_notes, b"Newer Local Notes").unwrap(); let future_time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(2_000_000_000); fs::OpenOptions::new() .write(true) .open(&local_notes) .unwrap() .set_times(std::fs::FileTimes::new().set_modified(future_time)) .expect("Set modified time on local_notes"); let mut update_opts = SyncOptions::default(); update_opts.direction = SyncDirection::Pull; update_opts.quiet = true; update_opts.update = true; let update_stats = run_sync( &db, 0, &dek, FORMAT_VERSION, "/", local_dest_dir.to_str().unwrap(), &update_opts, ) .unwrap(); // notes.txt darf NICHT überschrieben worden sein assert_eq!( fs::read(&local_notes).unwrap(), b"Newer Local Notes", "Neuere lokale Datei darf mit --update nicht überschrieben werden" ); assert!(update_stats.files_skipped >= 1); // Mit --force muss sie überschrieben werden update_opts.force = true; let force_stats = run_sync( &db, 0, &dek, FORMAT_VERSION, "/", local_dest_dir.to_str().unwrap(), &update_opts, ) .unwrap(); assert_eq!( fs::read(&local_notes).unwrap(), b"Older Vault Notes", "Mit --force muss die neuere Datei dennoch überschrieben werden" ); assert_eq!(force_stats.files_transferred, 1); let _ = fs::remove_dir_all(&temp_root); }