config: включение Telegram разведено с ключом доступа

- в секции [telegram] заведён обязательный ключ enabled: умолчания у него нет,
  файл без него негоден; bot_token стал только ключом доступа и при
  enabled = false не читается вовсе, а пустой при enabled = true роняет старт
- выключенный вход даёт подъём одним входом без единого обращения к Telegram и
  записью INFO вместо прежнего WARN: это выбор владельца, а не отклонение
- отказ разбора файла настроек больше не пересказывает toml — её ParseError
  несёт в тексте разбираемое значение, и оборванная строка секретного ключа
  уносила его в журнал; теперь называются путь, строка, столбец и последний ключ
This commit is contained in:
av
2026-08-13 21:46:14 +03:00
parent 903941f587
commit cd57b68215
27 changed files with 1536 additions and 123 deletions
+1
View File
@@ -22,6 +22,7 @@ SpeechKit, Yandex Object Storage и `ffmpeg`. Мерить нужно то, чт
| Дата | Запись | О чём |
| --- | --- | --- |
| 2026-08-13 | [Разбор TOML: какое семейство отказов несёт значения из файла](toml-decode-errors.md) | Значения только в `ParseError.Message`, врущее поле `Line`, отказ значением в BurntSushi/toml v1.5.0 |
| 2026-08-12 | [PocketBase: умолчания, которые ломают штатный сценарий](pocketbase-defaults.md) | Потолок файла 5 МиБ, тело 32 МиБ, таймаут чтения, суффикс имени, хук правки |
| 2026-08-11 | [gRPC-клиент SpeechKit: когда закрытие вообще может отказать](grpc-client-close.md) | Ленивое соединение и два исхода `Close` в grpc v1.74.2 |
| 2026-08-11 | [Фреймворк приложения: Svelte, Vue и React на одном экране](spa-framework.md) | Размер собранной статики, цена шага сборки, что у трёх кандидатов одинаково |
+55
View File
@@ -0,0 +1,55 @@
# Разбор TOML: какое семейство отказов несёт значения из файла
Отвечает на вопрос, возникший по ходу задачи `telegram-enabled-flag`: можно ли
пересказывать отказ библиотеки разбора в журнал, если в файле настроек лежат
секреты. Наблюдение понадобилось потому, что ревью дизайна назвало этот путь
утечкой, а чинить его без разреза пришлось бы выбрасыванием всего текста отказа —
то есть платой разборчивостью на каждой опечатке.
## Как снималось
Не замером, а **чтением исходников** зависимости, зафиксированной в `go.mod`:
`github.com/BurntSushi/toml` версии **v1.5.0**. Смотрел `error.go`, `parse.go`,
`decode.go`, `meta.go`, `lex.go` в кэше модулей. Дополнительно прогонял
`toml.Decode` на правдоподобных опечатках — в каталоге вне репозитория, чтобы не
править код проекта.
## Что выяснилось
- **Значения из файла несёт ровно одно семейство отказов — `toml.ParseError`.**
Его поле `Message` собирается из разбираемого куска: `Invalid float value: %q`
(`parse.go:341`), `invalid duration: %q`, `%v is out of range`, `Invalid
integer %q`. Туда же лексер отдаёт свои отказы через `panicItemf`
(`parse.go:134`).
- **Прочие отказы декодера значений не содержат вовсе.** Их строит `md.e`
(`decode.go:577`) и `md.badtype` — из имён ключей, имён типов (`%T` через
`fmtType`) и длин. Обойдены все места: `decode.go:282,288,297,329,348,385,388,399,428,437,467,487,518,552,561`.
- **`LastKey` секрета нести не может.** Текущий ключ присваивается только после
`itemKeyEnd`, то есть после `=` (`parse.go:200`), а лексер ключа до `=` не
доходит (`lex.go:481-501`). Посторонняя строка со значением ключом не станет.
- **Поле `Line` у `ParseError` врёт, а `Position.Line` — нет.** `panicErr` и
`panicItemf` кладут в устаревшее поле `Line` значение `it.pos.Len`, то есть
**длину**, а не номер строки (`parse.go:97,106`). Брать надо `Position.Line`.
- **`ParseError` возвращается значением, не указателем** (`decode.go:564`,
`parse.go` целиком), поэтому `errors.As` берёт целью `toml.ParseError`, а не
`*toml.ParseError`. `Unwrap` у типа нет.
- **Ветка без последнего ключа достижима обычной опечаткой.** Незакрытая скобка
секции даёт `LastKey=""`:
```
вход "[telegram\nenabled = true\n"
→ LastKey="" err=toml: line 2: expected '.' or ']' to end table name, but got '\n' instead
```
## Что из этого следует для кода
Разрез по семейству отказа: `ParseError` пересобирается своими словами — путь,
строка, столбец, последний ключ, — а его `Message` не берётся; прочие отказы
проходят как есть. Так инвариант «Секрет не покидает конфиг» держится, а
несовпадение типов по-прежнему называет ключ и типы.
**Наблюдение привязано к версии.** Версия, переложившая значение в другое
семейство или сменившая возврат на указатель, вернёт утечку молча. Держат это
проверки поломанного файла настроек в `internal/config/config_test.go`; при
подъёме версии библиотеки их отказ читается как сигнал перечитать эту записку, а
не как случайный шум.