package release import ( "bytes" "crypto/sha1" "encoding/hex" "errors" "fmt" "strconv" "strings" ) // Reading a .torrent file, ported from Fabled Librarian (#4463). A .torrent // is the metadata: its info dictionary names every file, and its SHA-1 is the // infohash. So a release offered as a .torrent link can be judged on what it // holds before any of it is downloaded. Bencode is four shapes; walking them // here costs less than a dependency. // TorrentMeta is what a .torrent says about itself. type TorrentMeta struct { // InfoHash is the v1 infohash: the SHA-1 of the info dictionary's own // bytes, lowercase hex. InfoHash string Name string Files []File } // ErrNotTorrent is bytes that are not a .torrent. var ErrNotTorrent = errors.New("not a .torrent file") // maxTorrentBytes bounds what is read, as Exchange bounds what it accepts. const maxTorrentBytes = 10 << 20 // ParseTorrent reads a .torrent's info dictionary. func ParseTorrent(b []byte) (TorrentMeta, error) { if len(b) == 0 || len(b) > maxTorrentBytes || b[0] != 'd' { return TorrentMeta{}, ErrNotTorrent } d := &bdecoder{b: b} root, err := d.value(0) if err != nil { return TorrentMeta{}, fmt.Errorf("%w: %v", ErrNotTorrent, err) } top, ok := root.(map[string]any) if !ok || d.infoEnd == 0 { return TorrentMeta{}, fmt.Errorf("%w: no info dictionary", ErrNotTorrent) } info, ok := top["info"].(map[string]any) if !ok { return TorrentMeta{}, fmt.Errorf("%w: no info dictionary", ErrNotTorrent) } sum := sha1.Sum(b[d.infoStart:d.infoEnd]) out := TorrentMeta{InfoHash: hex.EncodeToString(sum[:])} out.Name, _ = info["name"].(string) files, multi := info["files"].([]any) if !multi { // A single-file torrent: the name IS the file. length, _ := info["length"].(int64) out.Files = []File{{Path: out.Name, Size: length}} return out, nil } for index, f := range files { entry, ok := f.(map[string]any) if !ok { continue } parts, _ := entry["path"].([]any) segs := []string{out.Name} for _, p := range parts { if s, ok := p.(string); ok { segs = append(segs, s) } } length, _ := entry["length"].(int64) // BEP 47 padding files carry no content. if attr, _ := entry["attr"].(string); strings.Contains(attr, "p") { continue } out.Files = append(out.Files, File{Index: index, Path: strings.Join(segs, "/"), Size: length}) } return out, nil } // bdecoder walks bencode, and remembers where the top-level info dictionary // sits so its raw bytes can be hashed exactly as published. type bdecoder struct { b []byte i int infoStart, infoEnd int } // maxDepth: a real torrent nests four deep. Anything past this is hostile. const maxDepth = 32 func (d *bdecoder) value(depth int) (any, error) { if depth > maxDepth { return nil, errors.New("nested too deep") } if d.i >= len(d.b) { return nil, errors.New("truncated") } switch c := d.b[d.i]; { case c == 'i': end := bytes.IndexByte(d.b[d.i:], 'e') if end < 0 { return nil, errors.New("unterminated integer") } n, err := strconv.ParseInt(string(d.b[d.i+1:d.i+end]), 10, 64) if err != nil { return nil, err } d.i += end + 1 return n, nil case c == 'l': d.i++ var out []any for d.i < len(d.b) && d.b[d.i] != 'e' { v, err := d.value(depth + 1) if err != nil { return nil, err } out = append(out, v) } if d.i >= len(d.b) { return nil, errors.New("unterminated list") } d.i++ return out, nil case c == 'd': d.i++ out := map[string]any{} for d.i < len(d.b) && d.b[d.i] != 'e' { k, err := d.str() if err != nil { return nil, err } start := d.i v, err := d.value(depth + 1) if err != nil { return nil, err } if depth == 0 && k == "info" { d.infoStart, d.infoEnd = start, d.i } // Pieces are 20 bytes a piece and never read: not kept. if k != "pieces" { out[k] = v } } if d.i >= len(d.b) { return nil, errors.New("unterminated dictionary") } d.i++ return out, nil case c >= '0' && c <= '9': return d.str() default: return nil, fmt.Errorf("unexpected %q", c) } } func (d *bdecoder) str() (string, error) { colon := bytes.IndexByte(d.b[d.i:], ':') if colon < 0 { return "", errors.New("unterminated string length") } n, err := strconv.Atoi(string(d.b[d.i : d.i+colon])) if err != nil || n < 0 { return "", errors.New("bad string length") } start := d.i + colon + 1 if start+n > len(d.b) { return "", errors.New("string runs past the end") } d.i = start + n return string(d.b[start : start+n]), nil }