Files
aiturk-hermes-ide/tests/hermes_state/test_state_db_file_identity.py

300 lines
11 KiB
Python

"""File-identity guard on SessionDB writes (#89332).
When state.db is replaced out-of-band under a live handle, in-place FTS
rebuild / fail-open cannot help: they operate on a generation mismatch.
The store must fail loudly instead of limping.
"""
import json
import os
import shutil
import sqlite3
from pathlib import Path
import pytest
from hermes_state import (
SessionDB,
StateDbReplacedError,
classify_persistence_error,
divert_session_transcript_jsonl,
)
def _make_db(path: Path, session_id: str, content: str) -> SessionDB:
db = SessionDB(db_path=path)
db.create_session(session_id, "cli")
db.append_message(session_id, role="user", content=content)
return db
def _require_identity(db: SessionDB) -> None:
if db._db_file_identity is None:
pytest.skip("filesystem does not expose st_dev/st_ino for identity checks")
def test_replace_with_new_inode_fails_loudly_without_fts_repair(tmp_path):
live = tmp_path / "state.db"
other = tmp_path / "other.db"
db = _make_db(live, "live-sess", "original")
_require_identity(db)
alt = _make_db(other, "other-sess", "replacement")
alt.close()
recorded = db._db_file_identity
assert recorded is not None
os.replace(other, live)
assert _stat_changed(live, recorded)
with pytest.raises(StateDbReplacedError, match="replaced underneath"):
db.append_message("live-sess", role="user", content="after-replace")
assert db._db_replaced is True
# No FTS surgery ran: fail-open never detached the indexes.
assert db._fts_enabled is True
assert db._fts_stale is False
db.close()
def test_second_write_after_halt_does_not_attempt_repair(tmp_path):
live = tmp_path / "state.db"
other = tmp_path / "other.db"
db = _make_db(live, "s", "a")
_require_identity(db)
alt = _make_db(other, "t", "b")
alt.close()
os.replace(other, live)
with pytest.raises(StateDbReplacedError):
db.append_message("s", role="user", content="first")
with pytest.raises(StateDbReplacedError):
db.append_message("s", role="user", content="second")
assert db._fts_enabled is True
assert db._fts_stale is False
db.close()
def test_same_file_fts_corruption_still_fails_open(tmp_path):
"""Identity guard must not disable genuine in-file FTS recovery.
Since 18ac3c4fb6 the live write path never rebuilds FTS in place; the
recovery contract is the fail-open detach (stale marker + triggers
dropped) followed by a successful canonical retry.
"""
db = _make_db(tmp_path / "state.db", "s1", "hello world")
_require_identity(db)
identity = db._db_file_identity
raw = sqlite3.connect(str(tmp_path / "state.db"))
raw.execute(
"UPDATE messages_fts_data SET block = X'DEADBEEFDEADBEEFDEADBEEFDEADBEEF'"
)
raw.commit()
raw.close()
db.append_message("s1", role="user", content="healed append")
assert db._db_file_identity == identity
assert db._db_replaced is False
# Fail-open detach ran: canonical write landed, FTS marked stale.
assert db._fts_stale is True
assert db._fts_enabled is False
db.close()
def test_classify_replaced_is_not_disk_or_fts_repair():
err = StateDbReplacedError(
"FATAL: state.db was replaced underneath the gateway; refusing further writes"
)
assert classify_persistence_error(err) == "replaced"
assert classify_persistence_error(str(err)) == "replaced"
def test_new_sessiondb_on_replaced_path_records_new_identity(tmp_path):
live = tmp_path / "state.db"
other = tmp_path / "other.db"
db = _make_db(live, "s", "a")
old_id = db._db_file_identity
_require_identity(db)
db.close()
alt = _make_db(other, "t", "b")
alt.close()
os.replace(other, live)
reopened = SessionDB(db_path=live)
try:
assert reopened._db_file_identity != old_id
reopened.append_message("t", role="user", content="adopted after reopen")
assert reopened._db_replaced is False
finally:
reopened.close()
def test_fts_scoped_error_on_replaced_file_skips_fts_fail_open(tmp_path):
"""Even FTS-provenance corruption must not authorize surgery on a
replaced file. (A generic malformed error never reaches fail-open at
all since the provenance classifier of #99652 rejects it earlier.)"""
live = tmp_path / "state.db"
other = tmp_path / "other.db"
db = _make_db(live, "s", "a")
_require_identity(db)
alt = _make_db(other, "t", "b")
alt.close()
os.replace(other, live)
with pytest.raises(StateDbReplacedError):
db._enter_fts_fail_open(
sqlite3.DatabaseError(
'fts5: corrupt structure record for table "messages_fts"'
)
)
assert db._fts_enabled is True
assert db._fts_stale is False
db.close()
def test_copyfile_same_inode_fails_loudly_without_fts_repair(tmp_path):
"""``cp`` keeps st_ino; generation stamp must still halt (#89332)."""
live = tmp_path / "state.db"
other = tmp_path / "other.db"
db = _make_db(live, "live-sess", "original")
alt = _make_db(other, "other-sess", "replacement")
live_app = db._db_file_application_id
other_app = alt._db_file_application_id
if not live_app or not other_app:
alt.close()
db.close()
pytest.skip("generation stamp not recorded on this filesystem")
assert live_app != other_app
recorded = db._db_file_identity
alt.close()
shutil.copyfile(other, live)
if recorded is not None:
st = os.stat(live)
assert (st.st_dev, st.st_ino) == recorded
with pytest.raises(StateDbReplacedError, match="replaced underneath"):
db.append_message("live-sess", role="user", content="after-cp")
assert db._db_replaced is True
assert db._fts_enabled is True
assert db._fts_stale is False
db.close()
def test_divert_session_transcript_jsonl_appends(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
path = divert_session_transcript_jsonl(
"sess-jsonl",
[{"role": "user", "content": "hello-jsonl"}],
)
assert path == tmp_path / "sessions" / "sess-jsonl.jsonl"
lines = path.read_text(encoding="utf-8").strip().splitlines()
assert json.loads(lines[-1])["content"] == "hello-jsonl"
assert divert_session_transcript_jsonl("sess-jsonl", []) is None
def _stat_changed(path: Path, recorded) -> bool:
st = os.stat(path)
return (st.st_dev, st.st_ino) != recorded
# ---------------------------------------------------------------------------
# Lock safety of the identity probe itself (#100368 / howtocorrupt §2.2).
#
# _read_sqlite_application_id runs on EVERY write against the LIVE state.db.
# Before the _pread_db_header fix it did open("rb")/read/close, and that
# close() cancelled every POSIX advisory lock this process held on the file
# — including the WAL-mode DMS shared lock of the writer connection. These
# tests measure the actual kernel lock table (/proc/locks), so they are
# Linux-only; the hazard itself is POSIX-only.
# ---------------------------------------------------------------------------
def _posix_locks_on(paths):
"""Set of (inode, type, mode, start, end) locks held by this pid."""
import sys as _sys
if not _sys.platform.startswith("linux"):
pytest.skip("lock-table probe requires /proc/locks (Linux)")
inodes = {}
for p in paths:
try:
inodes[os.stat(p).st_ino] = str(p)
except OSError:
continue
pid = os.getpid()
held = set()
for line in Path("/proc/locks").read_text().splitlines():
parts = line.split()
try:
lpid = int(parts[4])
ino = int(parts[5].split(":")[2])
except (IndexError, ValueError):
continue
if lpid == pid and ino in inodes:
held.add((ino, parts[1], parts[3], parts[6], parts[7]))
return held
def test_identity_probe_does_not_cancel_live_posix_locks(tmp_path):
"""The on-write header probe must not drop the writer's DMS lock."""
from hermes_state import _read_sqlite_application_id
live = tmp_path / "state.db"
db = _make_db(live, "probe-sess", "seed")
try:
sidecars = [live, Path(str(live) + "-shm")]
# Hold an open write transaction: that is when the connection holds
# POSIX range locks on the main db file, and exactly the state a
# concurrent _raise_if_db_replaced probe (another thread, same
# process) can destroy.
db._conn.execute("BEGIN IMMEDIATE")
db._conn.execute(
"UPDATE sessions SET source = source WHERE id = 'probe-sess'"
)
before = _posix_locks_on(sidecars)
assert before, "expected in-transaction WAL connection to hold POSIX locks"
for _ in range(3):
_read_sqlite_application_id(live)
after = _posix_locks_on(sidecars)
db._conn.rollback()
lost = before - after
assert not lost, (
"identity probe cancelled POSIX locks held by the live "
f"connection (howtocorrupt §2.2): {lost}"
)
# The decisive check: the WAL DMS shared lock on the MAIN db file
# must survive. With the pre-fix open/read/close probe the close()
# cancels it (it is already gone by the time the connection has run
# its first identity check in __init__), leaving other processes
# free to treat this writer as dead and rerun WAL-index recovery
# underneath it.
db_ino = os.stat(live).st_ino
main_db_locks = {lk for lk in after if lk[0] == db_ino}
assert main_db_locks, (
"live writer connection holds no POSIX lock on state.db itself — "
"the WAL DMS lock was cancelled by a raw open/close probe "
"(howtocorrupt §2.2)"
)
# The connection must still be able to commit.
db.append_message("probe-sess", role="user", content="post-probe")
finally:
db.close()
def test_identity_probe_still_detects_replacement_after_fd_cache(tmp_path):
"""The cached-fd probe rebinds when the path names a new inode."""
from hermes_state import _read_sqlite_application_id
live = tmp_path / "state.db"
other = tmp_path / "other.db"
db = _make_db(live, "live-sess", "original")
_require_identity(db)
first = _read_sqlite_application_id(live) # populates the fd cache
db.close()
alt = _make_db(other, "other-sess", "replacement")
alt.close()
os.replace(other, live)
second = _read_sqlite_application_id(live)
assert second is not None
assert second != first, (
"probe kept reading the retired inode instead of rebinding to the "
"replacement file"
)