- admin_allowlist.py: list_admin_ids / set_admin_ids (atomic sync of the allowlist inside one transaction under the connection lock; refuses empty targets so the last admin can never be removed and the allowlist can never be emptied — fail closed) and seed_setup_admin (seeds ONLY an empty allowlist: stored entry-data copy first — disaster recovery for a lost database — else the HA user who completed the config flow from the entry context; non-empty lists are never resurrected). - __init__.py: both setup paths (first setup and the reopened-record path) seed after migrations; a failed seed closes the database and re-raises through the existing error handling. - options_flow.py: the admin picker is a multi-select of EXISTING HA users (hass.auth.async_get_users), defaulted to the current database allowlist, membership-checked at the schema level and backed up on the raw-input path; empty selections are refused (no_admins), unknown ids rejected (invalid_admin); the selection is applied to the DB when the runtime record is reachable and always stored on the entry as the recovery copy. Submissions without the key (legacy raw payloads) keep the three-key behaviour untouched. - const.CONF_ADMIN_USER_IDS; strings + translations for the field and the two errors; conftest entries gain a context dict (HA's entry context carries the flow user). - Tests: 20 allowlist business tests (round-trips, refusal matrix, seed precedence and narrowed-list persistence), 5 picker flow tests, 3 setup-seeding lifecycle tests. Suite: 901 passed.
332 lines
13 KiB
Python
332 lines
13 KiB
Python
"""The NestQuest integration."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import inspect
|
|
import sqlite3
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.exceptions import ConfigEntryNotReady
|
|
|
|
from .const import CONF_ADMIN_USER_IDS, DOMAIN, LOGGER
|
|
from .db import NestQuestDatabase
|
|
from .migrations import apply_migrations
|
|
from .store import async_get_db_path
|
|
from .admin_allowlist import seed_setup_admin
|
|
|
|
_LOGGER = LOGGER
|
|
|
|
#: SQLite reports "file is not a database" for corruption in every
|
|
#: sqlite3 version this integration targets; the version-specific
|
|
#: "encrypted or malformed" spelling (3.41+) maps to the same failure.
|
|
_CORRUPTION_MARKERS = (
|
|
"file is not a database",
|
|
"database disk image is malformed",
|
|
"encrypted or malformed",
|
|
"is not a database",
|
|
)
|
|
|
|
#: Primary sqlite error codes that mean "the file is corrupt" (SQLite
|
|
#: result codes, https://www.sqlite.org/c3ref/c_abort.html). Extended
|
|
#: codes carry the primary code in their low byte, so the match is on
|
|
#: errcode & 0xff.
|
|
_CORRUPT_PRIMARY_CODES = frozenset({11, 26}) # SQLITE_CORRUPT, SQLITE_NOTADB
|
|
|
|
|
|
def _is_corruption_error(error: BaseException) -> bool:
|
|
"""Return True when ``error`` indicates a corrupt database file.
|
|
|
|
Classification order: sqlite_errorcode (Python 3.11+) is primary
|
|
and locale-independent — SQLITE_CORRUPT and SQLITE_NOTADB, matched
|
|
on the primary byte so extended codes (SQLITE_CORRUPT_VTAB etc.)
|
|
classify correctly too. Message matching is the fallback for
|
|
older Pythons: the corruption spellings are the documented English
|
|
texts; a non-English locale could evade them, but the code check
|
|
already covers every locale on supported Pythons.
|
|
"""
|
|
if not isinstance(error, sqlite3.DatabaseError):
|
|
return False
|
|
errorcode = getattr(error, "sqlite_errorcode", None)
|
|
if errorcode is not None and (errorcode & 0xFF) in _CORRUPT_PRIMARY_CODES:
|
|
return True
|
|
message = str(error).lower()
|
|
return any(marker in message for marker in _CORRUPTION_MARKERS)
|
|
|
|
|
|
async def _preflight_existing_file(
|
|
hass: HomeAssistant, db_path: Path
|
|
) -> None:
|
|
"""Detect corruption in an EXISTING file before it is opened read-write.
|
|
|
|
Opening read-write persists WAL mode — for a rollback-journal 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 +
|
|
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.
|
|
"""
|
|
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():
|
|
return
|
|
# A zero-byte file is neither missing nor a valid database: it
|
|
# is a corrupt (truncated-to-nothing) existing file, likely an
|
|
# interrupted first write. The leave-untouched contract applies
|
|
# to it too: refuse rather than silently initialize over it.
|
|
if db_path.stat().st_size == 0:
|
|
raise sqlite3.DatabaseError(
|
|
"existing database file is empty (zero bytes): file is "
|
|
"not a database"
|
|
)
|
|
# 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()
|
|
finally:
|
|
conn.close()
|
|
verdict = row[0] if row else "missing"
|
|
if verdict != "ok":
|
|
# A non-ok integrity verdict IS corruption: raise with the
|
|
# verdict text (which matches the corruption markers) and
|
|
# the NOTADB code so classification is code-based.
|
|
raise sqlite3.DatabaseError(
|
|
f"integrity_check verdict {verdict!r} on read-only "
|
|
f"probe: file is not a database"
|
|
)
|
|
|
|
try:
|
|
# Every DB-touching call goes through the executor (the mock
|
|
# harness's async_add_executor_job is awaitable too).
|
|
await hass.async_add_executor_job(_probe)
|
|
except sqlite3.DatabaseError as err:
|
|
# Non-ok integrity verdicts are corruption, full stop: there is
|
|
# no benign reading of a failed whole-file integrity check, so
|
|
# ANY DatabaseError out of the probe that carries a corruption
|
|
# marker or code propagates as corruption. Only exotic probe
|
|
# failures that classify as non-corruption (e.g. a transient
|
|
# 'database is locked' on the read) are downgraded to a debug
|
|
# log, letting the real open path surface them.
|
|
if _is_corruption_error(err):
|
|
raise
|
|
raise sqlite3.DatabaseError(
|
|
f"read-only corruption probe of {db_path} failed: {err}"
|
|
) from err
|
|
|
|
async def _async_open_database(
|
|
hass: HomeAssistant, db_path: Path
|
|
) -> NestQuestDatabase:
|
|
"""Open the NestQuest database, mapping failure modes for setup.
|
|
|
|
Three startup cases (Feature 02 done-condition):
|
|
|
|
- file missing: SQLite creates it fresh on open — no special path,
|
|
migrations stamp version 1 on the next step.
|
|
- file present and valid: opens normally.
|
|
- file present but corrupt: log an ERROR with the path, close
|
|
anything opened, and raise ConfigEntryNotReady so HA retries —
|
|
the file is left byte-for-byte untouched so the owner can
|
|
restore a backup. No code path here deletes or overwrites an
|
|
existing database file.
|
|
|
|
Corruption can surface at EITHER stage: a truncated header fails
|
|
in the read-only preflight (before the file is ever opened
|
|
read-write), while damaged later pages fail during migrations —
|
|
the preflight guarantees no WAL sidecar or header write happens
|
|
on a corrupt file before it is detected. Both are classified the
|
|
same way.
|
|
"""
|
|
try:
|
|
await _preflight_existing_file(hass, db_path)
|
|
database = await NestQuestDatabase(hass).open(db_path)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except sqlite3.DatabaseError as err:
|
|
if _is_corruption_error(err):
|
|
_LOGGER.error(
|
|
"NestQuest database at %s is corrupt: %s. Refusing to "
|
|
"start with a corrupt database; the file was left "
|
|
"untouched so a backup can be restored. Home Assistant "
|
|
"will retry setup.",
|
|
db_path,
|
|
err,
|
|
)
|
|
raise ConfigEntryNotReady(
|
|
f"NestQuest database corrupt: {db_path}"
|
|
) from err
|
|
# open() failed for a non-corruption reason (locked file,
|
|
# permission problem): retryable, but nothing was opened.
|
|
_LOGGER.error(
|
|
"NestQuest database at %s failed to open: %s. Home "
|
|
"Assistant will retry setup.",
|
|
db_path,
|
|
err,
|
|
)
|
|
raise ConfigEntryNotReady(
|
|
f"NestQuest database unavailable: {db_path}"
|
|
) from err
|
|
try:
|
|
await apply_migrations(database)
|
|
except (sqlite3.DatabaseError, RuntimeError) as err:
|
|
await database.close()
|
|
if _is_corruption_error(err):
|
|
_LOGGER.error(
|
|
"NestQuest database at %s is corrupt: %s. Refusing to "
|
|
"start with a corrupt database; the file was left "
|
|
"untouched so a backup can be restored. Home Assistant "
|
|
"will retry setup.",
|
|
db_path,
|
|
err,
|
|
)
|
|
raise ConfigEntryNotReady(
|
|
f"NestQuest database corrupt: {db_path}"
|
|
) from err
|
|
# A non-corruption failure (e.g. a locked file, a future schema
|
|
# version) is also a retryable setup problem, but without the
|
|
# corruption framing.
|
|
_LOGGER.error(
|
|
"NestQuest database at %s failed to open: %s. Home "
|
|
"Assistant will retry setup.",
|
|
db_path,
|
|
err,
|
|
)
|
|
raise ConfigEntryNotReady(
|
|
f"NestQuest database unavailable: {db_path}"
|
|
) from err
|
|
except asyncio.CancelledError:
|
|
await database.close()
|
|
raise
|
|
return database
|
|
|
|
|
|
@dataclass
|
|
class NestQuestRuntimeData:
|
|
"""Runtime data stored on a NestQuest config entry."""
|
|
|
|
entry_id: str
|
|
options: dict[str, Any]
|
|
database: NestQuestDatabase
|
|
remove_update_listener: Callable[[], Any]
|
|
|
|
|
|
async def async_setup(hass: HomeAssistant, config: dict[str, Any]) -> bool:
|
|
"""Set up the NestQuest integration. YAML configuration is not used, returns True."""
|
|
return True
|
|
|
|
|
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
"""Set up NestQuest from a config entry."""
|
|
hass.data.setdefault(DOMAIN, {})
|
|
existing = hass.data[DOMAIN].get(entry.entry_id)
|
|
if existing is not None:
|
|
db = getattr(existing, "database", None)
|
|
if db is None or not db.connected:
|
|
# Reopen through the SAME safety path as first setup: a
|
|
# record without a live connection (a previous setup's
|
|
# close-on-failure, or a hand-replaced file) must get the
|
|
# missing/valid/corrupt classification too, never a raw
|
|
# sqlite3 error and never skipped migrations.
|
|
db = await _async_open_database(
|
|
hass, await async_get_db_path(hass)
|
|
)
|
|
context = getattr(entry, "context", None)
|
|
context_user_id = (
|
|
context.get("user_id")
|
|
if isinstance(context, dict)
|
|
else None
|
|
)
|
|
await seed_setup_admin(
|
|
db,
|
|
stored_admin_ids=entry.data.get(CONF_ADMIN_USER_IDS),
|
|
context_user_id=context_user_id,
|
|
)
|
|
existing.database = db
|
|
entry.runtime_data = existing
|
|
return True
|
|
|
|
async def _async_update_listener(
|
|
listener_hass: HomeAssistant, listener_entry: ConfigEntry
|
|
) -> None:
|
|
"""Reload the entry when its options change."""
|
|
await listener_hass.config_entries.async_reload(listener_entry.entry_id)
|
|
|
|
database = await _async_open_database(hass, await async_get_db_path(hass))
|
|
try:
|
|
# Seed the admin allowlist on first setup so the owner is never
|
|
# locked out: an empty database takes the config entry's stored
|
|
# admin copy (disaster recovery), else the HA user who
|
|
# completed the config flow. Non-empty allowlists are never
|
|
# touched (fail-closed narrowing is deliberate).
|
|
context = getattr(entry, "context", None)
|
|
context_user_id = (
|
|
context.get("user_id")
|
|
if isinstance(context, dict)
|
|
else None
|
|
)
|
|
await seed_setup_admin(
|
|
database,
|
|
stored_admin_ids=entry.data.get(CONF_ADMIN_USER_IDS),
|
|
context_user_id=context_user_id,
|
|
)
|
|
remove_update_listener = entry.add_update_listener(_async_update_listener)
|
|
except BaseException:
|
|
await database.close()
|
|
raise
|
|
runtime_data = NestQuestRuntimeData(
|
|
entry_id=entry.entry_id,
|
|
options=dict(entry.options),
|
|
database=database,
|
|
remove_update_listener=remove_update_listener,
|
|
)
|
|
entry.runtime_data = runtime_data
|
|
hass.data[DOMAIN][entry.entry_id] = runtime_data
|
|
return True
|
|
|
|
|
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
"""Unload a config entry."""
|
|
domain_data = hass.data.setdefault(DOMAIN, {})
|
|
runtime_data = domain_data.pop(entry.entry_id, None)
|
|
if not domain_data:
|
|
hass.data.pop(DOMAIN, None)
|
|
if runtime_data is None:
|
|
runtime_data = getattr(entry, "runtime_data", None)
|
|
if runtime_data is not None:
|
|
remove_update_listener = getattr(runtime_data, "remove_update_listener", None)
|
|
unload_error: BaseException | None = None
|
|
try:
|
|
if remove_update_listener is not None:
|
|
result = remove_update_listener()
|
|
if inspect.isawaitable(result):
|
|
await result
|
|
except BaseException as err:
|
|
unload_error = err
|
|
finally:
|
|
database = getattr(runtime_data, "database", None)
|
|
if database is not None:
|
|
await database.close()
|
|
if unload_error is not None:
|
|
if getattr(entry, "runtime_data", None) is not None:
|
|
entry.runtime_data = None
|
|
raise unload_error
|
|
if getattr(entry, "runtime_data", None) is not None:
|
|
entry.runtime_data = None
|
|
return True
|