Issue: #426 Issue: #427 Issue: #428 Issue: #431 Issue: #432 Issue: #434 User-Visible: no
27 KiB
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(коммиты17a1c10bdocs,f3c32fb2fix,d8e67f53test) - Заход: 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» не применяется.
Как проверялось
docs/SCOPE.md,PROCESS.md§2.7, §7.1, §8,AGENTS.md— формат ревью, классы файлов, обязательность таблицы «AC · чем доказан · чем краснеет» (#435).- Тело issue #432 и все комментарии (аналитика, вопрос/ответ владельца Q1, ТЗ на ревью, зелёное SPEC-REVIEW-432-r1, handoff «Реализация готова») прочитаны целиком.
- ТЗ
docs/specs/432-asset-resolve-authorization-cache.md(§7–§16, AC1–AC11, таблица §14) сверено построчно с фактическим кодом на SHAd8e67f53. - Полный
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:falsewriter-контракта,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 миграции.
- Трейлеры коммитов проверены:
f3c32fb2—Issue: #432/User-Visible: yes, оба changelog изменены в этом же коммите (git show --stat);d8e67f53—Issue: #432/User-Visible: no, толькоtests_backend/conftest.py(класс B, повторного changelog не требует). - Каждый мутант из п.4 (
scripts/mutation-gate.mjs) мысленно применён к соответствующей строкеasset_integrity.py/websocket_api.pyи прослежен по логике кода до конкретного assert, который он обязан сломать (таблица ниже); структурная валидность патчей подтверждена командой (см. «Гейты»). - Проверена история 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 — не находка ревью. - Отдельно проверено, что на этом же прогоне (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 (не блокирует, зафиксировано без правки).
- AC9 (streaming/headers для
assets) и часть AC3/AC4 доказаны только чтением и детерминированным white-box unit-тестом (шпион), без отдельной записи вscripts/mutation-gate.mjs. Это разрешено правилом #435 для AC, не требующих дорогого гейта (backend/HA здесь не обязателен именно для этой мутации: отделение ветокif kind == "assets"/elifтривиально и не зависит от concurrency или HA-специфики). Снимается без правки — расширять реестр ради тривиальной ветки было бы ритуалом, который правило прямо исключает. docs/CONFIG-COMPATIBILITY.mdдобавляет абзац о #432 в середину раздела про отдельный более старый compatibility-кейс (downgrade изображений), а не отдельным подзаголовком. Контент корректен и полон, это вопрос структуры документа. Снимается без правки.
Чего не проверял
- Backend/HA pytest не исполнял локально: в песочнице ревьюера нет модуля
homeassistantи.venv-backend; полагаюсь на зелёный Linux CI job «Бэкенд: pytest в Home Assistant» на точном SHAd8e67f53(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 → в задаче
Материал раунда
- Ветка:
issue/432-asset-resolve-authorization-cache, коммитd8e67f530c33— ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала:
3cba0a6a5cde3b9dcccba5c8bb7711c0fe92550cgit log --all --format='%H %T' | grep 3cba0a6a5cde - ТЗ
docs/specs/432-asset-resolve-authorization-cache.md, блоб8593bd54ad7d7e8a4d6459949fbb960c34ccddc9git log --all --find-object=8593bd54ad7d7e8a4d6459949fbb960c34ccddc9 -- docs/specs/432-asset-resolve-authorization-cache.md