Files
sanctum/tests/sync_test.rs
T

1190 lines
35 KiB
Rust

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::<u64>()));
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::<u64>()));
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::<u64>()));
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::<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);
}
#[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::<u64>()));
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::<u64>()));
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::<u64>()));
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::<u64>()));
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::<u64>()));
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::<u64>()));
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::<u64>()));
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::<u64>()));
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);
}