fix(state): resolve symlinks in deleted-WAL sidecar watch paths

/proc/<pid>/fd reports the kernel-resolved dentry, but
_watched_sqlite_sidecar_paths built its canonical watch keys with
abspath, which never resolves symlinks. With a symlinked HERMES_HOME
every deleted state.db-wal/-shm generation was invisible to the scan,
so refuse_deleted_wal_generation never fired and a second opener could
mint a replacement WAL -- the split-brain the guard exists to prevent.

foreign_state_db_holders and the macOS libproc leg already compare
against realpath'd paths for exactly this reason; only the Linux
deleted-sidecar scan was still on abspath. Watch both resolved
spellings per sidecar -- the realpath'd parent plus the literal
basename, and the fully resolved path -- because SQLite canonicalizes
a symlinked database file before naming its sidecars while older
versions name them after the path opened.
This commit is contained in:
beardthelion
2026-09-19 17:17:34 -05:00
committed by Teknium
parent 7ad0a7f325
commit 271a467009
2 changed files with 99 additions and 3 deletions

View File

@@ -136,9 +136,24 @@ def _stat_sqlite_sidecar_identity(db_path: Path) -> Dict[str, tuple]:
def _watched_sqlite_sidecar_paths(db_path) -> Dict[str, str]:
"""Map each sidecar's canonical (/proc-comparable) form to its literal, still-named path,
so a canonical match can be re-``stat``'d for identity rather than trusted as text."""
base = os.path.abspath(os.fspath(db_path))
literal = (base + "-wal", base + "-shm")
return {canonical_sqlite_path(path): path for path in literal}
literal_base = os.path.abspath(os.fspath(db_path))
literal = (literal_base + "-wal", literal_base + "-shm")
watched = {canonical_sqlite_path(path): path for path in literal}
# /proc reports the kernel-resolved dentry, so the watched canonicals must also resolve
# symlinks -- with abspath alone a symlinked HERMES_HOME makes every deleted sidecar
# invisible to the scan. Both spellings are watched: the fully resolved path, which is
# where current SQLite places -wal/-shm when the database file itself is a symlink, and
# the realpath'd parent with the literal basename, which is where they land when SQLite
# names the sidecars after the path it was opened through.
resolved_bases = (
os.path.join(os.path.realpath(os.path.dirname(literal_base)),
os.path.basename(literal_base)),
os.path.realpath(literal_base),
)
for base in resolved_bases:
for suffix in ("-wal", "-shm"):
watched.setdefault(canonical_sqlite_path(base + suffix), base + suffix)
return watched
def _identity_is_truly_unlinked(identity: "Tuple[int, int]", watched_path: str) -> bool:

View File

@@ -112,6 +112,87 @@ def test_iter_finds_self_after_wal_unlink(tmp_path, force_wal):
db.close()
@pytest.mark.skipif(
not sys.platform.startswith("linux"),
reason="deleted-WAL /proc scan is Linux-only",
)
def test_iter_finds_holder_through_symlinked_home(tmp_path, force_wal):
"""A symlinked HERMES_HOME must not hide a deleted WAL generation: /proc reports
the kernel-resolved dentry while the caller holds only the alias path."""
real_home = tmp_path / "hermes-real"
real_home.mkdir()
link_home = tmp_path / "hermes-link"
link_home.symlink_to(real_home)
alias_db = link_home / "state.db"
db = make_db(alias_db, "s", "held-through-alias")
require_wal(db)
lose_sidecars(alias_db, rename=False)
try:
holders = iter_deleted_sqlite_sidecar_holders(alias_db)
assert holders, "deleted WAL held under the real path must be found via the alias"
assert any("(deleted)" in target for _pid, target in holders)
with pytest.raises(DeletedWalGenerationError, match="deleted state.db-wal"):
refuse_deleted_wal_generation(alias_db)
finally:
db.close()
@pytest.mark.skipif(
not sys.platform.startswith("linux"),
reason="deleted-WAL /proc scan is Linux-only",
)
def test_second_sessiondb_open_refuses_through_symlinked_home(tmp_path, force_wal):
"""End to end through the real open path: SessionDB stores the alias verbatim and
calls the guard with it before connect, so the refusal must fire via the alias."""
real_home = tmp_path / "hermes-real"
real_home.mkdir()
link_home = tmp_path / "hermes-link"
link_home.symlink_to(real_home)
alias_db = link_home / "state.db"
writer = make_db(alias_db, "s", "held")
require_wal(writer)
lose_sidecars(alias_db, rename=False)
try:
with pytest.raises(DeletedWalGenerationError, match="deleted state.db-wal"):
SessionDB(db_path=alias_db)
assert not Path(os.fspath(alias_db) + "-wal").exists()
finally:
writer.close()
@pytest.mark.skipif(
not sys.platform.startswith("linux"),
reason="deleted-WAL /proc scan is Linux-only",
)
def test_iter_finds_holder_when_db_file_itself_is_symlink(tmp_path):
"""SQLite canonicalizes the db filename before naming sidecars, so a symlinked
state.db puts the WAL under the target's name. The scan must still match."""
db_dir = tmp_path / "dbdir"
db_dir.mkdir()
target_dir = tmp_path / "targetdir"
target_dir.mkdir()
link_db = db_dir / "state.db"
link_db.symlink_to(target_dir / "x.db")
conn = sqlite3.connect(os.fspath(link_db))
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("CREATE TABLE t(x)")
conn.execute("INSERT INTO t VALUES (1)")
conn.commit()
target_wal = target_dir / "x.db-wal"
if not target_wal.exists():
conn.close()
pytest.skip("this SQLite did not canonicalize the symlinked db path")
target_wal.unlink()
try:
holders = iter_deleted_sqlite_sidecar_holders(link_db)
assert holders, "deleted WAL under the resolved target name must be found"
finally:
conn.close()
@pytest.mark.skipif(
not sys.platform.startswith("linux"),
reason="deleted-WAL /proc scan is Linux-only",