359 lines
13 KiB
Python
359 lines
13 KiB
Python
"""An existing database's journal mode must not be rewritten silently (#89293).
|
|
|
|
``PRAGMA journal_mode`` is a property of the FILE. Switching an existing
|
|
database to WAL rewrites its header and outlives the process that did it.
|
|
|
|
``apply_wal_with_fallback`` already treats on-disk WAL as authoritative and
|
|
refuses to live-downgrade it. The mirror case had no protection at all: an
|
|
on-disk DELETE database was flipped to WAL by the *default* configured value,
|
|
with no log line -- so an operator who set DELETE on the file (the documented
|
|
mitigation for the SQLite 3.50.4 WAL-reset bug) had no way to learn their
|
|
choice had been overwritten, or that ``database.journal_mode`` is the lever
|
|
that makes it stick.
|
|
|
|
#89293 is the field report: after upgrading past the vulnerable SQLite,
|
|
``is_sqlite_wal_reset_vulnerable()`` stopped short-circuiting to DELETE and
|
|
4 of 5 databases silently returned to WAL.
|
|
|
|
This is a LOG-ONLY change. Half of these tests exist to prove that: the return
|
|
value, the resulting header, and the never-live-downgrade rule are all pinned
|
|
unchanged, and the brand-new-database case must stay silent or the warning
|
|
would fire on every fresh install.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
|
|
def _write_config(monkeypatch: pytest.MonkeyPatch, tmp_path, config: object) -> None:
|
|
home = tmp_path / "hermes-home"
|
|
home.mkdir(exist_ok=True)
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
(home / "config.yaml").write_text(yaml.safe_dump(config), encoding="utf-8")
|
|
|
|
|
|
def _configure_mode(monkeypatch: pytest.MonkeyPatch, tmp_path, mode: object) -> None:
|
|
_write_config(monkeypatch, tmp_path, {"database": {"journal_mode": mode}})
|
|
|
|
|
|
def _disable_vulnerable_gate(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(
|
|
"hermes_state.is_sqlite_wal_reset_vulnerable",
|
|
lambda **kwargs: False,
|
|
)
|
|
|
|
|
|
def _make_delete_db_with_content(path) -> None:
|
|
"""Create a real, non-empty database left in journal_mode=DELETE."""
|
|
conn = sqlite3.connect(str(path))
|
|
try:
|
|
assert conn.execute("PRAGMA journal_mode=DELETE").fetchone()[0].lower() == "delete"
|
|
conn.execute("CREATE TABLE t (x)")
|
|
conn.execute("INSERT INTO t VALUES (1)")
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_dedup():
|
|
"""Order-independence: the warning is deduped per process per db_label."""
|
|
import hermes_state
|
|
|
|
hermes_state._journal_upgrade_warned_paths.clear()
|
|
yield
|
|
hermes_state._journal_upgrade_warned_paths.clear()
|
|
|
|
|
|
class TestTheContentProbe:
|
|
"""``_database_has_content`` is what keeps fresh installs quiet."""
|
|
|
|
def test_a_brand_new_database_has_no_content(self, tmp_path):
|
|
from hermes_state import _database_has_content
|
|
|
|
conn = sqlite3.connect(str(tmp_path / "new.db"))
|
|
try:
|
|
assert _database_has_content(conn) is False
|
|
finally:
|
|
conn.close()
|
|
|
|
def test_a_database_with_a_table_has_content(self, tmp_path):
|
|
from hermes_state import _database_has_content
|
|
|
|
path = tmp_path / "used.db"
|
|
_make_delete_db_with_content(path)
|
|
conn = sqlite3.connect(str(path))
|
|
try:
|
|
assert _database_has_content(conn) is True
|
|
finally:
|
|
conn.close()
|
|
|
|
def test_an_unreadable_probe_answers_no_content(self, tmp_path):
|
|
"""Fail-quiet.
|
|
|
|
Answering True on an error would emit the warning for a database we
|
|
could not measure, which includes every fresh one.
|
|
"""
|
|
from hermes_state import _database_has_content
|
|
|
|
conn = sqlite3.connect(":memory:")
|
|
try:
|
|
conn.close()
|
|
assert _database_has_content(conn) is False
|
|
finally:
|
|
pass
|
|
|
|
|
|
class TestTheWarningFires:
|
|
|
|
def test_an_existing_delete_database_warns_when_flipped(
|
|
self, monkeypatch, tmp_path, caplog
|
|
):
|
|
from hermes_state import apply_wal_with_fallback
|
|
|
|
_configure_mode(monkeypatch, tmp_path, "wal")
|
|
_disable_vulnerable_gate(monkeypatch)
|
|
path = tmp_path / "existing-delete.db"
|
|
_make_delete_db_with_content(path)
|
|
|
|
conn = sqlite3.connect(str(path))
|
|
try:
|
|
with caplog.at_level("WARNING", logger="hermes_state"):
|
|
assert apply_wal_with_fallback(conn, db_label="state.db") == "wal"
|
|
finally:
|
|
conn.close()
|
|
|
|
blob = "\n".join(r.getMessage() for r in caplog.records)
|
|
assert "state.db" in blob
|
|
assert "delete" in blob.lower()
|
|
|
|
def test_the_warning_names_the_setting_that_makes_it_stick(
|
|
self, monkeypatch, tmp_path, caplog
|
|
):
|
|
"""The whole point.
|
|
|
|
Telling an operator their mode changed, without telling them which
|
|
lever survives an open, leaves them doing the same PRAGMA again.
|
|
"""
|
|
from hermes_state import apply_wal_with_fallback
|
|
|
|
_configure_mode(monkeypatch, tmp_path, "wal")
|
|
_disable_vulnerable_gate(monkeypatch)
|
|
path = tmp_path / "existing-delete.db"
|
|
_make_delete_db_with_content(path)
|
|
|
|
conn = sqlite3.connect(str(path))
|
|
try:
|
|
with caplog.at_level("WARNING", logger="hermes_state"):
|
|
apply_wal_with_fallback(conn, db_label="state.db")
|
|
finally:
|
|
conn.close()
|
|
|
|
blob = "\n".join(r.getMessage() for r in caplog.records)
|
|
assert "database.journal_mode" in blob, (
|
|
"the warning must name the config key, not just report the change"
|
|
)
|
|
|
|
def test_the_flip_still_happens(self, monkeypatch, tmp_path):
|
|
"""Log-only: WAL is still applied, and it still persists.
|
|
|
|
Staying on DELETE is itself treated as a defect elsewhere in the tree
|
|
(managed_uv repairs it on update, citing ~2600x slower appends), so
|
|
this must warn about the change without preventing it.
|
|
"""
|
|
from hermes_state import apply_wal_with_fallback
|
|
|
|
_configure_mode(monkeypatch, tmp_path, "wal")
|
|
_disable_vulnerable_gate(monkeypatch)
|
|
path = tmp_path / "existing-delete.db"
|
|
_make_delete_db_with_content(path)
|
|
|
|
conn = sqlite3.connect(str(path))
|
|
try:
|
|
assert apply_wal_with_fallback(conn, db_label="state.db") == "wal"
|
|
assert conn.execute("PRAGMA journal_mode").fetchone()[0].lower() == "wal"
|
|
finally:
|
|
conn.close()
|
|
|
|
def test_it_fires_once_per_process_per_database(
|
|
self, monkeypatch, tmp_path, caplog
|
|
):
|
|
"""kanban opens a connection per operation; undeduped this is a flood."""
|
|
from hermes_state import apply_wal_with_fallback
|
|
|
|
_configure_mode(monkeypatch, tmp_path, "wal")
|
|
_disable_vulnerable_gate(monkeypatch)
|
|
|
|
with caplog.at_level("WARNING", logger="hermes_state"):
|
|
for name in ("a", "b"):
|
|
path = tmp_path / f"{name}.db"
|
|
_make_delete_db_with_content(path)
|
|
conn = sqlite3.connect(str(path))
|
|
try:
|
|
apply_wal_with_fallback(conn, db_label="same-label.db")
|
|
finally:
|
|
conn.close()
|
|
|
|
hits = [r for r in caplog.records if "same-label.db" in r.getMessage()]
|
|
assert len(hits) == 1, f"expected one deduped warning, got {len(hits)}"
|
|
|
|
def test_a_second_database_gets_its_own_warning(
|
|
self, monkeypatch, tmp_path, caplog
|
|
):
|
|
"""#89293 saw four databases flip. Dedup is per label, not global."""
|
|
from hermes_state import apply_wal_with_fallback
|
|
|
|
_configure_mode(monkeypatch, tmp_path, "wal")
|
|
_disable_vulnerable_gate(monkeypatch)
|
|
|
|
with caplog.at_level("WARNING", logger="hermes_state"):
|
|
for label in ("state.db", "kanban.db"):
|
|
path = tmp_path / f"{label}"
|
|
_make_delete_db_with_content(path)
|
|
conn = sqlite3.connect(str(path))
|
|
try:
|
|
apply_wal_with_fallback(conn, db_label=label)
|
|
finally:
|
|
conn.close()
|
|
|
|
blob = "\n".join(r.getMessage() for r in caplog.records)
|
|
assert "state.db" in blob and "kanban.db" in blob
|
|
|
|
|
|
class TestTheWarningStaysQuiet:
|
|
"""Every one of these would be a false positive shipped to every user."""
|
|
|
|
def test_a_brand_new_database_is_silent(self, monkeypatch, tmp_path, caplog):
|
|
"""The load-bearing guard.
|
|
|
|
A fresh file reports journal_mode=delete (SQLite's default) and is
|
|
about to be switched to WAL, which looks identical to the reported
|
|
bug from `current_mode` alone. Only page_count tells them apart, and
|
|
every opener applies WAL before creating any schema -- so without
|
|
this guard the warning fires on every first run of every install.
|
|
"""
|
|
from hermes_state import apply_wal_with_fallback
|
|
|
|
_configure_mode(monkeypatch, tmp_path, "wal")
|
|
_disable_vulnerable_gate(monkeypatch)
|
|
|
|
conn = sqlite3.connect(str(tmp_path / "fresh.db"))
|
|
try:
|
|
with caplog.at_level("WARNING", logger="hermes_state"):
|
|
assert apply_wal_with_fallback(conn, db_label="fresh.db") == "wal"
|
|
finally:
|
|
conn.close()
|
|
|
|
assert not [r for r in caplog.records if "fresh.db" in r.getMessage()]
|
|
|
|
def test_an_existing_wal_database_is_silent(self, monkeypatch, tmp_path, caplog):
|
|
"""No flip happens: the probe returns early. Nothing to report."""
|
|
from hermes_state import apply_wal_with_fallback
|
|
|
|
_configure_mode(monkeypatch, tmp_path, "wal")
|
|
_disable_vulnerable_gate(monkeypatch)
|
|
path = tmp_path / "already-wal.db"
|
|
conn = sqlite3.connect(str(path))
|
|
try:
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
conn.execute("CREATE TABLE t (x)")
|
|
conn.commit()
|
|
with caplog.at_level("WARNING", logger="hermes_state"):
|
|
assert apply_wal_with_fallback(conn, db_label="already-wal.db") == "wal"
|
|
finally:
|
|
conn.close()
|
|
|
|
assert not [r for r in caplog.records if "already-wal.db" in r.getMessage()]
|
|
|
|
def test_configured_delete_is_silent(self, monkeypatch, tmp_path, caplog):
|
|
"""The operator used the durable lever. There is nothing to tell them."""
|
|
from hermes_state import apply_wal_with_fallback
|
|
|
|
_configure_mode(monkeypatch, tmp_path, "delete")
|
|
_disable_vulnerable_gate(monkeypatch)
|
|
path = tmp_path / "configured-delete.db"
|
|
_make_delete_db_with_content(path)
|
|
|
|
conn = sqlite3.connect(str(path))
|
|
try:
|
|
with caplog.at_level("WARNING", logger="hermes_state"):
|
|
assert apply_wal_with_fallback(conn, db_label="configured-delete.db") == "delete"
|
|
assert conn.execute("PRAGMA journal_mode").fetchone()[0].lower() == "delete"
|
|
finally:
|
|
conn.close()
|
|
|
|
assert not [
|
|
r for r in caplog.records if "configured-delete.db" in r.getMessage()
|
|
]
|
|
|
|
def test_the_wal_reset_vulnerable_path_is_silent(
|
|
self, monkeypatch, tmp_path, caplog
|
|
):
|
|
"""That branch returns before any flip, so it must not warn.
|
|
|
|
It is also the branch that KEPT #89293's databases on DELETE before
|
|
the SQLite upgrade -- warning here would blame the guard that was
|
|
doing its job.
|
|
"""
|
|
from hermes_state import apply_wal_with_fallback
|
|
|
|
_configure_mode(monkeypatch, tmp_path, "wal")
|
|
monkeypatch.setattr(
|
|
"hermes_state.is_sqlite_wal_reset_vulnerable",
|
|
lambda **kwargs: True,
|
|
)
|
|
path = tmp_path / "vulnerable.db"
|
|
_make_delete_db_with_content(path)
|
|
|
|
conn = sqlite3.connect(str(path))
|
|
try:
|
|
with caplog.at_level("WARNING", logger="hermes_state"):
|
|
apply_wal_with_fallback(conn, db_label="vulnerable.db")
|
|
finally:
|
|
conn.close()
|
|
|
|
assert not [
|
|
r
|
|
for r in caplog.records
|
|
if "database.journal_mode" in r.getMessage()
|
|
]
|
|
|
|
|
|
class TestTheExistingContractIsUnchanged:
|
|
"""Behaviour preservation for the rules this change sits next to."""
|
|
|
|
def test_on_disk_wal_is_still_never_live_downgraded(self, monkeypatch, tmp_path):
|
|
from hermes_state import apply_wal_with_fallback
|
|
|
|
_configure_mode(monkeypatch, tmp_path, "delete")
|
|
path = tmp_path / "existing-wal.db"
|
|
conn = sqlite3.connect(str(path))
|
|
try:
|
|
assert conn.execute("PRAGMA journal_mode=WAL").fetchone()[0].lower() == "wal"
|
|
monkeypatch.setattr(
|
|
"hermes_state.is_sqlite_wal_reset_vulnerable",
|
|
lambda **kwargs: True,
|
|
)
|
|
assert apply_wal_with_fallback(conn, db_label="existing-wal.db") == "wal"
|
|
assert conn.execute("PRAGMA journal_mode").fetchone()[0].lower() == "wal"
|
|
finally:
|
|
conn.close()
|
|
|
|
def test_default_config_still_yields_wal_on_a_fresh_database(
|
|
self, monkeypatch, tmp_path
|
|
):
|
|
from hermes_state import apply_wal_with_fallback
|
|
|
|
_configure_mode(monkeypatch, tmp_path, "wal")
|
|
_disable_vulnerable_gate(monkeypatch)
|
|
conn = sqlite3.connect(str(tmp_path / "default.db"))
|
|
try:
|
|
assert apply_wal_with_fallback(conn, db_label="default.db") == "wal"
|
|
assert conn.execute("PRAGMA journal_mode").fetchone()[0].lower() == "wal"
|
|
finally:
|
|
conn.close()
|