Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions cmd/wl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ func main() {
category := createCmd.String("category", "", "Category (e.g. models, datasets)")
tags := createCmd.String("tags", "", "Comma-separated tags")
comment := createCmd.String("comment", "", "Optional comment in the torrent file")
var webseeds stringSlice
createCmd.Var(&webseeds, "webseed", "BEP 19 web seed URL (HTTP origin fallback); repeatable")
createCmd.Parse(os.Args[2:])

path := createCmd.Arg(0)
Expand All @@ -86,6 +88,7 @@ func main() {
category: *category,
tags: *tags,
comment: *comment,
webseeds: webseeds,
}

if err := runCreate(opts); err != nil {
Expand Down Expand Up @@ -135,6 +138,16 @@ type createOpts struct {
category string
tags string
comment string
webseeds []string
}

// stringSlice is a repeatable string flag (e.g. --webseed a --webseed b).
type stringSlice []string

func (s *stringSlice) String() string { return strings.Join(*s, ",") }
func (s *stringSlice) Set(v string) error {
*s = append(*s, v)
return nil
}

func runCreate(opts createOpts) error {
Expand All @@ -160,6 +173,7 @@ func runCreate(opts createOpts) error {
Comment: opts.comment,
Source: envOr("WL_SOURCE", ""),
CreatedBy: envOr("WL_CREATED_BY", ""),
WebSeeds: opts.webseeds,
})
if err != nil {
return fmt.Errorf("create torrent: %w", err)
Expand Down
29 changes: 21 additions & 8 deletions internal/torrent/torrent.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"io"
"math/bits"
"net/url"
"os"
"path/filepath"
"sort"
Expand All @@ -24,14 +25,15 @@ const (

// CreateOptions configures torrent creation.
type CreateOptions struct {
Path string // file or directory to torrent
Name string // torrent name (defaults to basename of Path)
PieceLength int // must be power of 2, >= MinPieceLength
AnnounceURL string // tracker announce URL
Private bool // if true, disables DHT/PEX (BEP 27)
Comment string // optional comment
Source string // source tag in info dict (e.g. "weightless.ai")
CreatedBy string // created by field (e.g. "Weightless CLI v1.0")
Path string // file or directory to torrent
Name string // torrent name (defaults to basename of Path)
PieceLength int // must be power of 2, >= MinPieceLength
AnnounceURL string // tracker announce URL
Private bool // if true, disables DHT/PEX (BEP 27)
Comment string // optional comment
Source string // source tag in info dict (e.g. "weightless.ai")
CreatedBy string // created by field (e.g. "Weightless CLI v1.0")
WebSeeds []string // BEP 19 web seed URLs (top-level url-list; HTTP origin fallback)
}

// CreateResult holds the output of torrent creation.
Expand Down Expand Up @@ -64,6 +66,12 @@ func Create(opts CreateOptions) (*CreateResult, error) {
if opts.PieceLength < MinPieceLength || !isPowerOfTwo(opts.PieceLength) {
return nil, fmt.Errorf("piece length must be a power of 2 and >= %d, got %d", MinPieceLength, opts.PieceLength)
}
for _, ws := range opts.WebSeeds {
u, err := url.Parse(ws)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return nil, fmt.Errorf("web seed must be an absolute http(s) URL, got %q", ws)
}
}

info, err := os.Stat(opts.Path)
if err != nil {
Expand Down Expand Up @@ -108,6 +116,11 @@ func Create(opts CreateOptions) (*CreateResult, error) {
if len(pieceLayers) > 0 {
metaDict.set("piece layers", pieceLayers)
}
// BEP 19 web seeds (top-level, outside the info dict so the info hash is
// unaffected). Clients fall back to these HTTP origins when peers are scarce.
if len(opts.WebSeeds) > 0 {
metaDict.set("url-list", opts.WebSeeds)
}

torrentBytes, err := bencode.EncodeBytes(metaDict)
if err != nil {
Expand Down
70 changes: 70 additions & 0 deletions internal/torrent/torrent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,76 @@ func TestHybridGoldenVectors(t *testing.T) {
}
}

func TestCreateWebSeeds(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "data.bin")
data := make([]byte, 64*1024)
for i := range data {
data[i] = byte(i % 256)
}
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatal(err)
}

base := CreateOptions{
Path: path,
Name: "data.bin",
PieceLength: 16 * 1024,
AnnounceURL: "http://localhost:8080/announce",
}

// Without web seeds: no url-list key, capture baseline hashes.
plain, err := Create(base)
if err != nil {
t.Fatalf("Create (plain) failed: %v", err)
}
var plainMeta map[string]interface{}
if err := bencode.DecodeBytes(plain.TorrentBytes, &plainMeta); err != nil {
t.Fatal(err)
}
if _, ok := plainMeta["url-list"]; ok {
t.Error("url-list should be absent when no web seeds are given")
}

// With web seeds: url-list present (top-level), info hashes UNCHANGED.
seeds := []string{"https://example.com/data.bin", "http://mirror.example.org/data.bin"}
ws := base
ws.WebSeeds = seeds
result, err := Create(ws)
if err != nil {
t.Fatalf("Create (webseed) failed: %v", err)
}
if result.InfoHashHex != plain.InfoHashHex || result.InfoHashV1Hex != plain.InfoHashV1Hex {
t.Error("web seeds must not change the info hash (url-list is outside the info dict)")
}

var meta map[string]interface{}
if err := bencode.DecodeBytes(result.TorrentBytes, &meta); err != nil {
t.Fatal(err)
}
raw, ok := meta["url-list"].([]interface{})
if !ok {
t.Fatalf("url-list missing or not a list: %T", meta["url-list"])
}
got := make([]string, len(raw))
for i, v := range raw {
got[i] = v.(string)
}
if len(got) != 2 || got[0] != seeds[0] || got[1] != seeds[1] {
t.Errorf("url-list = %v, want %v", got, seeds)
}

// Invalid web seed URLs are rejected at the boundary.
for _, bad := range []string{"not-a-url", "ftp://x/y", "/relative/path", "https://"} {
bw := base
bw.WebSeeds = []string{bad}
if _, err := Create(bw); err == nil {
t.Errorf("Create accepted invalid web seed %q", bad)
}
}
}

func TestCreateSingleFileHybrid(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.dat")
Expand Down
Loading