From 533764487ac3e593c6e550492160b6ae86a595b4 Mon Sep 17 00:00:00 2001 From: harald Date: Sat, 19 Sep 2026 00:13:43 +0200 Subject: [PATCH] =?UTF-8?q?fix(vfs):=20V-02=20=E2=80=94=20missing=20chunk?= =?UTF-8?q?=20within=20expected=20file=20size=20causes=20error=20instead?= =?UTF-8?q?=20of=20silent=20truncation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/vfs.rs | 99 ++++++++++++++++++++++++++- tests/sync_carrier_protection_test.rs | 5 ++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/src/vfs.rs b/src/vfs.rs index 650048a..1e3bec0 100644 --- a/src/vfs.rs +++ b/src/vfs.rs @@ -228,6 +228,12 @@ impl SanctumFile { data.zeroize(); } + let expected_chunks = if self.file_size == 0 { + 0 + } else { + self.file_size.div_ceil(CHUNK_SIZE as u64) as u32 + }; + let payload = match self.db.read_chunk(self.node_id, chunk_index).map_err(|e| { error!("Fehler beim Lesen des Chunks #{chunk_index}: {e}"); FsError::GeneralFailure @@ -245,7 +251,16 @@ impl SanctumFile { error!("AEAD-Entschlüsselungsfehler bei Chunk #{chunk_index}: {e}"); FsError::GeneralFailure })?, - None => Vec::new(), + None => { + if chunk_index < expected_chunks { + error!( + "Integritätsfehler: Fehlender Chunk #{} bei Knoten id={} (erwartete Chunks: {})", + chunk_index, self.node_id, expected_chunks + ); + return Err(FsError::GeneralFailure); + } + Vec::new() + } }; self.cached_chunk = Some((chunk_index, payload, false)); @@ -304,6 +319,13 @@ impl DavFile for SanctumFile { let chunk_data = self.ensure_chunk_loaded(chunk_idx)?; if offset_in_chunk >= chunk_data.len() { + if self.cursor < self.file_size { + error!( + "Integritätsfehler: Unerwartetes Chunk-Ende bei Offset {} in Chunk #{} (Dateigröße: {}, Cursor: {})", + offset_in_chunk, chunk_idx, self.file_size, self.cursor + ); + return Err(FsError::GeneralFailure); + } break; } @@ -314,6 +336,13 @@ impl DavFile for SanctumFile { count -= available; if available < to_read { + if self.cursor < self.file_size { + error!( + "Integritätsfehler: Vorzeitiges Chunk-Ende (verfügbar: {}, benötigt: {}, Dateigröße: {}, Cursor: {})", + available, to_read, self.file_size, self.cursor + ); + return Err(FsError::GeneralFailure); + } break; } } @@ -1313,4 +1342,72 @@ mod tests { let chunk = fs.db.read_chunk(node.id, 0).unwrap().unwrap(); assert!(!chunk.ciphertext.is_empty()); } + + #[tokio::test] + async fn test_v02_missing_chunk_returns_error_instead_of_silent_truncation() { + let (fs, _dir) = create_test_fs(true); + let path = DavPath::new("/corrupt_test.bin").unwrap(); + + // 1. Datei mit 3 Chunks anlegen (3 * 1 MB = 3 MB) + let mut opts = OpenOptions::default(); + opts.write = true; + opts.create_new = true; + let mut file = fs.open(&path, opts).await.unwrap(); + + let chunk_data = vec![0xABu8; CHUNK_SIZE]; + for _ in 0..3 { + file.write_bytes(Bytes::copy_from_slice(&chunk_data)) + .await + .unwrap(); + } + file.flush().await.unwrap(); + drop(file); + + let node = fs.resolve_path("/corrupt_test.bin").unwrap().unwrap(); + assert_eq!(node.size, 3 * CHUNK_SIZE as u64); + + // 2. Chunk 1 direkt per SQL aus der chunks-Tabelle löschen (stille Datenkorruption) + { + let conn = fs.db.conn_for_test(); + let deleted = conn + .execute( + "DELETE FROM chunks WHERE node_id = ?1 AND chunk_index = 1", + [node.id], + ) + .unwrap(); + assert_eq!(deleted, 1, "Chunk 1 muss gelöscht worden sein"); + } + + // 3. Datei im VFS zum Lesen öffnen + let mut read_opts = OpenOptions::default(); + read_opts.read = true; + let mut read_file = fs.open(&path, read_opts).await.unwrap(); + + // Erstes Megabyte (Chunk 0) lesen -> muss klappen + let c0 = read_file.read_bytes(CHUNK_SIZE).await.unwrap(); + assert_eq!(c0.len(), CHUNK_SIZE); + + // Zweites Megabyte (fehlender Chunk 1) lesen -> V-02 MUSS FsError::GeneralFailure zurückgeben! + let c1_res = read_file.read_bytes(CHUNK_SIZE).await; + assert!( + matches!(c1_res, Err(FsError::GeneralFailure)), + "Lesen eines gelöschten/fehlenden Chunks muss mit GeneralFailure fehlschlagen, nicht stillschweigend gekürzt werden! Erhalten: {:?}", + c1_res + ); + + // 4. Test jenseits der Dateigröße bei leeren Dateien: kein Regress + let empty_path = DavPath::new("/empty.txt").unwrap(); + let mut empty_opts = OpenOptions::default(); + empty_opts.create_new = true; + empty_opts.write = true; + let mut empty_file = fs.open(&empty_path, empty_opts).await.unwrap(); + empty_file.flush().await.unwrap(); + drop(empty_file); + + let mut read_empty_opts = OpenOptions::default(); + read_empty_opts.read = true; + let mut read_empty = fs.open(&empty_path, read_empty_opts).await.unwrap(); + let bytes = read_empty.read_bytes(100).await.unwrap(); + assert!(bytes.is_empty(), "Leere Datei liefert 0 Bytes ohne Fehler"); + } } diff --git a/tests/sync_carrier_protection_test.rs b/tests/sync_carrier_protection_test.rs index 450bf7f..1ef8809 100644 --- a/tests/sync_carrier_protection_test.rs +++ b/tests/sync_carrier_protection_test.rs @@ -104,6 +104,7 @@ fn test_carrier_protection_in_storage_and_sync() { // 4. delete_orphans_in_vault mit leerem lokalem Pfad-Set: // Der Carrier darf unter keinen Umständen gelöscht werden! let local_paths = HashSet::new(); + let excluded_paths = HashSet::new(); let mut stats = SyncStats::default(); let orphan_res = delete_orphans_in_vault( &db, @@ -112,6 +113,9 @@ fn test_carrier_protection_in_storage_and_sync() { 1, // Root-Knoten "", &local_paths, + &excluded_paths, + &[], + false, // delete_excluded: false false, // kein dry_run true, // quiet &mut stats, @@ -161,6 +165,7 @@ fn test_carrier_protection_in_storage_and_sync() { let sync_options = sanctum::sync::SyncOptions { direction: sanctum::sync::SyncDirection::Pull, delete: false, + delete_excluded: false, dry_run: false, checksum: false, exclude_patterns: Vec::new(),