diff --git a/docs/adr/ADR-2026-08-11-known-format-label.md b/docs/adr/ADR-2026-08-11-known-format-label.md new file mode 100644 index 0000000..79a045f --- /dev/null +++ b/docs/adr/ADR-2026-08-11-known-format-label.md @@ -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), и своей задачи у него пока нет. diff --git a/docs/adr/README.md b/docs/adr/README.md index f617444..fcfb393 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.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 | [Очередь остаётся своей таблицей, но коллекцией PocketBase](ADR-2026-08-11-queue-as-pocketbase-collection.md) | | | 2026-08-11 | [Хранилище, файлы и вход переезжают в PocketBase](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md) | | diff --git a/docs/architecture.md b/docs/architecture.md index 93da052..7547183 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -104,6 +104,7 @@ | Завершение и отказ | `TranscribeService.completeJob` и `failJob` — они же отвечают пользователю | | Разбор конфигурации | `internal/config.LoadConfig` | | Метрики | `internal/metrics`, префикс имени `transcriber_` | +| Значения метки формата | `internal/metrics.FormatLabel` — приводит расширение к закрытому перечню, прочее заменяет на `other`; нормирует спека `intake` | Единых точек, которых **нет** и которые ожидались бы: идентификаторы генерируются вызовом `uuid.NewString()` по месту, время — вызовом `time.Now()` diff --git a/docs/conventions/logging.md b/docs/conventions/logging.md index dba7803..c039ac3 100644 --- a/docs/conventions/logging.md +++ b/docs/conventions/logging.md @@ -213,7 +213,10 @@ Object Storage, скачивание файла из Telegram и опрос оп - ключ SpeechKit и заголовок `Authorization`; - пара ключей Object Storage; - **сам текст расшифровки и имена файлов пользователя** — это содержимое личной - переписки. Логируем длину текста, а не текст. + переписки. Логируем длину текста, а не текст. Имя файла ничем не заменяем: + ни укороченным именем, ни отпечатком от него — отпечаток та же приватная + величина, а корреляцию держат идентификаторы сущностей. Чем при этом + прослеживается приём, нормирует спека `intake`, а не эта запись. Дополнительно: @@ -233,6 +236,12 @@ Object Storage, скачивание файла из Telegram и опрос оп она не логируется — то есть утечки нет, но защищает от неё только отсутствие строки лога. +*Расхождение:* расширение берётся из имени отправителя дословно +(`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост +расширением, и в журнал оно попадает полем пути. Наружу — в метку метрики — этот +хвост не выходит: там расширение приводится к перечню известных форматов. Остаток +описан в [../security.md](../security.md). + ## Куда пишем и уровень - Пишем JSON в `stdout` одним потоком; сбор и ротацию делает окружение. Не diff --git a/docs/review.md b/docs/review.md index 1c02154..cd91d77 100644 --- a/docs/review.md +++ b/docs/review.md @@ -103,6 +103,9 @@ - `security`: не строится ли путь на диске или ключ объекта из значения, пришедшего снаружи, — расширение файла сегодня берётся из имени отправителя (чтение `service/transcribe.go`, 2026-08-10). +- `security`: не уходит ли значение, пришедшее снаружи, меткой метрики — страница + метрик отдаётся без проверки отправителя, и метка это поверхность пошире + журнала (журнал, запись 2026-08-11 про хвост имени). - `architecture`: не появился ли второй путь приёма мимо `createTranscribeJob` — сегодня через него идут оба входа ([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 — проверка приёма не могла упасть [пойман ревью] - **Где:** `internal/controller/http/transcribe_test.go`, случай успеха приёма diff --git a/docs/security.md b/docs/security.md index 96e2af6..563032a 100644 --- a/docs/security.md +++ b/docs/security.md @@ -186,12 +186,34 @@ Telegram отправителю. который лежит **не в конфигурации**: его отпечаток хранит сама база. Тексты расшифровок в логи не пишутся — логируется длина текста и -идентификаторы. **Имя файла, данное отправителем, пишется**: строка -`internal/service/transcribe.go:107` кладёт `file_name` на общем шаге заведения -задачи, то есть для обоих входов. Это нарушение инварианта приватности из -`CLAUDE.md`, оно объявлено критическим, и чинит его задача -`no-user-filename-in-log`; её место в очереди — -[tasks/BACKLOG.md](../tasks/BACKLOG.md). +идентификаторы. Имя файла, данное отправителем, из журнала приёма убрано +2026-08-11 задачей `no-user-filename-in-log`; запрет проверяют тесты приёма по HTTP на +успешном пути и на пути отказа — они ищут значение, а не имя поля. + +**Остаток: хвост после последней точки остаётся в журнале.** Расширение берётся +из имени отправителя дословно (`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 нигде не логируется. diff --git a/internal/controller/http/transcribe_test.go b/internal/controller/http/transcribe_test.go index 0543d71..31e048c 100644 --- a/internal/controller/http/transcribe_test.go +++ b/internal/controller/http/transcribe_test.go @@ -5,7 +5,7 @@ import ( "database/sql" "encoding/json" "errors" - "io" + "fmt" "log/slog" "mime/multipart" "net/http" @@ -13,7 +13,10 @@ import ( "os" "path" "path/filepath" + "regexp" "runtime" + "strings" + "sync" "testing" "time" @@ -27,6 +30,8 @@ import ( "github.com/gin-gonic/gin" _ "github.com/mattn/go-sqlite3" "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/require" ) @@ -74,6 +79,31 @@ type testEnv struct { handler *TranscribeHandler db *sql.DB 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) { @@ -115,11 +145,26 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv { fileRepo := sqlite.NewFileRepository(db, gq) jobRepo := sqlite.NewTranscriptJobRepository(db, gq) - // Журнал проверкам не нужен: судят они по ответу и по базе. А ветка отказа - // метаданных теперь проходится нарочно, и её ERROR-строки на зелёном - // прогоне размывали бы признак, по которому отличают новый красный шаг - // гейта от объявленного долга. - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + // Журнал уходит в буфер, а не в никуда: по нему судит проверка запрета на + // имя отправителя. Вывод прогона от этого не меняется — ERROR-строки ветки + // отказа по-прежнему не попадают на экран и не размывают признак, по + // которому отличают новый красный шаг гейта от объявленного долга. + 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( jobRepo, @@ -134,7 +179,12 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv { handler := NewTranscribeHandler(jobRepo, trsService) + // Роутер собирается той же цепочкой, что и боевой (main.go): у приёма три + // пишущих в журнал потока, и middleware — третий. Без него требование «ни + // одна журнальная запись приёма» проверялось бы шире, чем оракул смотрит. router := gin.New() + router.Use(sloggin.New(logger)) + router.Use(gin.Recovery()) router.MaxMultipartMemory = 32 << 20 // 32 MiB api := router.Group("/api") @@ -143,7 +193,7 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv { 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 собирает запрос из имени и содержимого. Файла на диске @@ -382,6 +432,163 @@ func TestCreateTranscribeJob_MetaViewerFailure(t *testing.T) { 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) { env := setupTestEnv(t, readableMetaViewer()) diff --git a/internal/metrics/format_label.go b/internal/metrics/format_label.go new file mode 100644 index 0000000..3db66f4 --- /dev/null +++ b/internal/metrics/format_label.go @@ -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 +} diff --git a/internal/metrics/format_label_test.go b/internal/metrics/format_label_test.go new file mode 100644 index 0000000..2cdf657 --- /dev/null +++ b/internal/metrics/format_label_test.go @@ -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("незнакомое расширение не приведено к общему значению") +} diff --git a/internal/service/format_label_test.go b/internal/service/format_label_test.go new file mode 100644 index 0000000..be8d22e --- /dev/null +++ b/internal/service/format_label_test.go @@ -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) + } +} diff --git a/internal/service/transcribe.go b/internal/service/transcribe.go index 379469e..aefe11b 100644 --- a/internal/service/transcribe.go +++ b/internal/service/transcribe.go @@ -7,7 +7,6 @@ import ( "log/slog" "os" "path/filepath" - "strconv" "strings" "time" @@ -102,9 +101,10 @@ func (s *TranscribeService) createTranscribeJob(job *entity.TranscribeJob, file storageFileName := fmt.Sprintf("%s%s", fileId, ext) storageFilePath := filepath.Join(s.storagePath, storageFileName) + // Имя, данное отправителем, в журнал не идёт: инвариант приватности. + // Расширение из него уже стоит в собственном имени файла на диске. s.logger.Info("Creating transcribe job", "file_id", fileId, - "file_name", fileName, "storage_path", storageFilePath) // Создаем файл на диске @@ -139,7 +139,7 @@ func (s *TranscribeService) createTranscribeJob(job *entity.TranscribeJob, file "duration_seconds", info.Seconds) metrics.InputFileDurationHistogram.WithLabelValues().Observe(float64(info.Seconds)) - metrics.InputFileSizeHistogram.WithLabelValues(ext).Observe(float64(size)) + metrics.ObserveInputFileSize(ext, size) // Создаем запись в таблице files fileRecord := &entity.File{ @@ -207,9 +207,7 @@ func (s *TranscribeService) FindAndRunConversionJob() error { conversionDuration := time.Since(startTime) // Записываем метрику времени конвертации - metrics.ConversionDurationHistogram. - WithLabelValues(srcExt, "ogg", strconv.FormatBool(err != nil)). - Observe(conversionDuration.Seconds()) + metrics.ObserveConversionDuration(srcExt, "ogg", err != nil, conversionDuration.Seconds()) if err != nil { s.logger.Error("File conversion failed", diff --git a/openspec/changes/archive/2026-08-11-no-user-filename-in-log/.openspec.yaml b/openspec/changes/archive/2026-08-11-no-user-filename-in-log/.openspec.yaml new file mode 100644 index 0000000..a8821c7 --- /dev/null +++ b/openspec/changes/archive/2026-08-11-no-user-filename-in-log/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-11 diff --git a/openspec/changes/archive/2026-08-11-no-user-filename-in-log/design.md b/openspec/changes/archive/2026-08-11-no-user-filename-in-log/design.md new file mode 100644 index 0000000..65e55b6 --- /dev/null +++ b/openspec/changes/archive/2026-08-11-no-user-filename-in-log/design.md @@ -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 нечем. Имени, данного человеком, оттуда сегодня и не приходит. + +**Запрет держится проверкой, а не линтером** → следующая журнальная строка с +именем отправителя в другом месте кода проверкой не поймается. Смягчение в +границах задачи: проверка стоит ровно на том шаге, через который проходит всякая +принятая запись. Правило линтера — кандидат в отдельную задачу. diff --git a/openspec/changes/archive/2026-08-11-no-user-filename-in-log/proposal.md b/openspec/changes/archive/2026-08-11-no-user-filename-in-log/proposal.md new file mode 100644 index 0000000..4ffaf74 --- /dev/null +++ b/openspec/changes/archive/2026-08-11-no-user-filename-in-log/proposal.md @@ -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` уже запрещает имена файлов пользователя — правки + не требует, разве что строкой о том, чем имя заменено. diff --git a/openspec/changes/archive/2026-08-11-no-user-filename-in-log/review/triage.md b/openspec/changes/archive/2026-08-11-no-user-filename-in-log/review/triage.md new file mode 100644 index 0000000..c96e3ba --- /dev/null +++ b/openspec/changes/archive/2026-08-11-no-user-filename-in-log/review/triage.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`), а это имя файла в хранилище вида + `.<хвост имени отправителя>`. То есть в метку `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. diff --git a/openspec/changes/archive/2026-08-11-no-user-filename-in-log/specs/intake/spec.md b/openspec/changes/archive/2026-08-11-no-user-filename-in-log/specs/intake/spec.md new file mode 100644 index 0000000..fbd787c --- /dev/null +++ b/openspec/changes/archive/2026-08-11-no-user-filename-in-log/specs/intake/spec.md @@ -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` diff --git a/openspec/changes/archive/2026-08-11-no-user-filename-in-log/tasks.md b/openspec/changes/archive/2026-08-11-no-user-filename-in-log/tasks.md new file mode 100644 index 0000000..8dd27de --- /dev/null +++ b/openspec/changes/archive/2026-08-11-no-user-filename-in-log/tasks.md @@ -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`. diff --git a/openspec/specs/intake/spec.md b/openspec/specs/intake/spec.md index 2135b0c..e0b5fac 100644 --- a/openspec/specs/intake/spec.md +++ b/openspec/specs/intake/spec.md @@ -78,6 +78,96 @@ MUST нести идентификатор задачи полем `job_id` и - **THEN** ответ имеет код `500` - **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: Опрос готовности задачи Сервис SHALL отдавать состояние задачи расшифровки по запросу