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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Löscht einen Knoten und shreddert alle assoziierten Chunks atomar.
|
fn delete_subtree_tx(
|
||||||
pub fn delete_node(&self, id: i64) -> Result<()> {
|
tx: &rusqlite::Transaction<'_>,
|
||||||
self.assert_not_carrier(id)?;
|
id: i64,
|
||||||
|
carrier_id: Option<i64>,
|
||||||
// Verhindere auch das Löschen eines Verzeichnisses, das die Trägerdatei enthält
|
) -> Result<()> {
|
||||||
if let Ok(Some(carrier_id)) = self.find_carrier_node_id() {
|
if let Some(cid) = carrier_id {
|
||||||
if self.is_descendant_of(carrier_id, id).unwrap_or(false) {
|
if id == cid {
|
||||||
bail!("Verzeichnis enthält die Trägerdatei (Node-ID {}) und darf nicht gelöscht werden.", carrier_id);
|
bail!(
|
||||||
|
"Operation auf Alibi-Trägerdatei (Carrier, Node-ID {}) ist strikt untersagt.",
|
||||||
|
id
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Shredde Chunks dieses Knotens mit kryptografischem Zufallsrauschen
|
// 1. Chunks für diesen Knoten kryptografisch shreddern
|
||||||
let _ = self.shred_chunks_for_node(id);
|
{
|
||||||
|
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 child_ids: Vec<i64> = {
|
||||||
let conn = self.conn();
|
let mut stmt = tx.prepare("SELECT id FROM nodes WHERE parent_id = ?1")?;
|
||||||
let mut stmt = conn.prepare("SELECT id FROM nodes WHERE parent_id = ?1")?;
|
|
||||||
let ids = stmt
|
let ids = stmt
|
||||||
.query_map(params![id], |row| row.get(0))?
|
.query_map(params![id], |row| row.get(0))?
|
||||||
.filter_map(|r| r.ok())
|
.filter_map(|r| r.ok())
|
||||||
@@ -1708,7 +1738,7 @@ impl Database {
|
|||||||
|
|
||||||
let mut errors = Vec::new();
|
let mut errors = Vec::new();
|
||||||
for child_id in child_ids {
|
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);
|
error!("Konnte Kindknoten {} nicht löschen: {e}", child_id);
|
||||||
errors.push(e);
|
errors.push(e);
|
||||||
}
|
}
|
||||||
@@ -1720,10 +1750,33 @@ impl Database {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let conn = self.conn();
|
// 3. Chunks und Knoten aus der Datenbank löschen
|
||||||
conn.execute("DELETE FROM chunks WHERE node_id = ?1", params![id])?;
|
tx.execute("DELETE FROM chunks WHERE node_id = ?1", params![id])?;
|
||||||
conn.execute("DELETE FROM nodes WHERE 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);
|
drop(conn);
|
||||||
|
|
||||||
let _ = self.update_metadata_mac();
|
let _ = self.update_metadata_mac();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -3172,4 +3225,102 @@ mod tests {
|
|||||||
err_msg
|
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