fix(state): fail closed on unscoped corruption

This commit is contained in:
Pavel Diatchenko
2026-08-27 15:22:31 +12:00
committed by Teknium
parent d10ef89ee5
commit 96739033c4
2 changed files with 94 additions and 19 deletions
+12 -13
View File
@@ -5842,22 +5842,21 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
@staticmethod
def _is_fts_write_corruption_error(exc: sqlite3.DatabaseError) -> bool:
"""True for the error class a corrupt FTS index raises on writes.
"""Return true only when SQLite identifies corruption as FTS-scoped.
SQLite's message for a corrupt FTS index varies by version: older
builds raise the generic ``database disk image is malformed`` (covered
by :func:`is_malformed_db_error`); newer builds raise the FTS5-specific
``fts5: corrupt structure record for table "messages_fts"``. Both mean
the same thing for the write path: the canonical rows are fine, the
FTS shadow tables are not. The FTS-only rebuild and fail-open
detach are safe here because they only touch derived indexes; if the
damage is actually in a canonical B-tree, the rebuild itself fails and
the write propagates.
Newer SQLite builds include ``fts5`` in the error text. Older builds
may emit only ``database disk image is malformed`` while exposing the
extended ``SQLITE_CORRUPT_VTAB`` result code. A bare
``SQLITE_CORRUPT``/malformed-image error is structural and must not
trigger live FTS maintenance: it does not prove that canonical B-trees
are intact.
"""
if is_malformed_db_error(exc):
return True
corrupt_vtab = getattr(sqlite3, "SQLITE_CORRUPT_VTAB", 267)
error_code = getattr(exc, "sqlite_errorcode", None)
if error_code is not None:
return error_code == corrupt_vtab
msg = str(exc).lower()
return "fts5" in msg and "corrupt" in msg
return msg.startswith("fts5:") and "corrupt structure" in msg
def _foreign_state_db_holders(self) -> List[Tuple[int, str]]:
"""Return foreign processes holding this DB or its WAL sidecars.
+82 -6
View File
@@ -275,13 +275,33 @@ class TestRuntimeFtsRebuild:
# Cleanup
os.chmod(proc_root / "222" / "fd", 0o755)
def test_corruption_error_classification_covers_both_sqlite_messages(self):
"""SQLite's message for a corrupt FTS index varies by version: older
builds raise the generic malformed-image error, newer builds raise an
FTS5-specific one. Both must trigger the self-heal."""
assert SessionDB._is_fts_write_corruption_error(
sqlite3.DatabaseError("database disk image is malformed")
def test_corruption_error_classification_requires_fts_evidence(self):
"""Generic structural corruption must not enter live FTS repair.
Older SQLite builds may use the generic malformed-image text for an FTS
virtual-table failure, but still expose SQLITE_CORRUPT_VTAB. Preserve
that route while failing closed for unscoped SQLITE_CORRUPT errors.
"""
generic = sqlite3.DatabaseError("database disk image is malformed")
assert not SessionDB._is_fts_write_corruption_error(generic)
structural = sqlite3.DatabaseError("database disk image is malformed")
structural.sqlite_errorcode = sqlite3.SQLITE_CORRUPT
structural.sqlite_errorname = "SQLITE_CORRUPT"
assert not SessionDB._is_fts_write_corruption_error(structural)
fts_virtual_table = sqlite3.DatabaseError("database disk image is malformed")
fts_virtual_table.sqlite_errorcode = sqlite3.SQLITE_CORRUPT_VTAB
fts_virtual_table.sqlite_errorname = "SQLITE_CORRUPT_VTAB"
assert SessionDB._is_fts_write_corruption_error(fts_virtual_table)
contradictory = sqlite3.IntegrityError(
'fts5: corrupt structure record for table "messages_fts"'
)
contradictory.sqlite_errorcode = sqlite3.SQLITE_CONSTRAINT_TRIGGER
contradictory.sqlite_errorname = "SQLITE_CONSTRAINT_TRIGGER"
assert not SessionDB._is_fts_write_corruption_error(contradictory)
assert SessionDB._is_fts_write_corruption_error(
sqlite3.DatabaseError(
'fts5: corrupt structure record for table "messages_fts"'
@@ -291,6 +311,62 @@ class TestRuntimeFtsRebuild:
sqlite3.DatabaseError("no such table: nothing_fts_related")
)
def test_structural_corruption_propagates_without_live_fts_mutation(
self, db, tmp_path, monkeypatch
):
if not db._fts_enabled:
pytest.skip("FTS5 unavailable in this build")
rebuild_called = False
def _unexpected_rebuild():
nonlocal rebuild_called
rebuild_called = True
raise AssertionError("structural corruption must not rebuild FTS")
monkeypatch.setattr(db, "rebuild_fts", _unexpected_rebuild)
structural = sqlite3.DatabaseError("database disk image is malformed")
structural.sqlite_errorcode = sqlite3.SQLITE_CORRUPT
structural.sqlite_errorname = "SQLITE_CORRUPT"
with pytest.raises(sqlite3.DatabaseError) as caught:
db._execute_write(lambda _conn: (_ for _ in ()).throw(structural))
assert caught.value is structural
assert rebuild_called is False
assert db._fts_stale is False
assert _meta_value(tmp_path / "state.db", FTS_STALE_KEY) is None
assert _base_fts_triggers(tmp_path / "state.db") == set(_FTS_TRIGGERS)
def test_fts_looking_constraint_error_does_not_mutate_fts(
self, db, tmp_path, monkeypatch
):
if not db._fts_enabled:
pytest.skip("FTS5 unavailable in this build")
rebuild_called = False
def _unexpected_rebuild():
nonlocal rebuild_called
rebuild_called = True
raise AssertionError("contradictory error code must fail closed")
monkeypatch.setattr(db, "rebuild_fts", _unexpected_rebuild)
contradictory = sqlite3.IntegrityError(
'fts5: corrupt structure record for table "messages_fts"'
)
contradictory.sqlite_errorcode = sqlite3.SQLITE_CONSTRAINT_TRIGGER
contradictory.sqlite_errorname = "SQLITE_CONSTRAINT_TRIGGER"
with pytest.raises(sqlite3.IntegrityError) as caught:
db._execute_write(lambda _conn: (_ for _ in ()).throw(contradictory))
assert caught.value is contradictory
assert rebuild_called is False
assert db._fts_stale is False
assert _meta_value(tmp_path / "state.db", FTS_STALE_KEY) is None
assert _base_fts_triggers(tmp_path / "state.db") == set(_FTS_TRIGGERS)
def test_append_self_heals_after_fts_corruption(self, db, tmp_path):
if not db._fts_enabled:
pytest.skip("FTS5 unavailable in this build")