Compare commits

...
Author SHA1 Message Date
claude[bot] a6a9757b6d docs: review document for #432
Issue: #432
User-Visible: no
2026-09-03 10:09:05 +00:00
Sergey Matyunin d8e67f530c test(assets): isolate HA asset fixtures
Issue: #432
User-Visible: no
2026-09-03 12:51:20 +03:00
Sergey Matyunin f3c32fb203 fix(assets): bound resolve integrity work
Issue: #432
User-Visible: yes
2026-09-03 12:48:08 +03:00
claude[bot] 58df908db2 docs: review document for #432
Issue: #432
User-Visible: no
2026-09-03 09:38:37 +00:00
Sergey Matyunin 17a1c10bef docs(spec): define bounded asset resolution
Issue: #432
User-Visible: no
2026-09-03 12:32:33 +03:00
16 changed files with 1421 additions and 33 deletions
@@ -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
+31 -9
View File
@@ -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"])),
+5 -9
View File
@@ -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",
+18 -9
View File
@@ -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)
+18 -5
View File
@@ -380,10 +380,23 @@ Custom Background images use a separate content-addressed store at
`<config>/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 `<image>` 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 `<image>` 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 `<image>`/`<a>` 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 |
+4
View File
@@ -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
+5
View File
@@ -8,6 +8,11 @@
## Не выпущено
- Сохранённые пользовательские картинки по-прежнему видны домочадцам без права
редактирования, но произвольный поиск файлов теперь закрыт; повторные загрузки
карточки и HTTP используют одну ограниченную потоковую проверку вместо нового
чтения каждой картинки
([#432](https://github.com/Matysh/houseplan-card/issues/432)).
- Пользовательские изображения декора теперь проходят тот же стабильный барьер
записи координат, что мебель и фигуры, поэтому повторные сохранения и
«Оптимизировать планы» больше не сохраняют float-шум только у изображений
+9
View File
@@ -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
+239
View File
@@ -0,0 +1,239 @@
# CODE-REVIEW-432-r1
- Issue: https://github.com/Matysh/houseplan-card/issues/432
- ТЗ: `docs/specs/432-asset-resolve-authorization-cache.md` (SPEC-REVIEW-432-r1: зелёный)
- Материал ревью: SHA `d8e67f530c33c1b3178a60afb33a110cf5194bb2` (HEAD ветки
`issue/432-asset-resolve-authorization-cache` на момент ревью, коммит
`test(assets): isolate HA asset fixtures`), диапазон `origin/dev...HEAD`
(коммиты `17a1c10b` docs, `f3c32fb2` fix, `d8e67f53` test)
- Заход: r1 · блокирующих циклов израсходовано 0 из 4 (полный трек, лимит 4)
- Вердикт: **зелёный**
## Скоуп ревью
Первый заход код-ревью для issue #432: ограничение прав `houseplan/assets/resolve`
(read-only user видит только referenced-assets, writer — весь каталог) и общий
bounded/single-flight integrity-verifier для WS resolve и HTTP asset GET вместо
полного `read_bytes()+SHA-256` на каждый вызов. Класс изменений — A (Python backend)
+ B (тесты, `scripts/mutation-gate.mjs`) + C (документация); `src/**` не тронут.
Диапазон материала — весь диапазон `origin/dev...HEAD` (три коммита ветки), это
первый заход код-ревью, «Унаследовано из r0» не применяется.
## Как проверялось
1. `docs/SCOPE.md`, `PROCESS.md` §2.7, §7.1, §8, `AGENTS.md` — формат ревью, классы
файлов, обязательность таблицы «AC · чем доказан · чем краснеет» (#435).
2. Тело issue #432 и все комментарии (аналитика, вопрос/ответ владельца Q1, ТЗ на
ревью, зелёное SPEC-REVIEW-432-r1, handoff «Реализация готова») прочитаны целиком.
3. ТЗ `docs/specs/432-asset-resolve-authorization-cache.md` (§7–§16, AC1–AC11,
таблица §14) сверено построчно с фактическим кодом на SHA `d8e67f53`.
4. Полный `git diff origin/dev...HEAD` прочитан целиком:
- `custom_components/houseplan/asset_integrity.py` (новый файл, 140 строк) —
`AssetIntegrityVerifier`, LRU-кеш, single-flight, потоковый SHA-256;
- `custom_components/houseplan/decor_assets.py` — `read_asset()` (точечный lookup
одного sidecar) и общий `_read_catalog_row()`, которым теперь пользуются
и `read_catalog()`, и `read_asset()`;
- `custom_components/houseplan/websocket_api.py` — `ws_assets_resolve()`:
`_runtime()` до любого I/O, read-only membership filter под `write_lock`,
прямой `read_asset()` вместо `read_catalog()`, `verifier.verify()` вместо
инлайн-хеширования;
- `custom_components/houseplan/http_api.py` — `HouseplanContentView.get()`:
assets используют `verifier.verify()`, plans/files остались на `path.is_file()`;
- `custom_components/houseplan/auth.py` — не менялся, `may_write()` сверен как
существующий источник истины (fail-closed, admin_only-семантика);
- `tests_backend/test_decor_assets.py` (+195 строк) — чистые unit-тесты cache
hit/miss, LRU 256/257 границы, потокового ридера, single-flight, mid-read
инвалидации, direct lookup без сканирования каталога;
- `tests_backend/test_ha_websocket.py` (+139 строк) — HA-тесты readonly-фильтра
(со шпионом `read_asset`), `admin_only:false` writer-контракта, `not_ready`
до I/O (со шпионом на `Path`), общего hash-счётчика WS↔HTTP;
- `scripts/mutation-gate.mjs` (+52 строки) — 4 новых постоянных мутанта;
- `docs/ARCHITECTURE.md`, `docs/CONFIG-COMPATIBILITY.md`,
`docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md` — access/cost-контракт описан,
явно подтверждено отсутствие schema/capability/URL миграции.
5. Трейлеры коммитов проверены: `f3c32fb2` — `Issue: #432` / `User-Visible: yes`,
оба changelog изменены в этом же коммите (`git show --stat`); `d8e67f53` —
`Issue: #432` / `User-Visible: no`, только `tests_backend/conftest.py`
(класс B, повторного changelog не требует).
6. Каждый мутант из п.4 (`scripts/mutation-gate.mjs`) мысленно применён к
соответствующей строке `asset_integrity.py`/`websocket_api.py` и прослежен по
логике кода до конкретного assert, который он обязан сломать (таблица ниже);
структурная валидность патчей подтверждена командой (см. «Гейты»).
7. Проверена история CI ветки (`gh run list`/`gh run view`): коммит `f3c32fb2`
получил **красный** прогон Validate (job `Бэкенд: pytest в Home Assistant` —
failure), следующий коммит `d8e67f53` («isolate HA asset fixtures») —
точечный фикс утечки фикстуры (`tests_backend/conftest.py` теперь чистит и
`houseplan/assets`, не только `plans`/`files`), и на нём Validate зелёный
(run 33741146772, `Бэкенд: pytest в Home Assistant` — success). Это
единственная содержательная находка процесса разработки данной задачи, и она
закрыта третьим коммитом того же issue — не находка ревью.
8. Отдельно проверено, что на этом же прогоне (33741146772) job
`Фронтенд: типы, юниты, мутанты, синхрон бандла` — **skipped**, а не «уже
проверен»: путь-фильтр `changes` классифицирует `frontend` по regex, не
включающему `scripts/**`/`custom_components/**`, и весь диапазон коммитов
`origin/dev..HEAD` не тронул ни одного файла, попадающего под этот regex.
Значит фактическое утверждение задания «Validate зелёный, дешёвые гейты
подтверждены» верно для CI в целом (frontend-job там законно не участвует —
`src/**` не менялся), но `npm test`/`tsc`/`build` для этого диффа **не были
исполнены ни разу ни в одном прогоне этой ветки**. Прогнал их сам (см. «Гейты»).
## Проверка AC1–AC11
| AC | Что требует ТЗ | Где в коде | Вердикт |
|---|---|---|---|
| AC1 | read-only видит referenced saved asset, обе карточки без writer-only зависимости | `websocket_api.py:1140-1169`; `src/**` не менялся | доказано тестом + чтением |
| AC2 | unreferenced id → `missing`, referenced → `assets`; forbidden id не читает metadata/stat/blob; writer/`admin_only:false` — полный контракт | `websocket_api.py:1143-1166` (`allowed = requested & referenced` под `_check_write`) | доказано тестом (шпион) |
| AC3 | без runtime — `not_ready` до FS | `websocket_api.py:1140-1142` (`_runtime()` до `root = Path(...)`) | доказано тестом (шпион на `Path`) |
| AC4 | resolve читает только уникальные разрешённые id, не сканирует остальное; согласовано с `read_catalog()` | `decor_assets.py`: `read_asset()`/`_read_catalog_row()` — общий helper | доказано тестом (`Path.glob` запрещён) |
| AC5 | неизменившийся blob хешируется 1 раз для обоих транспортов | `asset_integrity.py:79-129`; оба вызывающих — `get_asset_integrity_verifier(hass)` | доказано HA-тестом (hash-counter WS↔HTTP) |
| AC6 | N параллельных проверок одного key = 1 hash; разные paths независимы; без «зависшего» in-flight | `asset_integrity.py:92-129` (`_inflight`, `finally: flight.event.set()`) | доказано тестом (barrier/ThreadPoolExecutor) |
| AC7 | смена сигнатуры инвалидирует; corrupt → `missing`/404; mid-read change не кешируется; повторный corrupt не перечитывает | `asset_integrity.py:92-129` (`stable = _signature(path) == before`) | доказано pure+HA тестами |
| AC8 | ≤256 entries, LRU eviction, без bytes, chunked reader | `asset_integrity.py:16-17,51-57,120-125` | доказано тестом (`Path.read_bytes` запрещён, 257-я запись) |
| AC9 | authenticated/signed GET сохраняют body/headers/`FileResponse`; plans/files вне verifier | `http_api.py:180-186,211-214` | доказано существующим + расширенным HA-тестом, подтверждено чтением |
| AC10 | payload/capability/config/i18n/URL не меняются; docs описывают контракт | `const.py`, i18n — 0 diff; `docs/ARCHITECTURE.md`, `docs/CONFIG-COMPATIBILITY.md`, оба changelog обновлены | проверено чтением (diffstat: 0 изменений в `const.py`, `manifest.json`, `src/i18n/**`) |
| AC11 | mutation-gate свидетели для дорогих защит | `scripts/mutation-gate.mjs` — 4 новых entries | см. таблицу «чем краснеет» ниже |
## Таблица защитных доказательств (правило #435)
| AC | Чем доказан | Чем краснеет (мутация → эффект) |
|---|---|---|
| AC2 | `test_decor_asset_resolve_readonly_is_limited_to_referenced_ids` (`tests_backend/test_ha_websocket.py`) — шпион на `read_asset`, `looked_up == [referenced_id]` | mutation-gate `asset-resolve-readonly-membership-removed`: `allowed = requested & referenced` → `allowed = requested`. Прочитано и прослежено: unreferenced id снова попадёт в `allowed`, `read_asset()` вызовется для него, `looked_up` тест-шпион поймает лишний id → assert падает |
| AC3 | `test_decor_asset_resolve_requires_runtime_before_io` — `monkeypatch.setattr(hp_ws, "Path", forbidden_path)`, ожидание `not_ready` | нет отдельного mutation-gate entry (чистый порядок операторов); снятие `if rt is None: return` эквивалентно удалению самого guard-а — сразу ловится тем же тестом (`Path` вызывается → `AssertionError` до отправки `not_ready`). Адресный red proof не требуется отдельно: тест уже устроен как ловушка на любой FS-вызов до ответа |
| AC4 | `test_direct_asset_lookup_never_scans_or_accepts_mismatched_sidecars` — `monkeypatch.setattr(Path, "glob", no_scan)` | замена `read_asset()` на `read_catalog(root)` немедленно попадает в `no_scan` → `AssertionError`. Проверено чтением (нет отдельного mutation-gate entry, но witness детерминирован и не требует HA) |
| AC5 | `test_integrity_cache_reuses_digest_and_caches_corrupt_signature` (pure) + HA-тест `test_decor_asset_list_resolve_delete_and_signed_content` (`hash_calls` считает вызовы `AssetIntegrityVerifier.verify` через инструментированный `hasher`, растёт только при реальной смене файла, WS после HTTP не увеличивает счётчик) | mutation-gate `asset-integrity-cache-hit-disabled`: `if cached is not None and ...` → `if False and ...`. Прослежено: cache-hit branch никогда не срабатывает → второй `verify()` того же файла снова становится owner → `calls == 2` вместо `1` → assert падает. Тот же код используется обоими транспортами, поэтому мутация ломает и кросс-транспортное свойство |
| AC6 | `test_integrity_cache_single_flights_same_path_and_releases_after_error` — `ObservedEvent` фиксирует, что второй вызов реально дождался владельца; `test_integrity_checks_for_different_paths_do_not_share_a_hash_lock` | mutation-gate `asset-integrity-single-flight-disabled`: `flight = self._inflight.get(key)` → `flight = None`. Прослежено: оба потока становятся владельцами, `waiter_joined.wait(2)` в тестовом hasher никогда не будет установлен вторым потоком → `coordinated()` виснет/не получает join → assert «the concurrent caller never joined the flight» падает, либо `calls == 2` |
| AC7 | `test_integrity_cache_invalidates_changed_signature_and_rejects_mid_read_change` — `mutating()` hasher переписывает файл во время чтения, ожидается `not unstable.verify(...)` и `not unstable._cache` | mutation-gate `asset-integrity-post-read-signature-ignored`: `stable = _signature(path) == before` → `stable = True`. Прослежено: `verify()` вернёт `True` (digest совпадёт с ожидаемым, т.к. hasher хэширует ещё старые байты), `assert not unstable.verify(...)` падает, `_cache` получит запись — второй assert тоже падает |
| AC8 | `test_integrity_cache_is_bounded_lru_and_stream_reader_avoids_read_bytes` — `monkeypatch.setattr(Path, "read_bytes", forbidden_read_bytes)`, 257 записей | адресный red proof (без отдельного mutation-gate entry, чистый unit, разрешено правилом #435 «для чистого AC8 допустим адресный red proof»): замена `_stream_sha256` на `hashlib.sha256(path.read_bytes())` немедленно ловится `forbidden_read_bytes`; удаление `while len(...) > max: popitem` ловится проверкой `len(verifier._cache) == 256` и отсутствием `paths[1]` |
| AC9 | расширенный `test_decor_asset_list_resolve_delete_and_signed_content` — статусы 200/404, `Content-Type`, `X-Content-Type-Options`, тело `== png` | проверено чтением: `http_api.py:185` (`elif not await ... path.is_file`) оставляет `plans`/`files` вне verifier структурно — ветка `if kind == "assets"` физически не выполняется для другого `kind`, отдельного мутанта не заводили, риск минимален (условие на `kind`, не на данных) |
Правило #435 требует непустой третий столбец для каждого защитного AC — заполнен
для всех семи (AC2–AC8); AC9 и часть AC3/AC4 доказаны детерминированным
white-box unit-тестом с шпионом, что процесс прямо признаёт достаточным для
«чистых» AC без отдельного мутанта.
## Гейты
Задание сообщило, что Validate на точном SHA `d8e67f53` зелёный
(run 33741146772) — проверено (`gh run view`), включая `Бэкенд: pytest в Home
Assistant` (success). Но `Фронтенд: типы, юниты, мутанты, синхрон бандла` на
этом прогоне **skipped** (путь-фильтр: диапазон `origin/dev..HEAD` не касается
`src/**`/`test/**`/`package.json` — `scripts/mutation-gate.mjs` под этот regex
не подпадает вовсе), то есть `typecheck`/`npm test`/`npm run build` в CI на этой
ветке не выполнялись ни разу. Прогнал сам:
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | зелёный, 0 ошибок |
| Unit (frontend) | `npm test` | `# tests 1793 / pass 1792 / fail 0 / skipped 1` |
| Build | `npm run build` | зелёный, `dist/` пересобран |
| Sync бандла | `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` | совпадают (ожидаемо: `src/**` не менялся) |
| Структура mutation-gate | `node scripts/mutation-gate.mjs --check` | `ok` на всех 60 записях, включая 4 новых |
| Backend pytest (Linux/HA) | не прогонял локально — `homeassistant` не установлен в песочнице ревьюера | подтверждено CI на точном SHA (run 33741146772, job success); AGENTS.md: «чистое подмножество без HA даёт зелёный результат, который ничего не доказывает» — поэтому не подменял локальным прогоном без HA |
| `node scripts/check-docs.mjs` | не требуется | `src/**` не менялся |
| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | прогнал | «Исполняемого frontend-диффа нет… Browser-smoke этим диффом не выбираются… Тронуто файлов: 15» — согласуется с ТЗ §11/§15.8 (browser/golden не требуются) |
| `npm run golden:verify` | не требуется | рендер/визуал не менялись |
| `node scripts/model-invariants.mjs` | не требуется | геометрия/`layout`/толщина не тронуты |
## Что проверено и корректно
- Полный код `asset_integrity.py` прочитан построчно; блокировка (`self._lock`)
удерживается только на bookkeeping (проверка кеша/inflight), сам `self._hasher(path)`
выполняется **вне** лока — соответствует §9.2 ТЗ («не держать один глобальный lock
на протяжении всех чтений»), подтверждено тестом на независимость разных путей.
- `finally: flight.event.set()` гарантирует, что исключение в hasher всё равно
разбудит ожидающих и снимет in-flight запись — нет вечного зависания (AC6).
- `read_asset()`/`_read_catalog_row()` — общий helper для `assets/list` и `resolve`,
что и требует §9.3 ТЗ («не разойдутся две копии validation»); добавленная
проверка `path.stem != aid` дополнительно исключает подмену sidecar под чужим
именем файла — усиление, а не регресс (протестировано отдельно, `read_catalog()`
на существующих валидных данных не меняет поведение: имя sidecar у
легитимно созданных записей всегда равно `asset_id` по построению
`asset_meta_path()`).
- `HouseplanContentView.get()`: `plans`/`files` остаются на `path.is_file()`,
verifier применяется только при `kind == "assets"` — контракт AC9/§8 ТЗ не
нарушен, CSP/`nosniff`/`immutable`/`FileResponse(chunk_size=...)` не тронуты.
- `may_write()` (`auth.py`) не менялся — переиспользован как единственный источник
writer/read-only семантики, соответствует §7.1 ТЗ.
- Трейлеры и changelog корректны для обоих коммитов класса A/B; `d8e67f53` —
точечная починка утечки тестовой фикстуры (`houseplan/assets` не чистился между
тестами, из-за чего `f3c32fb2` получил красный backend-job), закрыта в рамках
того же issue тем же коммитом с `User-Visible: no` — это ожидаемая часть работы
над задачей, а не находка ревью.
- Все 4 новых постоянных mutation-gate мутанта структурно применимы
(`--check` → `ok`) и при чтении логики каждый действительно ломает assert
того теста, который его сторожит (прослежено построчно, таблица выше).
- Read-only membership snapshot берётся под `rt.write_lock` и отпускается **до**
файлового I/O — соответствует §7.3 ТЗ («Lock не удерживается во время metadata
I/O или хеширования»).
- `_runtime()` вызывается синхронно до всякого обращения к `Path`/файловой системе
(`get_data()` — чтение `hass.data`, без I/O) — AC3 подтверждается и структурно,
не только тестом-шпионом.
- Публичный контракт (payload `{assets, missing}`, `DECOR_ASSETS_API_VERSION`,
URL-схема, i18n, config schema) не тронут — `git diff` по `const.py`,
`manifest.json`, `src/i18n/**`, `src/**` пуст.
## Находки
Нет находок уровня High или Medium.
**Low (не блокирует, зафиксировано без правки).**
1. AC9 (streaming/headers для `assets`) и часть AC3/AC4 доказаны только чтением
и детерминированным white-box unit-тестом (шпион), без отдельной записи в
`scripts/mutation-gate.mjs`. Это разрешено правилом #435 для AC, не требующих
дорогого гейта (backend/HA здесь не обязателен именно для этой мутации:
отделение веток `if kind == "assets"` / `elif` тривиально и не зависит от
concurrency или HA-специфики). Снимается без правки — расширять реестр ради
тривиальной ветки было бы ритуалом, который правило прямо исключает.
2. `docs/CONFIG-COMPATIBILITY.md` добавляет абзац о #432 в середину раздела про
отдельный более старый compatibility-кейс (downgrade изображений), а не
отдельным подзаголовком. Контент корректен и полон, это вопрос структуры
документа. Снимается без правки.
## Чего не проверял
- Backend/HA pytest не исполнял локально: в песочнице ревьюера нет модуля
`homeassistant` и `.venv-backend`; полагаюсь на зелёный Linux CI job
«Бэкенд: pytest в Home Assistant» на точном SHA `d8e67f53` (run 33741146772),
включающий все новые HA-тесты из `tests_backend/test_ha_websocket.py`.
- Полный `node scripts/mutation-gate.mjs` (без `--check`, реальный прогон 4 новых
мутантов) не выполнял: он требует backend-гейт (`backend-test-guard.mjs` → pytest
→ HA), которого в песочнице ревьюера нет, а канонически такой прогон — часть
предрелизного `.github/workflows/mutation-gate.yml`, не гейта код-ревью
(это явно задокументировано в самом `scripts/mutation-gate.mjs`: «прогон дорогой,
его место — перед стабильным релизом»). Корректность каждого мутанта проверена
чтением и прослеживанием логики до конкретного assert (таблица выше), автор
отдельно заявил в handoff, что все четыре пойманы red в WSL/Linux.
- Browser smoke, `golden:verify`, `model-invariants` — не прогонял: ТЗ §11/§15.8
явно исключает их (рендер/геометрия/`layout` не меняются), и
`scripts/smoke-select.mjs` подтверждает отсутствие исполняемого frontend-диффа.
- Производительность «в бою» (реальная нагрузка HA-инстанса с сотнями ассетов)
не измерялась — вне возможностей этого ревью; оценка по коду: I/O теперь
O(число уникальных разрешённых id) вместо O(каталог), повторный blob — 0 байт
чтения при валидном cache-hit, что соответствует §16 ТЗ, подтверждено тестом
с hash-counter.
- Не проверял поведение при недоступной файловой системе (permission denied,
диск в read-only режиме) сверх штатного пути `OSError` → `except OSError:` в
`_signature()`/verify() → fail-dark; отдельного теста на этот конкретный
сценарий нет, но код структурно идентичен уже покрытому «missing file» случаю
(тот же `except OSError` перехватывает оба).
## Вердикт
Вердикт: зелёный · заход r1 · блокирующих циклов 0/4 · High: 0 · Medium: 0 → в задаче
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/432-asset-resolve-authorization-cache`, коммит `d8e67f530c33` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `3cba0a6a5cde3b9dcccba5c8bb7711c0fe92550c`
```
git log --all --format='%H %T' | grep 3cba0a6a5cde
```
- ТЗ `docs/specs/432-asset-resolve-authorization-cache.md`, блоб `8593bd54ad7d7e8a4d6459949fbb960c34ccddc9`
```
git log --all --find-object=8593bd54ad7d7e8a4d6459949fbb960c34ccddc9 -- docs/specs/432-asset-resolve-authorization-cache.md
```
+194
View File
@@ -0,0 +1,194 @@
# SPEC-REVIEW-432-r1
- Issue: https://github.com/Matysh/houseplan-card/issues/432
- ТЗ: `docs/specs/432-asset-resolve-authorization-cache.md`
- Материал ревью: SHA `17a1c10bef67ecd6235d36e324416e58142f3e11` (HEAD ветки на момент ревью, коммит `docs(spec): define bounded asset resolution`, дерево ветки `issue/432-asset-resolve-authorization-cache`)
- Заход: r1 · блокирующих циклов израсходовано 0 из 4 (лимит для полного трека — 4; лёгкий/короткий трек не применяется, трек полный)
- Вердикт: **зелёный**
## Скоуп ревью
Первый заход ревью ТЗ для issue #432 (security/performance баг: `houseplan/assets/resolve`
без проверки прав и без ограничения стоимости хеширования; тот же дефект стоимости у
`HouseplanContentView.get()`). Аналитика зафиксировала полный трек (два endpoint/модуля,
публичный контракт доступа и стоимость файловых операций меняются — критерии `small`
не выполняются, это явно названо в комментарии аналитики). Владелец ответил на
единственный продуктовый вопрос (Q1: что видит non-admin при `admin_only`) до написания
ТЗ; ТЗ фиксирует принятый Default. Ревью — по `PROCESS.md` §2.4 и §7.1, разбор полный
(первый заход, раздел «Унаследовано» не применяется).
## Как проверялось
1. `docs/SCOPE.md` — сценарий и персоны сверены с J1 (живой обзор), J4 (онбординг/каталог)
и J6 (устойчивость интеграции); особо — «View mode is the product for two of the three
personas», что прямо мотивирует контракт non-admin в ТЗ.
2. `AGENTS.md`, `PROCESS.md` §1, §2.3–2.4, §5, §7.1, §7.2 — формат ТЗ, класс изменений
(класс C, документ, `Issue:#432`/`User-Visible: no` в коммите `17a1c10b` — сверено
`git show --stat`), обязательные разделы, лимит циклов, формат вердикта.
3. Тело issue #432 и все 4 комментария (аналитика, вопрос Q1, решение владельца по Q1,
хендофф ТЗ на ревью) прочитаны целиком.
4. Код на этом SHA прочитан против каждого фактического утверждения ТЗ, не поверх:
- `custom_components/houseplan/websocket_api.py:1127–1161` — `ws_assets_resolve`
подтверждён: нет `_check_write`, нет `_runtime()`, полный `read_catalog(root)` +
`path.read_bytes()` + SHA-256 на совпавшую строку каталога;
- `custom_components/houseplan/http_api.py:157–216` — `HouseplanContentView.get()`
подтверждён: полный `read_bytes()` + SHA-256 на каждый GET `assets`, `immutable`
заголовок не ограничивает повторные запросы;
- `custom_components/houseplan/auth.py:16–31` — `may_write()` подтверждает точную
семантику writer/read-only, которую ТЗ использует в AC1–AC3;
- `custom_components/houseplan/decor_assets.py:353–408` — `asset_refs()`,
`read_catalog()`, `public_asset()` существуют и имеют заявленную сигнатуру;
`asset_refs()` действительно покрывает единственное место использования
`asset_id` в конфиге (перепроверено по `import_export.py`, `validation.py` —
других держателей `asset_id` в config нет);
- `custom_components/houseplan/const.py` — квоты 200 файлов / 256 МиБ / 2 МиБ и
`DECOR_ASSETS_API_VERSION = 1` подтверждены, совпадают с заявленным в ТЗ §6/§10;
- `custom_components/houseplan/store.py:78–92` — `write_lock`/`upload_lock`
существуют на `HouseplanData`, паттерн `async with rt.write_lock` уже используется
для похожего authoritative snapshot в `ws_assets_list` — контракт §7.3 технически
реализуем без изобретения нового примитива.
5. Сверены смежные документы: `docs/specs/051-custom-decor-images.md:323` — оригинальный
контракт `houseplan/assets/resolve` действительно зафиксирован как `authenticated
read` (не writer-only); `docs/specs/131-readonly-cold-start.md` — подтверждает, что
read-only View обязан быть визуально полным, что обосновывает Default-решение по Q1.
`docs/CONFIG-COMPATIBILITY.md:170` — запись про #432 добавлена и указывает на верный
файл ТЗ.
6. Проверено использование `resolveDecorAssets()` (`src/decor-assets.ts`) обеими
поверхностями — `src/houseplan-card.ts` и `src/space-card.ts` — что подтверждает
заявление ТЗ §11 о parity full/space card и наличие существующего frontend unit
теста `test/decor-assets.test.mjs`, на который ТЗ ссылается как на доказательство
для read-only View (AC1 покрывается backend-контрактом + этим тестом, а не новым
frontend-тестом).
7. `scripts/mutation-gate.mjs` — подтверждено, что реестр уже содержит мутанты для
`custom_components/houseplan/websocket_api.py` с backend pytest guard'ами (например,
строки 146–179), то есть план ТЗ §14/AC11 зарегистрировать постоянных свидетелей для
backend-защит — не изобретение нового механизма, а использование существующего.
8. Проверены обязательные разделы §7.1 PROCESS.md построчно (см. таблицу ниже) и
однозначность/доказуемость каждого AC1–AC11.
9. Дешёвые гейты не перегонялись: коммит `17a1c10b` — чистый docs-diff (`docs/specs/
432-asset-resolve-authorization-cache.md` + одна строка в `docs/specs/README.md`),
подтверждено `git show --stat`; Validate на этом SHA зелёный (см. ссылку в задании).
Для документа спецификации без изменений в `src/**`/`custom_components/**/*.py`
`typecheck`/`test`/`build`/`check-docs`/инварианты модели не относятся к предмету
ревью этого этапа — само содержимое ещё не код, а его читаемость и доказуемость.
## Проверка §7.1 (обязательные разделы) и однозначность AC
| Раздел §7.1 | Есть в ТЗ | Где |
|---|---|---|
| Сценарий (персона/поверхность/момент) | ✅ | §1 |
| Что человек увидит до/после | ✅ | §2 |
| Проблема | ✅ | §3, подтверждена кодом (см. выше) |
| Скоуп / не-скоуп | ✅ | §5 / §6 |
| Контракт поведения | ✅ | §7–§10 (доступ WS, GET, cache, ошибки/совместимость) |
| UX | ✅ | §11 — явно «новых контролов, текстов… нет» |
| Модель данных и миграция | ✅ (кратко, по существу — миграции нет) | §10 «Ошибки и совместимость», §20 (cache не persisted) |
| i18n | ✅ | §11 |
| AC1…ACn с доказательством | ✅ | §13, каждый AC помечен способом доказательства (`backend/HA`, `backend/unit`, `review/docs`, `mutation gate`) |
| План автотестов | ✅ | §15, 8 пунктов, включая явный список implementation-гейтов |
| Риски | ✅ | §17, 6 рисков со смягчением |
| Откат | ✅ | §18 |
| Release-артефакты | ✅ | §19 |
Раздел «Модель данных и миграция» не вынесен отдельным заголовком, а распределён между
§10 и §20 — содержательно раздел закрыт (нет schema/capability migration, cache
memory-only и не persisted), структурно это Low, не блокирует (см. «Находки»).
Обязательная по правилу #435 таблица защитных доказательств присутствует (§14),
третий столбец «чем краснеет» заполнен для каждой строки конкретной мутацией и
наблюдаемым эффектом — не общей фразой.
## Проверка отсутствия непомеченных догадок
Каждое фактическое утверждение о текущем поведении кода в ТЗ (§3, §7.1, §9.3, ссылки на
`may_write`, `asset_refs`, `read_catalog`, `write_lock`, квоты, capability-версию,
контракт #51 «authenticated read», обязательность read-only View по #131) сверено с
реальным кодом/документами выше и подтвердилось. Технические решения, для которых
однозначного prior art нет (например, точный состав cache signature `size + mtime_ns +
ctime_ns`, выбор между fail-dark и одной повторной попыткой, место хранения cache —
`hass.data` либо runtime-сервис), явно вынесены в §20 «Принятые технические
предположения» с пометкой «ревьюер вправе оспорить» — ни одно не выдано за факт.
Продуктовый вопрос (Q1) задан владельцу отдельно и заранее, до написания ТЗ, что и
требует правило «не бывает сложной задачи без единого открытого вопроса» — вопрос был,
он закрыт до этапа ревью, что для ревью ТЗ корректно (открытых продуктовых вопросов
к моменту сдачи ТЗ быть не должно).
## Находки
Нет находок уровня High или Medium.
**Low (не блокирует, зафиксировано без правки).**
1. Раздел «модель данных и миграция» из обязательного списка §7.1 PROCESS.md не выделен
отдельным заголовком, а распределён по §10/§20. Содержание присутствует и
исчерпывающее (нет миграции, cache не persisted), поэтому это вопрос структуры
документа, а не пропущенное решение. Снимается без правки: следующий автор того же
ТЗ увидит прецедент, что содержание важнее буквального оглавления, когда факт «нет
миграции» явно закрыт в другом месте того же документа.
## Что проверено и корректно
- Полная grounding-проверка технических утверждений ТЗ против фактического кода
(`websocket_api.py`, `http_api.py`, `auth.py`, `decor_assets.py`, `const.py`,
`store.py`) — расхождений не найдено.
- Product-рамка: сценарий и «что человек увидит» отвечают на оба обязательных
продуктовых вопроса, персона и поверхность названы, соответствие J1/J4/J6 по
`docs/SCOPE.md` подтверждено, включая явную ссылку на инвариант read-only View (#131).
- Решение владельца по Q1 корректно перенесено в контракт (§4, §7.3) без искажения:
read-only видит только referenced-assets, writer — полный каталог, GET не меняется.
- AC1–AC11 однозначны, у каждого назван способ доказательства; для защитных AC (AC2,
AC3, AC5, AC6, AC7, AC9) заполнена обязательная по #435 таблица «чем доказан / чем
краснеет» с конкретной мутацией, а не общей фразой.
- Не-скоуп (§6) корректно отделяет эту задачу от смежных: quota/upload-валидация,
writer-only GET, config schema migration, frontend/i18n, общий cache для других
типов файлов — явно исключены и не проросли в контракт.
- Откат (§18) явно запрещает «тихо» отключать security/performance защиту через флаг —
соответствует духу standing rule о необратимых действиях.
- Release-артефакты (§19) требуют оба changelog, обновление ARCHITECTURE.md и
CONFIG-COMPATIBILITY.md, что уже подтверждено записью в README ТЗ на этом SHA.
- Трейлеры коммита `17a1c10b` (`Issue: #432`, `User-Visible: no`) корректны для
docs-only спецификации; `docs/specs/README.md` содержит обратную ссылку на issue.
## Чего не проверял
- Реализацию — код ещё не написан, это этап ревью ТЗ, не код-ревью.
- Полный набор гейтов (`typecheck`/`test`/`build`/`golden`/backend pytest/browser
smoke/`model-invariants`) — не относится к предмету этого этапа: диапазон материала
этого раунда — только `docs/specs/432-*.md` и тело issue, изменений в `src/**` или
`custom_components/**/*.py` в этом коммите нет. Дешёвые гейты на SHA `17a1c10b`
подтверждены зелёным Validate (ссылка в задании), поэтому не перегонялись повторно.
- Осуществимость точной реализации bounded single-flight (потокобезопасность между
executor-потоками HA) — это техническое решение, оставленное автору по правилу §7.1
PROCESS.md («всё, чего пользователь не наблюдает, агенты решают сами»); будет
предметом код-ревью через AC6 и его mutation witness.
- Полноту `scripts/mutation-gate.mjs` записей для AC2/AC5/AC6/AC7 — их ещё нет (ТЗ
только планирует их появление в §14/AC11), поэтому проверять на этом этапе нечего;
это станет предметом код-ревью.
## Материал раунда
- SHA: `17a1c10bef67ecd6235d36e324416e58142f3e11`
- Дерево: `docs/specs/432-asset-resolve-authorization-cache.md`,
`docs/specs/README.md` (запись про #432)
- Ветка: `issue/432-asset-resolve-authorization-cache`
- Первый заход — раздел «Унаследовано из r0» не применяется.
## Вердикт
Вердикт: зелёный · заход r1 · блокирующих циклов 0/4 · High: 0 · Medium: 0 → в задаче
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/432-asset-resolve-authorization-cache`, коммит `17a1c10bef67` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `efc2aa18b50775a262a444d5ea1544c588646eba`
```
git log --all --format='%H %T' | grep efc2aa18b507
```
- ТЗ `docs/specs/432-asset-resolve-authorization-cache.md`, блоб `8593bd54ad7d7e8a4d6459949fbb960c34ccddc9`
```
git log --all --find-object=8593bd54ad7d7e8a4d6459949fbb960c34ccddc9 -- docs/specs/432-asset-resolve-authorization-cache.md
```
@@ -0,0 +1,371 @@
# ТЗ #432 — Ограниченный resolve и единая проверка целостности изображений
- Issue: https://github.com/Matysh/houseplan-card/issues/432
- Приоритет / тип: P2 · bug · security
- Трек: полный — меняются два backend endpoint, публичный контракт доступа и
стоимость файловых операций; критерии `small` из `PROCESS.md` не выполняются
- Связано: #51 (custom decor images), #131 (полный View read-only-пользователя),
#421/#430 (исполняемые отрицательные доказательства)
- Решение владельца: Default по Q1 принят в issue 2026-09-03
## 1. Сценарий
**Персона:** домочадец без права редактирования либо администратор, открывающий
полный House Plan / отдельную карточку пространства. На плане есть загруженные
растровые или SVG-изображения декоративного слоя.
При загрузке View frontend разрешает сохранённые `asset_id`, подписывает URL и
рисует изображения. Параллельно прямой либо ошибочный клиент может многократно
вызывать `houseplan/assets/resolve` и GET тех же файлов. Проверка целостности не
должна превращать обычную загрузку или злоупотребление API в сотни мегабайт
повторного чтения с диска.
Задача обслуживает J1/J4/J6: View остаётся полным для household members,
пользовательский файл проверяется до показа, а интеграция остаётся устойчивой.
## 2. Что человек увидит до и после
**До:** сохранённые изображения отображаются, но каждый resolve/GET заново
читает файл целиком и считает SHA-256. Повторные или параллельные обращения могут
нагружать диск и задерживать Home Assistant. Попытка закрыть дыру обычной
write-проверкой, наоборот, убрала бы изображения у read-only-пользователя.
**После:** те же сохранённые изображения без новых сообщений и настроек видны
администратору, домочадцу, в full card и space card. Неизменившийся blob
хешируется один раз и переиспользуется обеими transport-поверхностями;
read-only-пользователь не может использовать resolve как просмотр всего
внутреннего asset-каталога.
## 3. Подтверждённая проблема
1. `ws_assets_resolve()` принимает до 200 id, сканирует каталог и для каждого
совпавшего blob выполняет `path.read_bytes()` + SHA-256. При лимите 2 МиБ на
файл это до 400 МиБ чтения за один вызов и снова столько же за следующий.
2. `HouseplanContentView.get()` перед каждым GET asset повторяет тот же полный
`read_bytes()` + SHA-256. Заголовок `immutable` не защищает от прямого клиента
и не объединяет full card со space card.
3. Общего cache/single-flight нет: два одновременных запроса могут независимо
хешировать один и тот же blob.
4. `assets/resolve` не требует готового runtime и не различает writer и
read-only user.
5. Действующее ТЗ #51 намеренно называет resolve `authenticated read`; обе
карточки вызывают его в View. Поэтому безусловный `_check_write()` нарушит
#131 и целевую персону из `docs/SCOPE.md`.
## 4. Решение владельца
Принят Default:
- non-admin при `admin_only` продолжает видеть сохранённые декоративные
изображения;
- такой пользователь может разрешать только `asset_id`, на которые ссылается
текущий сохранённый config;
- writer может разрешать любой существующий asset для редакторских сценариев;
- authenticated/signed GET точного content URL сохраняется;
- WS resolve и HTTP GET разделяют один ограниченный cache/single-flight по пути
и файловой сигнатуре.
## 5. Скоуп
В задачу входят:
1. готовый runtime как обязательная предпосылка `assets/resolve`;
2. least-privilege фильтр requested ids для read-only connection;
3. сохранение полного resolve-контракта для `may_write == true`, включая случай
`admin_only: false`;
4. прямое чтение metadata только для разрешённых requested ids вместо полного
сканирования каталога;
5. один общий для WS и HTTP bounded integrity verifier;
6. cache по каноническому пути и файловой сигнатуре, включая размер и точные
timestamps; cache хранит вычисленный digest, а не bytes;
7. single-flight для параллельной проверки одной файловой версии;
8. потоковый SHA-256 ограниченными chunks без `Path.read_bytes()`;
9. invalidation при изменении файловой сигнатуры, bounded eviction и fail-dark
при исчезновении, I/O error, смене файла во время чтения или неверном hash;
10. backend/HA tests и постоянные mutation-witness для дорогих защит;
11. уточнение архитектурной и compatibility-документации, changelog RU/EN.
## 6. Не-скоуп
- изменение форматов PNG/JPEG/WebP/SVG, upload validation или лимита 2 МиБ;
- изменение namespace-квоты 200 файлов / 256 МиБ;
- новые rate limits, user-visible ошибки, repair, diagnostics или настройки;
- скрытие сохранённых изображений от household members;
- превращение content GET в writer-only endpoint;
- изменение signed URL, срока подписи, URL-формата, CSP, MIME или streaming
`FileResponse`;
- удаление файлов, сборка мусора либо пересмотр standing rule из `SCOPE.md`;
- config/schema migration, новые persisted/compatibility-поля;
- frontend batching/cache, рендер, редакторы, touch-жесты и i18n;
- общий cache для plans, manuals, export/import и других файлов House Plan.
## 7. Контракт доступа к `houseplan/assets/resolve`
### 7.1. Предпосылки
- HA WebSocket authentication остаётся внешней обязательной границей.
- Handler первым получает runtime через действующий fail-closed путь. Если
интеграция не готова, возвращается `not_ready`; каталог и blobs не читаются.
- `may_write(hass, connection.user)` остаётся единственным определением writer:
admin при `admin_only: true` либо любой authenticated user при
`admin_only: false`.
### 7.2. Writer
Writer может запросить любой корректный `asset_id` в пределах существующего
лимита сообщения. Для каждого id сервер напрямую читает одноимённую metadata
запись и проверяет соответствующий blob. Существующий ответ сохраняется:
валидный asset входит в `assets`, отсутствующий/невалидный/повреждённый — в
`missing`; дубликат присутствует не более одного раза.
### 7.3. Read-only user
Под `runtime.write_lock` берётся короткий coherent snapshot сохранённого config
и из него существующим `asset_refs()` строится множество разрешённых id. Lock
не удерживается во время metadata I/O или хеширования.
- Запрошенный id из множества используется так же, как у writer.
- Запрошенный id вне множества сразу попадает в `missing` и не вызывает чтение
его metadata, stat либо blob.
- Ответ не различает «не существует», «повреждён» и «не разрешён». Это сохраняет
partial resolve и не создаёт existence oracle.
- Один запрещённый id не отменяет разрешённые элементы той же пачки.
Config может измениться сразу после snapshot; это допустимая read-consistency.
Следующий resolve увидит новую сохранённую ревизию. Файл не удаляется на одном
факте исчезновения ссылки.
## 8. Контракт content GET
`GET /api/houseplan/content/assets/_/<hash>.<ext>` сохраняет существующие два
пути доступа: authenticated request либо валидная HA-подпись. Membership в
текущем config повторно не проверяется: подписанный URL обязан работать, а
content-addressed hash практически не перебирается.
До `FileResponse` asset проходит тот же integrity verifier, что WS. Неверный
digest, исчезновение или ошибка чтения дают прежний 404. Valid response
сохраняет exact MIME, CSP для SVG, `nosniff`, immutable private cache header и
потоковую отдачу. Plans/files этой задачей не меняются.
## 9. Integrity cache и ограничение стоимости
### 9.1. Identity
Cache key включает resolved canonical path; запись содержит файловую сигнатуру
и фактический SHA-256. Сигнатура включает как минимум `size`, `mtime_ns` и
`ctime_ns` (либо документированную точную платформенную замену). Ожидаемый hash
сравнивается с digest, а не становится единственным доказательством cache hit.
Перед использованием hit выполняется `stat`. Несовпадение сигнатуры означает
miss. После холодного чтения выполняется повторный `stat`; если файл изменился
во время вычисления, результат не публикуется и запрос fail-dark либо делает не
более одной повторной стабильной попытки. Бесконечного retry нет.
### 9.2. Стоимость и память
- Blob читается фиксированными chunks; полные bytes не сохраняются в памяти.
- Неизменившаяся файловая версия хешируется один раз на жизнь cache независимо
от того, пришёл первый запрос через WS или HTTP.
- Одновременные проверки одного key/signature выполняют ровно одно чтение;
остальные ждут тот же результат. Ошибка также будит ожидающих и не оставляет
key навсегда in-flight.
- Разные файлы не обязаны выполняться последовательно; реализация не должна
держать один глобальный lock на протяжении всех чтений.
- Cache ограничен не более чем 256 entries и вытесняет least-recently-used либо
эквивалентно детерминированный старый entry.
- Cached digest/negative integrity result применим только к той же сигнатуре.
Missing path не кешируется бессрочно без файловой сигнатуры.
- Cache memory-only, не входит в config/diagnostics/export/backup и очищается при
перезапуске HA. Persisted invalidation или миграция не нужны.
### 9.3. Прямой metadata lookup
Resolve не вызывает полный `read_catalog(root)`. Для каждого уникального
разрешённого id читается только `<asset_id>.json`; запись проходит те же проверки
формы, extension, id и наличия blob, что каталог. Shared helper обязан оставлять
`assets/list` и resolve согласованными, чтобы две копии validation не разошлись.
## 10. Ошибки и совместимость
- Public success payload `{assets, missing}` и metadata row не меняются.
- `not_ready` — единственная новая наблюдаемая ошибка для вызова в момент, когда
config entry не загружена; это тот же lifecycle-контракт остальных WS-команд.
- Read-only forbidden id становится `missing`, не `unauthorized`.
- I/O/JSON/stat/hash failures не содержат disk path или exception в ответе.
- Existing valid configs, exports/imports и image records читаются без миграции.
- Новый frontend со старым backend и старый frontend с новым backend продолжают
работать в пределах контракта #51; capability version не повышается.
## 11. UX, accessibility, touch и i18n
Новых контролов, текстов, focus/keyboard semantics и переводов нет. Full card и
space card рисуют тот же image либо существующий missing-placeholder. View,
kiosk, phone и tablet обязаны сохранить parity для read-only user; редакторы
остаются доступны только по действующему `can_write`.
Golden и browser smoke не требуются: рендер и frontend не меняются. Read-only
View доказывается backend permission-контрактом плюс существующими frontend
unit tests вызова resolve; код-ревью отдельно проверяет, что frontend не получил
writer-only зависимость.
## 12. Затронутые модули
Ожидаемый набор; имена нового helper могут быть уточнены без изменения
контракта:
- `custom_components/houseplan/decor_assets.py` либо новый чистый модуль рядом —
direct metadata lookup и bounded single-flight integrity cache;
- `custom_components/houseplan/websocket_api.py` — runtime/access filter и
использование общего verifier;
- `custom_components/houseplan/http_api.py` — тот же verifier перед asset
`FileResponse`;
- `custom_components/houseplan/__init__.py` / runtime helper — один cache на HA
instance с корректным lifecycle;
- `tests_backend/test_decor_assets.py` — чистые cache/direct-lookup тесты;
- `tests_backend/test_ha_websocket.py` — HA permission, WS/HTTP и shared-cache
integration tests;
- `scripts/mutation-gate.mjs` — постоянные отрицательные свидетели;
- `docs/ARCHITECTURE.md`, `docs/CONFIG-COMPATIBILITY.md`, changelog RU/EN.
`src/**`, frontend bundle и i18n не должны меняться, если реализация не обнаружит
отдельный, заранее согласованный compatibility blocker.
## 13. Критерии приёмки
- **AC1 (backend/HA).** При `admin_only: true` read-only user успешно разрешает
сохранённый referenced asset; full и space View не получают writer-only
зависимости.
- **AC2 (backend/HA, security).** Тот же user получает unreferenced id в
`missing`, тогда как referenced id из той же пачки остаётся в `assets`;
metadata/stat/blob запрещённого id не читаются. Writer разрешает оба, а при
`admin_only: false` обычный authenticated user имеет writer-контракт.
- **AC3 (backend/HA, lifecycle).** Без loaded runtime resolve отвечает
`not_ready` до любых filesystem operations.
- **AC4 (backend/unit).** Resolve читает metadata только уникальных разрешённых
requested ids и не сканирует остальные catalog rows; malformed/mismatched row
fail-dark и согласована с `read_catalog()`.
- **AC5 (backend/HA, performance).** Последовательные WS resolve и HTTP GET
одного неизменившегося valid blob в любом порядке вызывают одно потоковое
вычисление SHA-256 на общую файловую версию.
- **AC6 (backend/unit, performance).** N параллельных проверок одного
path/signature выполняют один loader/hash, получают одинаковый результат и не
оставляют in-flight state после success или exception. Проверки разных paths
могут продвигаться независимо.
- **AC7 (backend/unit/HA, integrity).** Изменение signature инвалидирует hit;
заменённый corrupt blob становится `missing` в WS и 404 в HTTP. Смена файла во
время чтения не кеширует неподтверждённый digest. Повторный запрос той же
corrupt signature не перечитывает blob.
- **AC8 (backend/unit, budget).** Cache хранит не более 256 entries, вытесняет
старые, не хранит bytes и вычисляет digest chunks без `Path.read_bytes()`.
- **AC9 (backend/HA, compatibility).** Authenticated и signed valid GET сохраняют
body, MIME/security/cache headers и streaming `FileResponse`; plans/files
остаются вне нового verifier.
- **AC10 (review/docs).** Payload, capability, config schema, imports/exports,
frontend, i18n и URL не меняются; architecture/compatibility docs и оба
changelog описывают новый access/cost contract.
- **AC11 (mutation gate).** Для дорогих защит зарегистрированы и исполнены
постоянные свидетели: снятие read-only membership filter краснит AC2; отключение
cache hit/single-flight краснит AC5/AC6; принятие digest после смены signature
краснит AC7. Штатное дерево проходит те же guards зелёным.
## 14. Таблица защитных доказательств
Эта таблица обязательна для handoff и code-review по правилу #435; точные имена
могут быть уточнены, но третий столбец не может исчезнуть.
| AC | Чем доказан | Чем обязан покраснеть |
|---|---|---|
| AC2 | HA test `test_decor_asset_resolve_readonly_is_limited_to_referenced_ids` | мутант удаляет membership filter до metadata lookup; unreferenced id появляется в `assets` либо вызывает I/O |
| AC3 | HA test `test_decor_asset_resolve_requires_runtime_before_io` | мутант удаляет `_runtime()`/ранний return; filesystem probe фиксирует обращение |
| AC4 | pure/HA test direct lookup со сторонними catalog rows | мутант возвращает `read_catalog(root)`; sentinel metadata вне request читается |
| AC5 | HA test WS → HTTP и HTTP → WS с hash counter | мутант всегда объявляет cache miss; counter становится больше 1 |
| AC6 | pure threaded single-flight test с управляемым barrier/loader | мутант удаляет in-flight coordination; loader вызывается N раз |
| AC7 | pure + HA test смены signature и mid-read mutation | мутант игнорирует signature/post-read stat; старый/нестабильный digest принимается |
| AC8 | pure LRU/chunk-reader tests | мутант снимает eviction либо заменяет chunk loop на `read_bytes()`; size/reader sentinel нарушается |
| AC9 | существующий и расширенный signed-content HA test | мутант обходит verifier для HTTP либо меняет headers/FileResponse; corrupt body отдаётся или contract assertions падают |
AC2/AC5/AC6/AC7, которые зависят от HA либо concurrency и не гарантированно
воспроизводятся локально у ревьюера, получают persistent entries в
`scripts/mutation-gate.mjs`. Для чистого AC8 допустим адресный red proof с
выводом в документе ревью.
## 15. План автотестов
1. Расширить #51 HA fixture двумя assets: один referenced, второй нет; выполнить
resolve read-only и writer connections при обоих значениях `admin_only`.
2. Подменить direct metadata/stat/hash seams счётчиками и доказать, что forbidden
id не достигает файловой системы, а unrelated catalog row не сканируется.
3. Вызвать handler без runtime и проверить `not_ready` + нулевые I/O counters.
4. Чисто протестировать hit/miss, LRU boundary 256/257, changed size/timestamps,
cached corrupt digest и bounded retry при изменении во время чтения.
5. Через управляемые threads/barriers одновременно проверить один и разные keys;
тест не использует sleep и имеет bounded join/timeout только как защиту от
deadlock.
6. В HA test последовательно вызвать WS и signed HTTP (затем обратный порядок)
и проверить единый hash counter, 200/404 и неизменные headers/body.
7. Запустить каждый mutation witness: исправное дерево зелёное, мутированное
падает именно целевым assertion, а не import/timeout ошибкой.
8. Implementation gate: `npm run typecheck`, `npm test`, `npm run build`;
backend HA-harness — Linux CI. Browser/golden/performance smoke не выбираются,
если `smoke-select` не обнаружит расширение frontend/visible surface.
## 16. Производительность и безопасность
- Повторный неизменившийся blob: 0 прочитанных content bytes для hash; допустим
один `stat` и bounded cache lookup.
- Холодный blob: не более его фактического размера, читаемого chunks; параллельные
запросы одной версии не умножают bytes.
- Память cache: O(256) metadata/digests/in-flight records, без blob bytes.
- Resolve I/O: O(число уникальных разрешённых requested ids), а не O(весь каталог).
- Read-only user не получает metadata unreferenced asset и не может заставить
verifier прочитать его через WS resolve.
- Секреты, локальные пути и причины fail-dark не входят в transport response.
## 17. Риски
- **Stale cache скроет повреждение.** Смягчение: precise signature до hit и
повторный stat после чтения; AC7 с отрицательным witness.
- **Single-flight deadlock после исключения.** Смягчение: cleanup/notify в
`finally`, детерминированный concurrent error test.
- **Глобальный lock сериализует разные images.** Смягчение: in-flight ownership
по key, AC6 отдельно запускает два paths.
- **Read-only View случайно станет writer-only.** Смягчение: referenced success
закреплён AC1 для обеих карточек как blocking compatibility invariant.
- **Partial batch выдаст existence oracle.** Смягчение: forbidden id неотличим от
missing/corrupt и не отменяет разрешённые rows.
- **Config меняется между auth snapshot и resolve.** Смягчение: snapshot короткий,
file deletion по inference запрещено, следующий load пересинхронизирует View.
## 18. Откат
Откат — revert backend helper и его вызовов, возврат прежних resolve/GET путей.
Persisted state, config, assets и migration rollback отсутствуют. Security и
performance защиты не имеют runtime-флага: временное отключение cache не должно
молча отключать membership guard или integrity check.
## 19. Release-артефакты
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`: кратко описать сохранение
read-only View и устранение повторного хеширования (`User-Visible: yes`);
- `docs/ARCHITECTURE.md`: access matrix resolve/GET и shared verifier lifecycle;
- `docs/CONFIG-COMPATIBILITY.md`: отсутствие schema/capability migration и
rolling compatibility;
- `scripts/mutation-gate.mjs`: security/performance/integrity witnesses;
- user guide, i18n, screenshots/golden: без изменений;
- handoff содержит точный SHA, HA test names, hash/I/O counters и результаты
каждого отрицательного witness.
## 20. Принятые технические предположения
- Forbidden read-only id возвращается как `missing`, а не ошибкой всего вызова:
это сохраняет partial batching и не раскрывает существование файла.
- Cache принадлежит HA instance и лениво доступен обоим endpoint; конкретное
место хранения (`hass.data` либо эквивалентный runtime service) не является
persisted контрактом.
- Лимит cache 256 покрывает максимальные 200 promoted assets с небольшим
служебным запасом и остаётся явной тестируемой константой.
- Exact cache signature включает `ctime_ns` сверх предложенных issue
path/mtime/size: это усиливает invalidation без изменения пользовательского
контракта.
- Ссылки на строки ориентировочны; реализация привязывается к символам и
поведению, если `dev` сдвинется до начала разработки.
+1
View File
@@ -167,6 +167,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#421](https://github.com/Matysh/houseplan-card/issues/421) Отрицательные доказательства для трёх защитных проверок | [421-negative-test-proofs.md](421-negative-test-proofs.md) |
| [#426](https://github.com/Matysh/houseplan-card/issues/426) Отключение информационного окна комнаты при наведении | [426-room-hover-tooltip-toggle.md](426-room-hover-tooltip-toggle.md) |
| [#431](https://github.com/Matysh/houseplan-card/issues/431) Канонизация координат пользовательских изображений | [431-image-coordinate-canonicalization.md](431-image-coordinate-canonicalization.md) |
| [#432](https://github.com/Matysh/houseplan-card/issues/432) Ограниченный resolve и единая проверка целостности изображений | [432-asset-resolve-authorization-cache.md](432-asset-resolve-authorization-cache.md) |
## P3
+52
View File
@@ -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',
+1 -1
View File
@@ -51,6 +51,6 @@ if HAS_HA:
"""Do not let warm HA harness runs exhaust the shared file quota."""
if "hass" in request.fixturenames:
hass = request.getfixturevalue("hass")
for relative in ("houseplan/plans", "houseplan/files"):
for relative in ("houseplan/plans", "houseplan/files", "houseplan/assets"):
shutil.rmtree(Path(hass.config.path(relative)), ignore_errors=True)
yield
+195
View File
@@ -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": [
+139
View File
@@ -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"]