first commit

This commit is contained in:
2026-06-19 12:32:14 +09:00
commit eb8d9b9460
36 changed files with 6502 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
.claude
ref/
*.torrent
*.got
/got
/ria
bin/
.torrent.db*
*.part

162
README.md Normal file
View File

@@ -0,0 +1,162 @@
# got
A small, Rob-Pike-style command-line download tool in Go: one program that
downloads HTTP(S) URLs over several connections and BitTorrent
magnets/torrents, with a focused set of options.
The design leans on Go's runtime instead of a hand-rolled reactor. There is no
single-threaded event-poll loop: one goroutine per download blocks on real I/O,
a buffered channel of slots bounds concurrency, and `context` carries shutdown.
What remains is the data — and, per Pike, the data is the design.
## Build
```sh
go build -o got .
```
Requires Go 1.25+. The BitTorrent engine is
[`anacrolix/torrent`](https://github.com/anacrolix/torrent); everything else is
the standard library plus `golang.org/x/{term,time}`.
## Use
```sh
# segmented HTTP download, 16 connections, into ./downloads
got -x16 -s16 -d downloads https://example.com/big.iso
# resume an interrupted download
got -c -x16 https://example.com/big.iso
# the same file from several mirrors at once (one file, connections spread
# across servers, a dead mirror falls over); -Z downloads them separately instead
got -x4 https://a.example/big.iso https://b.example/big.iso
# a torrent or magnet; seed for 30 minutes after finishing
got --seed-time=30 ubuntu.torrent
got 'magnet:?xt=urn:btih:...'
# list the files in a torrent without downloading
got -S -T some.torrent
# several downloads at once, two running in parallel
got -j2 -i urls.txt
```
Run `got --help` (or `--help=http`, `--help=bittorrent`, ...) for the full
option list.
### Options
A focused subset of options, with familiar short flags and sensible defaults.
| flag | meaning | default |
|------|---------|---------|
| `-d, --dir` | output directory | `.` |
| `-o, --out` | output filename (single download) | from URL |
| `-i, --input-file` | read downloads line by line (TAB-separated URLs = mirrors; `-` = stdin) | |
| `-c, --continue` | resume a partial download | false |
| `-x, --max-connection-per-server` | connections to one server (116) | 1 |
| `-s, --split` | split a download into N connections | 5 |
| `-Z, --force-sequential` | download each command-line URL separately, not as mirrors | false |
| `-k, --min-split-size` | do not split below this size | 20M |
| `-j, --max-concurrent-downloads` | parallel downloads | 5 |
| `--max-overall-download-limit` | global speed cap | 0 (off) |
| `--max-download-limit` | per-download speed cap | 0 (off) |
| `--retry-wait` | seconds to wait between retries | 0 |
| `--load-cookies` / `--save-cookies` | read/write a Netscape `cookies.txt` | |
| `--conditional-get` | skip the download if the local file is up to date | false |
| `--remote-time` | set the file's mtime from the server | false |
| `--checksum` | verify the finished file: `TYPE=DIGEST` (sha-256, sha-1, md5, …) | |
| `--dry-run` | check the file is available but do not download it | false |
| `--ca-certificate` | verify HTTPS against the CA certificates in FILE (PEM) | |
| `--stop` | stop the program after N seconds | 0 (off) |
| `--disable-ipv6` | force IPv4-only connections | false |
| `-T, --torrent-file` | a `.torrent` file | |
| `--seed-time` | minutes to seed after finishing | seed by ratio |
| `--seed-ratio` | stop seeding at this ratio | 1.0 |
| `--listen-port` | port (range) for incoming peers | 6881-6999 |
| `--enable-dht` | use the BitTorrent DHT | true |
| `--select-file` | fetch only these file indexes (`1,3-5`) | all |
| `-S, --show-files` | list torrent files and exit | |
| `--bt-stop-timeout` | give up if no download progress for N s | 0 (off) |
| `--bt-metadata-timeout` | give up if a magnet can't fetch metadata in N s | 60 |
| `--save-session` | on exit, write unfinished downloads to FILE | |
| `-q, --quiet` | no progress readout | false |
### Resuming across runs
There is no daemon — resume is a plain file. Point
`--save-session` and `-i` at the same file and add `-c`: unfinished downloads
are loaded at start and the still-unfinished ones are written back on exit.
```sh
got -c --save-session=got.session -i got.session <new urls/magnets...>
```
On the first run the session file need not exist. HTTP downloads resume from
their `.got` control file; torrents resume from the data already on disk
(re-checked by the engine). A magnet with no reachable peers no longer hangs:
`--bt-metadata-timeout` bounds the metadata fetch and defaults to 60s (set it to
0 to wait forever).
## Design
```
main classify URIs, wire options, run, report exit code
└─ download the Download interface + the scheduler (-j semaphore, ctx)
├─ httpdl []Segment + worker pool + WriteAt + JSON resume sidecar
└─ bt anacrolix/torrent behind the Download interface
├─ cli one flat option table -> hand parser -> layered config
└─ progress one ticker goroutine, pull-snapshot, \r line redraw
```
The contract is one small interface:
```go
type Download interface {
Name() string
Run(ctx context.Context) error // blocks in its own goroutine
Stat() Stat // a value snapshot, lock-free to read
}
```
- **HTTP** splits the file into byte-range `Segment`s handed to a pool of
workers. Each worker streams its range straight to one shared file with
`os.File.WriteAt` — safe for concurrent non-overlapping writes, so there is no
shared seek offset and no mutex. Resume is a small JSON sidecar
(`<file>.got`) holding per-segment progress plus validators (length +
ETag/Last-Modified) so a stale file is never trusted.
- **BitTorrent** is `anacrolix/torrent` configured from the CLI options and
driven to completion, then seeded for `--seed-time` minutes or up to
`--seed-ratio`.
- **Options** are one flat `[]Opt` table — the single source of truth for
parsing, validation, defaults and `--help`. Layers apply in order:
built-in defaults, config file, proxy environment, command line.
- **Progress** is a single goroutine that ticks once a second, pulls a snapshot
of the running downloads, derives speeds from the change since the last tick,
and redraws one line with `\r` + erase-to-end-of-line.
## Status
Implemented: segmented HTTP(S) with resume — including a foreign/browser-started
partial file (`-c`) — multi-mirror downloads (several URLs, or TAB-grouped `-i`
lines, fetch one file with connections spread across servers and fallover; `-Z`
to download them separately), retries, rate limits, auto-renaming,
`Content-Disposition` naming, conditional GET, cookies
(`--load-cookies`/`--save-cookies`), HTTP basic auth, `--remote-time`, whole-file
checksum verification (`--checksum`), and a `--dry-run` availability check;
BitTorrent download + seeding (magnet and `.torrent`,
DHT, trackers, file selection, show-files, metadata-fetch timeout), seeding that
stops on whichever of `--seed-time`/`--seed-ratio` comes first, and a listen port
chosen from the whole range so a busy port no longer disables BitTorrent;
a command-line interface with documented exit codes, config file, live progress,
and session save/reload for resume across runs.
Deferred (not implemented yet): FTP/SFTP, Metalink, and the
JSON-RPC server. The architecture leaves room for
each — a new protocol is just another `Download`. One known limitation: the
BitTorrent engine (`anacrolix/torrent`) applies download/upload rate limits
client-wide, so `--max-upload-limit`/`--max-download-limit` act per-run rather
than per-torrent when several torrents run at once (`--max-overall-*` are exact);
DHT/PEX likewise cannot yet be disabled per private torrent.

521
bt/bt.go Normal file
View File

@@ -0,0 +1,521 @@
// Package bt downloads a torrent or magnet link. The peer wire protocol, DHT,
// trackers, magnet metadata, piece selection and choking are all carried by
// github.com/anacrolix/torrent; this package's job is to present that engine as
// a download.Download. Within a single run, one shared torrent.Client serves
// every download, so they share one listen port, DHT node and piece-store
// rather than fighting over them.
package bt
import (
"context"
"fmt"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"time"
missinggo "github.com/anacrolix/missinggo/v2"
"github.com/anacrolix/torrent"
"github.com/anacrolix/torrent/metainfo"
"github.com/hanbok/got/download"
"golang.org/x/time/rate"
)
// ClientConfig holds the run-wide settings used to build the shared client.
type ClientConfig struct {
Dir string
ListenPortSpec string // raw --listen-port spec ("6881-6999,7000"); empty lets the OS choose.
DHT bool
MaxPeers int // max peer connections per torrent (0 = library default)
OverallDown *rate.Limiter // global down limit (may be nil)
OverallUp *rate.Limiter // global up limit (may be nil)
DownLimit int64 // per-run bytes/s, 0 = unlimited (used only without a global limiter)
UpLimit int64
UserAgent string
DisableIPv6 bool // force IPv4-only (--disable-ipv6)
}
// ParsePorts expands a --listen-port spec into a flat list of candidate ports.
// The spec is a comma-separated list of single ports and a-b ranges
// (e.g. "6881-6889,6999"). Out-of-range and unparsable entries are
// skipped; an empty or all-bad spec yields nil.
func ParsePorts(spec string) []int {
var ports []int
for _, part := range strings.Split(spec, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
lo, hi, isRange := strings.Cut(part, "-")
a, err := strconv.Atoi(strings.TrimSpace(lo))
if err != nil {
// Unparsable low bound: drop the whole entry, symmetrically with the
// high bound below (an out-of-range a is still filtered per-port).
continue
}
if !isRange {
if a >= 1 && a <= 65535 {
ports = append(ports, a)
}
continue
}
b, err := strconv.Atoi(strings.TrimSpace(hi))
if err != nil {
continue
}
for i := a; i <= b; i++ {
if i >= 1 && i <= 65535 {
ports = append(ports, i)
}
}
}
return ports
}
// NewClient builds the shared anacrolix client from the run-wide config. When a
// listen-port spec is given it tries each candidate port in turn, advancing past
// any that is already in use, and falls back to an OS-chosen port if none bind,
// so a single busy port does not disable BitTorrent entirely.
//
// Two per-torrent features have no equivalent in anacrolix/torrent v1.61.0,
// which exposes only client-wide knobs; both must therefore be configured run-wide
// here rather than attached to an individual Torrent:
//
// 1. No per-Torrent rate limiter. Throttles are read from the client-wide
// DownloadRateLimiter/UploadRateLimiter (no TorrentSpec rate field exists), so
// per-torrent --max-download-limit / --max-upload-limit cannot be
// honored individually; the effective cap is the shared limiter set below.
// 2. No per-Torrent DHT/PEX/LSD toggle for BEP27 private torrents. Those are
// client-wide only (NoDHT, DisablePEX, etc.), so a private torrent's leak
// prevention must come from a run-wide setting (e.g. --enable-dht=false).
func NewClient(cfg ClientConfig) (*torrent.Client, error) {
c := torrent.NewDefaultClientConfig()
c.DataDir = cfg.Dir
if c.DataDir == "" {
c.DataDir = "."
}
c.Seed = true
c.NoDHT = !cfg.DHT
if cfg.MaxPeers > 0 {
c.EstablishedConnsPerTorrent = cfg.MaxPeers
}
if cfg.UserAgent != "" {
c.HTTPUserAgent = cfg.UserAgent
}
c.DisableIPv6 = cfg.DisableIPv6
switch {
case cfg.OverallDown != nil:
c.DownloadRateLimiter = cfg.OverallDown
case cfg.DownLimit > 0:
c.DownloadRateLimiter = rate.NewLimiter(rate.Limit(cfg.DownLimit), download.LimiterBurst(cfg.DownLimit))
}
switch {
case cfg.OverallUp != nil:
c.UploadRateLimiter = cfg.OverallUp
case cfg.UpLimit > 0:
c.UploadRateLimiter = rate.NewLimiter(rate.Limit(cfg.UpLimit), download.LimiterBurst(cfg.UpLimit))
}
// Candidate ports from the --listen-port spec.
ports := ParsePorts(cfg.ListenPortSpec)
if len(ports) == 0 {
// No usable spec: let the OS choose (anacrolix already retries on 0).
return torrent.NewClient(c)
}
// Try each candidate; the library only auto-retries when the port is 0, so
// for a fixed busy port it returns an address-in-use error and we advance.
var lastErr error
for _, p := range ports {
c.ListenPort = p
cl, err := torrent.NewClient(c)
if err == nil {
return cl, nil
}
if !isAddrInUse(err) {
return nil, err
}
lastErr = err
}
// Every named port was busy; fall back to an OS-chosen port rather than
// giving up on BitTorrent, so the run keeps going with whatever it can bind.
c.ListenPort = 0
cl, err := torrent.NewClient(c)
if err != nil {
return nil, fmt.Errorf("all listen ports busy (%w); fallback failed: %v", lastErr, err)
}
return cl, nil
}
// isAddrInUse reports whether err is an "address already in use" bind failure,
// matching either missinggo's helper or the raw string the library wraps.
func isAddrInUse(err error) bool {
return err != nil && (missinggo.IsAddrInUse(err) || strings.Contains(err.Error(), "address already in use"))
}
// Options are the per-download settings.
type Options struct {
SeedTimeSet bool // was --seed-time given?
SeedTime time.Duration // how long to seed (0 with SeedTimeSet means no seeding)
SeedRatio float64 // stop seeding at this ratio; checked alongside SeedTime
StopTimeout time.Duration // abort if no download progress for this long (0 = off)
MetaTimeout time.Duration // abort a magnet that can't fetch metadata in this long (0 = off)
SelectFiles map[int]bool // 1-based file indexes to fetch; empty = all
CheckIntegrity bool // re-verify data against piece hashes before downloading
DryRun bool // fetch metadata only, then stop without downloading (--dry-run)
}
// Download implements download.Download for a single torrent or magnet, added
// to a shared client.
type Download struct {
client *torrent.Client
source string // magnet URI or path to a .torrent file
isFile bool
opts Options
name atomic.Pointer[string]
status int32 // download.Status
// t is published once Run adds the torrent to the client. It is atomic so
// Stat stays lock-free, exactly like httpdl.Stat and bt's own name field.
t atomic.Pointer[torrent.Torrent]
}
// New builds a torrent download on the shared client. source is a .torrent path
// when isFile is true, otherwise a magnet URI.
func New(client *torrent.Client, source string, isFile bool, opts Options) *Download {
d := &Download{client: client, source: source, isFile: isFile, opts: opts}
if isFile {
d.setName(strings.TrimSuffix(filepath.Base(source), ".torrent"))
} else {
d.setName(magnetName(source))
}
return d
}
func (d *Download) Name() string {
if p := d.name.Load(); p != nil {
return *p
}
return "torrent"
}
func (d *Download) setName(s string) { d.name.Store(&s) }
// setStatus stores the lifecycle state with a single atomic op so Stat stays
// lock-free, mirroring httpdl. Errored is set once, by Run's deferred guard, when
// Run returns a non-nil error.
func (d *Download) setStatus(s download.Status) {
atomic.StoreInt32(&d.status, int32(s))
}
// id is the process-unique identity for this download: the infohash once known,
// otherwise the source string (a pre-metadata magnet has no infohash yet, and
// two such magnets would otherwise collide on Name).
func (d *Download) id(t *torrent.Torrent) string {
if t != nil {
if h := t.InfoHash(); h != (metainfo.Hash{}) {
return h.HexString()
}
}
return d.source
}
func (d *Download) Stat() download.Stat {
t := d.t.Load()
st := download.Status(atomic.LoadInt32(&d.status))
if t == nil || t.Info() == nil {
return download.Stat{Name: d.Name(), ID: d.id(t), IsBT: true, Status: st, Total: -1}
}
s := t.Stats()
total, completed := t.Length(), t.BytesCompleted()
// With --select-file, report progress against the selected subset, not the
// whole torrent, so the percentage and ETA mean something.
if len(d.opts.SelectFiles) > 0 {
total, completed = 0, 0
for i, f := range t.Files() {
if d.opts.SelectFiles[i+1] {
total += f.Length()
completed += f.BytesCompleted()
}
}
}
return download.Stat{
Name: d.Name(),
ID: d.id(t),
IsBT: true,
Status: st,
Total: total,
Completed: completed,
Uploaded: s.BytesWrittenData.Int64(),
Conns: s.ActivePeers,
Seeders: s.ConnectedSeeders,
}
}
// Run adds the torrent to the shared client, downloads it, then seeds. On any
// failure the named return is non-nil, and the deferred guard flips the status
// to Errored exactly once — so each failure site below just returns its error,
// mirroring httpdl.Run's idiom.
func (d *Download) Run(ctx context.Context) (err error) {
defer func() {
if err != nil {
d.setStatus(download.Errored)
}
}()
d.setStatus(download.Active)
if d.client == nil {
return fmt.Errorf("bittorrent is unavailable")
}
t, err := d.add()
if err != nil {
return err
}
defer t.Drop() // release this torrent; the client lives on for others
d.t.Store(t)
if err := d.awaitInfo(ctx, t); err != nil {
return err
}
d.setName(t.Name())
// --dry-run: fetching the metadata is all a torrent does under a dry run
// (the content download is cancelled). Report success without downloading.
if d.opts.DryRun {
d.setStatus(download.Complete)
return nil
}
if err := d.choose(t); err != nil {
return err
}
if d.opts.CheckIntegrity {
if err := t.VerifyDataContext(ctx); err != nil {
return err
}
}
if err := d.wait(ctx, t); err != nil {
return err
}
d.setStatus(download.Seeding)
d.seed(ctx, t)
d.setStatus(download.Complete)
return nil
}
// add hands the source to the shared client. It recovers from the library's
// panics on malformed input (e.g. a magnet with a zero infohash) so a bad
// argument fails as an ordinary error instead of crashing the whole program.
func (d *Download) add() (t *torrent.Torrent, err error) {
defer func() {
if r := recover(); r != nil {
t, err = nil, fmt.Errorf("invalid torrent/magnet: %v", r)
}
}()
if d.isFile {
return d.client.AddTorrentFromFile(d.source)
}
return d.client.AddMagnet(d.source)
}
// choose applies --select-file: when set, only the listed files are fetched.
// With no selection we ask for everything. It is an error for a selection to
// match no file, so a typo'd index fails loudly instead of "completing" nothing.
func (d *Download) choose(t *torrent.Torrent) error {
if len(d.opts.SelectFiles) == 0 {
t.DownloadAll()
return nil
}
files := t.Files()
matched := 0
for i, f := range files {
if d.opts.SelectFiles[i+1] {
f.SetPriority(torrent.PiecePriorityNormal)
matched++
} else {
f.SetPriority(torrent.PiecePriorityNone)
}
}
if matched == 0 {
return fmt.Errorf("--select-file: no file index in 1..%d", len(files))
}
return nil
}
// awaitInfo blocks until the torrent metadata arrives. It gives up after
// --bt-metadata-timeout (default 60s) so a magnet with no reachable peers — a
// dead link, or UDP trackers behind a firewall with no DHT — fails fast instead
// of hanging forever. Setting --bt-metadata-timeout=0 disables the timeout and
// waits indefinitely.
func (d *Download) awaitInfo(ctx context.Context, t *torrent.Torrent) error {
var deadline <-chan time.Time
if d.opts.MetaTimeout > 0 {
tm := time.NewTimer(d.opts.MetaTimeout)
defer tm.Stop()
deadline = tm.C
}
select {
case <-t.GotInfo():
return nil
case <-ctx.Done():
return ctx.Err()
case <-deadline:
return fmt.Errorf("timed out fetching metadata after %s", d.opts.MetaTimeout)
}
}
// wait blocks until the wanted data is complete, ctx is cancelled, or the
// download stalls past --bt-stop-timeout.
func (d *Download) wait(ctx context.Context, t *torrent.Torrent) error {
tick := time.NewTicker(500 * time.Millisecond)
defer tick.Stop()
last := t.BytesCompleted()
lastChange := time.Now()
for {
if d.complete(t) {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-tick.C:
if n := t.BytesCompleted(); n != last {
last, lastChange = n, time.Now()
} else if d.opts.StopTimeout > 0 && time.Since(lastChange) > d.opts.StopTimeout {
return fmt.Errorf("no progress for %s", d.opts.StopTimeout)
}
}
}
}
// complete reports whether everything we asked for is downloaded AND verified.
// For a selection we check each wanted file's pieces are hash-complete rather
// than trusting byte counts, which include not-yet-verified (dirty) bytes.
func (d *Download) complete(t *torrent.Torrent) bool {
if len(d.opts.SelectFiles) == 0 {
return t.Complete().Bool()
}
for i, f := range t.Files() {
if !d.opts.SelectFiles[i+1] {
continue
}
for _, ps := range f.State() {
if !ps.Complete {
return false
}
}
}
return true
}
// seed keeps the torrent up after completion. Seeding stops as soon as EITHER
// criterion fires: the --seed-time elapses or the --seed-ratio is reached. Both
// are active at once when both are set; with neither set we seed forever (until
// ctx is cancelled). One short-circuit applies: --seed-time=0 means do not seed.
func (d *Download) seed(ctx context.Context, t *torrent.Torrent) {
if d.opts.SeedTimeSet && d.opts.SeedTime == 0 {
return
}
// Arm the time criterion whenever --seed-time was given (independently of the
// ratio criterion below).
var timer <-chan time.Time
if d.opts.SeedTimeSet {
tm := time.NewTimer(d.opts.SeedTime)
defer tm.Stop()
timer = tm.C
}
tick := time.NewTicker(time.Second)
defer tick.Stop()
// Share ratio is uploaded/downloaded. The denominator is the torrent's
// completed bytes at the moment seeding starts — under --select-file only the
// selected files are ever fetched, so this is already close to the subset, but
// it is not subset-filtered: any boundary/shared pieces count toward it too.
dl := t.BytesCompleted()
for {
select {
case <-ctx.Done():
return
case <-timer:
return
case <-tick.C:
// Ratio criterion is checked every tick whenever a ratio is set,
// regardless of whether --seed-time is also set.
if d.opts.SeedRatio > 0 && dl > 0 {
st := t.Stats()
up := st.BytesWrittenData.Int64()
if float64(up)/float64(dl) >= d.opts.SeedRatio {
return
}
}
}
}
}
// FileInfo describes one file inside a torrent, for --show-files.
type FileInfo struct {
Index int
Path string
Length int64
}
// Files lists the files in a .torrent without starting a download.
func Files(torrentPath string) ([]FileInfo, error) {
mi, err := metainfo.LoadFromFile(torrentPath)
if err != nil {
return nil, err
}
info, err := mi.UnmarshalInfo()
if err != nil {
return nil, err
}
if len(info.Files) == 0 {
return []FileInfo{{1, info.Name, info.Length}}, nil
}
out := make([]FileInfo, len(info.Files))
for i, f := range info.Files {
out[i] = FileInfo{i + 1, strings.Join(f.Path, "/"), f.Length}
}
return out, nil
}
// SourceInfoHash returns the v1 infohash of a .torrent file (isFile) or a magnet
// URI, with ok=false when it can't be known without a network fetch (an HTTP
// URL), the source is malformed, or the v1 infohash is zero — so a BitTorrent
// v2-only source is treated as unique (not deduped). Callers use it to collapse
// two sources that name the same torrent into one download: the shared client
// keys torrents by infohash and does not refcount, so two jobs sharing one
// torrent would have the first to finish Drop it out from under the other.
func SourceInfoHash(source string, isFile bool) (metainfo.Hash, bool) {
var h metainfo.Hash
if isFile {
mi, err := metainfo.LoadFromFile(source)
if err != nil {
return metainfo.Hash{}, false
}
h = mi.HashInfoBytes()
} else {
m, err := metainfo.ParseMagnetUri(source)
if err != nil {
return metainfo.Hash{}, false
}
h = m.InfoHash
}
if h == (metainfo.Hash{}) {
return metainfo.Hash{}, false
}
return h, true
}
func magnetName(uri string) string {
if m, err := metainfo.ParseMagnetUri(uri); err == nil && m.DisplayName != "" {
return m.DisplayName
}
return "magnet"
}

47
bt/bt_test.go Normal file
View File

@@ -0,0 +1,47 @@
package bt
import (
"reflect"
"testing"
)
func TestParsePorts(t *testing.T) {
tests := []struct {
spec string
want []int
}{
// port spec examples.
{"6881,6885", []int{6881, 6885}},
{"6881-6999", rangeInts(6881, 6999)},
{"7000-7001,8000", []int{7000, 7001, 8000}},
{"6881-6889,6999", append(rangeInts(6881, 6889), 6999)},
// single bare port.
{"6881", []int{6881}},
// whitespace and empty fields are tolerated.
{" 6881 , 6885 ", []int{6881, 6885}},
{"6881,,6885", []int{6881, 6885}},
// empty / all-bad specs yield nil.
{"", nil},
{",", nil},
{"abc", nil},
// out-of-range ports are dropped.
{"0", nil},
{"70000", nil},
// a range that overruns 65535 keeps only the valid part.
{"65534-65540", []int{65534, 65535}},
}
for _, tt := range tests {
got := ParsePorts(tt.spec)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("ParsePorts(%q) = %v, want %v", tt.spec, got, tt.want)
}
}
}
func rangeInts(a, b int) []int {
out := make([]int, 0, b-a+1)
for i := a; i <= b; i++ {
out = append(out, i)
}
return out
}

107
cli/help.go Normal file
View File

@@ -0,0 +1,107 @@
package cli
import (
"fmt"
"io"
"strings"
)
// Version is the program version, overridable at build time via -ldflags.
var Version = "0.1.0"
// PrintVersion writes the version banner.
func PrintVersion(w io.Writer) {
fmt.Fprintf(w, "got %s — a Rob-Pike-style command-line downloader in Go\n", Version)
}
// PrintHelp writes usage. A bare -h/--help (empty tag) shows only the Basic
// group, keeping the default terse; --help=all shows every group and
// --help=<group> (basic/http/bittorrent/advanced) filters to one.
func PrintHelp(w io.Writer, tag string) {
all := tag == "all"
// Empty tag means a bare -h/--help: default to the Basic group only.
bare := tag == ""
want, filtered := tagByName(tag)
if bare {
want, filtered = Basic, true
}
if !all && !bare && !filtered {
fmt.Fprintf(w, "got: unknown help tag %q (use all|basic|http|bittorrent|advanced)\n\n", tag)
}
fmt.Fprint(w, `Usage: got [OPTIONS] URI | magnet:... | file.torrent ...
A multi-protocol download tool: segmented HTTP(S) and BitTorrent.
`)
for _, group := range []Tag{Basic, HTTP, BitTorrent, Advanced} {
if filtered && !all && group != want {
continue
}
fmt.Fprintf(w, "%s options:\n", title(group.String()))
for i := range options {
o := &options[i]
if o.Tag != group {
continue
}
fmt.Fprintf(w, " %s %s\n", flagLabel(o), o.Help)
}
fmt.Fprintln(w)
}
// Only the full listing (all groups) repeats the meta flags.
if all || !filtered {
fmt.Fprint(w, " -h, --help[=TAG] show help (TAG: all|basic|http|bittorrent|advanced)\n")
fmt.Fprint(w, " -v, --version show version\n\n")
}
if bare {
fmt.Fprint(w, "Showing basic options. Use --help=all for every option, or --help=advanced for advanced ones.\n")
}
}
// title upper-cases the first letter of a single word.
func title(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
func tagByName(name string) (Tag, bool) {
switch name {
case "basic":
return Basic, true
case "http":
return HTTP, true
case "bittorrent", "bt":
return BitTorrent, true
case "advanced":
return Advanced, true
default:
return Basic, false
}
}
// flagLabel renders the "-x, --long=ARG" column for one option.
func flagLabel(o *Opt) string {
var b strings.Builder
if o.Short != 0 {
fmt.Fprintf(&b, "-%c, ", o.Short)
} else {
b.WriteString(" ")
}
b.WriteString("--" + o.Long)
switch o.Kind {
case Bool:
// no argument shown
case Size:
b.WriteString("=SIZE")
case Int:
b.WriteString("=N")
case Float:
b.WriteString("=RATIO")
case Enum:
b.WriteString("=" + strings.Join(o.Enum, "|"))
default:
b.WriteString("=VAL")
}
return b.String()
}

143
cli/option.go Normal file
View File

@@ -0,0 +1,143 @@
// Package cli turns command-line arguments and config files into a resolved set
// of options. The whole design is one flat table of option descriptors; help
// text, defaults, parsing and validation all derive from it. The whole option
// model is plain data rather than a class hierarchy.
package cli
// Kind is the value type of an option. It drives parsing and how the value is
// later read back.
type Kind int
const (
Bool Kind = iota // bare flag means true; --flag=false also accepted
Int // integer, optionally bounded by Min/Max
Size // byte size with optional K/M/G suffix
Float // floating point
Str // free-form string
Enum // one of Opt.Enum
List // repeatable; values accumulate, newline-joined
)
// Tag groups options for --help.
type Tag int
const (
Basic Tag = iota
HTTP
BitTorrent
Advanced
)
func (t Tag) String() string {
switch t {
case Basic:
return "basic"
case HTTP:
return "http"
case BitTorrent:
return "bittorrent"
case Advanced:
return "advanced"
default:
return "?"
}
}
// Opt describes one option: its names, value kind, default and help text.
type Opt struct {
Long string // long name without dashes, e.g. "max-connection-per-server"
Short byte // short flag byte, e.g. 'x'; 0 if none
Kind Kind
Default string
Help string
Tag Tag
Enum []string // valid values when Kind == Enum
// Min is the inclusive lower Int/Size bound. The zero value enforces a
// floor of 0 (negative inputs are rejected).
Min int64
// Max is the inclusive upper Int/Size bound; the zero value means no upper
// bound. For Size options Min/Max are byte counts.
Max int64
}
// options is the single source of truth: a focused, practical subset.
var options = []Opt{
// --- basic ---
{Long: "dir", Short: 'd', Kind: Str, Help: "directory to store downloaded files", Tag: Basic},
{Long: "out", Short: 'o', Kind: Str, Help: "output filename for the download", Tag: Basic},
{Long: "input-file", Short: 'i', Kind: Str, Help: "read URIs line by line from FILE (- for stdin)", Tag: Basic},
{Long: "continue", Short: 'c', Kind: Bool, Default: "false", Help: "resume a partially downloaded file", Tag: Basic},
{Long: "max-concurrent-downloads", Short: 'j', Kind: Int, Default: "5", Min: 1, Help: "max number of parallel downloads", Tag: Basic},
{Long: "check-integrity", Short: 'V', Kind: Bool, Default: "false", Help: "re-verify torrent data against piece hashes (BitTorrent)", Tag: Basic},
{Long: "show-files", Short: 'S', Kind: Bool, Default: "false", Help: "list files in a torrent and exit", Tag: Basic},
{Long: "allow-overwrite", Kind: Bool, Default: "false", Help: "overwrite an existing file", Tag: Basic},
{Long: "auto-file-renaming", Kind: Bool, Default: "true", Help: "rename file (.1, .2, ...) if it already exists", Tag: Basic},
{Long: "max-overall-download-limit", Kind: Size, Default: "0", Help: "global download speed limit (0 = unlimited)", Tag: Basic},
{Long: "max-download-limit", Kind: Size, Default: "0", Help: "per-download speed limit (0 = unlimited)", Tag: Basic},
{Long: "quiet", Short: 'q', Kind: Bool, Default: "false", Help: "suppress the progress readout", Tag: Basic},
{Long: "human-readable", Kind: Bool, Default: "true", Help: "show sizes as Ki/Mi/Gi", Tag: Basic},
// --- http ---
{Long: "max-connection-per-server", Short: 'x', Kind: Int, Default: "1", Min: 1, Max: 16, Help: "max connections to one server (1-16)", Tag: HTTP},
{Long: "split", Short: 's', Kind: Int, Default: "5", Min: 1, Help: "split a download into N connections; actual connections are min(max-connection-per-server, split), and -x defaults to 1", Tag: HTTP},
{Long: "min-split-size", Short: 'k', Kind: Size, Default: "20M", Min: 1 << 20, Max: 1 << 30, Help: "do not split a piece smaller than SIZE (1M-1024M)", Tag: HTTP},
{Long: "force-sequential", Short: 'Z', Kind: Bool, Default: "false", Help: "download each command-line URI as its own file instead of mirroring them", Tag: HTTP},
{Long: "max-tries", Short: 'm', Kind: Int, Default: "5", Min: 0, Help: "max retries per segment (0 = unlimited)", Tag: HTTP},
{Long: "timeout", Short: 't', Kind: Int, Default: "60", Min: 1, Help: "connection timeout in seconds", Tag: HTTP},
{Long: "header", Kind: List, Help: "append an extra HTTP header (repeatable)", Tag: HTTP},
{Long: "user-agent", Short: 'U', Kind: Str, Default: "got/1.0", Help: "HTTP User-Agent", Tag: HTTP},
{Long: "referer", Kind: Str, Help: "HTTP Referer header", Tag: HTTP},
{Long: "all-proxy", Kind: Str, Help: "proxy for all protocols (host:port)", Tag: HTTP},
{Long: "check-certificate", Kind: Bool, Default: "true", Help: "verify the server's TLS certificate", Tag: HTTP},
{Long: "ca-certificate", Kind: Str, Help: "verify HTTPS servers against the CA certificates in FILE (PEM)", Tag: HTTP},
{Long: "retry-wait", Kind: Int, Default: "0", Min: 0, Max: 600, Help: "seconds to wait between retries (0 = no wait)", Tag: HTTP},
{Long: "connect-timeout", Kind: Int, Default: "60", Min: 1, Help: "connection establishment timeout in seconds", Tag: HTTP},
{Long: "load-cookies", Kind: Str, Help: "load Cookies from FILE (Netscape/Mozilla format)", Tag: HTTP},
{Long: "save-cookies", Kind: Str, Help: "save Cookies to FILE on exit", Tag: HTTP},
{Long: "conditional-get", Kind: Bool, Default: "false", Help: "download only if the remote file is newer than the local one", Tag: HTTP},
{Long: "remote-time", Kind: Bool, Default: "false", Help: "set the local file's mtime from the server", Tag: HTTP},
{Long: "http-user", Kind: Str, Help: "HTTP basic-auth user", Tag: HTTP},
{Long: "http-passwd", Kind: Str, Help: "HTTP basic-auth password", Tag: HTTP},
{Long: "lowest-speed-limit", Kind: Size, Default: "0", Help: "abort if speed stays below SIZE bytes/sec (0 = off)", Tag: HTTP},
{Long: "checksum", Kind: Str, Help: "verify the finished file: TYPE=DIGEST, e.g. sha-256=<hex> (md5, sha-1, sha-224, sha-256, sha-384, sha-512)", Tag: HTTP},
// --- bittorrent ---
{Long: "torrent-file", Short: 'T', Kind: Str, Help: "path to a .torrent file", Tag: BitTorrent},
{Long: "seed-time", Kind: Float, Help: "minutes to seed after completion (0 = no seeding)", Tag: BitTorrent},
{Long: "seed-ratio", Kind: Float, Default: "1.0", Help: "stop seeding at this share ratio (0 = unlimited)", Tag: BitTorrent},
{Long: "bt-stop-timeout", Kind: Int, Default: "0", Min: 0, Help: "stop a torrent with no download progress for N seconds (0 = off)", Tag: BitTorrent},
{Long: "bt-metadata-timeout", Kind: Int, Default: "60", Min: 0, Help: "stop a magnet that can't fetch metadata in N seconds (0 = off)", Tag: BitTorrent},
{Long: "listen-port", Kind: Str, Default: "6881-6999", Help: "TCP port (range) for incoming peers", Tag: BitTorrent},
{Long: "enable-dht", Kind: Bool, Default: "true", Help: "enable the BitTorrent DHT", Tag: BitTorrent},
{Long: "bt-max-peers", Kind: Int, Default: "55", Min: 0, Help: "max peers per torrent (0 = unlimited)", Tag: BitTorrent},
{Long: "max-overall-upload-limit", Kind: Size, Default: "0", Help: "global upload speed limit (0 = unlimited)", Tag: BitTorrent},
{Long: "max-upload-limit", Short: 'u', Kind: Size, Default: "0", Help: "per-torrent upload speed limit", Tag: BitTorrent},
{Long: "select-file", Kind: Str, Help: "download only these file indexes, e.g. 1,3-5", Tag: BitTorrent},
{Long: "follow-torrent", Kind: Bool, Default: "true", Help: "after fetching a .torrent over HTTP, download its content", Tag: BitTorrent},
// --- advanced ---
{Long: "file-allocation", Short: 'a', Kind: Enum, Default: "prealloc", Enum: []string{"none", "prealloc", "trunc", "falloc"}, Help: "how to allocate disk space", Tag: Advanced},
{Long: "dry-run", Kind: Bool, Default: "false", Help: "check that the file is available but do not download it", Tag: Advanced},
{Long: "stop", Kind: Int, Default: "0", Min: 0, Help: "stop the program after N seconds (0 = off)", Tag: Advanced},
{Long: "disable-ipv6", Kind: Bool, Default: "false", Help: "disable IPv6 (force IPv4-only connections)", Tag: Advanced},
{Long: "save-session", Kind: Str, Help: "on exit, write unfinished downloads to FILE (reload with -i)", Tag: Advanced},
{Long: "auto-save-interval", Kind: Int, Default: "60", Min: 0, Max: 600, Help: "save the session file every N seconds (0 = only on exit)", Tag: Advanced},
{Long: "conf-path", Kind: Str, Help: "path to the config file", Tag: Advanced},
{Long: "no-conf", Kind: Bool, Default: "false", Help: "do not read a config file", Tag: Advanced},
}
// byLong and byShort index the table. Built once at init.
var (
byLong = map[string]*Opt{}
byShort = map[byte]*Opt{}
)
func init() {
for i := range options {
o := &options[i]
byLong[o.Long] = o
if o.Short != 0 {
byShort[o.Short] = o
}
}
}

109
cli/options.go Normal file
View File

@@ -0,0 +1,109 @@
package cli
import (
"fmt"
"strconv"
"strings"
)
// Options is the resolved configuration: a flat name->value map plus a record
// of which names were explicitly set (so callers can tell a default from a
// chosen value). Values are kept as strings end-to-end and converted only at
// the point of use by the typed getters below.
type Options struct {
vals map[string]string
set map[string]bool
}
func newOptions() *Options {
return &Options{vals: map[string]string{}, set: map[string]bool{}}
}
// IsSet reports whether name was given on the command line or in the config
// (as opposed to coming from the built-in default).
func (o *Options) IsSet(name string) bool { return o.set[name] }
// Str returns the raw string value (empty if unset and no default).
func (o *Options) Str(name string) string { return o.vals[name] }
// boolWords is the single accepted vocabulary for boolean options, mapping each
// recognised spelling to its truth value. The Bool reader, truthy, and
// validate() all consult it, so exactly the words that validate are honoured
// (no "accepted by the reader but rejected by validate" surprises like --x=on).
var boolWords = map[string]bool{
"true": true, "yes": true, "1": true, "on": true,
"false": false, "no": false, "0": false, "off": false,
}
// boolWord reports a value's truth and whether it is a recognised boolean word.
func boolWord(s string) (val, ok bool) {
val, ok = boolWords[strings.ToLower(strings.TrimSpace(s))]
return val, ok
}
// Bool reports whether the value is a truthy boolean word.
func (o *Options) Bool(name string) bool {
val, _ := boolWord(o.vals[name])
return val
}
// Int returns the value as an int, or 0 if empty/invalid.
func (o *Options) Int(name string) int { return int(o.Int64(name)) }
// Int64 returns the value as an int64, or 0 if empty/invalid.
func (o *Options) Int64(name string) int64 {
n, _ := strconv.ParseInt(strings.TrimSpace(o.vals[name]), 10, 64)
return n
}
// Float returns the value as a float64, or 0 if empty/invalid.
func (o *Options) Float(name string) float64 {
f, _ := strconv.ParseFloat(strings.TrimSpace(o.vals[name]), 64)
return f
}
// Size returns a byte count parsed from a value like "20M" or "512K".
func (o *Options) Size(name string) int64 {
n, _ := parseSize(o.vals[name])
return n
}
// List returns a repeatable option's accumulated values.
func (o *Options) List(name string) []string {
v := o.vals[name]
if v == "" {
return nil
}
return strings.Split(v, "\n")
}
// parseSize converts a size string into a byte count: the first 'K'/'k' or
// 'M'/'m' in the string selects the multiplier (1024 or 1024*1024) and
// everything from that byte on is discarded; with no such unit the whole string
// is the byte count. There is no gigabyte unit, so "1G" is rejected; "1Mi" and
// "10MB" are 1M and 10M (the trailing bytes are dropped). An empty string is 0
// bytes; a negative value is rejected.
func parseSize(s string) (int64, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, nil
}
mult := int64(1)
if i := strings.IndexAny(s, "KkMm"); i >= 0 {
if c := s[i]; c == 'M' || c == 'm' {
mult = 1 << 20
} else {
mult = 1 << 10
}
s = s[:i]
}
n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
if err != nil {
return 0, fmt.Errorf("bad size %q", s)
}
// Size parsing rejects negative sizes outright.
if n < 0 {
return 0, fmt.Errorf("negative size %q", s)
}
return n * mult, nil
}

311
cli/parse.go Normal file
View File

@@ -0,0 +1,311 @@
package cli
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
// Action tells main what the user asked for.
type Action int
const (
Run Action = iota
ShowHelp
ShowVersion
)
// Result is everything Parse extracts from the command line.
type Result struct {
Action Action
HelpTag string // when Action == ShowHelp; "" means all
Options *Options
URIs []string
}
// Parse resolves args into options and URIs, applying the layered override
// order: built-in defaults, then the config file, then proxy
// environment variables, then the command line (last wins).
func Parse(args []string) (*Result, error) {
cmdVals, uris, action, helpTag, err := parseArgs(args)
if err != nil {
return nil, err
}
if action != Run {
return &Result{Action: action, HelpTag: helpTag}, nil
}
opts := newOptions()
applyDefaults(opts)
if !truthy(cmdVals["no-conf"]) {
explicit := cmdVals["conf-path"]
path, fromUser := confPath(explicit)
if err := applyConfig(opts, path, fromUser); err != nil {
return nil, err
}
}
applyEnv(opts)
for name, val := range cmdVals {
if err := set(opts, name, val); err != nil {
return nil, err
}
}
return &Result{Action: Run, Options: opts, URIs: uris}, nil
}
// parseArgs walks the argument list once. It recognises --long, --long=val,
// -x, -xval, -x val and short bool bundles like -cq. Like getopt_long, it
// permutes: a non-flag token becomes a URI but scanning continues so flags may
// follow URIs. A bare "--" is a hard terminator forcing the rest to URIs.
// --help/-h and --version/-v short-circuit. Values are returned raw; layering
// happens in Parse.
func parseArgs(args []string) (vals map[string]string, uris []string, action Action, helpTag string, err error) {
vals = map[string]string{}
for i := 0; i < len(args); i++ {
a := args[i]
switch {
case a == "--": // end of options: the rest are URIs
uris = append(uris, args[i+1:]...)
return
case a == "-" || !strings.HasPrefix(a, "-"): // URI (or stdin); keep scanning
uris = append(uris, a)
continue
case strings.HasPrefix(a, "--"):
name, val, hasVal := strings.Cut(a[2:], "=")
if name == "help" {
return vals, uris, ShowHelp, val, nil
}
if name == "version" {
return vals, uris, ShowVersion, "", nil
}
o := byLong[name]
if o == nil {
return nil, nil, Run, "", fmt.Errorf("unknown option --%s", name)
}
if !hasVal {
if o.Kind == Bool {
val = "true"
} else {
if i+1 >= len(args) {
return nil, nil, Run, "", fmt.Errorf("option --%s needs a value", name)
}
i++
val = args[i]
}
}
accumulate(vals, o, val)
default: // short flags: -x, -xval, -x val, bundles -cq
j := 1
for j < len(a) {
c := a[j]
if c == 'h' {
return vals, uris, ShowHelp, "", nil
}
if c == 'v' {
return vals, uris, ShowVersion, "", nil
}
o := byShort[c]
if o == nil {
return nil, nil, Run, "", fmt.Errorf("unknown option -%c", c)
}
if o.Kind == Bool {
accumulate(vals, o, "true")
j++
continue
}
// value-taking short: rest of token, else next arg.
val := a[j+1:]
if val == "" {
if i+1 >= len(args) {
return nil, nil, Run, "", fmt.Errorf("option -%c needs a value", c)
}
i++
val = args[i]
}
accumulate(vals, o, val)
break // consumed the rest of the token as the value
}
}
}
return
}
// accumulate stores a value, joining repeatable List options with newlines.
func accumulate(vals map[string]string, o *Opt, val string) {
if o.Kind == List {
if prev, ok := vals[o.Long]; ok {
vals[o.Long] = prev + "\n" + val
return
}
}
vals[o.Long] = val
}
func applyDefaults(o *Options) {
for i := range options {
opt := &options[i]
if opt.Default != "" {
o.vals[opt.Long] = opt.Default
}
}
}
// applyConfig reads "name=value" lines (with # comments) into o. A missing
// file is silently ignored for the default path, but is fatal when the user
// named it explicitly via --conf-path: an explicitly requested config file
// that cannot be read is an error.
func applyConfig(o *Options, path string, explicit bool) error {
if path == "" {
return nil
}
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) && !explicit {
return nil // a missing default config file is not an error
}
return err
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
name, val, _ := strings.Cut(line, "=")
name = strings.TrimSpace(strings.TrimPrefix(name, "--"))
opt := byLong[name]
if opt == nil {
return fmt.Errorf("%s: unknown option %q", path, name)
}
if err := set(o, name, strings.TrimSpace(val)); err != nil {
return err
}
}
return sc.Err()
}
// applyEnv folds the conventional proxy environment variables into all-proxy
// unless it was already set explicitly.
func applyEnv(o *Options) {
if o.set["all-proxy"] {
return
}
for _, key := range []string{"all_proxy", "https_proxy", "http_proxy"} {
if v := os.Getenv(key); v != "" {
o.vals["all-proxy"] = v
return
}
}
}
// set validates a value against its option descriptor and stores it, marking
// the option as explicitly set.
func set(o *Options, name, val string) error {
opt := byLong[name]
if opt == nil {
return fmt.Errorf("unknown option %q", name)
}
if err := validate(opt, val); err != nil {
return err
}
if opt.Kind == List {
if prev, ok := o.vals[name]; ok && o.set[name] {
val = prev + "\n" + val
}
}
o.vals[name] = val
o.set[name] = true
return nil
}
func validate(o *Opt, val string) error {
switch o.Kind {
case Bool:
// Booleans accept true/false plus the yes/no/1/0/on/off spellings, but
// nothing else, so e.g. --enable-dht=flase is a parse error. The accepted
// set is boolWords, the same vocabulary the readers honour.
if _, ok := boolWord(val); !ok {
return fmt.Errorf("--%s: %q is not a boolean (true/false)", o.Long, val)
}
case Int:
n, err := strconv.ParseInt(strings.TrimSpace(val), 10, 64)
if err != nil {
return fmt.Errorf("--%s: %q is not an integer", o.Long, val)
}
if err := checkBounds(o, n); err != nil {
return err
}
case Size:
n, err := parseSize(val)
if err != nil {
return fmt.Errorf("--%s: %v", o.Long, err)
}
if err := checkBounds(o, n); err != nil {
return err
}
case Float:
// seed-time and seed-ratio are both rates/durations that must be
// non-negative; a negative SeedTime would fire the seed timer immediately
// and a negative SeedRatio is silently ignored. Reject negatives here.
// Opt.Min/Max are int64, so we only enforce the floor, not full bounds.
f, err := strconv.ParseFloat(strings.TrimSpace(val), 64)
if err != nil {
return fmt.Errorf("--%s: %q is not a number", o.Long, val)
}
if f < 0 {
return fmt.Errorf("--%s: %v below minimum 0", o.Long, f)
}
case Enum:
for _, e := range o.Enum {
if val == e {
return nil
}
}
return fmt.Errorf("--%s: %q not one of %s", o.Long, val, strings.Join(o.Enum, "|"))
}
return nil
}
// checkBounds enforces an Int/Size option's numeric range. Min defaults to a
// floor of 0 (negative sizes and counts are rejected); a zero Max means "no
// upper bound". For Size options n and the bounds are byte counts.
func checkBounds(o *Opt, n int64) error {
if n < o.Min {
return fmt.Errorf("--%s: %d below minimum %d", o.Long, n, o.Min)
}
if o.Max > 0 && n > o.Max {
return fmt.Errorf("--%s: %d above maximum %d", o.Long, n, o.Max)
}
return nil
}
// confPath picks the config file and reports whether the user named it. An
// explicit --conf-path wins (and is treated as user-requested, so a missing
// file is fatal). Otherwise it falls back to $XDG_CONFIG_HOME/got/got.conf,
// then ~/.config/got/got.conf.
func confPath(explicit string) (path string, fromUser bool) {
if explicit != "" {
return explicit, true
}
// XDG_CONFIG_HOME is only honoured when it is an absolute path.
if x := os.Getenv("XDG_CONFIG_HOME"); filepath.IsAbs(x) {
return filepath.Join(x, "got", "got.conf"), false
}
home, err := os.UserHomeDir()
if err != nil {
return "", false
}
return filepath.Join(home, ".config", "got", "got.conf"), false
}
func truthy(s string) bool {
val, _ := boolWord(s)
return val
}

231
cli/parse_test.go Normal file
View File

@@ -0,0 +1,231 @@
package cli
import (
"path/filepath"
"testing"
)
func TestParseArgs(t *testing.T) {
tests := []struct {
name string
args []string
action Action
wantVal map[string]string
uris []string
}{
{"short attached", []string{"-x16", "https://e.com"}, Run, map[string]string{"max-connection-per-server": "16"}, []string{"https://e.com"}},
{"long equals + bool", []string{"--split=8", "-c", "u"}, Run, map[string]string{"split": "8", "continue": "true"}, []string{"u"}},
{"long separate value", []string{"--out", "f.bin", "u"}, Run, map[string]string{"out": "f.bin"}, []string{"u"}},
{"bool bundle", []string{"-cq", "u"}, Run, map[string]string{"continue": "true", "quiet": "true"}, []string{"u"}},
{"repeatable header", []string{"--header", "A: 1", "--header", "B: 2", "u"}, Run, map[string]string{"header": "A: 1\nB: 2"}, []string{"u"}},
{"two uris", []string{"a", "b"}, Run, map[string]string{}, []string{"a", "b"}},
{"permute flag after uri", []string{"u", "--split=8"}, Run, map[string]string{"split": "8"}, []string{"u"}},
{"permute interleaved", []string{"a", "-c", "b", "--quiet"}, Run, map[string]string{"continue": "true", "quiet": "true"}, []string{"a", "b"}},
{"dash dash terminator", []string{"--split=8", "--", "--not-a-flag", "u"}, Run, map[string]string{"split": "8"}, []string{"--not-a-flag", "u"}},
{"bt metadata timeout", []string{"--bt-metadata-timeout=120", "magnet:x"}, Run, map[string]string{"bt-metadata-timeout": "120"}, []string{"magnet:x"}},
{"help", []string{"--help"}, ShowHelp, nil, nil},
{"help short", []string{"-h"}, ShowHelp, nil, nil},
{"version", []string{"-v"}, ShowVersion, nil, nil},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
vals, uris, action, _, err := parseArgs(tc.args)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if action != tc.action {
t.Fatalf("action = %v, want %v", action, tc.action)
}
if tc.action != Run {
return
}
for k, want := range tc.wantVal {
if vals[k] != want {
t.Errorf("vals[%q] = %q, want %q", k, vals[k], want)
}
}
if len(uris) != len(tc.uris) {
t.Fatalf("uris = %v, want %v", uris, tc.uris)
}
for i := range uris {
if uris[i] != tc.uris[i] {
t.Errorf("uris[%d] = %q, want %q", i, uris[i], tc.uris[i])
}
}
})
}
}
func TestParseArgsErrors(t *testing.T) {
for _, args := range [][]string{
{"--unknown-flag"},
{"-Q"}, // -Q is not a defined short flag
{"--out"}, // missing value
} {
if _, _, _, _, err := parseArgs(args); err == nil {
t.Errorf("parseArgs(%v) = nil error, want error", args)
}
}
}
// got bounds the magnet metadata fetch by default rather than waiting forever;
// guard the default value and its zero floor.
func TestBTMetadataTimeoutDefault(t *testing.T) {
o, ok := byLong["bt-metadata-timeout"]
if !ok {
t.Fatal("bt-metadata-timeout option is missing")
}
if o.Default != "60" {
t.Errorf("bt-metadata-timeout default = %q, want %q", o.Default, "60")
}
if o.Min != 0 {
t.Errorf("bt-metadata-timeout Min = %d, want 0 (reject negatives, allow 0=off)", o.Min)
}
}
func TestParseSize(t *testing.T) {
tests := []struct {
in string
want int64
}{
{"", 0},
{"100", 100},
{"512K", 512 << 10},
{"20M", 20 << 20},
{"1024M", 1 << 30}, // there is no G unit; the 1G bound is typed as 1024M
{"1Mi", 1 << 20}, // the first K/M is taken and the rest discarded
{"10MB", 10 << 20},
{"2k", 2 << 10},
}
for _, tc := range tests {
got, err := parseSize(tc.in)
if err != nil {
t.Errorf("parseSize(%q) error: %v", tc.in, err)
continue
}
if got != tc.want {
t.Errorf("parseSize(%q) = %d, want %d", tc.in, got, tc.want)
}
}
}
func TestValidateRange(t *testing.T) {
x := byLong["max-connection-per-server"]
if err := validate(x, "16"); err != nil {
t.Errorf("validate 16: %v", err)
}
if err := validate(x, "99"); err == nil {
t.Errorf("validate 99: want out-of-range error")
}
a := byLong["file-allocation"]
if err := validate(a, "bogus"); err == nil {
t.Errorf("validate enum bogus: want error")
}
}
func TestValidateIntFloor(t *testing.T) {
// Min:0 now enforces a floor of 0: negatives are rejected.
m := byLong["max-tries"] // Min 0, "0 = unlimited" runtime meaning preserved
if err := validate(m, "0"); err != nil {
t.Errorf("validate max-tries 0: %v, want nil (0 stays a valid input)", err)
}
if err := validate(m, "-1"); err == nil {
t.Errorf("validate max-tries -1: want below-minimum error")
}
}
func TestValidateFloatNonNegative(t *testing.T) {
// Float options (seed-time, seed-ratio) reject negatives: a negative
// SeedTime would fire the seed timer immediately and a negative SeedRatio
// is silently ignored, so neither is a meaningful input.
for _, name := range []string{"seed-ratio", "seed-time"} {
o := byLong[name]
if err := validate(o, "-1"); err == nil {
t.Errorf("validate %s -1: want below-minimum error", name)
}
if err := validate(o, "0"); err != nil {
t.Errorf("validate %s 0: %v, want nil", name, err)
}
if err := validate(o, "1.5"); err != nil {
t.Errorf("validate %s 1.5: %v, want nil", name, err)
}
}
}
func TestValidateBool(t *testing.T) {
b := byLong["enable-dht"]
for _, ok := range []string{"true", "false", "yes", "no", "1", "0", "on", "off"} {
if err := validate(b, ok); err != nil {
t.Errorf("validate bool %q: %v", ok, err)
}
}
if err := validate(b, "flase"); err == nil {
t.Errorf("validate bool flase: want error")
}
}
func TestValidateSizeBounds(t *testing.T) {
k := byLong["min-split-size"] // 1M..1024M (no G unit)
if err := validate(k, "20M"); err != nil {
t.Errorf("validate min-split-size 20M: %v", err)
}
if err := validate(k, "512K"); err == nil {
t.Errorf("validate min-split-size 512K: want below-minimum error")
}
// 1025M exceeds the 1<<30 cap and still parses, so it tests the bounds path
// (where "2G" would have failed earlier as an unknown unit).
if err := validate(k, "1025M"); err == nil {
t.Errorf("validate min-split-size 1025M: want above-maximum error")
}
}
func TestParseSizeNegative(t *testing.T) {
if _, err := parseSize("-1"); err == nil {
t.Errorf("parseSize(-1): want error (size parsing rejects negative values)")
}
if _, err := parseSize("-5M"); err == nil {
t.Errorf("parseSize(-5M): want error")
}
}
// size parsing knows only K and M; a "G" suffix is not a unit and must be
// rejected rather than silently treated as 2^30.
func TestParseSizeNoGigaUnit(t *testing.T) {
for _, in := range []string{"1G", "1g", "1Gi", "5GB"} {
if _, err := parseSize(in); err == nil {
t.Errorf("parseSize(%q): want error (no G unit)", in)
}
}
}
func TestExplicitConfMissingFatal(t *testing.T) {
missing := filepath.Join(t.TempDir(), "no-such-file.conf")
// Default (non-explicit) missing path is silently ignored.
if err := applyConfig(newOptions(), missing, false); err != nil {
t.Errorf("applyConfig default missing: %v, want nil", err)
}
// Explicit missing path is fatal.
if err := applyConfig(newOptions(), missing, true); err == nil {
t.Errorf("applyConfig explicit missing: want error")
}
}
func TestOptionsGetters(t *testing.T) {
o := newOptions()
o.vals["split"] = "8"
o.vals["min-split-size"] = "20M"
o.vals["continue"] = "true"
o.vals["seed-ratio"] = "1.5"
if o.Int("split") != 8 {
t.Errorf("Int split = %d", o.Int("split"))
}
if o.Size("min-split-size") != 20<<20 {
t.Errorf("Size min-split-size = %d", o.Size("min-split-size"))
}
if !o.Bool("continue") {
t.Errorf("Bool continue = false")
}
if o.Float("seed-ratio") != 1.5 {
t.Errorf("Float seed-ratio = %v", o.Float("seed-ratio"))
}
}

69
download/download.go Normal file
View File

@@ -0,0 +1,69 @@
// Package download defines the core abstraction of the program: a Download is
// a thing you can Run and ask for a Stat snapshot. Where a single-threaded
// Command/event-poll reactor would express this, we use one
// goroutine per Download blocking on real I/O. Following Rob Pike, the data
// (Stat) and the interface are kept small; the algorithms fall out of them.
package download
import "context"
// Status is the coarse lifecycle state of a download, using the
// active/waiting/complete/error vocabulary.
type Status int
const (
Waiting Status = iota
Active
Seeding
Complete
Errored
)
func (s Status) String() string {
switch s {
case Waiting:
return "waiting"
case Active:
return "active"
case Seeding:
return "seeding"
case Complete:
return "complete"
case Errored:
return "error"
default:
return "unknown"
}
}
// Stat is a snapshot of a download's progress. Every field is a plain value so
// a renderer running in another goroutine can copy it without sharing memory.
// Completed and Uploaded are cumulative byte counters; the renderer derives
// speeds from successive snapshots rather than each download tracking its own.
type Stat struct {
Name string
// ID is a process-unique stable identity for this download. Name can
// collide (two pre-metadata magnets both show their source string), so the
// renderer keys its per-download speed samples on ID, not Name.
ID string
IsBT bool // true for BitTorrent; progress always shows CN and shows SD for torrents
Status Status
Total int64 // total bytes, or -1 if not yet known
Completed int64 // bytes downloaded so far
Uploaded int64 // bytes uploaded (BitTorrent), 0 otherwise
Conns int // active connections / peers
Seeders int // connected seeders (BitTorrent only)
}
// Done reports whether the download has reached a terminal state.
func (s Stat) Done() bool { return s.Status == Complete || s.Status == Errored }
// A Download is one logical job: a URL, a torrent, a magnet. Run blocks in its
// own goroutine until the work finishes, fails, or ctx is cancelled. Stat may
// be called concurrently at any time and must not block; implementations back
// it with atomics or a briefly-held lock.
type Download interface {
Name() string
Run(ctx context.Context) error
Stat() Stat
}

103
download/engine.go Normal file
View File

@@ -0,0 +1,103 @@
package download
import (
"context"
"sync"
)
// Result pairs a finished download with its outcome and a final stat snapshot.
type Result struct {
Name string
Index int // position in the slice passed to Run, so callers can restore input order
Err error
Stat Stat
}
// Engine runs a set of downloads, at most maxConcurrent at a time, and exposes
// a live snapshot of the running ones for the progress renderer. A buffered
// channel of slots enforces the concurrency limit, and one goroutine per
// download replaces an explicit scheduler tick.
type Engine struct {
maxConcurrent int
mu sync.Mutex
active []Download
}
// NewEngine returns an engine that runs at most maxConcurrent downloads at once.
func NewEngine(maxConcurrent int) *Engine {
if maxConcurrent < 1 {
maxConcurrent = 1
}
return &Engine{maxConcurrent: maxConcurrent}
}
// Run starts every download, honouring the concurrency limit, and returns once
// all of them have finished or ctx is cancelled. There is one Result per
// download; results arrive in completion order, but each Result carries its
// input Index so callers can sort back to the original slice order.
func (e *Engine) Run(ctx context.Context, downloads []Download) []Result {
slots := make(chan struct{}, e.maxConcurrent)
results := make(chan Result, len(downloads))
var wg sync.WaitGroup
for i, d := range downloads {
wg.Add(1)
go func(i int, d Download) {
defer wg.Done()
// Acquire a concurrency slot, or bail if we're shutting down
// before this download ever started.
select {
case slots <- struct{}{}:
case <-ctx.Done():
results <- Result{Name: d.Name(), Index: i, Err: ctx.Err(), Stat: d.Stat()}
return
}
defer func() { <-slots }()
e.track(d)
defer e.untrack(d)
err := d.Run(ctx)
results <- Result{Name: d.Name(), Index: i, Err: err, Stat: d.Stat()}
}(i, d)
}
wg.Wait()
close(results)
out := make([]Result, 0, len(downloads))
for r := range results {
out = append(out, r)
}
return out
}
// Snapshot returns a Stat for every currently running download.
func (e *Engine) Snapshot() []Stat {
e.mu.Lock()
defer e.mu.Unlock()
out := make([]Stat, 0, len(e.active))
for _, d := range e.active {
out = append(out, d.Stat())
}
return out
}
func (e *Engine) track(d Download) {
e.mu.Lock()
e.active = append(e.active, d)
e.mu.Unlock()
}
func (e *Engine) untrack(d Download) {
e.mu.Lock()
defer e.mu.Unlock()
for i, x := range e.active {
if x == d {
e.active = append(e.active[:i], e.active[i+1:]...)
return
}
}
}

18
download/limit.go Normal file
View File

@@ -0,0 +1,18 @@
package download
// LimiterBurst returns the token-bucket burst size for a bytes/sec rate limit,
// as used with golang.org/x/time/rate. The burst is the rate itself, but
// floored at 256 KiB: with burst==rate a tiny limit (say a few KiB/s) would
// hand out only a few bytes per refill, so a single read large enough to fill a
// network buffer could never proceed and throughput would stall well below the
// limit. The floor guarantees every limit still permits one usefully sized read
// while the long-run average stays bounded by the rate.
//
// This is the single source of truth for the burst calculation; httpdl, bt, and
// main call it rather than each keeping a private copy.
func LimiterBurst(bps int64) int {
if bps < 256*1024 {
return 256 * 1024
}
return int(bps)
}

94
go.mod Normal file
View File

@@ -0,0 +1,94 @@
module github.com/hanbok/got
go 1.25
require (
github.com/anacrolix/missinggo/v2 v2.10.0
github.com/anacrolix/torrent v1.61.0
golang.org/x/sys v0.38.0
golang.org/x/term v0.37.0
golang.org/x/time v0.14.0
)
require (
github.com/RoaringBitmap/roaring v1.2.3 // indirect
github.com/alecthomas/atomic v0.1.0-alpha2 // indirect
github.com/anacrolix/btree v0.0.0-20251201064447-d86c3fa41bd8 // indirect
github.com/anacrolix/chansync v0.7.0 // indirect
github.com/anacrolix/dht/v2 v2.23.0 // indirect
github.com/anacrolix/envpprof v1.4.0 // indirect
github.com/anacrolix/generics v0.1.1-0.20251125230353-15d98d46693b // indirect
github.com/anacrolix/go-libutp v1.3.2 // indirect
github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb // indirect
github.com/anacrolix/missinggo v1.3.0 // indirect
github.com/anacrolix/missinggo/perf v1.0.0 // indirect
github.com/anacrolix/mmsg v1.0.1 // indirect
github.com/anacrolix/multiless v0.4.0 // indirect
github.com/anacrolix/stm v0.5.0 // indirect
github.com/anacrolix/sync v0.5.5-0.20251119100342-d78dd1f686f1 // indirect
github.com/anacrolix/upnp v0.1.4 // indirect
github.com/anacrolix/utp v0.1.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/benbjohnson/immutable v0.4.1-0.20221220213129-8932b999621d // indirect
github.com/bits-and-blooms/bitset v1.2.2 // indirect
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.0 // indirect
github.com/edsrzf/mmap-go v1.1.0 // indirect
github.com/go-llsqlite/adapter v0.0.0-20230927005056-7f5ce7f0c916 // indirect
github.com/go-llsqlite/crawshaw v0.5.6-0.20250312230104-194977a03421 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/btree v1.1.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/huandu/xstrings v1.3.2 // indirect
github.com/klauspost/cpuid/v2 v2.2.3 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/minio/sha256-simd v1.0.0 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect
github.com/mschoch/smat v0.2.0 // indirect
github.com/multiformats/go-multihash v0.2.3 // indirect
github.com/multiformats/go-varint v0.0.6 // indirect
github.com/pion/datachannel v1.5.9 // indirect
github.com/pion/dtls/v3 v3.0.3 // indirect
github.com/pion/ice/v4 v4.0.2 // indirect
github.com/pion/interceptor v0.1.40 // indirect
github.com/pion/logging v0.2.3 // indirect
github.com/pion/mdns/v2 v2.0.7 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.15 // indirect
github.com/pion/rtp v1.8.18 // indirect
github.com/pion/sctp v1.8.33 // indirect
github.com/pion/sdp/v3 v3.0.9 // indirect
github.com/pion/srtp/v3 v3.0.4 // indirect
github.com/pion/stun/v3 v3.0.0 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect
github.com/pion/turn/v4 v4.0.0 // indirect
github.com/pion/webrtc/v4 v4.0.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/protolambda/ctxlock v0.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/tidwall/btree v1.8.1 // indirect
github.com/wlynxg/anet v0.0.3 // indirect
go.etcd.io/bbolt v1.3.6 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
golang.org/x/crypto v0.44.0 // indirect
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/text v0.31.0 // indirect
lukechampine.com/blake3 v1.1.6 // indirect
modernc.org/libc v1.22.3 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.21.1 // indirect
zombiezen.com/go/sqlite v0.13.1 // indirect
)

509
go.sum Normal file
View File

@@ -0,0 +1,509 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
crawshaw.io/iox v0.0.0-20181124134642-c51c3df30797/go.mod h1:sXBiorCo8c46JlQV3oXPKINnZ8mcqnye1EkVkqsectk=
crawshaw.io/sqlite v0.3.2/go.mod h1:igAO5JulrQ1DbdZdtVq48mnZUBAPOeFzer7VhDWNtW4=
filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmGfU=
filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/RoaringBitmap/roaring v0.4.7/go.mod h1:8khRDP4HmeXns4xIj9oGrKSz7XTQiJx2zgh7AcNke4w=
github.com/RoaringBitmap/roaring v0.4.17/go.mod h1:D3qVegWTmfCaX4Bl5CrBE9hfrSrrXIr8KVNvRsDi1NI=
github.com/RoaringBitmap/roaring v0.4.23/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06Mq5mKs52e1TwOo=
github.com/RoaringBitmap/roaring v1.2.3 h1:yqreLINqIrX22ErkKI0vY47/ivtJr6n+kMhVOVmhWBY=
github.com/RoaringBitmap/roaring v1.2.3/go.mod h1:plvDsJQpxOC5bw8LRteu/MLWHsHez/3y6cubLI4/1yE=
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
github.com/alecthomas/assert/v2 v2.0.0-alpha3 h1:pcHeMvQ3OMstAWgaeaXIAL8uzB9xMm2zlxt+/4ml8lk=
github.com/alecthomas/assert/v2 v2.0.0-alpha3/go.mod h1:+zD0lmDXTeQj7TgDgCt0ePWxb0hMC1G+PGTsTCv1B9o=
github.com/alecthomas/atomic v0.1.0-alpha2 h1:dqwXmax66gXvHhsOS4pGPZKqYOlTkapELkLb3MNdlH8=
github.com/alecthomas/atomic v0.1.0-alpha2/go.mod h1:zD6QGEyw49HIq19caJDc2NMXAy8rNi9ROrxtMXATfyI=
github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142 h1:8Uy0oSf5co/NZXje7U1z8Mpep++QJOldL2hs/sBQf48=
github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/anacrolix/btree v0.0.0-20251201064447-d86c3fa41bd8 h1:c02PsmoaChabVqAFm7pqPI1UIkDdDAjUaWa6ZmfxybQ=
github.com/anacrolix/btree v0.0.0-20251201064447-d86c3fa41bd8/go.mod h1:7stWJ39LeusmMI8mjJuhFNRqep//vx0AsaySRoK9or0=
github.com/anacrolix/chansync v0.7.0 h1:wgwxbsJRmOqNjil4INpxHrDp4rlqQhECxR8/WBP4Et0=
github.com/anacrolix/chansync v0.7.0/go.mod h1:DZsatdsdXxD0WiwcGl0nJVwyjCKMDv+knl1q2iBjA2k=
github.com/anacrolix/dht/v2 v2.23.0 h1:EuD17ykTTEkAMPLjBsS5QjGOwuBgLTdQhds6zPAjeVY=
github.com/anacrolix/dht/v2 v2.23.0/go.mod h1:seXRz6HLw8zEnxlysf9ye2eQbrKUmch6PyOHpe/Nb/U=
github.com/anacrolix/envpprof v0.0.0-20180404065416-323002cec2fa/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c=
github.com/anacrolix/envpprof v1.0.0/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c=
github.com/anacrolix/envpprof v1.1.0/go.mod h1:My7T5oSqVfEn4MD4Meczkw/f5lSIndGAKu/0SM/rkf4=
github.com/anacrolix/envpprof v1.4.0 h1:QHeIcrgHcRChhnxR8l6rlaLlRQx9zd7Q2NII6Zbt83w=
github.com/anacrolix/envpprof v1.4.0/go.mod h1:7QIG4CaX1uexQ3tqd5+BRa/9e2D02Wcertl6Yh0jCB0=
github.com/anacrolix/generics v0.0.0-20230113004304-d6428d516633/go.mod h1:ff2rHB/joTV03aMSSn/AZNnaIpUw0h3njetGsaXcMy8=
github.com/anacrolix/generics v0.1.1-0.20251125230353-15d98d46693b h1:Kuvx/A/TTJuT9x8mn7DeGx2KW9tWn1LI8bira67xdT0=
github.com/anacrolix/generics v0.1.1-0.20251125230353-15d98d46693b/go.mod h1:NGehhfeXJPBujPx0s6cstSj8B+TERsTY32Xckfx5ftc=
github.com/anacrolix/go-libutp v1.3.2 h1:WswiaxTIogchbkzNgGHuHRfbrYLpv4o290mlvcx+++M=
github.com/anacrolix/go-libutp v1.3.2/go.mod h1:fCUiEnXJSe3jsPG554A200Qv+45ZzIIyGEvE56SHmyA=
github.com/anacrolix/log v0.3.0/go.mod h1:lWvLTqzAnCWPJA08T2HCstZi0L1y2Wyvm3FJgwU9jwU=
github.com/anacrolix/log v0.6.0/go.mod h1:lWvLTqzAnCWPJA08T2HCstZi0L1y2Wyvm3FJgwU9jwU=
github.com/anacrolix/log v0.13.1/go.mod h1:D4+CvN8SnruK6zIFS/xPoRJmtvtnxs+CSfDQ+BFxZ68=
github.com/anacrolix/log v0.14.2/go.mod h1:1OmJESOtxQGNMlUO5rcv96Vpp9mfMqXXbe2RdinFLdY=
github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb h1:nGNLCQbxFQZz7/9PXLGQ9GmavI/W+eX66pSwVeUwugU=
github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb/go.mod h1:YjBZbwe2v3RsU7WdoBlVSPVpfKuOAno9SRQ/8tIl+hk=
github.com/anacrolix/lsan v0.0.0-20211126052245-807000409a62/go.mod h1:66cFKPCO7Sl4vbFnAaSq7e4OXtdMhRSBagJGWgmpJbM=
github.com/anacrolix/lsan v0.1.0 h1:TbgB8fdVXgBwrNsJGHtht9+9FepNFu5H7dU8ek6XYAY=
github.com/anacrolix/lsan v0.1.0/go.mod h1:66cFKPCO7Sl4vbFnAaSq7e4OXtdMhRSBagJGWgmpJbM=
github.com/anacrolix/missinggo v0.0.0-20180725070939-60ef2fbf63df/go.mod h1:kwGiTUTZ0+p4vAz3VbAI5a30t2YbvemcmspjKwrAz5s=
github.com/anacrolix/missinggo v1.1.0/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo=
github.com/anacrolix/missinggo v1.1.2-0.20190815015349-b888af804467/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xopPFJJbTi5yIo=
github.com/anacrolix/missinggo v1.2.1/go.mod h1:J5cMhif8jPmFoC3+Uvob3OXXNIhOUikzMt+uUjeM21Y=
github.com/anacrolix/missinggo v1.3.0 h1:06HlMsudotL7BAELRZs0yDZ4yVXsHXGi323QBjAVASw=
github.com/anacrolix/missinggo v1.3.0/go.mod h1:bqHm8cE8xr+15uVfMG3BFui/TxyB6//H5fwlq/TeqMc=
github.com/anacrolix/missinggo/perf v1.0.0 h1:7ZOGYziGEBytW49+KmYGTaNfnwUqP1HBsy6BqESAJVw=
github.com/anacrolix/missinggo/perf v1.0.0/go.mod h1:ljAFWkBuzkO12MQclXzZrosP5urunoLS0Cbvb4V0uMQ=
github.com/anacrolix/missinggo/v2 v2.2.0/go.mod h1:o0jgJoYOyaoYQ4E2ZMISVa9c88BbUBVQQW4QeRkNCGY=
github.com/anacrolix/missinggo/v2 v2.5.1/go.mod h1:WEjqh2rmKECd0t1VhQkLGTdIWXO6f6NLjp5GlMZ+6FA=
github.com/anacrolix/missinggo/v2 v2.10.0 h1:pg0iO4Z/UhP2MAnmGcaMtp5ZP9kyWsusENWN9aolrkY=
github.com/anacrolix/missinggo/v2 v2.10.0/go.mod h1:nCRMW6bRCMOVcw5z9BnSYKF+kDbtenx+hQuphf4bK8Y=
github.com/anacrolix/mmsg v1.0.1 h1:TxfpV7kX70m3f/O7ielL/2I3OFkMPjrRCPo7+4X5AWw=
github.com/anacrolix/mmsg v1.0.1/go.mod h1:x8kRaJY/dCrY9Al0PEcj1mb/uFHwP6GCJ9fLl4thEPc=
github.com/anacrolix/multiless v0.4.0 h1:lqSszHkliMsZd2hsyrDvHOw4AbYWa+ijQ66LzbjqWjM=
github.com/anacrolix/multiless v0.4.0/go.mod h1:zJv1JF9AqdZiHwxqPgjuOZDGWER6nyE48WBCi/OOrMM=
github.com/anacrolix/stm v0.2.0/go.mod h1:zoVQRvSiGjGoTmbM0vSLIiaKjWtNPeTvXUSdJQA4hsg=
github.com/anacrolix/stm v0.5.0 h1:9df1KBpttF0TzLgDq51Z+TEabZKMythqgx89f1FQJt8=
github.com/anacrolix/stm v0.5.0/go.mod h1:MOwrSy+jCm8Y7HYfMAwPj7qWVu7XoVvjOiYwJmpeB/M=
github.com/anacrolix/sync v0.0.0-20180808010631-44578de4e778/go.mod h1:s735Etp3joe/voe2sdaXLcqDdJSay1O0OPnM0ystjqk=
github.com/anacrolix/sync v0.3.0/go.mod h1:BbecHL6jDSExojhNtgTFSBcdGerzNc64tz3DCOj/I0g=
github.com/anacrolix/sync v0.5.5-0.20251119100342-d78dd1f686f1 h1:oLCfNgEOR3/Z98mSwmwTM1pcqCDb/1zIjxCNn7dzVaE=
github.com/anacrolix/sync v0.5.5-0.20251119100342-d78dd1f686f1/go.mod h1:21cUWerw9eiu/3T3kyoChu37AVO+YFue1/H15qqubS0=
github.com/anacrolix/tagflag v0.0.0-20180109131632-2146c8d41bf0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw=
github.com/anacrolix/tagflag v1.0.0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw=
github.com/anacrolix/tagflag v1.1.0/go.mod h1:Scxs9CV10NQatSmbyjqmqmeQNwGzlNe0CMUMIxqHIG8=
github.com/anacrolix/torrent v1.61.0 h1:vxo+B4SwnoP5AQWbhvnTYIaTgPSX+llYUVuQVsN4Jg8=
github.com/anacrolix/torrent v1.61.0/go.mod h1:yKUKuZSSDdyOsCbuH+rDOpswl/g546gICapdrU7aUmQ=
github.com/anacrolix/upnp v0.1.4 h1:+2t2KA6QOhm/49zeNyeVwDu1ZYS9dB9wfxyVvh/wk7U=
github.com/anacrolix/upnp v0.1.4/go.mod h1:Qyhbqo69gwNWvEk1xNTXsS5j7hMHef9hdr984+9fIic=
github.com/anacrolix/utp v0.1.0 h1:FOpQOmIwYsnENnz7tAGohA+r6iXpRjrq8ssKSre2Cp4=
github.com/anacrolix/utp v0.1.0/go.mod h1:MDwc+vsGEq7RMw6lr2GKOEqjWny5hO5OZXRVNaBJ2Dk=
github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/benbjohnson/immutable v0.2.0/go.mod h1:uc6OHo6PN2++n98KHLxW8ef4W42ylHiQSENghE1ezxI=
github.com/benbjohnson/immutable v0.4.1-0.20221220213129-8932b999621d h1:2qVb9bsAMtmAfnxXltm+6eBzrrS7SZ52c3SedsulaMI=
github.com/benbjohnson/immutable v0.4.1-0.20221220213129-8932b999621d/go.mod h1:iAr8OjJGLnLmVUr9MZ/rz4PWUy6Ouc2JLYuMArmvAJM=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bits-and-blooms/bitset v1.2.0/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA=
github.com/bits-and-blooms/bitset v1.2.2 h1:J5gbX05GpMdBjCvQ9MteIg2KKDExr7DrgK+Yc15FvIk=
github.com/bits-and-blooms/bitset v1.2.2/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA=
github.com/bradfitz/iter v0.0.0-20140124041915-454541ec3da2/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo=
github.com/bradfitz/iter v0.0.0-20190303215204-33e6a9893b0c/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo=
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 h1:GKTyiRCL6zVf5wWaqKnf+7Qs6GbEPfd4iMOitWzXJx8=
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8/go.mod h1:spo1JLcs67NmW1aVLEgtA8Yy1elc+X8y5SRW1sFW4Og=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
github.com/dustin/go-humanize v0.0.0-20180421182945-02af3965c54e/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/edsrzf/mmap-go v1.1.0 h1:6EUwBLQ/Mcr1EYLE4Tn1VdW1A4ckqCQWZBw8Hr0kjpQ=
github.com/edsrzf/mmap-go v1.1.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
github.com/frankban/quicktest v1.9.0/go.mod h1:ui7WezCLWMWxVWr1GETZY3smRy0G4KWq9vcPtJmFl7Y=
github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
github.com/glycerine/go-unsnap-stream v0.0.0-20190901134440-81cf024a9e0a/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
github.com/glycerine/goconvey v0.0.0-20180728074245-46e3a41ad493/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
github.com/glycerine/goconvey v0.0.0-20190315024820-982ee783a72e/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-llsqlite/adapter v0.0.0-20230927005056-7f5ce7f0c916 h1:OyQmpAN302wAopDgwVjgs2HkFawP9ahIEqkUYz7V7CA=
github.com/go-llsqlite/adapter v0.0.0-20230927005056-7f5ce7f0c916/go.mod h1:DADrR88ONKPPeSGjFp5iEN55Arx3fi2qXZeKCYDpbmU=
github.com/go-llsqlite/crawshaw v0.5.6-0.20250312230104-194977a03421 h1:GClwZI0at7xwV0TpgUMTYr/DoTE7TJZ/tc29LcPcs7o=
github.com/go-llsqlite/crawshaw v0.5.6-0.20250312230104-194977a03421/go.mod h1:/YJdV7uBQaYDE0fwe4z3wwJIZBJxdYzd38ICggWqtaE=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20180124185431-e89373fe6b4a/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20190309154008-847fc94819f9/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo=
github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4=
github.com/huandu/xstrings v1.3.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw=
github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU=
github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg=
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
github.com/multiformats/go-varint v0.0.6 h1:gk85QWKxh3TazbLxED/NlDVv8+q+ReFJk7Y2W/KhfNY=
github.com/multiformats/go-varint v0.0.6/go.mod h1:3Ls8CIEsrijN6+B7PbrXRPxHRPuXSrVKRY101jdMZYE=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw=
github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pion/datachannel v1.5.9 h1:LpIWAOYPyDrXtU+BW7X0Yt/vGtYxtXQ8ql7dFfYUVZA=
github.com/pion/datachannel v1.5.9/go.mod h1:kDUuk4CU4Uxp82NH4LQZbISULkX/HtzKa4P7ldf9izE=
github.com/pion/dtls/v3 v3.0.3 h1:j5ajZbQwff7Z8k3pE3S+rQ4STvKvXUdKsi/07ka+OWM=
github.com/pion/dtls/v3 v3.0.3/go.mod h1:weOTUyIV4z0bQaVzKe8kpaP17+us3yAuiQsEAG1STMU=
github.com/pion/ice/v4 v4.0.2 h1:1JhBRX8iQLi0+TfcavTjPjI6GO41MFn4CeTBX+Y9h5s=
github.com/pion/ice/v4 v4.0.2/go.mod h1:DCdqyzgtsDNYN6/3U8044j3U7qsJ9KFJC92VnOWHvXg=
github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4=
github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
github.com/pion/rtp v1.8.18 h1:yEAb4+4a8nkPCecWzQB6V/uEU18X1lQCGAQCjP+pyvU=
github.com/pion/rtp v1.8.18/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
github.com/pion/sctp v1.8.33 h1:dSE4wX6uTJBcNm8+YlMg7lw1wqyKHggsP5uKbdj+NZw=
github.com/pion/sctp v1.8.33/go.mod h1:beTnqSzewI53KWoG3nqB282oDMGrhNxBdb+JZnkCwRM=
github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY=
github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M=
github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M=
github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ=
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
github.com/pion/webrtc/v4 v4.0.0 h1:x8ec7uJQPP3D1iI8ojPAiTOylPI7Fa7QgqZrhpLyqZ8=
github.com/pion/webrtc/v4 v4.0.0/go.mod h1:SfNn8CcFxR6OUVjLXVslAQ3a3994JhyE3Hw1jAuqEto=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.5.1/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/protolambda/ctxlock v0.1.0 h1:rCUY3+vRdcdZXqT07iXgyr744J2DU2LCBIXowYAjBCE=
github.com/protolambda/ctxlock v0.1.0/go.mod h1:vefhX6rIZH8rsg5ZpOJfEDYQOppZi19SfPiGOFrNnwM=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417 h1:Lt9DzQALzHoDwMBGJ6v8ObDPR0dzr2a6sXTB1Fq7IHs=
github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417/go.mod h1:qe5TWALJ8/a1Lqznoc5BDHpYX/8HU60Hm2AwRmqzxqA=
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8=
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/assertions v0.0.0-20190215210624-980c5ac6f3ac/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
github.com/smartystreets/goconvey v0.0.0-20190306220146-200a235640ff/go.mod h1:KSQcGKpxUMHk3nbYzs/tIBAM2iDooCn0BmttHOJEbLs=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA=
github.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A=
github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU=
go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20220428152302-39d4317da171/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
lukechampine.com/blake3 v1.1.6 h1:H3cROdztr7RCfoaTpGZFQsrqvweFLrqS73j7L7cmR5c=
lukechampine.com/blake3 v1.1.6/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA=
modernc.org/libc v1.22.3 h1:D/g6O5ftAfavceqlLOFwaZuA5KYafKwmr30A6iSqoyY=
modernc.org/libc v1.22.3/go.mod h1:MQrloYP209xa2zHome2a8HLiLm6k0UT8CoHpV74tOFw=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.21.1 h1:GyDFqNnESLOhwwDRaHGdp2jKLDzpyT/rNLglX3ZkMSU=
modernc.org/sqlite v1.21.1/go.mod h1:XwQ0wZPIh1iKb5mkvCJ3szzbhk+tykC8ZWqTRTgYRwI=
zombiezen.com/go/sqlite v0.13.1 h1:qDzxyWWmMtSSEH5qxamqBFmqA2BLSSbtODi3ojaE02o=
zombiezen.com/go/sqlite v0.13.1/go.mod h1:Ht/5Rg3Ae2hoyh1I7gbWtWAl89CNocfqeb/aAMTkJr4=

27
httpdl/alloc_linux.go Normal file
View File

@@ -0,0 +1,27 @@
//go:build linux
package httpdl
import (
"os"
"golang.org/x/sys/unix"
)
// allocate reserves disk space for the output file according to the
// --file-allocation mode: none does nothing (sparse), trunc sets the size,
// prealloc/falloc reserve real blocks with fallocate (falling back to a plain
// truncate if the filesystem does not support it).
func allocate(f *os.File, mode string, size int64) error {
switch mode {
case "none":
return nil
case "trunc":
return f.Truncate(size)
default: // prealloc, falloc
if err := unix.Fallocate(int(f.Fd()), 0, 0, size); err != nil {
return f.Truncate(size)
}
return nil
}
}

14
httpdl/alloc_other.go Normal file
View File

@@ -0,0 +1,14 @@
//go:build !linux
package httpdl
import "os"
// allocate is the portable fallback: fallocate is Linux-specific, so on other
// platforms prealloc/falloc degrade to a plain truncate.
func allocate(f *os.File, mode string, size int64) error {
if mode == "none" {
return nil
}
return f.Truncate(size)
}

22
httpdl/ca_test.go Normal file
View File

@@ -0,0 +1,22 @@
package httpdl
import (
"os"
"path/filepath"
"testing"
)
func TestLoadCAPool(t *testing.T) {
// A missing file is an error, not a silent fall-back to the system roots.
if _, err := loadCAPool(filepath.Join(t.TempDir(), "absent.pem")); err == nil {
t.Errorf("loadCAPool(missing): want error")
}
// A file with no PEM certificates is an error (AppendCertsFromPEM found none).
bad := filepath.Join(t.TempDir(), "bad.pem")
if err := os.WriteFile(bad, []byte("not a certificate\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := loadCAPool(bad); err == nil {
t.Errorf("loadCAPool(garbage): want error (no certificates)")
}
}

82
httpdl/checksum.go Normal file
View File

@@ -0,0 +1,82 @@
package httpdl
import (
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"errors"
"fmt"
"hash"
"io"
"os"
"strings"
)
// ErrChecksum reports that a finished download did not match the digest given
// with --checksum. main maps it to exit code 32 via errors.Is, so it
// must stay on the error chain (CONTRACT).
var ErrChecksum = errors.New("checksum mismatch")
// hashers maps each supported hash-type name to a constructor. These are exactly
// the names accepted in --checksum.
var hashers = map[string]func() hash.Hash{
"md5": md5.New,
"sha-1": sha1.New,
"sha-224": sha256.New224,
"sha-256": sha256.New,
"sha-384": sha512.New384,
"sha-512": sha512.New,
}
// parseChecksum splits a "<type>=<digest>" spec into a hash constructor
// and the expected lowercase-hex digest. Type and digest are matched
// case-insensitively. An unknown type or a malformed digest is an
// error, so a bad --checksum fails before the download rather than after it.
func parseChecksum(spec string) (func() hash.Hash, string, error) {
typ, want, ok := strings.Cut(spec, "=")
if !ok {
return nil, "", fmt.Errorf("bad --checksum %q: want TYPE=DIGEST", spec)
}
newHash, ok := hashers[strings.ToLower(strings.TrimSpace(typ))]
if !ok {
return nil, "", fmt.Errorf("unsupported checksum type %q (md5, sha-1, sha-224, sha-256, sha-384, sha-512)", typ)
}
want = strings.ToLower(strings.TrimSpace(want))
if _, err := hex.DecodeString(want); err != nil {
return nil, "", fmt.Errorf("bad --checksum digest %q: not hex", want)
}
if n := newHash().Size() * 2; len(want) != n {
return nil, "", fmt.Errorf("bad --checksum digest %q: %s wants %d hex chars", want, typ, n)
}
return newHash, want, nil
}
// ValidChecksum reports whether spec is a well-formed --checksum value: a
// supported type and a correctly-sized hex digest. main calls it once at startup
// so a bad spec aborts as a usage error before any download begins, validating
// --checksum at option-parse time.
func ValidChecksum(spec string) error {
_, _, err := parseChecksum(spec)
return err
}
// verifyFile streams the file at path through newHash and compares the result to
// want, returning ErrChecksum on a mismatch. Streaming keeps memory use constant
// regardless of file size.
func verifyFile(path string, newHash func() hash.Hash, want string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
h := newHash()
if _, err := io.Copy(h, f); err != nil {
return err
}
if got := hex.EncodeToString(h.Sum(nil)); got != want {
return fmt.Errorf("%s: %w (want %s, got %s)", path, ErrChecksum, want, got)
}
return nil
}

60
httpdl/checksum_test.go Normal file
View File

@@ -0,0 +1,60 @@
package httpdl
import (
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"path/filepath"
"testing"
)
func TestParseChecksum(t *testing.T) {
// A valid spec parses; the type and digest match case-insensitively.
good := "SHA-256=" + hex.EncodeToString(make([]byte, 32))
if _, want, err := parseChecksum(good); err != nil || len(want) != 64 {
t.Fatalf("parseChecksum(%q) = %q, %v; want a 64-char digest and nil error", good, want, err)
}
full := hex.EncodeToString(make([]byte, 32))
bad := map[string]string{
"no '='": "sha-256",
"unknown type": "sha-999=" + full,
"wrong type name": "sha256=" + full, // the type is spelled sha-256
"digest not hex": "sha-256=zzzz",
"wrong length": "sha-256=abcd",
}
for name, spec := range bad {
if _, _, err := parseChecksum(spec); err == nil {
t.Errorf("%s: parseChecksum(%q) = nil error, want error", name, spec)
}
}
}
func TestValidChecksum(t *testing.T) {
good := "sha-256=" + hex.EncodeToString(make([]byte, 32))
if err := ValidChecksum(good); err != nil {
t.Errorf("ValidChecksum(%q) = %v, want nil", good, err)
}
if err := ValidChecksum("crc32=deadbeef"); err == nil {
t.Errorf("ValidChecksum(crc32): want error (unsupported type)")
}
}
func TestVerifyFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "f")
data := []byte("the data is the design")
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(data)
// A matching digest verifies clean.
if err := verifyFile(path, sha256.New, hex.EncodeToString(sum[:])); err != nil {
t.Errorf("verifyFile match: %v", err)
}
// A wrong digest returns ErrChecksum, which main maps to exit code 32.
wrong := hex.EncodeToString(make([]byte, 32))
if err := verifyFile(path, sha256.New, wrong); !errors.Is(err, ErrChecksum) {
t.Errorf("verifyFile mismatch = %v; want ErrChecksum", err)
}
}

View File

@@ -0,0 +1,97 @@
package httpdl
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/hanbok/got/download"
)
// TestConditionalGet304 checks that --conditional-get sends If-Modified-Since
// for an existing local file and treats a 304 as success without rewriting the
// file.
func TestConditionalGet304(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "file.bin")
const existing = "already here"
if err := os.WriteFile(out, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
gotIfModSince := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("If-Modified-Since") != "" {
gotIfModSince = true
}
w.WriteHeader(http.StatusNotModified)
}))
defer srv.Close()
d := New([]string{srv.URL + "/file.bin"}, Config{
Dir: dir,
Out: "file.bin",
MaxConnPerServer: 1,
ConditionalGet: true,
AllowOverwrite: true,
UserAgent: "got-test",
})
if err := d.Run(context.Background()); err != nil {
t.Fatalf("conditional-get 304 should succeed, got %v", err)
}
if !gotIfModSince {
t.Fatal("expected an If-Modified-Since header on the request")
}
if d.Stat().Status != download.Complete {
t.Fatalf("status = %v, want Complete", d.Stat().Status)
}
// The file must be untouched: 304 means "up to date".
b, err := os.ReadFile(out)
if err != nil {
t.Fatal(err)
}
if string(b) != existing {
t.Fatalf("file was rewritten: %q, want %q", b, existing)
}
}
// TestConditionalGetSkippedWithControlFile verifies the rule that
// --conditional-get is ignored when a .got control file exists, so an
// in-progress resume is never short-circuited by a 304.
func TestConditionalGetSkippedWithControlFile(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "file.bin")
if err := os.WriteFile(out, []byte("partial"), 0o644); err != nil {
t.Fatal(err)
}
// A control file present means a resume is in flight.
if err := os.WriteFile(controlPath(out), []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
sawIfModSince := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("If-Modified-Since") != "" {
sawIfModSince = true
}
// Serve a small body so the run can complete.
http.Error(w, "no range", http.StatusOK)
}))
defer srv.Close()
d := New([]string{srv.URL + "/file.bin"}, Config{
Dir: dir,
Out: "file.bin",
MaxConnPerServer: 1,
ConditionalGet: true,
AllowOverwrite: true,
UserAgent: "got-test",
})
_ = d.Run(context.Background())
if sawIfModSince {
t.Fatal("If-Modified-Since must not be sent when a control file exists")
}
}

79
httpdl/control.go Normal file
View File

@@ -0,0 +1,79 @@
package httpdl
import (
"encoding/json"
"os"
"path/filepath"
"sync/atomic"
)
// control is the on-disk resume state, a small JSON sidecar next to the output
// file (<out>.got). It is a Go-simple binary-free control file: enough to skip
// finished segments and resume partial ones, plus validators (Total +
// ETag/LastModified) so we never trust a stale file.
type control struct {
URL string `json:"url"`
Total int64 `json:"total"`
ETag string `json:"etag,omitempty"`
LastModified string `json:"last_modified,omitempty"`
Segs []segState `json:"segs"`
}
type segState struct {
Start int64 `json:"start"`
End int64 `json:"end"`
Written int64 `json:"written"`
}
func controlPath(out string) string { return out + ".got" }
// snapshot builds a control record from the live segments.
func snapshot(url string, total int64, etag, lastmod string, segs []seg) control {
c := control{URL: url, Total: total, ETag: etag, LastModified: lastmod, Segs: make([]segState, len(segs))}
for i := range segs {
c.Segs[i] = segState{segs[i].start, segs[i].end, atomic.LoadInt64(&segs[i].written)}
}
return c
}
// save writes the control file atomically (temp + rename).
func (c control) save(out string) error {
data, err := json.Marshal(c)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
return err
}
tmp := controlPath(out) + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, controlPath(out))
}
// loadControl reads a control file if it is present and still matches the
// download (same URL, length and validator). It returns nil when there is
// nothing trustworthy to resume from.
func loadControl(out, url string, total int64, etag, lastmod string) *control {
data, err := os.ReadFile(controlPath(out))
if err != nil {
return nil
}
var c control
if json.Unmarshal(data, &c) != nil {
return nil
}
if c.URL != url || c.Total != total {
return nil
}
if (etag != "" || c.ETag != "") && c.ETag != etag {
return nil
}
if (lastmod != "" || c.LastModified != "") && c.LastModified != lastmod {
return nil
}
return &c
}
func removeControl(out string) { os.Remove(controlPath(out)) }

177
httpdl/cookies.go Normal file
View File

@@ -0,0 +1,177 @@
package httpdl
import (
"bufio"
"fmt"
"math"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"strconv"
"strings"
"time"
)
// Cookie handling implements the --load-cookies / --save-cookies options, which
// read and write the Netscape/Mozilla cookies.txt format (one tab-separated
// cookie per line). We back it with the standard net/http/cookiejar so the
// http.Client applies and tracks Set-Cookie headers automatically; the file
// format is just the on-disk representation of that jar.
// loadCookieJar builds a cookie jar seeded from a Netscape cookies.txt file. A
// missing file yields an empty jar (not an error): there are simply no cookies
// to load. A malformed line is skipped, giving per-line tolerance.
func loadCookieJar(path string) (*cookiejar.Jar, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, err
}
if path == "" {
return jar, nil
}
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return jar, nil // nothing to load yet
}
return nil, fmt.Errorf("load-cookies %s: %w", path, err)
}
defer f.Close()
// Group cookies by the URL their domain implies, since SetCookies keys on a
// URL. We synthesise a representative URL per (domain, secure, path).
byURL := map[string][]*http.Cookie{}
urls := map[string]*url.URL{}
sc := bufio.NewScanner(f)
for sc.Scan() {
line := sc.Text()
c, u := parseNetscapeCookie(line)
if c == nil {
continue
}
key := u.String()
if _, ok := urls[key]; !ok {
urls[key] = u
}
byURL[key] = append(byURL[key], c)
}
if err := sc.Err(); err != nil {
return nil, fmt.Errorf("load-cookies %s: %w", path, err)
}
for key, cs := range byURL {
jar.SetCookies(urls[key], cs)
}
return jar, nil
}
// parseNetscapeCookie parses one cookies.txt line into a cookie plus the URL it
// belongs to, or (nil, nil) for a comment, blank or malformed line. The format
// is seven tab-separated fields:
//
// domain includeSubdomains path secure expiry name value
//
// Curl marks HttpOnly cookies with a "#HttpOnly_" line prefix rather than a
// real comment, so we recognise that prefix instead of dropping the line.
// Honouring HttpOnly_ rather than skipping every "#" line never loses a cookie.
func parseNetscapeCookie(line string) (*http.Cookie, *url.URL) {
httpOnly := false
if strings.HasPrefix(line, "#HttpOnly_") {
httpOnly = true
line = strings.TrimPrefix(line, "#HttpOnly_")
} else if strings.HasPrefix(line, "#") {
return nil, nil
}
if strings.TrimSpace(line) == "" {
return nil, nil
}
f := strings.Split(line, "\t")
if len(f) < 6 {
return nil, nil
}
domain := f[0]
hostOnly := !strings.EqualFold(f[1], "TRUE")
path := f[2]
secure := strings.EqualFold(f[3], "TRUE")
name := f[5]
value := ""
if len(f) >= 7 {
value = f[6]
}
if name == "" || domain == "" {
return nil, nil
}
c := &http.Cookie{
Name: name,
Value: value,
Path: path,
Secure: secure,
HttpOnly: httpOnly,
}
// A non-host-only cookie (includeSubdomains TRUE) carries a Domain attribute
// so the jar applies it to subdomains; a host-only one leaves Domain empty so
// the jar binds it to the exact request host.
if !hostOnly {
c.Domain = domain
}
// expiry 0 means a session cookie; otherwise it is a Unix timestamp. The jar
// drops already-expired cookies on load.
if exp, err := strconv.ParseInt(strings.TrimSpace(f[4]), 10, 64); err == nil && exp > 0 {
c.Expires = time.Unix(exp, 0)
}
// The jar needs a URL whose host and scheme match the cookie. A leading dot
// denotes a domain cookie; strip it for the host but keep the cookie Domain
// so the jar treats it as domain-wide.
host := strings.TrimPrefix(domain, ".")
scheme := "http"
if secure {
scheme = "https"
}
u := &url.URL{Scheme: scheme, Host: host, Path: path}
return c, u
}
// saveCookieJar writes every cookie the jar holds (for the hosts we have
// contacted) back to path in Netscape format, implementing --save-cookies on
// exit. Because net/http/cookiejar exposes cookies only per URL, we ask it for
// the cookies of each host we recorded a request against.
func saveCookieJar(jar *cookiejar.Jar, urls []*url.URL, path string) error {
if path == "" || jar == nil {
return nil
}
var b strings.Builder
b.WriteString("# Netscape HTTP Cookie File\n")
seen := map[string]bool{}
for _, u := range urls {
for _, c := range jar.Cookies(u) {
// Cookies() drops the domain/path/expiry, so reconstruct sensible
// values: host-only domain, root path, far-future expiry. This is the
// best a host-only readback can do; it round-trips the name/value pair
// that authenticated downloads actually need.
key := u.Host + "\x00" + c.Name
if seen[key] {
continue
}
seen[key] = true
expiry := int64(math.MaxInt32) // session-stable far future
line := strings.Join([]string{
u.Host, "FALSE", "/",
boolToTRUE(u.Scheme == "https"),
strconv.FormatInt(expiry, 10),
c.Name, c.Value,
}, "\t")
b.WriteString(line)
b.WriteByte('\n')
}
}
return os.WriteFile(path, []byte(b.String()), 0o600)
}
func boolToTRUE(b bool) string {
if b {
return "TRUE"
}
return "FALSE"
}

126
httpdl/cookies_test.go Normal file
View File

@@ -0,0 +1,126 @@
package httpdl
import (
"net/http"
"os"
"path/filepath"
"strconv"
"testing"
"time"
)
func TestParseNetscapeCookie(t *testing.T) {
future := time.Now().Add(24 * time.Hour).Unix()
tests := []struct {
name string
line string
wantNil bool
wantName string
wantValue string
wantDom string // expected http.Cookie.Domain (empty for host-only)
wantHTTP bool
}{
{
name: "domain cookie",
line: ".example.com\tTRUE\t/\tFALSE\t" + itoa(future) + "\tSID\tabc123",
wantName: "SID",
wantValue: "abc123",
wantDom: ".example.com",
},
{
name: "host-only cookie",
line: "example.com\tFALSE\t/\tTRUE\t" + itoa(future) + "\ttoken\txyz",
wantName: "token",
wantValue: "xyz",
wantDom: "", // host-only: no Domain attribute
},
{
name: "httponly prefix kept",
line: "#HttpOnly_example.com\tFALSE\t/\tFALSE\t" + itoa(future) + "\tHO\tsecret",
wantName: "HO",
wantValue: "secret",
wantHTTP: true,
},
{
name: "empty value field",
line: "example.com\tFALSE\t/\tFALSE\t0\tflag\t",
wantName: "flag",
wantValue: "",
},
{
name: "comment line",
line: "# this is a comment",
wantNil: true,
},
{
name: "blank line",
line: " ",
wantNil: true,
},
{
name: "too few fields",
line: "example.com\tFALSE\t/\tFALSE\t0",
wantNil: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
c, u := parseNetscapeCookie(tc.line)
if tc.wantNil {
if c != nil {
t.Fatalf("expected nil cookie, got %+v", c)
}
return
}
if c == nil {
t.Fatal("expected a cookie, got nil")
}
if c.Name != tc.wantName || c.Value != tc.wantValue {
t.Fatalf("name/value = %q/%q, want %q/%q", c.Name, c.Value, tc.wantName, tc.wantValue)
}
if c.Domain != tc.wantDom {
t.Fatalf("domain = %q, want %q", c.Domain, tc.wantDom)
}
if c.HttpOnly != tc.wantHTTP {
t.Fatalf("httponly = %v, want %v", c.HttpOnly, tc.wantHTTP)
}
if u == nil {
t.Fatal("expected a URL, got nil")
}
})
}
}
func itoa(n int64) string { return strconv.FormatInt(n, 10) }
func itoa64(n int64) string { return strconv.FormatInt(n, 10) }
func TestLoadCookieJarMissingFile(t *testing.T) {
// A missing cookies file is not an error: there are simply no cookies to load.
jar, err := loadCookieJar(filepath.Join(t.TempDir(), "nope.txt"))
if err != nil {
t.Fatalf("missing file should not error: %v", err)
}
if jar == nil {
t.Fatal("expected an empty jar, got nil")
}
}
func TestLoadCookieJarAppliesCookie(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "cookies.txt")
future := time.Now().Add(24 * time.Hour).Unix()
data := "# Netscape HTTP Cookie File\n" +
".example.com\tTRUE\t/\tFALSE\t" + itoa64(future) + "\tSID\tabc123\n"
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
jar, err := loadCookieJar(path)
if err != nil {
t.Fatal(err)
}
req, _ := http.NewRequest(http.MethodGet, "http://www.example.com/file", nil)
got := jar.Cookies(req.URL)
if len(got) != 1 || got[0].Name != "SID" || got[0].Value != "abc123" {
t.Fatalf("jar did not apply the domain cookie to a subdomain: %+v", got)
}
}

1270
httpdl/httpdl.go Normal file

File diff suppressed because it is too large Load Diff

140
httpdl/mirror_test.go Normal file
View File

@@ -0,0 +1,140 @@
package httpdl
import (
"bytes"
"context"
"crypto/sha256"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
)
func TestMirrorPick(t *testing.T) {
d := &Download{uris: []string{"a", "b", "c"}}
if d.primary() != "a" {
t.Errorf("primary() = %q, want a", d.primary())
}
for i, want := range []string{"a", "b", "c", "a", "b"} {
if got := d.mirror(i); got != want {
t.Errorf("mirror(%d) = %q, want %q", i, got, want)
}
}
// A single mirror always returns itself, whatever the index.
one := &Download{uris: []string{"only"}}
if one.mirror(7) != "only" {
t.Errorf("single-mirror mirror(7) = %q, want only", one.mirror(7))
}
}
// TestMaxConns checks the connection ceiling min(split, mirrors*x), floored
// at 1, since that decides how many segment workers fan out.
func TestMaxConns(t *testing.T) {
cases := []struct{ split, x, mirrors, want int }{
{5, 1, 1, 1}, // default -x1 -s5: one connection
{5, 16, 1, 5}, // split is the ceiling on one server
{16, 16, 1, 16},
{5, 1, 3, 3}, // 3 mirrors * 1 per host < split
{16, 2, 3, 6}, // 3 mirrors * 2 per host < split
{0, 1, 1, 1}, // floor at 1
}
for _, c := range cases {
uris := make([]string, c.mirrors)
for i := range uris {
uris[i] = "http://h/x"
}
d := New(uris, Config{Split: c.split, MaxConnPerServer: c.x})
if d.maxConns != c.want {
t.Errorf("split=%d x=%d mirrors=%d: maxConns=%d, want %d", c.split, c.x, c.mirrors, d.maxConns, c.want)
}
}
}
// TestMirrorFallback proves a per-segment 404 on one mirror falls over to a
// healthy mirror, and the assembled file is byte-correct.
func TestMirrorFallback(t *testing.T) {
data := make([]byte, 300000)
for i := range data {
data[i] = byte(i * 7)
}
good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.ServeContent(w, r, "f.bin", time.Time{}, bytes.NewReader(data))
}))
defer good.Close()
var badHits int32
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&badHits, 1)
http.Error(w, "gone", http.StatusNotFound) // 404 every range
}))
defer bad.Close()
dir := t.TempDir()
out := filepath.Join(dir, "f.bin")
// Primary is healthy (so the probe anchors size/validators), bad is a mirror
// that 404s every segment it's handed.
d := New([]string{good.URL + "/f.bin", bad.URL + "/f.bin"}, Config{
Dir: dir, Out: "f.bin", Split: 4, MaxConnPerServer: 2, MinSplit: 1,
Tries: 1, UserAgent: "got-test",
})
if err := d.Run(context.Background()); err != nil {
t.Fatalf("Run with a 404 mirror should still complete via the healthy one: %v", err)
}
got, err := os.ReadFile(out)
if err != nil {
t.Fatal(err)
}
if sha256.Sum256(got) != sha256.Sum256(data) {
t.Fatalf("content mismatch: got %d bytes, want %d", len(got), len(data))
}
if atomic.LoadInt32(&badHits) == 0 {
t.Error("the bad mirror was never tried — the fallback path wasn't exercised")
}
}
// TestMirrorDivergentRejected proves a mirror serving a DIFFERENT file (a 206
// with the wrong total length) is rejected and falls over, so its bytes never
// corrupt the output via total-length validation.
func TestMirrorDivergentRejected(t *testing.T) {
good := make([]byte, 300000)
for i := range good {
good[i] = byte(i * 7)
}
bad := make([]byte, 250000) // different length and content, but ranges work
for i := range bad {
bad[i] = 0xAA
}
primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.ServeContent(w, r, "f.bin", time.Time{}, bytes.NewReader(good))
}))
defer primary.Close()
var divHits int32
div := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&divHits, 1)
http.ServeContent(w, r, "f.bin", time.Time{}, bytes.NewReader(bad))
}))
defer div.Close()
dir := t.TempDir()
out := filepath.Join(dir, "f.bin")
d := New([]string{primary.URL + "/f.bin", div.URL + "/f.bin"}, Config{
Dir: dir, Out: "f.bin", Split: 4, MaxConnPerServer: 2, MinSplit: 1,
Tries: 1, UserAgent: "got-test",
})
if err := d.Run(context.Background()); err != nil {
t.Fatalf("Run: %v", err)
}
got, err := os.ReadFile(out)
if err != nil {
t.Fatal(err)
}
if sha256.Sum256(got) != sha256.Sum256(good) {
t.Fatalf("a divergent mirror corrupted the output: got %d bytes, want %d (content differs)", len(got), len(good))
}
if atomic.LoadInt32(&divHits) == 0 {
t.Error("divergent mirror never contacted — the length-validation path wasn't exercised")
}
}

28
httpdl/retry_test.go Normal file
View File

@@ -0,0 +1,28 @@
package httpdl
import (
"errors"
"net"
"strings"
"testing"
)
// retriesExhausted must wrap (not discard) the underlying cause, so main's
// exit-code mapping can still tell a DNS failure from a refused connection.
func TestRetriesExhausted(t *testing.T) {
cause := &net.DNSError{Err: "no such host", Name: "x.invalid", IsNotFound: true}
err := retriesExhausted("http://x", 5, cause)
var de *net.DNSError
if !errors.As(err, &de) {
t.Fatalf("dropped the cause: %v", err)
}
if !strings.Contains(err.Error(), "after 5 tries") {
t.Errorf("missing try count: %v", err)
}
// A single attempt did not "retry", so it must not claim it did.
if one := retriesExhausted("segment 0", 1, cause); strings.Contains(one.Error(), "tries") {
t.Errorf("single attempt should not mention tries: %v", one)
}
}

53
httpdl/segment.go Normal file
View File

@@ -0,0 +1,53 @@
package httpdl
import "sync/atomic"
// seg is one contiguous byte range of the output file, downloaded by a single
// ranged GET. A segment IS a byte range — a plain HTTP downloader does not need
// the Piece/Segment/block layering that exists only to share code with
// BitTorrent. written is updated with atomic ops so Stat() can read it while a
// worker advances it.
type seg struct {
index int
start int64 // first byte offset, inclusive
end int64 // last byte offset, inclusive
written int64 // bytes already written into this segment (the resume point)
}
func (s *seg) length() int64 { return s.end - s.start + 1 }
func (s *seg) done() bool { return atomic.LoadInt64(&s.written) >= s.length() }
func (s *seg) advance(n int64) { atomic.AddInt64(&s.written, n) }
func (s *seg) progress() int64 { return atomic.LoadInt64(&s.written) }
func (s *seg) offset() int64 { return s.start + atomic.LoadInt64(&s.written) }
func (s *seg) remaining() int64 { return s.length() - atomic.LoadInt64(&s.written) }
// makeSegments divides a file of total bytes into contiguous segments, using at
// most conns of them and never splitting below minSplit. The remainder lands in
// the last segment. With conns==1 (the default) this yields one segment.
func makeSegments(total, minSplit int64, conns int) []seg {
if conns < 1 {
conns = 1
}
if minSplit < 1 {
minSplit = 1
}
n := int64(conns)
if max := total / minSplit; max < n {
n = max
}
if n < 1 {
n = 1
}
chunk := total / n
segs := make([]seg, n)
var start int64
for i := int64(0); i < n; i++ {
end := start + chunk - 1
if i == n-1 {
end = total - 1
}
segs[i] = seg{index: int(i), start: start, end: end}
start = end + 1
}
return segs
}

188
httpdl/segment_test.go Normal file
View File

@@ -0,0 +1,188 @@
package httpdl
import (
"os"
"path/filepath"
"testing"
)
func TestMakeSegments(t *testing.T) {
tests := []struct {
name string
total int64
minSplit int64
conns int
wantN int
}{
{"even split", 1000, 100, 5, 5},
{"min-split caps count", 1000, 400, 5, 2},
{"tiny file is one segment", 50, 100, 5, 1},
{"single connection", 1000, 1, 1, 1},
{"min-split exact", 1000, 200, 5, 5},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
segs := makeSegments(tc.total, tc.minSplit, tc.conns)
if len(segs) != tc.wantN {
t.Fatalf("got %d segments, want %d", len(segs), tc.wantN)
}
// Segments must be contiguous and cover the whole file exactly.
var next int64
for i, s := range segs {
if s.start != next {
t.Errorf("segment %d starts at %d, want %d", i, s.start, next)
}
if s.end < s.start {
t.Errorf("segment %d has end %d < start %d", i, s.end, s.start)
}
next = s.end + 1
}
if next != tc.total {
t.Errorf("segments cover %d bytes, want %d", next, tc.total)
}
})
}
}
func TestSegProgress(t *testing.T) {
s := seg{start: 100, end: 199} // length 100
if s.length() != 100 {
t.Fatalf("length = %d", s.length())
}
if s.done() {
t.Fatalf("new segment should not be done")
}
s.advance(60)
if s.offset() != 160 {
t.Errorf("offset = %d, want 160", s.offset())
}
if s.remaining() != 40 {
t.Errorf("remaining = %d, want 40", s.remaining())
}
s.advance(40)
if !s.done() {
t.Errorf("segment should be done after writing full length")
}
}
func TestSplitExt(t *testing.T) {
tests := []struct {
in string
stem, ext string
}{
{"foo.txt", "foo", ".txt"},
{"foo", "foo", ""},
{"/dir/foo.tar.gz", "/dir/foo.tar", ".gz"},
{"/dir/.bashrc", "/dir/.bashrc", ""}, // dotfile, no extension
{".bashrc", ".bashrc", ""},
{"archive.", "archive", "."},
}
for _, tc := range tests {
stem, ext := splitExt(tc.in)
if stem != tc.stem || ext != tc.ext {
t.Errorf("splitExt(%q) = %q,%q; want %q,%q", tc.in, stem, ext, tc.stem, tc.ext)
}
}
}
func TestUniqueName(t *testing.T) {
dir := t.TempDir()
// foo.txt taken -> foo.1.txt (.N inserted before the extension, item [13]).
taken := filepath.Join(dir, "foo.txt")
if err := os.WriteFile(taken, nil, 0o644); err != nil {
t.Fatal(err)
}
got, err := uniqueName(taken)
if err != nil {
t.Fatalf("uniqueName: %v", err)
}
if want := filepath.Join(dir, "foo.1.txt"); got != want {
t.Errorf("uniqueName(%q) = %q, want %q", taken, got, want)
}
// No-extension base -> base.1.
plain := filepath.Join(dir, "data")
if err := os.WriteFile(plain, nil, 0o644); err != nil {
t.Fatal(err)
}
got, err = uniqueName(plain)
if err != nil {
t.Fatalf("uniqueName: %v", err)
}
if want := filepath.Join(dir, "data.1"); got != want {
t.Errorf("uniqueName(%q) = %q, want %q", plain, got, want)
}
// foo.txt and foo.1.txt both taken -> foo.2.txt.
if err := os.WriteFile(filepath.Join(dir, "foo.1.txt"), nil, 0o644); err != nil {
t.Fatal(err)
}
got, err = uniqueName(taken)
if err != nil {
t.Fatalf("uniqueName: %v", err)
}
if want := filepath.Join(dir, "foo.2.txt"); got != want {
t.Errorf("uniqueName second pass = %q, want %q", got, want)
}
// A candidate that exists but has a .got control file is acceptable (it is a
// resumable partial).
ctrlBase := filepath.Join(dir, "r.bin")
cand1 := filepath.Join(dir, "r.1.bin")
if err := os.WriteFile(ctrlBase, nil, 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(cand1, nil, 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(controlPath(cand1), nil, 0o644); err != nil {
t.Fatal(err)
}
got, err = uniqueName(ctrlBase)
if err != nil {
t.Fatalf("uniqueName: %v", err)
}
if got != cand1 {
t.Errorf("uniqueName with control sidecar = %q, want %q", got, cand1)
}
}
func TestNameFromContentDisposition(t *testing.T) {
tests := []struct {
cd, want string
}{
{`attachment; filename="report.pdf"`, "report.pdf"},
{`attachment; filename=report.pdf`, "report.pdf"},
{`inline; filename*=UTF-8''na%C3%AFve.txt`, "naïve.txt"},
{`attachment; filename="../../etc/passwd"`, "passwd"}, // path stripped
{`attachment`, ""},
{`garbage`, ""},
}
for _, tc := range tests {
if got := nameFromContentDisposition(tc.cd); got != tc.want {
t.Errorf("nameFromContentDisposition(%q) = %q, want %q", tc.cd, got, tc.want)
}
}
}
func TestSeedSegmentsFromPrefix(t *testing.T) {
segs := makeSegments(1000, 100, 5) // five 200-byte segments
seedSegmentsFromPrefix(segs, 450) // 2 full segments + 50 bytes of the third
wants := []int64{200, 200, 50, 0, 0}
for i := range segs {
if segs[i].written != wants[i] {
t.Errorf("segment %d written = %d, want %d", i, segs[i].written, wants[i])
}
}
}
func TestParseContentRange(t *testing.T) {
start, end, total, ok := parseContentRange("bytes 0-0/12345")
if !ok || start != 0 || end != 0 || total != 12345 {
t.Errorf("parseContentRange = %d,%d,%d,%v", start, end, total, ok)
}
if _, _, _, ok := parseContentRange("garbage"); ok {
t.Errorf("parseContentRange(garbage) ok = true, want false")
}
}

88
httpdl/single_test.go Normal file
View File

@@ -0,0 +1,88 @@
package httpdl
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/hanbok/got/download"
)
// TestSingleShortBody verifies that a single-stream transfer whose body ends
// before the declared Content-Length is reported as an error (a retryable short
// read) rather than a false success (item [1]). A proxy that closes early must
// not leave the download marked Complete.
func TestSingleShortBody(t *testing.T) {
const declared = 1000
// Hijack the connection so we can declare a long Content-Length but write
// only a few bytes and then close — exactly the early-close a re-chunking
// proxy produces. httptest's normal writer would otherwise flag the mismatch
// on its own terms; hijacking gives us full control of the wire bytes.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hj, ok := w.(http.Hijacker)
if !ok {
t.Errorf("server does not support hijacking")
return
}
conn, _, err := hj.Hijack()
if err != nil {
t.Errorf("hijack: %v", err)
return
}
defer conn.Close()
fmt.Fprintf(conn, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\n\r\n", declared)
conn.Write([]byte("short")) // only 5 of the promised 1000 bytes
// closing the connection now is the early EOF
}))
defer srv.Close()
dir := t.TempDir()
out := filepath.Join(dir, "file.bin")
d := New([]string{srv.URL + "/file.bin"}, Config{
Dir: dir,
Out: "file.bin",
MaxConnPerServer: 1,
Tries: 1, // a single attempt so the retryable short read surfaces, not loops
UserAgent: "got-test",
})
err := d.single(context.Background(), out)
if err == nil {
t.Fatal("single() returned nil for a body shorter than Content-Length; want an error")
}
if d.Stat().Status == download.Complete {
t.Fatalf("status = Complete after a short body; want not Complete")
}
}
// TestSingleCompleteFullBody is the positive control: a body that matches its
// Content-Length succeeds. This guards against the short-body check rejecting a
// legitimately complete stream.
func TestSingleCompleteFullBody(t *testing.T) {
const body = "the whole thing"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", fmt.Sprint(len(body)))
w.WriteHeader(http.StatusOK)
w.Write([]byte(body))
}))
defer srv.Close()
dir := t.TempDir()
out := filepath.Join(dir, "file.bin")
d := New([]string{srv.URL + "/file.bin"}, Config{
Dir: dir,
Out: "file.bin",
MaxConnPerServer: 1,
Tries: 1,
UserAgent: "got-test",
})
if err := d.single(context.Background(), out); err != nil {
t.Fatalf("single() of a full body returned %v; want nil", err)
}
}

32
httpdl/speed_test.go Normal file
View File

@@ -0,0 +1,32 @@
package httpdl
import "testing"
// TestBelowSpeed covers the speedGuard decision, including the counter-reset
// case (item [1]): d.completed can jump backward when singleOnce rewrites it
// with an absolute StoreInt64 and a resumed Range is answered by a 200 that
// resets the offset to 0. A backward jump must NOT be read as a slow window, or
// a healthy download would be false-aborted.
func TestBelowSpeed(t *testing.T) {
const limit = 1000
cases := []struct {
name string
prev, now int64
want bool
}{
{"fast", 0, 5000, false},
{"exactly at limit aborts", 1000, 2000, true},
{"just under limit aborts", 1000, 1999, true},
{"just over limit ok", 1000, 2001, false},
{"stalled aborts", 4242, 4242, true},
{"counter reset is not slow", 1_000_000, 0, false},
{"small backward jump is not slow", 5000, 4500, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := belowSpeed(c.prev, c.now, limit); got != c.want {
t.Errorf("belowSpeed(%d, %d, %d) = %v; want %v", c.prev, c.now, limit, got, c.want)
}
})
}
}

826
main.go Normal file
View File

@@ -0,0 +1,826 @@
// Command got is a Rob-Pike-style command-line download tool: a small
// multi-protocol downloader. It downloads HTTP(S) URLs over several connections
// and BitTorrent magnets/torrents, with a comprehensive set of command-line
// options. Each download runs as one goroutine; a scheduler bounds how many run
// at once; a single goroutine draws the progress line.
package main
import (
"bufio"
"context"
"errors"
"fmt"
"net"
"os"
"os/signal"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/anacrolix/torrent"
"github.com/anacrolix/torrent/metainfo"
"github.com/hanbok/got/bt"
"github.com/hanbok/got/cli"
"github.com/hanbok/got/download"
"github.com/hanbok/got/httpdl"
"github.com/hanbok/got/progress"
"golang.org/x/time/rate"
)
func main() {
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
res, err := cli.Parse(args)
if err != nil {
fmt.Fprintln(os.Stderr, "got:", err)
return 1
}
switch res.Action {
case cli.ShowVersion:
cli.PrintVersion(os.Stdout)
return 0
case cli.ShowHelp:
cli.PrintHelp(os.Stdout, res.HelpTag)
return 0
}
opts := res.Options
// --checksum is validated up front so a bad type or digest aborts before any
// download, rejecting it at option-parse time rather than failing mid-run
// (and once, not once per HTTP download that shares the spec).
if cs := opts.Str("checksum"); cs != "" {
if err := httpdl.ValidChecksum(cs); err != nil {
fmt.Fprintln(os.Stderr, "got:", err)
return 1
}
}
targets := gatherURIs(opts, res.URIs)
if len(targets) == 0 {
fmt.Fprintln(os.Stderr, "got: no URI, torrent or magnet given (try --help)")
return 1
}
if opts.Bool("show-files") {
return showFiles(allURIs(targets))
}
jobs, btClient, err := build(opts, targets)
if err != nil {
fmt.Fprintln(os.Stderr, "got:", err)
return 1
}
if btClient != nil {
defer btClient.Close()
}
// A single registered signal channel handles both presses: the first cancels
// the graceful context, the second saves the session best-effort and forces
// exit. Using one channel avoids a startup race where two separate handlers
// could split the two presses and need a third to actually quit.
sessionFile := opts.Str("save-session")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// --stop: cancel everything after N seconds, exactly like a Ctrl-C, so an
// unattended run can't hang forever (0 = off).
if stop := opts.Int("stop"); stop > 0 {
t := time.AfterFunc(time.Duration(stop)*time.Second, cancel)
defer t.Stop()
}
jobsRef := &jobsHolder{}
go handleSignals(cancel, sessionFile, jobsRef)
eng := download.NewEngine(opts.Int("max-concurrent-downloads"))
uiCtx, uiCancel := context.WithCancel(context.Background())
if !opts.Bool("quiet") && !opts.Bool("dry-run") {
rep := progress.New(eng.Snapshot, opts.Bool("human-readable"))
go rep.Run(uiCtx)
}
jobsRef.set(jobs)
results := runJobs(ctx, eng, jobs)
// --follow-torrent: a .torrent fetched over HTTP becomes a BitTorrent
// download of its content (on by default). Done as a second pass so the
// engine and scheduler stay simple.
if opts.Bool("follow-torrent") && ctx.Err() == nil && !opts.Bool("dry-run") {
if follow := followUps(jobs, btClient, btOptions(opts)); len(follow) > 0 {
// The engine numbers each Result by its position in the slice it was
// passed, so the follow pass restarts at Index 0. Offset those by the
// initial job count to give the concatenated slice one contiguous
// Index space, so the sort below restores input order deterministically.
nInitial := len(jobs)
jobs = append(jobs, follow...)
jobsRef.set(jobs)
followResults := runJobs(ctx, eng, follow)
for i := range followResults {
followResults[i].Index += nInitial
}
results = append(results, followResults...)
}
}
uiCancel()
time.Sleep(20 * time.Millisecond) // let the renderer clear its line
saveCookies(jobs)
if sessionFile != "" {
if err := saveSession(sessionFile, jobs); err != nil {
fmt.Fprintln(os.Stderr, "got:", err)
}
}
// Restore input order so the OK/FAIL lines and the exit code are
// deterministic regardless of which download finished first.
sort.Slice(results, func(i, j int) bool { return results[i].Index < results[j].Index })
if opts.Bool("dry-run") {
return reportDryRun(results)
}
return report(results)
}
// jobsHolder lets the signal handler reach the latest job list for a
// best-effort session save on a forced (second-Ctrl-C) exit.
type jobsHolder struct {
mu sync.Mutex
jobs []job
}
func (h *jobsHolder) set(jobs []job) {
h.mu.Lock()
h.jobs = jobs
h.mu.Unlock()
}
func (h *jobsHolder) get() []job {
h.mu.Lock()
defer h.mu.Unlock()
return h.jobs
}
// job pairs a download with the source string that produced it, so we can write
// the still-unfinished ones back to a session file.
type job struct {
source string
dl download.Download
}
// build turns each URI into a Download, classified by scheme, and creates the
// one shared BitTorrent client the run needs (nil if no torrents are involved).
// The overall speed limits are one shared limiter each, applied to both HTTP
// downloads and the BitTorrent client so the cap is truly global.
// errDuplicate marks a source that names the same torrent as an earlier one.
// exitCode maps it to the duplicate-infohash exit code (12) via errors.Is.
var errDuplicate = errors.New("duplicate download")
// failedDownload is a Download that does no work and fails immediately with a
// fixed error. build() uses it for a duplicate torrent source, so the duplicate
// is reported as a failed download without ever obtaining — or Dropping — a
// torrent on the shared client.
type failedDownload struct {
name string
err error
}
func (f failedDownload) Name() string { return f.name }
func (f failedDownload) Run(context.Context) error { return f.err }
func (f failedDownload) Stat() download.Stat {
return download.Stat{Name: f.name, Status: download.Errored, Total: -1}
}
// dedupeTorrents finds torrent/magnet sources that name the same torrent as an
// earlier source (same infohash), returning each duplicate URI mapped to the
// earlier source it repeats. HTTP(S) URLs and any source whose infohash can't be
// read without a network fetch are never duplicates here. The shared client keys
// torrents by infohash and does not refcount, so build() turns each duplicate
// into a failed download (duplicate infohash) rather than letting two jobs
// share — and Drop — one torrent.
func dedupeTorrents(uris []string, follow bool) map[string]string {
seen := map[metainfo.Hash]string{}
dup := map[string]string{}
for _, u := range uris {
k := uriKind(u, follow)
if k != kindMagnet && k != kindTorrent {
continue
}
h, ok := bt.SourceInfoHash(u, k == kindTorrent)
if !ok {
continue
}
if first, isDup := seen[h]; isDup {
dup[u] = first
} else {
seen[h] = u
}
}
return dup
}
func build(opts *cli.Options, targets []target) ([]job, *torrent.Client, error) {
flat := allURIs(targets)
// Two sources naming the same torrent (a magnet and its own .torrent, say)
// would share one torrent in the client, which does not refcount: the first to
// finish would Drop it out from under the other. The duplicate is reported as
// a failed download (duplicate infohash, exit 12); the loop below does this,
// so the duplicate never obtains or Drops a shared torrent.
dups := dedupeTorrents(flat, opts.Bool("follow-torrent"))
overallDL := makeLimiter(opts.Size("max-overall-download-limit"))
overallUL := makeLimiter(opts.Size("max-overall-upload-limit"))
// --out names a single output file, so it only applies when exactly one HTTP
// download (one mirror group) is queued; warn rather than silently misname.
nhttp := countHTTP(targets)
single := nhttp == 1
if nhttp > 1 && opts.Str("out") != "" {
fmt.Fprintln(os.Stderr, "got: --out ignored: it applies only to a single HTTP download")
}
hc := httpConfig(opts, single, overallDL)
bo := btOptions(opts)
// One client serves every torrent, shared across the session. A failure to start
// it (e.g. the listen port is taken) disables BitTorrent but still lets HTTP
// downloads run; those torrents then report the error.
var client *torrent.Client
if needsBT(opts, flat) {
c, err := bt.NewClient(btClientConfig(opts, overallDL, overallUL))
if err != nil {
fmt.Fprintln(os.Stderr, "got: bittorrent disabled:", err)
} else {
client = c
}
}
var out []job
for _, t := range targets {
u := t.uris[0] // primary: classifies the target and names the session source
src := strings.Join(t.uris, "\t")
if first, isDup := dups[u]; isDup {
out = append(out, job{source: src, dl: failedDownload{
name: u,
err: fmt.Errorf("%w: same torrent as %s", errDuplicate, first),
}})
continue
}
var dl download.Download
switch uriKind(u, opts.Bool("follow-torrent")) {
case kindMagnet:
dl = bt.New(client, u, false, bo)
case kindHTTP, kindFollow:
dl = httpdl.New(t.uris, hc) // every mirror URL for this one file
case kindTorrent:
dl = bt.New(client, u, true, bo)
default:
if client != nil {
client.Close()
}
return nil, nil, fmt.Errorf("unsupported URI: %s", u)
}
out = append(out, job{source: src, dl: dl})
}
return out, client, nil
}
// uriKind classifies a URI by scheme into the taxonomy build() and needsBT()
// both switch on. Scheme wins over suffix: an http URL ending in .torrent is a
// file to fetch over HTTP, and with --follow-torrent it becomes a torrent only
// after the fetch (kindFollow), so it still downloads over HTTP first.
func uriKind(u string, follow bool) kind {
lu := strings.ToLower(u)
switch {
case strings.HasPrefix(u, "magnet:"):
return kindMagnet
case strings.HasPrefix(u, "http://"), strings.HasPrefix(u, "https://"):
if follow && strings.HasSuffix(lu, ".torrent") {
return kindFollow
}
return kindHTTP
case strings.HasSuffix(lu, ".torrent"):
return kindTorrent
}
return kindUnsupported
}
type kind int
const (
kindUnsupported kind = iota
kindMagnet // magnet: link, a BitTorrent download
kindHTTP // plain HTTP(S) download
kindTorrent // local .torrent file, a BitTorrent download
kindFollow // HTTP(S) .torrent fetched, then followed as BitTorrent
)
// needsBT reports whether the run will involve any torrent, so we only start a
// client when one is actually needed.
func needsBT(opts *cli.Options, uris []string) bool {
follow := opts.Bool("follow-torrent")
for _, u := range uris {
switch uriKind(u, follow) {
case kindMagnet, kindTorrent, kindFollow:
return true
}
}
return false
}
// countHTTP counts HTTP download targets. A mirror group is one output file, so
// it counts groups, not URLs, for the --out single-file decision.
func countHTTP(targets []target) int {
n := 0
for _, t := range targets {
u := t.uris[0]
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") {
n++
}
}
return n
}
// runJobs runs a set of jobs through the engine and returns their results.
func runJobs(ctx context.Context, eng *download.Engine, jobs []job) []download.Result {
dls := make([]download.Download, len(jobs))
for i := range jobs {
dls[i] = jobs[i].dl
}
return eng.Run(ctx, dls)
}
// followUps turns each completed HTTP .torrent download into a BitTorrent
// download of its content. A file is followed only if it parses as a torrent,
// so a regular download that merely ends in ".torrent" is left alone.
func followUps(jobs []job, client *torrent.Client, bo bt.Options) []job {
var out []job
for _, j := range jobs {
h, ok := j.dl.(*httpdl.Download)
if !ok || j.dl.Stat().Status != download.Complete {
continue
}
// j.source is the TAB-joined mirror group; the primary (uris[0]) is what
// build() classified and named the download on, so test its suffix.
primary, _, _ := strings.Cut(j.source, "\t")
if !strings.HasSuffix(strings.ToLower(primary), ".torrent") {
continue
}
path := h.Path()
if _, err := bt.Files(path); err != nil {
continue // downloaded file is not a valid torrent
}
out = append(out, job{source: path, dl: bt.New(client, path, true, bo)})
}
return out
}
// saveCookies asks each HTTP download to write its cookie jar back to the
// --save-cookies file, persisting cookies at session end. A download with no
// --save-cookies configured is a no-op.
func saveCookies(jobs []job) {
for _, j := range jobs {
if h, ok := j.dl.(*httpdl.Download); ok {
if err := h.SaveCookies(); err != nil {
fmt.Fprintln(os.Stderr, "got:", err)
}
}
}
}
// target is one download: a single torrent/magnet/URL, or several HTTP mirror
// URLs that serve identical bytes (uris[0] is the primary).
type target struct{ uris []string }
// allURIs flattens targets to a plain URL list for the per-URL helpers
// (dedupe, needsBT, show-files) that don't care about grouping.
func allURIs(ts []target) []string {
var u []string
for _, t := range ts {
u = append(u, t.uris...)
}
return u
}
// gatherURIs collects download targets from the command line, --torrent-file,
// and --input-file. Plain command-line http/https URIs are grouped into ONE
// mirrored download by default (every command-line URI names the same file);
// -Z/--force-sequential makes each its own download. Torrents and magnets
// are always one target each; --input-file groups TAB-separated URIs per line.
func gatherURIs(opts *cli.Options, cmdURIs []string) []target {
var ts []target
follow := opts.Bool("follow-torrent")
seq := opts.Bool("force-sequential")
var httpGroup []string
for _, u := range cmdURIs {
switch uriKind(u, follow) {
case kindHTTP, kindFollow:
if seq {
ts = append(ts, target{uris: []string{u}})
} else {
httpGroup = append(httpGroup, u)
}
default:
ts = append(ts, target{uris: []string{u}})
}
}
if len(httpGroup) > 0 {
ts = append(ts, target{uris: httpGroup})
}
if tf := opts.Str("torrent-file"); tf != "" {
ts = append(ts, target{uris: []string{tf}})
}
if in := opts.Str("input-file"); in != "" {
ts = append(ts, readInputFile(in)...)
}
return ts
}
// readInputFile parses an input file: one download per line, and TAB-separated
// URIs on a line are mirrors of that one download. Blank lines and '#' comments
// are skipped.
func readInputFile(path string) []target {
f := os.Stdin
if path != "-" {
var err error
if f, err = os.Open(path); err != nil {
// A missing input file is fine: it just means "nothing to resume
// yet", which is what the load+save-session idiom needs on first run.
if !os.IsNotExist(err) {
fmt.Fprintln(os.Stderr, "got:", err)
}
return nil
}
defer f.Close()
}
var ts []target
sc := bufio.NewScanner(f)
// Magnet links with many trackers (and long signed URLs) can exceed the
// 64KB default token size, so raise the cap.
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
line := sc.Text()
if t := strings.TrimSpace(line); t == "" || strings.HasPrefix(t, "#") {
continue
}
var g []string
for _, field := range strings.Split(line, "\t") {
if field = strings.TrimSpace(field); field != "" {
g = append(g, field)
}
}
if len(g) > 0 {
ts = append(ts, target{uris: g})
}
}
if err := sc.Err(); err != nil {
fmt.Fprintln(os.Stderr, "got:", err)
}
return ts
}
func httpConfig(opts *cli.Options, single bool, overallDL *rate.Limiter) httpdl.Config {
out := ""
if single {
out = opts.Str("out")
}
return httpdl.Config{
Dir: opts.Str("dir"),
Out: out,
Split: opts.Int("split"),
MaxConnPerServer: opts.Int("max-connection-per-server"),
MinSplit: opts.Size("min-split-size"),
Tries: opts.Int("max-tries"),
Timeout: time.Duration(opts.Int("timeout")) * time.Second,
FileAlloc: opts.Str("file-allocation"),
Continue: opts.Bool("continue"),
AllowOverwrite: opts.Bool("allow-overwrite"),
AutoRename: opts.Bool("auto-file-renaming"),
Headers: opts.List("header"),
UserAgent: opts.Str("user-agent"),
Referer: opts.Str("referer"),
Proxy: opts.Str("all-proxy"),
CheckCert: opts.Bool("check-certificate"),
Limit: opts.Size("max-download-limit"),
OverallLimiter: overallDL,
RetryWait: time.Duration(opts.Int("retry-wait")) * time.Second,
AutoSaveInterval: time.Duration(opts.Int("auto-save-interval")) * time.Second,
ConnectTimeout: time.Duration(opts.Int("connect-timeout")) * time.Second,
LoadCookies: opts.Str("load-cookies"),
SaveCookies: opts.Str("save-cookies"),
ConditionalGet: opts.Bool("conditional-get"),
RemoteTime: opts.Bool("remote-time"),
HTTPUser: opts.Str("http-user"),
HTTPPasswd: opts.Str("http-passwd"),
LowestSpeedLimit: opts.Size("lowest-speed-limit"),
Checksum: opts.Str("checksum"),
DryRun: opts.Bool("dry-run"),
CACert: opts.Str("ca-certificate"),
DisableIPv6: opts.Bool("disable-ipv6"),
}
}
// btClientConfig is the run-wide configuration for the one shared torrent client.
func btClientConfig(opts *cli.Options, overallDL, overallUL *rate.Limiter) bt.ClientConfig {
return bt.ClientConfig{
Dir: opts.Str("dir"),
ListenPortSpec: opts.Str("listen-port"),
DHT: opts.Bool("enable-dht"),
MaxPeers: opts.Int("bt-max-peers"),
OverallDown: overallDL,
OverallUp: overallUL,
DownLimit: opts.Size("max-download-limit"),
UpLimit: opts.Size("max-upload-limit"),
UserAgent: opts.Str("user-agent"),
DisableIPv6: opts.Bool("disable-ipv6"),
}
}
// btOptions is the per-torrent configuration.
func btOptions(opts *cli.Options) bt.Options {
return bt.Options{
SeedTimeSet: opts.IsSet("seed-time"),
SeedTime: time.Duration(opts.Float("seed-time") * float64(time.Minute)),
SeedRatio: opts.Float("seed-ratio"),
StopTimeout: time.Duration(opts.Int("bt-stop-timeout")) * time.Second,
MetaTimeout: time.Duration(opts.Int("bt-metadata-timeout")) * time.Second,
SelectFiles: parseSelect(opts.Str("select-file")),
CheckIntegrity: opts.Bool("check-integrity"),
DryRun: opts.Bool("dry-run"),
}
}
// showFiles prints the file list of each torrent argument and returns an exit
// code. Magnets need their metadata fetched first, which we don't do here.
func showFiles(uris []string) int {
printed := false
for _, u := range uris {
if !strings.HasSuffix(strings.ToLower(u), ".torrent") {
if strings.HasPrefix(u, "magnet:") {
fmt.Printf("%s: --show-files needs a .torrent (magnet metadata is fetched at download time)\n", u)
}
continue
}
files, err := bt.Files(u)
if err != nil {
fmt.Fprintln(os.Stderr, "got:", err)
return 1
}
fmt.Printf("%s:\n", u)
for _, f := range files {
fmt.Printf(" %d %s (%d bytes)\n", f.Index, f.Path, f.Length)
}
printed = true
}
if !printed {
return 1
}
return 0
}
// reportDryRun prints what --dry-run learned about each source — that it is
// reachable and how big it is — none of it having been downloaded, and returns
// the exit code: 0 if every source was reachable, else the dominant error's
// code (so a 404 under --dry-run still exits 3, a refused host 6, etc.).
func reportDryRun(results []download.Result) int {
var lastReal error
for _, r := range results {
switch {
case r.Err == nil:
if r.Stat.Total >= 0 {
fmt.Printf("DRY %s (%d bytes)\n", r.Name, r.Stat.Total)
} else {
fmt.Printf("DRY %s (size unknown)\n", r.Name)
}
case errors.Is(r.Err, context.Canceled):
fmt.Printf("STOP %s\n", r.Name)
default:
fmt.Printf("FAIL %s: %v\n", r.Name, r.Err)
lastReal = r.Err
}
}
if lastReal != nil {
return exitCode(lastReal)
}
return 0
}
// report prints a one-line outcome per download and returns the process exit
// code, mapped to the documented exit-status table:
// 0 all OK, 7 cancelled with at least one completed, 1 cancelled with nothing
// completed, else the code of the dominant (last) real error, mapped to an
// exit code by exitCode.
func report(results []download.Result) int {
completed, cancelled := 0, 0
var lastReal error
for _, r := range results {
switch {
case r.Err == nil:
fmt.Printf("OK %s\n", r.Name)
completed++
case errors.Is(r.Err, context.Canceled):
fmt.Printf("STOP %s (interrupted%s)\n", r.Name, progressNote(r.Stat))
cancelled++
default:
fmt.Printf("FAIL %s: %v\n", r.Name, r.Err)
lastReal = r.Err
}
}
// Exit code 7 is returned when the only failures were interruptions and at
// least one download finished: unfinished downloads remained in the queue when
// the process exited by pressing Ctrl-C. With no real error and nothing
// complete, fall back to 1.
if lastReal == nil {
switch {
case cancelled > 0 && completed > 0:
return 7
case cancelled > 0:
return 1
default:
return 0
}
}
return exitCode(lastReal)
}
// exitCode maps a real (non-cancellation) download error to the documented
// exit-status codes. The retry path wraps the underlying cause with %w, so we can inspect the
// chain: an existing output file (13) and a 404 (3) are classified by their
// httpdl sentinel type; name resolution (19), timeout (2), and a connection-level
// network problem (6) are classified by typed errors with a substring fallback
// for third-party net text; everything else is the generic unknown error (1).
// Telling 19 from 6 from 2 apart is the point — it says whether the user's own
// network, the remote host, or a stall is to blame.
func exitCode(err error) int {
switch {
case errors.Is(err, httpdl.ErrOutputExists):
return 13
case isNameResolution(err):
return 19
case isTimeout(err):
return 2
case isNetwork(err):
return 6
case errors.Is(err, httpdl.ErrNotFound):
return 3
case errors.Is(err, httpdl.ErrTooSlow):
return 5
case errors.Is(err, httpdl.ErrChecksum):
return 32
case errors.Is(err, errDuplicate):
return 12
}
return 1
}
// isNameResolution reports whether err is (or wraps) a DNS lookup failure —
// "name resolution failed" (19), which usually points at the user's own
// network or resolver rather than a dead remote.
func isNameResolution(err error) bool {
var de *net.DNSError
if errors.As(err, &de) {
return true
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "no such host") || strings.Contains(msg, "server misbehaving")
}
// isNetwork reports whether err is a connection-level failure — refused, reset,
// or an unreachable host/network — a "network problem" (6).
func isNetwork(err error) bool {
if errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.ECONNRESET) ||
errors.Is(err, syscall.ENETUNREACH) || errors.Is(err, syscall.EHOSTUNREACH) {
return true
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "connection refused") ||
strings.Contains(msg, "connection reset") ||
strings.Contains(msg, "network is unreachable") ||
strings.Contains(msg, "no route to host")
}
// isTimeout reports whether err is (or wraps) a timeout: a deadline exceeded, a
// net.Error that timed out, or an idle-read timeout reported as such.
func isTimeout(err error) bool {
if errors.Is(err, context.DeadlineExceeded) {
return true
}
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
return true
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "timeout") || strings.Contains(msg, "timed out")
}
// saveSession writes the sources of every download that did not finish to path,
// one per line, so a later `got -i path` resumes them (HTTP via -c, BitTorrent
// via anacrolix's recheck of existing data). An all-done run leaves the file
// empty.
func saveSession(path string, jobs []job) error {
lines := unfinishedSources(jobs)
data := ""
if len(lines) > 0 {
data = strings.Join(lines, "\n") + "\n"
}
// Write to a temp file in the same directory, then rename over the target so
// a crash or a force-quit mid-write can never leave a half-written (and thus
// truncated) resume list. The rename is atomic within one filesystem.
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".got-session-*")
if err != nil {
return err
}
tmpName := tmp.Name()
if _, err := tmp.WriteString(data); err != nil {
tmp.Close()
os.Remove(tmpName)
return err
}
if err := tmp.Close(); err != nil {
os.Remove(tmpName)
return err
}
if err := os.Rename(tmpName, path); err != nil {
os.Remove(tmpName)
return err
}
return nil
}
// unfinishedSources returns the source string of each download not in the
// Complete state.
func unfinishedSources(jobs []job) []string {
var out []string
for _, j := range jobs {
if j.dl.Stat().Status != download.Complete {
out = append(out, j.source)
}
}
return out
}
// progressNote describes how far an interrupted download got, so the user knows
// resuming is worthwhile.
func progressNote(s download.Stat) string {
if s.Total > 0 && s.Completed > 0 {
return fmt.Sprintf("; %d%% done", int(s.Completed*100/s.Total))
}
return ""
}
// handleSignals owns the one registered signal channel. The first Ctrl-C (or
// TERM) cancels the graceful context so run() can wind down and save normally;
// a second forces exit, but first writes the session best-effort so the resume
// list survives a force-quit.
func handleSignals(cancel context.CancelFunc, sessionFile string, jobs *jobsHolder) {
sig := make(chan os.Signal, 2)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
<-sig // first: cancel the graceful context
cancel()
<-sig // second: force exit
if sessionFile != "" {
if err := saveSession(sessionFile, jobs.get()); err != nil {
fmt.Fprintln(os.Stderr, "got:", err)
}
}
fmt.Fprintln(os.Stderr, "\ngot: forced exit")
os.Exit(130)
}
func makeLimiter(bps int64) *rate.Limiter {
if bps <= 0 {
return nil
}
return rate.NewLimiter(rate.Limit(bps), download.LimiterBurst(bps))
}
// parseSelect parses "1,3-5" into a set of 1-based file indexes.
func parseSelect(s string) map[int]bool {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
out := map[int]bool{}
for _, part := range strings.Split(s, ",") {
lo, hi, ok := strings.Cut(part, "-")
a, _ := strconv.Atoi(strings.TrimSpace(lo))
if !ok {
if a > 0 {
out[a] = true
}
continue
}
b, _ := strconv.Atoi(strings.TrimSpace(hi))
for i := a; i <= b; i++ {
if i > 0 {
out[i] = true
}
}
}
return out
}

199
main_test.go Normal file
View File

@@ -0,0 +1,199 @@
package main
import (
"context"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"reflect"
"syscall"
"testing"
"github.com/hanbok/got/cli"
"github.com/hanbok/got/download"
"github.com/hanbok/got/httpdl"
)
// fakeDL is a stand-in Download whose status we control.
type fakeDL struct {
name string
status download.Status
}
func (f fakeDL) Name() string { return f.name }
func (f fakeDL) Run(ctx context.Context) error { return nil }
func (f fakeDL) Stat() download.Stat { return download.Stat{Name: f.name, Status: f.status} }
func TestUnfinishedSources(t *testing.T) {
jobs := []job{
{source: "http://a/done", dl: fakeDL{status: download.Complete}},
{source: "http://b/failed", dl: fakeDL{status: download.Errored}},
{source: "magnet:c", dl: fakeDL{status: download.Active}},
{source: "http://d/done", dl: fakeDL{status: download.Complete}},
}
got := unfinishedSources(jobs)
want := []string{"http://b/failed", "magnet:c"}
if !reflect.DeepEqual(got, want) {
t.Errorf("unfinishedSources = %v, want %v", got, want)
}
}
func TestParseSelect(t *testing.T) {
tests := []struct {
in string
want map[int]bool
}{
{"", nil},
{"1", map[int]bool{1: true}},
{"1,3", map[int]bool{1: true, 3: true}},
{"3-5", map[int]bool{3: true, 4: true, 5: true}},
{"1,3-5,8", map[int]bool{1: true, 3: true, 4: true, 5: true, 8: true}},
}
for _, tc := range tests {
if got := parseSelect(tc.in); !reflect.DeepEqual(got, tc.want) {
t.Errorf("parseSelect(%q) = %v, want %v", tc.in, got, tc.want)
}
}
}
func TestExitCode(t *testing.T) {
dnsErr := &net.DNSError{Err: "no such host", Name: "nope.invalid", IsNotFound: true}
refused := &net.OpError{Op: "dial", Net: "tcp", Err: os.NewSyscallError("connect", syscall.ECONNREFUSED)}
tests := []struct {
name string
err error
want int
}{
{"idle timeout", errors.New("idle timeout: no data received"), 2},
{"metadata timed out", errors.New("timed out fetching metadata after 3s"), 2},
{"deadline", context.DeadlineExceeded, 2},
{"file exists", fmt.Errorf("/tmp/x: %w (use --allow-overwrite or -c)", httpdl.ErrOutputExists), 13},
{"dns typed", dnsErr, 19},
{"dns wrapped through retries", fmt.Errorf("http://x: %w (after 5 tries)", dnsErr), 19},
{"dns by message", errors.New("dial tcp: lookup nope.invalid: no such host"), 19},
{"refused typed", refused, 6},
{"refused wrapped through retries", fmt.Errorf("http://x: %w (after 5 tries)", refused), 6},
{"refused by message", errors.New("dial tcp 127.0.0.1:1: connect: connection refused"), 6},
{"not found", fmt.Errorf("%w: http://x/y: 404 Not Found", httpdl.ErrNotFound), 3},
{"too slow", fmt.Errorf("http://x: %w (<= %d bytes/sec)", httpdl.ErrTooSlow, 1024), 5},
{"checksum", fmt.Errorf("/tmp/x: %w (want a, got b)", httpdl.ErrChecksum), 32},
{"duplicate", fmt.Errorf("%w: same torrent as y", errDuplicate), 12},
{"unknown", errors.New("disk full"), 1},
}
for _, tc := range tests {
if got := exitCode(tc.err); got != tc.want {
t.Errorf("%s: exitCode = %d, want %d", tc.name, got, tc.want)
}
}
// The whole point: a local DNS failure and a dead remote must not look alike.
if dns, nw := exitCode(dnsErr), exitCode(refused); dns == nw {
t.Errorf("DNS (%d) and refused (%d) must map to different codes", dns, nw)
}
}
// TestDedupeTorrents guards the same-infohash hazard: the shared client does not
// refcount torrents, so two sources naming one torrent must collapse to a single
// download or the first to finish would Drop it out from under the other.
func TestDedupeTorrents(t *testing.T) {
const h = "c892b8de86be44003d92041a16787911c139ab1e"
a := "magnet:?xt=urn:btih:" + h + "&dn=one"
b := "magnet:?xt=urn:btih:" + h + "&dn=two" // same infohash as a
c := "magnet:?xt=urn:btih:1111111111111111111111111111111111111111&dn=other"
web := "http://example.com/file.iso"
dup := dedupeTorrents([]string{a, web, b, c}, true)
// b is the only duplicate (of a); a, web and c are not duplicates.
if len(dup) != 1 || dup[b] != a {
t.Errorf("dup = %v, want {b: a}", dup)
}
}
// TestGatherURIsGrouping checks the command-line grouping: plain http URIs
// become one mirrored download by default, -Z splits them, and torrents/magnets
// stay separate.
func TestGatherURIsGrouping(t *testing.T) {
parse := func(args ...string) []target {
res, err := cli.Parse(append([]string{"--no-conf"}, args...))
if err != nil {
t.Fatalf("parse %v: %v", args, err)
}
return gatherURIs(res.Options, res.URIs)
}
if ts := parse("http://a/x", "http://b/x"); len(ts) != 1 || len(ts[0].uris) != 2 {
t.Errorf("two http args: got %v, want one 2-mirror group", ts)
}
if ts := parse("-Z", "http://a/x", "http://b/x"); len(ts) != 2 {
t.Errorf("-Z: got %d targets, want 2 separate downloads", len(ts))
}
if ts := parse("http://a/x", "magnet:?xt=urn:btih:abc"); len(ts) != 2 {
t.Errorf("http + magnet: got %d targets, want 2 (magnet never mirrors)", len(ts))
}
}
// TestReadInputFileTAB checks the input-file grouping: TAB-separated URIs on
// a line are mirrors of one download; a plain line is one download.
func TestReadInputFileTAB(t *testing.T) {
p := filepath.Join(t.TempDir(), "in.txt")
if err := os.WriteFile(p, []byte("# comment\nhttp://a/x\thttp://b/x\nhttp://c/y\n\n"), 0o644); err != nil {
t.Fatal(err)
}
ts := readInputFile(p)
if len(ts) != 2 {
t.Fatalf("got %d targets, want 2: %v", len(ts), ts)
}
if want := []string{"http://a/x", "http://b/x"}; !reflect.DeepEqual(ts[0].uris, want) {
t.Errorf("line 1 mirrors = %v, want %v", ts[0].uris, want)
}
if want := []string{"http://c/y"}; !reflect.DeepEqual(ts[1].uris, want) {
t.Errorf("line 2 = %v, want %v", ts[1].uris, want)
}
}
func TestReportExit(t *testing.T) {
canceled := download.Result{Name: "a", Err: context.Canceled}
done := download.Result{Name: "b", Err: nil}
timeout := download.Result{Name: "c", Err: errors.New("idle timeout")}
tests := []struct {
name string
results []download.Result
want int
}{
{"all ok", []download.Result{done}, 0},
{"cancelled with a completion", []download.Result{done, canceled}, 7},
{"cancelled only", []download.Result{canceled}, 1},
{"real error wins over cancel", []download.Result{done, canceled, timeout}, 2},
}
for _, tc := range tests {
if got := report(tc.results); got != tc.want {
t.Errorf("%s: report = %d, want %d", tc.name, got, tc.want)
}
}
}
func TestSaveSessionAtomic(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.txt")
jobs := []job{
{source: "http://a/done", dl: fakeDL{status: download.Complete}},
{source: "magnet:c", dl: fakeDL{status: download.Active}},
}
if err := saveSession(path, jobs); err != nil {
t.Fatalf("saveSession: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read: %v", err)
}
if got, want := string(data), "magnet:c\n"; got != want {
t.Errorf("session = %q, want %q", got, want)
}
// No temp files should be left behind by an atomic write.
entries, _ := os.ReadDir(dir)
if len(entries) != 1 {
t.Errorf("expected only the session file, got %d entries", len(entries))
}
}

79
progress/format.go Normal file
View File

@@ -0,0 +1,79 @@
package progress
import (
"fmt"
"time"
)
// humanSize formats a byte count by abbreviating it: divide by
// 1024 while the value is at least one unit and a larger unit remains, then bump
// to the next unit when the quotient is >=922 (so 0.9Mi shows instead of 922Ki).
// The unit ladder is capped at Gi, so a terabyte prints
// as "1024.0GiB". When human is false it prints the raw integer with a B suffix.
func humanSize(n int64, human bool) string {
if !human {
return fmt.Sprintf("%dB", n)
}
const unit = 1024
if n < unit {
return fmt.Sprintf("%dB", n)
}
// "KMG" caps the ladder at Gi (the units {"","Ki","Mi","Gi"}).
const suffixes = "KMG"
div, exp := int64(unit), 0
for v := n / unit; v >= unit && exp+1 < len(suffixes); v /= unit {
div *= unit
exp++
}
// Bump to the next unit when the quotient is >=922 and a unit remains, so
// 922*1024 bytes reads as 0.9MiB rather than 922KiB.
if q := n / div; q >= 922 && exp+1 < len(suffixes) {
div *= unit
exp++
}
val := float64(n) / float64(div)
suffix := suffixes[exp]
// One decimal for small mantissas and for anything that overflowed the
// capped Gi unit (1024.0GiB and up); a bare integer otherwise (e.g. 20MiB).
if val < 10 || exp == len(suffixes)-1 && val >= unit {
return fmt.Sprintf("%.1f%ciB", val, suffix)
}
return fmt.Sprintf("%.0f%ciB", val, suffix)
}
// speed formats a bytes-per-second rate. The DL:/UL: fields show the bare
// abbreviated size with no "/s" suffix.
func speed(bytesPerSec int64, human bool) string {
return humanSize(bytesPerSec, human)
}
// secfmt renders a duration as "1h2m3s", appending each unit only when it is
// nonzero (but still showing seconds when the whole input is 0):
// 3600s->"1h", 120s->"2m", 3720s->"1h2m". A non-positive or absurd
// duration renders as "--".
func secfmt(d time.Duration) string {
s := int64(d.Seconds())
if s < 0 || d > 99*time.Hour {
return "--"
}
h, m, sec := s/3600, (s%3600)/60, s%60
var str string
if h > 0 {
str += fmt.Sprintf("%dh", h)
}
if m > 0 {
str += fmt.Sprintf("%dm", m)
}
if sec > 0 || s == 0 {
str += fmt.Sprintf("%ds", sec)
}
return str
}
// percent returns completed/total as a 0-100 integer, or 0 when total is unknown.
func percent(completed, total int64) int {
if total <= 0 {
return 0
}
return int(completed * 100 / total)
}

73
progress/format_test.go Normal file
View File

@@ -0,0 +1,73 @@
package progress
import (
"testing"
"time"
)
func TestHumanSize(t *testing.T) {
tests := []struct {
n int64
human bool
want string
}{
{500, true, "500B"},
{1536, true, "1.5KiB"},
{1 << 20, true, "1.0MiB"},
{5 * 1 << 20, true, "5.0MiB"},
{20 * 1 << 20, true, "20MiB"},
{1 << 30, true, "1.0GiB"},
{2048, false, "2048B"},
// abbrevSize bumps to the next unit once the quotient reaches 922, so
// 922*1024 bytes reads as 0.9MiB rather than 922KiB.
{922 * 1024, true, "0.9MiB"},
{921 * 1024, true, "921KiB"},
// The unit ladder is capped at Gi, so a terabyte overflows GiB.
{1 << 40, true, "1024.0GiB"},
}
for _, tc := range tests {
if got := humanSize(tc.n, tc.human); got != tc.want {
t.Errorf("humanSize(%d, %v) = %q, want %q", tc.n, tc.human, got, tc.want)
}
}
}
func TestSecfmt(t *testing.T) {
tests := []struct {
d time.Duration
want string
}{
{0, "0s"},
{30 * time.Second, "30s"},
{90 * time.Second, "1m30s"},
{120 * time.Second, "2m"},
{3600 * time.Second, "1h"},
{3661 * time.Second, "1h1m1s"},
{3720 * time.Second, "1h2m"},
{-1 * time.Second, "--"},
}
for _, tc := range tests {
if got := secfmt(tc.d); got != tc.want {
t.Errorf("secfmt(%v) = %q, want %q", tc.d, got, tc.want)
}
}
}
func TestSpeedNoSuffix(t *testing.T) {
// The DL:/UL: fields show the bare abbreviated size with no "/s".
if got := speed(1536, true); got != "1.5KiB" {
t.Errorf("speed(1536,true) = %q, want %q", got, "1.5KiB")
}
if got := speed(2048, false); got != "2048B" {
t.Errorf("speed(2048,false) = %q, want %q", got, "2048B")
}
}
func TestPercent(t *testing.T) {
if got := percent(50, 100); got != 50 {
t.Errorf("percent(50,100) = %d, want 50", got)
}
if got := percent(1, 0); got != 0 {
t.Errorf("percent(1,0) = %d, want 0", got)
}
}

274
progress/progress.go Normal file
View File

@@ -0,0 +1,274 @@
// Package progress renders a live, single-line status display. One goroutine ticks
// once a second, pulls a snapshot of the running downloads, and derives speeds
// from the change since the previous tick. There are no locks here: the data
// arrives by value through the snapshot function.
package progress
import (
"context"
"fmt"
"io"
"os"
"strings"
"time"
"unicode/utf8"
"github.com/hanbok/got/download"
"golang.org/x/term"
)
// Reporter draws the progress line until its context is cancelled.
type Reporter struct {
snap func() []download.Stat
human bool
interval time.Duration
w io.Writer
tty bool
width int
win map[string]*speedWindow // keyed by Stat.ID
lastLog time.Time // last non-TTY line emitted
}
// logEvery bounds how often progress is printed when output is not a TTY, so a
// redirected or piped run does not get a line every second.
const logEvery = 30 * time.Second
// windowTime is the span of the sliding speed window (10s): the rate is the
// byte delta across this window divided by its real duration, which smooths the
// per-tick jitter.
const windowTime = 10 * time.Second
// sample is one (time, cumulative-counters) observation in a speedWindow.
type sample struct {
t time.Time
completed, uploaded int64
}
// speedWindow keeps the last ~windowTime of samples for one download so we can
// derive down/up speed from the change across the window rather than the last
// tick alone. Samples are appended in time order, so stale ones live at the
// front.
type speedWindow struct {
samples []sample
}
// New returns a Reporter that reads live stats from snap.
func New(snap func() []download.Stat, human bool) *Reporter {
return &Reporter{
snap: snap,
human: human,
interval: time.Second,
w: os.Stdout,
tty: term.IsTerminal(int(os.Stdout.Fd())),
width: 80,
win: map[string]*speedWindow{},
}
}
// Run draws on every tick and clears the line when ctx is cancelled.
func (r *Reporter) Run(ctx context.Context) {
t := time.NewTicker(r.interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
r.render(true)
return
case <-t.C:
r.render(false)
}
}
}
func (r *Reporter) render(final bool) {
stats := r.snap()
now := time.Now()
// Record one sample per download before deriving rates, and forget windows
// for downloads that have dropped out of the snapshot.
seen := make(map[string]bool, len(stats))
for _, s := range stats {
seen[s.ID] = true
w := r.win[s.ID]
if w == nil {
w = &speedWindow{}
r.win[s.ID] = w
}
w.add(now, s.Completed, s.Uploaded)
}
for id := range r.win {
if !seen[id] {
delete(r.win, id)
}
}
var line string
switch len(stats) {
case 0:
if !final && r.tty {
return // nothing active; leave the line as-is
}
case 1:
line = r.lineOne(stats[0])
default:
line = r.lineMany(stats)
}
if r.tty {
r.width = termWidth(r.width)
line = clampRunes(line, r.width)
fmt.Fprint(r.w, "\r"+line+"\x1b[K")
if final {
fmt.Fprintln(r.w)
}
return
}
// Not a TTY: can't redraw one line, so print sparingly (and always the
// final line) to keep redirected output and logs readable.
if line != "" && (final || now.Sub(r.lastLog) >= logEvery) {
fmt.Fprintln(r.w, line)
r.lastLog = now
}
}
func (r *Reporter) lineOne(s download.Stat) string {
dl, ul := r.rates(s)
seeding := s.Status == download.Seeding
var b strings.Builder
fmt.Fprintf(&b, "[%s ", shortName(s.Name))
// While seeding, report the share ratio in place of the size block and drop
// the DL: field; otherwise show completed/total (or bare completed).
if seeding {
fmt.Fprintf(&b, "SEED(%.1f)", ratio(s.Uploaded, s.Completed))
} else if s.Total > 0 {
fmt.Fprintf(&b, "%s/%s(%d%%)", humanSize(s.Completed, r.human), humanSize(s.Total, r.human), percent(s.Completed, s.Total))
} else {
b.WriteString(humanSize(s.Completed, r.human))
}
// CN is always shown (including HTTP); SD is shown for any torrent.
fmt.Fprintf(&b, " CN:%d", s.Conns)
if s.IsBT {
fmt.Fprintf(&b, " SD:%d", s.Seeders)
}
if !seeding {
fmt.Fprintf(&b, " DL:%s", speed(dl, r.human))
}
if seeding || ul > 0 {
fmt.Fprintf(&b, " UL:%s", speed(ul, r.human))
}
if !seeding && s.Total > 0 && dl > 0 {
eta := time.Duration(float64(s.Total-s.Completed)/float64(dl)) * time.Second
fmt.Fprintf(&b, " ETA:%s", secfmt(eta))
}
b.WriteByte(']')
return b.String()
}
func (r *Reporter) lineMany(stats []download.Stat) string {
var totDL, totUL int64
for _, s := range stats {
dl, ul := r.rates(s)
totDL += dl
totUL += ul
}
var b strings.Builder
fmt.Fprintf(&b, "[%d active DL:%s UL:%s]", len(stats), speed(totDL, r.human), speed(totUL, r.human))
for i, s := range stats {
if i >= 4 {
fmt.Fprintf(&b, "[+%d]", len(stats)-i)
break
}
if s.Total > 0 {
fmt.Fprintf(&b, "[%s %d%%]", shortName(s.Name), percent(s.Completed, s.Total))
} else {
fmt.Fprintf(&b, "[%s %s]", shortName(s.Name), humanSize(s.Completed, r.human))
}
}
return b.String()
}
// add records a sample and drops any that have fallen out of the window. A
// sample is only appended once per second, but the latest counters are folded
// into the newest slot so the final cancel-render, which typically fires in the
// same second as the preceding tick, still sees current totals.
func (w *speedWindow) add(now time.Time, completed, uploaded int64) {
// Drop samples older than the window, keeping the first one that still falls
// inside it as the baseline for the delta.
cut := now.Add(-windowTime)
i := 0
for i < len(w.samples) && w.samples[i].t.Before(cut) {
i++
}
w.samples = w.samples[i:]
if n := len(w.samples); n > 0 && now.Sub(w.samples[n-1].t) < time.Second {
w.samples[n-1].completed = completed
w.samples[n-1].uploaded = uploaded
return
}
w.samples = append(w.samples, sample{now, completed, uploaded})
}
// rates derives down/up speed in bytes/s from the change across the speed
// window (windowBytes / windowSeconds). With a single sample we have no span
// yet, so we report 0 rather than mistaking the cumulative bytes (e.g. resumed
// data) for one window's worth.
func (r *Reporter) rates(s download.Stat) (dl, ul int64) {
w := r.win[s.ID]
if w == nil || len(w.samples) < 2 {
return 0, 0
}
first, last := w.samples[0], w.samples[len(w.samples)-1]
secs := last.t.Sub(first.t).Seconds()
if secs <= 0 {
return 0, 0
}
dl = int64(float64(last.completed-first.completed) / secs)
ul = int64(float64(last.uploaded-first.uploaded) / secs)
if dl < 0 {
dl = 0
}
if ul < 0 {
ul = 0
}
return dl, ul
}
// ratio is uploaded/completed for the seeding readout; 0 when nothing has been
// downloaded yet to avoid dividing by zero.
func ratio(uploaded, completed int64) float64 {
if completed <= 0 {
return 0
}
return float64(uploaded) / float64(completed)
}
func shortName(s string) string {
const max = 20
// Truncate on runes, not bytes, so a multibyte name (CJK, emoji) is never
// cut mid-rune.
if utf8.RuneCountInString(s) > max {
return string([]rune(s)[:max-1]) + "~"
}
return s
}
// clampRunes limits a line to width runes (not bytes) so multibyte glyphs are
// never split when the terminal is narrow. A non-positive width leaves it whole.
func clampRunes(s string, width int) string {
if width <= 0 || utf8.RuneCountInString(s) <= width {
return s
}
return string([]rune(s)[:width])
}
func termWidth(prev int) int {
if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 {
return w
}
if prev > 0 {
return prev
}
return 80
}

135
progress/progress_test.go Normal file
View File

@@ -0,0 +1,135 @@
package progress
import (
"strings"
"testing"
"time"
"github.com/hanbok/got/download"
)
// reporterAt builds a Reporter wired to a fixed stat snapshot for rendering tests.
func newReporter(human bool) *Reporter {
return &Reporter{human: human, width: 200, win: map[string]*speedWindow{}}
}
// TestSlidingWindowSpeed feeds samples across a ~10s window and checks the rate
// is the windowed delta over its real duration, not the last-tick delta.
func TestSlidingWindowSpeed(t *testing.T) {
r := newReporter(false)
base := time.Unix(1000, 0)
// Five one-second ticks, +1000 bytes each: window holds 4s of deltas.
w := &speedWindow{}
r.win["x"] = w
for i := 0; i < 5; i++ {
w.add(base.Add(time.Duration(i)*time.Second), int64(i*1000), 0)
}
s := download.Stat{ID: "x", Completed: 4000}
dl, ul := r.rates(s)
if dl != 1000 {
t.Errorf("dl = %d, want 1000", dl)
}
if ul != 0 {
t.Errorf("ul = %d, want 0", ul)
}
}
// TestWindowDropsStale ensures samples older than windowTime are evicted so the
// baseline of the delta stays inside the window.
func TestWindowDropsStale(t *testing.T) {
w := &speedWindow{}
base := time.Unix(0, 0)
// One old sample, then a fresh one 20s later.
w.add(base, 0, 0)
w.add(base.Add(20*time.Second), 5000, 0)
if len(w.samples) != 1 {
t.Fatalf("len(samples) = %d, want 1 (stale dropped)", len(w.samples))
}
}
// TestSingleSampleZero: with only one sample there is no span, so speed is 0.
func TestSingleSampleZero(t *testing.T) {
r := newReporter(false)
r.win["x"] = &speedWindow{}
r.win["x"].add(time.Unix(0, 0), 1<<20, 0)
if dl, _ := r.rates(download.Stat{ID: "x"}); dl != 0 {
t.Errorf("dl = %d, want 0 for single sample", dl)
}
}
// TestRatesKeyedByID confirms two downloads sharing a Name don't cross-subtract
// because the window is keyed on Stat.ID.
func TestRatesKeyedByID(t *testing.T) {
r := newReporter(false)
base := time.Unix(0, 0)
for _, id := range []string{"a", "b"} {
r.win[id] = &speedWindow{}
r.win[id].add(base, 0, 0)
}
r.win["a"].add(base.Add(time.Second), 1000, 0)
r.win["b"].add(base.Add(time.Second), 2000, 0)
if dl, _ := r.rates(download.Stat{ID: "a", Name: "dup"}); dl != 1000 {
t.Errorf("a dl = %d, want 1000", dl)
}
if dl, _ := r.rates(download.Stat{ID: "b", Name: "dup"}); dl != 2000 {
t.Errorf("b dl = %d, want 2000", dl)
}
}
// TestLineOneSeeding: while seeding, render SEED(ratio), drop DL:, keep UL:.
func TestLineOneSeeding(t *testing.T) {
r := newReporter(true)
s := download.Stat{
ID: "h", Name: "f", IsBT: true, Status: download.Seeding,
Total: 1000, Completed: 1000, Uploaded: 1500, Conns: 3, Seeders: 2,
}
line := r.lineOne(s)
if !strings.Contains(line, "SEED(1.5)") {
t.Errorf("missing SEED(1.5): %q", line)
}
if strings.Contains(line, "DL:") {
t.Errorf("DL: should be dropped while seeding: %q", line)
}
if !strings.Contains(line, "UL:") {
t.Errorf("UL: should be kept while seeding: %q", line)
}
}
// TestLineOneCNSD: CN always shown; SD shown for torrents (even with 0 seeders),
// absent for HTTP.
func TestLineOneCNSD(t *testing.T) {
r := newReporter(true)
bt := r.lineOne(download.Stat{ID: "h", Name: "f", IsBT: true, Total: 10, Completed: 1, Conns: 4, Seeders: 0})
if !strings.Contains(bt, "CN:4") || !strings.Contains(bt, "SD:0") {
t.Errorf("torrent line missing CN/SD: %q", bt)
}
http := r.lineOne(download.Stat{ID: "h2", Name: "f", IsBT: false, Total: 10, Completed: 1, Conns: 1})
if !strings.Contains(http, "CN:1") {
t.Errorf("http line missing CN: %q", http)
}
if strings.Contains(http, "SD:") {
t.Errorf("http line should not show SD: %q", http)
}
}
// TestShortNameRune ensures CJK names are truncated on runes, not bytes.
func TestShortNameRune(t *testing.T) {
name := strings.Repeat("あ", 30) // 30 runes, 90 bytes
got := shortName(name)
runes := []rune(got)
if len(runes) != 20 {
t.Errorf("shortName rune count = %d, want 20", len(runes))
}
if runes[len(runes)-1] != '~' {
t.Errorf("expected trailing ~, got %q", got)
}
}
// TestClampRunes clamps on runes so a multibyte glyph is never split.
func TestClampRunes(t *testing.T) {
s := strings.Repeat("あ", 10) // 10 runes
got := clampRunes(s, 5)
if r := []rune(got); len(r) != 5 {
t.Errorf("clampRunes rune count = %d, want 5", len(r))
}
}