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();
|
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| {
|
let payload = match self.db.read_chunk(self.node_id, chunk_index).map_err(|e| {
|
||||||
error!("Fehler beim Lesen des Chunks #{chunk_index}: {e}");
|
error!("Fehler beim Lesen des Chunks #{chunk_index}: {e}");
|
||||||
FsError::GeneralFailure
|
FsError::GeneralFailure
|
||||||
@@ -245,7 +251,16 @@ impl SanctumFile {
|
|||||||
error!("AEAD-Entschlüsselungsfehler bei Chunk #{chunk_index}: {e}");
|
error!("AEAD-Entschlüsselungsfehler bei Chunk #{chunk_index}: {e}");
|
||||||
FsError::GeneralFailure
|
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));
|
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)?;
|
let chunk_data = self.ensure_chunk_loaded(chunk_idx)?;
|
||||||
if offset_in_chunk >= chunk_data.len() {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,6 +336,13 @@ impl DavFile for SanctumFile {
|
|||||||
count -= available;
|
count -= available;
|
||||||
|
|
||||||
if available < to_read {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1313,4 +1342,72 @@ mod tests {
|
|||||||
let chunk = fs.db.read_chunk(node.id, 0).unwrap().unwrap();
|
let chunk = fs.db.read_chunk(node.id, 0).unwrap().unwrap();
|
||||||
assert!(!chunk.ciphertext.is_empty());
|
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:
|
// 4. delete_orphans_in_vault mit leerem lokalem Pfad-Set:
|
||||||
// Der Carrier darf unter keinen Umständen gelöscht werden!
|
// Der Carrier darf unter keinen Umständen gelöscht werden!
|
||||||
let local_paths = HashSet::new();
|
let local_paths = HashSet::new();
|
||||||
|
let excluded_paths = HashSet::new();
|
||||||
let mut stats = SyncStats::default();
|
let mut stats = SyncStats::default();
|
||||||
let orphan_res = delete_orphans_in_vault(
|
let orphan_res = delete_orphans_in_vault(
|
||||||
&db,
|
&db,
|
||||||
@@ -112,6 +113,9 @@ fn test_carrier_protection_in_storage_and_sync() {
|
|||||||
1, // Root-Knoten
|
1, // Root-Knoten
|
||||||
"",
|
"",
|
||||||
&local_paths,
|
&local_paths,
|
||||||
|
&excluded_paths,
|
||||||
|
&[],
|
||||||
|
false, // delete_excluded: false
|
||||||
false, // kein dry_run
|
false, // kein dry_run
|
||||||
true, // quiet
|
true, // quiet
|
||||||
&mut stats,
|
&mut stats,
|
||||||
@@ -161,6 +165,7 @@ fn test_carrier_protection_in_storage_and_sync() {
|
|||||||
let sync_options = sanctum::sync::SyncOptions {
|
let sync_options = sanctum::sync::SyncOptions {
|
||||||
direction: sanctum::sync::SyncDirection::Pull,
|
direction: sanctum::sync::SyncDirection::Pull,
|
||||||
delete: false,
|
delete: false,
|
||||||
|
delete_excluded: false,
|
||||||
dry_run: false,
|
dry_run: false,
|
||||||
checksum: false,
|
checksum: false,
|
||||||
exclude_patterns: Vec::new(),
|
exclude_patterns: Vec::new(),
|
||||||
|
|||||||
Reference in New Issue
Block a user