Files
hermes-agent/tests/test_zeroed_state_db.py
T
Jasmine Naderi 6048696ed0 fix(state): cross-process quarantine lock + oversized DB pruning suppression (#68805)
Reviewer egilewski identified two recovery-loss paths:

Path A — quarantine race (hermes_state.py):
SessionDB checks state.db before quarantine_zeroed_state_db() without a
shared cross-process lock, and Path.rename() may replace an existing
destination. Two writable startups can therefore move the first
instance's newly created database over the .zeroed-*.bak, erase the
original damaged-file evidence, and replace the live database with
another empty one.

Fix: add a cross-process lock (msvcrt on Windows, fcntl on POSIX)
around quarantine_zeroed_state_db() with a 5s bounded timeout. Under
the lock: re-check is_zeroed_state_db (another process may have already
quarantined it and created a fresh DB), use a PID-suffixed unique
destination, and non-clobbering rename with counter fallback.

Path B — size-cap pruning gap (hermes_cli/backup.py):
_too_large() runs before failed-database tracking. With keep=1 and a
size cap, an oversized state.db is omitted while failed_dbs stays empty,
so automatic pruning deletes the older complete snapshot that may
contain the only recoverable database.

Fix: track oversized DB files in a new oversized_skipped list (both in
the directory walk and top-level file loop). The manifest now records
oversized_skipped. Pruning is suppressed when failed_dbs or
oversized_skipped is non-empty, preserving the older complete snapshot
as recovery source.

Tests:
- test_concurrent_quarantine_no_clobber: two threads racing on the
  same zeroed state.db — verifies quarantine backup survives with
  original bytes and live DB is valid.
- test_oversized_db_suppresses_pruning: keep=1 + oversized state.db
  verifies the older complete snapshot is not pruned.

All 33 tests pass (3 zeroed_state_db + 25 TestQuickSnapshot + 5
quarantine_forensic_logging).
2026-07-24 23:06:05 -07:00

104 lines
3.2 KiB
Python

"""#68474 hardening: zeroed state.db detection + quarantine."""
from __future__ import annotations
from pathlib import Path
import pytest
def test_is_zeroed_state_db_and_quarantine(tmp_path):
import hermes_state as hs
db = tmp_path / "state.db"
db.write_bytes(bytes(1024))
assert hs.is_zeroed_state_db(db) is True
q = hs.quarantine_zeroed_state_db(db)
assert q is not None
assert q.exists()
assert not db.exists()
assert q.read_bytes() == bytes(1024)
def test_sessiondb_opens_fresh_after_zeroed_quarantine(tmp_path, monkeypatch):
import hermes_state as hs
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
db = tmp_path / "state.db"
db.write_bytes(bytes(4096))
sdb = hs.SessionDB(db_path=db)
try:
# Fresh DB should open and accept schema
assert db.exists()
assert not hs.is_zeroed_state_db(db)
# Quarantine retained
backups = list(tmp_path.glob("state.db.zeroed-*.bak"))
assert len(backups) == 1
assert backups[0].stat().st_size == 4096
finally:
sdb.close()
def test_concurrent_quarantine_no_clobber(tmp_path):
"""#68805: two concurrent startups must not race on quarantine.
Without the cross-process lock, the second process could move its
newly-created empty DB over the first process's quarantine backup,
erasing the original damaged-file evidence. With the lock, the
second process re-checks under the lock, finds the file no longer
zeroed (or gone), and returns without clobbering.
"""
import hermes_state as hs
import threading
import sqlite3
db = tmp_path / "state.db"
db.write_bytes(bytes(4096)) # zeroed (all-NUL) 4 KB file
results: list = [None, None]
errors: list = [None, None]
def worker(idx):
try:
# Each worker opens its own SessionDB on the same path.
# The first one quarantines the zeroed file and creates a
# fresh DB. The second one should find a valid DB (or no
# file) under the lock and NOT clobber the quarantine.
sdb = hs.SessionDB(db_path=db)
try:
results[idx] = "ok"
finally:
sdb.close()
except Exception as exc:
errors[idx] = exc
t1 = threading.Thread(target=worker, args=(0,))
t2 = threading.Thread(target=worker, args=(1,))
t1.start()
t2.start()
t1.join(timeout=10)
t2.join(timeout=10)
# Both workers should complete without error
assert errors[0] is None, f"Worker 0 raised: {errors[0]}"
assert errors[1] is None, f"Worker 1 raised: {errors[1]}"
# The quarantine backup must survive — exactly one .bak file with
# the original 4096 zeroed bytes.
backups = list(tmp_path.glob("state.db.zeroed-*.bak"))
assert len(backups) >= 1, "At least one quarantine backup must exist"
for bak in backups:
assert bak.stat().st_size == 4096, (
f"Quarantine backup {bak} was clobbered: "
f"expected 4096 bytes, got {bak.stat().st_size}"
)
# The live state.db must be a valid (non-zeroed) SQLite database
assert db.exists()
assert not hs.is_zeroed_state_db(db)
conn = sqlite3.connect(str(db))
conn.execute("SELECT 1")
conn.close()