имя файла отправителя убрано из журнала приёма
- расширение приводится к перечню известных форматов прежде метки метрики: страница метрик открыта, и хвост имени уезжал на неё дословно - проверки приёма перехватывают все три потока журнала и читают реестр метрик, каждая падает при снятии того, что сторожит
This commit is contained in:
@@ -0,0 +1,45 @@
|
|||||||
|
# ADR-2026-08-11. Наружу расширение выходит только приведённым к перечню
|
||||||
|
|
||||||
|
- **Дата:** 2026-08-11
|
||||||
|
- **Источник:** [openspec/changes/archive/2026-08-11-no-user-filename-in-log/design.md](../../openspec/changes/archive/2026-08-11-no-user-filename-in-log/design.md), раздел `Decisions`
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Расширение принятой записи приводится к закрытому перечню известных форматов
|
||||||
|
прежде, чем уйти меткой метрики; всё, чего в перечне нет, заменяется одним общим
|
||||||
|
значением. Имя файла на диске при этом не трогается — там расширение остаётся
|
||||||
|
тем, каким пришло.
|
||||||
|
|
||||||
|
Дословно из источника:
|
||||||
|
|
||||||
|
> Из трёх способов человек выбрал средний. Отвергнуты: оставить как есть и завести
|
||||||
|
> задачу — канал жил бы до неё, а закрытие этой задачи читалось бы как
|
||||||
|
> «починено»; приводить расширение везде, включая имя файла на диске, —
|
||||||
|
> раскладка каталога записей объявлена необратимой и меняется решением человека,
|
||||||
|
> а не по ходу починки журнала.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Расширение берётся из имени, которое дал отправитель, дословно: имя
|
||||||
|
`запись.тайное-слово` отдаёт `тайное-слово`, а `Разговор с Петровым 11.08` —
|
||||||
|
`08`. Оно уходило меткой метрики, а страница метрик отдаётся без проверки
|
||||||
|
отправителя. Канал оказался шире того, ради которого задача заводилась: журнал
|
||||||
|
читает владелец сервиса, метки — кто угодно, и то же значение оседает в
|
||||||
|
хранилище метрик. Тем же каналом множество значений метки становится
|
||||||
|
неограниченным: их задаёт анонимный отправитель.
|
||||||
|
|
||||||
|
Отказ от нормализации на диске — не экономия, а граница обратимости: формат
|
||||||
|
имени файла и раскладка `data/files` объявлены необратимыми, и меняются они
|
||||||
|
решением человека под свою задачу, а не попутно с починкой журнала.
|
||||||
|
|
||||||
|
## Цена
|
||||||
|
|
||||||
|
- Перечень форматов стал нормой и требует ведения: формат, который сервис
|
||||||
|
начнёт принимать, до внесения в перечень будет виден в метрике как общее
|
||||||
|
значение, неотличимо от чужого хвоста.
|
||||||
|
- Форма метки размера принятой записи изменилась — ведущая точка пропала
|
||||||
|
(`.mp3` стало `mp3`). Ряды, собранные до выкладки, перестают пополняться.
|
||||||
|
- Настоящий формат записи, попавшей в общее значение, остаётся видимым только в
|
||||||
|
журнале — по полю пути строки приёма и полю формата строки конвертации.
|
||||||
|
- Хвост расширения по-прежнему уходит в журнал внутри пути файла. Это остаток,
|
||||||
|
он записан в [../security.md](../security.md), и своей задачи у него пока нет.
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
|
|
||||||
| Дата | Запись | Статус |
|
| Дата | Запись | Статус |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| 2026-08-11 | [Наружу расширение выходит только приведённым к перечню](ADR-2026-08-11-known-format-label.md) | |
|
||||||
| 2026-08-11 | [Приложение пишем на Vue, а Node входит в гейт и в образ](ADR-2026-08-11-spa-on-vue.md) | |
|
| 2026-08-11 | [Приложение пишем на Vue, а Node входит в гейт и в образ](ADR-2026-08-11-spa-on-vue.md) | |
|
||||||
| 2026-08-11 | [Очередь остаётся своей таблицей, но коллекцией PocketBase](ADR-2026-08-11-queue-as-pocketbase-collection.md) | |
|
| 2026-08-11 | [Очередь остаётся своей таблицей, но коллекцией PocketBase](ADR-2026-08-11-queue-as-pocketbase-collection.md) | |
|
||||||
| 2026-08-11 | [Хранилище, файлы и вход переезжают в PocketBase](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md) | |
|
| 2026-08-11 | [Хранилище, файлы и вход переезжают в PocketBase](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md) | |
|
||||||
|
|||||||
@@ -104,6 +104,7 @@
|
|||||||
| Завершение и отказ | `TranscribeService.completeJob` и `failJob` — они же отвечают пользователю |
|
| Завершение и отказ | `TranscribeService.completeJob` и `failJob` — они же отвечают пользователю |
|
||||||
| Разбор конфигурации | `internal/config.LoadConfig` |
|
| Разбор конфигурации | `internal/config.LoadConfig` |
|
||||||
| Метрики | `internal/metrics`, префикс имени `transcriber_` |
|
| Метрики | `internal/metrics`, префикс имени `transcriber_` |
|
||||||
|
| Значения метки формата | `internal/metrics.FormatLabel` — приводит расширение к закрытому перечню, прочее заменяет на `other`; нормирует спека `intake` |
|
||||||
|
|
||||||
Единых точек, которых **нет** и которые ожидались бы: идентификаторы
|
Единых точек, которых **нет** и которые ожидались бы: идентификаторы
|
||||||
генерируются вызовом `uuid.NewString()` по месту, время — вызовом `time.Now()`
|
генерируются вызовом `uuid.NewString()` по месту, время — вызовом `time.Now()`
|
||||||
|
|||||||
@@ -213,7 +213,10 @@ Object Storage, скачивание файла из Telegram и опрос оп
|
|||||||
- ключ SpeechKit и заголовок `Authorization`;
|
- ключ SpeechKit и заголовок `Authorization`;
|
||||||
- пара ключей Object Storage;
|
- пара ключей Object Storage;
|
||||||
- **сам текст расшифровки и имена файлов пользователя** — это содержимое личной
|
- **сам текст расшифровки и имена файлов пользователя** — это содержимое личной
|
||||||
переписки. Логируем длину текста, а не текст.
|
переписки. Логируем длину текста, а не текст. Имя файла ничем не заменяем:
|
||||||
|
ни укороченным именем, ни отпечатком от него — отпечаток та же приватная
|
||||||
|
величина, а корреляцию держат идентификаторы сущностей. Чем при этом
|
||||||
|
прослеживается приём, нормирует спека `intake`, а не эта запись.
|
||||||
|
|
||||||
Дополнительно:
|
Дополнительно:
|
||||||
|
|
||||||
@@ -233,6 +236,12 @@ Object Storage, скачивание файла из Telegram и опрос оп
|
|||||||
она не логируется — то есть утечки нет, но защищает от неё только отсутствие
|
она не логируется — то есть утечки нет, но защищает от неё только отсутствие
|
||||||
строки лога.
|
строки лога.
|
||||||
|
|
||||||
|
*Расхождение:* расширение берётся из имени отправителя дословно
|
||||||
|
(`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост
|
||||||
|
расширением, и в журнал оно попадает полем пути. Наружу — в метку метрики — этот
|
||||||
|
хвост не выходит: там расширение приводится к перечню известных форматов. Остаток
|
||||||
|
описан в [../security.md](../security.md).
|
||||||
|
|
||||||
## Куда пишем и уровень
|
## Куда пишем и уровень
|
||||||
|
|
||||||
- Пишем JSON в `stdout` одним потоком; сбор и ротацию делает окружение. Не
|
- Пишем JSON в `stdout` одним потоком; сбор и ротацию делает окружение. Не
|
||||||
|
|||||||
@@ -103,6 +103,9 @@
|
|||||||
- `security`: не строится ли путь на диске или ключ объекта из значения,
|
- `security`: не строится ли путь на диске или ключ объекта из значения,
|
||||||
пришедшего снаружи, — расширение файла сегодня берётся из имени отправителя
|
пришедшего снаружи, — расширение файла сегодня берётся из имени отправителя
|
||||||
(чтение `service/transcribe.go`, 2026-08-10).
|
(чтение `service/transcribe.go`, 2026-08-10).
|
||||||
|
- `security`: не уходит ли значение, пришедшее снаружи, меткой метрики — страница
|
||||||
|
метрик отдаётся без проверки отправителя, и метка это поверхность пошире
|
||||||
|
журнала (журнал, запись 2026-08-11 про хвост имени).
|
||||||
- `architecture`: не появился ли второй путь приёма мимо
|
- `architecture`: не появился ли второй путь приёма мимо
|
||||||
`createTranscribeJob` — сегодня через него идут оба входа
|
`createTranscribeJob` — сегодня через него идут оба входа
|
||||||
([architecture.md](architecture.md), «Единые точки проекта»).
|
([architecture.md](architecture.md), «Единые точки проекта»).
|
||||||
@@ -178,6 +181,26 @@ API и имя не откатываются обратной правкой по
|
|||||||
поймать их было некому. У восстановленных нет поля «Чем воспроизведён», и
|
поймать их было некому. У восстановленных нет поля «Чем воспроизведён», и
|
||||||
выдумывать его задним числом нельзя.
|
выдумывать его задним числом нельзя.
|
||||||
|
|
||||||
|
## 2026-08-11 — хвост имени отправителя уезжал на открытую страницу метрик [пойман ревью]
|
||||||
|
|
||||||
|
- **Где:** `internal/service/transcribe.go`, метки `file_extension` у размера
|
||||||
|
принятой записи и `source_format` у длительности конвертации
|
||||||
|
- **Симптом:** имя `запись.тайное-слово` клало `тайное-слово` меткой метрики, а
|
||||||
|
`GET /metrics` отдаётся без проверки отправителя. Тем же каналом множеством значений
|
||||||
|
метки распоряжался анонимный отправитель
|
||||||
|
- **Причина:** расширение берётся из имени отправителя дословно (`filepath.Ext`)
|
||||||
|
и употреблялось меткой без приведения. Канал старше задачи, которая его нашла
|
||||||
|
- **Чем воспроизведён:** прогон `filepath.Ext` на именах вида
|
||||||
|
`запись.тайное-слово`, `Разговор с Петровым 11.08`, затем чтение реестра
|
||||||
|
метрик после приёма — метка несла хвост дословно
|
||||||
|
- **Почему не поймали:** метрику никто не считал выходом приватного значения.
|
||||||
|
Тема `security` смотрела журнал, ответ и пути на диске; вопроса про метку в
|
||||||
|
перечне вопросов не было, и ни один проход её не открывал. Поймали три прохода
|
||||||
|
разом на задаче, которая закрывала соседний канал
|
||||||
|
- **Что меняем:** вопрос про метку добавлен в «Вопросы по темам»; правило
|
||||||
|
приведения нормировано спекой `intake` и записано
|
||||||
|
[решением](adr/ADR-2026-08-11-known-format-label.md)
|
||||||
|
|
||||||
## 2026-08-11 — проверка приёма не могла упасть [пойман ревью]
|
## 2026-08-11 — проверка приёма не могла упасть [пойман ревью]
|
||||||
|
|
||||||
- **Где:** `internal/controller/http/transcribe_test.go`, случай успеха приёма
|
- **Где:** `internal/controller/http/transcribe_test.go`, случай успеха приёма
|
||||||
|
|||||||
+28
-6
@@ -186,12 +186,34 @@ Telegram отправителю.
|
|||||||
который лежит **не в конфигурации**: его отпечаток хранит сама база.
|
который лежит **не в конфигурации**: его отпечаток хранит сама база.
|
||||||
|
|
||||||
Тексты расшифровок в логи не пишутся — логируется длина текста и
|
Тексты расшифровок в логи не пишутся — логируется длина текста и
|
||||||
идентификаторы. **Имя файла, данное отправителем, пишется**: строка
|
идентификаторы. Имя файла, данное отправителем, из журнала приёма убрано
|
||||||
`internal/service/transcribe.go:107` кладёт `file_name` на общем шаге заведения
|
2026-08-11 задачей `no-user-filename-in-log`; запрет проверяют тесты приёма по HTTP на
|
||||||
задачи, то есть для обоих входов. Это нарушение инварианта приватности из
|
успешном пути и на пути отказа — они ищут значение, а не имя поля.
|
||||||
`CLAUDE.md`, оно объявлено критическим, и чинит его задача
|
|
||||||
`no-user-filename-in-log`; её место в очереди —
|
**Остаток: хвост после последней точки остаётся в журнале.** Расширение берётся
|
||||||
[tasks/BACKLOG.md](../tasks/BACKLOG.md).
|
из имени отправителя дословно (`filepath.Ext`), поэтому имя
|
||||||
|
`запись.тайное-слово` отдаёт `тайное-слово`, а `Разговор с Петровым 11.08` —
|
||||||
|
`08`. Оно стоит в собственном имени файла на диске, а путь к файлу логируется.
|
||||||
|
Читает этот журнал владелец сервиса. Нормализация расширения на диске — отдельная
|
||||||
|
работа, задачи на неё пока нет: формат имени файла объявлен необратимым и меняется
|
||||||
|
решением человека.
|
||||||
|
|
||||||
|
**Наружу хвост не выходит.** Метки метрик (`file_extension` у
|
||||||
|
`transcriber_input_file_size_bytes`, `source_format` у
|
||||||
|
`transcriber_conversion_duration_seconds`) несут расширение, только приведённое к
|
||||||
|
закрытому перечню известных форматов; всё прочее заменяется значением `other`.
|
||||||
|
Это закрыто задачей `no-user-filename-in-log` 2026-08-11 вместе с самим именем.
|
||||||
|
Заодно у метки размера принятой записи пропала ведущая точка (`.mp3` стало
|
||||||
|
`mp3`) — форма выровнялась с меткой конвертации, которая точку не носила
|
||||||
|
никогда. Ряды, собранные до выкладки, перестают пополняться: панель, отобранная
|
||||||
|
по старому значению, покажет пустоту, и это не поломка.
|
||||||
|
Требование важно тем, что `GET /metrics` открыт вместе с остальным: без
|
||||||
|
приведения хвост читал бы кто угодно из интернета, а множеством значений метки
|
||||||
|
распоряжался бы анонимный отправитель.
|
||||||
|
|
||||||
|
Приём из Telegram имени, данного человеком, до сервиса не доводит: оттуда
|
||||||
|
приходит путь, выданный самим Telegram. Настоящее имя документа дальше проверки
|
||||||
|
типа файла не идёт.
|
||||||
|
|
||||||
Токен бота попадает в URL скачивания файла (`file.Link(token)`), и этот URL
|
Токен бота попадает в URL скачивания файла (`file.Link(token)`), и этот URL
|
||||||
нигде не логируется.
|
нигде не логируется.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -13,7 +13,10 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -27,6 +30,8 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
"github.com/pressly/goose/v3"
|
"github.com/pressly/goose/v3"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
sloggin "github.com/samber/slog-gin"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -74,6 +79,31 @@ type testEnv struct {
|
|||||||
handler *TranscribeHandler
|
handler *TranscribeHandler
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
storageDir string
|
storageDir string
|
||||||
|
journal *journalBuffer
|
||||||
|
}
|
||||||
|
|
||||||
|
// journalBuffer — перехваченный журнал одной проверки. Свой на случай: общий на
|
||||||
|
// пакет сделал бы исход функцией от соседних случаев — «поля на месте» прошло бы
|
||||||
|
// на чужой строке, а «маркера нет» покраснело бы от чужой. Замок нужен потому,
|
||||||
|
// что пишущих в него потоков три: логгер сервиса, стандартный `log` транспорта
|
||||||
|
// и middleware запроса.
|
||||||
|
type journalBuffer struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
text strings.Builder
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *journalBuffer) Write(p []byte) (int, error) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return b.text.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// String отдаёт весь перехваченный текст. Проверки ищут в нём значение, а не имя
|
||||||
|
// поля: имя, вернувшееся под другим ключом, поиск по ключу не разбудил бы.
|
||||||
|
func (b *journalBuffer) String() string {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return b.text.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupTestDB(t *testing.T) (*sql.DB, *goqu.Database) {
|
func setupTestDB(t *testing.T) (*sql.DB, *goqu.Database) {
|
||||||
@@ -115,11 +145,26 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
|
|||||||
fileRepo := sqlite.NewFileRepository(db, gq)
|
fileRepo := sqlite.NewFileRepository(db, gq)
|
||||||
jobRepo := sqlite.NewTranscriptJobRepository(db, gq)
|
jobRepo := sqlite.NewTranscriptJobRepository(db, gq)
|
||||||
|
|
||||||
// Журнал проверкам не нужен: судят они по ответу и по базе. А ветка отказа
|
// Журнал уходит в буфер, а не в никуда: по нему судит проверка запрета на
|
||||||
// метаданных теперь проходится нарочно, и её ERROR-строки на зелёном
|
// имя отправителя. Вывод прогона от этого не меняется — ERROR-строки ветки
|
||||||
// прогоне размывали бы признак, по которому отличают новый красный шаг
|
// отказа по-прежнему не попадают на экран и не размывают признак, по
|
||||||
// гейта от объявленного долга.
|
// которому отличают новый красный шаг гейта от объявленного долга.
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
journal := &journalBuffer{}
|
||||||
|
logger := slog.New(slog.NewTextHandler(journal, nil))
|
||||||
|
|
||||||
|
// Второй писатель журнала приёма — HTTP-транспорт: он пишет через стандартный
|
||||||
|
// `log` (расхождение записано в docs/conventions/logging.md). В бою `main.go`
|
||||||
|
// зовёт `slog.SetDefault`, и такая запись садится в `msg` строки `slog`;
|
||||||
|
// повторяем это здесь, чтобы оракул видел ту же цепочку, что и прод, а не
|
||||||
|
// свою. Без перехвата оракул был бы уже требования, которое накрывает все
|
||||||
|
// журнальные записи приёма.
|
||||||
|
//
|
||||||
|
// Подмена процессная, а не своя у случая: `t.Parallel()` в этом файле
|
||||||
|
// запрещён. При параллельных случаях вывод указывал бы на буфер соседа, и
|
||||||
|
// проверка запрета прошла бы, ничего не прочитав.
|
||||||
|
prevDefault := slog.Default()
|
||||||
|
slog.SetDefault(logger)
|
||||||
|
t.Cleanup(func() { slog.SetDefault(prevDefault) })
|
||||||
|
|
||||||
trsService := service.NewTranscribeService(
|
trsService := service.NewTranscribeService(
|
||||||
jobRepo,
|
jobRepo,
|
||||||
@@ -134,7 +179,12 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
|
|||||||
|
|
||||||
handler := NewTranscribeHandler(jobRepo, trsService)
|
handler := NewTranscribeHandler(jobRepo, trsService)
|
||||||
|
|
||||||
|
// Роутер собирается той же цепочкой, что и боевой (main.go): у приёма три
|
||||||
|
// пишущих в журнал потока, и middleware — третий. Без него требование «ни
|
||||||
|
// одна журнальная запись приёма» проверялось бы шире, чем оракул смотрит.
|
||||||
router := gin.New()
|
router := gin.New()
|
||||||
|
router.Use(sloggin.New(logger))
|
||||||
|
router.Use(gin.Recovery())
|
||||||
router.MaxMultipartMemory = 32 << 20 // 32 MiB
|
router.MaxMultipartMemory = 32 << 20 // 32 MiB
|
||||||
|
|
||||||
api := router.Group("/api")
|
api := router.Group("/api")
|
||||||
@@ -143,7 +193,7 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
|
|||||||
api.GET("/status/:id", handler.GetTranscribeJobStatus)
|
api.GET("/status/:id", handler.GetTranscribeJobStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &testEnv{router: router, handler: handler, db: db, storageDir: storageDir}
|
return &testEnv{router: router, handler: handler, db: db, storageDir: storageDir, journal: journal}
|
||||||
}
|
}
|
||||||
|
|
||||||
// createMultipartRequest собирает запрос из имени и содержимого. Файла на диске
|
// createMultipartRequest собирает запрос из имени и содержимого. Файла на диске
|
||||||
@@ -382,6 +432,163 @@ func TestCreateTranscribeJob_MetaViewerFailure(t *testing.T) {
|
|||||||
assert.Equal(t, 0, countJobs(t, env))
|
assert.Equal(t, 0, countJobs(t, env))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// senderNameMarker — метка внутри имени, которое даёт отправитель. ASCII и
|
||||||
|
// заведомо уникальна: в остальном выводе прогона такой строки нет, поэтому
|
||||||
|
// находка означает утечку, а не совпадение. Ищется она **значением**, а не
|
||||||
|
// именем журнального поля: имя, вернувшееся под другим ключом, поиск по ключу
|
||||||
|
// пропустил бы.
|
||||||
|
const senderNameMarker = "SENDERNAMELEAKMARKER7Q2"
|
||||||
|
|
||||||
|
// Тексты, по которым проверки находят журнальные строки. Оба — записанный долг
|
||||||
|
// `docs/conventions/logging.md`: `msg` обязан стать короткой категорией, а
|
||||||
|
// транспорту не положено логировать вовсе. Когда долг закроют, правка будет
|
||||||
|
// здесь и одна, а смысл утверждений менять не придётся.
|
||||||
|
const (
|
||||||
|
msgIntake = "Creating transcribe job"
|
||||||
|
msgTransportErr = "Err:"
|
||||||
|
msgMiddleware = "Incoming request"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateTranscribeJob_SenderFileNameNotLogged(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
// Метка стоит в основе имени, а расширение обычное: расширение запретом не
|
||||||
|
// накрыто и в журнале остаётся законно.
|
||||||
|
req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("запись"))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
env.router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusCreated, w.Code)
|
||||||
|
|
||||||
|
journal := env.journal.String()
|
||||||
|
|
||||||
|
// Сперва — что поток middleware вообще перехвачен. Он третий писатель
|
||||||
|
// журнала приёма, и без этого утверждения снятие его из тестового роутера
|
||||||
|
// сузило бы оракул молча.
|
||||||
|
require.Contains(t, journal, msgMiddleware,
|
||||||
|
"строка middleware о запросе попадает в перехваченный журнал")
|
||||||
|
|
||||||
|
assert.NotContains(t, journal, senderNameMarker,
|
||||||
|
"имя, данное отправителем, не пишется в журнал: инвариант приватности")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTranscribeJob_SenderFileNameNotLoggedOnFailure(t *testing.T) {
|
||||||
|
// Отказ — тот путь, где имя приехало бы в журнал текстом ошибки: приём
|
||||||
|
// назван конвенцией логирующей границей, и цепочка `%w` осядет полем error.
|
||||||
|
env := setupTestEnv(t, &stubMetaViewer{err: errors.New("не удалось прочитать запись")})
|
||||||
|
|
||||||
|
req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("не запись вовсе"))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
env.router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||||
|
|
||||||
|
journal := env.journal.String()
|
||||||
|
|
||||||
|
// Сперва — что второй поток журнала вообще перехвачен. Без этого
|
||||||
|
// утверждения снятие `slog.SetDefault` из окружения оставило бы проверку
|
||||||
|
// зелёной, а оракул критического инварианта молча сузился бы вдвое.
|
||||||
|
require.Contains(t, journal, msgTransportErr,
|
||||||
|
"строка транспорта, идущая мимо slog, попадает в перехваченный журнал")
|
||||||
|
|
||||||
|
assert.NotContains(t, journal, senderNameMarker,
|
||||||
|
"имя отправителя не пишется в журнал и на пути отказа")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
content := []byte("содержимое записи")
|
||||||
|
req := createMultipartRequest(t, "sample.mp3", content)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
env.router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusCreated, w.Code)
|
||||||
|
|
||||||
|
var response CreateTranscribeJobResponse
|
||||||
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
|
||||||
|
|
||||||
|
job, err := env.handler.jobRepo.GetByID(response.JobID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, job.FileID)
|
||||||
|
|
||||||
|
// Отбор по идентификатору **этого** прогона: иначе утверждение прошло бы по
|
||||||
|
// строке, оставленной соседней проверкой, и прослеживаемость числилась бы
|
||||||
|
// сохранённой при пустом журнале.
|
||||||
|
journal := env.journal.String()
|
||||||
|
assert.Contains(t, journal, *job.FileID, "по журналу видно, какой файл заведён")
|
||||||
|
assert.Contains(t, journal, ".mp3", "расширение принятой записи в журнале остаётся")
|
||||||
|
|
||||||
|
// Разделитель ключа и значения задаёт обработчик: сегодня текстовый, по
|
||||||
|
// конвенции — JSON. Утверждение держится на значении и переживёт замену.
|
||||||
|
assert.Regexp(t, fmt.Sprintf(`size["=:\s]+%d`, len(content)), journal,
|
||||||
|
"размер принятой записи в байтах в журнале остаётся")
|
||||||
|
|
||||||
|
// Запись о приёме не сменила адресата: на DEBUG её в боевой настройке не
|
||||||
|
// будет вовсе, и разбор постфактум опереться будет не на что. Уровень
|
||||||
|
// ищется в строке самого приёма — соседние строки тоже идут на INFO, и
|
||||||
|
// поиск по всему журналу не упал бы от понижения этой.
|
||||||
|
assert.Regexp(t, `(?m)^.*level["=:\s]+INFO.*`+regexp.QuoteMeta(msgIntake)+`.*$`, journal,
|
||||||
|
"строка приёма остаётся на уровне INFO")
|
||||||
|
|
||||||
|
// И она ровно одна: вторая строка приёма означала бы второй путь заведения
|
||||||
|
// задачи мимо общей точки, то есть место, куда запрет не доехал.
|
||||||
|
assert.Equal(t, 1, strings.Count(journal, msgIntake),
|
||||||
|
"на принятую запись приходится одна журнальная строка приёма")
|
||||||
|
}
|
||||||
|
|
||||||
|
// metricLabelValues собирает значения меток названного семейства метрик из
|
||||||
|
// общего реестра процесса. Проверка судит реестр, а не функцию приведения:
|
||||||
|
// приведение, снятое в точке употребления, функцию не ломает, а хвост имени
|
||||||
|
// уходит на страницу метрик, которая отдаётся без проверки отправителя.
|
||||||
|
func metricLabelValues(t *testing.T, family string) []string {
|
||||||
|
families, err := prometheus.DefaultGatherer.Gather()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var values []string
|
||||||
|
for _, mf := range families {
|
||||||
|
if mf.GetName() != family {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, m := range mf.GetMetric() {
|
||||||
|
for _, label := range m.GetLabel() {
|
||||||
|
values = append(values, label.GetValue())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTranscribeJob_MetricLabelCarriesNoSenderName(t *testing.T) {
|
||||||
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
// Хвост после последней точки — это тоже кусок имени, данного отправителем.
|
||||||
|
req := createMultipartRequest(t, "sample."+senderNameMarker, []byte("запись"))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
env.router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusCreated, w.Code)
|
||||||
|
|
||||||
|
values := metricLabelValues(t, "transcriber_input_file_size_bytes")
|
||||||
|
require.NotEmpty(t, values, "метрика размера принятой записи заполняется приёмом")
|
||||||
|
assert.NotContains(t, values, "."+senderNameMarker,
|
||||||
|
"метка метрики не несёт хвоста имени, данного отправителем")
|
||||||
|
assert.NotContains(t, values, senderNameMarker,
|
||||||
|
"метка метрики не несёт хвоста имени и без ведущей точки")
|
||||||
|
assert.Contains(t, values, "other",
|
||||||
|
"незнакомое расширение приведено к общему значению")
|
||||||
|
|
||||||
|
// А на диске расширение остаётся пришедшим: раскладка каталога записей
|
||||||
|
// объявлена необратимой, и приведение сюда не распространяется.
|
||||||
|
files := storedFiles(t, env)
|
||||||
|
require.Len(t, files, 1)
|
||||||
|
assert.Equal(t, "."+senderNameMarker, filepath.Ext(files[0]))
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetTranscribeJobStatus_Success(t *testing.T) {
|
func TestGetTranscribeJobStatus_Success(t *testing.T) {
|
||||||
env := setupTestEnv(t, readableMetaViewer())
|
env := setupTestEnv(t, readableMetaViewer())
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OtherFormatLabel — значение метки для всего, чего нет в перечне known-форматов.
|
||||||
|
const OtherFormatLabel = "other"
|
||||||
|
|
||||||
|
// knownFormats — закрытый перечень расширений, которые допускаются меткой.
|
||||||
|
//
|
||||||
|
// Состав: пути, которые выдаёт Telegram (голосовое приходит как
|
||||||
|
// `voice/file_N.oga`, кружок — с `.mp4`), плюс форматы, доезжающие приёмом по
|
||||||
|
// HTTP, плюс собственное умолчание сервиса на случай имени без расширения.
|
||||||
|
// Списку, по которому бот отбирает **документы** (`isAudioDocument`), перечень
|
||||||
|
// намеренно не равен: тот судит по типу содержимого и своим списком пользуется
|
||||||
|
// лишь когда типа нет, а сюда попадает и то, что приходит другими путями.
|
||||||
|
// Сведение двух списков в один уронило бы основной вход сервиса в `other`.
|
||||||
|
var knownFormats = map[string]struct{}{
|
||||||
|
"mp3": {},
|
||||||
|
"wav": {},
|
||||||
|
"ogg": {},
|
||||||
|
"oga": {},
|
||||||
|
"opus": {},
|
||||||
|
"flac": {},
|
||||||
|
"m4a": {},
|
||||||
|
"aac": {},
|
||||||
|
"wma": {},
|
||||||
|
"mp4": {},
|
||||||
|
"mkv": {},
|
||||||
|
"mov": {},
|
||||||
|
"avi": {},
|
||||||
|
"webm": {},
|
||||||
|
"audio": {}, // умолчание сервиса, когда расширения в имени не было
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatLabel приводит расширение к виду, годному для метки метрики.
|
||||||
|
//
|
||||||
|
// Расширение приходит из имени, которое дал отправитель, и потому может быть
|
||||||
|
// чем угодно: имя `запись.тайное-слово` отдаёт `тайное-слово`. Страница метрик
|
||||||
|
// открыта, то есть метка — поверхность пошире журнала. Незнакомое значение
|
||||||
|
// заменяется одним общим: это закрывает и утечку куска имени, и рост числа
|
||||||
|
// временных рядов, которым иначе распоряжается анонимный отправитель.
|
||||||
|
//
|
||||||
|
// Имени файла на диске это не касается — там расширение остаётся пришедшим.
|
||||||
|
// ObserveInputFileSize записывает размер принятой записи. Расширение приводится
|
||||||
|
// здесь, а не у вызывающего: сырая точка употребления — это место, где хвост
|
||||||
|
// имени отправителя однажды снова уедет наружу, и проверка у вызывающего этого
|
||||||
|
// не заметит.
|
||||||
|
func ObserveInputFileSize(ext string, size int64) {
|
||||||
|
InputFileSizeHistogram.WithLabelValues(FormatLabel(ext)).Observe(float64(size))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObserveConversionDuration записывает длительность конвертации. Исходный формат
|
||||||
|
// приводится по той же причине, что и в приёме.
|
||||||
|
func ObserveConversionDuration(srcExt, targetFormat string, failed bool, seconds float64) {
|
||||||
|
ConversionDurationHistogram.
|
||||||
|
WithLabelValues(FormatLabel(srcExt), targetFormat, strconv.FormatBool(failed)).
|
||||||
|
Observe(seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FormatLabel(ext string) string {
|
||||||
|
normalized := strings.ToLower(strings.TrimPrefix(ext, "."))
|
||||||
|
if _, ok := knownFormats[normalized]; ok {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
return OtherFormatLabel
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Метка метрики уезжает на страницу, которая отдаётся без проверки отправителя,
|
||||||
|
// поэтому судим здесь ровно одно: что наружу выходит только известное значение.
|
||||||
|
func TestFormatLabel(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
ext string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "известное расширение с точкой", ext: ".mp3", want: "mp3"},
|
||||||
|
{name: "известное расширение без точки", ext: "mp3", want: "mp3"},
|
||||||
|
{name: "регистр приводится", ext: ".MP3", want: "mp3"},
|
||||||
|
{name: "умолчание сервиса", ext: ".audio", want: "audio"},
|
||||||
|
// Голосовое из Telegram приходит путём вида `voice/file_N.oga`, кружок —
|
||||||
|
// с `.mp4`. Это основной вход сервиса: усечение перечня до списка, по
|
||||||
|
// которому бот отбирает документы, схлопнуло бы его в общее значение.
|
||||||
|
{name: "голосовое из Telegram", ext: ".oga", want: "oga"},
|
||||||
|
{name: "видеокружок из Telegram", ext: ".mp4", want: "mp4"},
|
||||||
|
{name: "opus", ext: ".opus", want: "opus"},
|
||||||
|
// Значение сверяется с литералом, а не с самой константой: сверка с
|
||||||
|
// константой утверждала бы тавтологию, а спека нормирует слово `other`
|
||||||
|
// дословно.
|
||||||
|
{name: "хвост имени отправителя", ext: ".тайное-слово", want: "other"},
|
||||||
|
{name: "часть даты в имени", ext: ".08", want: "other"},
|
||||||
|
{name: "пустое", ext: "", want: "other"},
|
||||||
|
{name: "одна точка", ext: ".", want: "other"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := FormatLabel(tc.ext); got != tc.want {
|
||||||
|
t.Errorf("FormatLabel(%q) = %q, ожидалось %q", tc.ext, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// labelValues собирает значения меток названного семейства из общего реестра.
|
||||||
|
func labelValues(t *testing.T, family string) []string {
|
||||||
|
families, err := prometheus.DefaultGatherer.Gather()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("не удалось прочитать реестр метрик: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var values []string
|
||||||
|
for _, mf := range families {
|
||||||
|
if mf.GetName() != family {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, m := range mf.GetMetric() {
|
||||||
|
for _, label := range m.GetLabel() {
|
||||||
|
values = append(values, label.GetValue())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
// Метку конвертации приёмом по HTTP не достать: шаг живёт в воркере, и своего
|
||||||
|
// окружения у него нет. Поэтому обёртка судится здесь — по реестру, а не по
|
||||||
|
// чистой функции: сырое употребление гистограммы мимо обёртки и есть то место,
|
||||||
|
// где хвост имени отправителя однажды снова уедет наружу.
|
||||||
|
func TestObserveConversionDurationLabelsAreKnown(t *testing.T) {
|
||||||
|
const tail = "CONVLEAKMARKER5X8"
|
||||||
|
|
||||||
|
ObserveConversionDuration("."+tail, "ogg", false, 1)
|
||||||
|
|
||||||
|
values := labelValues(t, "transcriber_conversion_duration_seconds")
|
||||||
|
if len(values) == 0 {
|
||||||
|
t.Fatal("метрика длительности конвертации не заполнилась")
|
||||||
|
}
|
||||||
|
assertTailAbsentAndOtherPresent(t, values, tail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Та же проверка для метки приёма — на случай, если обёртку обойдут только с
|
||||||
|
// одной стороны.
|
||||||
|
func TestObserveInputFileSizeLabelsAreKnown(t *testing.T) {
|
||||||
|
const tail = "INPUTLEAKMARKER5X8"
|
||||||
|
|
||||||
|
ObserveInputFileSize("."+tail, 42)
|
||||||
|
|
||||||
|
values := labelValues(t, "transcriber_input_file_size_bytes")
|
||||||
|
if len(values) == 0 {
|
||||||
|
t.Fatal("метрика размера принятой записи не заполнилась")
|
||||||
|
}
|
||||||
|
assertTailAbsentAndOtherPresent(t, values, tail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertTailAbsentAndOtherPresent судит значения метки по двум признакам сразу.
|
||||||
|
// Одного «хвоста нет» мало: приведение, ослабленное до смены регистра, хвост
|
||||||
|
// пропустило бы, а поиск заглавного маркера в строчном значении его не нашёл бы.
|
||||||
|
// Поэтому сравнение регистронезависимое, и рядом стоит второй признак — что
|
||||||
|
// незнакомое расширение вообще доехало до общего значения.
|
||||||
|
func assertTailAbsentAndOtherPresent(t *testing.T, values []string, tail string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for _, v := range values {
|
||||||
|
if strings.Contains(strings.ToLower(v), strings.ToLower(tail)) {
|
||||||
|
t.Fatalf("метка несёт хвост имени, данного отправителем: %q", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, v := range values {
|
||||||
|
if v == "other" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("незнакомое расширение не приведено к общему значению")
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Умолчание расширения живёт в этом пакете, а перечень значений метки — в
|
||||||
|
// пакете метрик, и связывает их только совпадение двух литералов. Компилятор
|
||||||
|
// расхождения не поймает: смена умолчания просто сложит все записи без
|
||||||
|
// расширения в общее значение, и метка перестанет отличать «расширения не было»
|
||||||
|
// от чужого хвоста в имени.
|
||||||
|
func TestDefaultAudioExtIsKnownToMetrics(t *testing.T) {
|
||||||
|
if got := metrics.FormatLabel(defaultAudioExt); got == metrics.OtherFormatLabel {
|
||||||
|
t.Fatalf("умолчание %q не входит в перечень известных форматов: метка отдаёт %q",
|
||||||
|
defaultAudioExt, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -102,9 +101,10 @@ func (s *TranscribeService) createTranscribeJob(job *entity.TranscribeJob, file
|
|||||||
storageFileName := fmt.Sprintf("%s%s", fileId, ext)
|
storageFileName := fmt.Sprintf("%s%s", fileId, ext)
|
||||||
storageFilePath := filepath.Join(s.storagePath, storageFileName)
|
storageFilePath := filepath.Join(s.storagePath, storageFileName)
|
||||||
|
|
||||||
|
// Имя, данное отправителем, в журнал не идёт: инвариант приватности.
|
||||||
|
// Расширение из него уже стоит в собственном имени файла на диске.
|
||||||
s.logger.Info("Creating transcribe job",
|
s.logger.Info("Creating transcribe job",
|
||||||
"file_id", fileId,
|
"file_id", fileId,
|
||||||
"file_name", fileName,
|
|
||||||
"storage_path", storageFilePath)
|
"storage_path", storageFilePath)
|
||||||
|
|
||||||
// Создаем файл на диске
|
// Создаем файл на диске
|
||||||
@@ -139,7 +139,7 @@ func (s *TranscribeService) createTranscribeJob(job *entity.TranscribeJob, file
|
|||||||
"duration_seconds", info.Seconds)
|
"duration_seconds", info.Seconds)
|
||||||
|
|
||||||
metrics.InputFileDurationHistogram.WithLabelValues().Observe(float64(info.Seconds))
|
metrics.InputFileDurationHistogram.WithLabelValues().Observe(float64(info.Seconds))
|
||||||
metrics.InputFileSizeHistogram.WithLabelValues(ext).Observe(float64(size))
|
metrics.ObserveInputFileSize(ext, size)
|
||||||
|
|
||||||
// Создаем запись в таблице files
|
// Создаем запись в таблице files
|
||||||
fileRecord := &entity.File{
|
fileRecord := &entity.File{
|
||||||
@@ -207,9 +207,7 @@ func (s *TranscribeService) FindAndRunConversionJob() error {
|
|||||||
conversionDuration := time.Since(startTime)
|
conversionDuration := time.Since(startTime)
|
||||||
|
|
||||||
// Записываем метрику времени конвертации
|
// Записываем метрику времени конвертации
|
||||||
metrics.ConversionDurationHistogram.
|
metrics.ObserveConversionDuration(srcExt, "ogg", err != nil, conversionDuration.Seconds())
|
||||||
WithLabelValues(srcExt, "ogg", strconv.FormatBool(err != nil)).
|
|
||||||
Observe(conversionDuration.Seconds())
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("File conversion failed",
|
s.logger.Error("File conversion failed",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-08-11
|
||||||
@@ -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 нечем. Имени, данного человеком, оттуда сегодня и не приходит.
|
||||||
|
|
||||||
|
**Запрет держится проверкой, а не линтером** → следующая журнальная строка с
|
||||||
|
именем отправителя в другом месте кода проверкой не поймается. Смягчение в
|
||||||
|
границах задачи: проверка стоит ровно на том шаге, через который проходит всякая
|
||||||
|
принятая запись. Правило линтера — кандидат в отдельную задачу.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Приём кладёт в журнал имя файла, которое дал отправитель, на каждой принятой
|
||||||
|
записи. Имя записи из семейного архива — такое же содержимое личной переписки,
|
||||||
|
как и сам текст расшифровки; инвариант приватности объявляет такую запись
|
||||||
|
критической и необратимой, потому что строка уже уехала в журнал контейнера.
|
||||||
|
|
||||||
|
Доходит это имя до сервиса сегодня только по HTTP: из Telegram приходит путь,
|
||||||
|
выданный самим Telegram, а не имя человека. Строка журнала при этом общая для
|
||||||
|
обоих входов, и запрет пишется на неё, а не на транспорт.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Журнал приёма перестаёт нести имя, данное отправителем. Правка ложится на
|
||||||
|
общий шаг заведения задачи, через который идут оба входа.
|
||||||
|
- Прослеживаемость приёма сохраняется: идентификатор записи, её расширение и
|
||||||
|
размер в байтах в журнале остаются, по ним путь записи собирается отбором.
|
||||||
|
- Запрет становится проверяемым, и проверяется он на успехе и на отказе: приём
|
||||||
|
прогоняется с записью, чьё имя содержит опознаваемую строку, и журнал этой
|
||||||
|
строки не содержит.
|
||||||
|
- **Метка метрики перестаёт нести кусок имени.** Расширение — хвост после
|
||||||
|
последней точки — берётся из имени отправителя и уезжало меткой на страницу
|
||||||
|
метрик, которая отдаётся кому угодно. Теперь оно приводится к перечню
|
||||||
|
известных форматов, всё прочее становится одним общим значением. Решение
|
||||||
|
принято контрольной точкой уже после ревью кода, которое построило этот путь.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
Новых нет.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `intake`: приём получает требование о том, чего его журнал нести не вправе, и
|
||||||
|
о том, что в нём остаётся ради прослеживаемости.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- `internal/service/transcribe.go`, общий шаг заведения задачи — журнальные
|
||||||
|
строки приёма и обе метки, несущие расширение;
|
||||||
|
- `internal/metrics` — приведение расширения к перечню известных форматов;
|
||||||
|
- проверка приёма: новый прогон с опознаваемым именем и разбором перехваченного
|
||||||
|
журнала;
|
||||||
|
- `docs/conventions/logging.md` уже запрещает имена файлов пользователя — правки
|
||||||
|
не требует, разве что строкой о том, чем имя заменено.
|
||||||
@@ -0,0 +1,802 @@
|
|||||||
|
# Триаж ревью кода: `no-user-filename-in-log`
|
||||||
|
|
||||||
|
Документ ведётся по кругам. Наверху — последний круг, ниже приложением — отчёт
|
||||||
|
первого круга целиком, как он был написан. История не переписывается: исход
|
||||||
|
каждой находки первого круга проставлен отдельным разделом, а не правкой её
|
||||||
|
текста.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Круг 2 (после контрольной точки о метке метрики)
|
||||||
|
|
||||||
|
## Сводка
|
||||||
|
|
||||||
|
- **Change:** `no-user-filename-in-log`. База диффа `origin/master`, судится
|
||||||
|
рабочее дерево (`git diff`) плюс три файла не под git:
|
||||||
|
`internal/metrics/format_label.go`, `internal/metrics/format_label_test.go`,
|
||||||
|
`internal/service/format_label_test.go`. Итого 5 изменённых файлов
|
||||||
|
(+258/−17) и 3 новых.
|
||||||
|
- **Размер:** среднее. **Сложность:** знакомое. **Метка: medium** — максимум по
|
||||||
|
осям, не менялась между кругами. Второй круг добавил файл в
|
||||||
|
`internal/metrics`, но проектный триггер «новая метрика в `internal/metrics`»
|
||||||
|
(`docs/review.md`, «Триггеры метки») опускает до `small` именно новую
|
||||||
|
**метрику**; здесь новой метрики нет, добавлено приведение значения метки.
|
||||||
|
Метка остаётся `medium`.
|
||||||
|
- **Режим прогона:** по графу. Второй круг: `specs`, `code`, `basics`, `triage`.
|
||||||
|
- **Гейт:** красный **только объявленным долгом**. Проверено мной самим:
|
||||||
|
`go build ./...`, `go vet ./...`, `gofmt -l .`, `go test ./...` зелёные;
|
||||||
|
`golangci-lint run` даёт ровно 4 знакомых замечания (`speechkit.go:55`,
|
||||||
|
`main.go:124`, `worker.go:51`, `transcribe.go:395`);
|
||||||
|
`openspec validate no-user-filename-in-log --strict` — `is valid`.
|
||||||
|
`go test -race -count=5 ./internal/controller/http/` — `ok, 1.663s`.
|
||||||
|
Новых красных шагов нет.
|
||||||
|
- **Находок на входе второго круга:** 15 (specs 6, basics 3, code 6) + 1
|
||||||
|
кандидат в правила. За два круга — 32.
|
||||||
|
**После дедупликации по причине и отсева:** 3 в первых двух секциях
|
||||||
|
(1 блокирующая, 2 к исправлению), 3 гипотезы, 5 кандидатов в правила.
|
||||||
|
|
||||||
|
### Находка о прогоне: `autotests` на втором круге не запускался
|
||||||
|
|
||||||
|
Второй круг прогнал `specs`, `code` и `basics`. Проход `autotests` — **нет**, и
|
||||||
|
это его тема: круг добавил 130 строк в `internal/controller/http/transcribe_test.go`
|
||||||
|
и два новых тестовых файла целиком. Дом темы (`CLAUDE.md`, «Гейт» и «Команды»)
|
||||||
|
второй круг не открывал никто.
|
||||||
|
|
||||||
|
Это не абстрактный пробел: **блокирующая находка №1 ниже — ровно тот класс,
|
||||||
|
который ищет `autotests`**, и нашёл её я мутацией, а не проход. Судить, сколько
|
||||||
|
ещё такого осталось в добавленных проверках, нечем: я не читаю код в поисках
|
||||||
|
дефектов, я проверяю чужие выводы, а выводов по теме `autotests` за второй круг
|
||||||
|
не поступало.
|
||||||
|
|
||||||
|
### Сигнал о заниженной метке
|
||||||
|
|
||||||
|
- **Круг 1:** `review-code` пришёл и метку заниженной **не** считает. От
|
||||||
|
`review-basics` сигнала не было — ни «верна», ни «занижена».
|
||||||
|
- **Круг 2:** сигнала не пришло **ни от одного** прохода — ни от `review-code`,
|
||||||
|
ни от `review-basics`. Отличить «возражений нет» от «не сказано» по молчанию
|
||||||
|
нельзя, и я этого не делаю.
|
||||||
|
|
||||||
|
### План разметки против исхода — оба круга
|
||||||
|
|
||||||
|
План не менялся. Своих тем проекта сверх ядра нет, тем без дома нет.
|
||||||
|
|
||||||
|
| тема | дом | глубина | закрывает | круг 1 | круг 2 |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| requirements | `openspec/specs/intake/spec.md` + дельта change | разбор | specs | **закрыта**, 4 находки | **закрыта**, 6 находок |
|
||||||
|
| autotests | `CLAUDE.md`, «Гейт» и «Команды» | — | autotests | **закрыта**, 0 находок + 1 promote | **отчёта не пришло — проход не запускался** |
|
||||||
|
| conventions | `docs/conventions/logging.md` + весь каталог | разбор | code | **закрыта**, 6 находок | **закрыта**, 6 находок |
|
||||||
|
| architecture | `docs/architecture.md`, «Единые точки проекта» + `docs/passport.md` | разбор | basics | **закрыта**, ответ по вопросу темы + 1 находка | **закрыта**, 1 находка (дом правила) |
|
||||||
|
| security | `docs/security.md`, «Куда уходит содержимое записи», «Из чего строятся пути и ключи» | разбор | basics | **закрыта**, 1 находка (главная) | **закрыта**, подтверждение: канал наружу перебран поимённо |
|
||||||
|
| operations | `docs/architecture.md`, «Эксплуатация» + `docs/database.md` | разбор | basics | **закрыта**, ответы по вопросам темы | **закрыта**, 2 находки (умолчание, видимость настоящего формата) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Блокирует мердж
|
||||||
|
|
||||||
|
### 1. Второй употребитель приведения метки оракулом не закрыт: снятие `FormatLabel` на шаге конвертации не роняет ничего
|
||||||
|
|
||||||
|
- **Где:** `internal/service/transcribe.go:212` —
|
||||||
|
`WithLabelValues(metrics.FormatLabel(srcExt), "ogg", strconv.FormatBool(err != nil))`.
|
||||||
|
Критерий `tasks.md` 2а.2 («Обе метки, несущие расширение, идут через
|
||||||
|
приведение») отмечен выполненным.
|
||||||
|
- **Severity:** major. Не критично тем, что код **сегодня верен**: приведение на
|
||||||
|
месте, хвост наружу не идёт. Критично то, что удержано оно ничем, а чек-лист
|
||||||
|
утверждает обратное.
|
||||||
|
- **Оракул (мутация на копии дерева, прогнана на этом прогоне):**
|
||||||
|
|
||||||
|
```
|
||||||
|
# копия дерева, sed по строке 212: FormatLabel(srcExt) -> srcExt
|
||||||
|
go test ./internal/...
|
||||||
|
MUT[M2 приведение снято у метки конвертации]: ЗЕЛЁНАЯ (мутация не поймана)
|
||||||
|
```
|
||||||
|
|
||||||
|
Для сравнения — тот же приём на первом употребителе краснеет:
|
||||||
|
|
||||||
|
```
|
||||||
|
MUT[M1 приведение снято у метки размера приёма]: КРАСНЕЕТ
|
||||||
|
--- FAIL: TestCreateTranscribeJob_MetricLabelCarriesNoSenderName
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Что именно уходит этой меткой:** `srcExt` строится из `srcFile.FileName`
|
||||||
|
(`transcribe.go:195`), а это имя файла в хранилище вида
|
||||||
|
`<uuid>.<хвост имени отправителя>`. То есть в метку `source_format` метрики
|
||||||
|
`transcriber_conversion_duration_seconds` попадает тот же приватный хвост, что
|
||||||
|
и в метку приёма, и уезжает он на тот же анонимный `GET /metrics`
|
||||||
|
(`main.go:216`). Канал по свойствам идентичен закрытому.
|
||||||
|
- **Почему это блокирует, а не «стоит исправить»:** `design.md` этого change сам
|
||||||
|
объявил такую проверку недостаточной — дословно: «Проверка функции в отрыве от
|
||||||
|
точек употребления зелёная и при снятом приведении: правка места вызова
|
||||||
|
вернула бы хвост наружу молча». Решение принято, реализовано для одной точки
|
||||||
|
из двух, а чек-лист отмечает обе. Это записанное свойство проекта —
|
||||||
|
`docs/review.md`, «Типовые узлы / Любой узел»: «проверка **способна упасть**…
|
||||||
|
Признак ищется мутацией», и журнальная запись 2026-08-11 об этом же классе.
|
||||||
|
Ранжирую первым по правилу молчания: следующий, кто уберёт `FormatLabel` со
|
||||||
|
строки 212 как лишний вызов, сузит оракул критического инварианта, и никто не
|
||||||
|
узнает.
|
||||||
|
- **Чего эта находка НЕ утверждает:** «`/metrics` открыт без аутентификации» —
|
||||||
|
типовое ложноположительное проекта (`docs/review.md`, «Типовые
|
||||||
|
ложноположительные», пункт про HTTP API). Новое здесь не открытость, а
|
||||||
|
неудержанность приведения на второй точке.
|
||||||
|
- **Действие: развилка.**
|
||||||
|
|
||||||
|
> Приведение расширения к перечню стоит в двух местах, а оракул — только на
|
||||||
|
> одном. Мутация на втором (`transcribe.go:212`, метка `source_format` шага
|
||||||
|
> конвертации) зелёная: снятие приведения не роняет ни одной проверки.
|
||||||
|
> Своего тестового окружения у `internal/service` нет вовсе — есть только
|
||||||
|
> `format_label_test.go` на 19 строк. Что делаем до мерджа?
|
||||||
|
>
|
||||||
|
> **(а)** Мержим как есть: пробел записываем строкой в `design.md` разделом
|
||||||
|
> Risks и вешаем на существующую задачу `tasks/items/pipeline-step-tests.md`
|
||||||
|
> — она заведена ровно про непокрытые `FindAndRunConversionJob` и соседей.
|
||||||
|
> Чек-лист `tasks.md` 2а.2 при этом надо переформулировать: «приведение стоит
|
||||||
|
> на обеих точках, оракул — на одной».
|
||||||
|
> **(б)** Строим окружение проверки для `FindAndRunConversionJob`: репозитории
|
||||||
|
> SQLite, каталог хранения, подставной конвертер. Цена сопоставима с самой
|
||||||
|
> задачей и вылезает за её scope.
|
||||||
|
> **(в)** Убираем возможность обойти приведение по построению: прячем
|
||||||
|
> `InputFileSizeHistogram` и `ConversionDurationHistogram` за функциями
|
||||||
|
> `internal/metrics`, которые сами зовут `FormatLabel`. Тогда сырое расширение
|
||||||
|
> передать меткой нечем, и обе точки закрывает уже существующая проверка
|
||||||
|
> реестра. Меняется экспортируемая поверхность пакета `internal/metrics` —
|
||||||
|
> пакет внутренний, публичного контракта это не трогает, но это правка сверх
|
||||||
|
> заказанного объёма.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Стоит исправить сейчас
|
||||||
|
|
||||||
|
### 2. Комментарий оракула ссылается на механизм, которого в коде нет
|
||||||
|
|
||||||
|
- **Где:** `internal/controller/http/transcribe_test.go:492` — «снятие
|
||||||
|
`log.SetOutput` из окружения оставило бы проверку зелёной». Тот же текст в
|
||||||
|
`tasks.md`, пункт 2.1: «в него же перенаправляется вывод стандартного `log`».
|
||||||
|
- **Оракул:** `grep -n "log.SetOutput" internal/controller/http/transcribe_test.go`
|
||||||
|
отдаёт единственное совпадение — саму эту строку комментария. В окружении
|
||||||
|
стоит `slog.SetDefault(logger)` (`:167`), как в `main.go:50`. Механизм заменён
|
||||||
|
в этом же круге по находке прохода `code` — комментарий и чек-лист за правкой
|
||||||
|
не поехали.
|
||||||
|
- **Почему сейчас:** причина ровно та же, что у находки №3 первого круга
|
||||||
|
(`src_ext`, метки не существует), и лечится так же — одним словом. Проза,
|
||||||
|
называющая несуществующий механизм, сбивает следующего: он не найдёт
|
||||||
|
`log.SetOutput`, решит, что утверждение мёртвое, и снимет его. Утверждение
|
||||||
|
живое — мутация подтверждает:
|
||||||
|
|
||||||
|
```
|
||||||
|
MUT[M4 slog.SetDefault снят]: КРАСНЕЕТ
|
||||||
|
--- FAIL: TestCreateTranscribeJob_SenderFileNameNotLoggedOnFailure
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Действие: инлайн.** В обоих местах заменить `log.SetOutput` на
|
||||||
|
`slog.SetDefault` и уточнить формулировку: вывод стандартного `log` уходит в
|
||||||
|
буфер не перенаправлением, а тем, что `slog.SetDefault` сажает его в `msg`
|
||||||
|
записи `slog`.
|
||||||
|
|
||||||
|
### 3. Значение `other` нормировано спекой дословно, но ни одна проверка к литералу не привязана
|
||||||
|
|
||||||
|
- **Где:** `internal/metrics/format_label.go:6` — `const OtherFormatLabel = "other"`;
|
||||||
|
дельта-спека, требование «Метка метрики несёт только известное расширение»:
|
||||||
|
«всякое другое MUST заменяться единым значением `other`».
|
||||||
|
- **Оракул (мутация, прогнана):**
|
||||||
|
|
||||||
|
```
|
||||||
|
# const OtherFormatLabel = "other" -> const OtherFormatLabel = ""
|
||||||
|
MUT[M11 общее значение пустое]: ЗЕЛЁНАЯ (мутация не поймана)
|
||||||
|
```
|
||||||
|
|
||||||
|
Обе проверки — и `TestFormatLabel`, и
|
||||||
|
`TestCreateTranscribeJob_MetricLabelCarriesNoSenderName` — сверяются с самой
|
||||||
|
константой, то есть утверждают тавтологию. Значение метки — величина, которую
|
||||||
|
видит человек в панели снаружи; спека называет её дословно, а код может
|
||||||
|
сменить её молча, включая на пустую строку, при которой метка из выборок
|
||||||
|
Prometheus фактически исчезает.
|
||||||
|
- **Почему сейчас, а не в гипотезы:** это не догадка, а прогнанная мутация, и
|
||||||
|
закрывается одной строкой в `internal/metrics/format_label_test.go`:
|
||||||
|
`if OtherFormatLabel != "other" { t.Fatal(...) }` — либо заменой `want:
|
||||||
|
OtherFormatLabel` на `want: "other"` в трёх случаях таблицы.
|
||||||
|
- **Действие: инлайн.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Гипотезы без доказательства
|
||||||
|
|
||||||
|
- **Запрет `t.Parallel()` держится комментарием** (перенесено с первого круга и
|
||||||
|
остаётся верным; механизм только сменился). Теперь процессная подмена — это
|
||||||
|
`slog.SetDefault`, и ничто, кроме текста рядом, не мешает следующей проверке в
|
||||||
|
этом файле стать параллельной; тогда перехват уедет к соседу. Оракула нет:
|
||||||
|
воспроизвести можно только внесением `t.Parallel()`, то есть той самой будущей
|
||||||
|
правкой. `Confidence: medium`, severity не выше `minor`.
|
||||||
|
- **Позитивное утверждение `require.Contains(journal, msgTransportErr)`
|
||||||
|
привязано к расхождению, которое конвенция объявляет подлежащим устранению**
|
||||||
|
(`log.Printf` в HTTP-транспорте мимо `slog`). Когда расхождение починят,
|
||||||
|
проверка отказа покраснеет по причине, не связанной с приватностью. Вынос
|
||||||
|
текста в константу с комментарием про долг (сделан вторым кругом) снижает цену
|
||||||
|
правки, но событие в будущем оракулом не закрывает. `minor`.
|
||||||
|
- **Приём из Telegram своего оракула не получил ни на одном круге.** Правка
|
||||||
|
достаётся ему общим шагом `createTranscribeJob`, и обойти её со стороны
|
||||||
|
Telegram нечем — но это рассуждение по коду, а не прогон. Объявлено риском в
|
||||||
|
`design.md`. `minor`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Promote candidates
|
||||||
|
|
||||||
|
- **Правило линтера на ключи `file_name`/`filename` в вызовах логгера**
|
||||||
|
(`sloglint` либо `forbidigo`). Пришло от `autotests` на первом круге;
|
||||||
|
`design.md` объявил это non-goal задачи.
|
||||||
|
- **Свойство в `docs/review.md`, «Типовые узлы / Любой узел»:** оракул ставится
|
||||||
|
**на каждой точке употребления**, а не на самой функции и не на одной из
|
||||||
|
точек. Обобщение трёх находок подряд — неудержанного middleware (круг 1, №2),
|
||||||
|
неудержанного `log`-потока (круг 1, починено) и неудержанной точки конвертации
|
||||||
|
(круг 2, №1). Три повторения одного класса за один change — заявка на
|
||||||
|
записанное правило, а не на разовую починку.
|
||||||
|
- **Конвенции о метриках в проекте нет вовсе.** `docs/conventions/` — пять
|
||||||
|
файлов: `config`, `database`, `errors`, `logging`, `web-ui`. Что можно класть в
|
||||||
|
метку, кто отвечает за кардинальность, как объявлять смену формы значения —
|
||||||
|
дома нет. Второй круг завёл строку в `docs/architecture.md`, «Единые точки
|
||||||
|
проекта», но это указатель на реализацию, а не правило. Пришло от `code`.
|
||||||
|
- **Нормализация расширения, взятого из имени отправителя, в журнале и в имени
|
||||||
|
файла на диске** — отдельной задачей, с явным решением человека про раскладку
|
||||||
|
`data/files` (`CLAUDE.md` называет её необратимой). Остаток записан прозой в
|
||||||
|
`docs/security.md`; задачи на него нет.
|
||||||
|
- **Смена формы значения метки (`.mp3` → `mp3`) записана в `docs/security.md`.**
|
||||||
|
Дом спорный: это факт эксплуатации, а не модели угроз, и человек, у которого
|
||||||
|
опустела панель, полезет в `docs/architecture.md`, «Эксплуатация». Не находка
|
||||||
|
— правило о том, где объявляются несовместимые смены формы метрик, входит в
|
||||||
|
предыдущий пункт.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что из починенного починено верно и не ослаблено
|
||||||
|
|
||||||
|
Проверено мутациями на копии дерева (`git ls-files` + три файла не под git), не
|
||||||
|
со слов. Базовый прогон копии зелёный, откат подтверждён.
|
||||||
|
|
||||||
|
| мутация | исход | что этим удержано |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| приведение снято у метки размера приёма | **КРАСНЕЕТ** (`…MetricLabelCarriesNoSenderName`) | круг 2, specs #1 — для точки приёма |
|
||||||
|
| приведение снято у метки конвертации | **ЗЕЛЁНАЯ** | **не удержано — находка №1** |
|
||||||
|
| имя отправителя вернулось под ключом `src` | **КРАСНЕЕТ** (обе проверки запрета) | круг 1: поиск идёт по значению, не по ключу |
|
||||||
|
| `slog.SetDefault` снят из окружения | **КРАСНЕЕТ** (`…NotLoggedOnFailure`) | круг 2, code #4 — боевая цепочка журнала |
|
||||||
|
| middleware не подключён к тестовому роутеру | **КРАСНЕЕТ** (`…SenderFileNameNotLogged`) | круг 1, находка №2 |
|
||||||
|
| строка приёма понижена до `DEBUG` | **КРАСНЕЕТ** (`…JournalTracesRecord`) | круг 1: адресат записи |
|
||||||
|
| умолчание сервиса выведено из перечня | **КРАСНЕЕТ** (`TestDefaultAudioExtIsKnownToMetrics` + `…DifferentFileExtensions`) | круг 2, basics #1 — дубль умолчания |
|
||||||
|
| вторая строка приёма в журнале | **КРАСНЕЕТ** (`…JournalTracesRecord`) | круг 1, находка №4 — «ровно одна запись» |
|
||||||
|
| приведение регистра снято | **КРАСНЕЕТ** (`TestFormatLabel`) | круг 2, specs #5 |
|
||||||
|
| `oga`, `opus`, `mp4` выведены из перечня | **КРАСНЕЕТ** (`TestFormatLabel`) | круг 2, code #1 — голосовые Telegram |
|
||||||
|
| `OtherFormatLabel` стал пустым | **ЗЕЛЁНАЯ** | **не удержано — находка №3** |
|
||||||
|
|
||||||
|
Дополнительно проверено поимённо, а не со слов:
|
||||||
|
|
||||||
|
- **Круг 1, находка №3 закрыта.** `docs/security.md` называет метки
|
||||||
|
`file_extension` у `transcriber_input_file_size_bytes` и `source_format` у
|
||||||
|
`transcriber_conversion_duration_seconds`. Сверено с
|
||||||
|
`internal/metrics/metrics.go:24` и `:44` — совпадает.
|
||||||
|
`grep -rn "src_ext"` по коду и документам совпадений не даёт (остались только
|
||||||
|
упоминания в архиве этого же отчёта).
|
||||||
|
- **Круг 2, specs #4 закрыта.** `design.md` держит решение о метке в Decisions,
|
||||||
|
с записью развилки и двух отвергнутых вариантов (оставить как есть; приводить
|
||||||
|
везде, включая диск). `proposal.md` согласован.
|
||||||
|
- **Круг 2, specs #3 и basics #2 закрыты.** Перечень назван поимённо в
|
||||||
|
требовании спеки (14 форматов + `audio`), дом правила — строка в
|
||||||
|
`docs/architecture.md`, «Единые точки проекта».
|
||||||
|
- **Ослаблений не найдено.** Ни одна проверка первого круга не стала слабее,
|
||||||
|
`go test ./...` зелёный, `go test -race -count=5` зелёный, новых замечаний
|
||||||
|
линтера нет, `openspec validate --strict` — valid.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что осталось непочиненным и почему
|
||||||
|
|
||||||
|
Ничего не выброшено молча. Полный список:
|
||||||
|
|
||||||
|
1. **Точка конвертации не удержана оракулом** — находка №1, развилка, решение за
|
||||||
|
человеком. Единственное, что блокирует мердж.
|
||||||
|
2. **Комментарий про `log.SetOutput`** — находка №2, инлайн, одно слово в двух
|
||||||
|
местах.
|
||||||
|
3. **Литерал `other` не привязан к спеке** — находка №3, инлайн, одна строка.
|
||||||
|
4. **Остаток «хвост расширения остаётся в журнале» задачей не заведён.** Записан
|
||||||
|
прозой в `docs/security.md` и в Risks `design.md`. Не починено **намеренно**:
|
||||||
|
заведение задач принадлежит скиллу задач, а не конвейеру ревью. Идёт урожаем
|
||||||
|
(Promote candidates, пункт 4).
|
||||||
|
5. **Конвенции о метриках нет** — урожай, Promote candidates, пункт 3. Не
|
||||||
|
находка об этом коде.
|
||||||
|
6. **Расширение в имени файла на диске не нормализуется** — решение принято
|
||||||
|
человеком на контрольной точке и записано отвергнутым вариантом в
|
||||||
|
`design.md`. Раскладка `data/files` объявлена необратимой в `CLAUDE.md`.
|
||||||
|
Закрыто как решение, не как пробел.
|
||||||
|
7. **Сценарий «Известное расширение идёт как есть» назван неточно** — тело
|
||||||
|
сценария (`sample.MP3` → `mp3`) описывает приведение регистра, заголовок
|
||||||
|
говорит «как есть». Выброшено по отсеву вкусовщины: поведения не меняет, на
|
||||||
|
стоимость следующей правки не влияет, записанной конвенции не нарушает —
|
||||||
|
текст требования выше разночтение снимает дословно.
|
||||||
|
|
||||||
|
Потолок первых двух секций **не срабатывал**: 1 из 3 и 2 из 4. За срезом не
|
||||||
|
осталось ничего.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Границы покрытия
|
||||||
|
|
||||||
|
### План
|
||||||
|
|
||||||
|
Шесть тем, у каждой есть дом и глубина; все шесть перечислены в таблице выше с
|
||||||
|
исходом по каждому кругу. Тем без дома нет. Своих тем проекта сверх ядра нет.
|
||||||
|
**Тема без отчёта одна: `autotests` на втором круге** — проход не запускался,
|
||||||
|
дом темы (`CLAUDE.md`, «Гейт» и «Команды») второй круг не открывал никто, а
|
||||||
|
добавленный кругом код — это на 100% тестовый код.
|
||||||
|
|
||||||
|
### Проходы
|
||||||
|
|
||||||
|
- **Круг 1**, метка `medium`, режим «по графу»: `review-autotests`,
|
||||||
|
`review-specs`, `review-code`, `review-basics` (темы `security`, `operations`,
|
||||||
|
`architecture`, глубина разбор), `review-triage`.
|
||||||
|
- **Круг 2**, та же метка и режим: `review-specs`, `review-code`,
|
||||||
|
`review-basics`, `review-triage`.
|
||||||
|
- **Не запускались:** `review-autotests` на втором круге — причина мне не
|
||||||
|
сообщена (в задании сказано «`specs`, `code`, `basics` прогнаны заново», без
|
||||||
|
обоснования пропуска). Проходы ревью дизайна (`specs`, `rubric` на предложении)
|
||||||
|
отработали до кода и в этот прогон не входят.
|
||||||
|
|
||||||
|
### Чего в конвейере нет вовсе
|
||||||
|
|
||||||
|
Четыре строки, которые не принесёт ни один проход:
|
||||||
|
|
||||||
|
1. **Решения проекта не сверялись.** `docs/adr/` — процессный документ, прогон
|
||||||
|
его не открывает. Расхождение изменения с записанным решением ловит сверка
|
||||||
|
документации (`av-dev-docs:healthcheck`), а не ревью. В каталоге лежат четыре
|
||||||
|
ADR, включая `ADR-2026-08-11-stub-adapters-in-tests.md`, — ни один из них
|
||||||
|
этим прогоном не читался как источник требований.
|
||||||
|
2. **Записанные наблюдения проекта не использовались.** `docs/research/` — тоже
|
||||||
|
процессный. Все числа в этом отчёте сняты на этом прогоне, команды приложены.
|
||||||
|
3. **Поимённая сверка с руководством по стилю Go не задавалась ни одним
|
||||||
|
проходом.** Различение «идиоматично против распространено» — например, `map[string]struct{}`
|
||||||
|
против `slices.Contains` в `format_label.go`, или уместность тавтологичного
|
||||||
|
сравнения с константой — не спрашивал никто.
|
||||||
|
4. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера
|
||||||
|
нет.** Проход независимой реализации снят по стоимости, а не по замеру; «не
|
||||||
|
знаю, чего не знаю» никто не достаёт.
|
||||||
|
|
||||||
|
### Чего запущенные проходы не могли проверить в принципе
|
||||||
|
|
||||||
|
- Ни один проход не гонял сервис на настоящих данных: `testdata` в проекте нет
|
||||||
|
по запрету `CLAUDE.md`, реальные ключи Yandex Cloud под запретом, боевую БД и
|
||||||
|
`data/files` трогать нельзя. Все оракулы — синтетический вход и мутации.
|
||||||
|
Для находки про внешний формат это существенно: перечень из 14 расширений
|
||||||
|
собран рассуждением о том, что выдаёт Telegram и что берёт `ffmpeg`, и **ни на
|
||||||
|
одном настоящем файле не проверен**.
|
||||||
|
- `specs` судит код против заказанного поведения и не ищет дефектов вне него;
|
||||||
|
`code` судит технику и конвенции и не судит требования; `basics` идёт по трём
|
||||||
|
темам и только по их записанным домам; `autotests` судит прогон и мутации, а
|
||||||
|
не смысл проверок, — и второй круг не судил вовсе.
|
||||||
|
- Приём из Telegram своей проверки не получил ни на одном круге (объявлено
|
||||||
|
риском в `design.md`).
|
||||||
|
- Шаг конвертации (`FindAndRunConversionJob`) не покрыт ни одним тестом вообще —
|
||||||
|
это записано отдельной задачей `tasks/items/pipeline-step-tests.md`, а не
|
||||||
|
находка этого прогона.
|
||||||
|
- Триаж не читает код в поисках дефектов: пропуск любого прохода — мой пропуск
|
||||||
|
тоже. Пропуск `autotests` на втором круге назван поимённо выше.
|
||||||
|
|
||||||
|
### Что осталось целиком на человеке
|
||||||
|
|
||||||
|
Из `docs/review.md`, «Недоступно проверке», двумя отдельными списками — они не
|
||||||
|
сливаются.
|
||||||
|
|
||||||
|
**Не проверит ни один проход:**
|
||||||
|
|
||||||
|
- `operations`: поведение внешних сервисов под нагрузкой и на границах —
|
||||||
|
SpeechKit и Object Storage поднять в тесте нечем;
|
||||||
|
- `operations`: реальный профиль нагрузки. Проект живёт на единицах записей в
|
||||||
|
день, и утверждения о росте (в том числе о кардинальности метки) остаются
|
||||||
|
условиями, а не замерами;
|
||||||
|
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата
|
||||||
|
отдан внешней программе, и она вне нашей границы.
|
||||||
|
|
||||||
|
**Перестали проверять сознательно:**
|
||||||
|
|
||||||
|
- `autotests`: разбор вывода настоящего `ffprobe`. Проверки приёма получают
|
||||||
|
длительность от подставного источника; своего теста у
|
||||||
|
`adapter/metaviewer/ffmpeg` нет. Решение и его цена —
|
||||||
|
`docs/adr/ADR-2026-08-11-stub-adapters-in-tests.md`.
|
||||||
|
|
||||||
|
**Плюс общее, вне зависимости от проекта:** история инцидентов, поведение под
|
||||||
|
реальным потоком, поведение внешних систем в их версиях, завязка потребителей на
|
||||||
|
текущее поведение и вопрос «а нужна ли эта функциональность вообще». Последнее
|
||||||
|
здесь не пустое: панели и алерты, отобранные по `file_extension=".mp3"`,
|
||||||
|
перестанут пополняться после выкладки, и знает об этом только человек.
|
||||||
|
|
||||||
|
### Каких документов проекта не хватило
|
||||||
|
|
||||||
|
Строка на каждый, с причиной. Слить нельзя — чинится разным.
|
||||||
|
|
||||||
|
- **`CLAUDE.md`, «Ориентир по размеру порции: не замерялся».** Разметка «инлайн
|
||||||
|
против развилки» опирается на right-size, а мерки right-size в проекте нет.
|
||||||
|
Пометки «инлайн» в находках 2 и 3 поставлены по объёму правки (одно слово,
|
||||||
|
одна строка), и это моё предположение, а не сверка с записанным ориентиром.
|
||||||
|
- **Конвенции о метриках в `docs/conventions/` нет** — каталог есть, файла нет.
|
||||||
|
Правила «что можно класть в метку», «кто отвечает за кардинальность», «как
|
||||||
|
объявляется несовместимая смена формы значения» дома не имеют. Отсюда развилка
|
||||||
|
вместо однозначного вердикта в находке №1 и вкусовой характер вопроса о доме
|
||||||
|
записи про `.mp3` → `mp3`.
|
||||||
|
- **`docs/review.md`, «Типовые ложноположительные» — раздел есть и не пуст**,
|
||||||
|
четыре пункта; применён пункт про открытый HTTP API (дважды: к находке №1
|
||||||
|
этого круга и к находке №1 первого). Деградации по нему нет.
|
||||||
|
- Прочие нужные документы на месте и использованы: инварианты `CLAUDE.md`,
|
||||||
|
`docs/review.md` целиком, `docs/security.md`, `docs/conventions/logging.md`,
|
||||||
|
`docs/architecture.md`.
|
||||||
|
|
||||||
|
### Сработавшие потолки
|
||||||
|
|
||||||
|
- **Ни один проход ни на одном круге не сообщил свой потолок** — ни сколько
|
||||||
|
находок показал из скольких, ни что осталось за срезом. Это находка о прогоне,
|
||||||
|
повторившаяся во второй раз: судить, полон ли вход триажа, нечем. «15 находок
|
||||||
|
на входе второго круга» может означать «15 из 15», а может «15 из скольких-то».
|
||||||
|
- **Потолок триажа не срабатывал:** 1 из 3 в «Блокирует мердж», 2 из 4 в «Стоит
|
||||||
|
исправить сейчас». Ничего не выброшено из-за потолка. Выброшенное выброшено по
|
||||||
|
дедупликации (basics #1 и code #3 — одна причина, дубль умолчания; specs #1
|
||||||
|
второго круга и моя находка №1 — одна причина, приведение без оракула на точке
|
||||||
|
употребления) и по отсеву вкусовщины (пункт 7 раздела «Что осталось
|
||||||
|
непочиненным»).
|
||||||
|
|
||||||
|
### Метка `medium`, не `small`
|
||||||
|
|
||||||
|
Строка про `small` к этому прогону неприменима: `basics` отработал на глубине
|
||||||
|
«разбор», дома тем `security`, `operations` и `architecture` открывались на обоих
|
||||||
|
кругах.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Можно ли мержить
|
||||||
|
|
||||||
|
**Кода, который сейчас неверен, я не нашёл ни на одном круге второго прохода.**
|
||||||
|
Приведение стоит на обеих точках, хвост имени отправителя наружу не выходит,
|
||||||
|
инвариант приватности из `CLAUDE.md` соблюдён, гейт красный только объявленным
|
||||||
|
долгом, `openspec validate --strict` — valid.
|
||||||
|
|
||||||
|
**Мержить можно после того, как человек закроет развилку №1** — она про то, чем
|
||||||
|
удержано верное поведение, а не про само поведение. Любой из трёх вариантов
|
||||||
|
развилки делает состояние мерджабельным; вариант (а) — самый дешёвый и требует
|
||||||
|
только переформулировать чек-лист `tasks.md` 2а.2, чтобы он не утверждал того,
|
||||||
|
чего нет.
|
||||||
|
|
||||||
|
Находки №2 и №3 — инлайн, вместе это одно слово в двух местах и одна строка в
|
||||||
|
тесте; мерджу они не мешают, но чинятся дешевле сейчас, чем потом.
|
||||||
|
|
||||||
|
Формулировка «критичных проблем не обнаружено» к этому прогону применима **только
|
||||||
|
вместе с секцией границ покрытия выше**, и главная её строка — `autotests` на
|
||||||
|
втором круге не запускался, а блокирующую находку нашёл я мутацией, а не проход.
|
||||||
|
|
||||||
|
---
|
||||||
|
---
|
||||||
|
|
||||||
|
# Приложение: отчёт первого круга, полностью, как был написан
|
||||||
|
|
||||||
|
> Ниже — текст триажа первого круга без правок. Исходы его находок проставлены
|
||||||
|
> в разделе «Что из починенного починено верно» выше: находка №1 закрыта
|
||||||
|
> решением контрольной точки (приведение метки), находки №2, №3 и №4 починены и
|
||||||
|
> удержаны мутациями.
|
||||||
|
|
||||||
|
## Сводка
|
||||||
|
|
||||||
|
- **Change:** `no-user-filename-in-log`. База диффа `origin/master`, судится
|
||||||
|
рабочее дерево (`git diff`): 4 файла, +172/−15.
|
||||||
|
- **Размер:** среднее. **Сложность:** знакомое. **Метка: medium** — максимум по
|
||||||
|
осям. Разметчик сам назвал спорным неприменение проектного триггера
|
||||||
|
«изменение, трогающее оба входа сразу»: правка лежит в единой точке
|
||||||
|
`createTranscribeJob`, а не отдельной работой по каждому входу. Человек на
|
||||||
|
чекпоинте согласился оставить `medium`.
|
||||||
|
- **Режим прогона:** по графу. Состав ревью кода: `autotests`, `specs`, `code`,
|
||||||
|
`basics`, `triage`.
|
||||||
|
- **Сигнал о заниженной метке:** `review-code` пришёл и метку заниженной **не**
|
||||||
|
считает. От `review-basics` сигнала в переданных мне выводах нет — ни «метка
|
||||||
|
верна», ни «занижена»; отличить «возражений нет» от «не сказано» по молчанию
|
||||||
|
нельзя, и я этого не делаю.
|
||||||
|
- **Гейт:** красный **только объявленным долгом**. Проверено мной самим, не со
|
||||||
|
слов прохода: `go build ./...`, `go vet ./...`, `gofmt -l .`, `go test ./...`
|
||||||
|
зелёные; `golangci-lint run` даёт ровно 4 знакомых замечания
|
||||||
|
(`speechkit.go:55`, `main.go:124`, `worker.go:51`, `transcribe.go:395`).
|
||||||
|
Смещение `394 → 395` внесено самим диффом. Новых красных шагов нет.
|
||||||
|
- **Находок на входе:** 17 (autotests 0 + 1 promote, specs 4, basics 6, code 6).
|
||||||
|
**После дедупликации по причине и отсева:** 4 в первых двух секциях, 3 в
|
||||||
|
гипотезах, 3 кандидата в правила.
|
||||||
|
|
||||||
|
### План разметки против исхода
|
||||||
|
|
||||||
|
| тема | дом | глубина | закрывает | исход |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| requirements | `openspec/specs/intake/spec.md` + дельта change | разбор | specs | **закрыта**, 4 находки |
|
||||||
|
| autotests | `CLAUDE.md`, «Гейт» и «Команды» | — | autotests | **закрыта**, 0 находок + 1 promote |
|
||||||
|
| conventions | `docs/conventions/logging.md` + весь каталог | разбор | code | **закрыта**, 6 находок |
|
||||||
|
| architecture | `docs/architecture.md`, «Единые точки проекта» + `docs/passport.md` | разбор | basics | **закрыта**, ответ по вопросу темы + 1 находка |
|
||||||
|
| security | `docs/security.md`, «Куда уходит содержимое записи», «Из чего строятся пути и ключи» | разбор | basics | **закрыта**, 1 находка (главная) |
|
||||||
|
| operations | `docs/architecture.md`, «Эксплуатация» + `docs/database.md` | разбор | basics | **закрыта**, ответы по вопросам темы |
|
||||||
|
|
||||||
|
Тем без дома нет. Тем без отчёта нет. Своих тем проекта сверх ядра нет.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Блокирует мердж
|
||||||
|
|
||||||
|
### 1. Дельта-спека письменно узаконивает произвольный текст отправителя, а хвост уходит на анонимный `/metrics`
|
||||||
|
|
||||||
|
- **Где:** `openspec/changes/no-user-filename-in-log/specs/intake/spec.md`
|
||||||
|
(«Расширение, взятое из этого имени, запретом не накрыто»);
|
||||||
|
`internal/service/transcribe.go:95` и `:143`; `internal/metrics/metrics.go:24`
|
||||||
|
и `:44`; `main.go:216`.
|
||||||
|
- **Severity:** critical. Инвариант `CLAUDE.md`: «Текст расшифровки, имя файла
|
||||||
|
пользователя и его сообщение в лог не пишутся — только длина и
|
||||||
|
идентификаторы. Нарушение необратимо».
|
||||||
|
- **Причина одна** на три носителя, поэтому это одна находка, а не три: `ext`
|
||||||
|
берётся из недоверенного имени дословно. Её нашли три прохода независимо
|
||||||
|
(`specs` #1, `basics` #1, `code` #1) — приоритет от этого выше, `confidence`
|
||||||
|
нет: под всеми проходами одна модель.
|
||||||
|
- **Оракул (построен и прогнан на этом прогоне):**
|
||||||
|
|
||||||
|
```
|
||||||
|
filepath.Ext("запись.тайное-слово") -> ".тайное-слово"
|
||||||
|
filepath.Ext("Разговор с Петровым 11.08") -> ".08"
|
||||||
|
filepath.Ext("отчёт.для Ивановой") -> ".для Ивановой"
|
||||||
|
```
|
||||||
|
|
||||||
|
Метка Prometheus отдаётся наружу дословно — программа с тем же выражением и
|
||||||
|
тем же `HistogramVec`, что в `metrics.go:24`, на `GET /metrics`:
|
||||||
|
|
||||||
|
```
|
||||||
|
transcriber_input_file_size_bytes_count{file_extension=".тайное-слово"} 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Путь до этой строки достроен по коду: `header.Filename`
|
||||||
|
(`internal/controller/http/transcribe.go:43`) → `CreateJobFromApi` →
|
||||||
|
`createTranscribeJob` → `ext` (`:95`) → `WithLabelValues(ext)` (`:143`);
|
||||||
|
`router.GET("/metrics", gin.WrapH(promhttp.Handler()))` (`main.go:216`) стоит
|
||||||
|
за `sloggin` и `gin.Recovery` и ни за какой проверкой.
|
||||||
|
- **Чего эта находка НЕ утверждает:** «HTTP API открыт без аутентификации» —
|
||||||
|
типовое ложноположительное проекта (`docs/review.md`, «Типовые
|
||||||
|
ложноположительные»), и открытость `/metrics` записана в `docs/security.md`
|
||||||
|
строкой «Метрики и здоровье». Новое здесь не открытость, а то, что на эту
|
||||||
|
открытую поверхность попадает значение, которым распоряжается анонимный
|
||||||
|
отправитель, — и что дельта-спека это разрешает текстом.
|
||||||
|
- **Почему первое место в ранжировании:** ущерб необратим по букве инварианта
|
||||||
|
(строка уехала в собранные логи и в хранилище метрик), а канал шире того, что
|
||||||
|
задача закрыла: журнал читает владелец, `/metrics` — кто угодно из интернета.
|
||||||
|
Тем же концом это неограниченная кардинальность метрики, и множество значений
|
||||||
|
метки задаёт анонимный отправитель — этот исход вероятнее утечки осмысленного
|
||||||
|
слова и вредит эксплуатации.
|
||||||
|
- **Действие: развилка.**
|
||||||
|
|
||||||
|
> Хвост после последней точки в имени отправителя уходит дословно в журнал, в
|
||||||
|
> метку `file_extension` и в метку `source_format` на анонимный `/metrics`.
|
||||||
|
> Дельта-спека сейчас пишет это в канон фразой «расширение запретом не
|
||||||
|
> накрыто». Что делаем до мерджа?
|
||||||
|
>
|
||||||
|
> **(а)** Мержим как есть: остаток записан в модель угроз, нормализация уходит
|
||||||
|
> отдельной задачей. Канон при этом получает разрешающую фразу.
|
||||||
|
> **(б)** Мержим, сузив формулировку требования (например: в журнал и в метку
|
||||||
|
> идёт расширение из списка разрешённых, прочее заменяется на `.bin`), и в этой
|
||||||
|
> же задаче нормализуем **только значение метки метрики**. Имя файла на диске
|
||||||
|
> не трогается, раскладка `data/files` не меняется, правка локальна.
|
||||||
|
> **(в)** Нормализуем `ext` целиком в `createTranscribeJob`. Тогда меняется
|
||||||
|
> формат имени файла на диске — `CLAUDE.md` называет это необратимым и
|
||||||
|
> требующим отдельного решения человека, то есть возврата на чекпоинт.
|
||||||
|
|
||||||
|
### 2. Третий журнальный поток в оракуле ничем не удержан: снятие middleware не роняет ни одной проверки
|
||||||
|
|
||||||
|
- **Где:** `internal/controller/http/transcribe_test.go`, `setupTestEnv` и
|
||||||
|
`TestCreateTranscribeJob_SenderFileNameNotLogged`.
|
||||||
|
- **Severity:** major. Класс тот же, что проход `code` нашёл для стандартного
|
||||||
|
`log` и что уже починено; для потока middleware дефект остался.
|
||||||
|
- **Оракул (мутация на копии дерева, прогнана):** удаление
|
||||||
|
`router.Use(sloggin.New(logger))` из тестового роутера —
|
||||||
|
`ok git.vakhrushev.me/av/transcriber/internal/controller/http`, зелено. Для
|
||||||
|
сравнения, три другие мутации краснеют как заявлено: снятие `log.SetOutput`
|
||||||
|
роняет `…NotLoggedOnFailure`; имя под ключом `upload` роняет обе проверки
|
||||||
|
запрета; `Info → Debug` на строке приёма роняет `…JournalTracesRecord`.
|
||||||
|
- **Почему это важно, а не педантизм:** `design.md` включил middleware в
|
||||||
|
тестовый роутер ровно затем, чтобы квантор требования («ни одна журнальная
|
||||||
|
запись приёма») совпал с оракулом. Сегодня совпадение держится ничем: любой,
|
||||||
|
кто уберёт строку как лишнюю, сузит оракул критического инварианта молча.
|
||||||
|
Это записанное свойство проекта — `docs/review.md`, «Типовые узлы / Любой
|
||||||
|
узел»: «проверка способна упасть», и там же журнальная запись 2026-08-11 об
|
||||||
|
этом же классе.
|
||||||
|
- **Что удержит:** в перехваченном журнале есть строка middleware, вот она:
|
||||||
|
`msg="Incoming request" … request.path=/api/audio … response.status=201`.
|
||||||
|
Достаточно `require.Contains(journal, "Incoming request")` в проверке
|
||||||
|
успешного пути — рядом с уже стоящим `require.Contains(journal, "Err:")` в
|
||||||
|
проверке отказа.
|
||||||
|
- **Действие: инлайн.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Стоит исправить сейчас
|
||||||
|
|
||||||
|
### 3. Модель угроз называет метку `src_ext`, которой не существует
|
||||||
|
|
||||||
|
- **Где:** `docs/security.md:200` — «`file_extension` у размера принятой записи и
|
||||||
|
`src_ext` у длительности конвертации».
|
||||||
|
- **Оракул:** `internal/metrics/metrics.go:44` — метки
|
||||||
|
`{"source_format", "target_format", "error"}`. `grep -rn "src_ext"` по коду
|
||||||
|
отдаёт единственное совпадение: локальную переменную Go в
|
||||||
|
`internal/service/transcribe.go:194`. Журнальное поле рядом называется
|
||||||
|
`src_format`. Метки `src_ext` нет ни в одной метрике.
|
||||||
|
- **Почему сейчас:** эта строка — единственный носитель остатка в будущее.
|
||||||
|
Задача про нормализацию будет искать по имени метки и не найдёт её.
|
||||||
|
`file_extension` назван верно, ошибка ровно в одном слове.
|
||||||
|
- **Действие: инлайн.** Заменить `src_ext` на `source_format`.
|
||||||
|
|
||||||
|
### 4. Критерий рубрики «ровно одна запись приёма» оракулом не закрыт
|
||||||
|
|
||||||
|
- **Где:** `openspec/changes/no-user-filename-in-log/tasks.md`, критерий «в
|
||||||
|
буфере ровно одна запись приёма на принятую запись, её уровень `INFO`»;
|
||||||
|
проверка `TestCreateTranscribeJob_JournalTracesRecord`.
|
||||||
|
- **Что есть:** уровень теперь привязан к строке самого приёма и мутацией
|
||||||
|
проверен (`Info → Debug` краснеет). Числа записей не проверяет ничто.
|
||||||
|
- **Почему сейчас, а не в гипотезы:** это не догадка, а разрыв между отмеченным
|
||||||
|
как выполненный критерием и оракулом; закрывается одной строкой
|
||||||
|
`strings.Count(journal, "Creating transcribe job") == 1`. Заодно это сторож на
|
||||||
|
вторую строку приёма, в которую имя вернётся мимо нынешних проверок.
|
||||||
|
- **Действие: инлайн.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Гипотезы без доказательства
|
||||||
|
|
||||||
|
- **Запрет `t.Parallel()` держится комментарием.** `log.SetOutput` процессный, и
|
||||||
|
ничто, кроме текста рядом, не мешает следующей проверке в этом файле стать
|
||||||
|
параллельной; тогда перехват уедет к соседу. Оракула нет: воспроизвести это
|
||||||
|
можно только внесением `t.Parallel()`, то есть той самой будущей правкой.
|
||||||
|
`Confidence: medium`, severity не выше `minor`.
|
||||||
|
- **Позитивное утверждение `require.Contains(journal, "Err:")` привязано к
|
||||||
|
расхождению, которое конвенция объявляет подлежащим устранению** (`log.Printf`
|
||||||
|
в HTTP-транспорте мимо `slog`). Когда расхождение починят, проверка отказа
|
||||||
|
покраснеет по причине, не связанной с приватностью. Оракула нет — событие в
|
||||||
|
будущем. `minor`.
|
||||||
|
- **Кардинальность метки `file_extension` не замерена.** Утверждение «число
|
||||||
|
временных рядов растёт неограниченно» верно по построению, но роста никто не
|
||||||
|
мерил, а `docs/review.md` прямо велит не считать неизмеренный рост новой
|
||||||
|
находкой (строка про «файлы и объекты не удаляются»). Вес — только внутри
|
||||||
|
развилки №1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Promote candidates
|
||||||
|
|
||||||
|
- **Правило линтера на ключи `file_name`/`filename` в вызовах логгера**
|
||||||
|
(`sloglint` либо `forbidigo`). Пришло от `autotests`; `design.md` объявил это
|
||||||
|
non-goal задачи. Претензия на правило проекта, а не на этот код.
|
||||||
|
- **Свойство в `docs/review.md`, «Типовые узлы / Любой узел»:** каждый
|
||||||
|
перехваченный в оракуле поток журнала удерживается **своим** положительным
|
||||||
|
утверждением, иначе оракул сужается молча. Обобщение находки №2 и уже
|
||||||
|
починенной находки прохода `code`.
|
||||||
|
- **Нормализация расширения, взятого из имени отправителя** — отдельной задачей
|
||||||
|
в урожай (список разрешённых расширений, `.bin` для прочего), с явным решением
|
||||||
|
про имя файла на диске. Исход зависит от развилки №1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что из уже починенного починено недостаточно
|
||||||
|
|
||||||
|
Проверено мутациями на копии дерева, не со слов.
|
||||||
|
|
||||||
|
- **Починено верно:** снятие `log.SetOutput` роняет проверку отказа; имя под
|
||||||
|
чужим ключом роняет обе проверки запрета; `Info → Debug` роняет проверку
|
||||||
|
прослеживаемости; утверждения о размере и уровне переписаны так, что
|
||||||
|
переживают смену обработчика на JSON (`size["=:\s]+%d` и
|
||||||
|
`level["=:\s]+INFO` разбирают и `size=33`, и `"size":33`); мёртвый
|
||||||
|
`log.SetFlags` снят; тестовый роутер собирается той же цепочкой, что боевой;
|
||||||
|
второй дом состава журнальной строки из `docs/conventions/logging.md` убран,
|
||||||
|
строка «*Расхождение:*» на месте.
|
||||||
|
- **Недостаточно — находка №2:** middleware в роутер добавлен, но не удержан;
|
||||||
|
фикс закрыл поток `log` и не закрыл поток, ради которого роутер и меняли.
|
||||||
|
- **Недостаточно — находка №3:** переписанный остаток в `docs/security.md`
|
||||||
|
называет несуществующую метку `src_ext`.
|
||||||
|
- **Недостаточно — находка №4:** критерий «ровно одна запись приёма» отмечен
|
||||||
|
выполненным, оракула у него нет.
|
||||||
|
- **Ослаблений не найдено:** ни одна прежняя проверка не стала слабее, `go test
|
||||||
|
./...` зелёный, новых замечаний линтера нет.
|
||||||
|
|
||||||
|
Отдельно подтверждаю утверждения, на которых стоит `design.md`, — проверял сам:
|
||||||
|
приём из Telegram имени, данного человеком, до сервиса не доводит
|
||||||
|
(`internal/controller/tg/tg.go:276` берёт `file.FilePath`, выданный Telegram;
|
||||||
|
`document.FileName` дальше проверки типа в `isAudioDocument` не идёт), а ключ
|
||||||
|
объекта в Object Storage строится после конвертации и всегда имеет вид
|
||||||
|
`идентификатор.ogg` (`internal/service/transcribe.go:190`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Границы покрытия
|
||||||
|
|
||||||
|
### План
|
||||||
|
|
||||||
|
Шесть тем, у каждой есть дом и глубина, все шесть перечислены в сводке выше с
|
||||||
|
исходом. Тем без дома нет, тем без отчёта нет. Своих тем проекта сверх ядра нет.
|
||||||
|
|
||||||
|
### Проходы
|
||||||
|
|
||||||
|
- **Запускались** на метке `medium`, режим «по графу»: `review-autotests`,
|
||||||
|
`review-specs` (код против спек), `review-code` (техника и конвенции, глубина
|
||||||
|
разбор), `review-basics` (темы `security`, `operations`, `architecture`,
|
||||||
|
глубина разбор), `review-triage`.
|
||||||
|
- **Не запускались:** проходы ревью дизайна (`specs`, `rubric`) — они
|
||||||
|
отработали до кода, на этапе предложения, и в этот прогон не входят.
|
||||||
|
- **Чего в конвейере нет вовсе** — четыре строки, которые не принесёт ни один
|
||||||
|
проход:
|
||||||
|
1. **Решения проекта не сверялись.** `docs/adr/` — процессный документ, прогон
|
||||||
|
его не открывает. Расхождение изменения с записанным решением ловит сверка
|
||||||
|
документации (`av-dev-docs:healthcheck`), а не ревью.
|
||||||
|
2. **Записанные наблюдения проекта не использовались.** `docs/research/` —
|
||||||
|
тоже процессный. Все числа в этом отчёте сняты на этом прогоне, команды
|
||||||
|
приложены.
|
||||||
|
3. **Поимённая сверка с руководством по стилю Go не задавалась ни одним
|
||||||
|
проходом.** Различение «идиоматично против распространено» не спрашивал
|
||||||
|
никто.
|
||||||
|
4. **Альтернативной реализации, с которой можно сдиффить решения, у конвейера
|
||||||
|
нет.** Проход независимой реализации снят по стоимости; «не знаю, чего не
|
||||||
|
знаю» никто не достаёт.
|
||||||
|
|
||||||
|
### Чего запущенные проходы не могли проверить в принципе
|
||||||
|
|
||||||
|
- Ни один проход не гонял сервис на настоящих данных: `testdata` в проекте нет
|
||||||
|
по запрету `CLAUDE.md`, реальные ключи Yandex Cloud под запретом, боевую БД и
|
||||||
|
`data/files` трогать нельзя. Все оракулы — синтетический вход и мутации.
|
||||||
|
- `autotests` судит прогон и мутации, а не смысл проверок; `specs` судит код
|
||||||
|
против заказанного поведения и не ищет дефектов вне него; `code` судит технику
|
||||||
|
и конвенции и не судит требования; `basics` идёт по трём темам и только по их
|
||||||
|
записанным домам.
|
||||||
|
- Приём из Telegram своей проверки не получил вовсе (объявлено риском в
|
||||||
|
`design.md`): правка достаётся ему общим шагом кода, но оракула на него нет.
|
||||||
|
- Триаж не читает код в поисках дефектов: пропуск любого прохода — мой пропуск
|
||||||
|
тоже.
|
||||||
|
|
||||||
|
### Что осталось целиком на человеке
|
||||||
|
|
||||||
|
Из `docs/review.md`, «Недоступно проверке», двумя списками — они не сливаются.
|
||||||
|
|
||||||
|
**Не проверит ни один проход:**
|
||||||
|
|
||||||
|
- `operations`: поведение SpeechKit и Object Storage под нагрузкой и на границах
|
||||||
|
— поднять их в тесте нечем;
|
||||||
|
- `operations`: реальный профиль нагрузки; проект живёт на единицах записей в
|
||||||
|
день, и утверждения о росте остаются условиями;
|
||||||
|
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата
|
||||||
|
отдан внешней программе.
|
||||||
|
|
||||||
|
**Перестали проверять сознательно:**
|
||||||
|
|
||||||
|
- `autotests`: разбор вывода настоящего `ffprobe`. Проверки приёма получают
|
||||||
|
длительность от подставного источника; своего теста у
|
||||||
|
`adapter/metaviewer/ffmpeg` нет. Решение и его цена —
|
||||||
|
`docs/adr/ADR-2026-08-11-stub-adapters-in-tests.md`.
|
||||||
|
|
||||||
|
**Плюс общее, вне зависимости от проекта:** история инцидентов, поведение под
|
||||||
|
реальным потоком, поведение внешних систем в их версиях, завязка потребителей на
|
||||||
|
текущее поведение и вопрос «а нужна ли эта функциональность вообще».
|
||||||
|
|
||||||
|
### Каких документов проекта не хватило
|
||||||
|
|
||||||
|
- **`CLAUDE.md`, «Ориентир по размеру порции: не замерялся».** Разметка
|
||||||
|
«инлайн против развилки» опирается на right-size, а мерки right-size в проекте
|
||||||
|
нет. Пометки «инлайн» в находках 2–4 поставлены по объёму правки (одна-две
|
||||||
|
строки), и это моё предположение, а не сверка с записанным ориентиром.
|
||||||
|
- **Дома у вопроса «что можно класть в метку метрики» нет.** `docs/security.md`
|
||||||
|
описывает открытость `/metrics`, но правила состава меток нет ни в модели
|
||||||
|
угроз, ни в конвенциях; отсюда развилка вместо однозначного вердикта в находке
|
||||||
|
№1.
|
||||||
|
- Прочие нужные документы на месте и использованы: инварианты `CLAUDE.md`,
|
||||||
|
`docs/review.md` (включая «Типовые ложноположительные» — применён пункт про
|
||||||
|
открытый HTTP API), `docs/security.md`, `docs/conventions/logging.md`.
|
||||||
|
Деградации по ним нет.
|
||||||
|
|
||||||
|
### Сработавшие потолки
|
||||||
|
|
||||||
|
- **Ни один из четырёх проходов не сообщил свой потолок** — ни сколько находок
|
||||||
|
показал из скольких, ни что осталось за срезом. Это находка о прогоне: судить,
|
||||||
|
полон ли вход триажа, нечем, и «на входе 17 находок» может означать «17 из
|
||||||
|
17», а может «17 из скольких-то».
|
||||||
|
- **Потолок триажа сработал мягко:** 2 из 3 в «Блокирует мердж» и 2 из 4 в
|
||||||
|
«Стоит исправить сейчас». Ничего не выброшено из-за потолка; выброшенное
|
||||||
|
выброшено по дедупликации (одна причина на три носителя в находке №1, один
|
||||||
|
класс на четыре формулировки в находке №2) и по отсеву — снятый `log.SetFlags`
|
||||||
|
и второй дом в конвенции уже починены, а «изоляция буфера держится не тем, чем
|
||||||
|
заявлено» после правки сведено к комментарию и уехало в гипотезы.
|
||||||
|
|
||||||
|
Формулировка «критичных проблем не обнаружено» к этому прогону неприменима:
|
||||||
|
критичная проблема обнаружена и стоит развилкой №1.
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Имя файла, данное отправителем, не попадает в журнал
|
||||||
|
|
||||||
|
Приём SHALL не писать имя файла, данное отправителем, ни в одну свою журнальную
|
||||||
|
запись — ни на успешном пути, ни на пути отказа, где имя могло бы приехать
|
||||||
|
текстом ошибки. Имя приходит извне вместе с записью и принадлежит содержимому
|
||||||
|
личной переписки наравне с текстом расшифровки; журнал уезжает в собранные логи,
|
||||||
|
откуда строку не убрать.
|
||||||
|
|
||||||
|
Расширение, взятое из этого имени, в журнале остаётся: оно стоит в собственном
|
||||||
|
имени файла на диске, и по нему прослеживается путь записи. Что именно попадает в
|
||||||
|
журнал ради прослеживаемости, нормирует требование ниже; наружу расширение
|
||||||
|
выходит только приведённым к известному виду — этому отдано третье требование.
|
||||||
|
|
||||||
|
Сценарии судят приём по HTTP, потому что имя, данное отправителем, доходит до
|
||||||
|
сервиса только оттуда: из Telegram приходит путь, выданный самим Telegram, а не
|
||||||
|
имя человека. Правка при этом ложится на общий шаг заведения задачи, через
|
||||||
|
который идут оба входа, поэтому своей нормы приём из Telegram здесь не получает —
|
||||||
|
её напишет задача, которая тронет его поведение.
|
||||||
|
|
||||||
|
#### Scenario: Имя записи не видно в журнале принятой записи
|
||||||
|
|
||||||
|
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||||
|
- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт
|
||||||
|
опознаваемую строку при обычном расширении `.mp3`
|
||||||
|
- **THEN** ни одна журнальная запись приёма этой строки не содержит
|
||||||
|
- **AND** расширение `.mp3` в журнале допустимо
|
||||||
|
|
||||||
|
#### Scenario: Имя записи не видно в журнале при отказе приёма
|
||||||
|
|
||||||
|
- **GIVEN** источник метаданных не может прочитать запись
|
||||||
|
- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт
|
||||||
|
опознаваемую строку
|
||||||
|
- **THEN** ни одна журнальная запись приёма, включая запись об ошибке, этой
|
||||||
|
строки не содержит
|
||||||
|
|
||||||
|
### Requirement: Журнал приёма прослеживает запись
|
||||||
|
|
||||||
|
Приём SHALL писать в журнал идентификатор заведённого файла, расширение принятой
|
||||||
|
записи и её размер в байтах. По ним путь записи собирается отбором по журналу, и
|
||||||
|
удаление имени отправителя прослеживаемости не отнимает.
|
||||||
|
|
||||||
|
Расширение засчитывается присутствием собственного имени файла в хранилище:
|
||||||
|
отдельного поля под него приём не заводит.
|
||||||
|
|
||||||
|
#### Scenario: Идентификатор, расширение и размер на месте
|
||||||
|
|
||||||
|
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||||
|
- **WHEN** программа шлёт `POST /api/audio` с записью
|
||||||
|
- **THEN** журнал приёма несёт идентификатор заведённого файла, расширение
|
||||||
|
принятой записи и её размер в байтах
|
||||||
|
|
||||||
|
### Requirement: Метка метрики несёт только известное расширение
|
||||||
|
|
||||||
|
Сервис SHALL приводить расширение принятой записи к известному виду прежде, чем
|
||||||
|
употребить его меткой метрики: расширение приводится к нижнему регистру и
|
||||||
|
сверяется с закрытым перечнем; совпавшее идёт приведённым, всякое другое MUST
|
||||||
|
заменяться единым значением `other`. Перечень — `mp3`, `wav`, `ogg`, `oga`,
|
||||||
|
`opus`, `flac`, `m4a`, `aac`, `wma`, `mp4`, `mkv`, `mov`, `avi`, `webm`, плюс
|
||||||
|
`audio`: последнее не формат, а собственное умолчание сервиса на случай имени
|
||||||
|
без расширения, и различать его от чужого хвоста метка обязана.
|
||||||
|
|
||||||
|
Страница метрик отдаётся без проверки отправителя, поэтому метка — поверхность
|
||||||
|
пошире журнала: её читает кто угодно. Тем же ограничением снимается и рост числа
|
||||||
|
временных рядов, которым иначе распоряжается анонимный отправитель.
|
||||||
|
|
||||||
|
Требование намеренно шире приёма: под него подпадает и метка шага конвертации.
|
||||||
|
Когда конвертацию нормируют своей capability, обязанность переезжает туда вместе
|
||||||
|
с ней.
|
||||||
|
|
||||||
|
Имя файла на диске это требование не трогает: там расширение остаётся тем, каким
|
||||||
|
пришло, — это уже нормировано требованием «Имя файла в хранилище».
|
||||||
|
|
||||||
|
Настоящий формат записи, попавшей в `other`, остаётся видимым в журнале: значение
|
||||||
|
`other` в метке означает «расширение не из перечня», а само оно стоит в поле
|
||||||
|
пути журнальной строки приёма и в поле формата строки конвертации.
|
||||||
|
|
||||||
|
#### Scenario: Незнакомое расширение наружу не выходит
|
||||||
|
|
||||||
|
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||||
|
- **WHEN** программа шлёт запись с именем, чей хвост после последней точки не
|
||||||
|
принадлежит перечню known-форматов
|
||||||
|
- **THEN** метка метрики принимает значение `other`
|
||||||
|
- **AND** файл в каталоге хранения сохраняет пришедшее расширение
|
||||||
|
|
||||||
|
#### Scenario: Известное расширение идёт как есть
|
||||||
|
|
||||||
|
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||||
|
- **WHEN** программа шлёт запись с именем `sample.MP3`
|
||||||
|
- **THEN** метка метрики принимает значение `mp3`
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
## 1. Правка журнала приёма
|
||||||
|
|
||||||
|
- [x] 1.1 Убрать поле с именем, данным отправителем, из журнальной строки общего
|
||||||
|
шага заведения задачи (`internal/service/transcribe.go`,
|
||||||
|
`createTranscribeJob`). Идентификатор файла и путь к нему в хранилище остаются,
|
||||||
|
уровень строки остаётся `INFO`.
|
||||||
|
- [x] 1.2 Проверить остаток поиском по **значению**: прогон приёма с маркером в
|
||||||
|
имени, поиск маркера по всему выводу прогона — ничего не найдено. Поиск по
|
||||||
|
имени поля `file_name` остатком не считается.
|
||||||
|
|
||||||
|
## 2. Проверка
|
||||||
|
|
||||||
|
- [x] 2.1 Окружение проверок приёма по HTTP отдаёт журнал в буфер вместо
|
||||||
|
`io.Discard`. Буфер свой на каждый случай, а цепочка журнала воспроизводит
|
||||||
|
боевую: окружение зовёт `slog.SetDefault` и собирает роутер тем же набором
|
||||||
|
middleware, что `main.go`, — иначе два потока из трёх остаются вне оракула.
|
||||||
|
Вывод прогона от этого не меняется.
|
||||||
|
- [x] 2.7 У каждого из трёх потоков журнала своё удерживающее утверждение:
|
||||||
|
снятие потока из окружения роняет проверку, а не проходит молча.
|
||||||
|
- [x] 2.2 Проверка успешного приёма: имя записи несёт маркер — уникальную
|
||||||
|
ASCII-строку, которой нет в остальном выводе, — при обычном расширении `.mp3`.
|
||||||
|
В перехваченном журнале маркера нет.
|
||||||
|
- [x] 2.3 Проверка отказного приёма: источник метаданных не читает запись, имя
|
||||||
|
несёт тот же маркер. В перехваченном журнале, включая запись об ошибке,
|
||||||
|
маркера нет.
|
||||||
|
- [x] 2.4 Проверка прослеживаемости: в журнале есть идентификатор заведённого
|
||||||
|
файла, расширение принятой записи и её размер в байтах. Утверждение отбирается
|
||||||
|
по идентификатору **этого** прогона.
|
||||||
|
- [x] 2.5 Мутация: вернуть имя в журнальную строку под **другим** ключом —
|
||||||
|
проверки 2.2 и 2.3 краснеют; снять мутацию — зеленеют.
|
||||||
|
- [x] 2.6 `go test -race -count=5 ./internal/controller/http/` зелёный.
|
||||||
|
|
||||||
|
## 2а. Метка метрики (добавлено чекпоинтом после ревью кода)
|
||||||
|
|
||||||
|
- [x] 2а.1 Расширение приводится к закрытому перечню известных форматов прежде,
|
||||||
|
чем уйти меткой метрики; всё прочее — `other`. Имя файла на диске не трогается.
|
||||||
|
- [x] 2а.2 Обе метки, несущие расширение, идут через приведение: размер принятой
|
||||||
|
записи и длительность конвертации. Сырой точки употребления гистограммы в
|
||||||
|
сервисе не остаётся — приведение живёт внутри обёрток пакета метрик, и обойти
|
||||||
|
его можно только заведя новую точку.
|
||||||
|
- [x] 2а.4 Обе обёртки судятся по реестру метрик, а не по чистой функции:
|
||||||
|
снятие приведения в любой из них роняет проверку.
|
||||||
|
- [x] 2а.3 Проверка приведения: известное расширение с точкой и без, смена
|
||||||
|
регистра, умолчание сервиса, хвост имени отправителя, часть даты, пустое.
|
||||||
|
|
||||||
|
## 3. Гейт и документы
|
||||||
|
|
||||||
|
- [x] 3.1 `task gate` зелёный сверх объявленного долга (4 замечания
|
||||||
|
`golangci-lint` в существующем коде).
|
||||||
|
- [x] 3.2 `docs/security.md`: строка «Имя файла, данное отправителем, пишется»
|
||||||
|
переписана остатком — имя из журнала приёма убрано, хвост после последней
|
||||||
|
точки продолжает попадать в журнал внутри пути файла в хранилище.
|
||||||
|
- [x] 3.3 Решить, нужна ли строка в `docs/conventions/logging.md` о том, чем
|
||||||
|
заменено имя, и либо дописать её, либо назвать причину отказа.
|
||||||
|
|
||||||
|
## Критерии приёмки
|
||||||
|
|
||||||
|
### Из записи задачи `no-user-filename-in-log`, дословно
|
||||||
|
|
||||||
|
- Имени, данного отправителем, нет ни в одной журнальной строке приёма. Оракул —
|
||||||
|
прогон приёма с записью, чьё имя содержит опознаваемую строку, и `grep` этой
|
||||||
|
строки по перехваченному журналу: ничего не найдено.
|
||||||
|
- Идентификатор файла, его расширение и длина в журнале остаются: по ним путь
|
||||||
|
записи прослеживается. Оракул — тот же перехваченный журнал, `file_id` и
|
||||||
|
`size` на месте.
|
||||||
|
|
||||||
|
### Рубрика ревью дизайна
|
||||||
|
|
||||||
|
- Запрещённое значение не появляется ни в одном поле и ни в одном `msg` записи о
|
||||||
|
приёме, включая ветку отказа. Оракул — прогон успеха и прогон отказа с
|
||||||
|
маркером в имени, поиск маркера по перехваченному журналу пуст в обоих.
|
||||||
|
- Оракул перехватывает весь журнальный поток приёма, а не один обработчик.
|
||||||
|
Оракул — мутация: вернуть имя в обход `slog`, проверка краснеет.
|
||||||
|
- Проверка способна упасть. Оракул — мутация с **другим** ключом поля роняет
|
||||||
|
проверку; проверка ищет значение, а не имя ключа.
|
||||||
|
- Маркер уникален и записан ASCII, поиск идёт по сырому тексту буфера. Оракул —
|
||||||
|
при возвращённом поле утечка находится, несмотря на экранирование обработчиком.
|
||||||
|
- Буфер журнала свой на случай, утверждение о полях отбирается по идентификатору
|
||||||
|
этого прогона. Оракул — `go test -race -count=5 ./internal/controller/http/`
|
||||||
|
зелёный.
|
||||||
|
- Запись о приёме не исчезает и не меняет адресата: уровень остаётся `INFO`,
|
||||||
|
категория `msg` прежняя. Оракул — в буфере ровно одна запись приёма на
|
||||||
|
принятую запись, её уровень `INFO`.
|
||||||
|
- Прослеживаемость названа полями поимённо, с единицей у числового: размер — в
|
||||||
|
байтах. Оракул — критерий приёмки называет те же ключи, что и требование.
|
||||||
|
- Косвенные носители имени названы поимённо и каждый закрыт либо назван
|
||||||
|
остатком: текст ошибки — закрыт проверкой 2.3, путь на диске и ключ объекта
|
||||||
|
строятся из идентификатора и расширения, расширение — остаток строкой в
|
||||||
|
`docs/security.md`.
|
||||||
@@ -78,6 +78,96 @@ MUST нести идентификатор задачи полем `job_id` и
|
|||||||
- **THEN** ответ имеет код `500`
|
- **THEN** ответ имеет код `500`
|
||||||
- **AND** задача расшифровки не заводится
|
- **AND** задача расшифровки не заводится
|
||||||
|
|
||||||
|
### Requirement: Имя файла, данное отправителем, не попадает в журнал
|
||||||
|
|
||||||
|
Приём SHALL не писать имя файла, данное отправителем, ни в одну свою журнальную
|
||||||
|
запись — ни на успешном пути, ни на пути отказа, где имя могло бы приехать
|
||||||
|
текстом ошибки. Имя приходит извне вместе с записью и принадлежит содержимому
|
||||||
|
личной переписки наравне с текстом расшифровки; журнал уезжает в собранные логи,
|
||||||
|
откуда строку не убрать.
|
||||||
|
|
||||||
|
Расширение, взятое из этого имени, в журнале остаётся: оно стоит в собственном
|
||||||
|
имени файла на диске, и по нему прослеживается путь записи. Что именно попадает в
|
||||||
|
журнал ради прослеживаемости, нормирует требование ниже; наружу расширение
|
||||||
|
выходит только приведённым к известному виду — этому отдано отдельное требование.
|
||||||
|
|
||||||
|
Сценарии судят приём по HTTP, потому что имя, данное отправителем, доходит до
|
||||||
|
сервиса только оттуда: из Telegram приходит путь, выданный самим Telegram, а не
|
||||||
|
имя человека. Правка при этом ложится на общий шаг заведения задачи, через
|
||||||
|
который идут оба входа, поэтому своей нормы приём из Telegram здесь не получает —
|
||||||
|
её напишет задача, которая тронет его поведение.
|
||||||
|
|
||||||
|
#### Scenario: Имя записи не видно в журнале принятой записи
|
||||||
|
|
||||||
|
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||||
|
- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт
|
||||||
|
опознаваемую строку при обычном расширении `.mp3`
|
||||||
|
- **THEN** ни одна журнальная запись приёма этой строки не содержит
|
||||||
|
- **AND** расширение `.mp3` в журнале допустимо
|
||||||
|
|
||||||
|
#### Scenario: Имя записи не видно в журнале при отказе приёма
|
||||||
|
|
||||||
|
- **GIVEN** источник метаданных не может прочитать запись
|
||||||
|
- **WHEN** программа шлёт `POST /api/audio` с записью, чья основа имени несёт
|
||||||
|
опознаваемую строку
|
||||||
|
- **THEN** ни одна журнальная запись приёма, включая запись об ошибке, этой
|
||||||
|
строки не содержит
|
||||||
|
|
||||||
|
### Requirement: Журнал приёма прослеживает запись
|
||||||
|
|
||||||
|
Приём SHALL писать в журнал идентификатор заведённого файла, расширение принятой
|
||||||
|
записи и её размер в байтах. По ним путь записи собирается отбором по журналу, и
|
||||||
|
удаление имени отправителя прослеживаемости не отнимает.
|
||||||
|
|
||||||
|
Расширение засчитывается присутствием собственного имени файла в хранилище:
|
||||||
|
отдельного поля под него приём не заводит.
|
||||||
|
|
||||||
|
#### Scenario: Идентификатор, расширение и размер на месте
|
||||||
|
|
||||||
|
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||||
|
- **WHEN** программа шлёт `POST /api/audio` с записью
|
||||||
|
- **THEN** журнал приёма несёт идентификатор заведённого файла, расширение
|
||||||
|
принятой записи и её размер в байтах
|
||||||
|
|
||||||
|
### Requirement: Метка метрики несёт только известное расширение
|
||||||
|
|
||||||
|
Сервис SHALL приводить расширение принятой записи к известному виду прежде, чем
|
||||||
|
употребить его меткой метрики: расширение приводится к нижнему регистру и
|
||||||
|
сверяется с закрытым перечнем; совпавшее идёт приведённым, всякое другое MUST
|
||||||
|
заменяться единым значением `other`. Перечень — `mp3`, `wav`, `ogg`, `oga`,
|
||||||
|
`opus`, `flac`, `m4a`, `aac`, `wma`, `mp4`, `mkv`, `mov`, `avi`, `webm`, плюс
|
||||||
|
`audio`: последнее не формат, а собственное умолчание сервиса на случай имени
|
||||||
|
без расширения, и различать его от чужого хвоста метка обязана.
|
||||||
|
|
||||||
|
Страница метрик отдаётся без проверки отправителя, поэтому метка — поверхность
|
||||||
|
пошире журнала: её читает кто угодно. Тем же ограничением снимается и рост числа
|
||||||
|
временных рядов, которым иначе распоряжается анонимный отправитель.
|
||||||
|
|
||||||
|
Требование намеренно шире приёма: под него подпадает и метка шага конвертации.
|
||||||
|
Когда конвертацию нормируют своей capability, обязанность переезжает туда вместе
|
||||||
|
с ней.
|
||||||
|
|
||||||
|
Имя файла на диске это требование не трогает: там расширение остаётся тем, каким
|
||||||
|
пришло, — это уже нормировано требованием «Имя файла в хранилище».
|
||||||
|
|
||||||
|
Настоящий формат записи, попавшей в `other`, остаётся видимым в журнале: значение
|
||||||
|
`other` в метке означает «расширение не из перечня», а само оно стоит в поле
|
||||||
|
пути журнальной строки приёма и в поле формата строки конвертации.
|
||||||
|
|
||||||
|
#### Scenario: Незнакомое расширение наружу не выходит
|
||||||
|
|
||||||
|
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||||
|
- **WHEN** программа шлёт запись с именем, чей хвост после последней точки не
|
||||||
|
принадлежит перечню known-форматов
|
||||||
|
- **THEN** метка метрики принимает значение `other`
|
||||||
|
- **AND** файл в каталоге хранения сохраняет пришедшее расширение
|
||||||
|
|
||||||
|
#### Scenario: Известное расширение идёт как есть
|
||||||
|
|
||||||
|
- **GIVEN** источник метаданных читает запись и отдаёт её длительность
|
||||||
|
- **WHEN** программа шлёт запись с именем `sample.MP3`
|
||||||
|
- **THEN** метка метрики принимает значение `mp3`
|
||||||
|
|
||||||
### Requirement: Опрос готовности задачи
|
### Requirement: Опрос готовности задачи
|
||||||
|
|
||||||
Сервис SHALL отдавать состояние задачи расшифровки по запросу
|
Сервис SHALL отдавать состояние задачи расшифровки по запросу
|
||||||
|
|||||||
Reference in New Issue
Block a user