fix: non-ok integrity verdicts are corruption, escaped file URIs, proven no-WAL-transition test
- a non-ok integrity_check verdict raises DatabaseError carrying the NOTADB marker so it classifies as corruption unconditionally; ANY DatabaseError escaping the probe propagates (no more swallowing structural problems as non-corruption) - file URI built via Path.resolve().as_uri() so #/?/% in paths cannot mis-target the probe - migration-stage test now spies on NestQuestDatabase.open and asserts the read-write open was NEVER reached: the whole-file preflight rejecting corruption before the WAL transition is the mechanism under test, and the byte-identical + no-sidecar assertions prove it - store guard permits SELECT + PRAGMA integrity_check on the ro-conn
This commit is contained in:
@@ -178,18 +178,17 @@ def test_corrupt_file_raises_not_ready_never_raw_error(tmp_path) -> None:
|
||||
|
||||
|
||||
def test_migration_stage_corruption_leaves_db_and_sidecars_untouched(
|
||||
tmp_path,
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
"""A valid header with a corrupt LATER page is detected during
|
||||
migrations, AFTER the read-write open has switched the file to WAL.
|
||||
The leave-untouched guarantee must still hold byte-for-byte for
|
||||
the main file AND no WAL/SHM sidecars may be left behind for the
|
||||
owner to clean up.
|
||||
"""A valid header with a corrupt LATER page must be rejected by the
|
||||
whole-file read-only preflight BEFORE the read-write open can
|
||||
switch the journal mode to WAL.
|
||||
|
||||
The fixture is deliberately a ROLLBACK-JOURNAL file (created with
|
||||
plain sqlite3, default journal_mode=delete, NOT via the wrapper):
|
||||
that is the case where the WAL transition actually rewrites the
|
||||
header — a WAL-mode fixture would mask the write under test.
|
||||
header — a WAL-mode fixture would mask the write under test. The
|
||||
open() spy proves the read-write open was never reached.
|
||||
"""
|
||||
db_path = tmp_path / "nestquest.db"
|
||||
conn = sqlite3.connect(db_path)
|
||||
@@ -219,10 +218,30 @@ def test_migration_stage_corruption_leaves_db_and_sidecars_untouched(
|
||||
corrupt_bytes = db_path.read_bytes()
|
||||
digest = hashlib.sha256(corrupt_bytes).hexdigest()
|
||||
|
||||
# Track whether the read-write open is reached at all: the whole
|
||||
# preflight must reject this corruption BEFORE NestQuestDatabase
|
||||
# .open() is ever called, which is the only way the file can stay
|
||||
# byte-identical (the open persists WAL, rewriting the header).
|
||||
open_calls: list[str] = []
|
||||
original_open = NestQuestDatabase.open
|
||||
|
||||
async def _spy_open(self, path):
|
||||
open_calls.append(str(path))
|
||||
return await original_open(self, path)
|
||||
|
||||
monkeypatch.setattr(NestQuestDatabase, "open", _spy_open)
|
||||
|
||||
with pytest.raises(ConfigEntryNotReady) as excinfo:
|
||||
_run(_async_open_database(_make_hass_mock(), db_path))
|
||||
assert isinstance(excinfo.value.__cause__, sqlite3.DatabaseError)
|
||||
|
||||
# The corruption was caught at the preflight: the read-write open
|
||||
# (and with it the WAL header rewrite) was never attempted.
|
||||
assert open_calls == [], (
|
||||
"whole-file preflight must reject corruption before the "
|
||||
"read-write open can transition the journal mode"
|
||||
)
|
||||
|
||||
# The main file is byte-identical to the corrupt state we handed in.
|
||||
assert hashlib.sha256(db_path.read_bytes()).hexdigest() == digest
|
||||
# No WAL/SHM sidecars left behind (WAL mode was never persisted,
|
||||
|
||||
+4
-3
@@ -143,18 +143,19 @@ def test_init_module_never_opens_or_executes_sqlite() -> None:
|
||||
Path(store.__file__).parent / "__init__.py"
|
||||
).read_text(encoding="utf-8")
|
||||
# The preflight connect must be the URI read-only form, and only
|
||||
# SELECT statements may run through it.
|
||||
# SELECT statements and the integrity_check PRAGMA may run there.
|
||||
assert "sqlite3.connect(uri, uri=True)" in init_path
|
||||
assert "?mode=ro" in init_path
|
||||
assert 'PRAGMA integrity_check' in init_path
|
||||
forbidden = re.compile(
|
||||
r"sqlite3\s*\.\s*connect(?!\(\s*uri\s*,)"
|
||||
r"|\bconn(ection)?\s*\.\s*execute\s*\(\s*[\"'](?!SELECT)"
|
||||
r"|\bconn(ection)?\s*\.\s*execute\s*\(\s*[\"'](?!SELECT\s|PRAGMA\s+integrity_check)"
|
||||
r"|\bexecutemany\b|\bexecutescript\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
assert forbidden.search(init_path) is None, (
|
||||
"__init__.py may use sqlite3 only for exception typing and the "
|
||||
"read-only corruption preflight"
|
||||
"read-only corruption preflight (SELECT + integrity_check)"
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user