From 8ff426d1b0cb56bb76306224a14933c15125fa55 Mon Sep 17 00:00:00 2001 From: harald Date: Sat, 19 Sep 2026 23:23:57 +0200 Subject: [PATCH] =?UTF-8?q?fix(storage):=20ST-03=20=E2=80=94=20wrap=20recu?= =?UTF-8?q?rsive=20node=20deletion=20in=20atomic=20sqlite=20transaction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/storage.rs | 185 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 168 insertions(+), 17 deletions(-) diff --git a/src/storage.rs b/src/storage.rs index 48a5883..a11b5bf 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -1681,24 +1681,54 @@ impl Database { Ok(()) } - /// Löscht einen Knoten und shreddert alle assoziierten Chunks atomar. - pub fn delete_node(&self, id: i64) -> Result<()> { - self.assert_not_carrier(id)?; - - // Verhindere auch das Löschen eines Verzeichnisses, das die Trägerdatei enthält - if let Ok(Some(carrier_id)) = self.find_carrier_node_id() { - if self.is_descendant_of(carrier_id, id).unwrap_or(false) { - bail!("Verzeichnis enthält die Trägerdatei (Node-ID {}) und darf nicht gelöscht werden.", carrier_id); + fn delete_subtree_tx( + tx: &rusqlite::Transaction<'_>, + id: i64, + carrier_id: Option, + ) -> Result<()> { + if let Some(cid) = carrier_id { + if id == cid { + bail!( + "Operation auf Alibi-Trägerdatei (Carrier, Node-ID {}) ist strikt untersagt.", + id + ); } } - // 1. Shredde Chunks dieses Knotens mit kryptografischem Zufallsrauschen - let _ = self.shred_chunks_for_node(id); + // 1. Chunks für diesen Knoten kryptografisch shreddern + { + let mut stmt = tx + .prepare("SELECT chunk_index, length(ciphertext) FROM chunks WHERE node_id = ?1")?; + let chunks: Vec<(u32, usize)> = stmt + .query_map(params![id], |row| Ok((row.get(0)?, row.get(1)?)))? + .filter_map(|r| r.ok()) + .collect(); - // 2. Shredde auch rekursiv alle Unterknoten + let mut update_stmt = tx.prepare( + "UPDATE chunks SET nonce = ?1, tag = ?2, ciphertext = ?3, generation = 0 WHERE node_id = ?4 AND chunk_index = ?5", + )?; + + for (chunk_idx, ct_len) in chunks { + let mut noise = vec![0u8; ct_len]; + let mut nonce_noise = [0u8; 12]; + let mut tag_noise = [0u8; 16]; + OsRng.fill_bytes(&mut noise); + OsRng.fill_bytes(&mut nonce_noise); + OsRng.fill_bytes(&mut tag_noise); + + let _ = update_stmt.execute(params![ + nonce_noise.as_slice(), + tag_noise.as_slice(), + noise, + id, + chunk_idx + ]); + } + } + + // 2. Kindknoten ermitteln und rekursiv innerhalb derselben Transaktion löschen let child_ids: Vec = { - let conn = self.conn(); - let mut stmt = conn.prepare("SELECT id FROM nodes WHERE parent_id = ?1")?; + let mut stmt = tx.prepare("SELECT id FROM nodes WHERE parent_id = ?1")?; let ids = stmt .query_map(params![id], |row| row.get(0))? .filter_map(|r| r.ok()) @@ -1708,7 +1738,7 @@ impl Database { let mut errors = Vec::new(); for child_id in child_ids { - if let Err(e) = self.delete_node(child_id) { + if let Err(e) = Self::delete_subtree_tx(tx, child_id, carrier_id) { error!("Konnte Kindknoten {} nicht löschen: {e}", child_id); errors.push(e); } @@ -1720,10 +1750,33 @@ impl Database { ); } - let conn = self.conn(); - conn.execute("DELETE FROM chunks WHERE node_id = ?1", params![id])?; - conn.execute("DELETE FROM nodes WHERE id = ?1", params![id])?; + // 3. Chunks und Knoten aus der Datenbank löschen + tx.execute("DELETE FROM chunks WHERE node_id = ?1", params![id])?; + tx.execute("DELETE FROM nodes WHERE id = ?1", params![id])?; + Ok(()) + } + + /// Löscht einen Knoten und shreddert alle assoziierten Chunks atomar in einer SQLite-Transaktion (ST-03). + pub fn delete_node(&self, id: i64) -> Result<()> { + self.assert_not_carrier(id)?; + + // Verhindere auch das Löschen eines Verzeichnisses, das die Trägerdatei enthält + let carrier_id = self.find_carrier_node_id().ok().flatten(); + if let Some(cid) = carrier_id { + if self.is_descendant_of(cid, id).unwrap_or(false) { + bail!( + "Verzeichnis enthält die Trägerdatei (Node-ID {}) und darf nicht gelöscht werden.", + cid + ); + } + } + + let mut conn = self.conn(); + let tx = conn.transaction()?; + Self::delete_subtree_tx(&tx, id, carrier_id)?; + tx.commit()?; drop(conn); + let _ = self.update_metadata_mac(); Ok(()) } @@ -3172,4 +3225,102 @@ mod tests { err_msg ); } + + #[test] + fn test_st03_recursive_delete_transaction_rollback() { + let db = Database::open_in_memory().unwrap(); + let (salt, kdf, wrapped_dek, nonce, tag) = ( + [1u8; 16], + KdfParams::default(), + vec![2u8; 40], + [3u8; 12], + [4u8; 16], + ); + db.init_schema(&salt, &kdf, &wrapped_dek, &nonce, &tag) + .unwrap(); + + // 1. Ordner mit zwei Dateien anlegen + let folder = db.create_node(1, "folder", true).unwrap(); + let file1 = db.create_node(folder.id, "file1.txt", false).unwrap(); + let file2 = db.create_node(folder.id, "file2.txt", false).unwrap(); + + // Chunks für file1 und file2 anlegen + let chunk_data = vec![0x42u8; 1024]; + let nonce = [1u8; 12]; + let tag = [2u8; 16]; + db.write_chunk(file1.id, 0, 1, &nonce, &tag, &chunk_data) + .unwrap(); + db.write_chunk(file2.id, 0, 1, &nonce, &tag, &chunk_data) + .unwrap(); + + // 2. Trigger erstellen, der Löschen von file2 verhindert + { + let conn = db.conn(); + conn.execute( + "CREATE TRIGGER prevent_del_file2 BEFORE DELETE ON nodes + WHEN OLD.name = 'file2.txt' + BEGIN + SELECT RAISE(FAIL, 'file2 ist schreibgeschützt'); + END;", + [], + ) + .unwrap(); + } + + // 3. Löschen des gesamten Ordners aufrufen -> schlägt fehl + let res = db.delete_node(folder.id); + assert!(res.is_err(), "delete_node muss fehlschlagen"); + + // 4. ST-03 Atomaritätsprüfung (Rollback): + // Weder folder noch file1 noch file2 dürfen gelöscht worden sein! + assert!( + db.get_node_by_id(folder.id).unwrap().is_some(), + "Folder darf nach Rollback nicht gelöscht sein" + ); + assert!( + db.get_node_by_id(file1.id).unwrap().is_some(), + "file1 darf nach Rollback nicht gelöscht sein" + ); + assert!( + db.get_node_by_id(file2.id).unwrap().is_some(), + "file2 darf nach Rollback nicht gelöscht sein" + ); + + // Chunk von file1 muss unversehrt existieren + let chunk1 = db.read_chunk(file1.id, 0).unwrap(); + assert!( + chunk1.is_some(), + "Chunk von file1 darf nach Rollback nicht gelöscht sein" + ); + assert_eq!( + chunk1.unwrap().ciphertext, + chunk_data, + "Chunk von file1 muss unversehrt sein" + ); + + // 5. Trigger entfernen und erneut löschen -> muss jetzt vollständig gelingen + { + let conn = db.conn(); + conn.execute("DROP TRIGGER prevent_del_file2", []).unwrap(); + } + db.delete_node(folder.id) + .expect("delete_node nach Trigger-Entfernung muss gelingen"); + + assert!( + db.get_node_by_id(folder.id).unwrap().is_none(), + "Folder muss gelöscht sein" + ); + assert!( + db.get_node_by_id(file1.id).unwrap().is_none(), + "file1 muss gelöscht sein" + ); + assert!( + db.get_node_by_id(file2.id).unwrap().is_none(), + "file2 muss gelöscht sein" + ); + assert!( + db.read_chunk(file1.id, 0).unwrap().is_none(), + "Chunk von file1 muss gelöscht sein" + ); + } }