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 sha2::{Digest, Sha256}; use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE}; use crate::storage::{Database, NodeRecord}; use crate::ui; use crate::vfs::is_leak_file; pub use crate::pathutil::{is_path_traversal, validate_node_name}; /// Berechnet den SHA-256 Hash einer lokalen Datei für verlässliche Checksummen-Vergleiche (S-10). fn calc_local_file_sha256(path: &Path) -> Result { let mut file = File::open(path)?; let mut hasher = Sha256::new(); let mut buffer = [0u8; 64 * 1024]; loop { let n = file.read(&mut buffer)?; if n == 0 { break; } hasher.update(&buffer[..n]); } Ok(hex::encode(hasher.finalize())) } /// Berechnet den SHA-256 Hash der entschlüsselten Nutzdaten eines Vault-Knotens (S-10). fn calc_vault_node_sha256( db: &Database, node: &NodeRecord, dek: &[u8; 32], version: u32, ) -> Result { let mut hasher = Sha256::new(); 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 { let record = db .read_chunk(node.id, idx)? .ok_or_else(|| anyhow::anyhow!("Fehlender Chunk {} für Knoten {}", idx, node.id))?; let decrypted = decrypt_chunk( dek, node.id, idx, &record.ciphertext, &record.nonce, &record.tag, version, record.generation, )?; hasher.update(&decrypted); } Ok(hex::encode(hasher.finalize())) } /// 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 delete_excluded: bool, pub dry_run: bool, pub checksum: bool, pub exclude_patterns: Vec, pub quiet: bool, pub force: bool, pub backup: bool, pub update: bool, } impl Default for SyncOptions { fn default() -> Self { Self { direction: SyncDirection::Push, delete: false, delete_excluded: false, dry_run: false, checksum: false, exclude_patterns: Vec::new(), quiet: false, force: false, backup: false, update: 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_skipped_symlinks: usize, pub files_skipped_invalid: usize, pub files_backed_up: 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 }, } /// Einfacher, effizienter Glob-Matcher ohne externe Abhängigkeiten (unterstützt `*`, `**` und `?`). pub fn glob_match(pattern: &str, text: &str) -> bool { glob_match_slice(pattern.as_bytes(), text.as_bytes()) } fn glob_match_slice(pat: &[u8], text: &[u8]) -> bool { if pat.is_empty() { return text.is_empty(); } // Double-Star '**': Entspricht 0 oder mehr beliebigen Zeichen inklusive Verzeichnistrenner if pat.starts_with(b"**") { let rest_pat = if pat.len() > 2 && pat[2] == b'/' { &pat[3..] } else { &pat[2..] }; if rest_pat.is_empty() { return true; } for i in 0..=text.len() { if glob_match_slice(rest_pat, &text[i..]) { return true; } } return false; } // Single-Star '*': Entspricht 0 oder mehr Zeichen innerhalb desselben Pfadsegments (stoppt an '/') if pat[0] == b'*' { let rest_pat = &pat[1..]; for i in 0..=text.len() { if i > 0 && (text[i - 1] == b'/' || text[i - 1] == b'\\') { break; } if glob_match_slice(rest_pat, &text[i..]) { return true; } } return false; } if text.is_empty() { return false; } // Question mark '?': Einzelzeichen-Platzhalter (außer Pfadtrenner) if pat[0] == b'?' { if text[0] == b'/' || text[0] == b'\\' { return false; } return glob_match_slice(&pat[1..], &text[1..]); } if pat[0] == text[0] { return glob_match_slice(&pat[1..], &text[1..]); } false } /// Prüft, ob ein Dateiname oder Pfad einem der Ausschlussmuster entspricht (S-08). /// /// Unterstützte Musterformate: /// 1. Anti-Leak Shield: OS- und Explorer-Metadaten (z. B. Thumbs.db, .DS_Store) werden stets ausgeschlossen. /// 2. Glob-Muster mit Wildcards: /// - `*.ext`: Schließt alle Dateien mit dieser Endung im gesamten Baum aus (z. B. `*.tmp`, `*.bak`). /// - `prefix*`: Schließt alle Dateien aus, deren Name mit dem Präfix beginnt (z. B. `temp_*`, `backup*`). /// - `*middle*`: Schließt Dateien/Pfade aus, die die Zeichenkette enthalten. /// - `?`: Einzelzeichen-Platzhalter (z. B. `file?.txt`). /// 3. Verzeichnis-Ausschlüsse: /// - `dirname/` oder `dirname`: Schließt das Verzeichnis und alle darin enthaltenen Dateien/Unterordner aus. /// - `/path/to/dir`: Verankert den Ausschluss relativ zum Synchronisations-Wurzelverzeichnis. /// 4. Pfadspezifische Muster: /// - `build/*.bin`: Schließt `.bin`-Dateien im Ordner `build` aus. 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('\\', "/") .trim_start_matches('/') .to_ascii_lowercase(); let norm_name = name.to_ascii_lowercase(); for pat in patterns { let p = pat.trim(); if p.is_empty() { continue; } let p_norm = p.replace('\\', "/").to_ascii_lowercase(); // Verzeichnis-Ausschluss mit nachgestelltem Slash (z. B. "logs/" oder "build/temp/") if p_norm.ends_with('/') { let dir_prefix = p_norm.trim_matches('/'); if norm_rel == dir_prefix || norm_rel.starts_with(&format!("{}/", dir_prefix)) || norm_rel.contains(&format!("/{}/", dir_prefix)) { return true; } } // Muster mit Pfadtrennzeichen (relativ verankert oder spezifischer Unterpfad, z. B. "/logs", "sub/*.txt") if p_norm.contains('/') { let p_clean = p_norm.trim_start_matches('/'); if glob_match(p_clean, &norm_rel) { return true; } // Wenn p_clean ein Verzeichnis ohne Wildcards ist, auch alle Unterpfade erfassen if !p_clean.contains('*') && !p_clean.contains('?') && (norm_rel == p_clean || norm_rel.starts_with(&format!("{}/", p_clean))) { return true; } } else { // Reines Dateinamen- oder Segment-Muster ohne '/' (z. B. "*.tmp", "temp_*", "node_modules") if glob_match(&p_norm, &norm_name) || glob_match(&p_norm, &norm_rel) { return true; } // Wenn p_norm ein exakter Ordnername ist (z. B. "node_modules", ".git"), alle Pfade mit diesem Segment erfassen if !p_norm.contains('*') && !p_norm.contains('?') { for seg in norm_rel.split('/') { if seg == p_norm { 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 { validate_node_name(file_name)?; 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 { db.assert_not_carrier(node.id)?; if node.is_dir { bail!( "Pfad-Konflikt: '{}' existiert im Tresor als Ordner", file_name ); } // Fast Check: Wenn Größe und mtime identisch sind, überspringen (ohne --checksum) if !checksum && node.size == local_size && node.modified_at == local_mtime { return Ok(FileTransferResult::Skipped { size: local_size }); } // Deep Check mit --checksum: Echter kryptografischer SHA-256 Hashvergleich (S-10) if checksum && node.size == local_size { if let (Ok(local_hash), Ok(vault_hash)) = ( calc_local_file_sha256(local_path), calc_vault_node_sha256(db, node, dek, version), ) { if local_hash == vault_hash { 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 gen = db.next_chunk_generation(node_id, chunk_idx)?; let (ciphertext, nonce, tag) = encrypt_chunk(dek, node_id, chunk_idx, chunk_data, version, gen)?; bytes_written += n as u64; db.write_chunk_and_update_size( node_id, chunk_idx, gen, &nonce, &tag, &ciphertext, bytes_written, local_mtime, )?; chunk_idx += 1; } // Bei Überschreiben einer ehemals größeren Datei überzählige alte Chunks entfernen if bytes_written == 0 { // S-05: 0-Byte Datei: Alle Chunks (inkl. Chunk 0) restlos löschen und shreddern db.delete_all_chunks(node_id)?; } else { db.truncate_chunks_after(node_id, chunk_idx.saturating_sub(1))?; } // S-04: Verwende bytes_written statt local_size zur Vermeidung von TOCTOU-Diskrepanzen db.update_node_size_and_time(node_id, bytes_written, local_mtime)?; Ok(FileTransferResult::Transferred { size: bytes_written, }) } /// Erstellt einen sicheren Pfad für Sicherungskopien (.bak) überschriebener Dateien (S-09). pub fn make_backup_path(path: &Path) -> std::path::PathBuf { let mut bak = path.to_path_buf(); let ext = bak .extension() .map(|e| e.to_string_lossy().to_string()) .unwrap_or_default(); let new_ext = if ext.is_empty() { "bak".to_string() } else { format!("{}.bak", ext) }; bak.set_extension(new_ext); if !bak.exists() { return bak; } for i in 1..=1000 { let mut numbered = path.to_path_buf(); let numbered_ext = if ext.is_empty() { format!("bak.{}", i) } else { format!("{}.bak.{}", ext, i) }; numbered.set_extension(numbered_ext); if !numbered.exists() { return numbered; } } bak } /// Synchronisiert eine Datei aus dem Tresor auf die lokale Festplatte mit atomarem Schreiben, /// optionalem .bak-Sicherheits-Backup und Konfliktschutz (S-09, Pull). pub fn sync_single_file_to_host_opts( db: &Database, _vault_id: u32, dek: &[u8; 32], version: u32, node: &NodeRecord, local_path: &Path, options: &SyncOptions, stats: &mut SyncStats, ) -> Result { db.assert_not_carrier(node.id)?; validate_node_name(&node.name)?; let local_exists = local_path.exists(); if local_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(); // Identische Datei: Überspringen if !options.checksum && local_size == node.size && local_mtime == node.modified_at { return Ok(FileTransferResult::Skipped { size: node.size }); } // Deep Check mit --checksum: Echter kryptografischer SHA-256 Hashvergleich (S-10) if options.checksum && local_size == node.size { if let (Ok(local_hash), Ok(vault_hash)) = ( calc_local_file_sha256(local_path), calc_vault_node_sha256(db, node, dek, version), ) { if local_hash == vault_hash { return Ok(FileTransferResult::Skipped { size: node.size }); } } } // S-09: Konfliktschutz (--update) // Wenn die lokale Datei neuer ist als die Version im Tresor, // verweigere das Überschreiben, sofern nicht --force gesetzt ist. if options.update && !options.force && local_mtime > node.modified_at { if !options.quiet { println!( " {} Lokale Datei ist neuer ({}) - übersprungen mit --update: {}", ui::yellow("[!]"), local_mtime, local_path.display() ); } return Ok(FileTransferResult::Skipped { size: node.size }); } } } if options.dry_run { if local_exists && options.backup { stats.files_backed_up += 1; } return Ok(FileTransferResult::DryRunTransferred { size: node.size }); } if let Some(parent) = local_path.parent() { fs::create_dir_all(parent)?; } // S-09: Atomares Schreiben über temporäre Datei im selben Verzeichnis // Verhindert inkonsistente / beschädigte Zieldateien bei Abbruch oder I/O-Fehlern let parent_dir = local_path.parent().unwrap_or_else(|| Path::new(".")); let temp_path = parent_dir.join(format!( ".sanctum_pull_{}_{}.tmp", std::process::id(), rand::random::() )); let write_res = (|| -> Result<()> { let mut out_file = File::create(&temp_path).with_context(|| { format!( "Konnte temporäre Zieldatei '{}' nicht erstellen", temp_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, record.generation, )?; 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(()) })(); if let Err(e) = write_res { let _ = fs::remove_file(&temp_path); return Err(e); } // S-09: Backup-Erstellung bei bestehender Zieldatei (--backup) if local_exists && options.backup { let backup_path = make_backup_path(local_path); fs::rename(local_path, &backup_path).with_context(|| { format!( "Konnte bestehende Zieldatei nicht nach '{}' sichern", backup_path.display() ) })?; stats.files_backed_up += 1; if !options.quiet { println!( " {} Backup erstellt: {}", ui::cyan("[*]"), backup_path.display() ); } } // Atomares Ersetzen der Zieldatei if local_path.exists() { fs::remove_file(local_path).with_context(|| { format!( "Konnte bestehende Zieldatei '{}' vor atomarem Verschieben nicht entfernen", local_path.display() ) })?; } fs::rename(&temp_path, local_path).with_context(|| { format!( "Konnte temporäre Datei '{}' nicht nach '{}' verschieben", temp_path.display(), local_path.display() ) })?; Ok(FileTransferResult::Transferred { size: node.size }) } /// Abwärtskompatibler Wrapper für sync_single_file_to_host. 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 { let mut stats = SyncStats::default(); let options = SyncOptions { checksum, dry_run, ..Default::default() }; sync_single_file_to_host_opts( db, vault_id, dek, version, node, local_path, &options, &mut stats, ) } /// 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(); // S-06: Advisory-Lock gegen parallelen Mount / gleichzeitigen Zugriff if let Some((pid, host, time)) = db.check_advisory_lock()? { if !options.force { bail!( "Container ist gesperrt: Wird aktuell von Prozess {} auf Host '{}' verwendet (seit UNIX-Zeit {}). Verwenden Sie --force zum Überschreiben.", pid, host, time ); } else if !options.quiet { println!( " {} Warnung: Aktiver Advisory-Lock (Prozess {} auf '{}') wird durch --force überschrieben.", ui::yellow("[!]"), pid, host ); } } db.set_active_dek(zeroize::Zeroizing::new(*dek)); 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 }; let sym_meta = fs::symlink_metadata(local_source)?; if sym_meta.file_type().is_symlink() { stats.files_scanned += 1; stats.files_skipped += 1; stats.files_skipped_symlinks += 1; if !options.quiet { println!( " {} Symlink übersprungen: {}", ui::yellow("[!]"), source_str ); } return Ok(()); } 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(); let mut excluded_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, &mut excluded_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, &excluded_relative_paths, &options.exclude_patterns, options.delete_excluded, 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, excluded_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(); if is_path_traversal(&file_name) { bail!( "Path traversal Versuch erkannt in Dateinamen: '{}'", file_name ); } if let Err(e) = validate_node_name(&file_name) { stats.files_scanned += 1; stats.files_skipped += 1; stats.files_skipped_invalid += 1; if !options.quiet { println!( " {} Ungültiger Dateiname übersprungen: {} ({})", ui::yellow("[!]"), file_name, e ); } continue; } 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) { excluded_relative_paths.insert(rel_path.clone()); continue; } // S-03: Symlinks standardmäßig überspringen (Schutz vor Zyklen und Rekursion) let sym_meta = match fs::symlink_metadata(&path) { Ok(m) => m, Err(_) => continue, }; if sym_meta.file_type().is_symlink() { stats.files_scanned += 1; stats.files_skipped += 1; stats.files_skipped_symlinks += 1; if !options.quiet { println!(" {} Symlink übersprungen: {}", ui::yellow("[!]"), rel_path); } 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, excluded_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(()) } pub fn delete_orphans_in_vault( db: &Database, vault_id: u32, dek: &[u8; 32], current_vault_id: i64, prefix_rel: &str, local_relative_paths: &HashSet, excluded_relative_paths: &HashSet, exclude_patterns: &[String], delete_excluded: bool, dry_run: bool, quiet: bool, stats: &mut SyncStats, ) -> Result<()> { let carrier_id = db.find_carrier_node_id()?.unwrap_or(0); let children = db.list_children_in_vault(current_vault_id, vault_id, dek)?; for child in children { // S-03 Carrier Guard: Trägerdatei und übergeordnete Verzeichnisse niemals löschen! if carrier_id > 0 && (child.id == carrier_id || db.is_descendant_of(carrier_id, child.id).unwrap_or(false)) { continue; } let child_rel = if prefix_rel.is_empty() { child.name.clone() } else { format!("{}/{}", prefix_rel, child.name) }; // S-01: Ausgeschlossene Dateien und ganze Teilbäume vor dem Löschen schützen if !delete_excluded { if is_excluded(&child.name, &child_rel, exclude_patterns) { continue; } if excluded_relative_paths .iter() .any(|ex| child_rel == *ex || child_rel.starts_with(&format!("{}/", ex))) { continue; } } 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, excluded_relative_paths, exclude_patterns, delete_excluded, 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_opts( db, vault_id, dek, version, &vault_source_node, &local_file_path, options, stats, )? { 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(); let mut excluded_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, &mut excluded_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, &excluded_relative_paths, &options.exclude_patterns, options.delete_excluded, 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, excluded_relative_paths: &mut HashSet, ) -> Result<()> { let carrier_id = db.find_carrier_node_id()?.unwrap_or(0); let children = db.list_children_in_vault(vault_node_id, vault_id, dek)?; for child in children { // R-02 Carrier Guard: Trägerdatei niemals auf den Host spiegeln / herausziehen if carrier_id > 0 && (child.id == carrier_id || db.is_descendant_of(carrier_id, child.id).unwrap_or(false)) { continue; } if is_path_traversal(&child.name) { bail!( "Path traversal Versuch erkannt in Knotennamen: '{}'", child.name ); } if let Err(e) = validate_node_name(&child.name) { stats.files_scanned += 1; stats.files_skipped += 1; stats.files_skipped_invalid += 1; if !options.quiet { println!( " {} Ungültiger Dateiname im Tresor übersprungen: {} ({})", ui::yellow("[!]"), child.name, e ); } continue; } 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) { excluded_relative_paths.insert(child_rel.clone()); continue; } // S-02 & S-08 Path Traversal Guard: // Plattformunabhängiger Pfadaufbau mit Segment-Validierung let mut local_child_path = local_target_base.to_path_buf(); for seg in child_rel.split('/') { if seg.is_empty() || seg == "." || seg == ".." || seg.contains('\\') { bail!( "Path traversal Versuch erkannt in relativem Pfad: '{}'", child_rel ); } local_child_path.push(seg); } if !local_child_path.starts_with(local_target_base) { bail!( "Path traversal Versuch erkannt: '{}' bricht aus Zielverzeichnis aus", child_rel ); } vault_relative_paths.insert(child_rel.clone()); 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, excluded_relative_paths, )?; } else { stats.files_scanned += 1; match sync_single_file_to_host_opts( db, vault_id, dek, version, &child, &local_child_path, options, stats, )? { 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, excluded_relative_paths: &HashSet, exclude_patterns: &[String], delete_excluded: bool, 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 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('\\', "/"); // S-01: Ausgeschlossene Dateien und ganze Teilbäume vor dem Löschen schützen if !delete_excluded { if is_excluded(&file_name, &rel_path, exclude_patterns) { continue; } if excluded_relative_paths .iter() .any(|ex| rel_path == *ex || rel_path.starts_with(&format!("{}/", ex))) { continue; } } let is_symlink = match fs::symlink_metadata(&path) { Ok(m) => m.file_type().is_symlink(), Err(_) => false, }; if !vault_relative_paths.contains(&rel_path) { stats.files_deleted += 1; if path.is_dir() && !is_symlink { 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() && !is_symlink { delete_orphans_on_host( base_dir, &path, vault_relative_paths, excluded_relative_paths, exclude_patterns, delete_excluded, dry_run, quiet, stats, )?; } } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn test_glob_match() { assert!(glob_match("*.txt", "hello.txt")); assert!(glob_match("*.txt", ".txt")); assert!(!glob_match("*.txt", "hello.doc")); assert!(glob_match("temp_*", "temp_file.dat")); assert!(!glob_match("temp_*", "other_temp_file.dat")); assert!(glob_match("build/*.bin", "build/app.bin")); assert!(!glob_match("build/*.bin", "build/debug/app.bin")); assert!(glob_match("file?.txt", "file1.txt")); assert!(!glob_match("file?.txt", "file12.txt")); assert!(glob_match("*test*", "my_test_case")); } #[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(), "target/".to_string(), "node_modules".to_string(), "build/*.bin".to_string(), "file?.doc".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("app.log", "logs/app.log", &patterns)); assert!(is_excluded("deep.log", "logs/sub/deep.log", &patterns)); assert!(is_excluded("cache.bin", "target/cache.bin", &patterns)); assert!(is_excluded( "package.json", "node_modules/pkg/package.json", &patterns )); assert!(is_excluded( "package.json", "client/node_modules/pkg/package.json", &patterns )); assert!(is_excluded("app.bin", "build/app.bin", &patterns)); assert!(is_excluded("file1.doc", "sub/file1.doc", &patterns)); assert!(!is_excluded("file12.doc", "sub/file12.doc", &patterns)); assert!(!is_excluded( "important.doc", "sub/important.doc", &patterns )); assert!(!is_excluded("video.mp4", "video.mp4", &patterns)); assert!(!is_excluded("app.bin", "other/app.bin", &patterns)); } }