package torrentfile import ( "bytes" "crypto/sha1" "errors" "fmt" "os" "path" "github.com/jackpal/bencode-go" ) type bencodeTorrent struct { Announce string `bencode:"announce"` AnnounceList [][]string `bencode:"announce-list"` Info bencodeInfo `bencode:"info"` } type bencodeInfo struct { Pieces string `bencode:"pieces"` PieceLength int `bencode:"piece length"` Length int `bencode:"length"` Files []file `bencode:"files"` Name string `bencode:"name"` } type file struct { Length int `bencode:"length"` Path []string `bencode:"path"` } type File struct { Path string Length int } type TorrentFile struct { Announce string Trackers []string InfoHash [20]byte PieceHashes [][20]byte PieceLength int Length int Name string Files []File } func Open(path string) (*TorrentFile, error) { rawData, err := os.ReadFile(path) if err != nil { return nil, err } infoBytes, err := extractInfoBytes(rawData) if err != nil { return nil, err } var bto bencodeTorrent if err := bencode.Unmarshal(bytes.NewReader(rawData), &bto); err != nil { return nil, err } if err := validateMetadata(bto.Info, bto.Announce); err != nil { return nil, err } pieceHashes, err := splitPieceHashes(bto.Info.Pieces) if err != nil { return nil, err } files, totalLength, err := deriveFilesAndLength(bto.Info) if err != nil { return nil, err } if err := validatePieceCount(totalLength, bto.Info.PieceLength, len(pieceHashes)); err != nil { return nil, err } infoHash := sha1.Sum(infoBytes) trackers := collectTrackers(bto.Announce, bto.AnnounceList) tf := TorrentFile{ Announce: bto.Announce, Trackers: trackers, InfoHash: infoHash, PieceHashes: pieceHashes, PieceLength: bto.Info.PieceLength, Length: totalLength, Name: bto.Info.Name, Files: files, } return &tf, nil } func validateMetadata(info bencodeInfo, announce string) error { switch { case announce == "": return errors.New("torrent announce URL is empty") case info.Name == "": return errors.New("torrent name is empty") case info.PieceLength <= 0: return errors.New("torrent piece length must be greater than zero") } switch { case info.Length > 0 && len(info.Files) > 0: return errors.New("torrent contains both single-file and multi-file metadata") case info.Length <= 0 && len(info.Files) == 0: return errors.New("torrent missing both length and files metadata") } return nil } func splitPieceHashes(rawPieces string) ([][20]byte, error) { pieces := []byte(rawPieces) if len(pieces) == 0 { return nil, errors.New("torrent has no piece hashes") } if len(pieces)%sha1.Size != 0 { return nil, fmt.Errorf("invalid pieces data size %d (must be multiple of %d)", len(pieces), sha1.Size) } hashes := make([][20]byte, len(pieces)/sha1.Size) for i := range hashes { start := i * sha1.Size copy(hashes[i][:], pieces[start:start+sha1.Size]) } return hashes, nil } func deriveFilesAndLength(info bencodeInfo) ([]File, int, error) { if info.Length > 0 { return []File{{Path: info.Name, Length: info.Length}}, info.Length, nil } files := make([]File, 0, len(info.Files)) totalLength := 0 for _, f := range info.Files { if f.Length <= 0 { return nil, 0, errors.New("torrent file length must be greater than zero") } if len(f.Path) == 0 { return nil, 0, errors.New("torrent file path is empty") } parts := make([]string, 0, len(f.Path)+1) parts = append(parts, info.Name) parts = append(parts, f.Path...) filePath := path.Join(parts...) if filePath == "." { return nil, 0, errors.New("torrent file path is invalid") } files = append(files, File{ Path: filePath, Length: f.Length, }) totalLength += f.Length } if totalLength <= 0 { return nil, 0, errors.New("torrent total length must be greater than zero") } return files, totalLength, nil } func validatePieceCount(totalLength, pieceLength, actualPieces int) error { expectedPieces := (totalLength + pieceLength - 1) / pieceLength if expectedPieces != actualPieces { return fmt.Errorf("piece hash count mismatch: expected %d, got %d", expectedPieces, actualPieces) } return nil } func extractInfoBytes(data []byte) ([]byte, error) { if len(data) == 0 || data[0] != 'd' { return nil, errors.New("torrent root must be a bencoded dictionary") } idx := 1 for idx < len(data) { if data[idx] == 'e' { break } key, next, err := parseBencodeString(data, idx) if err != nil { return nil, err } idx = next valueStart := idx valueEnd, err := skipBencodeValue(data, idx) if err != nil { return nil, err } if string(key) == "info" { return data[valueStart:valueEnd], nil } idx = valueEnd } return nil, errors.New("torrent does not contain top-level info dictionary") } func skipBencodeValue(data []byte, idx int) (int, error) { if idx >= len(data) { return 0, errors.New("unexpected end of bencoded data") } switch c := data[idx]; { case c == 'i': return skipBencodeInt(data, idx) case c == 'l': idx++ for { if idx >= len(data) { return 0, errors.New("unexpected end while parsing bencoded list") } if data[idx] == 'e' { return idx + 1, nil } next, err := skipBencodeValue(data, idx) if err != nil { return 0, err } idx = next } case c == 'd': idx++ for { if idx >= len(data) { return 0, errors.New("unexpected end while parsing bencoded dictionary") } if data[idx] == 'e' { return idx + 1, nil } _, next, err := parseBencodeString(data, idx) if err != nil { return 0, err } idx = next next, err = skipBencodeValue(data, idx) if err != nil { return 0, err } idx = next } case c >= '0' && c <= '9': _, next, err := parseBencodeString(data, idx) return next, err default: return 0, fmt.Errorf("invalid bencode token %q at index %d", c, idx) } } func skipBencodeInt(data []byte, idx int) (int, error) { if data[idx] != 'i' { return 0, fmt.Errorf("expected integer token at index %d", idx) } idx++ if idx >= len(data) { return 0, errors.New("unexpected end while parsing bencoded integer") } if data[idx] == '-' { idx++ if idx >= len(data) { return 0, errors.New("unexpected end after bencoded integer sign") } } if data[idx] < '0' || data[idx] > '9' { return 0, fmt.Errorf("invalid bencoded integer digit at index %d", idx) } for idx < len(data) && data[idx] != 'e' { if data[idx] < '0' || data[idx] > '9' { return 0, fmt.Errorf("invalid bencoded integer digit at index %d", idx) } idx++ } if idx >= len(data) || data[idx] != 'e' { return 0, errors.New("unterminated bencoded integer") } return idx + 1, nil } func parseBencodeString(data []byte, idx int) ([]byte, int, error) { length, valueStart, err := parseBencodeStringLength(data, idx) if err != nil { return nil, 0, err } valueEnd := valueStart + length if valueEnd > len(data) { return nil, 0, errors.New("bencoded string length exceeds input size") } return data[valueStart:valueEnd], valueEnd, nil } func parseBencodeStringLength(data []byte, idx int) (int, int, error) { if idx >= len(data) { return 0, 0, errors.New("unexpected end while parsing bencoded string length") } if data[idx] < '0' || data[idx] > '9' { return 0, 0, fmt.Errorf("invalid bencoded string length token at index %d", idx) } length := 0 for idx < len(data) && data[idx] >= '0' && data[idx] <= '9' { length = (length * 10) + int(data[idx]-'0') idx++ } if idx >= len(data) || data[idx] != ':' { return 0, 0, errors.New("unterminated bencoded string length") } return length, idx + 1, nil } func collectTrackers(primary string, announceList [][]string) []string { seen := make(map[string]struct{}) trackers := make([]string, 0, 1+len(announceList)) add := func(tracker string) { if tracker == "" { return } if _, ok := seen[tracker]; ok { return } seen[tracker] = struct{}{} trackers = append(trackers, tracker) } add(primary) for _, tier := range announceList { for _, tracker := range tier { add(tracker) } } return trackers }