Issue: #225 User-Visible: no
20 KiB
SPEC-REVIEW — issue #225, цикл r1
- Issue: https://github.com/Matysh/houseplan-card/issues/225
- ТЗ: тело issue #225 (лёгкий трек
small, файл вdocs/specs/не создаётся согласно §5 PROCESS.md) - Трек:
small— сложность 2/10, одна поверхность (custom_components/houseplan/import_export.py), лимит циклов ревью ТЗ — 2 - Ревьюер: Claude (роль «Ревьюер ТЗ», отдельная сессия от автора)
- Вердикт: жёлтый · цикл r1/2 · High: 0 · Medium: 1 → в задаче
Скоуп проверки
Issue #225 — баг: собственный экспорт House Plan (PDF-вложения с
кэш-бастером ?v=<timestamp> в URL) не проходит собственный импорт,
падая на invalid_content. ТЗ в теле issue (лёгкий трек) описывает фикс
_internal_path (import_export.py:350): разбирать URL через
urlsplit, игнорируя query/fragment при резолвинге пути к файлу.
Проверялись:
- критерии лёгкого трека (§5 PROCESS.md) — действительно ли применимы;
- соответствие описания бага фактическому состоянию кода
(
_internal_path,_looks_internal,_content_state,content_manifest,sanitize_filename,_SAFE_NAME_RE); - однозначность и доказуемость каждого AC1…AC7, включая перечисленные в AC4 конкретные traversal-полезные нагрузки;
- отсутствие догадок, выданных за факт, без пометки «предположение»;
- корректность решения не эскалировать вопрос об error-message владельцу (уже решено на этапе аналитики тем же лицом).
Это первый цикл, раздел «объём по дельте» (§2.10 PROCESS.md) не применяется — разбор выполнен полностью.
Как проверялось
- Прочитаны
docs/SCOPE.md(J6 «Keep the plan true as the home evolves» — перенос/восстановление конфигурации),AGENTS.md,PROCESS.mdцеликом (§2.3–2.4, §5, §7.1, §7.2, §12). - Прочитано тело issue #225 полностью и оба комментария (аналитика с
оценками и меткой
small, публикация ТЗ в теле issue). Метка на момент ревью —S4-spec-review. - Прочитан
custom_components/houseplan/import_export.py:_internal_path(350-370) — подтверждено: веткаfiles/режетurl[len(prefix):]по/без отделения query,tailвключает?v=...как часть последнего сегмента;_looks_internal(373-377) — подтверждено: смотрит только на префикс, не видит query;content_manifest(380-405) — подтверждено:storage: "internal" if internal else "external", ровно как описано в ТЗ;_content_state(1021-1071) — подтверждено: строка 1060if _looks_internal(item["url"]) and internal is None: raise ImportFailure("invalid_content", ...)— воспроизводит механизм отказа дословно;identity()(1034-1039) не включаетstorageв ключ сравнения — подтверждает риск ТЗ про сохранение совместимости по AC6;_validate_plan_only_document(685-712), в частности строка 704 (placement_manifest) — подтверждено, что классификацияstorageне участвует в её проверках, толькоowner == "space"— AC7 обоснован.
- Прочитан
custom_components/houseplan/validation.py:_SAFE_NAME_RE = [^A-Za-z0-9._-]+,sanitize_filename,sanitize_marker_id— подтверждено, что?/=заменяются на_, из-за чего сравнениеname == tail[-1]не проходит на суффиксе?v=.... - Прочитан
custom_components/houseplan/http_api.py:290-316— подтверждено, что текущая ручка загрузки возвращает URL без query (f"{CONTENT_URL}/files/{marker_id}/{name}"), то есть суффикс — исторические данные, а не то, что генерирует текущий код. - Прочитан
src/logic.ts:1841-1861(migratePdfUrls) — подтверждено, что фронт уже явно отделяетqueryотnameпри переносе вложений; довод ТЗ «контракт на фронте уже есть, бэкенд не в курсе» подтверждён чтением, а не принят на слово. - Проверено существование прецедента для AC2/AC3 —
tests_backend/test_ha_import_export.py:1439-1456(test_apply_rechecks_attachment_under_the_write_lock) использует канонический URL без query; ТЗ верно называет его образцом для новой фикстуры. - Проверено существование теста на внешний URL для AC5 —
tests_backend/test_ha_import_export.py:366,459используетhttps://example.invalid/floor.svg— существующий тест, изменения не требует. - Пройдена по шагам вычислением (без исполнения кода) каждая из четырёх
строк AC4 против описанного контракта (
urlsplit(url).path, query и fragment исключены из резолвинга) — см. находку M1 ниже. - Гейты кода не запускались: предмет ревью — ТЗ, реализации ещё нет
(
S4-spec-review), код не менялся.
Находки
Medium — в скоупе задачи
M1. Третий пример в AC4 требует поведения, которое прямо противоречит описанному в этом же ТЗ контракту резолвинга — критерий недоказуем без нарушения собственного контракта.
AC4 перечисляет четыре traversal-нагрузки и требует, чтобы все четыре
резолвились в None:
…/files/../../secret.pdf?v=1,…/files/m1/../../secret.pdf,…/files/m1/doc.pdf?x=/../../etc,…/plans/_/../x.svg?v=1
Контракт поведения этого же ТЗ формулирует фикс так: «путь для
резолвинга берётся из urlsplit(url).path, query и fragment в
определении файла не участвуют».
Проверка построчно:
- пример 1 (
…/files/../../secret.pdf?v=1):pathпосле отсечения query =.../files/../../secret.pdf; хвост после префикса —../../secret.pdf, при разбиении по/даёт 3 сегмента → уже сейчас, до всякого фикса, возвращаетNone(проверкаlen(tail) != 2,import_export.py:364). Легитимный regression-тест, поведение верно предсказано. - пример 2 (
…/files/m1/../../secret.pdf): хвостm1/../../secret.pdf→ 4 сегмента →None. Тоже легитимно и уже работает без фикса. - пример 4 (
…/plans/_/../x.svg?v=1):pathбез query =.../plans/_/../x.svg;raw_nameпосле префикса —../x.svg, содержит/→ веткаplans(import_export.py:355) уже сегодня возвращаетNoneпо проверке"/" in raw_name. Легитимно. - пример 3 (
…/files/m1/doc.pdf?x=/../../etc): по заявленному контрактуqueryне участвует в резолвинге пути.urlsplit(url).pathдля этой строки —.../files/m1/doc.pdf, то есть чистый, без единой точки... Хвост после префикса —m1/doc.pdf, ровно 2 сегмента,marker="m1",name="doc.pdf"— оба проходятsanitize_*без изменений. Корректная по контракту реализация обязана вернуть("attachment", root/FILES_DIR/"m1"/"doc.pdf"), а неNone.
Иначе говоря: содержимое query в этом примере физически не может
повлиять на путь к файлу — «../../etc» лежит внутри значения
параметра x, а не в пути. Требование «резолвится в None» либо
недостижимо без дополнительной, нигде в ТЗ не описанной проверки
(«отклонять .. при обнаружении где угодно в исходной строке URL, а не
только в пути») — тогда это новый пункт контракта, а не следствие уже
заявленного; либо AC написан по ошибке и пример перепутан местами:
скорее всего он должен был доказывать обратное — что «мусорный»
traversal-паттерн в query безопасен именно потому, что резолвинг его
игнорирует, и годился бы как положительный кейс в AC1, а не как
негативный в AC4.
Почему это Medium, а не Low. Это не стилистическая неточность:
разработчик, который напишет тест по этому пункту буквально, либо
получит красный тест на корректной реализации (совпадающей с
контрактом ТЗ), либо втихую ослабит заявленный контракт («query не
участвует»), добавив непрописанную проверку — оба исхода нежелательны,
и оба возникают из-за противоречия внутри одного документа. Именно
такую непроверяемость обязано ловить ревью ТЗ (§7.1 PROCESS.md:
«Ambiguity is asked, not guessed»).
Фикс, ожидаемый в этом же цикле: одно из двух — либо убрать третий
пример из AC4 и перенести его (в виде «резолвится корректно, query с
../../ внутри не создаёт traversal и не бьёт по AC1») в AC1 как ещё
один параметр, подтверждающий, что мусор в query безопасен именно
потому что игнорируется; либо, если автор действительно имел в виду
доп. defense-in-depth проверку по всей исходной строке — явно дописать
её в «Контракт поведения» и объяснить, зачем она нужна при том, что
query и так не участвует в резолвинге. Само по себе это правка на
две строки текста ТЗ, не код.
Low
Не найдено находок, которые стоило бы фиксировать отдельно и не чинить. (См. отдельно нижеследующее наблюдение по AC1 — оно снимается ревьюером с записью, не заводится как Low-находка.)
Что проверено и корректно
- Обязательные для лёгкого трека элементы шаблона (§5 PROCESS.md)
присутствуют и по существу: проблема (с воспроизведением и точными
номерами строк), контракт поведения, AC1…AC7 каждый с указанием
доказательства (
backend), план автотестов, риски, откат. ТЗ также добавляет сверх минимума сценарий/персону, «что человек увидит», «не в скоупе», модель данных/миграция/i18n и блок явных технических предположений — то есть избыточно полно для лёгкого трека, а не урезано. - Критерии лёгкого трека (§5) выполнены одновременно: сложность 2/10,
одна поверхность (
import_export.py), нет миграции конфига и compatibility-полей, нет нового UX-контракта (импорт собственного бэкапа — уже описанное поведение, меняется только исход), нет влияния на perf/touch. Трек выбран верно. - Диагноз бага воспроизведён чтением кода, а не принят на слово автора
— см. «Как проверялось»:
_internal_path/_looks_internalреально расходятся именно так, как описано,_SAFE_NAME_REреально ломает сравнение на?/=. - AC1, AC2, AC5, AC6, AC7 однозначны и доказуемы: у каждого понятный
вход/выход и существующая или явно описанная новая точка проверки.
AC3 корректно указывает на существующий прецедент фикстуры
(
test_apply_rechecks_attachment_under_the_write_lock) как образец, без которого регрессия дожила до боевого конфига. - Риск «резолвер стоит на пути защиты от traversal» назван явно и вынесен в отдельный AC (AC4), а не спрятан в примечание — правильная структура даже с учётом находки M1 (ошибка в одном из четырёх примеров, не в самом принципе выделения traversal в отдельный критерий).
- Риск смены классификации
content_manifest(external→internal) проверен по коду:identity()в_content_state(:1034-1039) действительно не используетstorageв ключе сравнения — совместимость со старыми экспортами реально сохраняется, а не заявлена без проверки. - Решение не выносить текст ошибки
invalid_contentпользователю в скоуп — уже принято владельцем на этапе аналитики (тот же аккаунт, комментарий S2-analysis) как сужение с обоснованием «вторая поверхность, потеря лёгкого трека»; ревью с этим решением не спорит, так как это ровно тот класс продуктового вопроса, который решает владелец, а не автор ТЗ задним числом. - Блок «Принято предположительно» — реальные технические допущения
(выбор
urlsplit, отказ от нормализации URL в сторону сохранения как есть, расположение юнитов вtest_validation.py), не подмена продуктового решения; продуктовое решение «бэкап должен импортироваться, вложения ведут себя как раньше» дано самим текстом issue. - Открытых продуктовых вопросов владельцу нет и не должно быть:
ожидаемое поведение (импорт проходит,
detach_requiredна чужом инстансе штатно) уже зафиксировано существующим механизмом импорта, решать в ТЗ было нечего кроме технического разбора URL. - Откат осмыслен и минимален: одна функция, откат — возврат прежнего тела, данные не мигрируют.
- Release-артефакты корректно требуют
User-Visible: yesи оба changelog — пользователь видит другой исход операции импорта, это не внутренняя правка.
Наблюдение по AC1 (снято ревьюером, не Low-находка)
AC1 явно тестирует резолвинг только для ветки вложений (files/... с
marker_id+name); ветка планов (plans/_/...) с query отдельным
кейсом не покрыта, хотя это та же функция _internal_path и правка
(отделение query через urlsplit в начале функции) по построению
одинаково затрагивает обе ветки. Не поднимаю до Low: реальных
источников query у plan_url в кодовой базе не найдено (ни
contentUrl() в src/logic.ts, ни ручка загрузки не добавляют его), и
баг-репорт исходно и единственно про PDF-вложения. Разработчик волен
добавить такой кейс без правки ТЗ — тестовое покрытие, а не контракт.
Чего не проверял
- Гейты кода (
npx tsc --noEmit,npm test,npm run build,python -m pytest tests_backend -q) не запускались: на этапе ревью ТЗ реализации ещё нет, продуктовый код не менялся (только тело issue — не файл в репозитории). - Полный HA-харнесс и реальный сценарий экспорт→импорт с настоящим PDF не прогонялся — на этом этапе прогонять нечего, это предмет AC3 на код-ревью.
- Не проверялась историческая полнота предыдущих ревью по #50 и #167 (упомянуты автором как соседние, не дубликаты) на предмет их собственных незакрытых находок — вне предмета этого ревью.
- Percent-encoding в именах файлов и нормализация существующих конфигов — намеренно вне скоупа этого issue (см. «Не в скоупе» ТЗ), не проверялись как отдельная тема.
Раздел «Унаследовано» — не применяется
Это первый цикл ревью ТЗ (r1); раздел «Унаследовано из r» и таблица закрытия предыдущего раунда не ведутся (§2.10 PROCESS.md действует со второго цикла).