fix(vfs): V-02 — missing chunk within expected file size causes error instead of silent truncation
This commit is contained in:
+98
-1
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user