Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 97 additions & 12 deletions cmd/wl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
Expand Down Expand Up @@ -357,16 +358,15 @@ func runGet(opts getOpts) error {
}
}

// Fetch .torrent from tracker API
torrentBytes, err := fetchTorrent(trackerBase, hash)
if err != nil {
return fmt.Errorf("fetch torrent: %w", err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

// Decode the torrent metadata
meta, err := torrent.Parse(torrentBytes)
// Metadata: try the registry first, then fall back to BEP 9 peer exchange
// (so a magnet resolves even when the tracker has no registry entry).
announceURL := buildAnnounceURL(trackerBase, opts.userID, opts.secret)
torrentBytes, meta, err := acquireMetadata(ctx, trackerBase, announceURL, mag)
if err != nil {
return fmt.Errorf("decode torrent: %w", err)
return err
}

// Determine output directory
Expand Down Expand Up @@ -408,9 +408,6 @@ func runGet(opts getOpts) error {
return fmt.Errorf("invalid v1 info hash %q: %w", mag.InfoHashV1, err)
}

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

return client.DownloadMVP(ctx, client.DownloadOptions{
Meta: client.TorrentMeta{
Name: meta.Name,
Expand All @@ -421,11 +418,99 @@ func runGet(opts getOpts) error {
Pieces: meta.Pieces,
Files: clientFiles,
},
TrackerURL: buildAnnounceURL(trackerBase, opts.userID, opts.secret),
TrackerURL: announceURL,
OutputDir: outDir,
})
}

// acquireMetadata returns the torrent bytes and parsed metadata, trying the
// registry API first and falling back to BEP 9 peer metadata exchange when the
// registry has no entry for the hash.
func acquireMetadata(ctx context.Context, trackerBase, announceURL string, mag torrent.Magnet) ([]byte, torrent.TorrentMeta, error) {
if tb, err := fetchTorrent(trackerBase, mag.BestHash()); err == nil {
if meta, perr := torrent.Parse(tb); perr == nil {
return tb, meta, nil
}
}

// Registry miss — fall back to peers. BEP 9 ut_metadata verifies against the
// v1 (SHA-1) info hash, so we need one.
fmt.Println(" registry has no entry; fetching metadata from peers (BEP 9)...")
if mag.InfoHashV1 == "" {
return nil, torrent.TorrentMeta{}, fmt.Errorf("not in registry and magnet has no v1 hash; cannot fetch metadata from peers")
}
v1Hash, err := hex.DecodeString(mag.InfoHashV1)
if err != nil || len(v1Hash) != 20 {
return nil, torrent.TorrentMeta{}, fmt.Errorf("invalid v1 info hash %q", mag.InfoHashV1)
}

peerID := newPeerID()
addrs, err := client.Announce(ctx, announceURL, string(v1Hash), peerID, 6881, 0)
if err != nil {
return nil, torrent.TorrentMeta{}, fmt.Errorf("announce for peers: %w", err)
}
if len(addrs) == 0 {
return nil, torrent.TorrentMeta{}, fmt.Errorf("no peers available to fetch metadata from")
}

infoBytes, err := fetchMetadataFromPeers(ctx, addrs, v1Hash, peerID)
if err != nil {
return nil, torrent.TorrentMeta{}, err
}
meta, err := torrent.ParseInfo(infoBytes)
if err != nil {
return nil, torrent.TorrentMeta{}, fmt.Errorf("parse peer metadata: %w", err)
}
tb, err := torrent.BuildMetainfo(infoBytes, announceURL)
if err != nil {
return nil, torrent.TorrentMeta{}, fmt.Errorf("rebuild torrent: %w", err)
}
return tb, meta, nil
}

// fetchMetadataFromPeers connects to peers in turn and returns the first info
// dict successfully fetched via BEP 9.
func fetchMetadataFromPeers(ctx context.Context, addrs []string, v1Hash []byte, peerID string) ([]byte, error) {
var lastErr error
for _, addr := range addrs {
p, err := client.Connect(ctx, addr)
if err != nil {
lastErr = err
continue
}
if err := p.Handshake(ctx, v1Hash, peerID); err != nil {
p.Close()
lastErr = err
continue
}
if p.MetadataSize <= 0 {
p.Close()
lastErr = fmt.Errorf("%s did not advertise metadata_size", addr)
continue
}
data, err := p.FetchMetadata(ctx, v1Hash)
p.Close()
if err == nil {
return data, nil
}
lastErr = err
}
return nil, fmt.Errorf("no peer served metadata (%d tried): %w", len(addrs), lastErr)
}

// newPeerID returns a 20-byte BEP 20 peer id.
func newPeerID() string {
b := make([]byte, 12)
if _, err := rand.Read(b); err != nil {
return "-WL0020-aaaaaaaaaaaa"
}
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
for i := range b {
b[i] = charset[int(b[i])%len(charset)]
}
return "-WL0020-" + string(b)
}

// buildAnnounceURL constructs the announce URL, optionally with a signed passkey path.
func buildAnnounceURL(trackerBase, userID, secret string) string {
base := strings.TrimSuffix(trackerBase, "/")
Expand Down
7 changes: 4 additions & 3 deletions cmd/wl/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -627,11 +627,12 @@ func TestGetTrackerNotFound(t *testing.T) {
magnetURI: "magnet:?xt=urn:btih:deadbeefdeadbeefdeadbeefdeadbeefdeadbeef&dn=missing",
trackerURL: server.URL,
})
// Registry 404 → BEP 9 fallback → announce also 404s → clean failure.
if err == nil {
t.Error("expected error for 404 response")
t.Fatal("expected error when neither registry nor peers can provide metadata")
}
if !strings.Contains(err.Error(), "not found") {
t.Errorf("expected 'not found' in error, got: %v", err)
if !strings.Contains(err.Error(), "peers") && !strings.Contains(err.Error(), "404") {
t.Errorf("expected a metadata-acquisition error, got: %v", err)
}
}

Expand Down
43 changes: 43 additions & 0 deletions internal/client/metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,49 @@ import (
"github.com/zeebo/bencode"
)

func TestFetchMetadataEndToEnd(t *testing.T) {
t.Parallel()
// Arbitrary bytes — FetchMetadata only SHA-1-verifies, it doesn't parse.
// Use >16 KiB to exercise multi-piece assembly.
metaBytes := make([]byte, 40000)
for i := range metaBytes {
metaBytes[i] = byte(i * 7)
}
infoHash := sha1.Sum(metaBytes)

ln, err := listenTCP(t)
if err != nil {
t.Fatal(err)
}
defer ln.Close()
go func() {
c, err := ln.Accept()
if err == nil {
serveMetadataPeer(c, metaBytes)
}
}()

ctx := context.Background()
p, err := Connect(ctx, ln.Addr().String())
if err != nil {
t.Fatal(err)
}
defer p.Close()
if err := p.Handshake(ctx, infoHash[:], "-WL0020-abcdef012345"); err != nil {
t.Fatalf("handshake: %v", err)
}
if p.MetadataSize != len(metaBytes) {
t.Fatalf("MetadataSize = %d, want %d", p.MetadataSize, len(metaBytes))
}
got, err := p.FetchMetadata(ctx, infoHash[:])
if err != nil {
t.Fatalf("FetchMetadata: %v", err)
}
if !bytes.Equal(got, metaBytes) {
t.Errorf("fetched metadata mismatch: got %d bytes, want %d", len(got), len(metaBytes))
}
}

func TestFetchMetadataRejectsBadSize(t *testing.T) {
t.Parallel()
// The size guards run before any network I/O, so a bare PeerConn is enough.
Expand Down
56 changes: 56 additions & 0 deletions internal/client/testhelpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,62 @@ func listenTCP(t *testing.T) (net.Listener, error) {
return net.Listen("tcp", "127.0.0.1:0")
}

// serveMetadataPeer simulates a peer that supports BEP 9 ut_metadata: it
// advertises metadata_size in the extended handshake and answers metadata
// requests with the bytes of metaBytes in 16 KiB pieces.
func serveMetadataPeer(conn net.Conn, metaBytes []byte) {
defer conn.Close()

buf := make([]byte, 68)
if _, err := io.ReadFull(conn, buf); err != nil {
return
}
conn.Write(buf) // echo handshake (preserves BEP 10 bit + info hash)
if buf[25]&0x10 == 0 {
return
}
ReadMessage(conn) // client's extended handshake

extPayload, _ := bencode.EncodeBytes(map[string]interface{}{
"m": map[string]int{"ut_metadata": 1},
"metadata_size": len(metaBytes),
})
WriteMessage(conn, &Message{ID: MsgExtended, Payload: append([]byte{0}, extPayload...)})

for {
m, err := ReadMessage(conn)
if err != nil {
return
}
if m == nil || m.ID != MsgExtended || len(m.Payload) < 2 {
continue
}
var req struct {
MsgType int `bencode:"msg_type"`
Piece int `bencode:"piece"`
}
if err := bencode.DecodeBytes(m.Payload[1:], &req); err != nil || req.MsgType != 0 {
continue
}
start := req.Piece * 16384
end := start + 16384
if start > len(metaBytes) {
start = len(metaBytes)
}
if end > len(metaBytes) {
end = len(metaBytes)
}
header, _ := bencode.EncodeBytes(map[string]int{
"msg_type": 1, // Data
"piece": req.Piece,
"total_size": len(metaBytes),
})
payload := append([]byte{1}, header...) // ext id (arbitrary) + header
payload = append(payload, metaBytes[start:end]...)
WriteMessage(conn, &Message{ID: MsgExtended, Payload: payload})
}
}

// handleTestPeerConn simulates a minimal BitTorrent peer.
// It handles BEP 3 handshake (echoed), BEP 10 extended handshake, and piece requests.
func handleTestPeerConn(conn net.Conn, allData []byte) {
Expand Down
22 changes: 21 additions & 1 deletion internal/torrent/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,26 @@ func Parse(data []byte) (TorrentMeta, error) {
return TorrentMeta{}, fmt.Errorf("missing or invalid info dict")
}

return parseInfoMap(info), nil
}

// ParseInfo decodes a bare bencoded info dictionary (the value of the "info"
// key, as returned by BEP 9 ut_metadata exchange) into TorrentMeta. Same LangSec
// posture as Parse: validate the structure before the permissive decoder runs.
func ParseInfo(infoData []byte) (TorrentMeta, error) {
if err := wbencode.Validate(infoData, wbencode.TorrentLimits); err != nil {
return TorrentMeta{}, fmt.Errorf("info dict validate: %w", err)
}
var info map[string]interface{}
if err := bencode.DecodeBytes(infoData, &info); err != nil {
return TorrentMeta{}, fmt.Errorf("bencode decode: %w", err)
}
return parseInfoMap(info), nil
}

// parseInfoMap extracts TorrentMeta fields from a decoded info dict. Shared by
// Parse (full .torrent) and ParseInfo (bare info dict from a peer).
func parseInfoMap(info map[string]interface{}) TorrentMeta {
meta := TorrentMeta{}
if v, ok := info["name"].(string); ok {
meta.Name = v
Expand Down Expand Up @@ -105,7 +125,7 @@ func Parse(data []byte) (TorrentMeta, error) {
meta.PieceCount = int((meta.TotalSize + int64(meta.PieceLength) - 1) / int64(meta.PieceLength))
}

return meta, nil
return meta
}

// walkFileTree recursively walks a BEP 52 file tree and collects file entries.
Expand Down
13 changes: 13 additions & 0 deletions internal/torrent/torrent.go
Original file line number Diff line number Diff line change
Expand Up @@ -601,3 +601,16 @@ func (d *orderedDict) MarshalBencode() ([]byte, error) {
buf = append(buf, 'e')
return buf, nil
}

// BuildMetainfo wraps a bare info dict (e.g. fetched from a peer via BEP 9) into
// a minimal .torrent metainfo so it can be saved to disk. infoData must be the
// exact bencoded info dict — it is embedded verbatim so the info hash is
// preserved. announce is optional.
func BuildMetainfo(infoData []byte, announce string) ([]byte, error) {
m := newOrderedDict()
if announce != "" {
m.set("announce", announce)
}
m.set("info", bencode.RawMessage(infoData))
return bencode.EncodeBytes(m)
}
58 changes: 58 additions & 0 deletions internal/torrent/torrent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,64 @@ func TestHybridGoldenVectors(t *testing.T) {
}
}

func TestParseInfoRoundTrip(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "rt.bin")
data := make([]byte, 300000)
for i := range data {
data[i] = byte(i * 13)
}
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatal(err)
}
result, err := Create(CreateOptions{Path: path, Name: "rt.bin", PieceLength: 256 * 1024, AnnounceURL: "http://t/announce"})
if err != nil {
t.Fatal(err)
}
full, err := Parse(result.TorrentBytes)
if err != nil {
t.Fatal(err)
}

// Extract the exact info-dict bytes (what a peer would serve over BEP 9).
var raw map[string]bencode.RawMessage
if err := bencode.DecodeBytes(result.TorrentBytes, &raw); err != nil {
t.Fatal(err)
}
infoBytes := []byte(raw["info"])
if sha256.Sum256(infoBytes) != result.InfoHash {
t.Fatal("extracted info bytes do not reproduce the v2 info hash")
}

// ParseInfo on the bare info dict must match Parse on the full torrent.
fromInfo, err := ParseInfo(infoBytes)
if err != nil {
t.Fatal(err)
}
if fromInfo.Name != full.Name || fromInfo.PieceLength != full.PieceLength ||
fromInfo.PieceCount != full.PieceCount || fromInfo.TotalSize != full.TotalSize ||
len(fromInfo.Files) != len(full.Files) || !bytes.Equal(fromInfo.Pieces, full.Pieces) {
t.Errorf("ParseInfo != Parse:\n got %+v\nwant %+v", fromInfo, full)
}

// BuildMetainfo must round-trip and preserve the info hash.
tb, err := BuildMetainfo(infoBytes, "http://t/announce")
if err != nil {
t.Fatal(err)
}
var raw2 map[string]bencode.RawMessage
if err := bencode.DecodeBytes(tb, &raw2); err != nil {
t.Fatal(err)
}
if sha256.Sum256([]byte(raw2["info"])) != result.InfoHash {
t.Error("BuildMetainfo changed the info hash")
}
if rebuilt, err := Parse(tb); err != nil || rebuilt.TotalSize != full.TotalSize {
t.Errorf("BuildMetainfo round-trip failed: %v", err)
}
}

func TestCreateStream(t *testing.T) {
t.Parallel()

Expand Down
Loading