diff --git a/custom_components/houseplan/asset_integrity.py b/custom_components/houseplan/asset_integrity.py new file mode 100644 index 00000000..c641b582 --- /dev/null +++ b/custom_components/houseplan/asset_integrity.py @@ -0,0 +1,139 @@ +"""Bounded, shared integrity verification for content-addressed decor assets.""" +from __future__ import annotations + +import hashlib +import logging +import threading +from collections import OrderedDict +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +ASSET_INTEGRITY_CACHE_ENTRIES = 256 +ASSET_HASH_CHUNK_BYTES = 64 * 1024 +_HASS_DATA_KEY = "asset_integrity_verifier" + + +@dataclass(frozen=True) +class FileSignature: + """File version facts available without reading its content.""" + + size: int + mtime_ns: int + ctime_ns: int + + +@dataclass(frozen=True) +class _CacheEntry: + signature: FileSignature + digest: str + + +@dataclass +class _Flight: + event: threading.Event + digest: str | None = None + + +def _signature(path: Path) -> FileSignature: + stat = path.stat() + return FileSignature( + size=stat.st_size, + mtime_ns=stat.st_mtime_ns, + ctime_ns=stat.st_ctime_ns, + ) + + +def _stream_sha256(path: Path) -> str: + """Hash a blob without retaining its bytes in memory.""" + digest = hashlib.sha256() + with path.open("rb") as stream: + while chunk := stream.read(ASSET_HASH_CHUNK_BYTES): + digest.update(chunk) + return digest.hexdigest() + + +class AssetIntegrityVerifier: + """Thread-safe LRU digest cache with per-file-version single-flight.""" + + def __init__( + self, + max_entries: int = ASSET_INTEGRITY_CACHE_ENTRIES, + *, + hasher: Callable[[Path], str] | None = None, + event_factory: Callable[[], threading.Event] | None = None, + ) -> None: + if max_entries < 1: + raise ValueError("max_entries must be positive") + self._max_entries = max_entries + self._hasher = hasher or _stream_sha256 + self._event_factory = event_factory or threading.Event + self._lock = threading.Lock() + self._cache: OrderedDict[str, _CacheEntry] = OrderedDict() + self._inflight: dict[tuple[str, FileSignature], _Flight] = {} + + def verify(self, path: Path, expected_digest: str) -> bool: + """Return whether one stable file version has the expected digest.""" + try: + canonical = str(path.resolve()) + except (OSError, RuntimeError): + return False + try: + before = _signature(path) + except OSError: + with self._lock: + self._cache.pop(canonical, None) + return False + + key = (canonical, before) + with self._lock: + cached = self._cache.get(canonical) + if cached is not None and cached.signature == before: + self._cache.move_to_end(canonical) + return cached.digest == expected_digest + if cached is not None: + self._cache.pop(canonical, None) + flight = self._inflight.get(key) + owner = flight is None + if owner: + flight = _Flight(self._event_factory()) + self._inflight[key] = flight + + assert flight is not None + if not owner: + flight.event.wait() + return flight.digest == expected_digest + + digest: str | None = None + stable = False + try: + digest = self._hasher(path) + # Never publish a digest for bytes that changed while they were read. + stable = _signature(path) == before + except Exception as err: # noqa: BLE001 - filesystem/hash seam fails dark + _LOGGER.debug("House Plan asset integrity check failed: %s", err) + finally: + with self._lock: + if stable and digest is not None: + self._cache[canonical] = _CacheEntry(before, digest) + self._cache.move_to_end(canonical) + while len(self._cache) > self._max_entries: + self._cache.popitem(last=False) + flight.digest = digest + self._inflight.pop(key, None) + flight.event.set() + return stable and digest == expected_digest + + +def get_asset_integrity_verifier(hass: Any) -> AssetIntegrityVerifier: + """Return the single verifier shared by HTTP and WS on this HA instance.""" + domain_data = hass.data.setdefault(DOMAIN, {}) + verifier = domain_data.get(_HASS_DATA_KEY) + if not isinstance(verifier, AssetIntegrityVerifier): + verifier = AssetIntegrityVerifier() + domain_data[_HASS_DATA_KEY] = verifier + return verifier diff --git a/custom_components/houseplan/decor_assets.py b/custom_components/houseplan/decor_assets.py index bac287ac..47124469 100644 --- a/custom_components/houseplan/decor_assets.py +++ b/custom_components/houseplan/decor_assets.py @@ -372,20 +372,42 @@ def asset_meta_path(root: Path, asset_id: str) -> Path: return root / f"{asset_id}.json" +def _read_catalog_row(root: Path, path: Path) -> dict[str, Any] | None: + """Read one sidecar through the validation shared by list and resolve.""" + try: + row = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(row, dict): + return None + aid = str(row.get("asset_id") or "") + ext = row.get("ext") + blob = root / f"{aid}{ext}" + if ( + path.stem != aid + or not ASSET_ID_RE.fullmatch(aid) + or ext not in ASSET_EXTENSIONS + or not blob.is_file() + ): + return None + return row + except (OSError, ValueError, TypeError): + return None + + +def read_asset(root: Path, asset_id: str) -> dict[str, Any] | None: + """Read one exact catalog row without scanning unrelated sidecars.""" + if not ASSET_ID_RE.fullmatch(asset_id): + return None + return _read_catalog_row(root, asset_meta_path(root, asset_id)) + + def read_catalog(root: Path) -> list[dict[str, Any]]: rows: list[dict[str, Any]] = [] if not root.is_dir(): return rows for path in root.glob("*.json"): - try: - row = json.loads(path.read_text(encoding="utf-8")) - aid = str(row.get("asset_id") or "") - ext = row.get("ext") - blob = root / f"{aid}{ext}" - if ASSET_ID_RE.fullmatch(aid) and ext in ASSET_EXTENSIONS and blob.is_file(): - rows.append(row) - except (OSError, ValueError, TypeError): - continue + row = _read_catalog_row(root, path) + if row is not None: + rows.append(row) return sorted( rows, key=lambda row: (str(row.get("created_at", "")), str(row["asset_id"])), diff --git a/custom_components/houseplan/http_api.py b/custom_components/houseplan/http_api.py index 741c9832..8d1af066 100644 --- a/custom_components/houseplan/http_api.py +++ b/custom_components/houseplan/http_api.py @@ -24,6 +24,7 @@ try: # KEY_HASS — the modern way to access hass from the aiohttp application except ImportError: # older HA versions KEY_HASS = "hass" # type: ignore[assignment] +from .asset_integrity import get_asset_integrity_verifier from .auth import may_write from .const import ( ASSETS_DIR, @@ -176,18 +177,13 @@ class HouseplanContentView(HomeAssistantView): if not str(path).startswith(str(base)): return web.Response(status=404) - if not await hass.async_add_executor_job(path.is_file): - return web.Response(status=404) suffix = path.suffix.lower() if kind == "assets": - try: - digest = await hass.async_add_executor_job( - lambda: hashlib.sha256(path.read_bytes()).hexdigest(), - ) - except OSError: - return web.Response(status=404) - if digest != path.stem: + verifier = get_asset_integrity_verifier(hass) + if not await hass.async_add_executor_job(verifier.verify, path, path.stem): return web.Response(status=404) + elif not await hass.async_add_executor_job(path.is_file): + return web.Response(status=404) headers = { "Cache-Control": "private, max-age=31536000, immutable" if kind == "assets" else "private, max-age=3600", diff --git a/custom_components/houseplan/websocket_api.py b/custom_components/houseplan/websocket_api.py index b2bdd657..7354fc52 100755 --- a/custom_components/houseplan/websocket_api.py +++ b/custom_components/houseplan/websocket_api.py @@ -21,6 +21,7 @@ from homeassistant.const import __version__ as HA_VERSION from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import issue_registry as ir +from .asset_integrity import get_asset_integrity_verifier from .auth import may_write from .const import ( ASSETS_DIR, @@ -54,6 +55,7 @@ from .decor_assets import ( asset_meta_path, asset_refs, public_asset, + read_asset, read_catalog, ) from .import_export import ( @@ -1135,22 +1137,29 @@ async def ws_assets_list(hass: HomeAssistant, connection, msg: dict[str, Any]) - @websocket_api.async_response async def ws_assets_resolve(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None: """Resolve each unique id once; absent/corrupt content is reported missing.""" - root = Path(hass.config.path(ASSETS_DIR)) + rt = _runtime(hass, connection, msg["id"]) + if rt is None: + return requested = set(msg["asset_ids"]) + allowed = requested + if not _check_write(hass, connection): + async with rt.write_lock: + stored = await rt.config_store.async_load() or {} + referenced = set(asset_refs(stored.get("config") or {})) + allowed = requested & referenced + + root = Path(hass.config.path(ASSETS_DIR)) + verifier = get_asset_integrity_verifier(hass) def _resolve() -> tuple[list[dict], list[str]]: rows: list[dict] = [] found: set[str] = set() - for row in read_catalog(root): - aid = row["asset_id"] - if aid not in requested: + for aid in sorted(allowed): + row = read_asset(root, aid) + if row is None: continue path = root / f"{aid}{row['ext']}" - try: - import hashlib - if hashlib.sha256(path.read_bytes()).hexdigest() != aid: - continue - except OSError: + if not verifier.verify(path, aid): continue rows.append(public_asset(row)) found.add(aid) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 118ea402..8d70dc6c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -380,10 +380,23 @@ Custom Background images use a separate content-addressed store at `/houseplan/assets/`. Raster input is fully decoded and SVG is parsed through a strict allowlist before promotion; the SHA-256 of canonical bytes is the persisted `asset_id`. Config never carries file bytes or a signed URL. -`houseplan/assets/resolve` maps unique ids to authenticated content paths, -while the shared `ContentSigner` batches signatures for `` elements. -Catalog deletion rechecks references across every space under the config write -lock. Missing or corrupt assets are never painted in View. +`houseplan/assets/resolve` maps unique ids to authenticated content paths. +Writers may resolve any catalog id; a read-only household member may resolve +only ids referenced by the current saved config, with forbidden ids reported as +ordinary `missing` entries. The reference snapshot is taken under the config +write lock, but file I/O happens after releasing it. The resolve path reads only +the requested sidecars rather than scanning the catalog. The HTTP content view +keeps its authenticated/signed exact-URL contract. + +Resolve and HTTP GET share one HA-instance memory-only integrity verifier. It +streams SHA-256 in bounded chunks and caches at most 256 actual digests by +canonical path plus size/mtime/ctime signature. Per-file-version single-flight +deduplicates concurrent reads without serialising different files; a second +`stat` prevents a digest for bytes changed mid-read from entering the cache. +Missing, changed and corrupt files fail dark. The shared `ContentSigner` batches +signatures for `` elements. Catalog deletion rechecks references across +every space under the config write lock. Missing or corrupt assets are never +painted in View. `removed:true` is a binding tombstone, not a renderable marker. It claims an HA binding against automatic discovery while intentionally exposing that same @@ -918,7 +931,7 @@ transmit light is the separate `zero_wall_style` policy. | `houseplan/files/migrate` | `from_id`, `to_id` | `{mapping}` — COPY, never move | | `houseplan/files/cleanup` | `marker_id`, `keep?` | replacement-only collection | | `houseplan/assets/list` | — | reusable image metadata plus authoritative `used_by` references | -| `houseplan/assets/resolve` | `asset_ids[]` (max 200) | verified metadata/content paths plus missing ids | +| `houseplan/assets/resolve` | `asset_ids[]` (max 200) | verified metadata/content paths plus missing ids; writer: catalog, read-only: saved references only | | `houseplan/assets/delete` | `asset_id` | explicit deletion only when no decor record refers to it | | `houseplan/content/sign` | `paths[]` | `{urls}` — authSig for ``/`` fetches | | `houseplan/export/create` | `kind`, `space_id?`, `plan_only?`, `card_version` | consistent versioned JSON document + safe filename; plan-only is valid only for one space | diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8ba9835c..9f4103df 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Saved custom images remain visible to read-only household members while + arbitrary asset lookup is blocked, and repeated card/HTTP loads now reuse one + bounded streaming integrity check instead of re-reading every image + ([#432](https://github.com/Matysh/houseplan-card/issues/432)). - Custom decor images now pass through the same stable coordinate-write barrier as furniture and shapes, so repeated saves and **Optimize Plans** no longer retain image-only floating-point noise diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 20d26900..60d89c6d 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -8,6 +8,11 @@ ## Не выпущено +- Сохранённые пользовательские картинки по-прежнему видны домочадцам без права + редактирования, но произвольный поиск файлов теперь закрыт; повторные загрузки + карточки и HTTP используют одну ограниченную потоковую проверку вместо нового + чтения каждой картинки + ([#432](https://github.com/Matysh/houseplan-card/issues/432)). - Пользовательские изображения декора теперь проходят тот же стабильный барьер записи координат, что мебель и фигуры, поэтому повторные сохранения и «Оптимизировать планы» больше не сохраняют float-шум только у изображений diff --git a/docs/CONFIG-COMPATIBILITY.md b/docs/CONFIG-COMPATIBILITY.md index e8edbbe2..069d2376 100644 --- a/docs/CONFIG-COMPATIBILITY.md +++ b/docs/CONFIG-COMPATIBILITY.md @@ -263,6 +263,15 @@ identity/hash remain exact, and every supplied non-null MIME must be supported. Before a permanent downgrade, remove all image objects with a current card and then explicitly delete their now-unused files from the palette. +The #432 backend hardening does not change that schema, URL shape, export format +or `decor_assets_api:1` capability. A read-only user still resolves images used +by the saved config; only arbitrary unreferenced ids are now returned as +`missing`. Writers keep the full catalog contract. Authenticated and signed +exact content URLs remain valid, while integrity results are shared in a bounded +memory-only cache. Old and new cards therefore remain rolling-compatible with +the hardened integration; the cache is discarded on restart and needs no data +migration or downgrade step. + ## Independent-wall opening host (#132) `space.openings[].host` is an optional discriminated object diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 0d2fe5fe..cd7d7d18 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -4797,6 +4797,58 @@ const MUTANT_DEFINITIONS = [ replace: ' if (row.url !== expectedUrl', }], }, + { + id: 'asset-resolve-readonly-membership-removed', + guard: 'node scripts/backend-test-guard.mjs ' + + 'decor_asset_resolve_readonly_is_limited_to_referenced_ids ' + + 'tests_backend/test_ha_websocket.py', + because: 'a read-only household member needs referenced images for View but must not use ' + + 'assets/resolve to probe or hash arbitrary catalog ids (#432 AC2)', + patches: [{ + file: 'custom_components/houseplan/websocket_api.py', + find: ' allowed = requested & referenced\n', + replace: ' allowed = requested\n', + }], + }, + { + id: 'asset-integrity-cache-hit-disabled', + guard: 'node scripts/backend-test-guard.mjs ' + + 'integrity_cache_reuses_digest_and_caches_corrupt_signature ' + + 'tests_backend/test_decor_assets.py', + because: 'unchanged valid and corrupt files must reuse the actual digest instead of ' + + 're-reading the blob for every WS resolve or HTTP GET (#432 AC5)', + patches: [{ + file: 'custom_components/houseplan/asset_integrity.py', + find: ' if cached is not None and cached.signature == before:\n', + replace: ' if False and cached is not None and cached.signature == before:\n', + }], + }, + { + id: 'asset-integrity-single-flight-disabled', + guard: 'node scripts/backend-test-guard.mjs ' + + 'integrity_cache_single_flights_same_path_and_releases_after_error ' + + 'tests_backend/test_decor_assets.py', + because: 'parallel requests for one file version must share one streaming hash and wake ' + + 'all waiters after success or failure (#432 AC6)', + patches: [{ + file: 'custom_components/houseplan/asset_integrity.py', + find: ' flight = self._inflight.get(key)\n', + replace: ' flight = None\n', + }], + }, + { + id: 'asset-integrity-post-read-signature-ignored', + guard: 'node scripts/backend-test-guard.mjs ' + + 'integrity_cache_invalidates_changed_signature_and_rejects_mid_read_change ' + + 'tests_backend/test_decor_assets.py', + because: 'a digest computed while the blob changes must fail dark and never become a ' + + 'trusted cache entry for either transport (#432 AC7)', + patches: [{ + file: 'custom_components/houseplan/asset_integrity.py', + find: ' stable = _signature(path) == before\n', + replace: ' stable = True\n', + }], + }, { id: 'pure-backend-test-pulls-home-assistant', guard: 'python3 -m pytest tests_backend/test_backend_quality.py -q -p no:cacheprovider', diff --git a/tests_backend/test_decor_assets.py b/tests_backend/test_decor_assets.py index c04fbe73..9e053522 100644 --- a/tests_backend/test_decor_assets.py +++ b/tests_backend/test_decor_assets.py @@ -2,19 +2,28 @@ from __future__ import annotations import base64 +import hashlib import importlib import json import struct +import threading import zlib +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path import pytest +from custom_components.houseplan.asset_integrity import ( + ASSET_INTEGRITY_CACHE_ENTRIES, + AssetIntegrityVerifier, +) from custom_components.houseplan.const import MAX_DECOR_ASSET_BYTES from custom_components.houseplan.decor_assets import ( DecorAssetError, asset_meta_path, asset_refs, public_asset, + read_asset, read_catalog, validate_asset, ) @@ -323,6 +332,192 @@ def test_catalog_empty_directory_and_metadata_path(tmp_path) -> None: assert asset_meta_path(tmp_path, aid) == tmp_path / f"{aid}.json" +def test_direct_asset_lookup_never_scans_or_accepts_mismatched_sidecars( + tmp_path, monkeypatch, +) -> None: + payload = b"one" + aid = hashlib.sha256(payload).hexdigest() + other = "d" * 64 + (tmp_path / f"{aid}.png").write_bytes(payload) + (tmp_path / f"{aid}.json").write_text(json.dumps({ + "asset_id": aid, "ext": ".png", "mime": "image/png", + }), encoding="utf-8") + (tmp_path / f"{other}.json").write_text(json.dumps({ + "asset_id": aid, "ext": ".png", "mime": "image/png", + }), encoding="utf-8") + assert [row["asset_id"] for row in read_catalog(tmp_path)] == [aid] + + def no_scan(_self, _pattern): + raise AssertionError("direct lookup must not scan the catalog") + + monkeypatch.setattr(Path, "glob", no_scan) + assert read_asset(tmp_path, aid)["asset_id"] == aid + assert read_asset(tmp_path, other) is None + + +def test_integrity_cache_reuses_digest_and_caches_corrupt_signature(tmp_path) -> None: + payload = b"stable-content" + path = tmp_path / "asset.bin" + path.write_bytes(payload) + expected = hashlib.sha256(payload).hexdigest() + calls = 0 + + def counted(candidate: Path) -> str: + nonlocal calls + calls += 1 + return hashlib.sha256(candidate.read_bytes()).hexdigest() + + verifier = AssetIntegrityVerifier(hasher=counted) + assert verifier.verify(path, expected) + assert verifier.verify(path, expected) + assert calls == 1 + + wrong = "0" * 64 + assert not verifier.verify(path, wrong) + assert not verifier.verify(path, wrong) + assert calls == 1, "the actual digest also caches a negative comparison" + + +def test_integrity_cache_invalidates_changed_signature_and_rejects_mid_read_change( + tmp_path, +) -> None: + path = tmp_path / "asset.bin" + first = b"first" + second = b"second-version" + path.write_bytes(first) + calls = 0 + + def counted(candidate: Path) -> str: + nonlocal calls + calls += 1 + return hashlib.sha256(candidate.read_bytes()).hexdigest() + + verifier = AssetIntegrityVerifier(hasher=counted) + assert verifier.verify(path, hashlib.sha256(first).hexdigest()) + path.write_bytes(second) + assert verifier.verify(path, hashlib.sha256(second).hexdigest()) + assert calls == 2 + + replacement = b"changed-during-read" + + def mutating(candidate: Path) -> str: + original = candidate.read_bytes() + candidate.write_bytes(replacement) + return hashlib.sha256(original).hexdigest() + + unstable = AssetIntegrityVerifier(hasher=mutating) + path.write_bytes(first) + assert not unstable.verify(path, hashlib.sha256(first).hexdigest()) + assert not unstable._cache, "an unstable digest must not become a cache hit" + + +def test_integrity_cache_single_flights_same_path_and_releases_after_error(tmp_path) -> None: + path = tmp_path / "asset.bin" + payload = b"concurrent" + path.write_bytes(payload) + expected = hashlib.sha256(payload).hexdigest() + waiter_joined = threading.Event() + real_event = threading.Event + + class ObservedEvent: + def __init__(self) -> None: + self._event = real_event() + + def set(self) -> None: + self._event.set() + + def wait(self, timeout=None) -> bool: + waiter_joined.set() + return self._event.wait(timeout) + + calls = 0 + + def coordinated(candidate: Path) -> str: + nonlocal calls + calls += 1 + assert waiter_joined.wait(2), "the concurrent caller never joined the flight" + return hashlib.sha256(candidate.read_bytes()).hexdigest() + + verifier = AssetIntegrityVerifier(hasher=coordinated, event_factory=ObservedEvent) + with ThreadPoolExecutor(max_workers=2) as pool: + first = pool.submit(verifier.verify, path, expected) + second = pool.submit(verifier.verify, path, expected) + assert first.result(timeout=3) and second.result(timeout=3) + assert calls == 1 + + attempts = 0 + + def once_broken(candidate: Path) -> str: + nonlocal attempts + attempts += 1 + if attempts == 1: + raise OSError("injected read failure") + return hashlib.sha256(candidate.read_bytes()).hexdigest() + + recovered = AssetIntegrityVerifier(hasher=once_broken) + assert not recovered.verify(path, expected) + assert recovered.verify(path, expected) + assert attempts == 2 and not recovered._inflight + + +def test_integrity_checks_for_different_paths_do_not_share_a_hash_lock(tmp_path) -> None: + first_path = tmp_path / "first.bin" + second_path = tmp_path / "second.bin" + first_path.write_bytes(b"first") + second_path.write_bytes(b"second") + first_started = threading.Event() + release_first = threading.Event() + + def coordinated(candidate: Path) -> str: + if candidate == first_path: + first_started.set() + assert release_first.wait(2) + return hashlib.sha256(candidate.read_bytes()).hexdigest() + + verifier = AssetIntegrityVerifier(hasher=coordinated) + with ThreadPoolExecutor(max_workers=2) as pool: + first = pool.submit( + verifier.verify, first_path, hashlib.sha256(b"first").hexdigest(), + ) + assert first_started.wait(1) + independent = pool.submit( + verifier.verify, second_path, hashlib.sha256(b"second").hexdigest(), + ) + assert independent.result(timeout=1) + release_first.set() + assert first.result(timeout=2) + + +def test_integrity_cache_is_bounded_lru_and_stream_reader_avoids_read_bytes( + tmp_path, monkeypatch, +) -> None: + assert ASSET_INTEGRITY_CACHE_ENTRIES == 256 + paths = [] + for index in range(ASSET_INTEGRITY_CACHE_ENTRIES + 1): + path = tmp_path / f"{index}.bin" + path.write_bytes(str(index).encode()) + paths.append(path) + + verifier = AssetIntegrityVerifier() + + def forbidden_read_bytes(_self): + raise AssertionError("integrity verification must stream chunks") + + monkeypatch.setattr(Path, "read_bytes", forbidden_read_bytes) + for index in range(ASSET_INTEGRITY_CACHE_ENTRIES): + assert verifier.verify( + paths[index], hashlib.sha256(str(index).encode()).hexdigest(), + ) + # Refresh zero, then the 257th insert must evict one rather than zero. + assert verifier.verify(paths[0], hashlib.sha256(b"0").hexdigest()) + last = ASSET_INTEGRITY_CACHE_ENTRIES + assert verifier.verify(paths[last], hashlib.sha256(str(last).encode()).hexdigest()) + assert len(verifier._cache) == ASSET_INTEGRITY_CACHE_ENTRIES + assert str(paths[0].resolve()) in verifier._cache + assert str(paths[1].resolve()) not in verifier._cache + assert str(paths[last].resolve()) in verifier._cache + + def test_reference_scan_is_cross_space_and_image_only() -> None: aid = "b" * 64 refs = asset_refs({"spaces": [ diff --git a/tests_backend/test_ha_websocket.py b/tests_backend/test_ha_websocket.py index b03a4788..c02318ca 100644 --- a/tests_backend/test_ha_websocket.py +++ b/tests_backend/test_ha_websocket.py @@ -1173,6 +1173,26 @@ async def test_not_ready_without_entry(hass: HomeAssistant, hass_ws_client: WebS assert not resp["success"] and resp["error"]["code"] == "not_ready" +async def test_decor_asset_resolve_requires_runtime_before_io( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, monkeypatch, +) -> None: + """#432 AC3: lifecycle refusal precedes even construction of a store path.""" + from custom_components.houseplan import websocket_api as hp_ws + + hp_ws.async_register(hass) + + def forbidden_path(*_args, **_kwargs): + raise AssertionError("asset filesystem touched before the runtime gate") + + monkeypatch.setattr(hp_ws, "Path", forbidden_path) + client = await hass_ws_client(hass) + await client.send_json_auto_id({ + "type": "houseplan/assets/resolve", "asset_ids": ["a" * 64], + }) + resp = await client.receive_json() + assert not resp["success"] and resp["error"]["code"] == "not_ready" + + async def test_plan_set_validates(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> None: await _setup(hass) client = await hass_ws_client(hass) @@ -2122,6 +2142,104 @@ async def test_decor_asset_upload_deduplicates_and_rejects_mime_spoofing( assert json.loads(spoofed.text)["error"] == "invalid_format" +async def test_decor_asset_resolve_readonly_is_limited_to_referenced_ids( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_read_only_access_token: str, + monkeypatch, +) -> None: + """#432 AC1/AC2: View keeps its images without exposing the catalog.""" + import hashlib + + from custom_components.houseplan import websocket_api as wsapi + from custom_components.houseplan.const import ASSETS_DIR + + await _setup(hass) + admin = await hass_ws_client(hass) + root = Path(hass.config.path(ASSETS_DIR)) + root.mkdir(parents=True, exist_ok=True) + payloads = (b"referenced", b"not-referenced") + asset_ids = [] + for index, payload in enumerate(payloads): + aid = hashlib.sha256(payload).hexdigest() + asset_ids.append(aid) + (root / f"{aid}.png").write_bytes(payload) + (root / f"{aid}.json").write_text(json.dumps({ + "asset_id": aid, "name": f"{index}.png", "mime": "image/png", + "ext": ".png", "width": 1, "height": 1, "bytes": len(payload), + "created_at": f"2026-01-0{index + 1}T00:00:00Z", + }), encoding="utf-8") + + cfg = await _cfg([{"id": "one", "plan_url": None}]) + cfg["spaces"][0]["decor"] = [{ + "id": "picture", "kind": "image", "asset_id": asset_ids[0], + "x": 0.1, "y": 0.2, "w": 0.3, "h": 0.4, + }] + assert (await _save(admin, cfg, 0))["success"] + + looked_up = [] + real_read_asset = wsapi.read_asset + + def observed_read_asset(asset_root: Path, asset_id: str): + looked_up.append(asset_id) + return real_read_asset(asset_root, asset_id) + + monkeypatch.setattr(wsapi, "read_asset", observed_read_asset) + readonly = await hass_ws_client(hass, access_token=hass_read_only_access_token) + await readonly.send_json_auto_id({ + "type": "houseplan/assets/resolve", "asset_ids": asset_ids, + }) + response = await readonly.receive_json() + assert response["success"] + assert [row["asset_id"] for row in response["result"]["assets"]] == [asset_ids[0]] + assert response["result"]["missing"] == [asset_ids[1]] + assert looked_up == [asset_ids[0]], "forbidden metadata/blob must not be touched" + + looked_up.clear() + await admin.send_json_auto_id({ + "type": "houseplan/assets/resolve", "asset_ids": asset_ids, + }) + response = await admin.receive_json() + assert response["success"] and len(response["result"]["assets"]) == 2 + assert set(looked_up) == set(asset_ids) + + +async def test_decor_asset_resolve_non_admin_is_writer_when_admin_only_is_off( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + hass_read_only_access_token: str, +) -> None: + """#432 AC2: resolve follows may_write instead of hard-coding admin.""" + import hashlib + + from custom_components.houseplan.const import ASSETS_DIR + + entry = MockConfigEntry( + domain=DOMAIN, title="House Plan", data={}, options={CONF_ADMIN_ONLY: False}, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + payload = b"writer-by-option" + aid = hashlib.sha256(payload).hexdigest() + root = Path(hass.config.path(ASSETS_DIR)) + root.mkdir(parents=True, exist_ok=True) + (root / f"{aid}.png").write_bytes(payload) + (root / f"{aid}.json").write_text(json.dumps({ + "asset_id": aid, "name": "writer.png", "mime": "image/png", "ext": ".png", + "width": 1, "height": 1, "bytes": len(payload), + "created_at": "2026-01-01T00:00:00Z", + }), encoding="utf-8") + + client = await hass_ws_client(hass, access_token=hass_read_only_access_token) + await client.send_json_auto_id({ + "type": "houseplan/assets/resolve", "asset_ids": [aid], + }) + response = await client.receive_json() + assert response["success"] + assert response["result"]["assets"][0]["asset_id"] == aid + + async def test_decor_asset_list_resolve_delete_and_signed_content( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_client_no_auth, ) -> None: @@ -2130,6 +2248,7 @@ async def test_decor_asset_list_resolve_delete_and_signed_content( import hashlib from custom_components.houseplan.const import ASSETS_DIR, CONTENT_URL + from custom_components.houseplan.asset_integrity import AssetIntegrityVerifier await _setup(hass) client = await hass_ws_client(hass) @@ -2144,6 +2263,16 @@ async def test_decor_asset_list_resolve_delete_and_signed_content( "asset_id": aid, "name": "pixel.png", "mime": "image/png", "ext": ".png", "width": 1, "height": 1, "bytes": len(png), "created_at": "2026-01-01T00:00:00Z", }), encoding="utf-8") + hash_calls = 0 + + def counted_hash(path: Path) -> str: + nonlocal hash_calls + hash_calls += 1 + return hashlib.sha256(path.read_bytes()).hexdigest() + + hass.data[DOMAIN]["asset_integrity_verifier"] = AssetIntegrityVerifier( + hasher=counted_hash, + ) cfg = await _cfg([{"id": "one", "plan_url": None}]) cfg["spaces"][0]["decor"] = [{ @@ -2174,11 +2303,21 @@ async def test_decor_asset_list_resolve_delete_and_signed_content( assert response.status == 200 and await response.read() == png assert response.headers["Content-Type"].startswith("image/png") assert response.headers["X-Content-Type-Options"] == "nosniff" + assert hash_calls == 1, "WS and HTTP must share one unchanged-file digest" (root / f"{aid}.png").write_bytes(b"tampered") assert (await http.get(signed)).status == 404 + assert hash_calls == 2 (root / f"{aid}.png").write_bytes(png) + assert (await http.get(signed)).status == 200 + assert hash_calls == 3 + await client.send_json_auto_id({ + "type": "houseplan/assets/resolve", "asset_ids": [aid], + }) + resolved_after_http = await client.receive_json() + assert resolved_after_http["success"] + assert hash_calls == 3, "HTTP and WS must share one unchanged-file digest" cfg["spaces"][0]["decor"] = [] saved = await _save(client, cfg, saved["result"]["rev"]) assert saved["success"]