package torrent import ( "bytes" "crypto/sha1" "crypto/sha256" "encoding/hex" "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) } }) } } func TestContextEmptyWhenNoFields(t *testing.T) { // info без имени (NoName-сентинел), без трекера/комментария: строк-фактов // минимум. Проверяем, что Context не паникует и не тянет сеть (чистая // функция — сетевых вызовов в коде нет по построению). data, _ := build(t, metainfo.Info{ Name: "-", // metainfo.NoName Length: 0, PieceLength: 1024, Pieces: pieces(1), }, "", "") info, err := Parse(data) if err != nil { t.Fatalf("parse: %v", err) } _ = info.Context() // не паникует; состав строк — по полям }