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); }