From c3e7c0d3d6d47e82dc096eadabe796a5af79c32c Mon Sep 17 00:00:00 2001 From: Joshua King Date: Mon, 14 Sep 2026 08:45:48 -0400 Subject: [PATCH] fix: immutable=1 probe URI (no sidecar creation guaranteed), fs work inside executor job, WAL-header corruption test case - probe URI adds immutable=1: SQLite's documented guarantee that the read-only connection performs no writes and creates no -wal/-shm sidecars, even against a WAL-header database - exists/stat/resolve and URI construction moved inside the executor job with the probe (no event-loop disk I/O) - new WAL-header corruption test asserts byte-identical file and no -shm creation from the probe --- custom_components/nestquest/__init__.py | 30 ++++++++++++++--------- tests/test_startup_db.py | 32 +++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index a3dcd24..ac2e77d 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -66,19 +66,27 @@ async def _preflight_existing_file( that rewrites the header byte 18/19 and can create -wal/-shm sidecars — which would modify a corrupt file before detection and break the leave-untouched guarantee. This preflight therefore - validates the WHOLE file with a READ-ONLY connection (mode=ro URI, - PRAGMA integrity_check, run on the executor like every other DB - call): corruption anywhere fails there, before anything is - written. Missing/empty files skip the preflight (they are created - fresh by the real open). + validates the WHOLE file with a READ-ONLY connection (mode=ro + + immutable=1 URI, PRAGMA integrity_check, run on the executor like + every other DB call): corruption anywhere fails there, before + anything is written. ``immutable=1`` is SQLite's documented + guarantee that the connection performs NO writes and creates NO + sidecars — even a WAL-header file gets its -wal/-shm reads served + without creating the shared-memory file. Missing/empty files skip + the preflight (they are created fresh by the real open). All + filesystem stat/URI work runs inside the executor job with the + probe so the event loop never touches disk. """ - if not db_path.exists() or db_path.stat().st_size == 0: - return - # Properly escape the path: '#'/'?'/'%' in a directory or file name - # would otherwise be parsed as URI fragments/parameters/escapes. - uri = db_path.resolve().as_uri() + "?mode=ro" - def _probe() -> None: + # The file-state check and URI construction live inside the + # executor job: exists/stat/resolve are disk I/O. + if not db_path.exists() or db_path.stat().st_size == 0: + return + # Properly escape the path: '#'/'?'/'%' in a directory or file + # name would otherwise be parsed as URI fragments/parameters/ + # escapes. immutable=1: SQLite guarantees no writes and no + # sidecar creation from this connection. + uri = db_path.resolve().as_uri() + "?mode=ro&immutable=1" conn = sqlite3.connect(uri, uri=True) try: row = conn.execute("PRAGMA integrity_check").fetchone() diff --git a/tests/test_startup_db.py b/tests/test_startup_db.py index f9a222d..5b476f0 100644 --- a/tests/test_startup_db.py +++ b/tests/test_startup_db.py @@ -250,6 +250,38 @@ def test_migration_stage_corruption_leaves_db_and_sidecars_untouched( assert not (tmp_path / "nestquest.db-shm").exists() +def test_wal_mode_corruption_leaves_file_and_sidecars_untouched( + tmp_path, monkeypatch +) -> None: + """A corrupt WAL-HEADER file must be rejected without creating + -shm sidecars from the read-only preflight itself. + + The immutable=1 probe URI is what guarantees a read-only + connection creates no sidecars even for a WAL-header database; the + sidecar assertions after the call prove it. + """ + db_path = tmp_path / "nestquest.db" + # WAL fixture: created via the wrapper (journal_mode=WAL persists). + _valid_database(db_path) + data = bytearray(db_path.read_bytes()) + # Corrupt the WAL header flag region: bytes 18/19 hold the + # read/write version numbers (2 = WAL). Flip them to garbage so + # the file claims a broken journal mode. + assert len(data) > 64 + data[18] = 0x99 + data[19] = 0x99 + db_path.write_bytes(bytes(data)) + corrupt_bytes = db_path.read_bytes() + digest = hashlib.sha256(corrupt_bytes).hexdigest() + + with pytest.raises(ConfigEntryNotReady): + _run(_async_open_database(_make_hass_mock(), db_path)) + + # Main file byte-identical; no -shm appeared from the probe. + assert hashlib.sha256(db_path.read_bytes()).hexdigest() == digest + assert not (tmp_path / "nestquest.db-shm").exists() + + def test_open_failure_closes_connection_when_opened( tmp_path, monkeypatch ) -> None: