420 lines
9.2 KiB
Go
420 lines
9.2 KiB
Go
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
|
|
}
|
|
|
|
// Если announce пустой, берём первый трекер из announce-list (BEP 12)
|
|
primaryAnnounce := bto.Announce
|
|
if primaryAnnounce == "" {
|
|
for _, tier := range bto.AnnounceList {
|
|
for _, tr := range tier {
|
|
if tr != "" {
|
|
primaryAnnounce = tr
|
|
break
|
|
}
|
|
}
|
|
if primaryAnnounce != "" {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := validateMetadata(bto.Info); 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(primaryAnnounce, bto.AnnounceList)
|
|
|
|
tf := TorrentFile{
|
|
Announce: primaryAnnounce,
|
|
Trackers: trackers,
|
|
InfoHash: infoHash,
|
|
PieceHashes: pieceHashes,
|
|
PieceLength: bto.Info.PieceLength,
|
|
Length: totalLength,
|
|
Name: bto.Info.Name,
|
|
Files: files,
|
|
}
|
|
|
|
return &tf, nil
|
|
}
|
|
|
|
func FromMetadata(infoBytes []byte, trackers []string) (*TorrentFile, error) {
|
|
var info bencodeInfo
|
|
if err := bencode.Unmarshal(bytes.NewReader(infoBytes), &info); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
primaryAnnounce := ""
|
|
if len(trackers) > 0 {
|
|
primaryAnnounce = trackers[0]
|
|
}
|
|
|
|
if err := validateMetadata(info); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
pieceHashes, err := splitPieceHashes(info.Pieces)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
files, totalLength, err := deriveFilesAndLength(info)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := validatePieceCount(totalLength, info.PieceLength, len(pieceHashes)); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
infoHash := sha1.Sum(infoBytes)
|
|
|
|
tf := TorrentFile{
|
|
Announce: primaryAnnounce,
|
|
Trackers: trackers,
|
|
InfoHash: infoHash,
|
|
PieceHashes: pieceHashes,
|
|
PieceLength: info.PieceLength,
|
|
Length: totalLength,
|
|
Name: info.Name,
|
|
Files: files,
|
|
}
|
|
|
|
return &tf, nil
|
|
}
|
|
|
|
func validateMetadata(info bencodeInfo) error {
|
|
switch {
|
|
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
|
|
}
|