diff --git a/src/main.rs b/src/main.rs index 1ba6547..2381c08 100644 --- a/src/main.rs +++ b/src/main.rs @@ -257,6 +257,14 @@ enum Commands { #[arg(short = 'f', long, default_value_t = false)] force: bool, + /// Erstellt Sicherungskopien (.bak) überschriebener lokaler Dateien beim Pull (S-09) + #[arg(short = 'b', long, default_value_t = false)] + backup: bool, + + /// Verhindert das Überschreiben neuerer lokaler Zieldateien beim Pull (S-09) + #[arg(short = 'u', long, default_value_t = false)] + update: bool, + /// Optionaler 24-Wort Notfallschlüssel (umgeht Passwortabfrage) #[arg(long, num_args = 0..=1, default_missing_value = "")] recovery_key: Option, @@ -1367,6 +1375,8 @@ fn handle_sync( exclude: Vec, quiet: bool, force: bool, + backup: bool, + update: bool, recovery_key: Option<&str>, ) -> Result<()> { if !container_path.exists() { @@ -1424,6 +1434,8 @@ fn handle_sync( exclude_patterns: exclude, quiet, force, + backup, + update, }; if !quiet { @@ -1499,6 +1511,12 @@ fn handle_sync( stats.files_skipped_invalid ); } + if stats.files_backed_up > 0 { + println!( + " • Backups: {} Sicherheitskopien (.bak)", + stats.files_backed_up + ); + } if delete { println!( " • Zu löschen: {} Dateien/Ordner", @@ -1538,6 +1556,12 @@ fn handle_sync( stats.files_skipped_invalid ); } + if stats.files_backed_up > 0 { + println!( + " • Backups: {} Sicherheitskopien (.bak)", + stats.files_backed_up + ); + } if delete { println!( " • Gelöscht: {} verwaiste Dateien/Ordner", @@ -1705,6 +1729,8 @@ async fn run() -> Result<()> { exclude, quiet, force, + backup, + update, recovery_key, } => { handle_sync( @@ -1719,6 +1745,8 @@ async fn run() -> Result<()> { exclude, quiet, force, + backup, + update, recovery_key.as_deref(), )?; } diff --git a/src/sync.rs b/src/sync.rs index c0b2414..ad9e2a2 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -84,6 +84,8 @@ pub struct SyncOptions { pub exclude_patterns: Vec, pub quiet: bool, pub force: bool, + pub backup: bool, + pub update: bool, } impl Default for SyncOptions { @@ -97,6 +99,8 @@ impl Default for SyncOptions { exclude_patterns: Vec::new(), quiet: false, force: false, + backup: false, + update: false, } } } @@ -109,6 +113,7 @@ pub struct SyncStats { 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, @@ -441,21 +446,54 @@ pub fn sync_single_file_to_vault( }) } -/// Synchronisiert eine Datei aus dem Tresor auf die lokale Festplatte (Pull). -pub fn sync_single_file_to_host( +/// 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, - checksum: bool, - dry_run: bool, + options: &SyncOptions, + stats: &mut SyncStats, ) -> Result { db.assert_not_carrier(node.id)?; validate_node_name(&node.name)?; - if local_path.exists() { + 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 @@ -465,12 +503,13 @@ pub fn sync_single_file_to_host( .unwrap_or_default() .as_secs(); - if !checksum && local_size == node.size && local_mtime == node.modified_at { + // 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 checksum && local_size == node.size { + 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), @@ -480,10 +519,28 @@ pub fn sync_single_file_to_host( } } } + + // 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 dry_run { + if options.dry_run { + if local_exists && options.backup { + stats.files_backed_up += 1; + } return Ok(FileTransferResult::DryRunTransferred { size: node.size }); } @@ -491,47 +548,123 @@ pub fn sync_single_file_to_host( fs::create_dir_all(parent)?; } - let mut out_file = File::create(local_path).with_context(|| { - format!( - "Konnte Zieldatei '{}' nicht erstellen", - local_path.display() - ) - })?; + // 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 total_chunks = if node.size == 0 { - 0 - } else { - ((node.size - 1) / CHUNK_SIZE as u64 + 1) as u32 - }; + let write_res = (|| -> Result<()> { + let mut out_file = File::create(&temp_path).with_context(|| { + format!( + "Konnte temporäre Zieldatei '{}' nicht erstellen", + temp_path.display() + ) + })?; - 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)?; + let total_chunks = if node.size == 0 { + 0 } else { - bail!( - "Beschädigte Datei im Tresor: Chunk #{} für Knoten '{}' fehlt", - idx, - node.name + ((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() ); } } - out_file.flush()?; - set_local_file_mtime(&out_file, node.modified_at); + // 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, @@ -988,15 +1121,15 @@ fn sync_pull( }; stats.files_scanned += 1; - match sync_single_file_to_host( + match sync_single_file_to_host_opts( db, vault_id, dek, version, &vault_source_node, &local_file_path, - options.checksum, - options.dry_run, + options, + stats, )? { FileTransferResult::Transferred { size } => { stats.files_transferred += 1; @@ -1174,15 +1307,15 @@ fn collect_and_pull_dir( )?; } else { stats.files_scanned += 1; - match sync_single_file_to_host( + match sync_single_file_to_host_opts( db, vault_id, dek, version, &child, &local_child_path, - options.checksum, - options.dry_run, + options, + stats, )? { FileTransferResult::Transferred { size } => { stats.files_transferred += 1; diff --git a/tests/sync_carrier_protection_test.rs b/tests/sync_carrier_protection_test.rs index fd571da..98e2489 100644 --- a/tests/sync_carrier_protection_test.rs +++ b/tests/sync_carrier_protection_test.rs @@ -171,6 +171,8 @@ fn test_carrier_protection_in_storage_and_sync() { exclude_patterns: Vec::new(), quiet: true, force: false, + backup: false, + update: false, }; let pull_res = sanctum::sync::run_sync( &db, diff --git a/tests/sync_test.rs b/tests/sync_test.rs index 84b4e31..4e17152 100644 --- a/tests/sync_test.rs +++ b/tests/sync_test.rs @@ -1064,3 +1064,128 @@ fn test_s08_refined_glob_and_directory_exclusions() { 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::())); + 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); +} + +