huy
This commit is contained in:
parent
62739ccae7
commit
636fd5baa8
11 changed files with 1147 additions and 1 deletions
17
README.md
17
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
14
cmd/torrent-client/main.go
Normal file
14
cmd/torrent-client/main.go
Normal 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
5
go.mod
Normal 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
2
go.sum
Normal 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=
|
||||||
31
internal/app/controller.go
Normal file
31
internal/app/controller.go
Normal 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
158
internal/torrent/engine.go
Normal 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
|
||||||
|
}
|
||||||
332
internal/torrentfile/torrentfile.go
Normal file
332
internal/torrentfile/torrentfile.go
Normal 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
|
||||||
|
}
|
||||||
125
internal/torrentfile/torrentfile_test.go
Normal file
125
internal/torrentfile/torrentfile_test.go
Normal 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
212
internal/tracker/tracker.go
Normal 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
|
||||||
|
}
|
||||||
210
internal/tracker/tracker_test.go
Normal file
210
internal/tracker/tracker_test.go
Normal 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
42
ui/window.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue