Files
transcriber/openspec/changes/archive/2026-08-11-no-user-filename-in-log/design.md
T
av bd6001cd8d имя файла отправителя убрано из журнала приёма
- расширение приводится к перечню известных форматов прежде метки метрики:
  страница метрик открыта, и хвост имени уезжал на неё дословно
- проверки приёма перехватывают все три потока журнала и читают реестр метрик,
  каждая падает при снятии того, что сторожит
2026-08-11 16:37:58 +03:00

14 KiB

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 нечем. Имени, данного человеком, оттуда сегодня и не приходит.

Запрет держится проверкой, а не линтером → следующая журнальная строка с именем отправителя в другом месте кода проверкой не поймается. Смягчение в границах задачи: проверка стоит ровно на том шаге, через который проходит всякая принятая запись. Правило линтера — кандидат в отдельную задачу.