mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6a9757b6d | ||
|
|
d8e67f530c | ||
|
|
f3c32fb203 | ||
|
|
58df908db2 | ||
|
|
17a1c10bef |
@@ -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
|
||||
@@ -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"])),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Сохранённые пользовательские картинки по-прежнему видны домочадцам без права
|
||||
редактирования, но произвольный поиск файлов теперь закрыт; повторные загрузки
|
||||
карточки и HTTP используют одну ограниченную потоковую проверку вместо нового
|
||||
чтения каждой картинки
|
||||
([#432](https://github.com/Matysh/houseplan-card/issues/432)).
|
||||
- Пользовательские изображения декора теперь проходят тот же стабильный барьер
|
||||
записи координат, что мебель и фигуры, поэтому повторные сохранения и
|
||||
«Оптимизировать планы» больше не сохраняют float-шум только у изображений
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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` сдвинется до начала разработки.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user