имя файла отправителя убрано из журнала приёма
- расширение приводится к перечню известных форматов прежде метки метрики: страница метрик открыта, и хвост имени уезжал на неё дословно - проверки приёма перехватывают все три потока журнала и читают реестр метрик, каждая падает при снятии того, что сторожит
This commit is contained in:
@@ -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 нечем. Имени, данного человеком, оттуда сегодня и не приходит.
|
||||
|
||||
**Запрет держится проверкой, а не линтером** → следующая журнальная строка с
|
||||
именем отправителя в другом месте кода проверкой не поймается. Смягчение в
|
||||
границах задачи: проверка стоит ровно на том шаге, через который проходит всякая
|
||||
принятая запись. Правило линтера — кандидат в отдельную задачу.
|
||||
Reference in New Issue
Block a user