mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +00:00
Issue: #426 Issue: #427 Issue: #428 Issue: #431 Issue: #432 Issue: #434 User-Visible: no
372 lines
28 KiB
Markdown
372 lines
28 KiB
Markdown
# ТЗ #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` сдвинется до начала разработки.
|