Files
houseplan-card/docs/reviews/CODE-REVIEW-51-r1.md
T
2026-09-02 21:56:04 +00:00

33 KiB
Raw Blame History

CODE-REVIEW — issue #51 «Custom decor images» · заход r1

  • Issue: https://github.com/Matysh/houseplan-card/issues/51
  • Материал: git log origin/dev..HEAD, git diff origin/dev...HEAD
  • SHA материала: 38205d87a6e65bf670e214050223a9e22a9d2a42 (сверено с git rev-parse HEAD непосредственно перед выводом)
  • База: origin/dev = e438c25ebe5ffbd4214da473d62a305016a3f3df
  • Ветка приведена к dev конвейером до ревью (8 коммитов dev поверх исходного c4e45aa9, итог — единственный ребейзнутый коммит 38205d87). Это другой код (§7.2) — разбор ниже полный, не по дельте. Второго раунда до этого не было, поэтому раздел «Унаследовано из r» не применяется.
  • Трейлеры коммита 38205d87: Issue: #51, User-Visible: yes — оба changelog правлены в этом же коммите (проверено, см. «Гейты»/AC16).

Скоуп изменения

Новый decor kind image: приватное content-addressed хранилище PNG/JPEG/WebP/SVG (backend decor_assets.py, http_api.py, websocket_api.py, validation.py, import_export.py, const.py), одна кнопка «Изображение» в Background editor, furniture-parity transforms без wall magnet, palette с явным удалением неиспользуемого файла, отображение в full View и houseplan-space-card, missing-asset repair-placeholder, export v2/import v1 совместимость, i18n RU/EN/DE/FR, документация. 63 файла, ~4200/1200 строк. Спека docs/specs/051-custom-decor-images.md (16 AC) прошла ревью ТЗ зелёным (docs/reviews/SPEC-REVIEW-51-r1.md, единственная находка — Low, косметика).

Как проверялось

Ревью выполнено чтением всего диффа (git diff origin/dev...HEAD) плюс двумя параллельными агентами: один разобрал backend security (decor_assets.py, http_api.py, websocket_api.py, validation.py, import_export.py, tests_backend/*), второй — frontend (decor-assets.ts, houseplan-editor-runtime.ts, space-render.ts, space-geometry.ts, houseplan-card.ts, backdrop-pick.ts, test/decor-assets.test.mjs, i18n). Их находки перепроверены мной лично там, где это осмысленно (см. ниже): самое серьёзное — воспроизведено собственноручно, не принято на слово.

Гейты — что прогнано и результат

Гейт Команда Результат
Typecheck npx tsc --noEmit зелёный
Unit npm test 1775 passed, 1 skipped, 0 failed (1776 подтестов)
Build npm run build зелёный
Bundle sync npm run bundle:sync зелёный, git status после — чисто (три копии совпадают)
Bundle budget npm run bundle:budget initial View 291026 B / 300000 B — прошёл, но запас 8974 Б < порога 15000 Б (предупреждение скрипта, ссылка на #367). Не блокирует, но следующая фича упрётся в стену бюджета — стоит упомянуть автору
Backend pure python3 -m pytest tests_backend/test_decor_assets.py -q (без Pillow/HA в песочнице ревьюера) 21 passed
Golden npm run golden:verify 153/153 passed — существующие сцены не пострадали от нового decor-слоя
Docs fingerprint не прогонялся отдельно — npm run build/bundle:sync не жаловались; diff src/** есть, но docs-скриншот-гейт CI не эмулировался локально (нет соответствующего npm-скрипта в перечне пройденных мной команд) не проверял, см. «Чего не проверял»
Model invariants (npm run invariants) не прогонялся diff не трогает marker.space, open_spans, thickness-записи, layout (проверено grep по src/ и custom_components/houseplan/*.py — совпадений нет); decor — не геометрия комнат/стен. Инварианты сочтены нерелевантными, не пропущенными
smoke_decor_images.mjs, smoke_decor_layer_order.mjs, smoke_furniture.mjs (названы автором) node demo/<file> OK (переподтверждено)
Дополнительные smoke по smoke-select.mjs (см. ниже) node demo/<file> × 11 10 зелёных, 1 красный — см. Finding 2
python -m pytest tests_backend/test_ha_websocket.py test_ha_import_export.py (полный HA-harness) — не прогонял: нет homeassistant/Pillow в песочнице ревьюера (по канону AGENTS.md — Linux CI/WSL). Разобрано чтением (см. ниже)

Выбор дополнительных smoke (scripts/smoke-select.mjs --base origin/dev --head HEAD)

Инструмент: изменено 10 файлов src/**, 146 символов на изменённых строках, порог «широкого» символа — 42. 67 файлов дали прямое совпадение, 48 — слабую связь. Полный прогон всех 67 непропорционален задаче (диапазон широк из-за общих символов _curSpaceCfg/_mode/_config/_saveConfig — они везде). Прогнал 11 из прямых совпадений, выбранных по риску: smoke_decor.mjs (общий decor-контракт), smoke_furniture_polish.mjs (transform-код теперь общий с image), smoke_grid_snap.mjs, smoke_hide_layers.mjs (AC5 hide_decor), smoke_wall_junctions.mjs (прецедент #234 — по имени про стыки стен, ранее ловил регресс толщины из внешне не связанного изменения), smoke_backdrop_guard.mjs (переиспользован в backdrop-pick.ts для upload-диалога), smoke_space_card.mjs, smoke_space_card_bg.mjs (AC5 static card parity), smoke_sign_cap.mjs, smoke_plan_signed.mjs (contentsigning), smoke_infinite_canvas.mjs (decorBoxItem/«Вписать всё»).

Результат: 10 OK, 1 упал — demo/smoke_decor.mjs (см. Finding 2). Не прогонял оставшиеся ~56 прямых совпадений и все 48 слабых связей — не пропорционально задаче; полный набор остаётся обязанностью пре-релиза.

Находки

Finding 1 (High) — SVG-санитайзер обходится кодировкой UTF-16: entity-bomb достигает ET.fromstring в обход собственной защиты от DTD/entity

Файл: custom_components/houseplan/decor_assets.py:161-172.

Проверка DTD/entity/PI — это префильтр по байтовым ASCII-подстрокам (b"<!doctype", b"<!entity", b"<?xml-stylesheet", а также отдельная проверка b"<?" in without_declaration) поверх data.lower(). Она ничего не находит, если файл закодирован в UTF-16 (каждый ASCII-символ перемежается нулевым байтом), поэтому строка b"<!doctype" физически отсутствует в байтах, хотя после декодирования это валидный <!DOCTYPE. ET.fromstring(data) в строке 170 — это xml.etree.ElementTree поверх expat, который сам автоматически определяет кодировку по BOM и раскрывает DTD/entity без собственной защиты от экспоненциального разрастания (в отличие от defusedxml, который здесь не используется нигде).

Воспроизведено лично (не только со слов агента): собрал classic "billion laughs" (5 уровней вложенности <!ENTITY a0>…<!ENTITY a4>, исходный размер ~800 байт), закодировал в UTF-16, скормил custom_components/houseplan/decor_assets.validate_asset. Результат: файл ушёл дальше строки 170 (ET.fromstring успешно раскрыл entity — сам разросшийся текст, ~32 000 символов, дошёл до элемента <title> и был отклонён только последующей проверкой длины текста title/desc — то есть раскрытие уже произошло и уже стоило памяти/CPU до того, как сработал любой из декларированных в ТЗ лимитов «element/depth/attribute» (они применяются в цикле по уже построенному дереву, а не во время парсинга). Если поместить растущую сущность не в <title>, а в атрибут вроде d, до неё вообще не дойдёт ни один из этих лимитов раньше, чем ET.fromstring целиком построит раздутое дерево в памяти — при нескольких дополнительных уровнях вложенности из файла кратно меньше 2 МиБ получается экспоненциальный рост в память/CPU, всё в HA executor-потоке (не фризит event loop, но реально грузит воркер и память процесса HA — то есть DoS на типичном для HA маломощном хосте).

Это прямой пробой явно заявленного в ТЗ контракта («XML parser запрещает DTD, entity declarations/resolution» и «Risks»/«Streaming byte cap ставится до parse, затем element/depth/... limits») — контракта, который является центральным для issue с меткой security. Ни один существующий тест не ловит этот класс обхода: tests_backend/test_decor_assets.py:38 содержит DOCTYPE/ entity-кейс, но только в ASCII-кодировке — «тест умеет падать» на прямой ASCII атаке, но не умеет упасть на этой (кодировочной) вариации, а значит не доказывает заявленный AC7 целиком.

Направление фикса (не мне решать точный путь — это техническое предположение автора): использовать defusedxml.ElementTree.fromstring (или явно сконфигурировать expat-парсер так, чтобы StartDoctypeDeclHandler/ EntityDeclHandler фейлили закрыто) вместо байтового префильтра — это единственный способ не зависеть от кодировки на входе.

Блокирует. AC7 не может считаться доказанным до исправления.

Finding 2 (Medium, в скоупе) — диф ломает существующий, не относящийся к задаче smoke: demo/smoke_decor.mjs

Файлы: src/houseplan-card.ts (диф убирает делегатор), demo/smoke_decor.mjs (не тронут этим diff, но полагается на убранный метод).

git diff origin/dev...HEAD -- src/houseplan-card.ts убирает из класса HouseplanCard тонкие делегаторы _furnPlace, _furnPick, _furnFieldValue, _furnFieldToCm, _furnMoveUpdate, _decorApplyBox, _renderFurnPalette, _renderFurniturePlacementPreview и др. (были вида private _furnPlace(...) { return this._editorRuntimeOrThrow()._furnPlace(...); }) — методы теперь живут только внутри HouseplanEditorRuntime и вызываются как this._furnPlace(...) изнутри самого runtime, что не ломает ни один реальный пользовательский сценарий (пользователь не видит разницы). Но demo/smoke_decor.mjs:192 вызывает c._furnPlace(...) напрямую на элементе карточки — это существующая (не относящаяся к image) конвенция теста.

Автор знал про эту архитектурную смену: demo/smoke_furniture.mjs в этом же diff переписан с c._furniturePreviewPlacement на c._editorRuntime?._furniturePreviewPlacement() ровно по этой причине. Но smoke_decor.mjs пропущен — не входил в список прогнанных автором smoke («node demo/smoke_decor_images.mjs — OK; …smoke_decor_layer_order.mjs — OK; …smoke_furniture.mjs — OK» — smoke_decor.mjs в списке нет).

Воспроизведено лично, дисциплина «тест умеет падать» подтверждена в обе стороны:

  • на HEAD (38205d87): node demo/smoke_decor.mjs → TypeError: c._furnPlace is not a function, процесс падает с ненулевым кодом;
  • на базе (origin/dev = e438c25e, отдельный git worktree, свежая пересборка): тот же smoke проходит полностью, OK.

Других мест, вызывающих остальные убранные делегаторы напрямую на c., не нашлось (grep по demo/*.mjs test/*.mjs — пусто) — поражение точечное, единственный файл.

Это не поведенческий дефект для пользователя, но это красный существующий гейт, который переживёт этот раунд ревью незамеченным, если его не запустить специально (что и произошло — автор его не запускал), и всплывёт при следующем полном пре-бета прогоне ровно по сценарию #230/#234/#237 («красный job до следующей задачи»). Фикс механический — тот же паттерн, что уже применён к smoke_furniture.mjs. В скоупе задачи (тот же файл houseplan-card.ts, тот же коммит), чинится в этом же issue, отдельный issue не заводится (#202).

Finding 3 (Medium, в скоупе) — «единая проекционная функция» AC5 не буквальна: рендер дублирован между View и Background editor

Файлы: src/houseplan-card.ts:8762-8779 (Background editor слой) и src/space-render.ts:636-655 (View/space-card слой).

Оба места независимо собирают одну и ту же трансформ-строку (translate(cx cy) rotate(angle) scale(±1 ±1) translate(-cx -cy)) и клэмп opacity — но как две раздельные реализации, а не единая функция, которую оба вызывают. Спека прямо требует: «Полный View и houseplan-space-card используют одну projection-функцию». houseplan-card.ts использует normalizeAngle, space-render.ts — свой Number(shape.angle) || 0; сегодня они численно эквивалентны (период вращения, одинаковая трактовка нечисловых значений), видимого дефекта нет.

Это ровно класс проблемы из «Одно число — один источник»: #233/#234 родились из двух независимых формул для «одного и того же» значения, которые молча разошлись при последующей правке одной из копий. Конкретный сценарий отказа: кто-то поправит клэмп opacity в одном файле (например, изменит fallback с 1 на «унаследовать предыдущее значение») и не тронет второй — тогда full View и houseplan-space-card отрисуют один и тот же DecorImage с разной прозрачностью, и ни один unit-тест этого не поймает (существующий furniture-stroke-contract проверяет паритет preview/placement для мебели, не paritet View/static-card для image). Единственная защита сегодня — ручное внимание при следующей правке.

В скоупе (оба файла в этом же diff), чинится в этом же issue сведением к одной экспортируемой функции проекции.

Finding 4 (Medium, в скоупе) — нет negative/positive-кэша resolve по asset_id; явное требование производительности из ТЗ не выполнено

Файл: src/decor-assets.ts (resolveDecorAssets, batching по 200, дедупликация Set — есть), вызовы houseplan-card.ts:4310 и space-card.ts:732.

Спека, раздел «Производительность»: «Missing ids также negative-cache-ятся на текущую config revision». Кэша (ни отрицательного, ни положительного) нет: _syncDecorAssets/_load перевызывают houseplan/assets/resolve при каждом onConfigChange/_reloadConfigOnly, включая случаи, когда набор decorAssetIds не изменился и/или id уже был подтверждён отсутствующим секундой раньше. Конкретный сценарий: план с одним missing image; любая структурная правка в редакторе (подвинуть мебель, переименовать комнату) шлёт resolve заново на тот же уже-известный-missing id — вместо нуля запросов на не относящуюся к декору правку получаем один WS-вызов на каждое изменение. AC14 (1000 записей, ограниченный resolve) формально проходит благодаря одной только батч-дедупликации на батч в 200 — тест не проверяет повторные вызовы между разными конфиг-ревизиями, поэтому пропускает именно то отклонение, которое здесь описано.

В скоупе (тот же новый файл), не архитектурный блокер, но явно недовыполненный пункт спеки — годится для правки в этом же issue.

Finding 5 (Low) — тач-committed размещение всегда использует mouse-допуск снапа, не touch

src/houseplan-editor-runtime.ts:5130-5150: _decorImagePlace вызывает this._decorSnap(raw) без pointerType, получая допуск по умолчанию ('mouse', ~8px) даже когда коммит пришёл с тач-пути (_furnPointerUp не прокидывает pending.pointerType, в отличие от _furnPlace, который явно форвардит его дальше). Практический эффект — тач-размещение картинки получает более тесный магнит, чем предусмотренный touch-радиус (~14px); паритет с мебелью небольшой, не架构ный. Можно поправить или снять с записью.

Finding 6 (Low, информационно, не находка) — SVG: числовые/строковые bounds отдельных атрибутов не проверяются, только допустимость имени

custom_components/houseplan/decor_assets.py ограничивает суммарный бюджет символов атрибутов (MAX_SVG_ATTR_CHARS=512000) на весь документ, но не проверяет диапазон/конечность значения отдельных атрибутов (transform, stroke-width, opacity и т. п. — только их допустимость по имени). Спека формулирует это как «числовые/строковые bounds» для этих атрибутов. Поскольку SVG вставляется только как <image> (изолированный растровый контекст в браузере, не inline DOM — см. п. 6 «Отображение и слои»), практический риск — не XSS/SSRF, а максимум деградация рендера самого файла в собственном изолированном контексте. Не блокирует, не Same класс, что Finding 1 (там пробита реальная защита от конкретной атаки; здесь просто не реализован явно заявленный, но не критичный по факту изоляции пункт спеки). Годится либо поправить, либо явно снять с owner-note в спеке «принято предположительно».

Finding 7 (Low, информационно) — javascript:/data:-substring проверка в SVG сама по себе тривиально обходима, но не является рабочей защитой

decor_assets.py:207-209: проверка low на подстроки javascript:, data:, http:, https:, // теоретически обходится непечатным символом внутри схемы (например, табуляцией), но это не единственная защита — href дополнительно ограничен строгим ^#id, и ни один другой allowlisted атрибут не трактуется как исполняемый URL-приёмник. Не эксплуатируется при текущем allowlist. Отмечено для полноты, не как отдельный блокер.

Проверено и корректно (по AC)

  • AC1 (кнопка/palette/one-shot) — подтверждено чтением + demo/smoke_decor_images.mjs (прогнан, OK) + переподтверждено фронтенд-агентом построчно (houseplan-editor-runtime.ts:5875, :5130-5148, :5909).
  • AC2 (100 см / aspect / cap 200) — initialDecorImageCm (src/decor-assets.ts:66-76) единая функция и для preview, и для commit; unit test/decor-assets.test.mjs покрывает cap-кейс реальными числами (400×200→100×50, 100×400→50×200 с реальным capping, не тавтология).
  • AC3 (furniture-parity, без wall magnet, hit-area — весь прямоугольник) — подтверждено: wall magnet явно гейтится kind === 'furniture' (houseplan-editor-runtime.ts:4316), image идёт через общий decor/room-магнит; pointer-events: bounding-box в src/styles/plan.styles.ts для .dimage/.dimage-missing. Демо-негативный свидетель есть (noFurnitureWallMagnet), хоть и не самый сильный (см. отчёт агента — не заводит отдельную находку, слабое место без конкретного сценария отказа).
  • AC4 (properties/replace/no-delete-on-replace) — подтверждено чтением (_decorSaveShape пишет только по shape.id, replace меняет только asset_id выбранного объекта) + smoke.
  • AC5 (общий рендер, hide_decor, пассивность) — hide_decor и pointer-inert подтверждены в обоих файлах; «одна проекционная функция» — см. Finding 3 (дублирование, не расхождение сегодня).
  • AC6 (raster/SVG сигнатуры, лимиты, атомарность) — подтверждено чтением decor_assets.py/http_api.py + test_ha_websocket.py (согласно отчёту агента: concurrent-upload dedup тест реально бы упал без lock/atomic-rename) — проверено чтением, не исполнением (HA-harness недоступен в песочнице ревьюера).
  • AC7 (SVG allowlist, sandbox headers) — не полностью доказан, см. Finding 1 (High). Заголовки (exact MIME, nosniff, sandbox CSP) подтверждены чтением http_api.py:195,211-213.
  • AC8 (content-addressed id, dedup) — подтверждено: asset_id есть sha256 канонических байт (decor_assets.py:71-72), resolve/GET перепроверяют хэш на каждое чтение.
  • AC9 (server-side refcount, in_use, no auto-cleanup) — подтверждено: delete не принимает client refcount вообще, пересчитывает под write_lock+upload_lock из config_store (websocket_api.py:1164-1190); test_ha_websocket.py содержит race-тест на in_use и идемпотентность — проверено чтением утверждений теста, не исполнением.
  • AC10 (missing asset/repair) — подтверждено: View возвращает [] при пустом href, editor рисует selectable placeholder с «Заменить»; smoke покрывает.
  • AC11 (export v2/import v1, manifest без байт) — подтверждено чтением import_export.py: _internal_path строгий regex на 64-hex+расширение (защита от path traversal), content_manifest пересчитывает hash с диска (не доверяет заявленному), импорт помечает missing_preserved без удаления geometry.
  • AC12 (rolling compatibility) — backend: схема аддитивна (vol.Any), decor_assets_api: 1 отдаётся безусловно; frontend fail-closed gate подтверждён (houseplan-card.ts:4300-4306, _haDecorAssetsApi сверяется с DECOR_ASSETS_API_VERSION перед показом кнопки/upload).
  • AC13 (i18n паритет) — подтверждено программно: ключи en/ru/de/fr идентичны построчно (сверка множеств ключей, разницы нет).
  • AC14 (bounded batched resolve) — батчинг/дедуп на вызов подтверждён; явно заявленный negative-cache отсутствует, см. Finding 4.
  • AC15 (гейты зелёные, targeted smoke до ревью) — typecheck/test/build зелёные лично переподтверждены; targeted-смоки, названные автором, зелёные; smoke_decor.mjs красный — см. Finding 2 (это не входит в AC15 буквально, так как AC15 требует только «targeted image smoke», но красный существующий smoke — самостоятельная находка).
  • AC16 (документация/changelog) — подтверждено: DECOR-EDITOR.md, ARCHITECTURE.md, CONFIG-COMPATIBILITY.md, USER-GUIDE.md/.ru.md, TESTING.md, TESTING-DEMO.md правлены; оба changelog в этом же User-Visible: yes коммите (проверено git show --format=%b).

Чего не проверял

  • Полный HA-harness (tests_backend/test_ha_websocket.py, test_ha_import_export.py, concurrency/race-тесты, golden HA-фикстуры) — в песочнице ревьюера нет homeassistant/Pillow (по канону AGENTS.md это Linux CI/WSL-only гейт), запуск дал бы молчаливый skip, а не доказательство. Разобрано чтением утверждений тестов и сверкой с кодом (см. AC6/AC9/AC11 выше) — не исполнением.
  • Полный demo/smoke_* набор (214 файлов, 67 прямых совпадений) — прогнал 14 из них (3 названных автором + 11 отобранных по риску), не 67. Оставшиеся ~53 прямых совпадения и 48 слабых связей не прогнаны — непропорционально объёму задачи; остаются обязанностью пре-бета прогона (AGENTS.md). Вывод smoke-select.mjs приложен выше целиком со списком «прямое совпадение»/ «слабая связь», решение по каждой из непрогнанных строки не расписано построчно — учтён общий довод (общие символы _mode/_config дают широкий, малоинформативный список).
  • Performance smoke / large-house 1000-record профиль (AC14 численно) — не прогонял; это предрелизный гейт, доказательство AC14 принято чтением батчинг-кода + unit-теста дедупликации.
  • npm run docs:check/scripts/check-docs.mjs (screenshot fingerprint) — не нашёл отдельного npm-скрипта с таким именем в package.json, не прогонял отдельно; npm run build/bundle:sync не жаловались на fingerprint-рассинхрон, но это не то же самое утверждение.
  • Точный численный порог AC2 «bounds» на промежуточных cell_cm/DPI комбинациях сверх того, что покрывает test/decor-assets.test.mjs, — принял unit-покрытие как достаточное доказательство, не гонял ручной матрицы.
  • Touch/accessibility (AC13 non-i18n часть: 44×44 таргеты, focus trap, aria) — не тестировал в браузере руками (ручного тестирования в цикле нет по процессу); принял на основе чтения CSS/aria-атрибутов в diff, не верифицировал реальным screen-reader/touch-эмулятором.

Материал раунда

  • Ветка на момент вывода: 38205d87a6e65bf670e214050223a9e22a9d2a42 (сверено git rev-parse HEAD непосредственно перед выводом — совпадает).
  • База: origin/dev = e438c25ebe5ffbd4214da473d62a305016a3f3df.
  • Предыдущего кодового раунда не было (это r1 code review); раздел «Закрытие предыдущего раунда» неприменим.

Материал раунда

  • Ветка: issue/51-custom-decor-images, коммит c4e45aa99c63 — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
  • Дерево материала: 5fe4f2859927979f0fdec07a24b989a67c5ccb38
    git log --all --format='%H %T' | grep 5fe4f2859927