ingest: закрыты мелочи приёма — вырожденное имя, контракт Result, корреляция add
- имя раздачи нормализуется на границе разбора: вырожденное `-` (metainfo.NoName) даёт пустое имя, пробельное схлопывается — сентинел больше не доходит ни до контекста распознавания, ни до source_ref, ни до подсказки вывода имени - контракт «на любом пути ошибки приёма результат нулевой» объявлен в ingest и удерживается структурно; три транспорта перестали обещать идентификатор, которого нет, и коррелируют отказ по request_id - scoped-логгер загрузки ставится до вызова внешнего сервиса в семи командах воркера — записи об отказе qBittorrent и метабаз получили download_id и infohash; граница разбора bencode записана в docs/research
This commit is contained in:
@@ -66,7 +66,15 @@ jellybit — **приложение, а не библиотека**: внешн
|
||||
- **+ корреляционный ключ** для владельца — `download_id` (если операция
|
||||
к загрузке) либо `request_id`, чтобы по нему найти полную ошибку в логах.
|
||||
Пример: «При обработке загрузки произошла ошибка, download_id=12345», а
|
||||
не «произошла ошибка» и не сырой текст;
|
||||
не «произошла ошибка» и не сырой текст.
|
||||
**Ключ есть не у всякого транспорта, и это называется вслух.** `request_id`
|
||||
— понятие HTTP-границы (chi `RequestID`); у Telegram и CLI его нет. Если
|
||||
операция ещё не завела загрузку (отказ приёма), у такого транспорта ключа
|
||||
нет вовсе — тогда сообщение остаётся без якоря, а диагностика ищется по
|
||||
записи доменной границы (`capability`, `infohash`). Заводить транспорту
|
||||
собственный идентификатор запроса ради ключа — решение уровня спеки, а не
|
||||
умолчание: второй канал корреляции рядом с существующим дороже, чем
|
||||
отсутствие ключа;
|
||||
- **маппинг доменной ошибки → статус/сообщение** (в jellybit —
|
||||
`httpapi.classifyErr`, единая точка для REST и веб-UI):
|
||||
|
||||
|
||||
@@ -204,6 +204,11 @@ Go-ошибки логируем как атрибут, не как текст
|
||||
|
||||
- Входящие HTTP-запросы логируем с полями `http.method`, `http.route`,
|
||||
`http.status_code`, `duration_ms`, `transport` (`http`/`web`/`telegram`).
|
||||
- **Поле, которое уже даёт scoped-логгер, руками не доклеиваем.** Команда,
|
||||
положившая scoped-логгер в `ctx`, не передаёт `download_id` ещё и аргументом
|
||||
записи: в JSON получается дублирующийся ключ, и строгий потребитель молча
|
||||
оставит одно из значений. Правило следует из «логгер несёт ключи сам» и
|
||||
проверяется чтением — линтером не выражается.
|
||||
- Для корреляции HTTP-запроса допустим `request_id` (напр. chi `RequestID`) —
|
||||
это отдельный слой от корреляции загрузки по `download_id` и не противоречит
|
||||
отказу от `trace_id`. Если запрос порождает загрузку — связь даёт
|
||||
|
||||
+10
-1
@@ -1,7 +1,13 @@
|
||||
# Разведка
|
||||
|
||||
Наблюдения за внешним миром: что реально шлёт источник, чем документация формата
|
||||
расходится с практикой. Источник истины — этот каталог, а не чужая документация.
|
||||
расходится с практикой и как ведут себя наши разборщики и зависимости на границе
|
||||
формата. Источник истины — этот каталог, а не чужая документация.
|
||||
|
||||
Наблюдение о чужом **коде** живёт здесь наравне с наблюдением о чужих
|
||||
**данных**, но у него есть срок годности: такая записка обязана называть версию
|
||||
зависимости и условие пересмотра, потому что протухает от обновления `go.mod`, а
|
||||
не от смены формата.
|
||||
|
||||
**Каждый вывод — с числами и командой или условиями, которыми получен**, чтобы
|
||||
его можно было перепроверить. Число без провенанса проход обязан читать как
|
||||
@@ -26,3 +32,6 @@ LLM-эндпоинта на живых раздачах. Автоматичес
|
||||
|
||||
- [torrent-bot-message.md](torrent-bot-message.md) — формат сообщения
|
||||
торрент-бота, из которого приходит magnet и контекст.
|
||||
- [torrent-bencode-limits.md](torrent-bencode-limits.md) — границы разбора
|
||||
`.torrent` в `anacrolix/torrent`: аллокация по объявленной длине строки,
|
||||
паники разбора, отсутствие «имени-заглушки». Проверено на `v1.61.0`.
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# Границы разбора `.torrent` в `anacrolix/torrent`
|
||||
|
||||
Наблюдения о том, как ведёт себя библиотека разбора на **недоверенных** байтах
|
||||
`.torrent`. В отличие от соседней записки про формат сообщения торрент-бота, это
|
||||
наблюдение о **чужом коде**, а не о чужих данных, и потому у него есть срок
|
||||
годности.
|
||||
|
||||
**Условие устаревания:** перепроверить при обновлении `anacrolix/torrent`.
|
||||
Числа и ссылки ниже сняты на `v1.61.0` (версия зафиксирована в `go.mod`); ссылки
|
||||
вида `файл:строка` относятся к ней и после бампа могут указывать не туда.
|
||||
|
||||
## Аллокация объявленной длины строки
|
||||
|
||||
`bencode` аллоцирует строку по длине, **объявленной во входе**, до того как эти
|
||||
байты прочитаны: `parseString` делает `make([]byte, length)` и только потом
|
||||
`io.ReadFull` (`bencode/decode.go:250` и `:258`). Ограничитель один — потолок
|
||||
`DefaultDecodeMaxStrLen = 1<<27 - 1` ≈ 128 MiB (`decode.go:17`), проверяемый в
|
||||
`parseStringLength` (`decode.go:223`) **до** аллокации. `metainfo.Load` создаёт
|
||||
декодер, не переопределяя `MaxStrLen` (`metainfo/metainfo.go:35-37`), то есть
|
||||
работает с потолком по умолчанию.
|
||||
|
||||
**Замер.** Вход — верхнеуровневый словарь `d7:comment<N>:xxxx`, где `<N>`
|
||||
объявляет длину, а байтов за ней нет. Мерилось дельтой
|
||||
`runtime.MemStats.TotalAlloc` вокруг вызова, Go 1.26.5. Программа целиком
|
||||
(положить в `tmp/bencodealloc/main.go`, запустить `go run ./tmp/bencodealloc`,
|
||||
каталог после замера удалить — `tmp/` в `.gitignore`):
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
)
|
||||
|
||||
func craft(declared int64, tail int) []byte {
|
||||
var b bytes.Buffer
|
||||
b.WriteString("d7:comment")
|
||||
fmt.Fprintf(&b, "%d:", declared)
|
||||
b.Write(bytes.Repeat([]byte("x"), tail))
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
func measure(name string, data []byte) {
|
||||
runtime.GC()
|
||||
var before, after runtime.MemStats
|
||||
runtime.ReadMemStats(&before)
|
||||
_, err := metainfo.Load(bytes.NewReader(data))
|
||||
runtime.ReadMemStats(&after)
|
||||
fmt.Printf("%-34s вход=%-4d байт аллоцировано=%8.2f MiB err=%v\n",
|
||||
name, len(data), float64(after.TotalAlloc-before.TotalAlloc)/(1<<20), err)
|
||||
}
|
||||
|
||||
func main() {
|
||||
measure("объявлено 1 MiB", craft(1<<20, 16))
|
||||
measure("объявлено 64 MiB", craft(64<<20, 16))
|
||||
measure("объявлено 128 MiB - 1 (потолок)", craft(1<<27-1, 16))
|
||||
measure("объявлено 128 MiB (выше потолка)", craft(1<<27, 16))
|
||||
measure("объявлено 1 GiB (выше потолка)", craft(1<<30, 16))
|
||||
}
|
||||
```
|
||||
|
||||
| Объявленная длина | Размер входа | Аллоцировано | Исход |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 MiB | 34 байта | 1.00 MiB | ошибка `unexpected EOF` |
|
||||
| 64 MiB | 35 байт | 64.01 MiB | ошибка `unexpected EOF` |
|
||||
| 128 MiB − 1 (потолок) | 36 байт | 128.00 MiB | ошибка `unexpected EOF` |
|
||||
| 128 MiB (выше потолка) | 36 байт | 0.00 MiB | ошибка `exceeds limit` |
|
||||
| 1 GiB (выше потолка) | 37 байт | 0.00 MiB | ошибка `exceeds limit` |
|
||||
|
||||
Что из этого следует:
|
||||
|
||||
- **Усиление огромное, и наш лимит размера от него не защищает.** 36 байт входа
|
||||
дают 128 MiB транзиентной аллокации — это ×3.7 млн, а не «крафт-8 MiB даёт
|
||||
128 MiB», как предполагала исходная нить ревью. Предел приёма
|
||||
`ingest.MaxTorrentSize` (8 MiB) стоит **до** разбора и на эту величину не
|
||||
влияет вовсе: атакующему хватает трёх десятков байт.
|
||||
- **Но аллокация ограничена сверху и одна на попытку разбора.** Выше потолка
|
||||
библиотека отказывает, не аллоцировав ничего; ниже — аллоцирует ровно
|
||||
объявленное и падает на чтении, обрывая разбор целиком. Дочитать несколько
|
||||
таких строк в одном входе нельзя: первая же необеспеченная строка роняет
|
||||
`Load`. Верхняя граница на один принятый `.torrent` — примерно 128 MiB, после
|
||||
чего память возвращается.
|
||||
- **Числа выше — это `TotalAlloc`, а не физическая память.** Разграничение
|
||||
существенное, и без него вывод читается страшнее, чем есть. Деградационный
|
||||
путь — `make([]byte, N)`, затем немедленно проваленный `io.ReadFull`, — до
|
||||
страниц буфера **не дотрагивается**, а Linux отдаёт анонимную память
|
||||
zero-fill-on-demand. Замер RSS (`/proc/self/status`, `VmRSS`) на том же
|
||||
входе:
|
||||
|
||||
| Что мерили | VmRSS |
|
||||
| --- | --- |
|
||||
| 1000 одновременных разборов вырожденного входа, пик | 7.4 MiB |
|
||||
| `make([]byte, 128 MiB)` без касания страниц | +0.8 MiB |
|
||||
| то же с касанием одного байта | +0.02 MiB |
|
||||
| то же с проходом по каждой странице | +128 MiB |
|
||||
|
||||
То есть N одновременных приёмов **не** дают N × 128 MiB физической памяти: до
|
||||
RAM это не доходит вовсе. Формулировка «N × 128 MiB» верна только про
|
||||
логический счётчик запрошенных байт кучи.
|
||||
- **Отсюда вывод сильнее, а не слабее.** Пункт принят не потому, что «профиля
|
||||
нагрузки нет и авось обойдётся», а потому, что **деградационный путь
|
||||
структурно не расходует физическую память**. Возвращаться к нему с лимитом
|
||||
параллелизма повода нет; повод появится, только если найдётся путь, на котором
|
||||
объявленная строка действительно дочитывается.
|
||||
- **Это вопрос устойчивости, а не безопасности.** Отказ в обслуживании изнутри
|
||||
контура явно вынесен за модель угроз (`../security.md` → «Что вне модели»).
|
||||
|
||||
## Паники разбора не выходят наружу — но гард у нас уже́е, чем кажется
|
||||
|
||||
`bencode.Decoder.Decode` ловит паники разбора и возвращает их ошибкой, **кроме**
|
||||
`runtime.Error` — такую он пере-паникует (`bencode/decode.go:38-46`). То есть
|
||||
арифметическая ошибка или выход за границы внутри библиотеки поднялись бы
|
||||
паникой через `metainfo.Load`.
|
||||
|
||||
Наш `recover`-гард в `internal/torrent` стоит только на `files()` и покрывает
|
||||
`UpvertedFiles`/`FileTree`. Он **не** покрывает `metainfo.Load`,
|
||||
`UnmarshalInfo` и `HashBytes` — они вызываются вне гарда.
|
||||
|
||||
Достижимого panic-пути через них найти не удалось. Проверялась гипотеза про
|
||||
отрицательную объявленную длину: `parseStringLength` её пропускает
|
||||
(`checkBufferedInt`, `decode.go:196-208`, принимает `-5`), и `make([]byte, -5)`
|
||||
дал бы `runtime.Error`. Путь **недостижим** — диспетчер значений входит в разбор
|
||||
строки только по ведущей цифре, а `-` отсекается раньше:
|
||||
|
||||
```
|
||||
"d7:comment-5:xxxxx" → bencode: syntax error (offset: 10): unknown value type '-'
|
||||
"d4:infod4:name-5:xxxxxee" → bencode: syntax error (offset: 14): unknown value type '-'
|
||||
```
|
||||
|
||||
Это отрицательный результат, а не гарантия: он говорит, что **этот** путь
|
||||
закрыт, и ничего не говорит об остальных. Расширять гард на `Load` при
|
||||
обновлении библиотеки — дешёвая страховка, если появится повод.
|
||||
|
||||
## Библиотека не подставляет «имя-заглушку»
|
||||
|
||||
Смежное наблюдение о той же библиотеке, записанное потому, что его отсутствие
|
||||
стоило ложной нити в ревью приёма 2026-07-08.
|
||||
|
||||
`metainfo.Info.BestName()` (`metainfo/info.go:200-205`) возвращает `NameUtf8`,
|
||||
иначе `Name`, иначе **пустую строку**. Константа `NoName = "-"`
|
||||
(`metainfo/info.go:44`) присваивается только в `BuildFromFilePath` — то есть при
|
||||
**авторинге** раздачи из вырожденного пути (`.`, `..`, `/`), и в разборе не
|
||||
участвует.
|
||||
|
||||
Значит `-` доходит до нас исключительно тогда, когда раздача **сама объявила**
|
||||
его полем `name`. Это конвенция «имени нет», принятая ради совместимости с
|
||||
Transmission (комментарий у константы ссылается на transmission#1775), и
|
||||
библиотека экспортирует константу именно затем, чтобы на неё ссылались.
|
||||
|
||||
Практический вывод: «раздача без имени» и «раздача с именем `-`» — **разные**
|
||||
входы, и путать их нельзя. Первый даёт пустую строку сам, второй нормализуется
|
||||
нами на границе разбора (`internal/torrent`, `displayName`).
|
||||
@@ -276,3 +276,32 @@ merge-раскладка при повторном добавлении разд
|
||||
асимметрию признака владения. Сам дефект — задачей в беклоге, кандидат
|
||||
`critical`; спека `state-reconciliation` в том же изменении перестала
|
||||
утверждать, что уборка «данных пользователя не касается».
|
||||
|
||||
## 2026-08-06 — нормализация имени раздачи сама производила сентинел, который отбрасывала [пойман]
|
||||
|
||||
- **Где:** `internal/torrent/torrent.go`, функция `displayName` (введена
|
||||
изменением `ingest-nits`). Норма — `openspec/specs/ingest/spec.md`, требование
|
||||
«Вырожденное имя раздачи не считается именем».
|
||||
- **Симптом:** найден на ревью изменения, до мерджа. В эксплуатации не был.
|
||||
- **Причина:** сравнение с `metainfo.NoName` стояло **до** схлопывания
|
||||
пробельного. Имя `" - "` сравнение не проходило, а `oneLine` превращал его
|
||||
ровно в `-`, и вырожденное значение уезжало вниз по потоку всеми тремя
|
||||
путями: строкой названия в контексте распознавания, в `source_ref` (фолбек на
|
||||
имя присланного файла не срабатывал — строка непуста) и подсказкой вывода
|
||||
отображаемого имени. Против `master` это **регрессия**: там стояло
|
||||
`strings.TrimSpace(...)` перед сравнением с `-`, и фолбек работал.
|
||||
- **Чем воспроизведён:** двумя независимыми падающими тестами — `adversary`
|
||||
прогнал приём на входах `" - "`, `"-\n"`, `"\t-"`, `" -"` и получил
|
||||
`source_ref = "-"` вместо `Dune.torrent`; `reimpl` принёс свой тест на разбор.
|
||||
В дереве остался табличный `TestParseNoNameSentinelDropped`.
|
||||
- **Почему не поймали раньше:** ловить было нечему — дефект внесён этим же
|
||||
изменением и пойман тем же прогоном. Отмечено потому, что это **эвал-сет
|
||||
наоборот**: случай, где верхняя ступень окупилась. Три прохода из семи
|
||||
(`specs`, `adversary`, `reimpl`) нашли его независимо, и двое принесли оракул;
|
||||
проход `code` (конвенции) и гейт его не видели — порядок двух операций внутри
|
||||
функции не выражается ни правилом линтера, ни конвенцией.
|
||||
- **Что меняем:** ничего в конвейере. Класс «нормализация и сравнение с
|
||||
константой идут в неверном порядке» дешевле ловить тестом на границе разбора,
|
||||
чем правилом; такой тест заведён. Наблюдение о самой библиотеке (что
|
||||
`BestName()` сентинел **не** синтезирует — исходное основание нити было
|
||||
неверным) записано в `research/torrent-bencode-limits.md`.
|
||||
|
||||
Reference in New Issue
Block a user