имя файла отправителя убрано из журнала приёма

- расширение приводится к перечню известных форматов прежде метки метрики:
  страница метрик открыта, и хвост имени уезжал на неё дословно
- проверки приёма перехватывают все три потока журнала и читают реестр метрик,
  каждая падает при снятии того, что сторожит
This commit is contained in:
av
2026-08-11 16:37:58 +03:00
parent ffa36e96c7
commit bd6001cd8d
18 changed files with 1797 additions and 20 deletions
@@ -0,0 +1,146 @@
## 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 нечем. Имени, данного человеком, оттуда сегодня и не приходит.
**Запрет держится проверкой, а не линтером** → следующая журнальная строка с
именем отправителя в другом месте кода проверкой не поймается. Смягчение в
границах задачи: проверка стоит ровно на том шаге, через который проходит всякая
принятая запись. Правило линтера — кандидат в отдельную задачу.