diff --git a/cmd/wl/main.go b/cmd/wl/main.go index 0de39c7..857cdcb 100644 --- a/cmd/wl/main.go +++ b/cmd/wl/main.go @@ -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) @@ -86,6 +88,7 @@ func main() { category: *category, tags: *tags, comment: *comment, + webseeds: webseeds, } if err := runCreate(opts); err != nil { @@ -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 { @@ -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) diff --git a/internal/torrent/torrent.go b/internal/torrent/torrent.go index c8b146d..80aaefd 100644 --- a/internal/torrent/torrent.go +++ b/internal/torrent/torrent.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "math/bits" + "net/url" "os" "path/filepath" "sort" @@ -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. @@ -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 { @@ -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 { diff --git a/internal/torrent/torrent_test.go b/internal/torrent/torrent_test.go index de4f47e..c98b68c 100644 --- a/internal/torrent/torrent_test.go +++ b/internal/torrent/torrent_test.go @@ -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")