fix(storage): ST-03 — wrap recursive node deletion in atomic sqlite transaction

This commit is contained in:
2026-09-19 23:23:57 +02:00
parent efddc2c477
commit 8ff426d1b0
+168 -17
View File
@@ -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<i64>,
) -> 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<i64> = {
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"
);
}
}