23 KiB
SPEC-REVIEW-498-r1
- Issue: https://github.com/Matysh/houseplan-card/issues/498
- ТЗ:
docs/specs/498-backend-hardening-quota-palette-svg-refs.md - Материал: ветка
issue/498-backend-hardening-quota-palette-svg-refs, SHA95493de2fcdbf4e27b91038de92ccebab42719ec(единственный коммит поверх аналитики; тело issue не редактировалось после S2) - Этап: ТЗ на ревью (PROCESS.md §2.4), заход r1, блокирующих циклов израсходовано 0 из 4
- Трек: полный (аналитик назвал нарушенный критерий §5 «одна поверхность» — три независимых модуля; корректно)
Скоуп
Три независимых защитных дефекта из аудита 2026-09-08:
- B5 —
check_quotaдважды считает собственный staged-файл (.upload-*уже лежит вfiles_root,dir_usageего находит,incomingприбавляется поверх). - B6 — проекция support-пакета копирует любые строковые ключи
fill_colors, а не только 11 ключей, которые читает карточка. - B7 — обход графа ссылок SVG (
href/url(#…)) рекурсивен, плоская цепь длиной ~2500 роняетRecursionError, наружу уходит 500 вместо кода отказа.
Ревью ТЗ проверяет: обязательные разделы §7.1 PROCESS.md, однозначность и доказуемость AC, отсутствие выданных за факт догадок, соответствие docs/SCOPE.md (не проверяю за отсутствием конфликта — задача чинит существующие защитные гейты upload/support/decor, все три поверхности внутри уже принятого функционала J4/J6, не расширяет продукт).
Как проверялось
Материал — только текст ТЗ и тело/комментарии issue; это стадия spec-review, гейты (typecheck/test/build) к ней не относятся (они гоняются на код-ревью) и не запускались. Вместо этого каждое утверждение ТЗ о текущем коде сверено чтением фактического дерева на 95493de2:
custom_components/houseplan/plans.py:201-239(dir_usage,check_quota) — арифметика двойного счёта подтверждена построчно, совпадает с описанием §1/§4.custom_components/houseplan/http_api.py:352-491(HouseplanUploadView.post) — подтверждено:tmp_pathсоздаётся вfiles_rootдо вызоваcheck_quota,check_quotaвызывается с телом временного файла какincomingбез исключения.tmp_pathдоступен в области видимости дляexclude=tmp_path, как предлагает ТЗ.custom_components/houseplan/http_api.py:230-263— подтверждено:except DecorAssetError— единственный перехват,RecursionError(подклассRuntimeError, неDecorAssetError) уйдёт как необработанное исключение → 500. Совпадает с §1 B7.custom_components/houseplan/decor_assets.py:200-289(_validate_svg,_visit) — подтверждена рекурсивная реализация цикл-детектора, семантикаvisiting/visited, регэкспыhref/url(), существующие лимитыMAX_SVG_ELEMENTS=5000,MAX_SVG_DEPTH=64(глубина XML-дерева, не графа ссылок — предлагаемыйMAX_SVG_REF_DEPTHдействительно независимая величина, как и написано в §11).custom_components/houseplan/support_package.py:96-152(_copy_keys,_global_settings,_project_value_badge) — подтверждено:fill_colorsкопируется по всем строковым ключам без allowlist; паттерн «пустое опускается» (out or None) действительно уже используется в_project_value_badge, так что ссылка на прецедент в §5 корректна.src/logic.ts:1412-1424(DEFAULT_FILL_COLORS) — сверил построчно с константойSUPPORT_FILL_COLOR_KEYSиз §5 ТЗ:light_on, light_off, light_none, temp_cold, temp_ok, temp_hot, lqi_low, lqi_high, glow_base, glow_light, wall_fill— 11 из 11, полное совпадение, порядок не важен. Предложенный тест «сверка сsrc/logic.ts» по образцу_ts_list(tests_backend/test_validation.py:909) — паттерн действительно существует и применим (хотяDEFAULT_FILL_COLORSобъектный литерал, а не массив, так что регэксп будет другим — это техническая деталь, не входит в компетенцию владельца).tests_backend/test_support_package.py:213-227— существующий тест действительно закрепляет пропуск произвольного ключа"warm", как написано в §1/§7.2.tests_backend/test_decor_assets.py:104,156— оба названных существующих теста (test_svg_rejects_the_whole_unsafe_document,test_svg_preserves_safe_local_gradient_clip_mask_and_transparency) существуют под указанными именами.- Другие вызовы
dir_usage/check_quota(tests_backend/test_validation.py:1530-1531,test_ha_websocket.py:3665,websocket_api.pyдляws_plan_upload) — единственные, все вызывают безexclude; добавлениеexcludeкак keyword-only с默认Noneне ломает ни один.Не-скоуппроws_plan_uploadкорректен (считаетlen(raw)до записи,dir_usageне вызывает).
Продуктовый код не менялся, мутантов и тестов ещё нет — это ожидаемо для стадии spec.
Находки
Medium (в скоупе задачи) — обязательный раздел «AC1…ACn» отсутствует, часть пунктов DoR не названа явно
PROCESS.md §7.1 перечисляет обязательные разделы ТЗ, включая «критерии приёмки AC1…ACn с указанием доказательства», и §2.5 (DoR) требует по каждому AC явно назвать способ доказательства (unit/backend/smoke/golden/«ревью кода»), а также «влияние на производительность и бюджеты названо (или явно "нет")» и «влияние на touch… (View и киоск — блокирующие)».
В документе docs/specs/498-*.md таких разделов нет: grep -n "AC[0-9]" по файлу не находит ни одного вхождения. Есть скоуп-пункты (§2.1-4), граничные условия (§4.2) и список тестов (§7.1-7.4), из которых AC можно собрать вручную, но они не сведены в пронумерованный список «AC → доказательство», как того требует DoR-чеклист — а именно по немуissue переводится в S5-ready. Аналогично нет ни одной явной строки о влиянии на производительность или touch (в этой задаче они действительно нулевые — чистый backend без UI, — но чеклист требует явного «нет», а не отсутствия упоминания).
Почему это не Low: без этого раздела формально нельзя перевести issue в S5-ready по букве DoR-чеклиста — не хватает одного из обязательных пунктов, а не стилистической мелочи.
Как чинится: свести существующее содержание §2/§4.2/§5/§6/§7 в явный список, например:
- AC1:
check_quotaне считает свой staged-файл дважды —backend,test_validation.py::test_issue_498_check_quota_excludes_the_staged_upload_itself. - AC2: endpoint принимает файл/файл-по-счёту ровно на границе квоты —
backend,test_ha_upload.py::test_issue_498_upload_accepts_the_last_bytes_and_the_last_file_of_the_quota. - AC3: параллельные загрузки по-прежнему учитывают друг друга —
backend,test_ha_upload.py::test_issue_498_concurrent_uploads_still_count_each_other. - AC4: support-пакет переносит только 11 ключей палитры продукта —
backend,test_support_package.py::test_rich_plan_projection_preserves_safe_structure_and_drops_unknown_values(обновлённый) +test_issue_498_palette_allowlist_matches_the_card_defaults. - AC5: пустая палитра не пишет ключ
fill_colors—backend,test_issue_498_projection_omits_an_empty_palette. - AC6: цепочка ссылок SVG обрабатывается итеративно и ограничена 64 без
RecursionError—backend,test_decor_assets.py::test_issue_498_flat_reference_chain_is_bounded_not_recursive+ endpoint-тест на 413. - AC7: цикл ссылок по-прежнему
invalid_image(регрессия) —backend, тот же тест. - Плюс строка «Производительность: нет влияния (чистая замена алгоритма на локальных данных, без изменения форматов)» и «Touch: не применимо (backend, без UI)».
Это техническая перестановка уже написанного текста, продуктовых вопросов не порождает — фиксится автором в этом же цикле.
Medium (в скоупе задачи) — предложенный алгоритм обхода графа ссылок (§6) не гарантирует заявленный предел глубины при недоброжелательном именовании id
§6 предписывает: «для каждого node_id in sorted(ids) — явный стек, множества visiting/visited… узел из visited пропускается». Узел, уже находящийся в visited, обрывает traversal без учёта его собственной глубины — это делает измеренную «глубину стека» зависимой от того, в каком порядке sorted(ids) начинает обходить компоненты, а не от истинной длины самого длинного пути в графе ссылок.
Конкретное воспроизведение (уменьшенный пример, лимит = 2 вместо 64, но конструкция линейно масштабируется на реальные значения 64/2500 из ТЗ):
Цепочка v0 → v1 → v2 → v3 (длина пути 4, что вдвое больше лимита 2), но id даны так, что sorted() посещает их в порядке v2, v0, v1, v3:
id("v2") = "a" id("v0") = "b" id("v1") = "c" id("v3") = "d"
ref_graph: a→d, b→c, c→a (то есть исходно v0→v1→v2→v3)
Обход sorted(ids) = ["a","b","c","d"]:
_visit("a"): стекa(depth1)→d(depth2, лимит не превышен, уdнет исходящих) → оба помеченыvisited. Максимальная зафиксированная глубина = 2._visit("b"): стекb(depth1)→c(depth2, лимит не превышен) → refc→a, ноaуже вvisited→ пропускается без учёта её собственной глубины 2 →b,cпомеченыvisited. Максимальная зафиксированная глубина = 2.
Ни разу условие «глубина стека > лимит» не сработало, хотя истинный путь b→c→a→d имеет длину 4 — вдвое больше лимита. При реальных значениях (лимит 64) достаточно нарезать любую сколь угодно длинную плоскую цепочку на сегменты по ≤64 узлов и присвоить id так, чтобы sorted() посещал сегменты от конца цепи к началу — весь граф пройдёт проверку too_large, невзирая на фактическую длину. Атрибут id подчиняется только формальному регэкспу (decor_assets.py:265), содержательных ограничений на него нет — конструирование такого имени полностью в руках того, кто формирует SVG.
Почему это не High. Вход writer-only (сам автор ТЗ это фиксирует в §1 и §8.2), обхода RecursionError/500 это не создаёт (переход на явный стек сам по себе полностью убирает исходный дефект B7 независимо от порядка обхода — падать нечему). Обойти можно только дополнительный лимит глубины цепочки, а обойти его может лишь тот же человек, кто сам загружает decor-ассет в свою инсталляцию — эксплуатировать через границу пользователей нечего. Тем не менее AC §1.2 буквально обещает «цепочка глубже 64 отклоняется», и предписанный алгоритм этого не гарантирует — а тест, построенный «естественно» (id вида g0, g1, …, g2499, как в примере ТЗ), никогда не вскроет проблему, потому что sorted(["g0","g1",…]) в лексикографическом порядке случайно совпадает с топологическим и стартует с настоящей головы цепи.
Как чинится (без продуктовых вопросов, чисто техническое решение): заменить проверку «глубина стека при обходе» на мемоизированную длину самого длинного пути от узла (depth[node] = 1 + max(depth[ref] for ref in children), вычисляется один раз при первом посещении и переиспользуется, а не просто булев visited) — стандартный «longest path in DAG» поверх уже имеющегося цикл-детектора visiting. Плюс добавить в §7.3 один тест с недоброжелательно упорядоченными id (как в примере выше, в масштабе 64/128), доказывающий, что предложенный тест test_issue_498_flat_reference_chain_is_bounded_not_recursive с «естественными» именами не покрывает этот случай сам по себе.
Low — «После» в §1.2 смешивает пользовательский язык с деталями реализации
AGENTS.md требует раздел «что человек увидит» одной фразой, без терминов реализации. §1.2 содержит «отклоняется кодом too_large (413)» и «cinvalid_image (400)» — это HTTP-статусы и внутренние коды ответа, а не то, что видит редактор на экране (карточка показывает переведённый тост, см. src/decor-image-editor.ts:154, too_large → backdrop.too_large_title). Не блокирует — по существу поведение не искажено, реального нового пользовательского текста ТЗ не придумывает (переиспользуется существующий код too_large и его существующий перевод), только стиль изложения. Оставляю на усмотрение автора без отдельного цикла.
Что проверено и корректно
- Все три описанных дефекта (B5/B6/B7) подтверждены построчным чтением кода на материале ревью — не переоценка аудита на устаревшем SHA.
- Константа
SUPPORT_FILL_COLOR_KEYS(§5) совпадает 1:1 сDEFAULT_FILL_COLORSвsrc/logic.ts— не догадка, а точное соответствие. - Не-скоуп корректен:
ws_plan_upload, численные лимиты, схемаfill_colors, автоочистка чужих.upload-*, перенос staging — все обоснованно исключены и не открывают продуктовых вопросов. - Совместимость сигнатур (
excludeкак keyword-only сNoneпо умолчанию) не ломает три существующих вызоваdir_usage/check_quota. - Персона и сценарий (§1.1) определены и совпадают с
docs/SCOPE.md(Home admin, редактор/владелец, поверхности upload/support/decor). - Продуктовых догадок, выданных за факт, не найдено — единственное найденное несоответствие (обход графа ссылок) относится к техническому решению автора, а не к продуктовой неопределённости, и разрешается вердиктом, а не владельцем.
- Откат (revert одного коммита), release-артефакты, риски — присутствуют и по существу верны.
- Задача внутри
docs/SCOPE.md: чинит существующие защитные гейты в рамках уже принятых поверхностей (upload/support/decor), не расширяет продукт — конфликта нет.
Чего не проверял
- Гейты
typecheck/test/build/pytestне запускал — стадия spec-review их не требует (продуктовый код ещё не написан), они относятся к код-ревью. - Не проверял
docs/CONFIG-COMPATIBILITY.mdиdocs/TOUCH-SUPPORT.mdпострочно — задача явно не трогает конфиг-схему и не имеет UI/touch-поверхности, что видно из диапазона затронутых файлов (только Python backend + его тесты); при появлении в дельте следующего раунда чего-либо, касающегося конфига или UI, потребуется отдельная сверка. - Не проверял производительность обхода (объём это дешёвая операция на ≤5000 узлах) — задача сама не заявляет влияния на перф-бюджеты, и после исправления Medium-находки выше это должно быть явно зафиксировано автором, а не мной.
- Не проверял корректность работы
asyncio.gather-теста для конкурентных загрузок (§7.1) на предмет детерминизма/флейкости — это вопрос дизайна теста, а не ТЗ, и вернётся на код-ревью, если тест окажется нестабильным.
Материал раунда
- Ветка:
issue/498-backend-hardening-quota-palette-svg-refs - SHA:
95493de2fcdbf4e27b91038de92ccebab42719ec - ТЗ:
docs/specs/498-backend-hardening-quota-palette-svg-refs.md(единственная редакция на момент ревью)
Вердикт
Жёлтый. High-находок нет, найдено 2 Medium в скоупе задачи (недостающий формальный раздел AC/DoR-пунктов; несостоятельный при недоброжелательном именовании id алгоритм ограничения глубины цепочки ссылок SVG) — обе чинятся автором в текущей задаче без нового issue. Заход r1, цикл израсходован (первое возвращение с блокирующими для перехода в S5-ready находками).
Материал раунда
- Ветка:
issue/498-backend-hardening-quota-palette-svg-refs, коммит95493de2fcdb— ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала:
7840be23823e5e1d1da310d66948225ddf7d4812git log --all --format='%H %T' | grep 7840be23823e - ТЗ
docs/specs/498-backend-hardening-quota-palette-svg-refs.md, блобa86281fe06197278ffeda08bfc271be8ff70b496git log --all --find-object=a86281fe06197278ffeda08bfc271be8ff70b496 -- docs/specs/498-backend-hardening-quota-palette-svg-refs.md - Вердикт конвейера:
yellow· High 0