20 KiB
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/devat5f000cb - 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»). Использован CIValidateна точном 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+ мутантов, которых этот диф не касается.