# Границы разбора `.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:xxxx`, где `` объявляет длину, а байтов за ней нет. Мерилось дельтой `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`).