Comment blocks in cmd/minstrel and 30 smaller internal packages, rewritten
to rule 32's standard with each citation read in context: task, milestone
and rule ids go, and so do the stories they were attached to ("used to",
"Before #N", measurements from one install, quotes, pointers to Fabled
Librarian's records and to docs/superpowers files that are not in the
repo). Where a story wrapped a real constraint, the constraint stays as a
present-tense fact.
Comments that had gone stale are corrected:
- taste.Config: the tuned values are overlaid by internal/recsettings;
it is no longer "a later follow-up".
- StorageConfig: the data dir holds art, caches and the stream secret,
not only playlist covers.
- the Subsonic album list and getUser comments describe what they do
today rather than a milestone that would change it.
Checked: the non-comment token stream of every changed file matches
HEAD, and gofmt -l is empty.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
179 lines
4.5 KiB
Go
179 lines
4.5 KiB
Go
package release
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha1"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Reading a .torrent file. 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
|
|
}
|