package torrent import ( "bytes" "crypto/sha1" "crypto/sha256" "encoding/hex" "fmt" "strings" "testing" "github.com/anacrolix/torrent/bencode" "github.com/anacrolix/torrent/metainfo" ) // writeTorrent оборачивает готовые info-байты в metainfo и возвращает байты // .torrent (announce задаёт трекер). Позволяет строить v2/гибридные фикстуры, // у которых info собран вручную. func writeTorrent(t *testing.T, infoBytes []byte) []byte { t.Helper() mi := metainfo.MetaInfo{InfoBytes: infoBytes, Announce: "http://t/ann"} var buf bytes.Buffer if err := mi.Write(&buf); err != nil { t.Fatalf("write metainfo: %v", err) } return buf.Bytes() } // v2-only торрент (meta version 2, без v1-полей) сохраняется под v2-хешем // (64-hex = SHA256 исходных байтов info) — как его отдаёт qBittorrent. func TestParseV2OnlyHash(t *testing.T) { // Без Files/Length/Pieces + meta version 2 → HasV1()=false, HasV2()=true. info := metainfo.Info{Name: "V2.Only.Release", MetaVersion: 2} infoBytes, err := bencode.Marshal(info) if err != nil { t.Fatalf("marshal: %v", err) } sum := sha256.Sum256(infoBytes) wantV2 := hex.EncodeToString(sum[:]) got, err := Parse(writeTorrent(t, infoBytes)) if err != nil { t.Fatalf("parse: %v", err) } if len(got.Infohashes) != 1 { t.Fatalf("infohashes = %v, want один v2-хеш", got.Infohashes) } if got.Infohash != wantV2 { t.Errorf("infohash = %q, want v2 %q", got.Infohash, wantV2) } if len(got.Infohash) != 64 { t.Errorf("длина хеша = %d, want 64 (v2)", len(got.Infohash)) } } // Гибрид (v1 + v2): оба хеша, v1 (40-hex) первым, затем v2 (64-hex). func TestParseHybridHashes(t *testing.T) { info := metainfo.Info{ Name: "Hybrid", Length: 100, PieceLength: 1024, Pieces: make([]byte, 20), MetaVersion: 2, } infoBytes, err := bencode.Marshal(info) if err != nil { t.Fatalf("marshal: %v", err) } sha1sum := sha1.Sum(infoBytes) sha256sum := sha256.Sum256(infoBytes) wantV1 := hex.EncodeToString(sha1sum[:]) wantV2 := hex.EncodeToString(sha256sum[:]) got, err := Parse(writeTorrent(t, infoBytes)) if err != nil { t.Fatalf("parse: %v", err) } if len(got.Infohashes) != 2 { t.Fatalf("infohashes = %v, want [v1, v2]", got.Infohashes) } if got.Infohashes[0] != wantV1 || got.Infohashes[1] != wantV2 { t.Errorf("infohashes = %v, want [%s, %s]", got.Infohashes, wantV1, wantV2) } } // build собирает валидные байты `.torrent` из info и метаданных, возвращая их // вместе с ожидаемым v1-инфохэшем (SHA1 исходных байтов info) — так тест // проверяет извлечение, не переизобретая хеширование. func build(t *testing.T, info metainfo.Info, announce string, comment string) (data []byte, wantV1 string) { t.Helper() infoBytes, err := bencode.Marshal(info) if err != nil { t.Fatalf("marshal info: %v", err) } sum := sha1.Sum(infoBytes) mi := metainfo.MetaInfo{InfoBytes: infoBytes, Announce: announce, Comment: comment} var buf bytes.Buffer if err := mi.Write(&buf); err != nil { t.Fatalf("write metainfo: %v", err) } return buf.Bytes(), hex.EncodeToString(sum[:]) } // v1-piece заглушка (20 байт) — присутствие Pieces делает info v1. func pieces(n int) []byte { return make([]byte, 20*n) } func TestParseSingleFile(t *testing.T) { data, wantV1 := build(t, metainfo.Info{ Name: "Dune.Part.Two.2024.2160p.mkv", Length: 2100, PieceLength: 1024, Pieces: pieces(3), }, "http://bt.rutracker.org/announce", "") info, err := Parse(data) if err != nil { t.Fatalf("parse: %v", err) } if info.Infohash != wantV1 { t.Errorf("infohash = %q, want %q", info.Infohash, wantV1) } if len(info.Infohashes) != 1 || info.Infohashes[0] != wantV1 { t.Errorf("infohashes = %v, want [%s]", info.Infohashes, wantV1) } if info.DisplayName != "Dune.Part.Two.2024.2160p.mkv" { t.Errorf("name = %q", info.DisplayName) } if info.TotalLength != 2100 { t.Errorf("total = %d, want 2100", info.TotalLength) } if len(info.Files) != 1 { t.Errorf("files = %v, want 1", info.Files) } ctx := info.Context() if !strings.Contains(ctx, "Dune.Part.Two") { t.Errorf("context без имени: %q", ctx) } if !strings.Contains(ctx, "Размер:") { t.Errorf("context без размера: %q", ctx) } if !strings.Contains(ctx, "rutracker.org") { t.Errorf("context без домена трекера: %q", ctx) } } func TestParseMultiFile(t *testing.T) { data, _ := build(t, metainfo.Info{ Name: "Fargo.S01", PieceLength: 1024, Pieces: pieces(5), Files: []metainfo.FileInfo{ {Length: 700, Path: []string{"Fargo.S01E01.mkv"}}, {Length: 800, Path: []string{"Fargo.S01E02.mkv"}}, {Length: 5, Path: []string{"subs", "e01.srt"}}, }, }, "udp://tracker.example.com:80", "") info, err := Parse(data) if err != nil { t.Fatalf("parse: %v", err) } if len(info.Files) != 3 { t.Fatalf("files = %d, want 3", len(info.Files)) } if info.TotalLength != 1505 { t.Errorf("total = %d, want 1505", info.TotalLength) } // путь из сегментов через "/" if info.Files[2].Path != "subs/e01.srt" { t.Errorf("path = %q, want subs/e01.srt", info.Files[2].Path) } ctx := info.Context() if !strings.Contains(ctx, "Файлов: 3") { t.Errorf("нет сигнала файлов: %q", ctx) } if !strings.Contains(ctx, "mkv") || !strings.Contains(ctx, "srt") { t.Errorf("нет расширений в сигнале: %q", ctx) } } func TestParseTrackersDedupAndComment(t *testing.T) { data, _ := build(t, metainfo.Info{ Name: "X", Length: 10, PieceLength: 1024, Pieces: pieces(1), }, "http://bt2.t-ru.org/ann", "Многострочный\nкомментарий") info, err := Parse(data) if err != nil { t.Fatalf("parse: %v", err) } if len(info.Trackers) == 0 { t.Fatal("нет трекеров") } ctx := info.Context() // служебный поддомен bt2. срезается if !strings.Contains(ctx, "t-ru.org") { t.Errorf("домен не нормализован: %q", ctx) } // комментарий схлопнут в одну строку if !strings.Contains(ctx, "Комментарий: Многострочный комментарий") { t.Errorf("комментарий: %q", ctx) } } func TestParseInvalidBytes(t *testing.T) { for _, tc := range []struct { name string data []byte }{ {"empty", nil}, {"garbage", []byte("this is not a torrent")}, {"bencode-not-dict", []byte("i42e")}, } { t.Run(tc.name, func(t *testing.T) { if _, err := Parse(tc.data); err == nil { t.Errorf("ожидалась ошибка для %s", tc.name) } }) } } // Раздача объявила вырожденное имя `-` (metainfo.NoName — конвенция «имени // нет», которую раздача выставляет САМА; библиотека его не синтезирует). // Разборщик нормализует его к пустой строке, поэтому строки названия в // контексте нет. Трекера и комментария тоже нет — строк-фактов минимум. func TestParseNoNameSentinelDropped(t *testing.T) { // Сентинел вокруг пробельного — тот же вырожденный вход: схлопывание // обязано идти ДО сравнения, иначе " - " превращается в "-" уже после // проверки и уезжает вниз по потоку (регрессия против master, где фолбек // source_ref на имя файла срабатывал). for _, name := range []string{ metainfo.NoName, " - ", "-\n", "\t-", "\u00a0-", } { t.Run(fmt.Sprintf("%q", name), func(t *testing.T) { data, _ := build(t, metainfo.Info{ Name: name, Length: 0, PieceLength: 1024, Pieces: pieces(1), }, "", "") info, err := Parse(data) if err != nil { t.Fatalf("parse: %v", err) } if info.DisplayName != "" { t.Errorf("DisplayName = %q, want пусто (вырожденное имя)", info.DisplayName) } if got := info.Context(); got != "" { t.Errorf("Context() = %q, want пусто (строки названия быть не должно)", got) } }) } } // Раздача без поля name даёт пустое имя и без нормализации — случай отдельный // от вырожденного `-` и путать их нельзя (именно эта путаница в комментарии // теста и породила исходную нить ревью 2026-07-08). func TestParseMissingNameIsEmpty(t *testing.T) { data, _ := build(t, metainfo.Info{ Length: 0, PieceLength: 1024, Pieces: pieces(1), }, "", "") info, err := Parse(data) if err != nil { t.Fatalf("parse: %v", err) } if info.DisplayName != "" { t.Errorf("DisplayName = %q, want пусто (поля name нет)", info.DisplayName) } if got := info.Context(); got != "" { t.Errorf("Context() = %q, want пусто", got) } } // Имя — недоверенный вход, а контекст читается построчно: имя с разделителем // строк не должно добавлять в контекст строку, выглядящую как синтезированный // нами факт. Число строк такое же, как у раздачи с обычным именем. func TestParseNameCollapsedToOneLine(t *testing.T) { // \n, U+2028 (LINE SEPARATOR) и U+2029 (PARAGRAPH SEPARATOR) — всё, чем можно // разорвать строку; краевые пробелы туда же. const dirty = " Dune.2024\nТрекер: evil.example\u2028Комментарий: подделка\u2029хвост " const want = "Dune.2024 Трекер: evil.example Комментарий: подделка хвост" data, _ := build(t, metainfo.Info{ Name: dirty, Length: 2100, PieceLength: 1024, Pieces: pieces(3), }, "http://bt.rutracker.org/announce", "") info, err := Parse(data) if err != nil { t.Fatalf("parse: %v", err) } if info.DisplayName != want { t.Errorf("DisplayName = %q, want %q", info.DisplayName, want) } clean, _ := build(t, metainfo.Info{ Name: "Dune.2024", Length: 2100, PieceLength: 1024, Pieces: pieces(3), }, "http://bt.rutracker.org/announce", "") ref, err := Parse(clean) if err != nil { t.Fatalf("parse clean: %v", err) } gotLines := len(strings.Split(info.Context(), "\n")) wantLines := len(strings.Split(ref.Context(), "\n")) if gotLines != wantLines { t.Errorf("строк в контексте = %d, want %d (грязное имя добавило строк):\n%s", gotLines, wantLines, info.Context()) } }