Files
houseplan-card/docs/reviews/CODE-REVIEW-225-r1.md
T
2026-08-20 22:00:22 +03:00

219 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Code review #225 — r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/225
- **Spec:** тело issue (лёгкий трек `small`), зелёное `SPEC-REVIEW-225-r2.md`
- **Reviewed branch:** `issue/225-import-attachment-url-query`
- **Reviewed range:** `origin/dev..HEAD` = `e59962f` (единственный продуктовый коммит)
- **Base:** `origin/dev` at `5f000cb`
- **Reviewer:** Claude, независимая сессия без контекста реализации
## Вердикт
**Жёлтый · цикл r1/2 · High: 0 · Medium: 2 → в задаче.**
Диагноз и контракт («парсить URL как URL», `urlsplit(url).path`, query/fragment
не участвуют в определении файла) реализованы и покрыты честными тестами:
семь параметризованных тестов на `_internal_path`/`_content_state` плюс
полный roundtrip-тест, все зелёные в CI на точном SHA (см. «Как проверялось»).
Я независимо воспроизвёл логику `_internal_path` вне HA-харнеса и подтвердил,
что каждый из новых тестов **умеет падать** на добеговом резолвере.
Но контракт реализован не полностью: `urlsplit(url).path` доверяет пути даже
тогда, когда у URL есть `scheme`/`netloc` — то есть строка вида
`https://evil.example/houseplan_files/files/m1/doc.pdf` после фикса резолвится
как **внутренний** файл, хотя `_looks_internal` (строковая проверка префикса,
не изменена) по‑прежнему говорит «не внутренний». Это то самое расхождение,
от которого защищает `_content_state` (docstring прямо называет угрозу:
«a crafted file could ... bypass the mandatory detach decision»), только в
обратную сторону от исходного бага. Ни один AC (в частности AC5) эту форму
входа не проверяет (M1).
Отдельно: ТЗ само выписало три записи мутационного гейта с готовыми
find/replace-патчами в формате `scripts/mutation-gate.mjs` — ровно так, как
это уже делалось для прежних бэкенд-фиксов в этом же файле (#167:
`plan-only-*`, тем же коммитом). В этой задаче патчи не легли в реестр —
проверка осталась разовой, только в тексте хендоффа (M2).
Оба High отсутствуют, обе находки в скоупе issue (правка того же файла и
того же гейта, который эта задача уже трогает) — по §2.7/§7.2 PROCESS.md
это жёлтый вердикт с возвратом автору, а не отдельный issue.
## Скоуп
Единственный продуктовый коммит `e59962f` (`Issue: #225`, `User-Visible: yes`,
трейлеры на месте):
- `custom_components/houseplan/import_export.py` (+10) — `_internal_path`
теперь режет `urlsplit(url).path` до сравнения сегментов; остальная логика
(префиксы `plans/_/`/`FILES_URL`, `sanitize_marker_id`/`sanitize_filename`,
требование ровно двух сегментов, посегментное сравнение) не тронута;
- `tests_backend/test_ha_import_export.py` (+128, 7 тестов) — AC1/AC1-plan,
AC4, AC4a, AC5, AC2 (обе ветки `same_source`), AC3 (полный roundtrip);
- `docs/CHANGELOG.md` + `docs/CHANGELOG.ru.md` (+6/+6) — пользовательская
формулировка совпадает с USER-GUIDE-стилем сообщения об ошибке, ссылка на
#225 есть в обоих.
Никаких изменений в `src/**`, i18n, миграции, конфиге совместимости — как и
заявлено в ТЗ. Ветка `issue/225-import-attachment-url-query` соответствует
правилу именования.
## Как проверялось
| Гейт | Результат |
|---|---|
| `npx tsc --noEmit` | pass |
| `npm test` | **962/962 pass** |
| `npm run build` + сверка трёх копий бандла | pass, `dist`/`custom_components/houseplan/frontend`/`demo/srv/assets` побайтово совпадают, `git status` после сборки чист |
| CI `Validate` на точном SHA `e59962f` (`gh run view 32403605914`) | `completed success`; job `backend` — `completed success`, лог: «Backend unit tests (pure + HA harness) — 335 passed in 3.78s» (полный HA-харнес, включая `test_ha_*.py`, не только «чистое» подмножество) |
| Независимая проверка «тест умеет падать» — извлёк `_internal_path` (плюс `sanitize_marker_id`/`sanitize_filename`, у функции нет собственной HA-зависимости, она приходит из модуля целиком) и прогнал старую/новую версию на всех кейсах AC1/AC1-plan/AC4/AC4a/AC5 вне HA-харнеса | новая версия проходит все кейсы AC1/AC1-plan/AC4/AC4a/AC5; **старая версия (`origin/dev`) красная** на 4/5 кейсов AC1, обоих кейсах AC1-plan-с-query и обоих кейсах AC4a — регрессия доказуема, а не заявлена на слово |
| `python -m pytest tests_backend -q` локально | не прогонял — окружение ревью на py3.12 без `homeassistant`, `test_ha_*.py` тем же механизмом молча пропускается (см. `AGENTS.md`); заменено CI-прогоном выше и независимой проверкой резолвера |
| browser `demo/smoke_*.mjs` (127 шт.) | не прогонял — задача не касается `src/**`, ни один AC их не называет |
| `npm run golden:verify` | не прогонял — визуала нет, бэкенд-only фикс |
| performance-профили | не прогонял — не названы в AC, путь не производительный |
## Проверка AC1–AC7
| AC | Метод по ТЗ | Статус | Как закрыт |
|---|---|---|---|
| AC1 | backend unit | ✅ | `test_issue_225_attachment_url_resolves_regardless_of_query` (5 кейсов) + `..._plan_url_resolves_regardless_of_query` (3 кейса); я пересчитал `urlsplit(url).path` для каждого кейса вручную и вне HA — совпадает |
| AC2 | backend unit/`_content_state` | ✅ | `test_issue_225_content_state_accepts_a_cache_busted_attachment`, обе ветки `same_source`; зелёный в CI на точном SHA (см. выше) |
| AC3 | backend HA-харнес, roundtrip | ✅ | `test_issue_225_backup_with_an_attachment_survives_a_full_round_trip`; зелёный в CI |
| AC4 | backend unit, traversal | ✅ | `test_issue_225_traversal_stays_closed_with_a_query` (4 кейса); подтверждено чтением — `sanitize_marker_id`/`sanitize_filename` и посегментное сравнение работают над `urlsplit(url).path`, то есть после отделения query, ровно как и до фикса |
| AC4a | backend unit | ✅ | `test_issue_225_hostile_looking_query_does_not_reject_a_valid_path` (2 кейса); я подтвердил разбором, что мусор в query/fragment действительно не участвует в резолвинге |
| AC5 | backend unit | ⚠️ формально ✅, но неполно | `test_issue_225_external_url_is_still_external` зелёный для `https://example.invalid/floor.svg?v=1` — путь этого URL (`/floor.svg`) не совпадает с внутренним префиксом. AC **не покрывает** случай, когда путь абсолютного/protocol‑relative внешнего URL *совпадает* с внутренним префиксом — см. M1: в этом случае `_internal_path` **резолвит его как внутренний**, хотя `_looks_internal` говорит «нет» |
| AC6 | regression, существующие тесты без правок | ✅ | diff — только добавления в конец файла (проверено `git show`), ни одна существующая строка теста не изменена; CI backend green |
| AC7 | plan-only idempotency (`:704`) | ✅ (чтением) | тесты `test_plan_only_export_projects_geometry_and_round_trips_room_labels` и соседние в диапазоне 489–736 не задеты диффом; `_internal_path` используется в plan-only пути так же, как раньше, отличие только в разборе query/fragment |
## Находки
### M1 (Medium, в скоупе) — `_internal_path` доверяет `path` даже при непустых `scheme`/`netloc`
`urlsplit(url).path` отбрасывает не только query/fragment, но и `scheme` с
`netloc`. Для строки с полной схемой или protocol‑relative префиксом это
означает, что путь резолвится как внутренний, даже когда URL целиком указывает
на другой хост.
Воспроизведено извлечением и прогоном литеральной логики `_internal_path` вне
HA (сравнение с `_looks_internal`, определённой в том же файле построчно):
```
url = "https://evil.example/houseplan_files/files/m1/doc.pdf"
_looks_internal(url) -> False (не меняется фиксом)
_internal_path(root, url) (НОВЫЙ) -> ('attachment', <root>/files/m1/doc.pdf)
_internal_path(root, url) (СТАРЫЙ, origin/dev) -> None
```
То же самое для protocol-relative `//evil.example/houseplan_files/files/m1/doc.pdf`
и для варианта с `?v=1`. `urlsplit` реально разбирает такие строки так, что
`netloc="evil.example"`, `path="/houseplan_files/files/m1/doc.pdf"` — я
проверил это отдельно интерпретатором, это не домысел.
Практический эффект ограничен (traversal по‑прежнему закрыт AC4 —
посегментные проверки не меняются): `_looks_internal` в `_content_state` не
кидает `ImportFailure`, потому что она сама смотрит на исходную строку и
по‑прежнему говорит «внешний». Но `internal is not None` (новое поведение)
уводит такую запись в ветку `available`/`detach_required` вместо `external`:
`content_manifest` на экспорте и `_content_state` на импорте начинают
описывать заведомо внешнюю ссылку так, будто она указывает на настоящий
локальный файл, включая проверку `is_file()` по пути, который в
действительности к этому URL не относится. Это ровно тот класс
несогласованности, который докстринг `_content_state` называет угрозой
(«a crafted file could ... bypass the mandatory detach decision»), только не
исходный баг issue, а новый, привнесённый самим фиксом.
Ни один AC (в частности AC5, единственный про «внешний» URL) не проверяет
такую форму — тест `https://example.invalid/floor.svg?v=1` не совпадает по
path с внутренним префиксом и потому не может поймать эту ветку.
**Фикс:** перед тем как доверять `parsed.path`, требовать
`not parsed.scheme and not parsed.netloc`, иначе — как и сегодня для любой
внешней ссылки — возвращать `None`. Пара строк плюс тест-кейс(ы) с `https://`
и `//`-префиксом на пути, совпадающем с внутренним namespace.
**Вердикт:** в скоупе issue (тот же файл, тот же контракт «разобрать URL как
URL»), чинится в этом же цикле.
### M2 (Medium, в скоупе) — заявленный мутационный гейт не зарегистрирован
ТЗ issue содержит раздел «Мутационный гейт» с тремя id и готовыми
find/replace-патчами (`internal-path-ignores-query`,
`internal-path-allows-traversal`, `roundtrip-import-with-attachment`) —
буквально в формате записей `scripts/mutation-gate.mjs`. Это не абстрактная
формулировка риска: патчи прямо адресуют строки `import_export.py`.
Прецедент в этом же файле — issue #167 (`feat: add plan-only space export`,
коммит `7f397a6`): пять аналогичных backend-мутантов
(`plan-only-room-area-restored` и соседние, гвард
`node scripts/backend-test-guard.mjs <pattern>`) были добавлены в реестр
**тем же коммитом**, что и сама правка. `scripts/backend-test-guard.mjs`
уже поддерживает произвольный `-k`-паттерн по `test_ha_import_export.py`, то
есть механизм для #225 был готов без доработок — `node
scripts/backend-test-guard.mjs issue_225_attachment_url_resolves` и подобные
сразу работали бы как guard.
В `e59962f` `scripts/mutation-gate.mjs` не менялся (проверено `git show
e59962f --stat` и `git log --follow -- scripts/mutation-gate.mjs`). Хендофф
описывает, что автор вручную прогонял мутации через отдельные патч-версии
файла и даже сделал полезное наблюдение (первая редакция
`internal-path-allows-traversal` оказалась «эквивалентной» — traversal
защищён двумя независимыми проверками одновременно) — но эта работа нигде не
осела постоянной проверкой. Без записи в реестре периодический
предрелизный `mutation-gate` (`.github/workflows/mutation-gate.yml`) никогда
не перепроверит, что будущий рефакторинг `_internal_path` не вернёт баг
#225 бесшумно — то есть именно тот сценарий, ради которого механизм
существует (см. комментарий в начале `scripts/mutation-gate.mjs`: «зелёный
тест в этом проекте несколько раз означал ничего не проверено»).
**Вердикт:** в скоупе issue (`scripts/mutation-gate.mjs` — класс B, «может
использовать issue того изменения, которое покрывает»), чинится добавлением
двух работающих записей в этом же цикле (третью, `internal-path-allows-
traversal`, — с патчем, который автор уже подобрал как небезрезультатный,
согласно находке из хендоффа).
## Что проверено и корректно
- Диагноз бага (несовпадение `_looks_internal`/`_internal_path` из-за
`sanitize_filename` над сырым хвостом с `?v=...`) и контракт исправления
(`urlsplit`, query/fragment не участвуют в определении файла) — совпадают
с фактическим кодом `_internal_path` до и после правки.
- Данные пользователя не переписываются: URL в конфиге сохраняется как есть
(проверено чтением — фикс меняет только внутреннюю логику резолвинга,
возвращаемое значение из `create_export`/`_content_state` не трогает
`item["url"]`; тест `test_issue_225_content_state_accepts_a_cache_busted_attachment`
отдельно утверждает `rows[0]["url"] == url`).
- `identity()` (`:1034-1039`) не включает `storage`/`state` в ключ сравнения
— смена классификации `external → internal` для канонических cache-busted
ссылок (сама цель фикса) не ломает сопоставление manifest-строк; тем же
свойством объясняется, почему M1 не валит существующие проверки
идентичности, только их семантику для одной специфичной формы входа.
- Traversal-защита (AC4) не ослаблена: `sanitize_marker_id`/
`sanitize_filename` и требование `len(tail) == 2` работают над результатом
`urlsplit(url).path`, то есть над тем же материалом, что и раньше для
URL без scheme/netloc — я прогнал старую и новую версию резолвера на всех
четырёх кейсах AC4 и получил идентичный `None` в обеих.
- AC6/AC7 не нарушены: diff — чистое добавление в конец файла тестов, ни
одна существующая строка не тронута; plan-only regression-диапазон вне
диффа.
- Trailers `Issue: #225`/`User-Visible: yes`, оба changelog в одном коммите,
ветка `issue/225-import-attachment-url-query` — по правилам.
- Три копии бандла побайтово совпадают после локальной пересборки — класс D
не разошёлся, хотя эта задача его не трогает (бэкенд-only).
## Чего не проверял
- `python -m pytest tests_backend -q` в собственном окружении — ревью
выполняется на py3.12 без установленного `homeassistant`; `conftest.py`
тем же механизмом, что и у автора, молча пропустил бы `test_ha_*.py`, то
есть локальный зелёный прогон здесь ничего не доказывает (см. `AGENTS.md`,
раздел «Backend»). Использован CI `Validate` на точном SHA (`backend`:
335 passed, полный HA-харнес) плюс независимое исполнение чистой логики
`_internal_path` вне HA — сильнее, чем «поверил хендоффу».
- Полный набор из 127 browser-smoke и `performance_smoke` — задача не
касается `src/**`, ни один AC их не называет, поверхность чисто бэкендовая.
- `npm run golden:verify` — визуальных изменений нет.
- Мутационный гейт (`node scripts/mutation-gate.mjs`, дорогой прогон с
пересборкой бандла в отдельном worktree) целиком не запускал — сам факт
отсутствия трёх заявленных записей в реестре зафиксирован находкой M2 без
необходимости гонять существующие 40+ мутантов, которых этот диф не
касается.