From 0706733abfcbedb45c462f1054a8dc478b419e15 Mon Sep 17 00:00:00 2001 From: Parfii-bot Date: Thu, 23 Apr 2026 15:45:23 +0800 Subject: [PATCH] fix(entity-store): FTS delete-tx + archive FTS desync (HIGH audit) 2 HIGH security/correctness fixes from post-v0.28 audit: - delete.rs: hard-delete now wraps FTS DELETE + table DELETE in unchecked_transaction. Prior: two separate execute calls could leave FTS orphan on second-stmt failure. Same pattern as C2 fix. - archive.rs: archive-UPDATE + FTS DELETE now atomic in one tx. Prior: archived rows remained searchable (UX/privacy leak). Semantics documented: archive = hidden from FTS; unarchive must re-insert (caller responsibility). +4 regression tests: delete_rollback_on_fts_sabotage, archive_removes_from_fts, archive_rollback_on_sabotage, delete_succeeds_when_no_fts_configured. All green (49/49 kes tests). Constructor Pattern: all files <200 LOC, all functions <30 LOC. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../kei-entity-store/src/verbs/archive.rs | 43 ++++- .../kei-entity-store/src/verbs/delete.rs | 75 +++++--- .../kei-entity-store/tests/bug_fixes_smoke.rs | 173 +++++++++++++++++- 3 files changed, 262 insertions(+), 29 deletions(-) diff --git a/_primitives/_rust/kei-entity-store/src/verbs/archive.rs b/_primitives/_rust/kei-entity-store/src/verbs/archive.rs index ef657b3..f7ad2d3 100644 --- a/_primitives/_rust/kei-entity-store/src/verbs/archive.rs +++ b/_primitives/_rust/kei-entity-store/src/verbs/archive.rs @@ -9,6 +9,15 @@ //! fall back to legacy `archived` heuristics (those remain in //! `delete.rs` soft-path only). //! +//! FTS semantics: archiving means "hidden from active listing". The verb +//! therefore DELETEs the row from `fts_` inside the same +//! transaction as the UPDATE, so `search` will no longer return the +//! archived row. If a future caller flips the column back to active +//! (unarchive), it MUST reinsert the row into the FTS index — the +//! current contract does not auto-reindex on unarchive. "Keep +//! searchable while archived" is a future feature (`search +//! --include-archived`), NOT today. +//! //! Input: `{ id: }`. //! Output: `{ id, archived_at }` — `archived_at` is the stamped //! timestamp when a `_at` column exists, else `null`. @@ -36,7 +45,7 @@ pub fn run( let has_ts = schema.fields.iter().any(|f| f.name == ts_col); let now: i64 = chrono::Utc::now().timestamp(); - let rows = execute_archive(conn, schema, field_name, &ts_col, has_ts, &id, now)?; + let rows = archive_tx(conn, schema, field_name, &ts_col, has_ts, &id, now)?; if rows == 0 { return Err(VerbError::not_found_text(schema.name, id.as_string())); } @@ -54,7 +63,9 @@ fn guard_enabled(schema: &EntitySchema) -> Result<(), VerbError> { Ok(()) } -fn execute_archive( +/// UPDATE the archived column (+ stamp) and DELETE the row from FTS (if +/// configured) in one transaction. Either both persist or neither does. +fn archive_tx( conn: &Connection, schema: &EntitySchema, field_name: &str, @@ -62,11 +73,35 @@ fn execute_archive( has_ts: bool, id: &PkValue, now: i64, +) -> Result { + let tx = conn.unchecked_transaction()?; + let rows = execute_archive(&tx, schema, field_name, ts_col, has_ts, id, now)?; + if rows > 0 && schema.fts_columns.is_some() { + tx.execute( + &format!( + "DELETE FROM fts_{t} WHERE {t}_id=?1", + t = schema.table + ), + rusqlite::params![id.as_sql()], + )?; + } + tx.commit()?; + Ok(rows) +} + +fn execute_archive( + tx: &rusqlite::Transaction<'_>, + schema: &EntitySchema, + field_name: &str, + ts_col: &str, + has_ts: bool, + id: &PkValue, + now: i64, ) -> Result { let marker = archive_marker(schema, field_name); let pk_name = schema.pk().name; let rows = if has_ts { - conn.execute( + tx.execute( &format!( "UPDATE {t} SET {field_name} = ?1, {ts_col} = ?2 WHERE {pk_name} = ?3", t = schema.table @@ -74,7 +109,7 @@ fn execute_archive( rusqlite::params![marker, now, id.as_sql()], )? } else { - conn.execute( + tx.execute( &format!( "UPDATE {t} SET {field_name} = ?1 WHERE {pk_name} = ?2", t = schema.table diff --git a/_primitives/_rust/kei-entity-store/src/verbs/delete.rs b/_primitives/_rust/kei-entity-store/src/verbs/delete.rs index 92b451b..bdd8f24 100644 --- a/_primitives/_rust/kei-entity-store/src/verbs/delete.rs +++ b/_primitives/_rust/kei-entity-store/src/verbs/delete.rs @@ -1,9 +1,13 @@ //! `delete` verb — hard DELETE by id, OR soft (if schema has an //! `archived` integer field, flips it to 1). +//! +//! The hard-delete path wraps the `fts_
` DELETE and the base-table +//! DELETE in a single `unchecked_transaction`, so a mid-flight FTS failure +//! rolls back the row delete too (mirrors create/update C2 fix). use crate::error::VerbError; use crate::schema::EntitySchema; -use crate::verbs::pk; +use crate::verbs::pk::{self, PkValue}; use rusqlite::Connection; use serde_json::{json, Value}; @@ -22,29 +26,9 @@ pub fn run( let soft = input.get("soft").and_then(|v| v.as_bool()).unwrap_or(false); let rows = if soft && has_archived_field(schema) { - conn.execute( - &format!( - "UPDATE {} SET archived = 1 WHERE {}=?1", - schema.table, - schema.pk().name - ), - rusqlite::params![id.as_sql()], - )? + soft_delete(conn, schema, &id)? } else { - if schema.fts_columns.is_some() { - conn.execute( - &format!("DELETE FROM fts_{} WHERE {}_id=?1", schema.table, schema.table), - rusqlite::params![id.as_sql()], - )?; - } - conn.execute( - &format!( - "DELETE FROM {} WHERE {}=?1", - schema.table, - schema.pk().name - ), - rusqlite::params![id.as_sql()], - )? + hard_delete_tx(conn, schema, &id)? }; if rows == 0 { return Err(VerbError::not_found_text(schema.name, id.as_string())); @@ -52,6 +36,51 @@ pub fn run( Ok(json!({ "ok": true, "id": id.as_json() })) } +fn soft_delete( + conn: &Connection, + schema: &EntitySchema, + id: &PkValue, +) -> Result { + let rows = conn.execute( + &format!( + "UPDATE {} SET archived = 1 WHERE {}=?1", + schema.table, + schema.pk().name + ), + rusqlite::params![id.as_sql()], + )?; + Ok(rows) +} + +/// FTS DELETE + base-table DELETE in one transaction. If either fails, +/// neither persists. +fn hard_delete_tx( + conn: &Connection, + schema: &EntitySchema, + id: &PkValue, +) -> Result { + let tx = conn.unchecked_transaction()?; + if schema.fts_columns.is_some() { + tx.execute( + &format!( + "DELETE FROM fts_{t} WHERE {t}_id=?1", + t = schema.table + ), + rusqlite::params![id.as_sql()], + )?; + } + let rows = tx.execute( + &format!( + "DELETE FROM {} WHERE {}=?1", + schema.table, + schema.pk().name + ), + rusqlite::params![id.as_sql()], + )?; + tx.commit()?; + Ok(rows) +} + fn has_archived_field(schema: &EntitySchema) -> bool { schema.fields.iter().any(|f| f.name == "archived") } diff --git a/_primitives/_rust/kei-entity-store/tests/bug_fixes_smoke.rs b/_primitives/_rust/kei-entity-store/tests/bug_fixes_smoke.rs index f0321cc..f947405 100644 --- a/_primitives/_rust/kei-entity-store/tests/bug_fixes_smoke.rs +++ b/_primitives/_rust/kei-entity-store/tests/bug_fixes_smoke.rs @@ -2,8 +2,8 @@ //! injection/M3/TEXT-cap/M2). Each test names the finding it pins. use kei_entity_store::error::VerbError; -use kei_entity_store::schema::{EntitySchema, FieldDef}; -use kei_entity_store::verbs::{create, search, update}; +use kei_entity_store::schema::{EdgeKeyKind, EntitySchema, FieldDef}; +use kei_entity_store::verbs::{archive, create, delete, search, update}; use kei_entity_store::verbs::validate::MAX_TEXT_BYTES; use kei_entity_store::Store; use rusqlite::Connection; @@ -190,3 +190,172 @@ fn m2_user_version_applied_once_idempotent() { assert_eq!(v, kei_entity_store::engine::CURRENT_USER_VERSION); } } + +// ---------- delete/archive transaction semantics ---------- +// +// Two audit findings pinned here: +// (1) delete.rs hard-path wraps FTS DELETE + table DELETE in one tx +// (was two separate execs — partial failure orphaned FTS rows). +// (2) archive.rs removes the row from FTS inside the same tx as the +// UPDATE (was UPDATE only — archived rows stayed searchable). + +static ARCHIVABLE_FIELDS: &[FieldDef] = &[ + FieldDef::pk("id"), + FieldDef::text_nn("title"), + FieldDef::text("description"), + FieldDef::integer("status"), + FieldDef::integer("status_at"), + FieldDef::created_at(), +]; + +/// FTS + archived_field both configured — required for +/// `archive_removes_from_fts` and `archive_rollback_on_sabotage`. +static ARCHIVABLE_FTS: EntitySchema = EntitySchema { + name: "item", + table: "items", + fields: ARCHIVABLE_FIELDS, + enabled_verbs: &["create", "get", "search", "archive"], + fts_columns: Some(&["title", "description"]), + edge_table: None, + edge_key_kind: EdgeKeyKind::IntegerPair, + archived_field: Some("status"), + custom_migrations: &[], +}; + +static NO_FTS_FIELDS: &[FieldDef] = &[ + FieldDef::pk("id"), + FieldDef::text_nn("title"), + FieldDef::created_at(), +]; + +/// No FTS — required for `delete_succeeds_when_no_fts_configured`. +static NO_FTS_SCHEMA: EntitySchema = EntitySchema { + name: "item", + table: "plain_items", + fields: NO_FTS_FIELDS, + enabled_verbs: &["create", "get", "delete"], + fts_columns: None, + edge_table: None, + edge_key_kind: EdgeKeyKind::IntegerPair, + archived_field: None, + custom_migrations: &[], +}; + +#[test] +fn delete_rollback_on_fts_sabotage() { + // Hard-delete path must roll back when the FTS DELETE fails — the + // row itself must survive. Pins: delete.rs tx wrapping (finding 1). + let s = mk(); + let id = create::run(s.conn(), &SCHEMA, json!({ "title": "keepme" })) + .unwrap()["id"] + .as_i64() + .unwrap(); + + s.conn().execute_batch("DROP TABLE fts_items;").unwrap(); + + let result = delete::run(s.conn(), &SCHEMA, json!({ "id": id })); + assert!(result.is_err(), "delete must fail when FTS is missing"); + + let count: i64 = s + .conn() + .query_row( + "SELECT COUNT(*) FROM items WHERE id=?1", + rusqlite::params![id], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 1, "row must survive FTS-failed delete (rollback)"); +} + +#[test] +fn archive_removes_from_fts() { + // Archive must remove the row from FTS so `search` no longer returns + // it. Pins: archive.rs fts-delete-in-tx (finding 2). + let s = Store::open_memory(&[&ARCHIVABLE_FTS]).unwrap(); + let id = create::run( + s.conn(), + &ARCHIVABLE_FTS, + json!({ "title": "uniqword42", "description": "findme" }), + ) + .unwrap()["id"] + .as_i64() + .unwrap(); + + // Before archive: search finds the row. + let before = + search::run(s.conn(), &ARCHIVABLE_FTS, json!({ "query": "uniqword42" })).unwrap(); + assert_eq!( + before["results"].as_array().unwrap().len(), + 1, + "row must be searchable before archive" + ); + + archive::run(s.conn(), &ARCHIVABLE_FTS, json!({ "id": id })).unwrap(); + + // After archive: search returns zero hits. + let after = + search::run(s.conn(), &ARCHIVABLE_FTS, json!({ "query": "uniqword42" })).unwrap(); + assert_eq!( + after["results"].as_array().unwrap().len(), + 0, + "archived row must not be searchable" + ); +} + +#[test] +fn archive_rollback_on_sabotage() { + // Archive wraps UPDATE + FTS DELETE in one tx. If the FTS delete + // fails, the UPDATE must roll back too. Pins: archive.rs tx + // wrapping (finding 2). + let s = Store::open_memory(&[&ARCHIVABLE_FTS]).unwrap(); + let id = create::run( + s.conn(), + &ARCHIVABLE_FTS, + json!({ "title": "stable", "description": "x" }), + ) + .unwrap()["id"] + .as_i64() + .unwrap(); + + s.conn().execute_batch("DROP TABLE fts_items;").unwrap(); + + let result = archive::run(s.conn(), &ARCHIVABLE_FTS, json!({ "id": id })); + assert!(result.is_err(), "archive must fail when FTS is missing"); + + // Status column must still be 0 (unarchived) — the UPDATE rolled back. + let status: i64 = s + .conn() + .query_row( + "SELECT status FROM items WHERE id=?1", + rusqlite::params![id], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + status, 0, + "archived_field must be unchanged after FTS-failed archive" + ); +} + +#[test] +fn delete_succeeds_when_no_fts_configured() { + // Delete on a schema with `fts_columns: None` must work — the tx + // wrapping must not introduce a spurious FTS DELETE. + let s = Store::open_memory(&[&NO_FTS_SCHEMA]).unwrap(); + let id = create::run(s.conn(), &NO_FTS_SCHEMA, json!({ "title": "bye" })) + .unwrap()["id"] + .as_i64() + .unwrap(); + + delete::run(s.conn(), &NO_FTS_SCHEMA, json!({ "id": id })).unwrap(); + + let count: i64 = s + .conn() + .query_row( + "SELECT COUNT(*) FROM plain_items WHERE id=?1", + rusqlite::params![id], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 0, "row must be gone after hard delete on no-FTS schema"); +}