diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a46182..f332810 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ Alle nennenswerten Änderungen an diesem Projekt werden in dieser Datei dokument Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/) und dieses Projekt folgt den Richtlinien von [Semantic Versioning](https://semver.org/lang/de/). +## [0.5.0] - 2026-09-16 + +### Added +- **`sanctum sync` - Native rsync-ähnliche Synchronisation**: + - Bidirektionale Synchronisation (`Push`: Host -> Tresor, `Pull`: Tresor -> Host) direkt auf SQLite- und VFS-Ebene unter vollständiger Umgehung des WebDAV-Layers. + - **Beseitigung des 4-GB-Dateilimits**: Löst das fundamentale Problem des Windows WebClient-Dienstes (`mrxdav.sys`), welcher systembedingt auf 32-Bit Dateigrößen (~4 GB - 1 Byte / Fehler `0x800700DF`) beschränkt ist. Sanctum streamt nun Dateien beliebiger Größe (5 GB, 50 GB, 500 GB+) in 1 MB Chunks mit AES-256-GCM direkt in/aus den Tresor. + - **Dry-Run-Simulation (`--dry-run` / `-n`)**: Ermöglicht die risikolose Vorschau aller auszuführenden Aktionen (Hinzufügen, Aktualisieren, Löschen, Überspringen) samt Datenmengen-Berechnung, ohne tatsächliche Änderungen am Ziel durchzuführen. + - **Spiegelung & Verwaiste Dateien bereinigen (`--delete`)**: Unterstützt das automatische Löschen von Dateien im Ziel, die in der Quelle nicht mehr existieren, für exakte Ordnerspiegelungen. + - **Inhaltsbasierte Hash-Prüfung (`--checksum` / `-c`)**: Optionaler byteweiser SHA-256 Inhaltsvergleich anstelle des schnellen Größen-/mtime-Prüfverfahrens. + - **Ausschlussmuster (`--exclude `)**: Unterstützung für flexible Glob-Muster zum Ignorieren temporärer Dateien, Downloads oder System-Metadaten (z. B. `*.crdownload`, `*.tmp`, `Thumbs.db`). + - **Quiet-Modus (`--quiet` / `-q`)**: Ermöglicht lautlose Ausführung für Skripte und geplante Aufgaben mit knapper Zusammenfassungsstatistik. + - **Metadaten-Konservierung**: Erhaltung und Übertragung präziser Datei-Modifikationszeitstempel (`mtime`) in beide Richtungen. +- **Integrationstests**: + - `tests/sync_test.rs`: Umfassende automatisierte Testabdeckung für Push/Pull, Multi-Chunk Großdateien, Fast-Delta-Check, Dry-Run-Invarianz, `--delete` und `--exclude`. + ## [0.4.1] - 2026-09-14 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 93be829..04e5ba6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1377,7 +1377,7 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "sanctum" -version = "0.4.1" +version = "0.5.0" dependencies = [ "aes-gcm", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index bc009b4..dcd68cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sanctum" -version = "0.4.1" +version = "0.5.0" edition = "2021" authors = ["Harald Pansi ", "Sanctum Engineering Team"] description = "Verschlüsselter Ein-Datei-Container unter Windows im reinen Userland via WebDAV" diff --git a/src/lib.rs b/src/lib.rs index ee5ea7b..52732cc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod mount; pub mod platform; pub mod recovery; pub mod storage; +pub mod sync; pub mod ui; pub mod verify; pub mod vfs; diff --git a/src/main.rs b/src/main.rs index 4c9452c..6980182 100644 --- a/src/main.rs +++ b/src/main.rs @@ -201,6 +201,48 @@ enum Commands { full: bool, }, + /// Synchronisiert Dateien und Ordner zwischen Host und Container (rsync-artig, ohne Größenbeschränkung) + Sync { + /// Pfad zur .sanctum Containerdatei + #[arg(short, long)] + path: PathBuf, + + /// Quellpfad (lokales Verzeichnis/Datei oder Vault-Pfad bei --pull) + source: String, + + /// Zielpfad (Vault-Pfad wie z. B. '/Downloads' oder lokaler Pfad bei --pull; Standard: '/') + #[arg(default_value = "/")] + target: String, + + /// Kehrt die Richtung um: Lädt Daten aus dem Tresor auf die lokale Festplatte (Pull) + #[arg(long, default_value_t = false)] + pull: bool, + + /// Löscht Dateien im Ziel, die an der Quelle nicht mehr existieren (Spiegelung) + #[arg(long, default_value_t = false)] + delete: 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, + + /// Vergleicht Dateien anhand kryptografischer Hashes statt nur Zeitstempel und Dateigröße + #[arg(short = 'c', long, default_value_t = false)] + checksum: bool, + + /// Schließt Dateien oder Ordner anhand von Mustern aus (z. B. '*.tmp', '*.crdownload') + #[arg(long)] + exclude: Vec, + + /// Unterdrückt Pro-Datei-Ausgaben + #[arg(short, long, default_value_t = false)] + quiet: bool, + + /// Optionaler 24-Wort Notfallschlüssel (umgeht Passwortabfrage) + #[arg(long, num_args = 0..=1, default_missing_value = "")] + recovery_key: Option, + }, + /// Registriert .sanctum Containerdateien im Windows Explorer (Doppelklick & Kontextmenü, keine Adminrechte) Register, @@ -884,6 +926,159 @@ fn handle_verify(container_path: &Path, full: bool) -> Result<()> { Ok(()) } +fn handle_sync( + container_path: &Path, + source: &str, + target: &str, + pull: bool, + delete: bool, + dry_run: bool, + checksum: bool, + exclude: Vec, + quiet: bool, + recovery_key: Option<&str>, +) -> Result<()> { + if !container_path.exists() { + bail!("Containerdatei '{}' existiert nicht.", container_path.display()); + } + + let auth = if let Some(key_arg) = recovery_key { + let phrase_str = if key_arg.trim().is_empty() { + println!("┌─────────────────────────────────────────────────────────────┐"); + println!("│ 🔑 Maskierte interaktive Notfallschlüssel-Eingabe │"); + println!("└─────────────────────────────────────────────────────────────┘"); + println!(); + rpassword::prompt_password("24-Wort Notfall-Wiederherstellungsschlüssel: ") + .context("Fehler beim Einlesen des Schlüssels")? + } else { + key_arg.to_string() + }; + ContainerAuth::RecoveryKey(Zeroizing::new(phrase_str)) + } else { + println!("┌─────────────────────────────────────────────────────────────┐"); + println!("│ Sanctum — Ordner- & Dateisynchronisation (Sync) │"); + println!("└─────────────────────────────────────────────────────────────┘"); + println!(" Container: {}", container_path.display()); + println!(); + let pass = rpassword::prompt_password("Master-Passwort: ") + .context("Fehler beim Einlesen des Passworts")?; + ContainerAuth::Password(Zeroizing::new(pass)) + }; + + let db = Database::open(container_path) + .context("Konnte Container-Datenbank nicht öffnen")?; + let meta = db.read_meta().context("Konnte Container-Header nicht lesen")?; + + let (dek, version, vault_id) = match auth { + ContainerAuth::Password(ref password) => { + let keys = meta + .authenticate(password) + .ok_or_else(|| anyhow::anyhow!("Ungültiges Master-Passwort!"))?; + (keys.dek().clone(), meta.version, keys.slot_id()) + } + ContainerAuth::RecoveryKey(ref phrase) => { + let dek = mnemonic_to_dek(phrase) + .context("Ungültiger 24-Wort Notfallschlüssel")?; + let is_hidden = { + let children = db.list_children_in_vault(2, 1, &dek).unwrap_or_default(); + !children.is_empty() + }; + let v_id = if is_hidden { 1 } else { 0 }; + (dek, meta.version, v_id) + } + }; + + // Richtung ermitteln + let direction = if pull || source.starts_with('/') { + sanctum::sync::SyncDirection::Pull + } else { + sanctum::sync::SyncDirection::Push + }; + + let options = sanctum::sync::SyncOptions { + direction, + delete, + dry_run, + checksum, + exclude_patterns: exclude, + quiet, + }; + + if !quiet { + let dir_str = match direction { + sanctum::sync::SyncDirection::Push => "Push (Host -> Container)", + sanctum::sync::SyncDirection::Pull => "Pull (Container -> Host)", + }; + + println!(); + if dry_run { + println!(" {} Ausführung im SIMULATIONS-MODUS (-n / --dry-run)!", ui::yellow("[!]")); + } + println!(" • Richtung: {}", ui::cyan(dir_str)); + println!(" • Quelle: {}", source); + println!(" • Ziel: {}", target); + if delete { + println!(" • Spiegelung: {} (Dateien im Ziel ohne Entsprechung werden gelöscht)", ui::red("--delete aktiv")); + } + if checksum { + println!(" • Modus: Kryptografischer Inhaltsabgleich (--checksum aktiv)"); + } + if !options.exclude_patterns.is_empty() { + println!(" • Filter: {} Ausschlussmuster", options.exclude_patterns.len()); + } + println!(); + println!(" {} Starte Synchronisation ...", ui::dim("[-]")); + println!(); + } + + let stats = sanctum::sync::run_sync( + &db, + vault_id, + &dek, + version, + source, + target, + &options, + )?; + + if !dry_run { + let _ = db.checkpoint(); + } + + if !quiet { + println!(); + if dry_run { + println!("┌─────────────────────────────────────────────────────────────┐"); + println!("│ ℹ Dry-Run Simulation erfolgreich abgeschlossen │"); + println!("└─────────────────────────────────────────────────────────────┘"); + println!(" • Gescannt: {} Dateien/Objekte", stats.files_scanned); + println!(" • Zu übertragen: {} Dateien ({})", stats.files_transferred, ui::format_bytes(stats.bytes_transferred)); + println!(" • Bereits aktuell: {} Dateien (übersprungen)", stats.files_skipped); + if delete { + println!(" • Zu löschen: {} Dateien/Ordner", stats.files_deleted); + } + println!(" • Hinweis: {} Es wurden keine Änderungen vorgenommen.", ui::yellow("✔")); + } else { + println!("┌─────────────────────────────────────────────────────────────┐"); + println!("│ ✔ Synchronisation erfolgreich abgeschlossen │"); + println!("└─────────────────────────────────────────────────────────────┘"); + println!(" • Gescannt: {} Dateien/Objekte", stats.files_scanned); + println!(" • Übertragen: {} Dateien ({})", stats.files_transferred, ui::format_bytes(stats.bytes_transferred)); + println!(" • Übersprungen: {} Dateien (bereits aktuell)", stats.files_skipped); + if delete { + println!(" • Gelöscht: {} verwaiste Dateien/Ordner", stats.files_deleted); + } + let secs = stats.elapsed.as_secs_f64(); + let mb = (stats.bytes_transferred as f64) / (1024.0 * 1024.0); + let speed = if secs > 0.05 { mb / secs } else { 0.0 }; + println!(" • Dauer: {:.2}s (Durchschnitt: {:.2} MB/s)", secs, speed); + } + println!(); + } + + Ok(()) +} + async fn run() -> Result<()> { let cli = Cli::parse(); @@ -1006,6 +1201,31 @@ async fn run() -> Result<()> { Commands::Verify { path, full } => { handle_verify(&path, full)?; } + Commands::Sync { + path, + source, + target, + pull, + delete, + dry_run, + checksum, + exclude, + quiet, + recovery_key, + } => { + handle_sync( + &path, + &source, + &target, + pull, + delete, + dry_run, + checksum, + exclude, + quiet, + recovery_key.as_deref(), + )?; + } Commands::Register => { println!("┌─────────────────────────────────────────────────────────────┐"); println!("│ Sanctum — Windows Explorer Integration einrichten │"); diff --git a/src/sync.rs b/src/sync.rs new file mode 100644 index 0000000..d3e5a23 --- /dev/null +++ b/src/sync.rs @@ -0,0 +1,850 @@ +use std::collections::HashSet; +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::path::Path; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use anyhow::{bail, Context, Result}; + +use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE}; +use crate::storage::{Database, NodeRecord}; +use crate::ui; +use crate::vfs::is_leak_file; + +/// Synchronisationsrichtung +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SyncDirection { + /// Host-Dateisystem -> Sanctum Container (Upload / Backup) + Push, + /// Sanctum Container -> Host-Dateisystem (Download / Restore) + Pull, +} + +/// Optionen für den Synchronisationslauf +#[derive(Debug, Clone)] +pub struct SyncOptions { + pub direction: SyncDirection, + pub delete: bool, + pub dry_run: bool, + pub checksum: bool, + pub exclude_patterns: Vec, + pub quiet: bool, +} + +impl Default for SyncOptions { + fn default() -> Self { + Self { + direction: SyncDirection::Push, + delete: false, + dry_run: false, + checksum: false, + exclude_patterns: Vec::new(), + quiet: false, + } + } +} + +/// Statistiken über den Synchronisationslauf +#[derive(Debug, Default, Clone)] +pub struct SyncStats { + pub files_scanned: usize, + pub files_transferred: usize, + pub files_skipped: usize, + pub files_deleted: usize, + pub bytes_transferred: u64, + pub elapsed: Duration, +} + +/// Ergebnis einer einzelnen Dateiübertragung +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileTransferResult { + Transferred { size: u64 }, + Skipped { size: u64 }, + DryRunTransferred { size: u64 }, +} + +/// Prüft, ob ein Dateiname oder Pfad einem der Ausschlussmuster entspricht. +pub fn is_excluded(name: &str, rel_path: &str, patterns: &[String]) -> bool { + // 1. Anti-Leak Shield: Typische Explorer- und OS-Metadaten immer ausschließen + if is_leak_file(name) { + return true; + } + + let norm_rel = rel_path.replace('\\', "/"); + let norm_name = name.to_ascii_lowercase(); + + for pat in patterns { + let p = pat.trim(); + if p.is_empty() { + continue; + } + + // Wildcard-Muster: *.ext + if p.starts_with("*.") { + let ext = &p[1..].to_ascii_lowercase(); + if norm_name.ends_with(ext) || norm_rel.to_ascii_lowercase().ends_with(ext) { + return true; + } + } + // Wildcard-Muster: prefix* + else if p.ends_with('*') && !p[..p.len() - 1].contains('*') { + let prefix = p[..p.len() - 1].to_ascii_lowercase(); + if norm_name.starts_with(&prefix) || norm_rel.to_ascii_lowercase().starts_with(&prefix) { + return true; + } + } + // Exakter Name oder Teilpfad + else { + let p_lower = p.to_ascii_lowercase(); + if norm_name == p_lower { + return true; + } + if norm_rel.trim_start_matches('/').to_ascii_lowercase() == p_lower.trim_start_matches('/') { + return true; + } + } + } + + false +} + +/// Liest bis zu `buf.len()` Bytes aus einer Datei (verlässliche Chunk-Lesung). +fn read_chunk_buffer(file: &mut File, buf: &mut [u8]) -> std::io::Result { + let mut total = 0; + while total < buf.len() { + match file.read(&mut buf[total..]) { + Ok(0) => break, + Ok(n) => total += n, + Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(e) => return Err(e), + } + } + Ok(total) +} + +/// Setzt den Modifikationszeitstempel einer lokalen Datei via std::fs::FileTimes. +fn set_local_file_mtime(file: &File, mtime_secs: u64) { + let times = std::fs::FileTimes::new() + .set_modified(UNIX_EPOCH + Duration::from_secs(mtime_secs)); + let _ = file.set_times(times); +} + +/// Stellt sicher, dass ein Verzeichnispfad im Tresor existiert und gibt den Ziel-Knoten zurück. +pub fn ensure_vault_dir_tree( + db: &Database, + vault_id: u32, + dek: &[u8; 32], + vault_dir_path: &str, +) -> Result { + let normalized = vault_dir_path.trim_matches('/'); + let root_id = Database::get_root_node_id_for_vault(vault_id); + let root_node = db + .get_node_by_id_in_vault(root_id, vault_id, dek)? + .context("Wurzelknoten im Container nicht gefunden")?; + + if normalized.is_empty() { + return Ok(root_node); + } + + let segments: Vec<&str> = normalized.split('/').filter(|s| !s.is_empty()).collect(); + let mut current_id = root_id; + let mut current_node = root_node; + + for segment in segments { + let children = db.list_children_in_vault(current_id, vault_id, dek)?; + if let Some(existing) = children.into_iter().find(|c| c.name == segment) { + if !existing.is_dir { + bail!("Pfad-Konflikt: '{}' existiert im Container bereits als Datei", segment); + } + current_id = existing.id; + current_node = existing; + } else { + let new_dir = db.create_node_in_vault(vault_id, current_id, segment, true, dek)?; + current_id = new_dir.id; + current_node = new_dir; + } + } + + Ok(current_node) +} + +/// Synchronisiert eine einzelne Host-Datei in den Tresor (Push). +pub fn sync_single_file_to_vault( + db: &Database, + vault_id: u32, + dek: &[u8; 32], + version: u32, + local_path: &Path, + parent_node_id: i64, + file_name: &str, + checksum: bool, + dry_run: bool, +) -> Result { + let meta = fs::metadata(local_path) + .with_context(|| format!("Konnte Metadaten für '{}' nicht lesen", local_path.display()))?; + let local_size = meta.len(); + let local_mtime = meta + .modified() + .unwrap_or(SystemTime::now()) + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let children = db.list_children_in_vault(parent_node_id, vault_id, dek)?; + let existing_node = children.into_iter().find(|c| c.name == file_name); + + if let Some(ref node) = existing_node { + if node.is_dir { + bail!("Pfad-Konflikt: '{}' existiert im Tresor als Ordner", file_name); + } + + // Fast Check: Wenn Größe und mtime identisch sind, überspringen + if !checksum && node.size == local_size && node.modified_at == local_mtime { + return Ok(FileTransferResult::Skipped { size: local_size }); + } + } + + if dry_run { + return Ok(FileTransferResult::DryRunTransferred { size: local_size }); + } + + let node_id = match existing_node { + Some(n) => n.id, + None => { + let new_node = db.create_node_in_vault(vault_id, parent_node_id, file_name, false, dek)?; + new_node.id + } + }; + + // 1-MB-Chunk Streaming direkt in SQLite + let mut file = File::open(local_path) + .with_context(|| format!("Konnte '{}' nicht zum Lesen öffnen", local_path.display()))?; + let mut buffer = vec![0u8; CHUNK_SIZE]; + let mut chunk_idx = 0u32; + let mut bytes_written = 0u64; + + loop { + let n = read_chunk_buffer(&mut file, &mut buffer)?; + if n == 0 { + break; + } + + let chunk_data = &buffer[..n]; + let (ciphertext, nonce, tag) = encrypt_chunk(dek, node_id, chunk_idx, chunk_data, version)?; + bytes_written += n as u64; + + db.write_chunk_and_update_size( + node_id, + chunk_idx, + &nonce, + &tag, + &ciphertext, + bytes_written, + local_mtime, + )?; + + chunk_idx += 1; + } + + // Bei Überschreiben einer ehemals größeren Datei überzählige alte Chunks entfernen + db.truncate_chunks_after(node_id, chunk_idx.saturating_sub(1))?; + db.update_node_size_and_time(node_id, local_size, local_mtime)?; + + Ok(FileTransferResult::Transferred { size: local_size }) +} + +/// Synchronisiert eine Datei aus dem Tresor auf die lokale Festplatte (Pull). +pub fn sync_single_file_to_host( + db: &Database, + _vault_id: u32, + dek: &[u8; 32], + version: u32, + node: &NodeRecord, + local_path: &Path, + checksum: bool, + dry_run: bool, +) -> Result { + if local_path.exists() { + if let Ok(meta) = fs::metadata(local_path) { + let local_size = meta.len(); + let local_mtime = meta + .modified() + .unwrap_or(SystemTime::now()) + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + if !checksum && local_size == node.size && local_mtime == node.modified_at { + return Ok(FileTransferResult::Skipped { size: node.size }); + } + } + } + + if dry_run { + return Ok(FileTransferResult::DryRunTransferred { size: node.size }); + } + + if let Some(parent) = local_path.parent() { + fs::create_dir_all(parent)?; + } + + let mut out_file = File::create(local_path) + .with_context(|| format!("Konnte Zieldatei '{}' nicht erstellen", local_path.display()))?; + + let total_chunks = if node.size == 0 { + 0 + } else { + ((node.size - 1) / CHUNK_SIZE as u64 + 1) as u32 + }; + + for idx in 0..total_chunks { + if let Some(record) = db.read_chunk(node.id, idx)? { + let plaintext = decrypt_chunk(dek, node.id, idx, &record.ciphertext, &record.nonce, &record.tag, version)?; + out_file.write_all(&plaintext)?; + } else { + bail!("Beschädigte Datei im Tresor: Chunk #{} für Knoten '{}' fehlt", idx, node.name); + } + } + + out_file.flush()?; + set_local_file_mtime(&out_file, node.modified_at); + + Ok(FileTransferResult::Transferred { size: node.size }) +} + +/// Führt die vollständige Synchronisation zwischen Host und Container aus. +pub fn run_sync( + db: &Database, + vault_id: u32, + dek: &[u8; 32], + version: u32, + source_arg: &str, + target_arg: &str, + options: &SyncOptions, +) -> Result { + let start_time = Instant::now(); + let mut stats = SyncStats::default(); + + match options.direction { + SyncDirection::Push => { + sync_push(db, vault_id, dek, version, source_arg, target_arg, options, &mut stats)?; + } + SyncDirection::Pull => { + sync_pull(db, vault_id, dek, version, source_arg, target_arg, options, &mut stats)?; + } + } + + stats.elapsed = start_time.elapsed(); + Ok(stats) +} + +/// Push: Host -> Tresor +fn sync_push( + db: &Database, + vault_id: u32, + dek: &[u8; 32], + version: u32, + source_str: &str, + target_str: &str, + options: &SyncOptions, + stats: &mut SyncStats, +) -> Result<()> { + let local_source = Path::new(source_str); + if !local_source.exists() { + bail!("Lokale Quelle '{}' existiert nicht.", source_str); + } + + let target_vault_dir = if target_str.is_empty() { "/" } else { target_str }; + + if local_source.is_file() { + let file_name = local_source + .file_name() + .context("Ungültiger Dateiname")? + .to_string_lossy(); + + if is_excluded(&file_name, &file_name, &options.exclude_patterns) { + if !options.quiet { + println!(" {} Ausgeschlossen: {}", ui::dim("[-]"), file_name); + } + return Ok(()); + } + + let parent_node = ensure_vault_dir_tree(db, vault_id, dek, target_vault_dir)?; + stats.files_scanned += 1; + + match sync_single_file_to_vault( + db, + vault_id, + dek, + version, + local_source, + parent_node.id, + &file_name, + options.checksum, + options.dry_run, + )? { + FileTransferResult::Transferred { size } => { + stats.files_transferred += 1; + stats.bytes_transferred += size; + if !options.quiet { + println!(" {} Übertragen: {} ({})", ui::green("[+]"), file_name, ui::format_bytes(size)); + } + } + FileTransferResult::DryRunTransferred { size } => { + stats.files_transferred += 1; + stats.bytes_transferred += size; + if !options.quiet { + println!(" {} [DRY-RUN] Würde übertragen: {} ({})", ui::yellow("[~]"), file_name, ui::format_bytes(size)); + } + } + FileTransferResult::Skipped { .. } => { + stats.files_skipped += 1; + if !options.quiet { + println!(" {} Aktuell (übersprungen): {}", ui::dim("[=]"), file_name); + } + } + } + } 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(); + + // Rekursiv alle lokalen Dateien und Ordner erfassen + collect_and_push_dir( + db, + vault_id, + dek, + version, + local_source, + local_source, + parent_node.id, + options, + stats, + &mut local_relative_paths, + )?; + + // Spiegelung mit --delete: Im Tresor verwaiste Dateien entfernen + if options.delete { + delete_orphans_in_vault( + db, + vault_id, + dek, + parent_node.id, + "", + &local_relative_paths, + options.dry_run, + options.quiet, + stats, + )?; + } + } + + Ok(()) +} + +fn collect_and_push_dir( + db: &Database, + vault_id: u32, + dek: &[u8; 32], + version: u32, + base_dir: &Path, + current_dir: &Path, + current_vault_parent_id: i64, + options: &SyncOptions, + stats: &mut SyncStats, + local_relative_paths: &mut HashSet, +) -> Result<()> { + 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('\\', "/"); + + if is_excluded(&file_name, &rel_path, &options.exclude_patterns) { + continue; + } + + local_relative_paths.insert(rel_path.clone()); + + if path.is_dir() { + // Ordner im Tresor anlegen falls nötig + let children = db.list_children_in_vault(current_vault_parent_id, vault_id, dek)?; + let sub_dir_node = match children.into_iter().find(|c| c.name == file_name && c.is_dir) { + Some(n) => n, + None => { + if !options.dry_run { + db.create_node_in_vault(vault_id, current_vault_parent_id, &file_name, true, dek)? + } else { + // Dummy für dry-run + NodeRecord { + id: -1, + parent_id: Some(current_vault_parent_id), + name: file_name.clone(), + is_dir: true, + size: 0, + created_at: 0, + modified_at: 0, + } + } + } + }; + + collect_and_push_dir( + db, + vault_id, + dek, + version, + base_dir, + &path, + sub_dir_node.id, + options, + stats, + local_relative_paths, + )?; + } else if path.is_file() { + stats.files_scanned += 1; + match sync_single_file_to_vault( + db, + vault_id, + dek, + version, + &path, + current_vault_parent_id, + &file_name, + options.checksum, + options.dry_run, + )? { + FileTransferResult::Transferred { size } => { + stats.files_transferred += 1; + stats.bytes_transferred += size; + if !options.quiet { + println!(" {} Übertragen: {} ({})", ui::green("[+]"), rel_path, ui::format_bytes(size)); + } + } + FileTransferResult::DryRunTransferred { size } => { + stats.files_transferred += 1; + stats.bytes_transferred += size; + if !options.quiet { + println!(" {} [DRY-RUN] Würde übertragen: {} ({})", ui::yellow("[~]"), rel_path, ui::format_bytes(size)); + } + } + FileTransferResult::Skipped { .. } => { + stats.files_skipped += 1; + if !options.quiet { + println!(" {} Aktuell (übersprungen): {}", ui::dim("[=]"), rel_path); + } + } + } + } + } + + Ok(()) +} + +fn delete_orphans_in_vault( + db: &Database, + vault_id: u32, + dek: &[u8; 32], + current_vault_id: i64, + prefix_rel: &str, + local_relative_paths: &HashSet, + dry_run: bool, + quiet: bool, + stats: &mut SyncStats, +) -> Result<()> { + let children = db.list_children_in_vault(current_vault_id, vault_id, dek)?; + + for child in children { + let child_rel = if prefix_rel.is_empty() { + child.name.clone() + } else { + format!("{}/{}", prefix_rel, child.name) + }; + + if !local_relative_paths.contains(&child_rel) { + stats.files_deleted += 1; + if dry_run { + if !quiet { + println!(" {} [DRY-RUN] Würde aus Tresor löschen: {}", ui::red("[-]"), child_rel); + } + } else { + db.delete_node(child.id)?; + if !quiet { + println!(" {} Gelöscht aus Tresor: {}", ui::red("[-]"), child_rel); + } + } + } else if child.is_dir { + delete_orphans_in_vault( + db, + vault_id, + dek, + child.id, + &child_rel, + local_relative_paths, + dry_run, + quiet, + stats, + )?; + } + } + + Ok(()) +} + +/// Pull: Tresor -> Host +fn sync_pull( + db: &Database, + vault_id: u32, + dek: &[u8; 32], + version: u32, + source_vault_str: &str, + target_host_str: &str, + options: &SyncOptions, + stats: &mut SyncStats, +) -> Result<()> { + let vault_source_node = db + .resolve_path_in_vault(source_vault_str, vault_id, dek)? + .with_context(|| format!("Quelle '{}' im Tresor nicht gefunden", source_vault_str))?; + + let local_target_dir = Path::new(target_host_str); + + if !vault_source_node.is_dir { + // Einzelne Datei aus Tresor laden + let local_file_path = if local_target_dir.is_dir() { + local_target_dir.join(&vault_source_node.name) + } else { + local_target_dir.to_path_buf() + }; + + stats.files_scanned += 1; + match sync_single_file_to_host( + db, + vault_id, + dek, + version, + &vault_source_node, + &local_file_path, + options.checksum, + options.dry_run, + )? { + FileTransferResult::Transferred { size } => { + stats.files_transferred += 1; + stats.bytes_transferred += size; + if !options.quiet { + println!(" {} Wiederhergestellt: {} ({})", ui::green("[+]"), local_file_path.display(), ui::format_bytes(size)); + } + } + FileTransferResult::DryRunTransferred { size } => { + stats.files_transferred += 1; + stats.bytes_transferred += size; + if !options.quiet { + println!(" {} [DRY-RUN] Würde wiederherstellen: {} ({})", ui::yellow("[~]"), local_file_path.display(), ui::format_bytes(size)); + } + } + FileTransferResult::Skipped { .. } => { + stats.files_skipped += 1; + if !options.quiet { + println!(" {} Aktuell (übersprungen): {}", ui::dim("[=]"), local_file_path.display()); + } + } + } + } else { + // Ganzes Verzeichnis aus Tresor laden + if !options.dry_run { + fs::create_dir_all(local_target_dir)?; + } + + let mut vault_relative_paths = HashSet::new(); + + collect_and_pull_dir( + db, + vault_id, + dek, + version, + vault_source_node.id, + "", + local_target_dir, + options, + stats, + &mut vault_relative_paths, + )?; + + // Spiegelung mit --delete: Lokale verwaiste Dateien entfernen + if options.delete && local_target_dir.exists() { + delete_orphans_on_host( + local_target_dir, + local_target_dir, + &vault_relative_paths, + options.dry_run, + options.quiet, + stats, + )?; + } + } + + Ok(()) +} + +fn collect_and_pull_dir( + db: &Database, + vault_id: u32, + dek: &[u8; 32], + version: u32, + vault_node_id: i64, + rel_prefix: &str, + local_target_base: &Path, + options: &SyncOptions, + stats: &mut SyncStats, + vault_relative_paths: &mut HashSet, +) -> Result<()> { + let children = db.list_children_in_vault(vault_node_id, vault_id, dek)?; + + for child in children { + let child_rel = if rel_prefix.is_empty() { + child.name.clone() + } else { + format!("{}/{}", rel_prefix, child.name) + }; + + if is_excluded(&child.name, &child_rel, &options.exclude_patterns) { + continue; + } + + vault_relative_paths.insert(child_rel.clone()); + let local_child_path = local_target_base.join(&child_rel.replace('/', "\\")); + + if child.is_dir { + if !options.dry_run { + fs::create_dir_all(&local_child_path)?; + } + collect_and_pull_dir( + db, + vault_id, + dek, + version, + child.id, + &child_rel, + local_target_base, + options, + stats, + vault_relative_paths, + )?; + } else { + stats.files_scanned += 1; + match sync_single_file_to_host( + db, + vault_id, + dek, + version, + &child, + &local_child_path, + options.checksum, + options.dry_run, + )? { + FileTransferResult::Transferred { size } => { + stats.files_transferred += 1; + stats.bytes_transferred += size; + if !options.quiet { + println!(" {} Wiederhergestellt: {} ({})", ui::green("[+]"), child_rel, ui::format_bytes(size)); + } + } + FileTransferResult::DryRunTransferred { size } => { + stats.files_transferred += 1; + stats.bytes_transferred += size; + if !options.quiet { + println!(" {} [DRY-RUN] Würde wiederherstellen: {} ({})", ui::yellow("[~]"), child_rel, ui::format_bytes(size)); + } + } + FileTransferResult::Skipped { .. } => { + stats.files_skipped += 1; + if !options.quiet { + println!(" {} Aktuell (übersprungen): {}", ui::dim("[=]"), child_rel); + } + } + } + } + } + + Ok(()) +} + +fn delete_orphans_on_host( + base_dir: &Path, + current_dir: &Path, + vault_relative_paths: &HashSet, + dry_run: bool, + quiet: bool, + stats: &mut SyncStats, +) -> Result<()> { + if !current_dir.exists() { + return Ok(()); + } + + for entry in fs::read_dir(current_dir)? { + let entry = entry?; + let path = entry.path(); + let rel_path = path + .strip_prefix(base_dir) + .unwrap_or(&path) + .to_string_lossy() + .replace('\\', "/"); + + if !vault_relative_paths.contains(&rel_path) { + stats.files_deleted += 1; + if path.is_dir() { + if dry_run { + if !quiet { + println!(" {} [DRY-RUN] Würde lokalen Ordner löschen: {}", ui::red("[-]"), rel_path); + } + } else { + fs::remove_dir_all(&path)?; + if !quiet { + println!(" {} Lokalen Ordner gelöscht: {}", ui::red("[-]"), rel_path); + } + } + } else { + if dry_run { + if !quiet { + println!(" {} [DRY-RUN] Würde lokale Datei löschen: {}", ui::red("[-]"), rel_path); + } + } else { + fs::remove_file(&path)?; + if !quiet { + println!(" {} Lokale Datei gelöscht: {}", ui::red("[-]"), rel_path); + } + } + } + } else if path.is_dir() { + delete_orphans_on_host(base_dir, &path, vault_relative_paths, dry_run, quiet, stats)?; + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_excluded_patterns() { + let patterns = vec![ + "*.tmp".to_string(), + "*.crdownload".to_string(), + "Thumbs.db".to_string(), + "backup_*".to_string(), + "/logs".to_string(), + ]; + + assert!(is_excluded("file.tmp", "sub/file.tmp", &patterns)); + assert!(is_excluded("download.crdownload", "download.crdownload", &patterns)); + assert!(is_excluded("Thumbs.db", "Thumbs.db", &patterns)); + assert!(is_excluded("backup_2026.tar", "backup_2026.tar", &patterns)); + assert!(!is_excluded("important.doc", "sub/important.doc", &patterns)); + assert!(!is_excluded("video.mp4", "video.mp4", &patterns)); + } +} diff --git a/src/ui.rs b/src/ui.rs index 15406d7..c17dc96 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -100,6 +100,27 @@ pub fn magenta(s: &str) -> String { } } +/// Formatiert eine Byte-Anzahl lesbar in B, KB, MB, GB oder TB. +pub fn format_bytes(bytes: u64) -> String { + const KB: f64 = 1024.0; + const MB: f64 = KB * 1024.0; + const GB: f64 = MB * 1024.0; + const TB: f64 = GB * 1024.0; + + let b = bytes as f64; + if b >= TB { + format!("{:.2} TB", b / TB) + } else if b >= GB { + format!("{:.2} GB", b / GB) + } else if b >= MB { + format!("{:.2} MB", b / MB) + } else if b >= KB { + format!("{:.2} KB", b / KB) + } else { + format!("{} B", bytes) + } +} + /// Gibt einen formatierten Fortschrittsschritt aus: ` [1/4] 📦 Schrittbeschreibung...` pub fn step(num: u8, total: u8, icon: &str, msg: &str) { let tag = cyan(&format!("[{}/{}]", num, total)); diff --git a/tests/sync_test.rs b/tests/sync_test.rs new file mode 100644 index 0000000..c5286ef --- /dev/null +++ b/tests/sync_test.rs @@ -0,0 +1,241 @@ +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: 1024, + time_cost: 1, + 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); +}