Files
sanctum/tests/sync_test.rs
T

465 lines
13 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);
}