diff --git a/cmd/wl/main.go b/cmd/wl/main.go index a586fa4..967cd73 100644 --- a/cmd/wl/main.go +++ b/cmd/wl/main.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "crypto/hmac" + "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" @@ -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 @@ -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, @@ -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, "/") diff --git a/cmd/wl/main_test.go b/cmd/wl/main_test.go index b921609..1c653c2 100644 --- a/cmd/wl/main_test.go +++ b/cmd/wl/main_test.go @@ -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) } } diff --git a/internal/client/metadata_test.go b/internal/client/metadata_test.go index 279ea42..e956ed0 100644 --- a/internal/client/metadata_test.go +++ b/internal/client/metadata_test.go @@ -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. diff --git a/internal/client/testhelpers_test.go b/internal/client/testhelpers_test.go index 6c915e5..bcc5bb7 100644 --- a/internal/client/testhelpers_test.go +++ b/internal/client/testhelpers_test.go @@ -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) { diff --git a/internal/torrent/parse.go b/internal/torrent/parse.go index 2d6492c..6a964a6 100644 --- a/internal/torrent/parse.go +++ b/internal/torrent/parse.go @@ -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 @@ -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. diff --git a/internal/torrent/torrent.go b/internal/torrent/torrent.go index a359623..6343f89 100644 --- a/internal/torrent/torrent.go +++ b/internal/torrent/torrent.go @@ -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) +} diff --git a/internal/torrent/torrent_test.go b/internal/torrent/torrent_test.go index 52f158e..154a2bc 100644 --- a/internal/torrent/torrent_test.go +++ b/internal/torrent/torrent_test.go @@ -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()