300 lines
11 KiB
Python
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"
|
|
)
|