- расширение приводится к перечню известных форматов прежде метки метрики: страница метрик открыта, и хвост имени уезжал на неё дословно - проверки приёма перехватывают все три потока журнала и читают реестр метрик, каждая падает при снятии того, что сторожит
147 lines
14 KiB
Markdown
147 lines
14 KiB
Markdown
## Context
|
||
|
||
Общий шаг заведения задачи пишет журнальную строку о принятой записи и кладёт в
|
||
неё три поля: идентификатор файла, имя, данное отправителем, и путь, по которому
|
||
запись легла на диск. Уровень строки — `INFO`, то есть в боевой настройке она
|
||
пишется всегда. Через этот шаг проходит и запись из Telegram, и запись по HTTP,
|
||
поэтому строка общая для обоих входов.
|
||
|
||
**Имя, данное отправителем, доходит до этой строки только по HTTP.** Приём по
|
||
HTTP отдаёт в сервис имя из формы запроса; приём из Telegram отдаёт путь,
|
||
выданный самим Telegram (`voice/file_5.oga`), а настоящее имя документа дальше
|
||
проверки типа файла не идёт. Утечка сегодня одна, и она на входе по HTTP; правка
|
||
всё равно делается на общем шаге, чтобы второй вход не мог её обойти.
|
||
|
||
`docs/conventions/logging.md` запрещает имена файлов пользователя прямо, и это же
|
||
объявлено критическим инвариантом. Ни линтером, ни проверкой запрет сегодня не
|
||
выражен: `sloglint` в наборе не включён, а проверки приёма журнал выбрасывают.
|
||
|
||
## Goals / Non-Goals
|
||
|
||
**Goals:**
|
||
|
||
- Убрать имя отправителя из журнала приёма — на общем шаге, то есть для обоих
|
||
входов разом.
|
||
- Оставить прослеживаемость: по журналу по-прежнему видно, какой файл заведён,
|
||
с каким расширением и какого размера запись.
|
||
- Сделать запрет проверяемым на обоих путях приёма — успешном и отказном, — и
|
||
проверяемым **по значению**, а не по имени журнального поля.
|
||
|
||
**Non-Goals:**
|
||
|
||
- Сверка остальных журнальных строк проекта с запретами. Прочие строки здесь не
|
||
пересматриваются — это отдельная работа.
|
||
- Нормализация расширения **в имени файла на диске**. Раскладка каталога записей
|
||
объявлена необратимой, и меняется она решением человека. Расширение там
|
||
остаётся пришедшим, а остаток записан строкой в `docs/security.md`. Метки
|
||
метрик под этот отказ не подпадают — про них решение ниже.
|
||
- Выражение запрета линтером (`sloglint`, `forbidigo`). Набор линтеров задача не
|
||
меняет.
|
||
- Перевод `log.Printf` в HTTP-транспорте на общий логгер. Расхождение записано в
|
||
конвенции журналирования и живёт своей жизнью; проверка его поток **видит**,
|
||
но чинить его здесь не будем.
|
||
|
||
## Decisions
|
||
|
||
**Поле с именем убирается, а не заменяется производной от имени.**
|
||
Рассматривались два способа сохранить корреляцию по имени: хеш имени и его
|
||
длина. Хеш — та же приватная величина в другой записи: по нему имя восстанавливают
|
||
перебором, а при повторной отправке одной записи он ещё и связывает отправки
|
||
между собой. Длина имени не отвечает ни на один вопрос разбора. Отказано обоим:
|
||
корреляцию в проекте держат идентификаторы сущностей, а не имена, и это уже
|
||
записано конвенцией журналирования.
|
||
|
||
**Расширение остаётся тем же полем, что и сейчас, — путём файла в хранилище.**
|
||
Путь несёт собственное имя файла: идентификатор плюс расширение. Отдельное поле
|
||
под расширение завело бы второй дом одному факту, и два поля начали бы
|
||
расходиться на первой же правке выбора расширения. Отвергнуто.
|
||
|
||
**Размер принятой записи в журнале уже есть** — соседняя строка об успешно
|
||
загруженной записи несёт идентификатор файла и размер в байтах. Заводить её
|
||
заново не требуется; требование о прослеживаемости она закрывает как есть.
|
||
Величина названа байтами во всех трёх местах — в требовании, здесь и в критериях
|
||
приёмки: рядом в той же строке лежит длительность, и «длина» читалась бы как она.
|
||
|
||
**Оракул — перехваченный журнал в проверке приёма по HTTP, и он видит все три
|
||
пишущих потока.** Окружение проверки сегодня отдаёт журнал в никуда, чтобы
|
||
строки отказа не путались с признаком красного гейта. Вместо «в никуда» журнал
|
||
уходит в буфер, и в тот же буфер сводятся: структурный логгер сервиса,
|
||
стандартный `log`, через который HTTP-транспорт пишет мимо `slog`, и middleware
|
||
запроса — ради него тестовый роутер собирается той же цепочкой, что и боевой.
|
||
Иначе квантор требования («ни одна журнальная запись приёма») был бы шире
|
||
оракула, и утечка через непокрытый поток оставила бы проверку зелёной.
|
||
|
||
**Перехват стандартного `log` удерживается положительным утверждением.** Проверка
|
||
отказного пути сперва требует, чтобы строка транспорта в буфере **была**, и лишь
|
||
затем — чтобы имени в нём не было. Без этого снятие перехвата не уронило бы
|
||
ничего, а оракул сузился бы вдвое молча. Перехват при этом процессный, поэтому
|
||
`t.Parallel()` в этом файле запрещён — сказано строкой рядом с кодом.
|
||
|
||
**Буфер свой на каждый случай, и утверждение отбирается по идентификатору этого
|
||
прогона.** Общий на пакет буфер сделал бы исход проверки функцией от соседних
|
||
случаев: «поля на месте» прошло бы на чужой строке, а «маркера нет» ложно
|
||
покраснело бы от чужой. Плюс параллельные случаи писали бы в один
|
||
`bytes.Buffer` — гонка на проверке критического инварианта, то есть флаки, а
|
||
флаки на таком месте снимают целиком.
|
||
|
||
**Проверка судит по значению маркера, а не по имени поля.** Маркер — уникальная
|
||
ASCII-строка, которой нет в остальном выводе. Поиск по имени поля (`file_name`)
|
||
не поймал бы возвращённое имя под другим ключом, а прецедент такого класса в
|
||
проекте уже записан: проверка приёма от 2026-08-11 была зелёной и не ловила
|
||
ничего. Отсюда же обязательный шаг приёмки — **мутация**: вернуть имя в журнал
|
||
под другим ключом, убедиться, что проверка краснеет.
|
||
|
||
**Отказной путь проверяется наравне с успешным.** Приём назван конвенцией
|
||
журналирования логирующей границей: ошибка попадает в журнал полем `error`
|
||
вместе со всей цепочкой `%w`. Обёртка вида `fmt.Errorf("сохранение %s: %w", …)`
|
||
где угодно ниже отдаст имя именно там, и проверять только успех значит не
|
||
проверять этот путь вовсе.
|
||
|
||
**Косвенные носители имени названы поимённо.** Текст ошибки — закрыт вторым
|
||
сценарием. Путь на диске строится из идентификатора и расширения; ключ объекта в
|
||
Object Storage берётся из имени **после конвертации**, то есть всегда
|
||
`идентификатор.ogg`, — наружу к внешнему сервису хвост не уезжает. Колонка
|
||
`error_text` журналом не является и этой задачей не трогается.
|
||
|
||
**Третий носитель хвоста — метка метрики, и он закрыт приведением.** Это решение
|
||
контрольной точки, принятое **после ревью кода**: проход построил путь целиком —
|
||
запись с именем `запись.тайное-слово` кладёт хвост меткой, а страница метрик
|
||
отдаётся без проверки отправителя, то есть читает её кто угодно, и то же
|
||
значение оседает в хранилище метрик. Канал оказался шире того, который задача
|
||
закрывала.
|
||
|
||
Из трёх способов человек выбрал средний. Отвергнуты: оставить как есть и завести
|
||
задачу — канал жил бы до неё, а закрытие этой задачи читалось бы как «починено»;
|
||
приводить расширение везде, включая имя файла на диске, — раскладка каталога
|
||
записей объявлена необратимой и меняется решением человека, а не по ходу
|
||
починки журнала. Принято: приводить **только значение метки** — расширение из
|
||
закрытого перечня идёт приведённым к нижнему регистру, всё прочее становится
|
||
одним общим значением. Имя на диске не трогается вовсе. Тем же ограничением
|
||
снимается рост числа временных рядов, которым иначе распоряжается анонимный
|
||
отправитель.
|
||
|
||
**Приведение проверяется по реестру метрик, а не по самой функции.** Проверка
|
||
функции в отрыве от точек употребления зелёная и при снятом приведении: правка
|
||
места вызова вернула бы хвост наружу молча. Поэтому проверка гонит приём с
|
||
незнакомым расширением и читает реестр: хвоста в метках нет, общее значение
|
||
есть, а на диске расширение осталось пришедшим.
|
||
|
||
## Risks / Trade-offs
|
||
|
||
**Расширение приходит из имени отправителя, и в журнал оно попадает как есть** →
|
||
имя вида `запись.тайное-слово` отдаёт `тайное-слово` расширением, и оно уедет в
|
||
журнал вместе с путём. Смягчение: остаток записывается строкой в
|
||
`docs/security.md`, чтобы закрытие задачи не читалось как «канал закрыт
|
||
целиком». Нормализация расширения остаётся отдельной работой со своим
|
||
требованием.
|
||
|
||
**Проверкой накрыт только вход по HTTP** → приём из Telegram делит с ним общий
|
||
шаг, и правка достаётся ему той же строкой кода, но своей проверки у него нет.
|
||
Смягчение: правка делается на общем шаге, а не в транспорте, — обойти её со
|
||
стороны Telegram нечем. Имени, данного человеком, оттуда сегодня и не приходит.
|
||
|
||
**Запрет держится проверкой, а не линтером** → следующая журнальная строка с
|
||
именем отправителя в другом месте кода проверкой не поймается. Смягчение в
|
||
границах задачи: проверка стоит ровно на том шаге, через который проходит всякая
|
||
принятая запись. Правило линтера — кандидат в отдельную задачу.
|