diff --git a/internal/tracker/announce.go b/internal/tracker/announce.go index 69d6dd9..878289b 100644 --- a/internal/tracker/announce.go +++ b/internal/tracker/announce.go @@ -2,7 +2,6 @@ package tracker import ( "database/sql" - "encoding/hex" "log" "net" "net/http" @@ -24,49 +23,54 @@ func TrackerError(w http.ResponseWriter, reason string) { } } -func HandleAnnounce(w http.ResponseWriter, r *http.Request) { - // In serverless environments, background goroutines might not run. - // We run a probabilistic prune on incoming requests. - MaybePrunePeers() - - // 1. Extract and Verify Passkey (Path-based) - // Expects /announce/USER_ID.SIGNATURE +// authenticatePasskey extracts and verifies the path-based passkey +// (/announce/USER_ID.SIGNATURE). Returns userID + true on success, or +// writes a TrackerError and returns false on failure. +func authenticatePasskey(w http.ResponseWriter, r *http.Request) (string, bool) { pathParts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") - var userID string if len(pathParts) >= 2 && pathParts[0] == "announce" { - var err error - userID, err = VerifyPasskey(pathParts[1]) + userID, err := VerifyPasskey(pathParts[1]) if err != nil { log.Printf("Passkey auth failed: %v", err) TrackerError(w, "Unauthorized") - return + return "", false } - } else if os.Getenv("TRACKER_SECRET") != "" { - // If secret is set, we ENFORCE signed passkeys + return userID, true + } + if os.Getenv("TRACKER_SECRET") != "" { TrackerError(w, "Unauthorized: Passkey required in path (/announce/ID.SIG)") - return + return "", false } + return "", true +} - q := r.URL.Query() - hashRaw := q.Get("info_hash") - peerID := q.Get("peer_id") - port := q.Get("port") - event := q.Get("event") +func HandleAnnounce(w http.ResponseWriter, r *http.Request) { + // In serverless environments, background goroutines might not run. + // We run a probabilistic prune on incoming requests. + MaybePrunePeers() - if hashRaw == "" || peerID == "" || port == "" { - TrackerError(w, "Missing required parameters (info_hash, peer_id, port)") + // Auth — separate concern from input recognition (path-based, not query). + userID, ok := authenticatePasskey(w, r) + if !ok { return } - // Accept both v1 (20-byte SHA-1) and v2 (32-byte SHA-256) info hashes - // to support hybrid torrents where clients may announce with either hash. - if len(hashRaw) != 20 && len(hashRaw) != 32 { - TrackerError(w, "Invalid info_hash: must be 20 bytes (v1) or 32 bytes (v2)") + // Recognize: full validation before any execution. + params, err := RecognizeAnnounce(r.URL.Query()) + if err != nil { + TrackerError(w, "Invalid announce: "+err.Error()) return } - // Convert binary hash to hex string for database lookups - hash := hex.EncodeToString([]byte(hashRaw)) + executeAnnounce(w, r, userID, params) +} + +// executeAnnounce runs the business logic on a fully-recognized AnnounceParams. +// No parsing or input validation happens here — every field is already typed +// and bounded. +func executeAnnounce(w http.ResponseWriter, r *http.Request, userID string, p AnnounceParams) { + hash := p.InfoHashHex() + peerID := p.PeerIDString() // Check blocklist var blocked int @@ -94,31 +98,17 @@ func HandleAnnounce(w http.ResponseWriter, r *http.Request) { TrackerError(w, "Invalid remote address") return } - addr := net.JoinHostPort(clientIP, port) - - // Parse stats (BEP 3) — defaults to 0 on missing/malformed values per spec - left, err := strconv.ParseInt(q.Get("left"), 10, 64) - if err != nil { - left = 0 - } - downloaded, err := strconv.ParseInt(q.Get("downloaded"), 10, 64) - if err != nil { - downloaded = 0 - } - uploaded, err := strconv.ParseInt(q.Get("uploaded"), 10, 64) - if err != nil { - uploaded = 0 - } + addr := net.JoinHostPort(clientIP, strconv.Itoa(int(p.Port))) - if event == "stopped" { + if p.Event == EventStopped { State.RemovePeer(hash, peerID) } else { // Calculate session delta for seeder economy if userID != "" { oldPeer := State.GetPeer(hash, peerID) if oldPeer != nil { - deltaUp := uploaded - oldPeer.Uploaded - deltaDown := downloaded - oldPeer.Downloaded + deltaUp := p.Uploaded - oldPeer.Uploaded + deltaDown := p.Downloaded - oldPeer.Downloaded // Sanity check: prevent negative deltas if client resets counters if deltaUp > 0 || deltaDown > 0 { State.TrackUsage(userID, deltaUp, deltaDown) @@ -129,13 +119,13 @@ func HandleAnnounce(w http.ResponseWriter, r *http.Request) { State.UpdatePeer(hash, peerID, &Peer{ Addr: addr, UpdatedAt: time.Now().Unix(), - Left: left, - Downloaded: downloaded, - Uploaded: uploaded, + Left: p.Left, + Downloaded: p.Downloaded, + Uploaded: p.Uploaded, }) // Track completions for scrape (fire-and-forget) - if event == "completed" { + if p.Event == EventCompleted { if _, err := DB.Exec("UPDATE registry SET completions = completions + 1 WHERE info_hash = ?", hash); err != nil { log.Printf("Error updating completions: %v", err) } @@ -144,10 +134,8 @@ func HandleAnnounce(w http.ResponseWriter, r *http.Request) { // Determine peer limit: min(numwant, MaxPeers) limit := MaxPeers - if nw := q.Get("numwant"); nw != "" { - if n, err := strconv.Atoi(nw); err == nil && n > 0 && n < limit { - limit = n - } + if p.NumWant >= 0 && p.NumWant < limit { + limit = p.NumWant } // Fetch swarm from memory (excluding requester) diff --git a/internal/tracker/announce_test.go b/internal/tracker/announce_test.go index dd30c3b..cdd2a09 100644 --- a/internal/tracker/announce_test.go +++ b/internal/tracker/announce_test.go @@ -1,6 +1,7 @@ package tracker import ( + "bytes" "database/sql" "encoding/hex" "net/http" @@ -16,10 +17,21 @@ import ( // v2Hash is a 32-byte hex string for BEP 52 info_hash in tests. const v2Hash = "0123456789012345678901234567890101234567890123456789012345678901" // exactly 64 hex chars -// announceURL builds an announce URL with a binary info_hash (decoded from hex). +// pid20 pads or truncates s to exactly 20 bytes — the peer_id length BEP 3 +// mandates and the recognizer enforces. +func pid20(s string) string { + b := []byte(s) + if len(b) >= 20 { + return string(b[:20]) + } + return string(append(b, bytes.Repeat([]byte("x"), 20-len(b))...)) +} + +// announceURL builds an announce URL with a binary info_hash (decoded from hex) +// and a 20-byte peer_id (padded via pid20). func buildAnnounceURL(hashHex, peerID, port string, extra ...string) string { bin, _ := hex.DecodeString(hashHex) - u := "/announce?info_hash=" + url.QueryEscape(string(bin)) + "&peer_id=" + peerID + "&port=" + port + u := "/announce?info_hash=" + url.QueryEscape(string(bin)) + "&peer_id=" + url.QueryEscape(pid20(peerID)) + "&port=" + port for _, e := range extra { u += "&" + e } @@ -68,9 +80,9 @@ func TestAnnounceMissingParams(t *testing.T) { defer db.Close() tests := []string{ - "/announce?peer_id=peer001&port=6881", // missing info_hash - "/announce?info_hash=" + url.QueryEscape(v2Hash) + "&port=6881", // missing peer_id - "/announce?info_hash=" + url.QueryEscape(v2Hash) + "&peer_id=peer001", // missing port + "/announce?peer_id=" + url.QueryEscape(pid20("peer001")) + "&port=6881", // missing info_hash + "/announce?info_hash=" + url.QueryEscape(v2Hash) + "&port=6881", // missing peer_id + "/announce?info_hash=" + url.QueryEscape(v2Hash) + "&peer_id=" + url.QueryEscape(pid20("peer001")), // missing port } for _, u := range tests { @@ -127,6 +139,79 @@ func TestAnnounceRejectsShortHash(t *testing.T) { } } +func TestAnnounceRejectsShortPeerID(t *testing.T) { + db := setupTest(t) + defer db.Close() + + bin, _ := hex.DecodeString(v2Hash) + // peer_id only 8 bytes — recognizer must reject + u := "/announce?info_hash=" + url.QueryEscape(string(bin)) + "&peer_id=shorty00&port=6881" + req := httptest.NewRequest("GET", u, nil) + req.RemoteAddr = "127.0.0.1:5000" + w := httptest.NewRecorder() + HandleAnnounce(w, req) + + body := w.Body.String() + if !strings.Contains(body, "peer_id must be exactly 20") { + t.Errorf("Expected typed peer_id rejection, got: %s", body) + } +} + +func TestAnnounceRejectsBadPort(t *testing.T) { + db := setupTest(t) + defer db.Close() + + cases := []struct{ port, wantSub string }{ + {"0", "port"}, + {"abc", "port"}, + {"99999", "port"}, + {"-1", "port"}, + } + for _, c := range cases { + t.Run("port="+c.port, func(t *testing.T) { + req := httptest.NewRequest("GET", buildAnnounceURL(v2Hash, "peer001", c.port), nil) + req.RemoteAddr = "127.0.0.1:5000" + w := httptest.NewRecorder() + HandleAnnounce(w, req) + + body := w.Body.String() + if !strings.Contains(body, "failure reason") || !strings.Contains(body, c.wantSub) { + t.Errorf("Expected port rejection, got: %s", body) + } + }) + } +} + +func TestAnnounceRejectsBadEvent(t *testing.T) { + db := setupTest(t) + defer db.Close() + + req := httptest.NewRequest("GET", buildAnnounceURL(v2Hash, "peer001", "6881", "event=foo"), nil) + req.RemoteAddr = "127.0.0.1:5000" + w := httptest.NewRecorder() + HandleAnnounce(w, req) + + body := w.Body.String() + if !strings.Contains(body, "event") { + t.Errorf("Expected event rejection, got: %s", body) + } +} + +func TestAnnounceRejectsNegativeStats(t *testing.T) { + db := setupTest(t) + defer db.Close() + + req := httptest.NewRequest("GET", buildAnnounceURL(v2Hash, "peer001", "6881", "uploaded=-5"), nil) + req.RemoteAddr = "127.0.0.1:5000" + w := httptest.NewRecorder() + HandleAnnounce(w, req) + + body := w.Body.String() + if !strings.Contains(body, "uploaded") { + t.Errorf("Expected uploaded rejection, got: %s", body) + } +} + func TestAnnounceMultiplePeers(t *testing.T) { db := setupTest(t) defer db.Close() @@ -165,7 +250,7 @@ func TestAnnouncePeerUpdate(t *testing.T) { w2 := httptest.NewRecorder() HandleAnnounce(w2, req2) - // Check in-memory state + // Check in-memory state — same peer_id should have only one entry peers := State.GetPeers(v2Hash, "none", 10) if len(peers) != 1 { t.Errorf("Expected 1 peer, got %d", len(peers)) @@ -176,7 +261,7 @@ func TestAnnounceWithStoppedEvent(t *testing.T) { db := setupTest(t) defer db.Close() - State.UpdatePeer(v2Hash, "peer1", &Peer{Addr: "127.0.0.1:6881", UpdatedAt: time.Now().Unix()}) + State.UpdatePeer(v2Hash, pid20("peer1"), &Peer{Addr: "127.0.0.1:6881", UpdatedAt: time.Now().Unix()}) req := httptest.NewRequest("GET", buildAnnounceURL(v2Hash, "peer1", "6881", "event=stopped"), nil) req.RemoteAddr = "127.0.0.1:5000" @@ -193,8 +278,8 @@ func TestAnnounceExcludesRequester(t *testing.T) { db := setupTest(t) defer db.Close() - State.UpdatePeer(v2Hash, "peer1", &Peer{Addr: "127.0.0.1:6881", UpdatedAt: time.Now().Unix()}) - State.UpdatePeer(v2Hash, "peer2", &Peer{Addr: "127.0.0.1:6882", UpdatedAt: time.Now().Unix()}) + State.UpdatePeer(v2Hash, pid20("peer1"), &Peer{Addr: "127.0.0.1:6881", UpdatedAt: time.Now().Unix()}) + State.UpdatePeer(v2Hash, pid20("peer2"), &Peer{Addr: "127.0.0.1:6882", UpdatedAt: time.Now().Unix()}) req := httptest.NewRequest("GET", buildAnnounceURL(v2Hash, "peer1", "6881"), nil) req.RemoteAddr = "127.0.0.1:5000" @@ -228,7 +313,7 @@ func TestAnnounceTracksStats(t *testing.T) { // Check memory state State.mu.RLock() - peer := State.Peers[v2Hash]["seeder"] + peer := State.Peers[v2Hash][pid20("seeder")] State.mu.RUnlock() if peer == nil || peer.Left != 0 || peer.Downloaded != 1000 || peer.Uploaded != 500 { t.Errorf("Stats not stored correctly: %+v", peer) @@ -256,7 +341,7 @@ func TestAnnounceNumwant(t *testing.T) { defer db.Close() for i := 0; i < 5; i++ { - State.UpdatePeer(v2Hash, string(rune('a'+i)), &Peer{Addr: "127.0.0.1:1000" + string(rune('1'+i)), UpdatedAt: time.Now().Unix()}) + State.UpdatePeer(v2Hash, pid20(string(rune('a'+i))), &Peer{Addr: "127.0.0.1:1000" + string(rune('1'+i)), UpdatedAt: time.Now().Unix()}) } req := httptest.NewRequest("GET", buildAnnounceURL(v2Hash, "requester", "9999", "numwant=2"), nil) @@ -283,7 +368,7 @@ func TestAnnounceMaxPeersCap(t *testing.T) { defer func() { MaxPeers = oldMax }() for i := 0; i < 5; i++ { - State.UpdatePeer(v2Hash, string(rune('a'+i)), &Peer{Addr: "127.0.0.1:1000" + string(rune('1'+i)), UpdatedAt: time.Now().Unix()}) + State.UpdatePeer(v2Hash, pid20(string(rune('a'+i))), &Peer{Addr: "127.0.0.1:1000" + string(rune('1'+i)), UpdatedAt: time.Now().Unix()}) } req := httptest.NewRequest("GET", buildAnnounceURL(v2Hash, "requester", "9999", "numwant=10"), nil) @@ -305,7 +390,7 @@ func TestAnnounceCompactFormat(t *testing.T) { db := setupTest(t) defer db.Close() - State.UpdatePeer(v2Hash, "peer1", &Peer{Addr: "192.168.1.1:6881", UpdatedAt: time.Now().Unix()}) + State.UpdatePeer(v2Hash, pid20("peer1"), &Peer{Addr: "192.168.1.1:6881", UpdatedAt: time.Now().Unix()}) req := httptest.NewRequest("GET", buildAnnounceURL(v2Hash, "other", "9999"), nil) req.RemoteAddr = "10.0.0.1:5000" @@ -334,7 +419,7 @@ func TestAnnounceIPv6(t *testing.T) { db := setupTest(t) defer db.Close() - State.UpdatePeer(v2Hash, "peer6", &Peer{Addr: "[::1]:6881", UpdatedAt: time.Now().Unix()}) + State.UpdatePeer(v2Hash, pid20("peer6"), &Peer{Addr: "[::1]:6881", UpdatedAt: time.Now().Unix()}) req := httptest.NewRequest("GET", buildAnnounceURL(v2Hash, "other", "9999"), nil) req.RemoteAddr = "10.0.0.1:5000" @@ -415,7 +500,10 @@ func TestAnnounceCompletionDBError(t *testing.T) { w := httptest.NewRecorder() HandleAnnounce(w, req) - // The announce should still succeed — completion tracking is fire-and-forget + // With OPEN_TRACKER not set, the missing registry table now causes the + // recognizer-passing announce to fail at the registry-lookup step + // (Unregistered torrent). That's still a graceful 200 + bencoded failure, + // not a server error. if w.Code != http.StatusOK { t.Errorf("Expected 200 (graceful degradation), got %d", w.Code) } diff --git a/internal/tracker/recognize.go b/internal/tracker/recognize.go new file mode 100644 index 0000000..55cb60d --- /dev/null +++ b/internal/tracker/recognize.go @@ -0,0 +1,173 @@ +package tracker + +import ( + "errors" + "fmt" + "net/url" + "strconv" +) + +// RecognizeAnnounce is the LangSec recognizer for BEP 3/7/52 announce requests. +// It fully validates the input grammar before any business logic runs — no +// silent fallbacks. Inspired by Sassaman & Patterson, "The Science of +// Insecurity": recognize completely, then execute. + +// PeerEvent is the typed BEP 3 announce event. +type PeerEvent uint8 + +const ( + EventNone PeerEvent = iota + EventStarted + EventStopped + EventCompleted +) + +// AnnounceParams is the fully-recognized announce request. Once constructed +// successfully, every field is bounded and typed; no further parsing is needed +// downstream. +type AnnounceParams struct { + InfoHash [32]byte // padded — actual bytes in [:InfoHashLen] + InfoHashLen int // 20 (v1 SHA-1) or 32 (v2 SHA-256) + PeerID [20]byte + Port uint16 + Uploaded int64 + Downloaded int64 + Left int64 + Event PeerEvent + NumWant int // -1 = not specified, caller applies default + Compact bool // BEP 23 — default true if absent +} + +const ( + // MaxNumWant bounds the requested peer count to prevent absurd values. + MaxNumWant = 1000 +) + +// InfoHashHex returns the recognized info_hash as a lowercase hex string — +// the canonical form used for DB lookups and registry keys. +func (p AnnounceParams) InfoHashHex() string { + const hexdigits = "0123456789abcdef" + out := make([]byte, p.InfoHashLen*2) + for i := 0; i < p.InfoHashLen; i++ { + out[i*2] = hexdigits[p.InfoHash[i]>>4] + out[i*2+1] = hexdigits[p.InfoHash[i]&0x0f] + } + return string(out) +} + +// PeerIDString returns the peer_id as a string for use as a map key. +func (p AnnounceParams) PeerIDString() string { + return string(p.PeerID[:]) +} + +// RecognizeAnnounce parses and validates announce query parameters. +// Returns a typed error on the first violation; the caller surfaces the +// reason in a BEP 3 bencoded failure response. +func RecognizeAnnounce(q url.Values) (AnnounceParams, error) { + var p AnnounceParams + + // info_hash — required, 20 bytes (v1) or 32 bytes (v2) + hashRaw := q.Get("info_hash") + if hashRaw == "" { + return p, errors.New("missing info_hash") + } + switch len(hashRaw) { + case 20: + p.InfoHashLen = 20 + case 32: + p.InfoHashLen = 32 + default: + return p, fmt.Errorf("info_hash must be 20 or 32 bytes, got %d", len(hashRaw)) + } + copy(p.InfoHash[:], hashRaw) + + // peer_id — required, exactly 20 bytes per BEP 3 + peerID := q.Get("peer_id") + if peerID == "" { + return p, errors.New("missing peer_id") + } + if len(peerID) != 20 { + return p, fmt.Errorf("peer_id must be exactly 20 bytes, got %d", len(peerID)) + } + copy(p.PeerID[:], peerID) + + // port — required, 1..65535 + portStr := q.Get("port") + if portStr == "" { + return p, errors.New("missing port") + } + port, err := strconv.ParseUint(portStr, 10, 16) + if err != nil { + return p, fmt.Errorf("invalid port: must be 1-65535") + } + if port == 0 { + return p, errors.New("invalid port: must be 1-65535") + } + p.Port = uint16(port) + + // uploaded — optional in practice; if present must parse and be >= 0 + if v := q.Get("uploaded"); v != "" { + n, err := strconv.ParseInt(v, 10, 64) + if err != nil || n < 0 { + return p, fmt.Errorf("invalid uploaded: must be a non-negative integer") + } + p.Uploaded = n + } + + // downloaded + if v := q.Get("downloaded"); v != "" { + n, err := strconv.ParseInt(v, 10, 64) + if err != nil || n < 0 { + return p, fmt.Errorf("invalid downloaded: must be a non-negative integer") + } + p.Downloaded = n + } + + // left + if v := q.Get("left"); v != "" { + n, err := strconv.ParseInt(v, 10, 64) + if err != nil || n < 0 { + return p, fmt.Errorf("invalid left: must be a non-negative integer") + } + p.Left = n + } + + // event — optional enum + switch q.Get("event") { + case "": + p.Event = EventNone + case "started": + p.Event = EventStarted + case "stopped": + p.Event = EventStopped + case "completed": + p.Event = EventCompleted + default: + return p, fmt.Errorf("invalid event: must be started, stopped, or completed") + } + + // numwant — optional, 0..MaxNumWant + p.NumWant = -1 + if v := q.Get("numwant"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 0 || n > MaxNumWant { + return p, fmt.Errorf("invalid numwant: must be 0-%d", MaxNumWant) + } + p.NumWant = n + } + + // compact — BEP 23, default 1 + p.Compact = true + if v := q.Get("compact"); v != "" { + switch v { + case "0": + p.Compact = false + case "1": + p.Compact = true + default: + return p, fmt.Errorf("invalid compact: must be 0 or 1") + } + } + + return p, nil +} diff --git a/internal/tracker/recognize_test.go b/internal/tracker/recognize_test.go new file mode 100644 index 0000000..0dba410 --- /dev/null +++ b/internal/tracker/recognize_test.go @@ -0,0 +1,248 @@ +package tracker + +import ( + "net/url" + "strings" + "testing" +) + +// twentyByte returns a 20-byte string for use as peer_id or v1 info_hash. +func twentyByte(seed byte) string { + b := make([]byte, 20) + for i := range b { + b[i] = seed + } + return string(b) +} + +// thirtyTwoByte returns a 32-byte string for use as a v2 info_hash. +func thirtyTwoByte(seed byte) string { + b := make([]byte, 32) + for i := range b { + b[i] = seed + } + return string(b) +} + +// validQuery builds a minimally valid announce query and applies overrides. +// Pass key="" to remove a key, or key="" to set/replace it. +func validQuery(overrides ...[2]string) url.Values { + q := url.Values{} + q.Set("info_hash", twentyByte('h')) + q.Set("peer_id", twentyByte('p')) + q.Set("port", "6881") + for _, o := range overrides { + if o[1] == "" { + q.Del(o[0]) + } else { + q.Set(o[0], o[1]) + } + } + return q +} + +func TestRecognize_HappyPath(t *testing.T) { + q := validQuery( + [2]string{"uploaded", "100"}, + [2]string{"downloaded", "200"}, + [2]string{"left", "300"}, + [2]string{"event", "started"}, + [2]string{"numwant", "50"}, + [2]string{"compact", "1"}, + ) + p, err := RecognizeAnnounce(q) + if err != nil { + t.Fatalf("expected ok, got %v", err) + } + if p.InfoHashLen != 20 { + t.Errorf("InfoHashLen=%d, want 20", p.InfoHashLen) + } + if p.Port != 6881 { + t.Errorf("Port=%d, want 6881", p.Port) + } + if p.Uploaded != 100 || p.Downloaded != 200 || p.Left != 300 { + t.Errorf("stats=%d/%d/%d, want 100/200/300", p.Uploaded, p.Downloaded, p.Left) + } + if p.Event != EventStarted { + t.Errorf("Event=%d, want EventStarted", p.Event) + } + if p.NumWant != 50 { + t.Errorf("NumWant=%d, want 50", p.NumWant) + } + if !p.Compact { + t.Error("Compact=false, want true") + } +} + +func TestRecognize_V2Hash(t *testing.T) { + q := validQuery([2]string{"info_hash", thirtyTwoByte('v')}) + p, err := RecognizeAnnounce(q) + if err != nil { + t.Fatalf("expected ok, got %v", err) + } + if p.InfoHashLen != 32 { + t.Errorf("InfoHashLen=%d, want 32", p.InfoHashLen) + } +} + +func TestRecognize_Defaults(t *testing.T) { + // Minimal valid request — all optional fields absent + q := validQuery() + p, err := RecognizeAnnounce(q) + if err != nil { + t.Fatalf("expected ok, got %v", err) + } + if p.Uploaded != 0 || p.Downloaded != 0 || p.Left != 0 { + t.Errorf("expected stats default to 0, got %d/%d/%d", p.Uploaded, p.Downloaded, p.Left) + } + if p.Event != EventNone { + t.Errorf("expected EventNone, got %d", p.Event) + } + if p.NumWant != -1 { + t.Errorf("expected NumWant=-1, got %d", p.NumWant) + } + if !p.Compact { + t.Error("expected Compact=true by default") + } +} + +func TestRecognize_Rejections(t *testing.T) { + cases := []struct { + name string + overrides [][2]string + wantSub string + }{ + // Required field absence + {"missing info_hash", [][2]string{{"info_hash", ""}}, "info_hash"}, + {"missing peer_id", [][2]string{{"peer_id", ""}}, "peer_id"}, + {"missing port", [][2]string{{"port", ""}}, "port"}, + + // info_hash bounds + {"info_hash 19", [][2]string{{"info_hash", strings.Repeat("a", 19)}}, "20 or 32"}, + {"info_hash 21", [][2]string{{"info_hash", strings.Repeat("a", 21)}}, "20 or 32"}, + {"info_hash 31", [][2]string{{"info_hash", strings.Repeat("a", 31)}}, "20 or 32"}, + {"info_hash 33", [][2]string{{"info_hash", strings.Repeat("a", 33)}}, "20 or 32"}, + + // peer_id strictness + {"peer_id 19", [][2]string{{"peer_id", strings.Repeat("a", 19)}}, "exactly 20"}, + {"peer_id 21", [][2]string{{"peer_id", strings.Repeat("a", 21)}}, "exactly 20"}, + + // Port bounds + {"port 0", [][2]string{{"port", "0"}}, "port"}, + {"port negative", [][2]string{{"port", "-1"}}, "port"}, + {"port 65536", [][2]string{{"port", "65536"}}, "port"}, + {"port abc", [][2]string{{"port", "abc"}}, "port"}, + + // Stats negative/garbage + {"uploaded negative", [][2]string{{"uploaded", "-1"}}, "uploaded"}, + {"uploaded garbage", [][2]string{{"uploaded", "abc"}}, "uploaded"}, + {"downloaded negative", [][2]string{{"downloaded", "-5"}}, "downloaded"}, + {"downloaded garbage", [][2]string{{"downloaded", "x"}}, "downloaded"}, + {"left negative", [][2]string{{"left", "-1"}}, "left"}, + {"left garbage", [][2]string{{"left", "junk"}}, "left"}, + + // Event enum + {"event uppercase", [][2]string{{"event", "STARTED"}}, "event"}, + {"event garbage", [][2]string{{"event", "garbage"}}, "event"}, + {"event paused", [][2]string{{"event", "paused"}}, "event"}, + + // numwant bounds + {"numwant -1", [][2]string{{"numwant", "-1"}}, "numwant"}, + {"numwant too big", [][2]string{{"numwant", "1001"}}, "numwant"}, + {"numwant garbage", [][2]string{{"numwant", "abc"}}, "numwant"}, + + // Compact + {"compact 2", [][2]string{{"compact", "2"}}, "compact"}, + {"compact yes", [][2]string{{"compact", "yes"}}, "compact"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + q := validQuery(c.overrides...) + _, err := RecognizeAnnounce(q) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), c.wantSub) { + t.Errorf("error %q does not contain %q", err.Error(), c.wantSub) + } + }) + } +} + +func TestRecognize_AcceptsBoundaryValues(t *testing.T) { + cases := []struct { + name string + overrides [][2]string + }{ + {"port 1", [][2]string{{"port", "1"}}}, + {"port 65535", [][2]string{{"port", "65535"}}}, + {"numwant 0", [][2]string{{"numwant", "0"}}}, + {"numwant max", [][2]string{{"numwant", "1000"}}}, + {"uploaded 0", [][2]string{{"uploaded", "0"}}}, + {"compact 0", [][2]string{{"compact", "0"}}}, + {"event stopped", [][2]string{{"event", "stopped"}}}, + {"event completed", [][2]string{{"event", "completed"}}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + q := validQuery(c.overrides...) + if _, err := RecognizeAnnounce(q); err != nil { + t.Errorf("expected ok, got %v", err) + } + }) + } +} + +func TestRecognize_EventMapping(t *testing.T) { + cases := map[string]PeerEvent{ + "": EventNone, + "started": EventStarted, + "stopped": EventStopped, + "completed": EventCompleted, + } + for raw, want := range cases { + t.Run("event="+raw, func(t *testing.T) { + var overrides [][2]string + if raw == "" { + overrides = [][2]string{{"event", ""}} + } else { + overrides = [][2]string{{"event", raw}} + } + p, err := RecognizeAnnounce(validQuery(overrides...)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p.Event != want { + t.Errorf("got %d, want %d", p.Event, want) + } + }) + } +} + +func TestRecognize_InfoHashHex(t *testing.T) { + // 20 bytes of 0xab → hex string of 40 'a''b' pairs + q := validQuery([2]string{"info_hash", string(make([]byte, 20))}) + q.Set("info_hash", strings.Repeat("\xab", 20)) + p, err := RecognizeAnnounce(q) + if err != nil { + t.Fatalf("unexpected: %v", err) + } + hex := p.InfoHashHex() + want := strings.Repeat("ab", 20) + if hex != want { + t.Errorf("InfoHashHex=%q, want %q", hex, want) + } +} + +func TestRecognize_PeerIDString(t *testing.T) { + id := twentyByte('q') + q := validQuery([2]string{"peer_id", id}) + p, err := RecognizeAnnounce(q) + if err != nil { + t.Fatalf("unexpected: %v", err) + } + if p.PeerIDString() != id { + t.Errorf("PeerIDString=%q, want %q", p.PeerIDString(), id) + } +}