From 9634beb7445febe497e502572502972b0a831b5c Mon Sep 17 00:00:00 2001 From: harald Date: Sat, 19 Sep 2026 08:59:00 +0200 Subject: [PATCH] =?UTF-8?q?fix(sync):=20S-05=20=E2=80=94=20delete=20and=20?= =?UTF-8?q?shred=20all=20chunks=20when=20truncating=20to=200=20bytes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/storage.rs | 44 +++++++++++++++++++++ src/sync.rs | 7 +++- tests/sync_test.rs | 97 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 1 deletion(-) diff --git a/src/storage.rs b/src/storage.rs index 371f0f7..9d4583b 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -1776,6 +1776,50 @@ impl Database { Ok(()) } + /// Löscht und shreddert alle Chunks eines Knotens (z. B. beim Kürzen auf 0 Bytes) (S-05). + pub fn delete_all_chunks(&self, node_id: i64) -> Result<()> { + self.assert_not_carrier(node_id)?; + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + { + let mut stmt = tx + .prepare("SELECT chunk_index, length(ciphertext) FROM chunks WHERE node_id = ?1")?; + let chunks_to_shred: Vec<(u32, usize)> = stmt + .query_map(params![node_id], |row| { + Ok((row.get(0)?, row.get::<_, usize>(1)?)) + })? + .filter_map(|r| r.ok()) + .collect(); + + let mut update_stmt = tx.prepare( + "UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3 WHERE node_id = ?4 AND chunk_index = ?5", + )?; + + for (idx, len) in chunks_to_shred { + let mut dummy_nonce = [0u8; 12]; + let mut dummy_tag = [0u8; 16]; + let mut dummy_payload = vec![0u8; len]; + OsRng.fill_bytes(&mut dummy_nonce); + OsRng.fill_bytes(&mut dummy_tag); + OsRng.fill_bytes(&mut dummy_payload); + + let _ = update_stmt.execute(params![ + dummy_nonce.as_slice(), + dummy_tag.as_slice(), + dummy_payload.as_slice(), + node_id, + idx + ]); + } + } + + tx.execute("DELETE FROM chunks WHERE node_id = ?1", params![node_id])?; + tx.commit()?; + drop(conn); + let _ = self.update_metadata_mac(); + Ok(()) + } + /// Erzeugt die deterministische kanonische Byterepräsentation aller Knoten für den Metadaten-MAC (K-01). pub fn canonical_nodes_bytes(&self) -> Result> { self.canonical_nodes_bytes_for_vault(0) diff --git a/src/sync.rs b/src/sync.rs index ef2b69c..124068f 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -335,7 +335,12 @@ pub fn sync_single_file_to_vault( } // Bei Überschreiben einer ehemals größeren Datei überzählige alte Chunks entfernen - db.truncate_chunks_after(node_id, chunk_idx.saturating_sub(1))?; + if bytes_written == 0 { + // S-05: 0-Byte Datei: Alle Chunks (inkl. Chunk 0) restlos löschen und shreddern + db.delete_all_chunks(node_id)?; + } else { + db.truncate_chunks_after(node_id, chunk_idx.saturating_sub(1))?; + } // S-04: Verwende bytes_written statt local_size zur Vermeidung von TOCTOU-Diskrepanzen db.update_node_size_and_time(node_id, bytes_written, local_mtime)?; diff --git a/tests/sync_test.rs b/tests/sync_test.rs index 755dc51..652ad11 100644 --- a/tests/sync_test.rs +++ b/tests/sync_test.rs @@ -689,3 +689,100 @@ fn test_s04_toctou_file_size_uses_bytes_written() { let _ = fs::remove_dir_all(&temp_root); } + +#[test] +fn test_s05_zero_byte_file_truncate_removes_all_chunks() { + let temp_root = + std::env::temp_dir().join(format!("sanctum_s05_test_{}", rand::random::())); + let container_path = temp_root.join("test.sanctum"); + let file_path = temp_root.join("truncate_file.bin"); + + fs::create_dir_all(&temp_root).unwrap(); + // 1. Zuerst große Datei mit 2.5 Chunks (> 2 MB) anlegen + let large_payload = vec![0xEEu8; 2_500_000]; + fs::write(&file_path, &large_payload).unwrap(); + + let (db, dek) = create_test_container(&container_path); + + // Initialer Push + sanctum::sync::sync_single_file_to_vault( + &db, + 0, + &dek, + FORMAT_VERSION, + &file_path, + 1, + "truncate_file.bin", + false, + false, + ) + .expect("Initial push of large file"); + + let node = db + .resolve_path_in_vault("/truncate_file.bin", 0, &dek) + .unwrap() + .expect("Node must exist"); + assert_eq!(node.size, 2_500_000); + + // Prüfe, dass Chunks existieren (3 Chunks: 0, 1, 2) + { + let conn = rusqlite::Connection::open(&container_path).unwrap(); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM chunks WHERE node_id = ?1", + rusqlite::params![node.id], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 3, "Datei muss 3 Chunks besitzen"); + } + + // 2. Jetzt: Lokale Datei durch 0-Byte Datei ersetzen (Truncate auf 0 Bytes) + std::thread::sleep(std::time::Duration::from_millis(1100)); // Timestamp ändern + fs::write(&file_path, b"").unwrap(); + + let res = sanctum::sync::sync_single_file_to_vault( + &db, + 0, + &dek, + FORMAT_VERSION, + &file_path, + 1, + "truncate_file.bin", + false, + false, + ) + .expect("Push of 0-byte file"); + + match res { + sanctum::sync::FileTransferResult::Transferred { size } => { + assert_eq!(size, 0); + } + _ => panic!("Expected Transferred {{ size: 0 }}"), + } + + // 3. Verifiziere: In SQLite darf KEIN EINZIGER Chunk mehr für diesen Knoten existieren! + // (Ohne den S-05 Fix blieb Chunk 0 fälschlicherweise in der Tabelle zurück) + { + let conn = rusqlite::Connection::open(&container_path).unwrap(); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM chunks WHERE node_id = ?1", + rusqlite::params![node.id], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + count, 0, + "Bei 0-Byte Datei müssen ALLE Chunks (inkl. Chunk 0) gelöscht sein (S-05)" + ); + } + + let updated_node = db + .resolve_path_in_vault("/truncate_file.bin", 0, &dek) + .unwrap() + .expect("Node must exist"); + assert_eq!(updated_node.size, 0); + + let _ = fs::remove_dir_all(&temp_root); +}