first commit
This commit is contained in:
27
httpdl/alloc_linux.go
Normal file
27
httpdl/alloc_linux.go
Normal 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
14
httpdl/alloc_other.go
Normal 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
22
httpdl/ca_test.go
Normal 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
82
httpdl/checksum.go
Normal 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
60
httpdl/checksum_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
97
httpdl/conditional_test.go
Normal file
97
httpdl/conditional_test.go
Normal 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
79
httpdl/control.go
Normal 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
177
httpdl/cookies.go
Normal 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
126
httpdl/cookies_test.go
Normal 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
1270
httpdl/httpdl.go
Normal file
File diff suppressed because it is too large
Load Diff
140
httpdl/mirror_test.go
Normal file
140
httpdl/mirror_test.go
Normal 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
28
httpdl/retry_test.go
Normal 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
53
httpdl/segment.go
Normal 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
188
httpdl/segment_test.go
Normal 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
88
httpdl/single_test.go
Normal 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
32
httpdl/speed_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user