This commit is contained in:
itexpert228 2026-03-06 12:02:52 +03:00
parent 62739ccae7
commit cf79db0932
11 changed files with 1147 additions and 1 deletions

View file

@ -1,3 +1,18 @@
# Ztorrent # Ztorrent
ну нихуя себе, как это великая ZOV OS и без своего торрент клиента, qbittorrent сосиииииииииииииииииииии Minimal BitTorrent client skeleton in Go.
## Run
```bash
go run ./cmd/torrent-client /path/to/file.torrent
```
## Current features
- Parse `.torrent` metadata and piece hashes
- Compute `info_hash` from raw `info` bytes (spec-compatible)
- Single-file and multi-file torrents
- Send tracker announce request with compact peer mode
- Parse compact peer list
- Show loaded torrent status and files in the UI layer

View file

@ -0,0 +1,14 @@
package main
import (
"github.com/veggiedefender/torrent-client/internal/app"
"github.com/veggiedefender/torrent-client/ui"
)
func main() {
controller := app.NewController()
ui.Start(controller)
}

5
go.mod Normal file
View file

@ -0,0 +1,5 @@
module github.com/veggiedefender/torrent-client
go 1.22
require github.com/jackpal/bencode-go v1.0.2

2
go.sum Normal file
View file

@ -0,0 +1,2 @@
github.com/jackpal/bencode-go v1.0.2 h1:LcCNfZ344u0LpBPOZNjpCLps/wUOuN4r87Fy9+5yU8g=
github.com/jackpal/bencode-go v1.0.2/go.mod h1:6jI9mUjO3GQbZti3JizEfxTzRfWOM8oBBcwbwlTfceI=

View file

@ -0,0 +1,31 @@
package app
import "github.com/veggiedefender/torrent-client/internal/torrent"
type Controller struct {
engine *torrent.Engine
}
func NewController() *Controller {
engine := torrent.NewEngine()
return &Controller{
engine: engine,
}
}
func (c *Controller) StartTorrent(path string) error {
return c.engine.LoadTorrent(path)
}
func (c *Controller) StopTorrent() {
c.engine.Stop()
}
func (c *Controller) Progress() float64 {
return c.engine.Progress()
}
func (c *Controller) Status() torrent.Status {
return c.engine.Status()
}

158
internal/torrent/engine.go Normal file
View file

@ -0,0 +1,158 @@
package torrent
import (
"context"
"crypto/rand"
"sync"
"time"
"github.com/veggiedefender/torrent-client/internal/torrentfile"
"github.com/veggiedefender/torrent-client/internal/tracker"
)
type Engine struct {
mu sync.RWMutex
torrent *torrentfile.TorrentFile
peers []tracker.Peer
peerID [20]byte
cancel context.CancelFunc
lastErr error
}
type Status struct {
Loaded bool
Name string
Length int
PieceLength int
PieceCount int
Files []torrentfile.File
Announce string
PeerCount int
PeerID string
Progress float64
LastError string
}
func NewEngine() *Engine {
return &Engine{
peerID: generatePeerID(),
}
}
func (e *Engine) LoadTorrent(path string) error {
tf, err := torrentfile.Open(path)
if err != nil {
e.setError(err)
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
e.swapCancel(cancel)
defer e.clearCancel()
peers, err := tracker.GetPeers(ctx, tf, tracker.AnnounceOptions{
PeerID: e.peerID,
Port: 6881,
})
if err != nil {
e.mu.Lock()
e.torrent = tf
e.peers = nil
e.lastErr = err
e.mu.Unlock()
return err
}
e.mu.Lock()
e.torrent = tf
e.peers = peers
e.lastErr = nil
e.mu.Unlock()
return nil
}
func (e *Engine) Stop() {
e.mu.Lock()
defer e.mu.Unlock()
if e.cancel != nil {
e.cancel()
e.cancel = nil
}
}
func (e *Engine) Progress() float64 {
return 0
}
func (e *Engine) Status() Status {
e.mu.RLock()
defer e.mu.RUnlock()
status := Status{
Loaded: e.torrent != nil,
PeerCount: len(e.peers),
PeerID: string(e.peerID[:]),
Progress: e.Progress(),
}
if e.torrent != nil {
status.Name = e.torrent.Name
status.Length = e.torrent.Length
status.PieceLength = e.torrent.PieceLength
status.PieceCount = len(e.torrent.PieceHashes)
status.Files = append(status.Files, e.torrent.Files...)
status.Announce = e.torrent.Announce
}
if e.lastErr != nil {
status.LastError = e.lastErr.Error()
}
return status
}
func (e *Engine) setError(err error) {
e.mu.Lock()
defer e.mu.Unlock()
e.lastErr = err
}
func (e *Engine) swapCancel(cancel context.CancelFunc) {
e.mu.Lock()
defer e.mu.Unlock()
if e.cancel != nil {
e.cancel()
}
e.cancel = cancel
}
func (e *Engine) clearCancel() {
e.mu.Lock()
defer e.mu.Unlock()
if e.cancel != nil {
e.cancel()
e.cancel = nil
}
}
func generatePeerID() [20]byte {
const prefix = "-ZT0001-"
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"
var id [20]byte
copy(id[:], prefix)
buf := make([]byte, len(id)-len(prefix))
if _, err := rand.Read(buf); err != nil {
now := time.Now().UnixNano()
for i := range buf {
buf[i] = byte(now >> (i * 8))
}
}
for i, b := range buf {
id[len(prefix)+i] = alphabet[int(b)%len(alphabet)]
}
return id
}

View file

@ -0,0 +1,332 @@
package torrentfile
import (
"bytes"
"crypto/sha1"
"errors"
"fmt"
"os"
"path"
"github.com/jackpal/bencode-go"
)
type bencodeTorrent struct {
Announce string `bencode:"announce"`
Info bencodeInfo `bencode:"info"`
}
type bencodeInfo struct {
Pieces string `bencode:"pieces"`
PieceLength int `bencode:"piece length"`
Length int `bencode:"length"`
Files []file `bencode:"files"`
Name string `bencode:"name"`
}
type file struct {
Length int `bencode:"length"`
Path []string `bencode:"path"`
}
type File struct {
Path string
Length int
}
type TorrentFile struct {
Announce string
InfoHash [20]byte
PieceHashes [][20]byte
PieceLength int
Length int
Name string
Files []File
}
func Open(path string) (*TorrentFile, error) {
rawData, err := os.ReadFile(path)
if err != nil {
return nil, err
}
infoBytes, err := extractInfoBytes(rawData)
if err != nil {
return nil, err
}
var bto bencodeTorrent
if err := bencode.Unmarshal(bytes.NewReader(rawData), &bto); err != nil {
return nil, err
}
if err := validateMetadata(bto.Info, bto.Announce); err != nil {
return nil, err
}
pieceHashes, err := splitPieceHashes(bto.Info.Pieces)
if err != nil {
return nil, err
}
files, totalLength, err := deriveFilesAndLength(bto.Info)
if err != nil {
return nil, err
}
if err := validatePieceCount(totalLength, bto.Info.PieceLength, len(pieceHashes)); err != nil {
return nil, err
}
infoHash := sha1.Sum(infoBytes)
tf := TorrentFile{
Announce: bto.Announce,
InfoHash: infoHash,
PieceHashes: pieceHashes,
PieceLength: bto.Info.PieceLength,
Length: totalLength,
Name: bto.Info.Name,
Files: files,
}
return &tf, nil
}
func validateMetadata(info bencodeInfo, announce string) error {
switch {
case announce == "":
return errors.New("torrent announce URL is empty")
case info.Name == "":
return errors.New("torrent name is empty")
case info.PieceLength <= 0:
return errors.New("torrent piece length must be greater than zero")
}
switch {
case info.Length > 0 && len(info.Files) > 0:
return errors.New("torrent contains both single-file and multi-file metadata")
case info.Length <= 0 && len(info.Files) == 0:
return errors.New("torrent missing both length and files metadata")
}
return nil
}
func splitPieceHashes(rawPieces string) ([][20]byte, error) {
pieces := []byte(rawPieces)
if len(pieces) == 0 {
return nil, errors.New("torrent has no piece hashes")
}
if len(pieces)%sha1.Size != 0 {
return nil, fmt.Errorf("invalid pieces data size %d (must be multiple of %d)", len(pieces), sha1.Size)
}
hashes := make([][20]byte, len(pieces)/sha1.Size)
for i := range hashes {
start := i * sha1.Size
copy(hashes[i][:], pieces[start:start+sha1.Size])
}
return hashes, nil
}
func deriveFilesAndLength(info bencodeInfo) ([]File, int, error) {
if info.Length > 0 {
return []File{{Path: info.Name, Length: info.Length}}, info.Length, nil
}
files := make([]File, 0, len(info.Files))
totalLength := 0
for _, f := range info.Files {
if f.Length <= 0 {
return nil, 0, errors.New("torrent file length must be greater than zero")
}
if len(f.Path) == 0 {
return nil, 0, errors.New("torrent file path is empty")
}
parts := make([]string, 0, len(f.Path)+1)
parts = append(parts, info.Name)
parts = append(parts, f.Path...)
filePath := path.Join(parts...)
if filePath == "." {
return nil, 0, errors.New("torrent file path is invalid")
}
files = append(files, File{
Path: filePath,
Length: f.Length,
})
totalLength += f.Length
}
if totalLength <= 0 {
return nil, 0, errors.New("torrent total length must be greater than zero")
}
return files, totalLength, nil
}
func validatePieceCount(totalLength, pieceLength, actualPieces int) error {
expectedPieces := (totalLength + pieceLength - 1) / pieceLength
if expectedPieces != actualPieces {
return fmt.Errorf("piece hash count mismatch: expected %d, got %d", expectedPieces, actualPieces)
}
return nil
}
func extractInfoBytes(data []byte) ([]byte, error) {
if len(data) == 0 || data[0] != 'd' {
return nil, errors.New("torrent root must be a bencoded dictionary")
}
idx := 1
for idx < len(data) {
if data[idx] == 'e' {
break
}
key, next, err := parseBencodeString(data, idx)
if err != nil {
return nil, err
}
idx = next
valueStart := idx
valueEnd, err := skipBencodeValue(data, idx)
if err != nil {
return nil, err
}
if string(key) == "info" {
return data[valueStart:valueEnd], nil
}
idx = valueEnd
}
return nil, errors.New("torrent does not contain top-level info dictionary")
}
func skipBencodeValue(data []byte, idx int) (int, error) {
if idx >= len(data) {
return 0, errors.New("unexpected end of bencoded data")
}
switch c := data[idx]; {
case c == 'i':
return skipBencodeInt(data, idx)
case c == 'l':
idx++
for {
if idx >= len(data) {
return 0, errors.New("unexpected end while parsing bencoded list")
}
if data[idx] == 'e' {
return idx + 1, nil
}
next, err := skipBencodeValue(data, idx)
if err != nil {
return 0, err
}
idx = next
}
case c == 'd':
idx++
for {
if idx >= len(data) {
return 0, errors.New("unexpected end while parsing bencoded dictionary")
}
if data[idx] == 'e' {
return idx + 1, nil
}
_, next, err := parseBencodeString(data, idx)
if err != nil {
return 0, err
}
idx = next
next, err = skipBencodeValue(data, idx)
if err != nil {
return 0, err
}
idx = next
}
case c >= '0' && c <= '9':
_, next, err := parseBencodeString(data, idx)
return next, err
default:
return 0, fmt.Errorf("invalid bencode token %q at index %d", c, idx)
}
}
func skipBencodeInt(data []byte, idx int) (int, error) {
if data[idx] != 'i' {
return 0, fmt.Errorf("expected integer token at index %d", idx)
}
idx++
if idx >= len(data) {
return 0, errors.New("unexpected end while parsing bencoded integer")
}
if data[idx] == '-' {
idx++
if idx >= len(data) {
return 0, errors.New("unexpected end after bencoded integer sign")
}
}
if data[idx] < '0' || data[idx] > '9' {
return 0, fmt.Errorf("invalid bencoded integer digit at index %d", idx)
}
for idx < len(data) && data[idx] != 'e' {
if data[idx] < '0' || data[idx] > '9' {
return 0, fmt.Errorf("invalid bencoded integer digit at index %d", idx)
}
idx++
}
if idx >= len(data) || data[idx] != 'e' {
return 0, errors.New("unterminated bencoded integer")
}
return idx + 1, nil
}
func parseBencodeString(data []byte, idx int) ([]byte, int, error) {
length, valueStart, err := parseBencodeStringLength(data, idx)
if err != nil {
return nil, 0, err
}
valueEnd := valueStart + length
if valueEnd > len(data) {
return nil, 0, errors.New("bencoded string length exceeds input size")
}
return data[valueStart:valueEnd], valueEnd, nil
}
func parseBencodeStringLength(data []byte, idx int) (int, int, error) {
if idx >= len(data) {
return 0, 0, errors.New("unexpected end while parsing bencoded string length")
}
if data[idx] < '0' || data[idx] > '9' {
return 0, 0, fmt.Errorf("invalid bencoded string length token at index %d", idx)
}
length := 0
for idx < len(data) && data[idx] >= '0' && data[idx] <= '9' {
length = (length * 10) + int(data[idx]-'0')
idx++
}
if idx >= len(data) || data[idx] != ':' {
return 0, 0, errors.New("unterminated bencoded string length")
}
return length, idx + 1, nil
}

View file

@ -0,0 +1,125 @@
package torrentfile
import (
"crypto/sha1"
"os"
"path/filepath"
"testing"
)
func TestOpenParsesTorrentMetadata(t *testing.T) {
t.Parallel()
info := "d6:lengthi5e4:name8:test.txt12:piece lengthi16384e6:pieces20:aaaaaaaaaaaaaaaaaaaa7:privatei1ee"
torrent := "d8:announce14:http://tracker4:info" + info + "e"
path := writeTempTorrent(t, torrent)
tf, err := Open(path)
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
expectedHash := sha1.Sum([]byte(info))
if tf.InfoHash != expectedHash {
t.Fatalf("unexpected info hash: got %x want %x", tf.InfoHash, expectedHash)
}
if tf.Announce != "http://tracker" {
t.Fatalf("unexpected announce: got %q", tf.Announce)
}
if tf.Name != "test.txt" {
t.Fatalf("unexpected name: got %q", tf.Name)
}
if tf.PieceLength != 16384 {
t.Fatalf("unexpected piece length: got %d", tf.PieceLength)
}
if tf.Length != 5 {
t.Fatalf("unexpected length: got %d", tf.Length)
}
if len(tf.PieceHashes) != 1 {
t.Fatalf("unexpected piece count: got %d", len(tf.PieceHashes))
}
if len(tf.Files) != 1 {
t.Fatalf("unexpected file count: got %d", len(tf.Files))
}
if got, want := tf.Files[0].Path, "test.txt"; got != want {
t.Fatalf("unexpected first file path: got %q want %q", got, want)
}
if got, want := tf.Files[0].Length, 5; got != want {
t.Fatalf("unexpected first file length: got %d want %d", got, want)
}
var expectedPiece [20]byte
copy(expectedPiece[:], []byte("aaaaaaaaaaaaaaaaaaaa"))
if tf.PieceHashes[0] != expectedPiece {
t.Fatalf("unexpected piece hash: got %x want %x", tf.PieceHashes[0], expectedPiece)
}
}
func TestOpenRejectsInvalidPieceHashes(t *testing.T) {
t.Parallel()
info := "d6:lengthi5e4:name8:test.txt12:piece lengthi16384e6:pieces19:aaaaaaaaaaaaaaaaaaae"
torrent := "d8:announce14:http://tracker4:info" + info + "e"
path := writeTempTorrent(t, torrent)
_, err := Open(path)
if err == nil {
t.Fatal("expected Open to fail for invalid piece hash length")
}
}
func TestOpenParsesMultiFileTorrent(t *testing.T) {
t.Parallel()
pieces := "aaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbb"
info := "d5:filesld6:lengthi3e4:pathl5:a.txteed6:lengthi2e4:pathl5:b.txteee4:name4:root12:piece lengthi4e6:pieces40:" + pieces + "e"
torrent := "d8:announce14:http://tracker4:info" + info + "e"
path := writeTempTorrent(t, torrent)
tf, err := Open(path)
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
if tf.Length != 5 {
t.Fatalf("unexpected total length: got %d", tf.Length)
}
if len(tf.Files) != 2 {
t.Fatalf("unexpected file count: got %d", len(tf.Files))
}
if got, want := tf.Files[0].Path, "root/a.txt"; got != want {
t.Fatalf("unexpected file[0] path: got %q want %q", got, want)
}
if got, want := tf.Files[1].Path, "root/b.txt"; got != want {
t.Fatalf("unexpected file[1] path: got %q want %q", got, want)
}
if len(tf.PieceHashes) != 2 {
t.Fatalf("unexpected piece hash count: got %d", len(tf.PieceHashes))
}
}
func TestOpenRejectsPieceCountMismatch(t *testing.T) {
t.Parallel()
info := "d6:lengthi10e4:name8:test.txt12:piece lengthi4e6:pieces40:aaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbe"
torrent := "d8:announce14:http://tracker4:info" + info + "e"
path := writeTempTorrent(t, torrent)
_, err := Open(path)
if err == nil {
t.Fatal("expected Open to fail for piece count mismatch")
}
}
func writeTempTorrent(t *testing.T, data string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "test.torrent")
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
t.Fatalf("failed to write temp torrent file: %v", err)
}
return path
}

212
internal/tracker/tracker.go Normal file
View file

@ -0,0 +1,212 @@
package tracker
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/jackpal/bencode-go"
"github.com/veggiedefender/torrent-client/internal/torrentfile"
)
type TrackerResponse struct {
FailureReason string `bencode:"failure reason"`
Interval int `bencode:"interval"`
Peers string `bencode:"peers"`
}
type Peer struct {
IP net.IP
Port uint16
}
type AnnounceOptions struct {
PeerID [20]byte
Port uint16
Uploaded int64
Downloaded int64
NumWant int
Timeout time.Duration
}
type httpDoer interface {
Do(req *http.Request) (*http.Response, error)
}
func GetPeers(ctx context.Context, tf *torrentfile.TorrentFile, opts AnnounceOptions) ([]Peer, error) {
opts = normalizeOptions(opts)
client := &http.Client{Timeout: opts.Timeout}
return getPeersWithClient(ctx, client, tf, opts)
}
func getPeersWithClient(ctx context.Context, client httpDoer, tf *torrentfile.TorrentFile, opts AnnounceOptions) ([]Peer, error) {
if tf == nil {
return nil, errors.New("torrent metadata is nil")
}
opts = normalizeOptions(opts)
if client == nil {
client = &http.Client{Timeout: opts.Timeout}
}
announceURL, err := buildAnnounceURL(tf, opts)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, announceURL, nil)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("tracker returned HTTP %d", resp.StatusCode)
}
var tr TrackerResponse
err = bencode.Unmarshal(resp.Body, &tr)
if err != nil {
return nil, err
}
if tr.FailureReason != "" {
return nil, fmt.Errorf("tracker failure: %s", tr.FailureReason)
}
return parsePeers([]byte(tr.Peers))
}
func normalizeOptions(opts AnnounceOptions) AnnounceOptions {
if opts.Port == 0 {
opts.Port = 6881
}
if opts.NumWant <= 0 {
opts.NumWant = 50
}
if opts.Timeout <= 0 {
opts.Timeout = 10 * time.Second
}
return opts
}
func buildAnnounceURL(tf *torrentfile.TorrentFile, opts AnnounceOptions) (string, error) {
if tf == nil {
return "", errors.New("torrent metadata is nil")
}
baseURL, err := url.Parse(tf.Announce)
if err != nil {
return "", fmt.Errorf("invalid tracker URL %q: %w", tf.Announce, err)
}
if baseURL.Scheme != "http" && baseURL.Scheme != "https" {
return "", fmt.Errorf("unsupported tracker scheme %q (only http/https are supported)", baseURL.Scheme)
}
if baseURL.Host == "" {
return "", errors.New("tracker URL host is empty")
}
uploaded := opts.Uploaded
if uploaded < 0 {
uploaded = 0
}
downloaded := opts.Downloaded
if downloaded < 0 {
downloaded = 0
}
left := int64(tf.Length) - downloaded
if left < 0 {
left = 0
}
parts := make([]string, 0, 10)
if baseURL.RawQuery != "" {
parts = append(parts, baseURL.RawQuery)
}
parts = append(parts,
"info_hash="+escapeBinary(tf.InfoHash[:]),
"peer_id="+escapeBinary(opts.PeerID[:]),
"port="+strconv.Itoa(int(opts.Port)),
"uploaded="+strconv.FormatInt(uploaded, 10),
"downloaded="+strconv.FormatInt(downloaded, 10),
"left="+strconv.FormatInt(left, 10),
"compact=1",
"numwant="+strconv.Itoa(opts.NumWant),
)
baseURL.RawQuery = strings.Join(parts, "&")
return baseURL.String(), nil
}
func escapeBinary(data []byte) string {
const hex = "0123456789ABCDEF"
var b strings.Builder
for _, c := range data {
if isURLUnreserved(c) {
b.WriteByte(c)
continue
}
b.WriteByte('%')
b.WriteByte(hex[c>>4])
b.WriteByte(hex[c&0x0F])
}
return b.String()
}
func isURLUnreserved(c byte) bool {
switch {
case c >= 'a' && c <= 'z':
return true
case c >= 'A' && c <= 'Z':
return true
case c >= '0' && c <= '9':
return true
case c == '-', c == '.', c == '_', c == '~':
return true
default:
return false
}
}
func parsePeers(data []byte) ([]Peer, error) {
if len(data) == 0 {
return []Peer{}, nil
}
if len(data)%6 != 0 {
return nil, fmt.Errorf("invalid compact peers length %d", len(data))
}
var peers []Peer
for i := 0; i < len(data); i += 6 {
ip := net.IP(data[i : i+4])
port := uint16(data[i+4])<<8 |
uint16(data[i+5])
peers = append(peers, Peer{
IP: ip,
Port: port,
})
}
return peers, nil
}

View file

@ -0,0 +1,210 @@
package tracker
import (
"bytes"
"context"
"io"
"net/http"
"strings"
"testing"
"github.com/veggiedefender/torrent-client/internal/torrentfile"
)
func TestParsePeers(t *testing.T) {
t.Parallel()
raw := []byte{127, 0, 0, 1, 0x1A, 0xE1}
peers, err := parsePeers(raw)
if err != nil {
t.Fatalf("parsePeers returned error: %v", err)
}
if len(peers) != 1 {
t.Fatalf("unexpected peer count: got %d", len(peers))
}
if got, want := peers[0].IP.String(), "127.0.0.1"; got != want {
t.Fatalf("unexpected peer IP: got %s want %s", got, want)
}
if got, want := peers[0].Port, uint16(6881); got != want {
t.Fatalf("unexpected peer port: got %d want %d", got, want)
}
}
func TestParsePeersRejectsInvalidLength(t *testing.T) {
t.Parallel()
_, err := parsePeers([]byte{1, 2, 3, 4, 5})
if err == nil {
t.Fatal("expected parsePeers to fail for invalid compact peer data")
}
}
func TestGetPeersBuildsAnnounceAndParsesResponse(t *testing.T) {
t.Parallel()
var infoHash [20]byte
copy(infoHash[:], []byte("abcdefghijklmnopqrst"))
var peerID [20]byte
copy(peerID[:], []byte("-ZT0001-123456789012"))
tf := &torrentfile.TorrentFile{
Announce: "http://tracker.test/announce",
InfoHash: infoHash,
Length: 1000,
}
var seenRequest *http.Request
client := fakeClient{
do: func(req *http.Request) (*http.Response, error) {
seenRequest = req
payload := []byte("d8:intervali1800e5:peers6:\x7f\x00\x00\x01\x1a\xe1e")
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(payload)),
}, nil
},
}
peers, err := getPeersWithClient(context.Background(), client, tf, AnnounceOptions{
PeerID: peerID,
Port: 6881,
Downloaded: 100,
})
if err != nil {
t.Fatalf("GetPeers returned error: %v", err)
}
if seenRequest == nil {
t.Fatal("expected tracker request to be sent")
}
q := seenRequest.URL.Query()
if got := q.Get("compact"); got != "1" {
t.Fatalf("unexpected compact value: got %q", got)
}
if got := q.Get("port"); got != "6881" {
t.Fatalf("unexpected port value: got %q", got)
}
if got := q.Get("left"); got != "900" {
t.Fatalf("unexpected left value: got %q", got)
}
if got := q.Get("numwant"); got != "50" {
t.Fatalf("unexpected numwant value: got %q", got)
}
if got := []byte(q.Get("info_hash")); !equalBytes(got, infoHash[:]) {
t.Fatalf("unexpected info_hash value: got %x want %x", got, infoHash)
}
if got := []byte(q.Get("peer_id")); !equalBytes(got, peerID[:]) {
t.Fatalf("unexpected peer_id value: got %x want %x", got, peerID)
}
if len(peers) != 1 {
t.Fatalf("unexpected peer count: got %d", len(peers))
}
if got, want := peers[0].Port, uint16(6881); got != want {
t.Fatalf("unexpected peer port: got %d want %d", got, want)
}
}
func TestBuildAnnounceURLEncodesBinaryValues(t *testing.T) {
t.Parallel()
var infoHash [20]byte
copy(infoHash[:], []byte{0x00, 0xFF, 0x2F, 'A', 0x20})
var peerID [20]byte
copy(peerID[:], []byte("-ZT0001-\x00\xFFabc"))
tf := &torrentfile.TorrentFile{
Announce: "http://tracker.test/announce?token=abc",
InfoHash: infoHash,
Length: 10,
}
announceURL, err := buildAnnounceURL(tf, AnnounceOptions{
PeerID: peerID,
Port: 6881,
NumWant: 42,
Uploaded: -5,
Downloaded: -7,
})
if err != nil {
t.Fatalf("buildAnnounceURL returned error: %v", err)
}
if !strings.Contains(announceURL, "token=abc") {
t.Fatalf("announce URL did not preserve existing query params: %s", announceURL)
}
if !strings.Contains(announceURL, "info_hash=%00%FF%2FA%20") {
t.Fatalf("announce URL did not encode binary info hash correctly: %s", announceURL)
}
if !strings.Contains(announceURL, "peer_id=-ZT0001-%00%FFabc") {
t.Fatalf("announce URL did not encode binary peer ID correctly: %s", announceURL)
}
if !strings.Contains(announceURL, "uploaded=0") {
t.Fatalf("announce URL did not clamp uploaded value: %s", announceURL)
}
if !strings.Contains(announceURL, "downloaded=0") {
t.Fatalf("announce URL did not clamp downloaded value: %s", announceURL)
}
if !strings.Contains(announceURL, "left=10") {
t.Fatalf("announce URL did not compute left bytes correctly: %s", announceURL)
}
}
func TestBuildAnnounceURLRejectsUnsupportedScheme(t *testing.T) {
t.Parallel()
_, err := buildAnnounceURL(&torrentfile.TorrentFile{
Announce: "udp://tracker.test:6969/announce",
Length: 10,
}, AnnounceOptions{})
if err == nil {
t.Fatal("expected buildAnnounceURL to reject unsupported tracker scheme")
}
}
func TestGetPeersReturnsTrackerFailure(t *testing.T) {
t.Parallel()
tf := &torrentfile.TorrentFile{
Announce: "http://tracker.test/announce",
Length: 10,
}
client := fakeClient{
do: func(req *http.Request) (*http.Response, error) {
payload := []byte("d14:failure reason11:bad requeste")
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(payload)),
}, nil
},
}
_, err := getPeersWithClient(context.Background(), client, tf, AnnounceOptions{})
if err == nil {
t.Fatal("expected getPeersWithClient to return tracker failure error")
}
}
type fakeClient struct {
do func(req *http.Request) (*http.Response, error)
}
func (c fakeClient) Do(req *http.Request) (*http.Response, error) {
return c.do(req)
}
func equalBytes(a, b []byte) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

42
ui/window.go Normal file
View file

@ -0,0 +1,42 @@
package ui
import (
"fmt"
"os"
"github.com/veggiedefender/torrent-client/internal/app"
)
func Start(controller *app.Controller) {
fmt.Println("Ztorrent")
if len(os.Args) < 2 {
fmt.Println("Usage: torrent-client <path-to-torrent-file>")
return
}
path := os.Args[1]
if err := controller.StartTorrent(path); err != nil {
fmt.Printf("Warning: tracker announce failed: %v\n", err)
}
status := controller.Status()
if !status.Loaded {
fmt.Println("No torrent metadata loaded.")
return
}
fmt.Printf("Name: %s\n", status.Name)
fmt.Printf("Size: %d bytes\n", status.Length)
fmt.Printf("Piece length: %d bytes\n", status.PieceLength)
fmt.Printf("Pieces: %d\n", status.PieceCount)
fmt.Printf("Files: %d\n", len(status.Files))
for _, file := range status.Files {
fmt.Printf(" - %s (%d bytes)\n", file.Path, file.Length)
}
fmt.Printf("Tracker: %s\n", status.Announce)
fmt.Printf("Peers: %d\n", status.PeerCount)
if status.LastError != "" {
fmt.Printf("Last error: %s\n", status.LastError)
}
}