Skip to content

Commit 6416dd5

Browse files
steveonjavakshitijk4poor
authored andcommitted
fix(kanban): harden SQLite against torn-write corruption (secure_delete + cell_size_check + synchronous=FULL)
Production corruption #6 left b-tree pages with zeroed headers but intact old cell content — the Bug E pattern. This fix applies three pragma calls on every connect(): - synchronous=FULL (was NORMAL): closes the WAL-checkpoint reordering window where a crash between WAL commit and main-DB write leaves a partially-written b-tree page header. Cost is <1ms per commit on local SSD; negligible at kanban write volume. - secure_delete=ON: forces SQLite to zero freed page bytes on disk. If a torn write or hardware fault later corrupts a page, the underlying cell content is zero, so corruption is detectable and no stale rows can resurface as live data. - cell_size_check=ON: adds a read-side guard so corrupt cells surface as errors at read time rather than as silent wrong-data returns. All three are connection-scoped and re-applied on every connect(). secure_delete also writes a persistent flag into the DB header on the first call against a fresh DB, making the protection durable across processes for new DBs. Tests added for all four required cases: each pragma active on a fresh connection, and all three re-applied after close+reopen. Also adds the required negative test (migration path does not reset pragmas).
1 parent 963d22c commit 6416dd5

2 files changed

Lines changed: 73 additions & 1 deletion

File tree

hermes_cli/kanban_db.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1181,8 +1181,16 @@ def connect(
11811181
# See hermes_state._WAL_INCOMPAT_MARKERS for detection logic.
11821182
from hermes_state import apply_wal_with_fallback
11831183
apply_wal_with_fallback(conn, db_label=f"kanban.db ({path.name})")
1184-
conn.execute("PRAGMA synchronous=NORMAL")
1184+
# FULL (was NORMAL): fsync before each checkpoint to narrow the
1185+
# crash window that can leave a b-tree page header torn.
1186+
conn.execute("PRAGMA synchronous=FULL")
11851187
conn.execute("PRAGMA foreign_keys=ON")
1188+
# Zero freed pages so a later torn write cannot expose stale
1189+
# cell content; persisted in the DB header for new DBs.
1190+
conn.execute("PRAGMA secure_delete=ON")
1191+
# Surface corrupt cells as read errors instead of silent
1192+
# wrong-data returns.
1193+
conn.execute("PRAGMA cell_size_check=ON")
11861194
needs_init = resolved not in _INITIALIZED_PATHS
11871195
if needs_init:
11881196
# Idempotent: runs CREATE TABLE IF NOT EXISTS + the additive

tests/hermes_cli/test_kanban_db.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3339,3 +3339,67 @@ def test_maybe_emit_scratch_tip_skips_non_scratch_workspaces(kanban_home, caplog
33393339
).fetchall()
33403340
assert "tip_scratch_workspace" not in [e["kind"] for e in events]
33413341

3342+
3343+
# ---------------------------------------------------------------------------
3344+
# Connection pragmas (secure_delete, cell_size_check, synchronous=FULL)
3345+
# ---------------------------------------------------------------------------
3346+
3347+
3348+
def test_connect_sets_secure_delete_on(tmp_path):
3349+
"""secure_delete=ON must be active on every new connection."""
3350+
db_path = tmp_path / "kanban.db"
3351+
kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
3352+
with kb.connect(db_path=db_path) as conn:
3353+
row = conn.execute("PRAGMA secure_delete").fetchone()
3354+
assert row[0] == 1, f"expected secure_delete=1, got {row[0]}"
3355+
3356+
3357+
def test_connect_sets_cell_size_check_on(tmp_path):
3358+
"""cell_size_check=ON must be active on every new connection."""
3359+
db_path = tmp_path / "kanban.db"
3360+
kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
3361+
with kb.connect(db_path=db_path) as conn:
3362+
row = conn.execute("PRAGMA cell_size_check").fetchone()
3363+
assert row[0] == 1, f"expected cell_size_check=1, got {row[0]}"
3364+
3365+
3366+
def test_connect_sets_synchronous_full(tmp_path):
3367+
"""synchronous must be FULL (=2), not NORMAL (=1)."""
3368+
db_path = tmp_path / "kanban.db"
3369+
kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
3370+
with kb.connect(db_path=db_path) as conn:
3371+
row = conn.execute("PRAGMA synchronous").fetchone()
3372+
assert row[0] == 2, f"expected synchronous=2 (FULL), got {row[0]}"
3373+
3374+
3375+
def test_connect_pragmas_applied_on_reconnect(tmp_path):
3376+
"""All three pragmas must be re-applied on every connect(), not just the first."""
3377+
db_path = tmp_path / "kanban.db"
3378+
kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
3379+
# First connection: write a task and close.
3380+
with kb.connect(db_path=db_path) as conn:
3381+
kb.create_task(conn, title="reconnect-check")
3382+
# Force re-init path by discarding path cache.
3383+
kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
3384+
# Second connection: pragmas must still be applied.
3385+
with kb.connect(db_path=db_path) as conn:
3386+
assert conn.execute("PRAGMA secure_delete").fetchone()[0] == 1
3387+
assert conn.execute("PRAGMA cell_size_check").fetchone()[0] == 1
3388+
assert conn.execute("PRAGMA synchronous").fetchone()[0] == 2
3389+
3390+
3391+
3392+
def test_pragmas_not_accidentally_disabled_by_migrate_path(tmp_path):
3393+
"""Migration path must not reset connection pragmas."""
3394+
db_path = tmp_path / "legacy.db"
3395+
kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
3396+
# Initialise with a fresh connect so schema + init run.
3397+
with kb.connect(db_path=db_path) as conn:
3398+
kb.create_task(conn, title="pre-migration-task")
3399+
# Simulate a re-entry through the init/migration path by discarding path cache.
3400+
kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
3401+
with kb.connect(db_path=db_path) as conn:
3402+
assert conn.execute("PRAGMA secure_delete").fetchone()[0] == 1
3403+
assert conn.execute("PRAGMA cell_size_check").fetchone()[0] == 1
3404+
assert conn.execute("PRAGMA synchronous").fetchone()[0] == 2
3405+

0 commit comments

Comments
 (0)