From 636fd5baa80f179260a94e18fbd23a4f5551be62 Mon Sep 17 00:00:00 2001 From: itexpert228 <67105314+fdaser1337@users.noreply.github.com> Date: Fri, 6 Mar 2026 12:02:52 +0300 Subject: [PATCH] huy --- README.md | 17 +- cmd/torrent-client/main.go | 14 + go.mod | 5 + go.sum | 2 + internal/app/controller.go | 31 +++ internal/torrent/engine.go | 158 +++++++++++ internal/torrentfile/torrentfile.go | 332 +++++++++++++++++++++++ internal/torrentfile/torrentfile_test.go | 125 +++++++++ internal/tracker/tracker.go | 212 +++++++++++++++ internal/tracker/tracker_test.go | 210 ++++++++++++++ ui/window.go | 42 +++ 11 files changed, 1147 insertions(+), 1 deletion(-) create mode 100644 cmd/torrent-client/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/app/controller.go create mode 100644 internal/torrent/engine.go create mode 100644 internal/torrentfile/torrentfile.go create mode 100644 internal/torrentfile/torrentfile_test.go create mode 100644 internal/tracker/tracker.go create mode 100644 internal/tracker/tracker_test.go create mode 100644 ui/window.go diff --git a/README.md b/README.md index fcc0a4f..07e48b9 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,18 @@ # Ztorrent -ну нихуя себе, как это великая ZOV OS и без своего торрент клиента, qbittorrent сосиииииииииииииииииииии \ No newline at end of file +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 diff --git a/cmd/torrent-client/main.go b/cmd/torrent-client/main.go new file mode 100644 index 0000000..740d396 --- /dev/null +++ b/cmd/torrent-client/main.go @@ -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) + +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3ee0634 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/veggiedefender/torrent-client + +go 1.22 + +require github.com/jackpal/bencode-go v1.0.2 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3dc9f35 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/app/controller.go b/internal/app/controller.go new file mode 100644 index 0000000..d6f8014 --- /dev/null +++ b/internal/app/controller.go @@ -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() +} diff --git a/internal/torrent/engine.go b/internal/torrent/engine.go new file mode 100644 index 0000000..620387e --- /dev/null +++ b/internal/torrent/engine.go @@ -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 +} diff --git a/internal/torrentfile/torrentfile.go b/internal/torrentfile/torrentfile.go new file mode 100644 index 0000000..dee4a5f --- /dev/null +++ b/internal/torrentfile/torrentfile.go @@ -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 +} diff --git a/internal/torrentfile/torrentfile_test.go b/internal/torrentfile/torrentfile_test.go new file mode 100644 index 0000000..c1d52b1 --- /dev/null +++ b/internal/torrentfile/torrentfile_test.go @@ -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 +} diff --git a/internal/tracker/tracker.go b/internal/tracker/tracker.go new file mode 100644 index 0000000..73a5ceb --- /dev/null +++ b/internal/tracker/tracker.go @@ -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 +} diff --git a/internal/tracker/tracker_test.go b/internal/tracker/tracker_test.go new file mode 100644 index 0000000..87f3a61 --- /dev/null +++ b/internal/tracker/tracker_test.go @@ -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 +} diff --git a/ui/window.go b/ui/window.go new file mode 100644 index 0000000..7390ce4 --- /dev/null +++ b/ui/window.go @@ -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 ") + 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) + } +}