diff --git a/src/main.rs b/src/main.rs index 19c689b..1ba6547 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1493,6 +1493,12 @@ fn handle_sync( stats.files_skipped_symlinks ); } + if stats.files_skipped_invalid > 0 { + println!( + " • Ungültige Namen: {} übersprungen", + stats.files_skipped_invalid + ); + } if delete { println!( " • Zu löschen: {} Dateien/Ordner", @@ -1526,6 +1532,12 @@ fn handle_sync( stats.files_skipped_symlinks ); } + if stats.files_skipped_invalid > 0 { + println!( + " • Ungültige Namen: {} übersprungen", + stats.files_skipped_invalid + ); + } if delete { println!( " • Gelöscht: {} verwaiste Dateien/Ordner", diff --git a/src/pathutil.rs b/src/pathutil.rs index 7edaab3..0a46998 100644 --- a/src/pathutil.rs +++ b/src/pathutil.rs @@ -1,5 +1,15 @@ use anyhow::{bail, Result}; +/// Prüft, ob ein Name Path-Traversal-Muster oder Pfadtrennzeichen enthält (S-02). +pub fn is_path_traversal(name: &str) -> bool { + name.is_empty() + || name == "." + || name == ".." + || name.contains('/') + || name.contains('\\') + || name.contains('\0') +} + /// Validiert einen Datei- oder Verzeichnisnamen gegen Path-Traversal, Null-Bytes, /// unzulässige Steuerzeichen und Windows-reservierte Gerätenamen (S-08, R-03). pub fn validate_node_name(name: &str) -> Result<()> { diff --git a/src/sync.rs b/src/sync.rs index 3366c32..2eaea8a 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -13,7 +13,7 @@ use crate::storage::{Database, NodeRecord}; use crate::ui; use crate::vfs::is_leak_file; -pub use crate::pathutil::validate_node_name; +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 { @@ -108,6 +108,7 @@ pub struct SyncStats { pub files_transferred: usize, pub files_skipped: usize, pub files_skipped_symlinks: usize, + pub files_skipped_invalid: usize, pub files_deleted: usize, pub bytes_transferred: u64, pub elapsed: Duration, @@ -645,7 +646,26 @@ fn collect_and_push_dir( let entry = entry?; let path = entry.path(); let file_name = entry.file_name().to_string_lossy().to_string(); - validate_node_name(&file_name)?; + 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) @@ -991,7 +1011,27 @@ fn collect_and_pull_dir( continue; } - validate_node_name(&child.name)?; + 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() diff --git a/tests/sync_test.rs b/tests/sync_test.rs index 3daee07..f0a6d81 100644 --- a/tests/sync_test.rs +++ b/tests/sync_test.rs @@ -903,3 +903,85 @@ fn test_s06_advisory_lock_blocks_sync_and_force_overrides() { let _ = fs::remove_dir_all(&temp_root); } + +#[test] +fn test_s07_skip_invalid_windows_filenames_push_and_pull() { + let temp_root = + std::env::temp_dir().join(format!("sanctum_s07_test_{}", rand::random::())); + let container_path = temp_root.join("s07.sanctum"); + let source_dir = temp_root.join("source"); + let restore_dir = temp_root.join("restore"); + + fs::create_dir_all(&source_dir).unwrap(); + fs::create_dir_all(&restore_dir).unwrap(); + + let (db, dek) = create_test_container(&container_path); + + // Erstelle valide Dateien + fs::write(source_dir.join("valid1.txt"), b"Content 1").unwrap(); + fs::write(source_dir.join("valid2.txt"), b"Content 2").unwrap(); + + // Versuche eine Datei mit ungültigem Namen anzulegen + // Unter Windows via extended path r"\\?\" + let extended_aux = format!(r"\\?\{}\aux.txt", source_dir.display()); + let created_aux = fs::write(&extended_aux, b"reserved aux file").is_ok(); + + let mut opts = SyncOptions::default(); + opts.direction = SyncDirection::Push; + opts.quiet = true; + + // 1. Push: Darf bei Vorhandensein von aux.txt nicht abbrechen + let push_stats = run_sync( + &db, + 0, + &dek, + FORMAT_VERSION, + source_dir.to_str().unwrap(), + "/", + &opts, + ) + .expect("Push sync must not abort when encountering invalid filenames"); + + assert_eq!(push_stats.files_transferred, 2); + if created_aux { + assert!(push_stats.files_skipped_invalid >= 1); + } + + // 2. Jetzt fügen wir manuell einen ungültigen Knoten in die Datenbank ein + // (z. B. von Linux oder externem Container importiert: "aux.txt") + { + let conn = rusqlite::Connection::open(&container_path).unwrap(); + let root_id = Database::get_root_node_id_for_vault(0); + let now = 123456789; + conn.execute( + "INSERT INTO nodes (parent_id, name, is_dir, size, created_at, modified_at) + VALUES (?1, ?2, 0, 0, ?3, ?4)", + rusqlite::params![root_id, "aux.txt", now, now], + ) + .unwrap(); + } + + // 3. Pull: Darf bei unzulässigem Knotennamen im Tresor nicht abbrechen + opts.direction = SyncDirection::Pull; + let pull_stats = run_sync( + &db, + 0, + &dek, + FORMAT_VERSION, + "/", + restore_dir.to_str().unwrap(), + &opts, + ) + .expect("Pull sync must not abort when encountering invalid filenames in vault"); + + // Valide Dateien müssen wiederhergestellt worden sein + assert!(restore_dir.join("valid1.txt").exists()); + assert!(restore_dir.join("valid2.txt").exists()); + assert_eq!(pull_stats.files_transferred, 2); + assert_eq!(pull_stats.files_skipped_invalid, 1); + + if created_aux { + let _ = fs::remove_file(&extended_aux); + } + let _ = fs::remove_dir_all(&temp_root); +}