fix(storage): ST-03 — wrap recursive node deletion in atomic sqlite transaction
This commit is contained in:
+168
-17
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user