Compare commits

...
Author SHA1 Message Date
Levi 44e2272ca4 fix(ci): restore Windows release asset uploads 2026-10-24 11:36:29 +08:00
Levi c51c5b284d fix(ci): select the first Windows compiler for runtime packaging (#1604) 2026-10-24 00:39:11 +08:00
Levi 4d65948934 fix(ci): resolve Windows AMD64 runtime DLLs from compiler (#1603) 2026-10-23 23:38:15 +08:00
Levi c275bc2e46 release: v2.0.0-beta.4 2026-10-23 21:00:49 +08:00
WuFeng 9aae13453a feat(download): auto-categorize downloads into folders by file extension (#1572) 2026-10-23 20:38:25 +08:00
Levi d125d4ab75 feat(http): support advisory extension download sizes (#1599) 2026-10-22 23:35:01 +08:00
Levi 4214765d39 refactor: remove proxy API (#1596) 2026-10-21 22:48:17 +08:00
Arpit Khandelwal 47cafb7575 perf(http): use 64 KiB connection read buffers (#1588) 2026-10-21 14:19:01 +08:00
Levi de65149b29 refactor(downloader): remove opts parameter from Resolve (#1595) 2026-10-19 21:40:57 +08:00
Junker der Provinz 5462b14705 feat(bt): support disabling DHT and PEX (#1555) 2026-10-17 11:22:34 +08:00
Levi eee39e012f fix(ui): widen navigation rail for macOS traffic lights (#1582) 2026-10-14 20:09:08 +08:00
Levi 12860bb207 perf(extensions): load store summaries and check updates asynchronously (#1583) 2026-10-14 20:08:41 +08:00
Levi b86c2c18e7 fix(ci): restore Windows ARM builds with scoped MSVC compatibility (#1586) 2026-10-13 21:06:09 +08:00
Ashish Goswami 6f20790b17 feat(protocol/http, mcpserver, download): add checksum verification, MCP speed tool, pause webhook, and CLI flags (#1542) 2026-10-13 08:28:56 +08:00
Kunpeng Xie fa622a4063 fix(http): skip prefetch wait for restored downloads (#1584) 2026-10-12 11:13:42 +08:00
Levi c6619794fc fix: preserve Cookie headers in extension requests (#1581) 2026-10-10 22:10:08 +08:00
Levi 87a8f87b70 feat(ui): rework extension update detection and entry point (#1535) 2026-10-10 22:09:47 +08:00
WuFeng ba84a04a43 fix(settings): use Save as the primary label on settings form dialogs (#1571) 2026-10-08 16:31:16 +08:00
Levi 224b488087 feat(task): record and display task completion time (#1536) 2026-09-25 19:15:31 +08:00
Levi 7ce4fb7f0e fix(ui): smooth task progress bar updates (#1532) 2026-09-25 19:08:20 +08:00
UltraGeek0102 8d1ae93b03 refactor(ios): clean up continued processing task handling (#1547) 2026-09-25 16:21:09 +08:00
JiangJiang 363153fa33 fix(http): use Content-Range total size for 206 responses (#1545) 2026-09-23 09:52:59 +08:00
Levi f0dab90e09 feat(extension): expose request mutation methods on task.meta.req (#1538) 2026-09-23 09:33:28 +08:00
doctor-martin-sowa 6e60889ce8 feat(lang): Add Slovak localization (app_sk.arb) (#1541) 2026-09-23 09:32:07 +08:00
Levi 88d150b933 test(bt): isolate cleanup fixtures from part-file promotion (#1531) 2026-09-21 22:30:54 +08:00
Levi 694b43e3f9 docs: update beta download link to v2.0.0-beta.3 2026-09-19 08:16:57 +08:00
218 changed files with 19087 additions and 2664 deletions

No files matched your search

+19 -4
View File
@@ -106,17 +106,32 @@ jobs:
if ("${{ matrix.arch }}" -eq "amd64") {
# for amd64
$release = "build\windows\x64\runner\Release\"
$mingw = "C:\Program Files\Git\mingw64\bin"
cp $mingw\libstdc++-6.dll $release
cp $mingw\libgcc_s_seh-1.dll $release
# Use the same toolchain as cgo instead of Git's bundled DLLs.
$cc = (Get-Command -Name (go env CC) -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source
foreach ($dll in @("libstdc++-6.dll", "libgcc_s_seh-1.dll", "libwinpthread-1.dll")) {
$runtimePath = [string](& $cc "-print-file-name=$dll")
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($runtimePath)) {
throw "Failed to locate $dll using $cc"
}
$runtimePath = $runtimePath.Trim()
# Some MinGW distributions keep runtime DLLs beside gcc.exe.
if ($runtimePath -eq $dll) {
$runtimePath = Join-Path (Split-Path -Parent $cc) $dll
}
if (!(Test-Path -LiteralPath $runtimePath -PathType Leaf)) {
throw "MinGW runtime $dll was not found by $cc (reported path: $runtimePath)"
}
Write-Host "Copying MinGW runtime: $runtimePath"
Copy-Item -LiteralPath $runtimePath -Destination $release
}
} else {
# for ARM64
$release = "build\windows\arm64\runner\Release\"
$mingw = "$env:CLANGARM64_ROOT\aarch64-w64-mingw32\bin"
cp $mingw\libc++.dll $release
cp $mingw\libunwind.dll $release
cp $mingw\libwinpthread-1.dll $release
}
cp $mingw\libwinpthread-1.dll $release
cp $system\msvcp140.dll $release
cp $system\vcruntime140.dll $release
cp $system\vcruntime140_1.dll $release
+17
View File
@@ -69,6 +69,23 @@ jobs:
with:
files: ./coverage.txt
token: ${{ secrets.CODECOV_UPLOAD_TOKEN }}
test-flutter-category:
# The settings editor and the backend have to read an extension list the
# same way, so both sides run the shared rule table. The Go side is covered
# by the test job above, this runs the ui side of it.
name: Test shared category rules
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Run the shared category rule table on the ui side
working-directory: ui/flutter
run: |
flutter pub get
flutter gen-l10n
flutter test test/api/downloader_config_test.dart
test-webview-integration:
name: Test webview integration (${{ matrix.name }})
strategy:
+1 -1
View File
@@ -63,7 +63,7 @@ Connect Gopeed to an AI agent and manage downloads with natural language. For ex
Gopeed 2.0.0 is currently in public beta, introducing a redesigned interface, a native communication architecture that connects desktop and mobile clients directly to the Go core through FFI, a more consistent cross-platform experience, improved task management, more flexible API support, and MCP-based AI agent integration. Some features may still be incomplete or unstable, so please try it and report any issues you encounter.
- [Download Gopeed 2.0.0 Beta 2](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2)
- [Download Gopeed 2.0.0 Beta 3](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3)
Once the features and stability meet our release standards, we will publish the official Gopeed 2.0.0 release. Beta users will be able to upgrade directly to the final release, while existing stable users will not be automatically moved onto the beta channel.
+1 -1
View File
@@ -63,7 +63,7 @@ Gopeed を AI Agent と連携すると、自然言語でダウンロードを管
Gopeed 2.0.0 は現在パブリックベータ版です。刷新されたインターフェース、デスクトップおよびモバイルから FFI で Go コアへ直接接続するネイティブ通信アーキテクチャ、より統一されたクロスプラットフォーム体験、強化されたタスク管理、より柔軟な API、MCP による AI Agent 連携機能を導入しています。一部の機能はまだ不完全または不安定な場合がありますので、ぜひお試しいただき、問題があればフィードバックをお寄せください。
- [Gopeed 2.0.0 Beta 2 をダウンロード](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2)
- [Gopeed 2.0.0 Beta 3 をダウンロード](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3)
機能と安定性が正式リリースの基準に達した時点で、Gopeed 2.0.0 正式版を公開します。ベータ版のユーザーは正式版へ直接アップデートできます。既存の安定版ユーザーが自動的にベータチャンネルへ移行することはありません。
+1 -1
View File
@@ -63,7 +63,7 @@ Sau khi kết nối Gopeed với AI Agent, bạn có thể quản lý tải xu
Gopeed 2.0.0 hiện đang trong giai đoạn beta công khai, với giao diện được thiết kế lại, kiến trúc giao tiếp native kết nối trực tiếp ứng dụng desktop và di động với lõi Go qua FFI, trải nghiệm đa nền tảng nhất quán hơn, khả năng quản lý tác vụ được cải thiện, API linh hoạt hơn và khả năng tích hợp AI Agent qua MCP. Một số tính năng có thể vẫn chưa hoàn thiện hoặc chưa ổn định, vì vậy hãy dùng thử và gửi phản hồi nếu bạn gặp vấn đề.
- [Tải Gopeed 2.0.0 Beta 2](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2)
- [Tải Gopeed 2.0.0 Beta 3](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3)
Khi các tính năng và độ ổn định đáp ứng tiêu chuẩn phát hành, chúng tôi sẽ phát hành Gopeed 2.0.0 chính thức. Người dùng bản beta có thể nâng cấp trực tiếp lên bản chính thức, trong khi người dùng bản ổn định hiện tại sẽ không tự động được chuyển sang kênh beta.
+1 -1
View File
@@ -63,7 +63,7 @@ Gopeed(全称 **Go Speed**,也叫“够快下载器”)是一款使用 Go
Gopeed 2.0.0 目前处于公开 Beta 测试阶段,引入了全新界面、通过 FFI 直连 Go 核心的原生通信架构、更一致的跨平台体验、更完善的任务管理、更灵活的 API,以及用于 AI Agent 接入的 MCP 能力。部分功能仍可能不够完善或稳定,欢迎提前体验并向我们反馈问题。
- [下载 Gopeed 2.0.0 Beta 2](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2)
- [下载 Gopeed 2.0.0 Beta 3](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3)
当功能完整度和稳定性达到正式发布标准后,我们会发布 Gopeed 2.0.0 正式版。已安装 Beta 版本的用户可以直接升级到最终正式版,现有稳定版用户则不会被自动切换到 Beta 渠道。
+1 -1
View File
@@ -63,7 +63,7 @@ Gopeed(**Go Speed** 的縮寫)是一款以 Go 與 Flutter 開發的高速、
Gopeed 2.0.0 目前處於公開 Beta 測試階段,引入了全新介面、透過 FFI 直連 Go 核心的原生通訊架構、更一致的跨平台體驗、更完善的任務管理、更靈活的 API,以及透過 MCP 串接 AI Agent 的能力。部分功能仍可能不夠完善或穩定,歡迎搶先體驗並向我們回報問題。
- [下載 Gopeed 2.0.0 Beta 2](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2)
- [下載 Gopeed 2.0.0 Beta 3](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3)
當功能完整度與穩定性達到正式發布標準後,我們會發布 Gopeed 2.0.0 正式版。已安裝 Beta 版本的使用者可以直接升級至最終正式版,現有穩定版使用者則不會被自動切換至 Beta 頻道。
+2 -2
View File
@@ -166,7 +166,7 @@ func runResolve(args []string) error {
result, err := d.Resolve(&base.Request{
URL: url,
Labels: map[string]string(labels),
}, nil)
})
if err != nil {
return err
}
@@ -250,7 +250,7 @@ func runDownload(args []string) error {
result, err := d.Resolve(&base.Request{
URL: url,
Labels: map[string]string(labels),
}, nil)
})
if err != nil {
return err
}
+7 -3
View File
@@ -7,9 +7,11 @@ import (
)
type args struct {
url string
connections *int
dir *string
url string
connections *int
dir *string
checksum *string
checksumAlgo *string
}
func parse() *args {
@@ -20,6 +22,8 @@ func parse() *args {
var args args
args.connections = flag.Int("C", 16, "Concurrent connections.")
args.dir = flag.String("D", dir, "Store directory.")
args.checksum = flag.String("checksum", "", "Expected checksum hash (hex encoded).")
args.checksumAlgo = flag.String("checksum-algo", "md5", "Checksum algorithm: md5, sha1, or sha256.")
flag.Parse()
t := flag.Args()
if len(t) > 0 {
+17 -2
View File
@@ -19,6 +19,18 @@ func main() {
var wg sync.WaitGroup
wg.Add(1)
var checksumOpt *http.ChecksumOption
if args.checksum != nil && *args.checksum != "" {
algo := "md5"
if args.checksumAlgo != nil && *args.checksumAlgo != "" {
algo = *args.checksumAlgo
}
checksumOpt = &http.ChecksumOption{
Algorithm: algo,
Expected: *args.checksum,
}
}
_, err := download.Boot().
URL(args.url).
Listener(func(event *download.Event) {
@@ -43,8 +55,11 @@ func main() {
}
}).
Create(&base.Options{
Path: *args.dir,
Extra: http.OptsExtra{Connections: *args.connections},
Path: *args.dir,
Extra: http.OptsExtra{
Connections: *args.connections,
Checksum: checksumOpt,
},
})
if err != nil {
panic(err)
+7 -1
View File
@@ -26,7 +26,7 @@ func Start(cfg *model.StartConfig) {
if err != nil {
panic(err)
}
if downloadCfg.FirstLoad {
if rest.Downloader.IsFirstLoad() {
// Set default download config
if cfg.DownloadConfig != nil {
cfg.DownloadConfig.Merge(downloadCfg)
@@ -51,6 +51,12 @@ func Start(cfg *model.StartConfig) {
rest.Downloader.PutConfig(downloadCfg)
}
}
// The default download dir is resolved only after the core starts, so
// seed the built-in categories now that the dir is known. Init is
// idempotent and leaves an existing category list untouched.
downloadCfg.Init()
rest.Downloader.PutConfig(downloadCfg)
}
watchExit()
+1 -1
View File
@@ -66,6 +66,7 @@ require (
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/mock v0.5.1 // indirect
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
golang.org/x/mod v0.33.0
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.20.0 // indirect
)
@@ -156,7 +157,6 @@ require (
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.50.0
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.34.0
+1 -1
View File
@@ -12,7 +12,7 @@ import (
// Each download task will have a corresponding Fetcher instance for the management of the download task
type Fetcher interface {
Setup(ctl *controller.Controller)
Resolve(req *base.Request, opts *base.Options) error
Resolve(req *base.Request) error
Start() error
// Patch modifies task-specific data based on the protocol.
// For HTTP: can modify Request info (URL, headers, etc.)
+72
View File
@@ -0,0 +1,72 @@
package fetcher
import (
"context"
"encoding/json"
"errors"
"time"
)
type TriggerSource string
const (
TriggerScheduled TriggerSource = "scheduled"
TriggerManual TriggerSource = "manual"
)
var ErrJobSkipped = errors.New("protocol job skipped")
var ErrConfigChanged = errors.New("protocol configuration changed during execution")
// ConfigState embeds the common backend-owned state section in a protocol config.
// Public config PUTs always retain this section from the latest stored config.
type ConfigState[T any] struct {
State T `json:"state"`
}
// ProtocolHost is scoped to one manager. UpdateState runs against the latest
// config under Downloader's lock; callbacks must not perform network I/O.
type ProtocolHost struct {
Read func(any) error
UpdateState func(func(config, state json.RawMessage) (any, error)) error
Fetch func(context.Context, string, int64, func([]byte) error) ([]byte, error)
}
func ReadConfig[T any](host ProtocolHost) (*T, error) {
var cfg T
if err := host.Read(&cfg); err != nil {
return nil, err
}
return &cfg, nil
}
// UpdateState gives the callback a detached config snapshot and mutable state.
// Only the state is committed; edits to the config snapshot are discarded.
func UpdateState[C, S any](host ProtocolHost, update func(*C, *S) error) error {
return host.UpdateState(func(rawConfig, rawState json.RawMessage) (any, error) {
var cfg C
var state S
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
return nil, err
}
if err := json.Unmarshal(rawState, &state); err != nil {
return nil, err
}
if err := update(&cfg, &state); err != nil {
return nil, err
}
return &state, nil
})
}
type CronJob struct {
Name string
Every time.Duration
Timeout time.Duration
Run func(context.Context, TriggerSource) error
}
type CronProvider interface{ CronJobs(ProtocolHost) []CronJob }
// ConfigNormalizer is optional. It owns protocol validation and derived fields.
type ConfigNormalizer interface {
NormalizeConfig(json.RawMessage) (any, error)
}
+73
View File
@@ -0,0 +1,73 @@
# GitHub mirror execution
This package owns GitHub URL rewriting and execution strategies. It depends only
on the standard library and does not depend on Downloader, HTTP clients, Git, or
storage. Downloader persists `Config` at its top-level `githubMirror` field;
`pkg/base` preserves the existing configuration names through aliases.
## Race: resource reads
```go
body, err := githubmirror.Race(ctx, cfg.GithubMirror, rawURL,
githubmirror.Options[[]byte]{
Kind: githubmirror.Resource,
Timeout: 30 * time.Second,
},
func(ctx context.Context, candidate string) ([]byte, error) {
return fetchAndValidate(ctx, client, candidate)
},
)
```
The callback owns HTTP requests, size limits, and content validation. The first
completed valid result wins. Other operations are canceled and drained. There is
no HEAD-probe assumption or client-specific HTTP behavior inside the executor.
## Fallback: repository operations
```go
repo, err := githubmirror.Fallback(ctx, cfg.GithubMirror, officialURL,
githubmirror.Options[*git.Repository]{
Kind: githubmirror.Repository,
},
func(ctx context.Context, candidate string) (*git.Repository, error) {
// Use a clean temporary checkout for each attempt.
return clone(ctx, candidate)
},
)
```
The official URL is attempted first; mirrors follow their configured order.
jsDelivr is excluded for repositories. Stop on the first success. Perform
persistent installation or other business state changes after success, rather
than inside a potentially repeated attempt.
## Results with resources
For temporary files, set `Options[*Download].Discard` to remove the file. Return
the partial `*Download` alongside errors, so cleanup also covers failed reads or
validation. The executor discards every failure and every race loser exactly
once. The caller owns and must track/release the winning result.
- `Timeout == 0` uses 30 seconds per resource attempt and no overall deadline
for repository attempts; a negative timeout is invalid. Extension Git HTTP
transport limits connections/TLS handshakes to 10 seconds and resets a 60-second
read deadline as data arrives. Slow continuous transfers can finish, while
stalled sources fail and allow ordered fallback. Parent cancellation still applies.
- Attempts must honor the supplied context; draining waits for them to return.
- Attempt contexts are canceled after return, so callbacks must finish reading
HTTP bodies before returning. Returning a still-streaming response is unsupported.
- Discard callbacks must accept zero values, must not panic, and run serially.
- Parent cancellation wins over a result selected while the parent is canceled;
the selected result is then discarded as well.
- Errors preserve their causes through wrapping/joining for `errors.Is`.
- `github.com` and `raw.githubusercontent.com` expand into eligible mirrors.
jsDelivr is only eligible for raw source resources. `api.github.com` is never
mirrored and is rejected by the public mirror candidates API.
- Client application update checks request the official GitHub API directly,
falling back to the independent Gopeed website API on request or validation
failure. Only package downloads obtain URLs from
`POST /api/v1/mirrors/github/candidates`. Candidate generation never performs
HTTP requests.
- Disabled mirrors and other URLs execute only the original URL.
- Config is a caller-owned immutable snapshot for the duration of a call.
+90
View File
@@ -0,0 +1,90 @@
// Package githubmirror rewrites GitHub URLs and executes mirrored operations.
package githubmirror
import (
"fmt"
"net/url"
"strings"
)
type Mirror struct {
Type string `json:"type"`
URL string `json:"url"`
IsBuiltIn bool `json:"isBuiltIn"`
IsDeleted bool `json:"isDeleted"`
}
type Config struct {
Enabled bool `json:"enabled"`
Mirrors []Mirror `json:"mirrors"`
}
func DefaultConfig() *Config {
return &Config{Enabled: true, Mirrors: []Mirror{
{Type: "jsdelivr", URL: "https://fastly.jsdelivr.net/gh", IsBuiltIn: true},
{Type: "ghProxy", URL: "https://fastgit.cc", IsBuiltIn: true},
{Type: "ghProxy", URL: "https://gh-proxy.com", IsBuiltIn: true},
{Type: "ghProxy", URL: "https://ghfast.top", IsBuiltIn: true},
}}
}
func (c *Config) Validate() error {
if len(c.Mirrors) > 32 {
return fmt.Errorf("too many GitHub mirrors")
}
for _, mirror := range c.Mirrors {
u, err := url.Parse(mirror.URL)
if err != nil || u == nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return fmt.Errorf("invalid GitHub mirror URL")
}
if mirror.Type != "jsdelivr" && mirror.Type != "ghProxy" {
return fmt.Errorf("invalid GitHub mirror type: %s", mirror.Type)
}
}
return nil
}
// Kind describes what a candidate endpoint must support.
type Kind uint8
const (
Resource Kind = iota
Repository
)
// Candidates preserves the official URL first. jsDelivr is only eligible for
// raw source resources; Git operations use mirrors supporting Git Smart HTTP.
// GitHub API URLs are never mirrored.
func Candidates(c *Config, raw string, kind Kind) []string {
result := []string{raw}
u, err := url.Parse(raw)
if err != nil || c == nil || !c.Enabled || u.User != nil || (u.Scheme != "https" && u.Scheme != "http") {
return result
}
if u.Host != "github.com" && u.Host != "raw.githubusercontent.com" {
return result
}
seen := map[string]bool{raw: true}
for _, mirror := range c.Mirrors {
if mirror.IsDeleted {
continue
}
var candidate string
switch mirror.Type {
case "ghProxy":
candidate = strings.TrimRight(mirror.URL, "/") + "/" + raw
case "jsdelivr":
if kind == Repository || u.Host != "raw.githubusercontent.com" {
continue
}
parts := strings.SplitN(strings.TrimPrefix(u.EscapedPath(), "/"), "/", 4)
if len(parts) != 4 {
continue
}
candidate = strings.TrimRight(mirror.URL, "/") + "/" + parts[0] + "/" + parts[1] + "@" + parts[2] + "/" + parts[3]
}
if candidate != "" && !seen[candidate] {
result = append(result, candidate)
seen[candidate] = true
}
}
return result
}
+38
View File
@@ -0,0 +1,38 @@
package githubmirror
import (
"reflect"
"testing"
)
func TestMirrorCandidates(t *testing.T) {
cfg := &Config{Enabled: true, Mirrors: []Mirror{
{Type: "jsdelivr", URL: "https://cdn.example/gh/"},
{Type: "ghProxy", URL: "https://mirror.example/"},
{Type: "ghProxy", URL: "https://deleted.example", IsDeleted: true},
}}
source := "https://raw.githubusercontent.com/owner/repo/main/a.txt"
want := []string{source, "https://cdn.example/gh/owner/repo@main/a.txt", "https://mirror.example/" + source}
if got := Candidates(cfg, source, Resource); !reflect.DeepEqual(got, want) {
t.Fatalf("got %v", got)
}
repo := "https://github.com/owner/repo.git"
if got := Candidates(cfg, repo, Repository); !reflect.DeepEqual(got, []string{repo, "https://mirror.example/" + repo}) {
t.Fatalf("git candidates %v", got)
}
for _, raw := range []string{"https://github.com.evil.example/a", "https://example.com/a", "https://user:[email protected]/a"} {
if got := Candidates(cfg, raw, Resource); !reflect.DeepEqual(got, []string{raw}) {
t.Fatalf("unexpected mirror: %v", got)
}
}
apiURL := "https://api.github.com/repos/GopeedLab/gopeed/releases?per_page=10"
for _, kind := range []Kind{Resource, Repository} {
if got := Candidates(cfg, apiURL, kind); !reflect.DeepEqual(got, []string{apiURL}) {
t.Fatalf("GitHub API must never use mirrors: %v", got)
}
}
cfg.Enabled = false
if got := Candidates(cfg, source, Resource); !reflect.DeepEqual(got, []string{source}) {
t.Fatalf("disabled mirror: %v", got)
}
}
+160
View File
@@ -0,0 +1,160 @@
package githubmirror
import (
"context"
"errors"
"fmt"
"time"
)
// Options configures execution, not URL persistence. Discard releases results
// from failed attempts and successful race losers, including partial results
// returned together with an error. The caller owns the winning result.
type Options[T any] struct {
Kind Kind
// Timeout limits each attempt. Zero uses 30 seconds for resources and no
// overall deadline for repositories, whose transport owns idle timeouts.
Timeout time.Duration
Discard func(T)
}
// Attempt must honor ctx and return a completed, validated result. Its context
// is canceled after it returns, so live response streams must not be returned.
// Return any partially acquired resources along with errors so Discard can
// release them. Failed attempts must not mutate persistent business state.
type Attempt[T any] func(context.Context, string) (T, error)
const defaultTimeout = 30 * time.Second
func prepare[T any](ctx context.Context, config *Config, raw string, options Options[T], attempt Attempt[T]) ([]string, time.Duration, error) {
if err := ctx.Err(); err != nil {
return nil, 0, err
}
if raw == "" {
return nil, 0, errors.New("empty GitHub source URL")
}
if options.Kind != Resource && options.Kind != Repository {
return nil, 0, errors.New("invalid GitHub resource kind")
}
if options.Timeout < 0 {
return nil, 0, errors.New("invalid GitHub attempt timeout")
}
if attempt == nil {
return nil, 0, errors.New("GitHub mirror attempt is required")
}
if config != nil {
if err := config.Validate(); err != nil {
return nil, 0, err
}
}
timeout := options.Timeout
if timeout == 0 && options.Kind == Resource {
timeout = defaultTimeout
}
return Candidates(config, raw, options.Kind), timeout, nil
}
func invoke[T any](ctx context.Context, timeout time.Duration, candidate string, attempt Attempt[T]) (value T, err error) {
var cancel context.CancelFunc
if timeout > 0 {
ctx, cancel = context.WithTimeout(ctx, timeout)
} else {
ctx, cancel = context.WithCancel(ctx)
}
defer cancel()
defer func() {
if recovered := recover(); recovered != nil {
err = fmt.Errorf("GitHub mirror attempt panicked: %v", recovered)
}
}()
value, err = attempt(ctx, candidate)
if err == nil {
err = ctx.Err()
}
return
}
func discard[T any](options Options[T], value T) {
if options.Discard != nil {
options.Discard(value)
}
}
// Race executes the official URL and eligible mirrors concurrently. The first
// completed successful result wins; losing attempts are canceled and drained
// before return so no temporary files or other resources outlive the call.
// HTTP status/content validation is the attempt's responsibility.
func Race[T any](ctx context.Context, config *Config, raw string, options Options[T], attempt Attempt[T]) (T, error) {
var zero T
candidates, timeout, err := prepare(ctx, config, raw, options, attempt)
if err != nil {
return zero, err
}
raceCtx, cancel := context.WithCancel(ctx)
defer cancel()
type outcome struct {
index int
value T
err error
}
outcomes := make(chan outcome, len(candidates))
for index, candidate := range candidates {
go func(index int, candidate string) {
value, err := invoke(raceCtx, timeout, candidate, attempt)
outcomes <- outcome{index, value, err}
}(index, candidate)
}
var winner T
won := false
failures := make([]error, len(candidates))
for range candidates {
outcome := <-outcomes
if !won && outcome.err == nil && ctx.Err() == nil {
winner = outcome.value
won = true
cancel()
} else {
discard(options, outcome.value)
if outcome.err != nil {
failures[outcome.index] = fmt.Errorf("GitHub source %d: %w", outcome.index+1, outcome.err)
}
if ctx.Err() != nil {
cancel()
}
}
}
if err := ctx.Err(); err != nil {
if won {
discard(options, winner)
}
return zero, err
}
if won {
return winner, nil
}
return zero, fmt.Errorf("all GitHub sources failed: %w", errors.Join(failures...))
}
// Fallback executes the official URL first, then eligible mirrors in their
// configured order. It stops immediately on success and cleans every failure.
func Fallback[T any](ctx context.Context, config *Config, raw string, options Options[T], attempt Attempt[T]) (T, error) {
var zero T
candidates, timeout, err := prepare(ctx, config, raw, options, attempt)
if err != nil {
return zero, err
}
failures := make([]error, 0, len(candidates))
for index, candidate := range candidates {
if err := ctx.Err(); err != nil {
return zero, err
}
value, err := invoke(ctx, timeout, candidate, attempt)
if err == nil && ctx.Err() == nil {
return value, nil
}
discard(options, value)
if parentErr := ctx.Err(); parentErr != nil {
return zero, parentErr
}
failures = append(failures, fmt.Errorf("GitHub source %d: %w", index+1, err))
}
return zero, fmt.Errorf("all GitHub sources failed: %w", errors.Join(failures...))
}
+214
View File
@@ -0,0 +1,214 @@
package githubmirror
import (
"context"
"errors"
"reflect"
"strings"
"sync"
"testing"
"time"
)
const testSource = "https://github.com/owner/repo.git"
func testConfig() *Config {
return &Config{Enabled: true, Mirrors: []Mirror{
{Type: "ghProxy", URL: "https://first.example"},
{Type: "ghProxy", URL: "https://second.example"},
}}
}
func TestRaceWinsAndDrainsOwnedResults(t *testing.T) {
config := testConfig()
candidates := Candidates(config, testSource, Resource)
var started sync.WaitGroup
started.Add(len(candidates))
discarded := []string{}
winner, err := Race(context.Background(), config, testSource, Options[string]{
Discard: func(value string) { discarded = append(discarded, value) },
}, func(ctx context.Context, address string) (string, error) {
started.Done()
started.Wait()
switch address {
case candidates[0]:
return address, errors.New("official offline")
case candidates[1]:
return address, nil
default:
<-ctx.Done()
return address, ctx.Err()
}
})
if err != nil || winner != candidates[1] {
t.Fatalf("winner=%s err=%v", winner, err)
}
if len(discarded) != 2 || !contains(discarded, candidates[0]) || !contains(discarded, candidates[2]) {
t.Fatalf("discarded %v", discarded)
}
}
func contains(values []string, value string) bool {
for _, item := range values {
if item == value {
return true
}
}
return false
}
func TestRaceCleansSimultaneousSuccessfulLosers(t *testing.T) {
config := testConfig()
var started sync.WaitGroup
started.Add(3)
cleaned := []string{}
winner, err := Race(context.Background(), config, testSource, Options[string]{Discard: func(value string) { cleaned = append(cleaned, value) }}, func(_ context.Context, address string) (string, error) {
started.Done()
started.Wait()
return address, nil
})
if err != nil || winner == "" || len(cleaned) != 2 || contains(cleaned, winner) {
t.Fatalf("winner=%s discarded=%v err=%v", winner, cleaned, err)
}
}
func TestRaceParentCancellationCleansWinnerToo(t *testing.T) {
config := testConfig()
config.Mirrors = config.Mirrors[:1]
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cleaned := []string{}
winner, err := Race(ctx, config, testSource, Options[string]{Discard: func(value string) { cleaned = append(cleaned, value) }}, func(ctx context.Context, address string) (string, error) {
if address == testSource {
return address, nil
}
<-ctx.Done()
cancel()
return address, ctx.Err()
})
if !errors.Is(err, context.Canceled) || winner != "" || len(cleaned) != 2 || !contains(cleaned, testSource) {
t.Fatalf("winner=%s cleanup=%v err=%v", winner, cleaned, err)
}
}
func TestRaceTimeoutAndErrorAggregation(t *testing.T) {
expected := errors.New("offline")
config := testConfig()
config.Mirrors = config.Mirrors[:1]
discarded := 0
_, err := Race(context.Background(), config, testSource, Options[string]{Timeout: 10 * time.Millisecond, Discard: func(string) { discarded++ }}, func(ctx context.Context, address string) (string, error) {
if address == testSource {
return address, expected
}
<-ctx.Done()
return address, ctx.Err()
})
if !errors.Is(err, expected) || !errors.Is(err, context.DeadlineExceeded) || discarded != 2 {
t.Fatalf("discarded=%d err=%v", discarded, err)
}
}
func TestFallbackPreservesOrderAndStopsOnSuccess(t *testing.T) {
config := testConfig()
candidates := Candidates(config, testSource, Repository)
tried := []string{}
cleaned := []string{}
winner, err := Fallback(context.Background(), config, testSource, Options[string]{Kind: Repository, Discard: func(value string) { cleaned = append(cleaned, value) }}, func(_ context.Context, address string) (string, error) {
tried = append(tried, address)
if address == candidates[1] {
return address, nil
}
return address, errors.New("offline")
})
if err != nil || winner != candidates[1] || !reflect.DeepEqual(tried, candidates[:2]) || !reflect.DeepEqual(cleaned, candidates[:1]) {
t.Fatalf("winner=%s tried=%v discarded=%v err=%v", winner, tried, cleaned, err)
}
tried = nil
_, err = Fallback(context.Background(), config, testSource, Options[string]{Kind: Repository}, func(_ context.Context, address string) (string, error) {
tried = append(tried, address)
return address, nil
})
if err != nil || !reflect.DeepEqual(tried, candidates[:1]) {
t.Fatalf("tried=%v err=%v", tried, err)
}
}
func TestFallbackTimeoutContinuesWithFreshContext(t *testing.T) {
discarded := []string{}
winner, err := Fallback(context.Background(), testConfig(), testSource, Options[string]{Kind: Repository, Timeout: 10 * time.Millisecond, Discard: func(value string) { discarded = append(discarded, value) }}, func(ctx context.Context, address string) (string, error) {
if address == testSource {
<-ctx.Done()
return address, ctx.Err()
}
if err := ctx.Err(); err != nil {
return address, err
}
return address, nil
})
if err != nil || !strings.HasPrefix(winner, "https://first.example/") || !reflect.DeepEqual(discarded, []string{testSource}) {
t.Fatalf("winner=%s cleanup=%v err=%v", winner, discarded, err)
}
}
func TestFallbackCancellationStopsBeforeNextMirror(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
calls := 0
cleaned := 0
_, err := Fallback(ctx, testConfig(), testSource, Options[string]{Kind: Repository, Discard: func(string) { cleaned++ }}, func(ctx context.Context, address string) (string, error) {
calls++
cancel()
return address, ctx.Err()
})
if !errors.Is(err, context.Canceled) || calls != 1 || cleaned != 1 {
t.Fatalf("calls=%d discarded=%d err=%v", calls, cleaned, err)
}
}
func TestExecutorsDoNotStartInvalidOrCanceledRequests(t *testing.T) {
canceled, cancel := context.WithCancel(context.Background())
cancel()
for _, test := range []struct {
ctx context.Context
config *Config
options Options[string]
attempt Attempt[string]
}{
{ctx: canceled, options: Options[string]{}, attempt: func(context.Context, string) (string, error) { t.Fatal("called after cancellation"); return "", nil }},
{ctx: context.Background(), options: Options[string]{Kind: 99}, attempt: func(context.Context, string) (string, error) { t.Fatal("invalid kind"); return "", nil }},
{ctx: context.Background(), options: Options[string]{Timeout: -time.Second}, attempt: func(context.Context, string) (string, error) { t.Fatal("invalid timeout"); return "", nil }},
{ctx: context.Background(), options: Options[string]{}, attempt: nil},
{ctx: context.Background(), config: &Config{Mirrors: []Mirror{{Type: "invalid", URL: "https://mirror.example"}}}, attempt: func(context.Context, string) (string, error) { t.Fatal("invalid config"); return "", nil }},
} {
if _, err := Race(test.ctx, test.config, testSource, test.options, test.attempt); err == nil {
t.Fatal("invalid race accepted")
}
if _, err := Fallback(test.ctx, test.config, testSource, test.options, test.attempt); err == nil {
t.Fatal("invalid fallback accepted")
}
}
}
func TestAttemptPanicBecomesError(t *testing.T) {
attempt := func(context.Context, string) (string, error) { panic("broken operation") }
if _, err := Race(context.Background(), nil, testSource, Options[string]{}, attempt); err == nil || !strings.Contains(err.Error(), "broken operation") {
t.Fatal(err)
}
if _, err := Fallback(context.Background(), nil, testSource, Options[string]{}, attempt); err == nil || !strings.Contains(err.Error(), "broken operation") {
t.Fatal(err)
}
}
func TestNonGithubResourcesAndDisabledMirrorsUseOnlyOriginal(t *testing.T) {
for _, test := range []struct {
config *Config
raw string
}{{testConfig(), "https://example.com/resource"}, {&Config{Enabled: false, Mirrors: testConfig().Mirrors}, testSource}} {
calls := 0
got, err := Race(context.Background(), test.config, test.raw, Options[string]{}, func(_ context.Context, address string) (string, error) { calls++; return address, nil })
if err != nil || got != test.raw || calls != 1 {
t.Fatalf("got=%s calls=%d err=%v", got, calls, err)
}
}
}
func TestRepositoryAttemptsHaveNoDefaultDeadline(t *testing.T) {
value, err := Fallback(context.Background(), nil, testSource, Options[string]{Kind: Repository}, func(ctx context.Context, raw string) (string, error) {
if _, ok := ctx.Deadline(); ok {
t.Fatal("repository attempt has a total deadline")
}
return raw, nil
})
if err != nil || value != testSource {
t.Fatalf("value=%s error=%v", value, err)
}
}
+27 -2
View File
@@ -1,12 +1,37 @@
package bt
import (
"time"
"github.com/GopeedLab/gopeed/internal/fetcher"
)
type trackerSubscriptionStatus struct {
Success bool `json:"success"`
TrackerCount int `json:"trackerCount"`
LastFetchTime time.Time `json:"lastFetchTime"`
}
type state struct {
TrackerSubscriptionStatuses map[string]trackerSubscriptionStatus `json:"trackerSubscriptionStatuses"`
TrackerSources map[string][]string `json:"trackerSources"`
LastTrackerUpdateTime time.Time `json:"lastTrackerUpdateTime"`
}
type config struct {
ListenPort int `json:"listenPort"`
Trackers []string `json:"trackers"`
fetcher.ConfigState[state]
TrackerSubscribeURLs []string `json:"trackerSubscribeUrls"`
AutoUpdateTrackers bool `json:"autoUpdateTrackers"`
CustomTrackers []string `json:"customTrackers"`
ListenPort int `json:"listenPort"`
// SeedKeep is always keep seeding after downloading is complete, unless manually stopped.
SeedKeep bool `json:"seedKeep"`
// SeedRatio is the ratio of uploaded data to downloaded data to seed.
SeedRatio float64 `json:"seedRatio"`
// SeedTime is the time in seconds to seed after downloading is complete.
SeedTime int64 `json:"seedTime"`
// DisableDHT and DisablePEX turn off DHT and PEX peer discovery. Like ListenPort,
// they are read when the shared client is built.
DisableDHT bool `json:"disableDht"`
DisablePEX bool `json:"disablePex"`
}
+464
View File
@@ -0,0 +1,464 @@
package bt
import (
"encoding/json"
"net/url"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/GopeedLab/gopeed/internal/controller"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/pkg/base"
btprotocol "github.com/GopeedLab/gopeed/pkg/protocol/bt"
"github.com/anacrolix/torrent"
pp "github.com/anacrolix/torrent/peer_protocol"
"golang.org/x/time/rate"
)
type btDownloadHarness struct {
*Fetcher
pendingRequests atomic.Int64
}
// These tests exercise the BT fetcher, native storage and real peer protocol.
// All content is generated locally and served by a loopback seeder.
func btDownloadFetcher(t *testing.T, f fetcher.Fetcher) *btDownloadHarness {
t.Helper()
if f == nil {
f = new(FetcherManager).Build()
}
ctl := controller.NewController()
ctl.GetConfig = func(v any) {
encoded, err := json.Marshal(new(FetcherManager).DefaultConfig())
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(encoded, v); err != nil {
t.Fatal(err)
}
}
f.Setup(ctl)
t.Cleanup(func() {
if err := f.Close(); err != nil {
t.Error(err)
}
if err := closeClient(); err != nil {
t.Error(err)
}
})
h := &btDownloadHarness{Fetcher: f.(*Fetcher)}
// Keep the test client on loopback and observe real cancellation replies.
// Waiting for them avoids the pinned library's immediate cancel/resume
// request scheduling bug, without relying on arbitrary sleep durations.
cfg = torrent.TestingConfig(t)
cfg.DisableTrackers = false
cfg.Seed = true
cfg.DisableUTP = true
cfg.DisableIPv6 = true
cfg.DisableWebtorrent = true
cfg.KeepAliveTimeout = time.Minute
cfg.MaxAllocPeerRequestDataPerConn = 1 << 20
cfg.Callbacks.SentRequest = []func(torrent.PeerRequestEvent){func(torrent.PeerRequestEvent) {
h.pendingRequests.Add(1)
}}
cfg.Callbacks.ReadMessage = func(_ *torrent.PeerConn, msg *pp.Message) {
if !msg.Keepalive && (msg.Type == pp.Piece || msg.Type == pp.Reject) {
h.pendingRequests.Add(-1)
}
}
var err error
client, err = torrent.NewClient(cfg)
if err != nil {
t.Fatal(err)
}
return h
}
func btResolve(t *testing.T, f *btDownloadHarness, source string) {
t.Helper()
if err := f.Resolve(&base.Request{URL: source}); err != nil {
t.Fatal(err)
}
}
func btStart(t *testing.T, f *btDownloadHarness) {
t.Helper()
if err := f.Start(); err != nil {
t.Fatal(err)
}
}
func btAwaitDone(t *testing.T, f *btDownloadHarness) {
t.Helper()
done := make(chan error, 1)
go func() { done <- f.Wait() }()
timeout := time.NewTimer(20 * time.Second)
defer timeout.Stop()
select {
case err := <-done:
if err != nil {
t.Fatalf("BT download failed: %v", err)
}
if !f.isDone() {
t.Fatal("Wait returned before selected files were verified")
}
case <-timeout.C:
var status strings.Builder
client.WriteStatus(&status)
t.Log(status.String())
t.Fatalf("BT download did not finish: progress=%v stats=%+v pieces=%+v peers=%+v", f.Progress(), f.torrent.Stats(), f.torrent.PieceStateRuns(), f.Stats().Runtime)
}
}
func btAwaitPartial(t *testing.T, f *btDownloadHarness) {
t.Helper()
timeout := time.NewTimer(15 * time.Second)
defer timeout.Stop()
ticker := time.NewTicker(20 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ticker.C:
progress := f.Progress().TotalDownloaded()
if progress >= 32*1024 && progress < f.Meta().Res.Size {
return
}
case <-timeout.C:
t.Fatal("BT transfer did not reach partial progress")
}
}
}
func btPause(t *testing.T, f *btDownloadHarness) {
t.Helper()
if err := f.Pause(); err != nil {
t.Fatal(err)
}
timeout := time.NewTimer(5 * time.Second)
defer timeout.Stop()
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for {
if f.pendingRequests.Load() == 0 {
// The message callback runs under the client lock. Acquiring that
// lock ensures the final cancellation reply has finished processing.
client.Stats()
return
}
select {
case <-ticker.C:
case <-timeout.C:
t.Fatal("peer did not acknowledge cancelled requests")
}
}
}
func TestBTDownloadSources(t *testing.T) {
for _, kind := range []string{"magnet", "torrent", "file URI", "data URI"} {
t.Run(kind, func(t *testing.T) {
fixture := newBTFixture(t, map[string][]byte{"": btPayload(96*1024, kind)}, 0)
source := fixture.Magnet
switch kind {
case "torrent":
source = fixture.TorrentFile
case "file URI":
source = fixture.FileURI()
case "data URI":
source = fixture.DataURI
}
f := btDownloadFetcher(t, nil)
btResolve(t, f, source)
if f.Meta().Opts != nil {
t.Fatal("Resolve assigned download options")
}
f.storage.mu.RLock()
bound := f.storage.owner != nil
f.storage.mu.RUnlock()
if bound {
t.Fatal("Resolve bound download storage")
}
resolvedTorrent := f.torrent
dir := filepath.Join(t.TempDir(), "chosen-after-resolve")
f.Meta().Opts = &base.Options{Path: dir}
if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Fatalf("output directory exists before Start: %v", err)
}
btStart(t, f)
if f.torrent != resolvedTorrent {
t.Fatal("Start replaced the resolved torrent")
}
btAwaitDone(t, f)
fixture.AssertFiles(t, dir)
})
}
}
func TestBTDirectDownloadWithExtraTrackers(t *testing.T) {
for _, source := range []string{"request", "config", "subscription"} {
t.Run(source, func(t *testing.T) {
fixture := makeBTFixture(t, map[string][]byte{
"a.bin": btPayload(20*1024, "direct-a"),
"nested/b.bin": btPayload(48*1024, "direct-b"),
}, 0, false, false)
magnet, err := url.Parse(fixture.Magnet)
if err != nil {
t.Fatal(err)
}
query := magnet.Query()
query.Del("tr")
query.Del("x.pe")
magnet.RawQuery = query.Encode()
req := &base.Request{URL: magnet.String()}
f := btDownloadFetcher(t, nil)
if source == "request" {
req.Extra = &btprotocol.ReqExtra{Trackers: []string{fixture.TrackerURL}}
} else {
backendConfig := new(FetcherManager).DefaultConfig().(*config)
if source == "config" {
backendConfig.CustomTrackers = []string{fixture.TrackerURL}
} else {
backendConfig.TrackerSubscribeURLs = []string{"https://subscription.example/list"}
backendConfig.State.TrackerSources[backendConfig.TrackerSubscribeURLs[0]] = []string{fixture.TrackerURL}
}
f.ctl.GetConfig = func(dst any) {
raw, err := json.Marshal(backendConfig)
if err != nil {
t.Error(err)
return
}
if err := json.Unmarshal(raw, dst); err != nil {
t.Error(err)
}
}
}
// CreateDirect supplies options before Downloader resolves the task.
dir := filepath.Join(t.TempDir(), "direct-download")
opts := &base.Options{Path: dir, SelectFiles: []int{}}
f.Meta().Opts = opts
resolved := make(chan error, 1)
go func() { resolved <- f.Resolve(req) }()
select {
case err := <-resolved:
if err != nil {
t.Fatal(err)
}
case <-time.After(5 * time.Second):
f.torrentDropFunc()
<-resolved
t.Fatal("direct magnet download did not obtain metadata from its extra tracker")
}
if f.Meta().Opts != opts || len(opts.SelectFiles) != len(fixture.Files) {
t.Fatal("resolution lost direct download options or did not select all files")
}
btStart(t, f)
btAwaitDone(t, f)
fixture.AssertFiles(t, dir)
})
}
}
func TestBTDownloadSelectedFiles(t *testing.T) {
fixture := newBTFixture(t, map[string][]byte{
"a.bin": btPayload(20*1024, "a"), "nested/b.bin": btPayload(48*1024, "b"),
}, 0)
f := btDownloadFetcher(t, nil)
dir := t.TempDir()
btResolve(t, f, fixture.Magnet)
selected := -1
for i, file := range f.Meta().Res.Files {
if file.Name == "b.bin" {
selected = i
}
}
if selected < 0 {
t.Fatal("selected file absent from metadata")
}
f.Meta().Opts = &base.Options{Path: dir}
if err := f.Patch(nil, &base.Options{SelectFiles: []int{selected}}); err != nil {
t.Fatal(err)
}
btStart(t, f)
btAwaitDone(t, f)
fixture.AssertFiles(t, dir, filepath.Join("bundle", "nested", "b.bin"))
if _, err := os.Stat(filepath.Join(dir, "bundle", "a.bin")); !os.IsNotExist(err) {
t.Fatalf("unselected output remains: %v", err)
}
if f.Meta().Res.Size != 48*1024 {
t.Fatal("selected resource size is incorrect")
}
}
func TestBTPauseAndResume(t *testing.T) {
fixture := newBTFixture(t, map[string][]byte{"": btPayload(512*1024, "pause")}, 64*1024)
f := btDownloadFetcher(t, nil)
dir := t.TempDir()
btResolve(t, f, fixture.Magnet)
f.Meta().Opts = &base.Options{Path: dir}
btStart(t, f)
btAwaitPartial(t, f)
btPause(t, f)
resolvedTorrent := f.torrent
fixture.Limiter.SetLimit(rate.Inf)
// Once cancelled requests have settled, no more data should arrive.
stats := f.torrent.Stats()
readWhilePaused := stats.BytesReadData.Int64()
time.Sleep(200 * time.Millisecond)
stats = f.torrent.Stats()
if stats.BytesReadData.Int64() != readWhilePaused || f.isDone() {
t.Fatal("data download continued while paused")
}
btStart(t, f)
if f.torrent != resolvedTorrent {
t.Fatal("resume replaced the resolved torrent")
}
btAwaitDone(t, f)
fixture.AssertFiles(t, dir)
}
type btFetcherCheckpoint struct {
Meta *fetcher.FetcherMeta `json:"meta"`
Data json.RawMessage `json:"data"`
}
func btStore(t *testing.T, f *btDownloadHarness) []byte {
t.Helper()
data, err := new(FetcherManager).Store(f.Fetcher)
if err != nil {
t.Fatal(err)
}
encodedData, err := json.Marshal(data)
if err != nil {
t.Fatal(err)
}
encoded, err := json.Marshal(btFetcherCheckpoint{Meta: f.Meta(), Data: encodedData})
if err != nil {
t.Fatal(err)
}
return encoded
}
func btRestore(t *testing.T, encoded []byte) *btDownloadHarness {
t.Helper()
var checkpoint btFetcherCheckpoint
if err := json.Unmarshal(encoded, &checkpoint); err != nil {
t.Fatal(err)
}
data, restore := new(FetcherManager).Restore()
if err := json.Unmarshal(checkpoint.Data, data); err != nil {
t.Fatal(err)
}
return btDownloadFetcher(t, restore(checkpoint.Meta, data))
}
func btCloseForRestart(t *testing.T, f *btDownloadHarness) {
t.Helper()
if err := f.Close(); err != nil {
t.Fatal(err)
}
if err := new(FetcherManager).Close(); err != nil {
t.Fatal(err)
}
}
func TestBTColdStartResume(t *testing.T) {
for _, kind := range []string{"v1", "hybrid"} {
t.Run(kind, func(t *testing.T) {
var fixture *btFixture
if kind == "hybrid" {
fixture = newBTHybridFixture(t, btPayload(512*1024, "restart-hybrid"))
fixture.Limiter.SetLimit(64 * 1024)
} else {
fixture = newBTFixture(t, map[string][]byte{"": btPayload(512*1024, "restart")}, 64*1024)
}
f := btDownloadFetcher(t, nil)
dir := t.TempDir()
btResolve(t, f, fixture.Magnet)
f.Meta().Opts = &base.Options{Path: dir}
btStart(t, f)
btAwaitPartial(t, f)
btPause(t, f)
checkpoint := btStore(t, f)
hash := f.Meta().Res.Hash
btCloseForRestart(t, f)
seedStats := fixture.Torrent.Stats()
sentBeforeRestart := seedStats.BytesWrittenData.Int64()
restored := btRestore(t, checkpoint)
if restored.Meta().Opts.Path != dir || restored.Meta().Res.Hash != hash {
t.Fatal("download directory or hash was lost")
}
fixture.Limiter.SetLimit(rate.Inf)
btStart(t, restored)
btAwaitDone(t, restored)
fixture.AssertFiles(t, dir)
seedStats = fixture.Torrent.Stats()
if sent := seedStats.BytesWrittenData.Int64() - sentBeforeRestart; sent >= 512*1024 {
t.Fatalf("restart redownloaded the entire file: %d bytes", sent)
}
})
}
}
func TestBTDownloadHybrid(t *testing.T) {
fixture := newBTHybridFixture(t, btPayload(80*1024, "hybrid-v2"))
f := btDownloadFetcher(t, nil)
dir := t.TempDir()
btResolve(t, f, fixture.Magnet)
f.Meta().Opts = &base.Options{Path: dir}
btStart(t, f)
btAwaitDone(t, f)
fixture.AssertFiles(t, dir)
}
func TestBTRepairCorruptedExistingFile(t *testing.T) {
fixture := newBTFixture(t, map[string][]byte{"": btPayload(128*1024, "repair")}, 0)
dir := t.TempDir()
damaged := append([]byte(nil), fixture.Files["payload.bin"]...)
damaged[20000] ^= 0xff
if err := os.WriteFile(filepath.Join(dir, "payload.bin"), damaged, 0600); err != nil {
t.Fatal(err)
}
f := btDownloadFetcher(t, nil)
btResolve(t, f, fixture.Magnet)
f.Meta().Opts = &base.Options{Path: dir}
btStart(t, f)
btAwaitDone(t, f)
fixture.AssertFiles(t, dir)
}
func TestBTPatchSelectionWhilePaused(t *testing.T) {
fixture := newBTFixture(t, map[string][]byte{"a.bin": btPayload(256*1024, "patch-a"), "b.bin": btPayload(256*1024, "patch-b")}, 64*1024)
f := btDownloadFetcher(t, nil)
dir := t.TempDir()
btResolve(t, f, fixture.Magnet)
f.Meta().Opts = &base.Options{Path: dir, SelectFiles: []int{0}}
btStart(t, f)
btAwaitPartial(t, f)
btPause(t, f)
if err := f.Patch(nil, &base.Options{SelectFiles: []int{1}}); err != nil {
t.Fatal(err)
}
fixture.Limiter.SetLimit(rate.Inf)
btStart(t, f)
btAwaitDone(t, f)
fixture.AssertFiles(t, dir, filepath.Join("bundle", "b.bin"))
for _, suffix := range []string{"", ".part"} {
if _, err := os.Stat(filepath.Join(dir, "bundle", "a.bin") + suffix); !os.IsNotExist(err) {
t.Fatalf("unselected output remains: %v", err)
}
}
}
func TestBTDownloadMultiFileWithEmptyFile(t *testing.T) {
fixture := newBTFixture(t, map[string][]byte{"empty.txt": {}, "nested/data.bin": btPayload(48*1024, "empty-file")}, 0)
f := btDownloadFetcher(t, nil)
dir := t.TempDir()
btResolve(t, f, fixture.Magnet)
f.Meta().Opts = &base.Options{Path: dir}
btStart(t, f)
btAwaitDone(t, f)
fixture.AssertFiles(t, dir)
}
+234 -110
View File
@@ -3,11 +3,13 @@ package bt
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"sync/atomic"
@@ -33,6 +35,10 @@ var (
)
type Fetcher struct {
mu sync.Mutex
closeOnce sync.Once
storage *deferredFileStorage
ctl *controller.Controller
config *config
@@ -77,6 +83,8 @@ func (f *Fetcher) initClient() (err error) {
cfg.Bep20 = fmt.Sprintf("-GP%s-", parseBep20())
cfg.ExtendedHandshakeClientVersion = fmt.Sprintf("Gopeed %s", base.Version)
cfg.ListenPort = f.config.ListenPort
cfg.NoDHT = f.config.DisableDHT
cfg.DisablePEX = f.config.DisablePEX
cfg.HTTPProxy = f.ctl.GetProxy(f.meta.Req.Proxy)
dnsResolver := &DnsCacheResolver{RefreshTimeout: 5 * time.Minute}
cfg.TrackerDialContext = dnsResolver.DialContext
@@ -92,13 +100,17 @@ func (f *Fetcher) initClient() (err error) {
return
}
func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
f.meta.Req = req
f.meta.Opts = opts
if f.meta.Opts == nil {
f.meta.Opts = &base.Options{}
func (f *Fetcher) Resolve(req *base.Request) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.torrent != nil {
f.safeDrop()
f.torrent = nil
f.storage = nil
f.torrentReady.Store(false)
}
if err := f.addTorrent(req, false); err != nil {
f.meta.Req = req
if err := f.addTorrent(req); err != nil {
return err
}
f.updateRes()
@@ -106,12 +118,72 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
}
func (f *Fetcher) Start() (err error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.meta.Opts == nil || f.meta.Opts.Path == "" {
return errors.New("download directory is required when starting a torrent")
}
if err := f.torrentDropCtx.Err(); err != nil {
return err
}
if !f.torrentReady.Load() {
if err = f.addTorrent(f.meta.Req, false); err != nil {
if err = f.addTorrent(f.meta.Req); err != nil {
return
}
}
path := f.meta.Opts.Path
if err := os.MkdirAll(path, 0755); err != nil {
return err
}
hashes, err := f.storage.bind(storage.NewFileClientOpts{
ClientBaseDir: path,
FilePathMaker: func(opts storage.FilePathMakerOpts) string {
_, name := torrentFileLayout(opts.Info, *opts.File)
return name
},
})
if err != nil {
return err
}
if hashes != nil {
// File storage infers completion from final file names and sizes. Verify
// before publishing that state, or peers can see an unverified file as
// complete and disconnect before a damaged piece is discovered.
// Late v2 hashes schedule their own initial checks through the library.
changes := f.torrent.SubscribePieceStateChanges()
defer changes.Close()
for i, known := range hashes {
if !known {
continue
}
if err := f.torrentDropCtx.Err(); err != nil {
return err
}
if err := f.torrent.Piece(i).VerifyDataContext(f.torrentDropCtx); err != nil {
return err
}
// VerifyDataContext can return while the result is still being
// written to storage. Wait for it before refreshing completion.
for {
state := f.torrent.Piece(i).State()
if !state.Hashing && !state.QueuedForHash && !state.Marking {
break
}
select {
case <-changes.Values:
case <-f.torrentDropCtx.Done():
return f.torrentDropCtx.Err()
case <-f.torrent.Closed():
return errors.New("torrent closed during verification")
}
}
}
for i := range hashes {
f.torrent.Piece(i).UpdateCompletion()
}
}
files := f.torrent.Files()
// If the user does not specify the file to download, all files will be downloaded by default
if f.data.Progress == nil {
@@ -132,21 +204,29 @@ func (f *Fetcher) Start() (err error) {
}
}
f.torrent.AllowDataDownload()
go f.doUpload(false)
return
}
func (f *Fetcher) Pause() (err error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.torrent == nil {
return nil
}
f.torrent.DisallowDataDownload()
return
}
func (f *Fetcher) Close() (err error) {
f.safeDrop()
f.torrentDropFunc()
f.uploadDoneCh <- nil
if len(client.Torrents()) == 0 {
err = closeClient()
}
f.closeOnce.Do(func() {
f.torrentDropFunc()
f.mu.Lock()
defer f.mu.Unlock()
f.safeDrop()
f.torrentReady.Store(false)
close(f.uploadDoneCh)
})
return nil
}
@@ -164,6 +244,8 @@ func (f *Fetcher) Meta() *fetcher.FetcherMeta {
}
func (f *Fetcher) Stats() *fetcher.Stats {
f.mu.Lock()
defer f.mu.Unlock()
var stats torrent.TorrentStats
var pieceMap *base.PieceMap
pieceMapReady := false
@@ -370,48 +452,65 @@ func normalizeBTTransport(network string) string {
}
func (f *Fetcher) Progress() fetcher.Progress {
f.mu.Lock()
defer f.mu.Unlock()
if !f.torrentReady.Load() {
return f.data.Progress
return append(fetcher.Progress(nil), f.data.Progress...)
}
for i := range f.data.Progress {
selectIndex := f.meta.Opts.SelectFiles[i]
file := f.torrent.Files()[selectIndex]
f.data.Progress[i] = file.BytesCompleted()
}
return f.data.Progress
return append(fetcher.Progress(nil), f.data.Progress...)
}
func (f *Fetcher) Wait() (err error) {
func (f *Fetcher) Wait() error {
f.mu.Lock()
var closed <-chan struct{}
if f.torrent != nil {
closed = f.torrent.Closed()
}
f.mu.Unlock()
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-f.torrentDropCtx.Done():
return
case <-time.After(time.Second):
if f.torrentReady.Load() && len(f.meta.Opts.SelectFiles) > 0 {
if f.isDone() {
// remove unselected files
for i, file := range f.torrent.Files() {
selected := false
for _, selectIndex := range f.meta.Opts.SelectFiles {
if i == selectIndex {
selected = true
break
}
}
if !selected {
root, name := torrentFileLayout(f.torrent.Info(), file.FileInfo())
if err := removeUnselectedFile(f.meta.Opts.Path, root, name); err != nil {
return err
}
}
}
return
}
return nil
case <-closed:
return nil
case <-ticker.C:
done, err := f.finishDownload()
if done || err != nil {
return err
}
}
}
}
func (f *Fetcher) finishDownload() (bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
if !f.torrentReady.Load() || f.meta.Opts == nil || len(f.meta.Opts.SelectFiles) == 0 || !f.isDoneLocked() {
return false, nil
}
selected := make(map[int]bool, len(f.meta.Opts.SelectFiles))
for _, i := range f.meta.Opts.SelectFiles {
selected[i] = true
}
for i, file := range f.torrent.Files() {
if selected[i] {
continue
}
root, name := torrentFileLayout(f.torrent.Info(), file.FileInfo())
if err := removeUnselectedFile(f.meta.Opts.Path, root, name); err != nil {
return false, err
}
}
return true, nil
}
// Match the file storage layout, including its NoName convention for rootless
// torrents. File.Path() always prepends BestName, even when storage omits it.
func torrentFileLayout(info *metainfo.Info, file metainfo.FileInfo) (root, name string) {
@@ -469,13 +568,22 @@ func removeUnselectedFile(downloadDir, torrentRoot, torrentPath string) error {
}
func (f *Fetcher) isDone() bool {
if f.meta.Opts == nil {
f.mu.Lock()
defer f.mu.Unlock()
return f.isDoneLocked()
}
func (f *Fetcher) isDoneLocked() bool {
if f.meta.Opts == nil || f.torrent == nil {
return false
}
for _, selectIndex := range f.meta.Opts.SelectFiles {
file := f.torrent.Files()[selectIndex]
if file.BytesCompleted() < file.Length() {
return false
for _, index := range f.meta.Opts.SelectFiles {
file := f.torrent.Files()[index]
for i := file.BeginPieceIndex(); i < file.EndPieceIndex(); i++ {
state := f.torrent.Piece(i).State()
if !state.Ok || !state.Complete || state.Hashing || state.QueuedForHash {
return false
}
}
}
return true
@@ -484,6 +592,11 @@ func (f *Fetcher) isDone() bool {
// Patch modifies the BT task settings.
// Invalid file indices are silently ignored.
func (f *Fetcher) Patch(req *base.Request, opts *base.Options) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.meta.Opts == nil {
f.meta.Opts = &base.Options{}
}
if opts == nil {
return nil
}
@@ -540,9 +653,9 @@ func (f *Fetcher) updateRes() {
Files: make([]*base.FileInfo, len(f.torrent.Files())),
Hash: f.torrent.InfoHash().String(),
}
// Directory torrent
if f.torrent.Info().Length == 0 {
res.Name = f.torrent.Name()
// Use the same root classification as storage (v2 single files have Length == 0).
if files := f.torrent.Files(); len(files) > 0 {
res.Name, _ = torrentFileLayout(f.torrent.Info(), files[0].FileInfo())
}
for i, file := range f.torrent.Files() {
res.Files[i] = &base.FileInfo{
@@ -559,61 +672,50 @@ func (f *Fetcher) updateRes() {
}
func (f *Fetcher) Upload() (err error) {
return f.addTorrent(f.meta.Req, true)
if err = f.Start(); err != nil {
return err
}
return nil
}
func (f *Fetcher) doUpload(fromUpload bool) {
if !f.torrentUpload.CompareAndSwap(false, true) {
return
}
// Check and update seed data
lastData := &fetcherData{
SeedBytes: f.data.SeedBytes,
SeedTime: f.data.SeedTime,
}
var doneTime int64 = 0
f.mu.Lock()
initialBytes, initialTime := f.data.SeedBytes, f.data.SeedTime
torrentClosed := f.torrent.Closed()
f.mu.Unlock()
var doneTime int64
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-f.torrentDropCtx.Done():
return
case <-time.After(time.Second):
case <-torrentClosed:
return
case <-ticker.C:
f.mu.Lock()
if !f.torrentReady.Load() {
f.mu.Unlock()
continue
}
stats := f.torrentStats()
f.data.SeedBytes = lastData.SeedBytes + stats.BytesWrittenData.Int64()
// Check is download complete, if not don't check and stop seeding
if !fromUpload && !f.isDone() {
f.data.SeedBytes = initialBytes + stats.BytesWrittenData.Int64()
if !fromUpload && !f.isDoneLocked() {
f.mu.Unlock()
continue
}
if doneTime == 0 {
doneTime = time.Now().Unix()
}
f.data.SeedTime = lastData.SeedTime + time.Now().Unix() - doneTime
// If the seed forever is true, keep seeding
if f.config.SeedKeep {
continue
}
// If the seed ratio is reached, stop seeding
if f.config.SeedRatio > 0 {
seedRadio := f.seedRadio()
if seedRadio >= f.config.SeedRatio {
f.Close()
break
}
}
// If the seed time is reached, stop seeding
if f.config.SeedTime > 0 {
if f.data.SeedTime >= f.config.SeedTime {
f.Close()
break
}
f.data.SeedTime = initialTime + time.Now().Unix() - doneTime
stop := !f.config.SeedKeep && ((f.config.SeedRatio > 0 && f.seedRadio() >= f.config.SeedRatio) || (f.config.SeedTime > 0 && f.data.SeedTime >= f.config.SeedTime))
f.mu.Unlock()
if stop {
f.Close()
return
}
}
}
@@ -631,6 +733,8 @@ func (f *Fetcher) torrentStats() torrent.TorrentStats {
}
func (f *Fetcher) UploadedBytes() int64 {
f.mu.Lock()
defer f.mu.Unlock()
return f.data.SeedBytes
}
@@ -639,7 +743,8 @@ func (f *Fetcher) WaitUpload() (err error) {
return nil
}
func (f *Fetcher) addTorrent(req *base.Request, fromUpload bool) (err error) {
func (f *Fetcher) addTorrent(req *base.Request) (err error) {
f.ctl.GetConfig(&f.config)
if err = base.ParseReqExtra[bt.ReqExtra](req); err != nil {
return
}
@@ -658,11 +763,16 @@ func (f *Fetcher) addTorrent(req *base.Request, fromUpload bool) (err error) {
var reader io.Reader
if schema == "FILE" {
fileUrl, _ := url.Parse(req.URL)
filePath := fileUrl.Path[1:]
filePath := fileUrl.Path
if runtime.GOOS == "windows" && len(filePath) > 2 && filePath[0] == '/' && filePath[2] == ':' {
filePath = filePath[1:]
}
filePath = filepath.FromSlash(filePath)
reader, err = os.Open(filePath)
if err != nil {
return
}
defer reader.(io.Closer).Close()
} else if schema == "DATA" {
_, data := util.ParseDataUri(req.URL)
reader = bytes.NewBuffer(data)
@@ -695,21 +805,6 @@ func (f *Fetcher) addTorrent(req *base.Request, fromUpload bool) (err error) {
return
}
}
spec.Storage = storage.NewFileOpts(storage.NewFileClientOpts{
ClientBaseDir: cfg.DataDir,
FilePathMaker: func(opts storage.FilePathMakerOpts) string {
_, name := torrentFileLayout(opts.Info, *opts.File)
return name
},
TorrentDirMaker: func(baseDir string, info *metainfo.Info, infoHash metainfo.Hash) string {
return f.meta.Opts.Path
},
})
f.torrent, _, err = client.AddTorrentSpec(spec)
if err != nil {
return
}
// Do not add external tracker to a private torrent.
if !privateTorrent {
// use map to deduplicate
@@ -722,23 +817,40 @@ func (f *Fetcher) addTorrent(req *base.Request, fromUpload bool) (err error) {
}
}
}
if len(f.config.Trackers) > 0 {
for _, tracker := range f.config.Trackers {
trackers[tracker] = true
}
for _, tracker := range f.config.effectiveTrackers() {
trackers[tracker] = true
}
if len(trackers) > 0 {
announceList := make([][]string, 0)
for tracker := range trackers {
announceList = append(announceList, []string{tracker})
spec.Trackers = append(spec.Trackers, []string{tracker})
}
f.torrent.AddTrackers(announceList)
}
}
<-f.torrent.GotInfo()
s := &deferredFileStorage{}
spec.Storage = s
var added bool
f.torrent, added, err = client.AddTorrentSpec(spec)
if err != nil {
return err
}
if !added {
f.torrent = nil
return errors.New("torrent is already active")
}
f.storage = s
f.torrent.DisallowDataDownload()
// Extra trackers must be available while magnet metadata is still missing;
// they may be the only way to discover a peer that can provide it.
select {
case <-f.torrent.GotInfo():
case <-f.torrentDropCtx.Done():
f.safeDrop()
return f.torrentDropCtx.Err()
case <-f.torrent.Closed():
return errors.New("torrent closed during resolution")
}
f.torrentReady.Store(true)
go f.doUpload(fromUpload)
return
}
@@ -834,17 +946,29 @@ func (fm *FetcherManager) AutoRename() bool {
func (fm *FetcherManager) DefaultConfig() any {
return &config{
TrackerSubscribeURLs: append([]string{}, defaultTrackerSubscribeURLs...),
AutoUpdateTrackers: true,
CustomTrackers: []string{},
ConfigState: fetcher.ConfigState[state]{State: state{
TrackerSources: map[string][]string{},
TrackerSubscriptionStatuses: map[string]trackerSubscriptionStatus{},
}},
ListenPort: 0,
Trackers: []string{},
SeedKeep: false,
SeedRatio: 1.0,
SeedTime: 120 * 60,
DisableDHT: false,
DisablePEX: false,
}
}
func (fm *FetcherManager) Store(f fetcher.Fetcher) (data any, err error) {
_f := f.(*Fetcher)
return _f.data, nil
_f.mu.Lock()
defer _f.mu.Unlock()
snapshot := *_f.data
snapshot.Progress = append(fetcher.Progress(nil), snapshot.Progress...)
return &snapshot, nil
}
func (fm *FetcherManager) Restore() (v any, f func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher) {
+68 -17
View File
@@ -18,11 +18,11 @@ import (
)
func TestFetcher_Resolve_Torrent(t *testing.T) {
doResolve(t, buildFetcher())
doResolve(t, buildFetcher(t))
}
func TestFetcher_Resolve_DataUri_Torrent(t *testing.T) {
fetcher := buildFetcher()
fetcher := buildFetcher(t)
buf, err := os.ReadFile("./testdata/ubuntu-22.04-live-server-amd64.iso.torrent")
if err != nil {
t.Fatal(err)
@@ -31,7 +31,7 @@ func TestFetcher_Resolve_DataUri_Torrent(t *testing.T) {
dataUri := "data:application/x-bittorrent;base64," + base64.StdEncoding.EncodeToString(buf)
err = fetcher.Resolve(&base.Request{
URL: dataUri,
}, nil)
})
if err != nil {
panic(err)
}
@@ -53,7 +53,7 @@ func TestFetcher_Resolve_DataUri_Torrent(t *testing.T) {
}
func TestFetcher_Config(t *testing.T) {
doResolve(t, buildConfigFetcher(nil))
doResolve(t, buildConfigFetcher(t, nil))
}
func TestFetcher_ResolveWithProxy(t *testing.T) {
@@ -61,7 +61,7 @@ func TestFetcher_ResolveWithProxy(t *testing.T) {
proxyListener := test.StartSocks5Server(usr, pwd)
defer proxyListener.Close()
doResolve(t, buildConfigFetcher(&base.DownloaderProxyConfig{
doResolve(t, buildConfigFetcher(t, &base.DownloaderProxyConfig{
Enable: true,
System: false,
Scheme: "socks5",
@@ -81,7 +81,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
"udp://tracker.bitsearch.to:1337/announce",
},
},
}, nil)
})
if err != nil {
panic(err)
}
@@ -111,7 +111,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
"udp://tracker.bitsearch.to:1337/announce",
},
},
}, nil)
})
if err != nil {
panic(err)
}
@@ -145,7 +145,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
t.Run("Resolve Unclean Torrent", func(t *testing.T) {
err := fetcher.Resolve(&base.Request{
URL: "./testdata/test.unclean.torrent",
}, nil)
})
if err != nil {
t.Errorf("Resolve Unclean Torrent Resolve() got = %v, want nil", err)
}
@@ -156,7 +156,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
uri := "file:///" + file
err := fetcher.Resolve(&base.Request{
URL: uri,
}, nil)
})
if err != nil {
t.Errorf("Resolve file scheme Torrent Resolve() got = %v, want nil", err)
}
@@ -211,7 +211,7 @@ func TestFetcherManager_ParseName(t *testing.T) {
}
}
func buildFetcher() fetcher.Fetcher {
func buildFetcher(t testing.TB) fetcher.Fetcher {
fb := new(FetcherManager)
fetcher := fb.Build()
newController := controller.NewController()
@@ -219,14 +219,15 @@ func buildFetcher() fetcher.Fetcher {
json.Unmarshal([]byte(test.ToJson(fb.DefaultConfig())), v)
}
fetcher.Setup(newController)
t.Cleanup(func() { fetcher.Close(); closeClient() })
return fetcher
}
func buildConfigFetcher(proxyConfig *base.DownloaderProxyConfig) fetcher.Fetcher {
func buildConfigFetcher(t testing.TB, proxyConfig *base.DownloaderProxyConfig) fetcher.Fetcher {
fetcher := new(FetcherManager).Build()
newController := controller.NewController()
mockCfg := config{
Trackers: []string{
CustomTrackers: []string{
"udp://tracker.birkenwald.de:6969/announce",
"udp://tracker.bitsearch.to:1337/announce",
}}
@@ -237,29 +238,30 @@ func buildConfigFetcher(proxyConfig *base.DownloaderProxyConfig) fetcher.Fetcher
return proxyConfig.ToHandler()
}
fetcher.Setup(newController)
t.Cleanup(func() { fetcher.Close(); closeClient() })
return fetcher
}
// TestFetcher_Patch tests the Patch functionality for BT fetcher.
// It tests modifying selected files after Resolve (without downloading).
func TestFetcher_Patch(t *testing.T) {
f := buildFetcher()
f := buildFetcher(t)
// Resolve a multi-file torrent
err := f.Resolve(&base.Request{
URL: "./testdata/test.torrent",
}, nil)
})
if err != nil {
t.Fatalf("Resolve failed: %v", err)
}
// Verify initial state: 3 files, all selected by default
// Resolution describes files without choosing download options.
meta := f.Meta()
if len(meta.Res.Files) != 3 {
t.Fatalf("Expected 3 files, got %d", len(meta.Res.Files))
}
if len(meta.Opts.SelectFiles) != 3 {
t.Fatalf("Expected 3 selected files, got %d", len(meta.Opts.SelectFiles))
if meta.Opts != nil {
t.Fatal("Resolve should not assign download options")
}
// Total size of all files: 107484864
@@ -366,3 +368,52 @@ func TestFetcher_Patch(t *testing.T) {
}
})
}
func TestFetcher_PeerDiscoveryConfig(t *testing.T) {
tests := []struct {
name string
stored any
wantDisableDHT bool
wantDisablePEX bool
}{
{"default config", new(FetcherManager).DefaultConfig(), false, false},
{"stored config without the fields", map[string]any{"listenPort": 0, "seedRatio": 1.0}, false, false},
{"both disabled", map[string]any{"disableDht": true, "disablePex": true}, true, true},
{"only DHT disabled", map[string]any{"disableDht": true}, true, false},
{"only PEX disabled", map[string]any{"disablePex": true}, false, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := buildFetcherWithConfig(tt.stored).(*Fetcher)
if f.config.DisableDHT != tt.wantDisableDHT || f.config.DisablePEX != tt.wantDisablePEX {
t.Errorf("config got DisableDHT = %v, DisablePEX = %v, want %v, %v", f.config.DisableDHT, f.config.DisablePEX, tt.wantDisableDHT, tt.wantDisablePEX)
}
})
}
}
func TestFetcher_PeerDiscoveryDisabledOnClient(t *testing.T) {
if err := closeClient(); err != nil {
t.Fatal(err)
}
f := buildFetcherWithConfig(map[string]any{"disableDht": true, "disablePex": true})
if err := f.Resolve(&base.Request{URL: "./testdata/test.torrent"}); err != nil {
t.Fatal(err)
}
if !cfg.NoDHT || !cfg.DisablePEX {
t.Errorf("client config got NoDHT = %v, DisablePEX = %v, want true", cfg.NoDHT, cfg.DisablePEX)
}
if n := len(client.DhtServers()); n != 0 {
t.Errorf("client got %d DHT servers, want 0", n)
}
}
func buildFetcherWithConfig(stored any) fetcher.Fetcher {
fetcher := new(FetcherManager).Build()
newController := controller.NewController()
newController.GetConfig = func(v any) {
json.Unmarshal([]byte(test.ToJson(stored)), v)
}
fetcher.Setup(newController)
return fetcher
}
+212
View File
@@ -0,0 +1,212 @@
package bt
import (
"bytes"
"context"
"encoding/base64"
"encoding/binary"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/anacrolix/torrent"
"github.com/anacrolix/torrent/bencode"
"github.com/anacrolix/torrent/merkle"
"github.com/anacrolix/torrent/metainfo"
"golang.org/x/time/rate"
)
// btFixture serves generated, trackerless content over a real loopback TCP peer.
// Its HTTP tracker exercises .torrent downloads; magnet links also carry the
// explicit x.pe address.
type btFixture struct {
MetaInfo *metainfo.MetaInfo
Magnet string
TorrentFile string
DataURI string
TrackerURL string
Files map[string][]byte // Paths relative to the selected download directory.
Seeder *torrent.Client
Torrent *torrent.Torrent
Limiter *rate.Limiter
}
func newBTFixture(t testing.TB, files map[string][]byte, bytesPerSecond int) *btFixture {
return makeBTFixture(t, files, bytesPerSecond, false, true)
}
func newBTHybridFixture(t testing.TB, payload []byte) *btFixture {
return makeBTFixture(t, map[string][]byte{"": payload}, 0, true, true)
}
func makeBTFixture(t testing.TB, files map[string][]byte, bytesPerSecond int, v2, private bool) *btFixture {
t.Helper()
source := t.TempDir()
name := "payload.bin"
if _, single := files[""]; !single {
name = "bundle"
}
root := filepath.Join(source, name)
expected := make(map[string][]byte, len(files))
for path, data := range files {
target := filepath.Join(root, filepath.FromSlash(path))
if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(target, data, 0600); err != nil {
t.Fatal(err)
}
expected[filepath.Join(name, filepath.FromSlash(path))] = append([]byte(nil), data...)
}
info := metainfo.Info{PieceLength: 16 * 1024}
if err := info.BuildFromFilePath(root); err != nil {
t.Fatal(err)
}
var layers map[string]string
if v2 {
data := files[""]
h := merkle.NewHash()
h.Write(data)
root := h.Sum(nil)
var pieceHashes []byte
for off := 0; off < len(data); off += int(info.PieceLength) {
end := min(off+int(info.PieceLength), len(data))
ph := merkle.NewHash()
ph.Write(data[off:end])
pieceHashes = append(pieceHashes, ph.SumMinLength(nil, int(info.PieceLength))...)
}
info = metainfo.Info{Name: name, PieceLength: info.PieceLength, Length: int64(len(data)), Pieces: info.Pieces, MetaVersion: 2, FileTree: metainfo.FileTree{File: metainfo.FileTreeFile{Length: int64(len(data)), PiecesRoot: string(root)}}}
layers = map[string]string{string(root): string(pieceHashes)}
}
// Private torrents avoid public DHT discovery once metadata is available.
info.Private = &private
raw, err := bencode.Marshal(&info)
if err != nil {
t.Fatal(err)
}
mi := &metainfo.MetaInfo{InfoBytes: raw, PieceLayers: layers}
cfg := torrent.TestingConfig(t)
cfg.DataDir = source
cfg.Seed = true
cfg.DisableUTP = true
cfg.DisableIPv6 = true
cfg.DisableWebtorrent = true
cfg.KeepAliveTimeout = time.Minute
cfg.MaxAllocPeerRequestDataPerConn = 1 << 20
limiter := rate.NewLimiter(rate.Inf, 16*1024)
if bytesPerSecond > 0 {
limiter.SetLimit(rate.Limit(bytesPerSecond))
}
cfg.UploadRateLimiter = limiter
seed, err := torrent.NewClient(cfg)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { seed.Close() })
seeded, _, err := seed.AddTorrentSpec(torrent.TorrentSpecFromMetaInfo(mi))
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := seeded.VerifyDataContext(ctx); err != nil {
t.Fatal(err)
}
select {
case <-seeded.Complete().On():
case <-ctx.Done():
t.Fatal("local seeder did not finish verification")
}
addr := seed.ListenAddrs()[0].String()
host, portString, err := net.SplitHostPort(addr)
if err != nil {
t.Fatal(err)
}
resolved, err := net.ResolveTCPAddr("tcp4", net.JoinHostPort(host, portString))
if err != nil {
t.Fatal(err)
}
compact := make([]byte, 6)
copy(compact, resolved.IP.To4())
binary.BigEndian.PutUint16(compact[4:], uint16(resolved.Port))
tracker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
response, err := bencode.Marshal(map[string]any{"interval": 3600, "peers": string(compact)})
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Write(response)
}))
t.Cleanup(tracker.Close)
// The pinned library has a hybrid tracker-dispatch assertion bug.
// Hybrid fixtures use their explicit loopback peer rather than a tracker.
if !v2 {
mi.Announce = tracker.URL
}
var encoded bytes.Buffer
if err := mi.Write(&encoded); err != nil {
t.Fatal(err)
}
torrentFile := filepath.Join(t.TempDir(), "fixture.torrent")
if err := os.WriteFile(torrentFile, encoded.Bytes(), 0600); err != nil {
t.Fatal(err)
}
m, err := mi.MagnetV2()
if err != nil {
t.Fatal(err)
}
magnet := m.String()
u, err := url.Parse(magnet)
if err != nil {
t.Fatal(err)
}
q := u.Query()
q.Add("x.pe", addr)
u.RawQuery = q.Encode()
return &btFixture{MetaInfo: mi, Magnet: u.String(), TorrentFile: torrentFile, DataURI: "data:application/x-bittorrent;base64," + base64.StdEncoding.EncodeToString(encoded.Bytes()), TrackerURL: tracker.URL, Files: expected, Seeder: seed, Torrent: seeded, Limiter: limiter}
}
func btPayload(size int, label string) []byte {
data := make([]byte, size)
for i := range data {
data[i] = byte((i*31 + i/251) % 251)
}
copy(data, label)
return data
}
func (f *btFixture) AssertFiles(t testing.TB, dir string, paths ...string) {
t.Helper()
if len(paths) == 0 {
for path := range f.Files {
paths = append(paths, path)
}
}
for _, path := range paths {
got, err := os.ReadFile(filepath.Join(dir, path))
if err != nil {
t.Fatal(err)
}
want, ok := f.Files[path]
if !ok {
t.Fatalf("unknown fixture file %q", path)
}
if !bytes.Equal(got, want) {
t.Fatalf("BT file %s differs: received %d bytes, want %d", path, len(got), len(want))
}
}
}
func (f *btFixture) FileURI() string {
path := filepath.ToSlash(f.TorrentFile)
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return (&url.URL{Scheme: "file", Path: path}).String()
}
+77 -12
View File
@@ -17,25 +17,85 @@ import (
"github.com/anacrolix/torrent/storage"
)
func TestStartUsesSelectionSubmittedAfterResolve(t *testing.T) {
config := torrent.TestingConfig(t)
config.DataDir = t.TempDir()
config.DisableTCP = true
config.DisableUTP = true
c, err := torrent.NewClient(config)
func TestStartDoesNotPublishUnverifiedCompletion(t *testing.T) {
cfg := torrent.TestingConfig(t)
cfg.DisableTCP = true
cfg.DisableUTP = true
c, err := torrent.NewClient(cfg)
if err != nil {
t.Fatal(err)
}
defer c.Close()
tor, err := c.AddTorrentFromFile("testdata/test.torrent")
payload := btPayload(32*1024, "unverified")
info := metainfo.Info{Name: "payload.bin", Length: int64(len(payload)), PieceLength: 16 * 1024}
for offset := 0; offset < len(payload); offset += int(info.PieceLength) {
hash := sha1.Sum(payload[offset : offset+int(info.PieceLength)])
info.Pieces = append(info.Pieces, hash[:]...)
}
raw, err := bencode.Marshal(info)
if err != nil {
t.Fatal(err)
}
f := &Fetcher{torrent: tor, meta: &fetcher.FetcherMeta{Opts: &base.Options{}}, data: &fetcherData{}}
store := &deferredFileStorage{}
spec := torrent.TorrentSpecFromMetaInfo(&metainfo.MetaInfo{InfoBytes: raw})
spec.Storage = store
tor, _, err := c.AddTorrentSpec(spec)
if err != nil {
t.Fatal(err)
}
tor.DisallowDataDownload()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
f := &Fetcher{torrent: tor, storage: store, meta: &fetcher.FetcherMeta{}, data: &fetcherData{}, torrentDropCtx: ctx, torrentDropFunc: cancel, config: &config{SeedKeep: true}}
f.torrentReady.Store(true)
f.updateRes()
// Resolve initializes all files; creation replaces the selection before Start.
f.meta.Opts = &base.Options{SelectFiles: []int{2}}
dir := t.TempDir()
payload[20000] ^= 0xff
if err := os.WriteFile(filepath.Join(dir, info.Name), payload, 0600); err != nil {
t.Fatal(err)
}
f.meta.Opts = &base.Options{Path: dir}
// Retain the original event channel to detect even a transient completion.
// With no network transports, the damaged piece cannot have been repaired.
completed := tor.Complete().On()
if err := f.Start(); err != nil {
t.Fatal(err)
}
select {
case <-completed:
t.Fatal("Start published completion before verifying the existing file")
default:
}
}
func TestStartUsesSelectionSubmittedAfterResolve(t *testing.T) {
cfg := torrent.TestingConfig(t)
cfg.DataDir = t.TempDir()
cfg.DisableTCP = true
cfg.DisableUTP = true
c, err := torrent.NewClient(cfg)
if err != nil {
t.Fatal(err)
}
defer c.Close()
store := &deferredFileStorage{}
mi, err := metainfo.LoadFromFile("testdata/test.torrent")
if err != nil {
t.Fatal(err)
}
spec := torrent.TorrentSpecFromMetaInfo(mi)
spec.Storage = store
tor, _, err := c.AddTorrentSpec(spec)
if err != nil {
t.Fatal(err)
}
tor.DisallowDataDownload()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
f := &Fetcher{torrent: tor, storage: store, meta: &fetcher.FetcherMeta{}, data: &fetcherData{}, torrentDropCtx: ctx, torrentDropFunc: cancel, config: &config{SeedKeep: true}}
f.torrentReady.Store(true)
f.updateRes()
f.meta.Opts = &base.Options{Path: t.TempDir(), SelectFiles: []int{2}}
if err := f.Start(); err != nil {
t.Fatal(err)
}
@@ -83,10 +143,15 @@ func TestWaitCleansUnselectedFilesFromSharedPiece(t *testing.T) {
{Length: 4, Path: []string{"keep.txt"}},
{Length: 4, Path: []string{"nested", "skip.txt"}},
}}
store := storage.NewFileOpts(storage.NewFileClientOpts{
storageOpts := storage.NewFileClientOpts{
ClientBaseDir: dir,
TorrentDirMaker: func(baseDir string, _ *metainfo.Info, _ metainfo.Hash) string { return baseDir },
})
}
// VerifyDataContext can return before storage finishes marking the piece
// complete. Disable automatic part-file promotion so it cannot rename
// the .part fixtures that this test creates to exercise cleanup.
storageOpts.UsePartFiles.Set(false)
store := storage.NewFileOpts(storageOpts)
defer store.Close()
config := torrent.TestingConfig(t)
config.DisableTCP = true
+116
View File
@@ -0,0 +1,116 @@
package bt
import (
"context"
"errors"
"os"
"sync"
g "github.com/anacrolix/generics"
"github.com/anacrolix/torrent/metainfo"
"github.com/anacrolix/torrent/storage"
)
// deferredFileStorage lets metadata resolve without opening the user's files.
// It binds once. After binding, pieces are supplied directly by file storage.
type deferredFileStorage struct {
mu sync.RWMutex
ctx context.Context
info *metainfo.Info
hash metainfo.Hash
knownHashes []bool
owner storage.ClientImplCloser
torrent storage.TorrentImpl
closed bool
}
func (s *deferredFileStorage) OpenTorrent(ctx context.Context, info *metainfo.Info, hash metainfo.Hash) (storage.TorrentImpl, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed || s.info != nil {
return storage.TorrentImpl{}, errors.New("torrent storage already opened or closed")
}
s.ctx, s.info, s.hash = ctx, info, hash
s.knownHashes = make([]bool, info.NumPieces())
return storage.TorrentImpl{
PieceWithHash: func(p metainfo.Piece, hash g.Option[[]byte]) storage.PieceImpl {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return metadataPiece{err: os.ErrClosed}
}
if s.owner == nil {
s.knownHashes[p.Index()] = hash.Ok
// A missing v2 hash must remain unknown until its arrival changes
// completion and updates the library's piece request priorities.
return metadataPiece{known: hash.Ok}
}
if s.torrent.PieceWithHash != nil {
return s.torrent.PieceWithHash(p, hash)
}
return s.torrent.Piece(p)
},
Close: s.Close,
}, nil
}
func (s *deferredFileStorage) bind(opts storage.NewFileClientOpts) ([]bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return nil, os.ErrClosed
}
if s.info == nil {
return nil, errors.New("torrent metadata is not ready")
}
if s.owner != nil {
return nil, nil
}
if opts.FilePathMaker == nil {
opts.FilePathMaker = func(o storage.FilePathMakerOpts) string { _, name := torrentFileLayout(o.Info, *o.File); return name }
}
owner := storage.NewFileOpts(opts)
t, err := owner.OpenTorrent(s.ctx, s.info, s.hash)
if err != nil {
owner.Close()
return nil, err
}
s.owner, s.torrent = owner, t
return append([]bool(nil), s.knownHashes...), nil
}
func (s *deferredFileStorage) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return nil
}
s.closed = true
if s.owner == nil {
return nil
}
var err error
if s.torrent.Close != nil {
err = s.torrent.Close()
}
return errors.Join(err, s.owner.Close())
}
type metadataPiece struct {
known bool
err error
}
func (p metadataPiece) Completion() storage.Completion {
return storage.Completion{Ok: p.known, Err: p.err}
}
func (p metadataPiece) ReadAt([]byte, int64) (int, error) {
return 0, errors.New("download storage is not initialized")
}
func (p metadataPiece) WriteAt([]byte, int64) (int, error) {
return 0, errors.New("download storage is not initialized")
}
func (p metadataPiece) MarkComplete() error { return errors.New("download storage is not initialized") }
func (p metadataPiece) MarkNotComplete() error {
return errors.New("download storage is not initialized")
}
+234
View File
@@ -0,0 +1,234 @@
package bt
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"slices"
"strings"
"sync"
"time"
"github.com/GopeedLab/gopeed/internal/fetcher"
)
var defaultTrackerSubscribeURLs = []string{
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all.txt",
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_http.txt",
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_https.txt",
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_ip.txt",
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_udp.txt",
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_ws.txt",
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best.txt",
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best_ip.txt",
"https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/all.txt",
"https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/best.txt",
"https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/http.txt",
}
func validTracker(raw string) bool {
u, err := url.Parse(raw)
if err != nil || u.Hostname() == "" || u.User != nil {
return false
}
switch u.Scheme {
case "udp", "http", "https", "ws", "wss":
return true
}
return false
}
func uniqueTrackers(groups ...[]string) []string {
result := []string{}
seen := map[string]bool{}
for _, group := range groups {
for _, raw := range group {
value := strings.TrimSpace(raw)
if value != "" && !seen[value] {
seen[value] = true
result = append(result, value)
}
}
}
return result
}
// effectiveTrackers derives the client list without persisting duplicate lists.
func (c *config) effectiveTrackers() []string {
groups := [][]string{c.CustomTrackers}
for _, source := range c.TrackerSubscribeURLs {
groups = append(groups, c.State.TrackerSources[source])
}
return uniqueTrackers(groups...)
}
func (fm *FetcherManager) NormalizeConfig(raw json.RawMessage) (any, error) {
c := fm.DefaultConfig().(*config)
if err := json.Unmarshal(raw, c); err != nil {
return nil, err
}
if c.ListenPort < 0 || c.ListenPort > 65535 || c.SeedRatio < 0 || c.SeedTime < 0 {
return nil, errors.New("invalid BT configuration")
}
if len(c.TrackerSubscribeURLs) > 64 {
return nil, errors.New("too many tracker subscriptions")
}
c.TrackerSubscribeURLs = uniqueTrackers(c.TrackerSubscribeURLs)
for _, source := range c.TrackerSubscribeURLs {
u, err := url.Parse(source)
if err != nil || u == nil || (u.Scheme != "https" && u.Scheme != "http") || u.Hostname() == "" || u.User != nil {
return nil, errors.New("invalid tracker subscription URL")
}
}
c.CustomTrackers = uniqueTrackers(c.CustomTrackers)
for _, tracker := range c.CustomTrackers {
if !validTracker(tracker) {
return nil, fmt.Errorf("invalid tracker URL: %s", tracker)
}
}
if c.State.TrackerSources == nil {
c.State.TrackerSources = map[string][]string{}
}
for source := range c.State.TrackerSources {
if !slices.Contains(c.TrackerSubscribeURLs, source) {
delete(c.State.TrackerSources, source)
}
}
if c.State.TrackerSubscriptionStatuses == nil {
c.State.TrackerSubscriptionStatuses = map[string]trackerSubscriptionStatus{}
}
for source := range c.State.TrackerSubscriptionStatuses {
if !slices.Contains(c.TrackerSubscribeURLs, source) {
delete(c.State.TrackerSubscriptionStatuses, source)
}
}
return c, nil
}
func (fm *FetcherManager) CronJobs(host fetcher.ProtocolHost) []fetcher.CronJob {
return []fetcher.CronJob{{
Name: "update-trackers",
Every: 24 * time.Hour,
Timeout: 90 * time.Second,
Run: func(ctx context.Context, source fetcher.TriggerSource) error {
return fm.updateTrackers(ctx, host, source)
},
}}
}
func parseTrackers(data []byte) ([]string, error) {
scanner := bufio.NewScanner(bytes.NewReader(data))
scanner.Buffer(make([]byte, 4096), 64*1024)
result := []string{}
for scanner.Scan() {
line := strings.TrimSpace(strings.TrimPrefix(scanner.Text(), "\ufeff"))
if validTracker(line) {
result = append(result, line)
}
if len(result) > 20000 {
return nil, errors.New("too many trackers")
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
if len(result) == 0 {
return nil, errors.New("subscription contains no valid trackers")
}
return uniqueTrackers(result), nil
}
func (fm *FetcherManager) updateTrackers(ctx context.Context, host fetcher.ProtocolHost, source fetcher.TriggerSource) error {
snapshot, err := fetcher.ReadConfig[config](host)
if err != nil {
return err
}
if (source == fetcher.TriggerScheduled && !snapshot.AutoUpdateTrackers) || len(snapshot.TrackerSubscribeURLs) == 0 {
return fetcher.ErrJobSkipped
}
fetched := map[string][]string{}
statuses := map[string]trackerSubscriptionStatus{}
var failures []error
var mu sync.Mutex
var wg sync.WaitGroup
slots := make(chan struct{}, 4)
for _, address := range snapshot.TrackerSubscribeURLs {
wg.Add(1)
go func(address string) {
defer wg.Done()
select {
case slots <- struct{}{}:
defer func() { <-slots }()
case <-ctx.Done():
return
}
data, err := host.Fetch(ctx, address, 2<<20, func(body []byte) error { _, err := parseTrackers(body); return err })
var trackers []string
if err == nil {
trackers, err = parseTrackers(data)
}
mu.Lock()
defer mu.Unlock()
statuses[address] = trackerSubscriptionStatus{Success: err == nil, TrackerCount: len(trackers), LastFetchTime: time.Now().UTC()}
if err != nil {
failures = append(failures, fmt.Errorf("subscription %s: %w", address, err))
} else {
fetched[address] = trackers
}
}(address)
}
wg.Wait()
if err := ctx.Err(); err != nil {
failures = append(failures, err)
}
if len(statuses) == 0 {
return errors.Join(failures...)
}
err = fetcher.UpdateState[config, state](host, func(current *config, state *state) error {
if !slices.Equal(current.TrackerSubscribeURLs, snapshot.TrackerSubscribeURLs) {
return fetcher.ErrConfigChanged
}
if state.TrackerSources == nil {
state.TrackerSources = map[string][]string{}
}
if state.TrackerSubscriptionStatuses == nil {
state.TrackerSubscriptionStatuses = map[string]trackerSubscriptionStatus{}
}
for address, status := range statuses {
state.TrackerSubscriptionStatuses[address] = status
}
for address, trackers := range fetched {
state.TrackerSources[address] = trackers
}
if len(failures) == 0 {
state.LastTrackerUpdateTime = time.Now().UTC()
}
return nil
})
if err != nil {
return err
}
current, err := fetcher.ReadConfig[config](host)
if err != nil {
return err
}
fm.applyTrackers(current.effectiveTrackers())
return errors.Join(failures...)
}
func (fm *FetcherManager) applyTrackers(trackers []string) {
lock.Lock()
defer lock.Unlock()
if client == nil {
return
}
announce := make([][]string, 0, len(trackers))
for _, tracker := range trackers {
announce = append(announce, []string{tracker})
}
for _, t := range client.Torrents() {
info := t.Info()
if info == nil || (info.Private != nil && *info.Private) {
continue
}
t.AddTrackers(announce)
}
}
+217
View File
@@ -0,0 +1,217 @@
package bt
import (
"context"
"encoding/json"
"errors"
"reflect"
"sync"
"testing"
"time"
"github.com/GopeedLab/gopeed/internal/fetcher"
)
func trackerTestHost(t *testing.T, c *config, fetch func(context.Context, string, int64, func([]byte) error) ([]byte, error)) (fetcher.ProtocolHost, *sync.Mutex) {
t.Helper()
mu := &sync.Mutex{}
return fetcher.ProtocolHost{
Read: func(dst any) error {
mu.Lock()
defer mu.Unlock()
raw, err := json.Marshal(c)
if err != nil {
return err
}
return json.Unmarshal(raw, dst)
},
UpdateState: func(change func(config, state json.RawMessage) (any, error)) error {
mu.Lock()
defer mu.Unlock()
raw, _ := json.Marshal(c)
currentState, _ := json.Marshal(c.State)
next, err := change(raw, currentState)
if err != nil {
return err
}
raw, err = json.Marshal(next)
if err != nil {
return err
}
return json.Unmarshal(raw, &c.State)
},
Fetch: fetch,
}, mu
}
func TestTrackerRefreshPartialSuccessUsesLatestConfig(t *testing.T) {
fm := &FetcherManager{}
cfg := fm.DefaultConfig().(*config)
cfg.TrackerSubscribeURLs = []string{"https://a.example/list", "https://b.example/list"}
cfg.CustomTrackers = []string{"udp://custom.example:80/announce"}
cfg.State.TrackerSources = map[string][]string{cfg.TrackerSubscribeURLs[0]: {"udp://old.example:80/announce"}, cfg.TrackerSubscribeURLs[1]: {"udp://cached.example:80/announce"}}
host, mu := trackerTestHost(t, cfg, nil)
host.Fetch = func(ctx context.Context, address string, _ int64, validate func([]byte) error) ([]byte, error) {
if address == "https://b.example/list" {
return nil, errors.New("offline")
}
// Simulate a frontend update while the network request is in flight.
mu.Lock()
cfg.CustomTrackers = []string{"udp://new-custom.example:80/announce"}
mu.Unlock()
data := []byte("udp://fresh.example:80/announce\n# ignored\nudp://fresh.example:80/announce\n")
return data, validate(data)
}
err := fm.updateTrackers(context.Background(), host, fetcher.TriggerManual)
if err == nil {
t.Fatal("partial failure must be reported")
}
want := []string{"udp://new-custom.example:80/announce", "udp://fresh.example:80/announce", "udp://cached.example:80/announce"}
if !reflect.DeepEqual(cfg.effectiveTrackers(), want) {
t.Fatalf("got %v", cfg.effectiveTrackers())
}
success := cfg.State.TrackerSubscriptionStatuses["https://a.example/list"]
failure := cfg.State.TrackerSubscriptionStatuses["https://b.example/list"]
if !success.Success || success.TrackerCount != 1 || success.LastFetchTime.IsZero() || failure.Success || failure.TrackerCount != 0 || failure.LastFetchTime.IsZero() {
t.Fatalf("unexpected subscription statuses: %+v", cfg.State.TrackerSubscriptionStatuses)
}
if !cfg.State.LastTrackerUpdateTime.IsZero() {
t.Fatal("partial refresh marked fully successful")
}
}
func TestTrackerRefreshDiscardsChangedSubscriptions(t *testing.T) {
fm := &FetcherManager{}
cfg := fm.DefaultConfig().(*config)
cfg.TrackerSubscribeURLs = []string{"https://a.example/list"}
host, mu := trackerTestHost(t, cfg, nil)
host.Fetch = func(context.Context, string, int64, func([]byte) error) ([]byte, error) {
mu.Lock()
cfg.TrackerSubscribeURLs = []string{}
mu.Unlock()
return []byte("udp://fresh.example:80/announce"), nil
}
if err := fm.updateTrackers(context.Background(), host, fetcher.TriggerManual); !errors.Is(err, fetcher.ErrConfigChanged) {
t.Fatal(err)
}
if len(cfg.State.TrackerSources) != 0 {
t.Fatal("outdated source was committed")
}
}
func TestTrackerRefreshManualWorksWhenAutomaticDisabled(t *testing.T) {
fm := &FetcherManager{}
cfg := fm.DefaultConfig().(*config)
cfg.AutoUpdateTrackers = false
cfg.TrackerSubscribeURLs = []string{"https://a.example/list"}
calls := 0
host, _ := trackerTestHost(t, cfg, func(context.Context, string, int64, func([]byte) error) ([]byte, error) {
calls++
return []byte("udp://fresh.example:80/announce"), nil
})
if err := fm.updateTrackers(context.Background(), host, fetcher.TriggerScheduled); !errors.Is(err, fetcher.ErrJobSkipped) {
t.Fatal(err)
}
if calls != 0 {
t.Fatal("scheduled job fetched while disabled")
}
if err := fm.updateTrackers(context.Background(), host, fetcher.TriggerManual); err != nil {
t.Fatal(err)
}
if calls != 1 || cfg.State.LastTrackerUpdateTime.IsZero() {
t.Fatal("manual refresh did not succeed")
}
}
func TestTrackerConfigPreservesExplicitSettings(t *testing.T) {
value, err := (&FetcherManager{}).NormalizeConfig(json.RawMessage(`{
"listenPort":6881, "trackerSubscribeUrls":[], "autoUpdateTrackers":false,
"customTrackers":["udp://custom.example:80/announce"]
}`))
if err != nil {
t.Fatal(err)
}
cfg := value.(*config)
if cfg.AutoUpdateTrackers || len(cfg.TrackerSubscribeURLs) != 0 || cfg.ListenPort != 6881 {
t.Fatalf("%+v", cfg)
}
if !reflect.DeepEqual(cfg.effectiveTrackers(), cfg.CustomTrackers) {
t.Fatalf("derived trackers: %v", cfg.effectiveTrackers())
}
}
func TestParseTrackerList(t *testing.T) {
got, err := parseTrackers([]byte("\ufeffudp://tracker.example:80/announce\r\n\n<html>error</html>\nhttps://tracker.example/announce\n"))
if err != nil || len(got) != 2 {
t.Fatalf("%v %v", got, err)
}
if _, err := parseTrackers([]byte("<html>mirror error</html>")); err == nil {
t.Fatal("invalid mirror body accepted")
}
}
func TestTrackerDefaultsRegisterDailyJob(t *testing.T) {
jobs := (&FetcherManager{}).CronJobs(fetcher.ProtocolHost{})
if len(jobs) != 1 || jobs[0].Name != "update-trackers" || jobs[0].Every != 24*time.Hour {
t.Fatal(jobs)
}
}
func TestTrackerRefreshAllFailuresPersistStatusesAndKeepCache(t *testing.T) {
fm := &FetcherManager{}
cfg := fm.DefaultConfig().(*config)
address := "https://custom.example/trackers.txt"
cfg.TrackerSubscribeURLs = []string{address}
cfg.State.TrackerSources[address] = []string{"udp://cached.example:80/announce"}
cfg.State.TrackerSubscriptionStatuses[address] = trackerSubscriptionStatus{Success: true, TrackerCount: 1, LastFetchTime: time.Now().Add(-time.Hour)}
host, _ := trackerTestHost(t, cfg, func(context.Context, string, int64, func([]byte) error) ([]byte, error) {
return nil, errors.New("offline")
})
before := time.Now()
if err := fm.updateTrackers(context.Background(), host, fetcher.TriggerManual); err == nil {
t.Fatal("failure was not reported")
}
status, ok := cfg.State.TrackerSubscriptionStatuses[address]
if !ok || status.Success || status.TrackerCount != 0 || status.LastFetchTime.Before(before) {
t.Fatalf("failed fetch status not stored: %+v", status)
}
if !reflect.DeepEqual(cfg.State.TrackerSources[address], []string{"udp://cached.example:80/announce"}) {
t.Fatal("last good cache lost")
}
if !reflect.DeepEqual(cfg.effectiveTrackers(), cfg.State.TrackerSources[address]) {
t.Fatal("cached trackers not retained")
}
}
func TestTrackerConfigPrunesRemovedSubscriptions(t *testing.T) {
fm := &FetcherManager{}
cfg := fm.DefaultConfig().(*config)
address := "https://custom.example/trackers.txt"
cfg.TrackerSubscribeURLs = []string{}
cfg.State.TrackerSources[address] = []string{"udp://cached.example:80/announce"}
cfg.State.TrackerSubscriptionStatuses[address] = trackerSubscriptionStatus{Success: true, TrackerCount: 1, LastFetchTime: time.Now()}
raw, _ := json.Marshal(cfg)
value, err := fm.NormalizeConfig(raw)
if err != nil {
t.Fatal(err)
}
next := value.(*config)
if len(next.State.TrackerSources) != 0 || len(next.State.TrackerSubscriptionStatuses) != 0 || len(next.effectiveTrackers()) != 0 {
t.Fatal("removed subscription still owns cached results")
}
}
func TestTrackerRefreshTimeoutPersistsCompletedAttemptStatus(t *testing.T) {
fm := &FetcherManager{}
cfg := fm.DefaultConfig().(*config)
address := "https://custom.example/trackers.txt"
cfg.TrackerSubscribeURLs = []string{address}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
host, _ := trackerTestHost(t, cfg, func(context.Context, string, int64, func([]byte) error) ([]byte, error) {
cancel()
return nil, context.DeadlineExceeded
})
if err := fm.updateTrackers(ctx, host, fetcher.TriggerManual); !errors.Is(err, context.DeadlineExceeded) {
t.Fatal(err)
}
status, ok := cfg.State.TrackerSubscriptionStatuses[address]
if !ok || status.Success || status.TrackerCount != 0 || status.LastFetchTime.IsZero() {
t.Fatalf("timeout status lost: %+v", status)
}
}
+2 -5
View File
@@ -67,17 +67,14 @@ func (f *Fetcher) Setup(ctl *controller.Controller) {
f.ctl.GetConfig(&f.config)
}
func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
func (f *Fetcher) Resolve(req *base.Request) error {
link, err := parseLink(req.URL)
if err != nil {
return err
}
f.meta.Req = req
f.meta.Opts = opts
if f.meta.Opts == nil {
f.meta.Opts = &base.Options{}
}
f.meta.Res = buildResource(link)
return nil
}
+2 -1
View File
@@ -13,7 +13,8 @@ func TestFetcher_Resolve(t *testing.T) {
f := (&FetcherManager{}).Build()
f.Setup(controller.NewController())
err := f.Resolve(&base.Request{URL: testLink}, &base.Options{Path: t.TempDir()})
f.Meta().Opts = &base.Options{Path: t.TempDir()}
err := f.Resolve(&base.Request{URL: testLink})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
+2 -6
View File
@@ -172,11 +172,11 @@ func (f *Fetcher) Meta() *fetcher.FetcherMeta {
return f.meta
}
func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) (err error) {
func (f *Fetcher) Resolve(req *base.Request) (err error) {
f.mu.Lock()
defer f.mu.Unlock()
f.meta.Req = req
f.meta.Opts = opts
f.extra = &ReqExtra{}
if err = base.ParseReqExtra[ReqExtra](req); err != nil {
return
@@ -194,10 +194,6 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) (err error) {
}
state.StagingID = randomStagingID()
f.state = state
// Pin the staging folder now so the very first persisted state already
// carries it: a crash right after create still resumes into the same
// folder.
f.resolveStagingDirLocked()
size := planTotalSize(state.Segments)
f.meta.Res = &base.Resource{
Range: false,
+16 -8
View File
@@ -80,7 +80,8 @@ func mustResolve(t *testing.T, f *Fetcher, playlistURL, referer string) {
if referer != "" {
req.Extra = &ReqExtra{Header: map[string]string{"Referer": referer}}
}
if err := f.Resolve(req, &base.Options{Path: t.TempDir()}); err != nil {
f.Meta().Opts = &base.Options{Path: t.TempDir()}
if err := f.Resolve(req); err != nil {
t.Fatalf("resolve: %v", err)
}
}
@@ -207,7 +208,8 @@ func TestFetcherE2E_RefererEnforced(t *testing.T) {
// Without Referer the resolve itself must fail (403 on the playlist).
blocked := newTestFetcher(t, testConfig())
req := &base.Request{URL: server.URL + "/video.m3u8"}
if err := blocked.Resolve(req, &base.Options{Path: t.TempDir()}); err == nil {
blocked.Meta().Opts = &base.Options{Path: t.TempDir()}
if err := blocked.Resolve(req); err == nil {
t.Fatal("resolve without Referer should fail")
}
@@ -217,7 +219,8 @@ func TestFetcherE2E_RefererEnforced(t *testing.T) {
URL: server.URL + "/video.m3u8",
Extra: &ReqExtra{Header: map[string]string{"Referer": page}},
}
if err := allowed.Resolve(allowedReq, &base.Options{Path: t.TempDir()}); err != nil {
allowed.Meta().Opts = &base.Options{Path: t.TempDir()}
if err := allowed.Resolve(allowedReq); err != nil {
t.Fatal(err)
}
mustRun(t, allowed)
@@ -560,7 +563,8 @@ func TestFetcherE2E_StoreRestoreAuthenticated(t *testing.T) {
URL: server.URL + "/video.m3u8",
Extra: &ReqExtra{Header: map[string]string{"Referer": page}},
}
if err := f.Resolve(req, &base.Options{Path: t.TempDir()}); err != nil {
f.Meta().Opts = &base.Options{Path: t.TempDir()}
if err := f.Resolve(req); err != nil {
t.Fatal(err)
}
if err := f.Start(); err != nil {
@@ -630,7 +634,8 @@ func TestFetcherE2E_PatchPartialUpdate(t *testing.T) {
Extra: &ReqExtra{Header: map[string]string{"Referer": page}},
}
opts := &base.Options{Path: t.TempDir()}
if err := f.Resolve(req, opts); err != nil {
f.Meta().Opts = opts
if err := f.Resolve(req); err != nil {
t.Fatal(err)
}
@@ -713,7 +718,8 @@ func TestFetcherE2E_PatchWhileRunningRejected(t *testing.T) {
defer server.Close()
f := newTestFetcher(t, testConfig())
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil {
f.Meta().Opts = &base.Options{Path: t.TempDir()}
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
t.Fatal(err)
}
if err := f.Start(); err != nil {
@@ -767,7 +773,8 @@ func TestFetcherE2E_RetryAfterFailure(t *testing.T) {
cfg.MaxRetries = 0 // fail the run on the first bad segment
cfg.PrefetchContentLength = false
f := newTestFetcher(t, cfg)
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil {
f.Meta().Opts = &base.Options{Path: t.TempDir()}
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
t.Fatal(err)
}
if err := f.Start(); err != nil {
@@ -828,7 +835,8 @@ func TestFetcherE2E_StateMissingSelfHeal(t *testing.T) {
defer server.Close()
f := newTestFetcher(t, testConfig())
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil {
f.Meta().Opts = &base.Options{Path: t.TempDir()}
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
t.Fatal(err)
}
// Simulate the crash window where the engine persisted the task with its
+6 -3
View File
@@ -189,7 +189,8 @@ func TestHLSRealMedia_TS(t *testing.T) {
defer server.Close()
f := newTestFetcher(t, testConfig())
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil {
f.Meta().Opts = &base.Options{Path: t.TempDir()}
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
t.Fatal(err)
}
if err := f.Start(); err != nil {
@@ -225,7 +226,8 @@ func TestHLSRealMedia_FMP4(t *testing.T) {
defer server.Close()
f := newTestFetcher(t, testConfig())
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil {
f.Meta().Opts = &base.Options{Path: t.TempDir()}
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
t.Fatal(err)
}
if err := f.Start(); err != nil {
@@ -274,7 +276,8 @@ func TestHLSRealMedia_AES128(t *testing.T) {
defer server.Close()
f := newTestFetcher(t, testConfig())
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil {
f.Meta().Opts = &base.Options{Path: t.TempDir()}
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
t.Fatal(err)
}
if f.meta.Res.Size != 0 {
+15 -7
View File
@@ -50,15 +50,20 @@ func TestFetcherE2E_SameURLTwoTasks(t *testing.T) {
// user duplicating a task. The engine would rename the second output
// (AutoRename); mirror that here.
f1 := newTestFetcher(t, cfg)
if err := f1.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: dir}); err != nil {
f1.Meta().Opts = &base.Options{Path: dir}
if err := f1.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
t.Fatal(err)
}
f2 := newTestFetcher(t, cfg)
if err := f2.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: dir, Name: "video-2.ts"}); err != nil {
f2.Meta().Opts = &base.Options{Path: dir, Name: "video-2.ts"}
if err := f2.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
t.Fatal(err)
}
if f1.state.StagingDir == "" || f1.state.StagingDir == f2.state.StagingDir {
t.Fatalf("staging dirs must be distinct per task: %q vs %q", f1.state.StagingDir, f2.state.StagingDir)
if f1.state.StagingID == "" || f1.state.StagingID == f2.state.StagingID {
t.Fatal("staging identities must be distinct per task")
}
if f1.state.StagingDir != "" || f2.state.StagingDir != "" {
t.Fatal("Resolve pinned an output directory")
}
// Start task 1, let it stage some segments, then pause it.
@@ -135,7 +140,8 @@ func TestFetcherE2E_ForeignJournalDiscarded(t *testing.T) {
cfg := testConfig()
cfg.PrefetchContentLength = false
f := newTestFetcher(t, cfg)
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil {
f.Meta().Opts = &base.Options{Path: t.TempDir()}
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
t.Fatal(err)
}
// Plant a journal that does not belong to this plan (wrong fingerprint).
@@ -176,7 +182,8 @@ func TestFetcherE2E_MergeFailurePreservesStaging(t *testing.T) {
defer server.Close()
f := newTestFetcher(t, testConfig())
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil {
f.Meta().Opts = &base.Options{Path: t.TempDir()}
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
t.Fatal(err)
}
run := &hlsRun{
@@ -250,7 +257,8 @@ func TestFetcherE2E_ReMergeOverExistingOutput(t *testing.T) {
defer server.Close()
f := newTestFetcher(t, testConfig())
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil {
f.Meta().Opts = &base.Options{Path: t.TempDir()}
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
t.Fatal(err)
}
tempDir := f.resolveStagingDirLocked()
+261 -155
View File
@@ -2,8 +2,13 @@ package http
import (
"context"
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"hash"
"io"
"net/http"
"net/url"
@@ -34,6 +39,11 @@ const (
stealMinChunkSize = 512 * 1024 // Min steal size: 512KB (avoid tiny chunks)
)
// connReadBufferSize is the read buffer each connection allocates once and
// reuses for its lifetime. 64 KiB keeps per-read overhead low on fast links;
// at 256 connections it is 16 MiB per task.
const connReadBufferSize = 64 * 1024
var (
errRangeRequestIgnored = errors.New("server ignored HTTP range request")
errInvalidRangeResponse = errors.New("invalid HTTP range response")
@@ -280,6 +290,12 @@ type Fetcher struct {
primaryReadyOnce sync.Once
primaryReadyCh chan struct{}
// checksumFailed marks that the last completed download failed
// checksum verification, so the next retry must force a full
// re-download instead of skipping connections that already
// reported Completed.
checksumFailed atomic.Bool
// Start pending mechanism
startPending atomic.Bool
resolvedCh chan struct{} // Signal when resolve completes
@@ -300,13 +316,17 @@ type Fetcher struct {
resolveFallback atomic.Bool // Resolve prefetch is still available until the first Range request takes over
// Target file
file *os.File
fileMu sync.Mutex
redirectURL string
redirectLock sync.Mutex
ifRange string
rangeReprobeEligible bool
rangeValidatorPinned bool
file *os.File
fileMu sync.Mutex
redirectURL string
redirectLock sync.Mutex
ifRange string
rangeReprobeEligible bool
rangeValidatorPinned bool
// sizeKnown distinguishes a display estimate from an HTTP-confirmed length,
// including zero. Nil preserves the interpretation of legacy snapshots.
sizeKnown *bool
// An interrupted chunked sequential response must restart from byte zero.
sequentialSizeUnknown bool
// Lifecycle control
@@ -353,6 +373,40 @@ func (f *Fetcher) setState(s fetcherState) {
f.state.Store(int32(s))
}
// Size state is read under connMu or before download workers are started.
func (f *Fetcher) hasKnownSize() bool {
if f.sizeKnown != nil {
return *f.sizeKnown
}
return f.meta != nil && f.meta.Res != nil && f.meta.Res.Size > 0 && !f.sequentialSizeUnknown
}
func (f *Fetcher) knownSize() int64 {
if f.hasKnownSize() {
return f.meta.Res.Size
}
return 0
}
func (f *Fetcher) sizeHint() int64 {
if f.meta.Opts != nil && f.meta.Opts.Size > 0 {
return f.meta.Opts.Size
}
return 0
}
// setResourceSizeLocked publishes the value and its authority in one snapshot.
func (f *Fetcher) setResourceSizeLocked(size int64, known bool) {
if !known {
size = f.sizeHint()
}
f.sizeKnown = &known
f.meta.Res.Size = size
if len(f.meta.Res.Files) > 0 && f.meta.Res.Files[0] != nil {
f.meta.Res.Files[0].Size = size
}
}
// updateMaxConnTime updates maxConnTime if the new duration is larger
func (f *Fetcher) updateMaxConnTime(d time.Duration) {
newVal := int64(d)
@@ -361,19 +415,11 @@ func (f *Fetcher) updateMaxConnTime(d time.Duration) {
}
}
func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
func (f *Fetcher) Resolve(req *base.Request) error {
if err := base.ParseReqExtra[fhttp.ReqExtra](req); err != nil {
return err
}
f.meta.Req = req
f.meta.Opts = opts
if f.meta.Opts == nil {
f.meta.Opts = &base.Options{}
}
if err := f.initOptions(); err != nil {
return err
}
f.setState(stateResolving)
@@ -417,13 +463,34 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
res.Range = true
}
// Get content length from Content-Length header
contentLength := resp.Header.Get(base.HttpHeaderContentLength)
if contentLength != "" {
parse, err := strconv.ParseInt(contentLength, 10, 64)
if err == nil {
res.Size = parse
// A partial response describes one segment, not the whole resource.
reuseResolveBody := true
if resp.StatusCode == base.HttpCodePartialContent {
start, _, total, err := parseRangeResponse(resp)
if err != nil {
resp.Body.Close()
f.setState(stateError)
return err
}
res.Size = total
res.Range = true
// Prefetch is a contiguous prefix copied at offset zero. A segment
// starting elsewhere must be fetched again through the Range path.
reuseResolveBody = start == 0
} else {
contentLength := resp.Header.Get(base.HttpHeaderContentLength)
if contentLength != "" {
if size, err := strconv.ParseInt(contentLength, 10, 64); err == nil {
res.Size = size
}
}
}
// A hint is only a display value, even when the origin advertises Range.
known := resp.StatusCode == base.HttpCodePartialContent || resp.ContentLength >= 0
f.sizeKnown = &known
f.sequentialSizeUnknown = !res.Range && !known
if !known {
res.Size = f.sizeHint()
}
// Parse last modified time
@@ -467,7 +534,12 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
// IMPORTANT: Keep the response body open for downloading in Start phase
// This is crucial for one-time URLs that can only be accessed once
f.resolveRespLock.Lock()
f.resolveResp = resp
if reuseResolveBody {
f.resolveResp = resp
} else {
resp.Body.Close()
f.prefetchDone.Store(true)
}
f.resolveRespLock.Unlock()
f.setState(stateResolved)
@@ -479,7 +551,7 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
// Start async prefetch in background (only for range-supported resources)
// For non-range resources, the response will be used directly in Start
if res.Range {
if res.Range && reuseResolveBody {
f.prefetchStopCh = make(chan struct{})
f.resolveFallback.Store(true)
go f.asyncPrefetch()
@@ -590,7 +662,7 @@ func (f *Fetcher) stopPrefetchAndCopyData() int64 {
}
// Wait for prefetch to finish (with timeout)
for i := 0; i < 1000 && !f.prefetchDone.Load(); i++ {
for i := 0; f.prefetchStopCh != nil && i < 1000 && !f.prefetchDone.Load(); i++ {
time.Sleep(10 * time.Millisecond)
}
@@ -645,8 +717,27 @@ func (f *Fetcher) cleanupPrefetchFile() {
f.resolveFallback.Store(false)
}
// prepareFullRedownload resets all fetcher connection and prefetch state
// after a checksum mismatch so the subsequent Start takes the fresh-start path.
func (f *Fetcher) prepareFullRedownload() {
f.connMu.Lock()
if f.meta.Res.Range {
f.connections = nil
f.resolveConn = nil
f.rangeReprobeEligible = false
f.rangeValidatorPinned = false
}
f.connMu.Unlock()
f.cleanupPrefetchFile()
f.resolveDataPos.Store(0)
}
// Options may be replaced between Resolve and Start when creating a resolved task.
func (f *Fetcher) initOptions() error {
if f.meta.Opts == nil {
f.meta.Opts = &base.Options{}
}
opts := f.meta.Opts
// Parse options
if err := base.ParseOptExtra[fhttp.OptsExtra](opts); err != nil {
@@ -662,6 +753,9 @@ func (f *Fetcher) initOptions() error {
extra.Connections = 1
}
}
if err := extra.Checksum.Validate(); err != nil {
return err
}
return nil
}
@@ -716,11 +810,26 @@ func (f *Fetcher) doStart() error {
// If retrying after error, reset connection states for retry
if state == stateError {
fullRedownload := f.checksumFailed.Swap(false)
if fullRedownload {
// Checksum mismatch: the file content itself is wrong even
// though every connection reported Completed. Truncate the
// corrupt file so stale bytes cannot linger if the retry is
// interrupted before every connection re-writes its chunk.
if err := os.Truncate(f.meta.SingleFilepath(), 0); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to reset file after checksum mismatch: %w", err)
}
f.prepareFullRedownload()
}
f.connMu.Lock()
for _, conn := range f.connections {
// Reset connections that can be retried
if !conn.Completed && conn.State != connCompleted {
if !f.hasSequentialPrefixLocked(conn) {
// On a normal error, only reset connections that didn't
// finish. On a checksum mismatch, reset EVERY connection —
// "Completed" only means the download loop finished, not
// that the bytes were verified correct.
shouldReset := fullRedownload || (!conn.Completed && conn.State != connCompleted)
if shouldReset {
if fullRedownload || !f.hasSequentialPrefixLocked(conn) {
f.resetConnectionForRestart(conn)
}
conn.State = connNotStarted
@@ -739,7 +848,7 @@ func (f *Fetcher) doStart() error {
_, err = os.Stat(name)
if err != nil {
if os.IsNotExist(err) {
file, err = f.ctl.Touch(name, f.meta.Res.Size)
file, err = f.ctl.Touch(name, f.knownSize())
} else {
return err
}
@@ -757,7 +866,7 @@ func (f *Fetcher) doStart() error {
// For non-range resources, the response will be used directly
var prefetchedBytes int64
if f.meta.Res.Range {
resolveComplete := f.meta.Res.Size > 0 && f.prefetchDone.Load() && f.prefetchErr == nil && f.prefetchSize.Load() >= f.meta.Res.Size
resolveComplete := f.knownSize() > 0 && f.prefetchDone.Load() && f.prefetchErr == nil && f.prefetchSize.Load() >= f.knownSize()
if f.resolveFallback.Load() && !resolveComplete {
// Keep Resolve downloading until the first Range response has been
// validated. The Range request starts at a stable snapshot; a valid
@@ -929,7 +1038,7 @@ func (f *Fetcher) expandConnections() {
return
}
totalSize := f.meta.Res.Size
totalSize := f.knownSize()
f.connMu.Lock()
@@ -951,8 +1060,14 @@ func (f *Fetcher) expandConnections() {
}
f.fileMu.Unlock()
f.setState(stateDone)
f.doneCh <- nil
var err error
if chkErr := f.verifyChecksum(); chkErr != nil {
err = chkErr
f.setState(stateError)
} else {
f.setState(stateDone)
}
f.doneCh <- err
return
}
@@ -1071,7 +1186,7 @@ func (f *Fetcher) runConnection(conn *connection) {
// Use fast-fail client for quick retry during download phase
client := f.buildFastFailClient()
buf := make([]byte, 8192)
buf := make([]byte, connReadBufferSize)
retries := 0
f.connMu.Lock()
@@ -1214,7 +1329,7 @@ func (f *Fetcher) downloadChunkOnce(conn *connection, client *http.Client, buf [
rangeMode := f.meta.Res.Range
ifRange := ""
if resumeProbe {
end := f.meta.Res.Size - 1
end := f.knownSize() - 1
rangeEnd = &end
ifRange = f.ifRange
} else if rangeMode && (f.rangeValidatorPinned || f.resolveFallback.Load()) {
@@ -1294,7 +1409,7 @@ func (f *Fetcher) downloadChunkOnce(conn *connection, client *http.Client, buf [
}
if rangeRequested && resp.StatusCode == base.HttpCodePartialContent {
var responseTotal int64
expectedResponseLength, responseTotal, err = validateRangeResponse(resp, rangeStart, rangeEnd, f.meta.Res.Size)
expectedResponseLength, responseTotal, err = validateRangeResponse(resp, rangeStart, rangeEnd, f.knownSize())
if err != nil {
resp.Body.Close()
return err
@@ -1319,7 +1434,7 @@ func (f *Fetcher) downloadChunkOnce(conn *connection, client *http.Client, buf [
f.rangeValidatorPinned = true
f.sequentialSizeUnknown = false
conn.Chunk.Begin = 0
conn.Chunk.setEnd(f.meta.Res.Size - 1)
conn.Chunk.setEnd(f.knownSize() - 1)
f.connMu.Unlock()
}
}
@@ -1344,7 +1459,7 @@ func (f *Fetcher) downloadChunkOnce(conn *connection, client *http.Client, buf [
resp.Body.Close()
return err
}
} else if f.meta.Res.Size > 0 {
} else if f.hasKnownSize() && f.meta.Res.Size > 0 {
expectedResponseLength = f.meta.Res.Size
if resp.ContentLength >= 0 && resp.ContentLength != expectedResponseLength {
resp.Body.Close()
@@ -1480,7 +1595,7 @@ func (f *Fetcher) commitResolveTakeover(conn *connection, requestedStart int64)
}
prefetched := f.stopPrefetchAndCopyData()
if prefetched < requestedStart || prefetched > f.meta.Res.Size {
if prefetched < requestedStart || prefetched > f.knownSize() {
return 0, fmt.Errorf("resolve prefetch advanced to invalid offset %d for range start %d", prefetched, requestedStart)
}
f.resolveDataPos.Store(prefetched)
@@ -1511,7 +1626,7 @@ func (f *Fetcher) completeFromResolveFallback(conn *connection) bool {
}
time.Sleep(10 * time.Millisecond)
}
knownSize := f.meta.Res.Size > 0
knownSize := f.hasKnownSize()
if f.prefetchErr != nil || (knownSize && f.prefetchSize.Load() != f.meta.Res.Size) {
f.cleanupPrefetchFile()
return false
@@ -1525,10 +1640,7 @@ func (f *Fetcher) completeFromResolveFallback(conn *connection) bool {
f.connMu.Lock()
if !knownSize {
f.meta.Res.Size = prefetched
if len(f.meta.Res.Files) > 0 && f.meta.Res.Files[0] != nil {
f.meta.Res.Files[0].Size = prefetched
}
f.setResourceSizeLocked(prefetched, true)
}
f.meta.Res.Range = false
f.rangeReprobeEligible = false
@@ -1544,27 +1656,41 @@ func (f *Fetcher) completeFromResolveFallback(conn *connection) bool {
return true
}
func validateRangeResponse(resp *http.Response, requestedStart int64, requestedEnd *int64, totalSize int64) (int64, int64, error) {
// parseRangeResponse validates a single byte range with a known complete length.
// Unknown totals are rejected: a segment length cannot stand in for resource size.
func parseRangeResponse(resp *http.Response) (int64, int64, int64, error) {
contentRange := resp.Header.Get(base.HttpHeaderContentRange)
fields := strings.Fields(contentRange)
if len(fields) != 2 || !strings.EqualFold(fields[0], base.HttpHeaderBytes) {
return 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
return 0, 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
}
rangeAndTotal := strings.Split(fields[1], "/")
if len(rangeAndTotal) != 2 {
return 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
return 0, 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
}
bounds := strings.Split(rangeAndTotal[0], "-")
if len(bounds) != 2 {
return 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
return 0, 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
}
start, startErr := strconv.ParseInt(bounds[0], 10, 64)
end, endErr := strconv.ParseInt(bounds[1], 10, 64)
total, totalErr := strconv.ParseInt(rangeAndTotal[1], 10, 64)
if startErr != nil || endErr != nil || totalErr != nil || start < 0 || end < start || total <= end {
return 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
return 0, 0, 0, fmt.Errorf("%w: malformed Content-Range %q", errInvalidRangeResponse, contentRange)
}
expectedLength := end - start + 1
if resp.ContentLength >= 0 && resp.ContentLength != expectedLength {
return 0, 0, 0, fmt.Errorf("%w: Content-Length %d does not match range length %d", errInvalidRangeResponse, resp.ContentLength, expectedLength)
}
return start, end, total, nil
}
func validateRangeResponse(resp *http.Response, requestedStart int64, requestedEnd *int64, totalSize int64) (int64, int64, error) {
start, end, total, err := parseRangeResponse(resp)
if err != nil {
return 0, 0, err
}
if start != requestedStart || (requestedEnd != nil && end != *requestedEnd) || (requestedEnd == nil && end != total-1) {
return 0, 0, fmt.Errorf("%w: Content-Range %d-%d does not match requested range", errInvalidRangeResponse, start, end)
@@ -1573,11 +1699,7 @@ func validateRangeResponse(resp *http.Response, requestedStart int64, requestedE
return 0, 0, fmt.Errorf("%w: Content-Range total %d does not match resource size %d", errInvalidRangeResponse, total, totalSize)
}
expectedLength := end - start + 1
if resp.ContentLength >= 0 && resp.ContentLength != expectedLength {
return 0, 0, fmt.Errorf("%w: Content-Length %d does not match range length %d", errInvalidRangeResponse, resp.ContentLength, expectedLength)
}
return expectedLength, total, nil
return end - start + 1, total, nil
}
func (f *Fetcher) resolveOpenEndedRange(conn *connection, totalSize int64) error {
@@ -1600,10 +1722,7 @@ func (f *Fetcher) resolveOpenEndedRange(conn *connection, totalSize int64) error
f.connMu.Unlock()
return err
}
f.meta.Res.Size = totalSize
if len(f.meta.Res.Files) > 0 && f.meta.Res.Files[0] != nil {
f.meta.Res.Files[0].Size = totalSize
}
f.setResourceSizeLocked(totalSize, true)
conn.Chunk.setEnd(totalSize - 1)
f.connMu.Unlock()
return nil
@@ -1675,10 +1794,7 @@ func (f *Fetcher) resizeSequentialResource(size int64) error {
return err
}
f.meta.Res.Size = size
if len(f.meta.Res.Files) > 0 {
f.meta.Res.Files[0].Size = size
}
f.setResourceSizeLocked(size, true)
f.sequentialSizeUnknown = false
f.connMu.Unlock()
return nil
@@ -1703,10 +1819,7 @@ func (f *Fetcher) beginUnknownSizeSequentialResource() error {
return err
}
f.meta.Res.Size = 0
if len(f.meta.Res.Files) > 0 {
f.meta.Res.Files[0].Size = 0
}
f.setResourceSizeLocked(0, false)
f.sequentialSizeUnknown = true
f.ifRange = ""
f.rangeValidatorPinned = false
@@ -1723,7 +1836,7 @@ func (f *Fetcher) runConnectionWithResolveResp(conn *connection) {
conn.State = connConnecting
f.connMu.Unlock()
buf := make([]byte, 8192)
buf := make([]byte, connReadBufferSize)
// Get the resolve response
f.resolveRespLock.Lock()
@@ -1790,11 +1903,9 @@ func (f *Fetcher) runConnectionWithResolveResp(conn *connection) {
if err != nil {
if err == io.EOF {
f.connMu.Lock()
if f.meta.Res.Size == 0 {
f.meta.Res.Size = conn.Downloaded
if len(f.meta.Res.Files) > 0 && f.meta.Res.Files[0] != nil {
f.meta.Res.Files[0].Size = conn.Downloaded
}
if !f.hasKnownSize() {
f.setResourceSizeLocked(conn.Downloaded, true)
f.sequentialSizeUnknown = false
}
conn.Completed = true
conn.State = connCompleted
@@ -1817,7 +1928,7 @@ func (f *Fetcher) runConnectionWithResolveResp(conn *connection) {
func (f *Fetcher) runConnectionFallback(conn *connection) {
// Use fast-fail client for quick retry during download phase
client := f.buildFastFailClient()
buf := make([]byte, 8192)
buf := make([]byte, connReadBufferSize)
retries := 0
countedRetries := 0
@@ -1833,78 +1944,15 @@ func (f *Fetcher) runConnectionFallback(conn *connection) {
}
f.connMu.Lock()
if retries > 0 {
f.resetConnectionForRestart(conn)
}
conn.State = connConnecting
f.connMu.Unlock()
err := func() error {
httpReq, err := f.buildRequest(conn.ctx, f.meta.Req)
if err != nil {
return err
}
// Record connection start time for adaptive timeout tracking
connStartTime := time.Now()
resp, err := client.Do(httpReq)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != base.HttpCodeOK && resp.StatusCode != base.HttpCodePartialContent {
return NewRequestError(resp.StatusCode)
}
// Record successful connection time for adaptive timeout
f.updateMaxConnTime(time.Since(connStartTime))
f.connMu.Lock()
conn.State = connDownloading
conn.failed = false
f.connMu.Unlock()
f.primaryReadyOnce.Do(func() {
close(f.primaryReadyCh)
})
if f.slowStart != nil {
f.slowStart.onConnectSuccess()
}
reader := f.responseReader(resp)
for {
if conn.ctx.Err() != nil {
return conn.ctx.Err()
}
n, err := reader.Read(buf)
if n > 0 {
f.connMu.Lock()
writeOffset := conn.Chunk.Downloaded
f.connMu.Unlock()
f.fileMu.Lock()
if f.file != nil {
_, writeErr := f.file.WriteAt(buf[:n], writeOffset)
if writeErr != nil {
f.fileMu.Unlock()
return writeErr
}
}
f.fileMu.Unlock()
f.connMu.Lock()
conn.Chunk.Downloaded += int64(n)
conn.Downloaded += int64(n)
f.connMu.Unlock()
}
if err != nil {
if err == io.EOF {
return nil
}
return err
}
}
}()
// Restored tasks without a reusable Resolve response need the same
// length handling and restart semantics as other sequential requests.
err := f.downloadChunkOnce(conn, client, buf)
if err == nil {
f.connMu.Lock()
@@ -2038,7 +2086,7 @@ func (f *Fetcher) resetConnectionForRestart(conn *connection) {
// so pause/retry must restart instead of continuing from the old offset.
if conn.Chunk == nil {
conn.Chunk = newChunk(0, 0)
} else {
} else if !f.meta.Res.Range {
conn.Chunk.Begin = 0
conn.Chunk.setEnd(0)
conn.Chunk.Downloaded = 0
@@ -2053,14 +2101,14 @@ func (f *Fetcher) resetConnectionForRestart(conn *connection) {
// hasSequentialPrefixLocked reports whether a single sequential connection has
// a useful contiguous prefix. The caller must hold connMu.
func (f *Fetcher) hasSequentialPrefixLocked(conn *connection) bool {
if f.meta == nil || f.meta.Res == nil || f.meta.Res.Range || !f.rangeReprobeEligible || f.meta.Res.Size <= 0 {
if f.meta == nil || f.meta.Res == nil || f.meta.Res.Range || !f.rangeReprobeEligible || f.knownSize() <= 0 {
return false
}
if conn == nil || conn.ID != 0 || len(f.connections) != 1 || conn.Chunk == nil {
return false
}
downloaded := conn.Chunk.Downloaded
return downloaded > 0 && downloaded < f.meta.Res.Size
return downloaded > 0 && downloaded < f.knownSize()
}
// canProbeSequentialResumeLocked reports whether the prefix can be resumed
@@ -2146,7 +2194,7 @@ func (f *Fetcher) onDownloadComplete() {
needsMoreData := false
if f.meta.Res.Range {
needsMoreData = conn.Chunk != nil && (conn.Chunk.openEnded() || conn.Chunk.remain() > 0)
} else if f.meta.Res.Size > 0 {
} else if f.hasKnownSize() && f.meta.Res.Size > 0 {
needsMoreData = conn.Downloaded < f.meta.Res.Size
} else {
needsMoreData = !conn.Completed && conn.State != connCompleted
@@ -2156,7 +2204,7 @@ func (f *Fetcher) onDownloadComplete() {
// This connection has remaining work and isn't done
// Check if it failed with 403 (server limit) - these can be ignored if other connections completed the work
if conn.State == connFailed && conn.failed {
if re := extractRequestError(conn.lastErr); re != nil && re.Code == 403 {
if re := extractRequestError(conn.lastErr); re != nil && re.Code == 403 && f.hasKnownSize() {
// 403 is server connection limit, check if other connections will complete this chunk
continue
}
@@ -2167,7 +2215,7 @@ func (f *Fetcher) onDownloadComplete() {
}
// If total downloaded matches file size, consider it a success regardless of connection failures
downloadComplete := f.meta.Res.Size > 0 && totalDownloaded >= f.meta.Res.Size
downloadComplete := f.hasKnownSize() && f.meta.Res.Size > 0 && totalDownloaded >= f.meta.Res.Size
// Integrity errors must win over byte-count completion. A malformed response
// may be detected only after the expected bytes have already been written.
@@ -2184,7 +2232,7 @@ func (f *Fetcher) onDownloadComplete() {
for _, conn := range f.connections {
if conn.State == connFailed && conn.failed {
// Skip 403 errors (server connection limit) - these are expected when exceeding server's limit
if re := extractRequestError(conn.lastErr); re != nil && re.Code == 403 {
if re := extractRequestError(conn.lastErr); re != nil && re.Code == 403 && f.hasKnownSize() {
continue
}
if re := extractRequestError(conn.lastErr); re != nil {
@@ -2209,10 +2257,15 @@ func (f *Fetcher) onDownloadComplete() {
}
f.fileMu.Unlock()
if finalErr != nil {
f.setState(stateError)
if finalErr == nil {
if chkErr := f.verifyChecksum(); chkErr != nil {
finalErr = chkErr
f.setState(stateError)
} else {
f.setState(stateDone)
}
} else {
f.setState(stateDone)
f.setState(stateError)
}
select {
@@ -2221,6 +2274,51 @@ func (f *Fetcher) onDownloadComplete() {
}
}
func (f *Fetcher) verifyChecksum() error {
if f.meta == nil || f.meta.Opts == nil || f.meta.Opts.Extra == nil {
return nil
}
extra, ok := f.meta.Opts.Extra.(*fhttp.OptsExtra)
if !ok || extra.Checksum == nil {
return nil
}
chk := extra.Checksum
if chk.Algorithm == "" && chk.Expected == "" {
return nil
}
var h hash.Hash
switch strings.ToLower(strings.TrimSpace(chk.Algorithm)) {
case "md5":
h = md5.New()
case "sha1", "sha-1":
h = sha1.New()
case "sha256", "sha-256":
h = sha256.New()
default:
return fmt.Errorf("unsupported checksum algorithm: %s", chk.Algorithm)
}
filePath := f.meta.SingleFilepath()
file, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("open file for checksum verification failed: %w", err)
}
defer file.Close()
buf := make([]byte, 64*1024)
if _, err := io.CopyBuffer(h, file, buf); err != nil {
return fmt.Errorf("calculate checksum failed: %w", err)
}
actual := hex.EncodeToString(h.Sum(nil))
expected := strings.ToLower(strings.TrimSpace(chk.Expected))
if !strings.EqualFold(actual, expected) {
f.checksumFailed.Store(true)
return fmt.Errorf("checksum mismatch: expected %s, got %s", expected, actual)
}
return nil
}
func (f *Fetcher) checkCompletion() bool {
// Check if all data has been downloaded
f.connMu.Lock()
@@ -2234,7 +2332,7 @@ func (f *Fetcher) checkCompletion() bool {
totalDownloaded += conn.Downloaded
}
if f.meta.Res.Size > 0 && totalDownloaded >= f.meta.Res.Size {
if f.hasKnownSize() && f.meta.Res.Size > 0 && totalDownloaded >= f.meta.Res.Size {
// Don't start a new goroutine - let the caller handle completion
return true
}
@@ -2261,6 +2359,14 @@ func (f *Fetcher) checkCompletion() bool {
// Patch modifies the HTTP request information.
func (f *Fetcher) Patch(req *base.Request, opts *base.Options) error {
if opts != nil && opts.Size > 0 {
f.connMu.Lock()
f.meta.Opts.Size = opts.Size
if f.meta.Res != nil && !f.hasKnownSize() {
f.setResourceSizeLocked(0, false)
}
f.connMu.Unlock()
}
// Patch request info
if req != nil {
if req.URL != "" {
@@ -2358,8 +2464,8 @@ func (f *Fetcher) Pause() error {
conn := f.connections[0]
if conn != nil && conn.ID == 0 && conn.Chunk != nil && !conn.Completed {
conn.Chunk.Begin = prefetched
if f.meta.Res.Size > 0 {
conn.Chunk.setEnd(f.meta.Res.Size - 1)
if f.knownSize() > 0 {
conn.Chunk.setEnd(f.knownSize() - 1)
} else {
conn.Chunk.End = nil
}
@@ -0,0 +1,391 @@
package http
import (
"crypto/sha256"
"encoding/hex"
"fmt"
gohttp "net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/protocol/http"
)
// serveRangeContent serves byte range requests or full responses from data.
func serveRangeContent(w gohttp.ResponseWriter, r *gohttp.Request, data []byte) {
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
rangeHdr := r.Header.Get(base.HttpHeaderRange)
total := int64(len(data))
if rangeHdr == "" {
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", total))
w.WriteHeader(gohttp.StatusOK)
_, _ = w.Write(data)
return
}
// Parse bytes=start-end or bytes=start-
if !strings.HasPrefix(rangeHdr, "bytes=") {
w.WriteHeader(gohttp.StatusBadRequest)
return
}
spec := strings.TrimPrefix(rangeHdr, "bytes=")
parts := strings.Split(spec, "-")
start, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || start < 0 || start >= total {
w.WriteHeader(gohttp.StatusRequestedRangeNotSatisfiable)
return
}
end := total - 1
if len(parts) > 1 && parts[1] != "" {
if parsedEnd, err := strconv.ParseInt(parts[1], 10, 64); err == nil && parsedEnd >= start && parsedEnd < total {
end = parsedEnd
}
}
contentLength := end - start + 1
w.Header().Set(base.HttpHeaderContentRange, fmt.Sprintf("bytes %d-%d/%d", start, end, total))
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", contentLength))
w.WriteHeader(gohttp.StatusPartialContent)
_, _ = w.Write(data[start : end+1])
}
func TestFetcher_ChecksumMismatch_Retry(t *testing.T) {
corruptContent := []byte("Corrupt Initial Download Data 33B")
correctContent := []byte("Correct Retried Download Data 33B")
correctSha256Hasher := sha256.New()
correctSha256Hasher.Write(correctContent)
correctSha256 := hex.EncodeToString(correctSha256Hasher.Sum(nil))
var phase atomic.Int32 // 0: serve corruptContent, 1: serve correctContent
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
var served []byte
if phase.Load() == 0 {
served = corruptContent
} else {
served = correctContent
}
serveRangeContent(w, r, served)
}))
defer server.Close()
downloadDir := t.TempDir()
fileName := "test_checksum_retry.txt"
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: downloadDir,
Name: fileName,
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "sha256",
Expected: correctSha256,
},
},
}
// 1. Initial attempt -> will download corruptContent, failing checksum verification
if err := f.Resolve(&base.Request{URL: server.URL + "/retry_test.txt"}); err != nil {
t.Fatalf("Resolve error: %v", err)
}
f.Meta().Opts = opts
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
err := f.Wait()
if err == nil {
t.Fatal("expected checksum mismatch error on initial attempt, got nil")
}
if !strings.Contains(err.Error(), "checksum mismatch") {
t.Fatalf("expected 'checksum mismatch' in error, got %v", err)
}
if f.getState() != stateError {
t.Fatalf("expected stateError after checksum mismatch, got %v", f.getState())
}
// Flip phase to 1 after observing checksum mismatch
phase.Store(1)
// 2. Retry download -> should trigger forced full reset, truncate file, and re-download correctContent
if err := f.Start(); err != nil {
t.Fatalf("Start error on retry: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error on retry (expected success): %v", err)
}
if f.getState() != stateDone {
t.Fatalf("expected stateDone after successful retry, got %v", f.getState())
}
// 3. Verify on-disk file content matches the re-downloaded bytes
filePath := f.meta.SingleFilepath()
fileBytes, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("failed to read completed file: %v", err)
}
if string(fileBytes) != string(correctContent) {
t.Fatalf("on-disk file content mismatch:\ngot: %q\nwant: %q", string(fileBytes), string(correctContent))
}
// 4. Compute hash independently and verify
actualHasher := sha256.New()
actualHasher.Write(fileBytes)
actualHash := hex.EncodeToString(actualHasher.Sum(nil))
if actualHash != correctSha256 {
t.Fatalf("re-computed on-disk hash mismatch: got %s, want %s", actualHash, correctSha256)
}
}
func TestFetcher_ChecksumMismatch_Retry_PartialPrefetch(t *testing.T) {
corruptContent := []byte("Corrupt Initial Download Data 33B")
correctContent := []byte("Correct Retried Download Data 33B")
correctSha256Hasher := sha256.New()
correctSha256Hasher.Write(correctContent)
correctSha256 := hex.EncodeToString(correctSha256Hasher.Sum(nil))
var phase atomic.Int32 // 0: corrupt, 1: correct
var isFirstRequest atomic.Bool
isFirstRequest.Store(true)
releasePrefetch := make(chan struct{})
var closeReleaseOnce sync.Once
var mu sync.Mutex
var phase1Ranges []string
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
curPhase := phase.Load()
if curPhase == 1 {
mu.Lock()
phase1Ranges = append(phase1Ranges, r.Header.Get(base.HttpHeaderRange))
mu.Unlock()
serveRangeContent(w, r, correctContent)
return
}
// Phase 0: First request is Resolve GET without Range header.
// Write exactly 7 bytes and flush, then block until release channel closes or context is done.
if isFirstRequest.CompareAndSwap(true, false) {
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(corruptContent)))
w.WriteHeader(gohttp.StatusOK)
_, _ = w.Write(corruptContent[:7])
if flusher, ok := w.(gohttp.Flusher); ok {
flusher.Flush()
}
select {
case <-releasePrefetch:
case <-r.Context().Done():
}
return
}
// Subsequent requests in Phase 0 (such as Range request from Start)
serveRangeContent(w, r, corruptContent)
}))
defer server.Close()
defer closeReleaseOnce.Do(func() { close(releasePrefetch) })
downloadDir := t.TempDir()
fileName := "test_checksum_prefetch_retry.txt"
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: downloadDir,
Name: fileName,
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "sha256",
Expected: correctSha256,
},
},
}
// 1. Resolve phase
if err := f.Resolve(&base.Request{URL: server.URL + "/prefetch_retry.txt"}); err != nil {
t.Fatalf("Resolve error: %v", err)
}
f.Meta().Opts = opts
// Wait until prefetch has received exactly 7 bytes
for i := 0; i < 200; i++ {
if f.prefetchSize.Load() >= 7 {
break
}
time.Sleep(10 * time.Millisecond)
}
if f.prefetchSize.Load() < 7 {
t.Fatalf("expected at least 7 prefetched bytes, got %d", f.prefetchSize.Load())
}
// Release prefetch handler right before Start
closeReleaseOnce.Do(func() { close(releasePrefetch) })
// 2. Start initial download attempt (will complete with corruptContent and fail checksum)
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
err := f.Wait()
if err == nil {
t.Fatal("expected checksum mismatch error on initial attempt, got nil")
}
if !strings.Contains(err.Error(), "checksum mismatch") {
t.Fatalf("expected 'checksum mismatch' in error, got %v", err)
}
if f.getState() != stateError {
t.Fatalf("expected stateError after checksum mismatch, got %v", f.getState())
}
// 3. Flip to phase 1 (correctContent) and retry
phase.Store(1)
if err := f.Start(); err != nil {
t.Fatalf("Start error on retry: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error on retry (expected success): %v", err)
}
if f.getState() != stateDone {
t.Fatalf("expected stateDone after successful retry, got %v", f.getState())
}
// Verify that in Phase 1 at least one Range request started at byte 0
mu.Lock()
rangesSeen := append([]string(nil), phase1Ranges...)
mu.Unlock()
hasByteZero := false
for _, r := range rangesSeen {
if strings.HasPrefix(r, "bytes=0-") {
hasByteZero = true
break
}
}
if !hasByteZero {
t.Fatalf("expected at least one Phase 1 Range request starting at byte 0, saw: %v", rangesSeen)
}
// Verify on-disk content matches correctContent exactly
filePath := f.meta.SingleFilepath()
fileBytes, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("failed to read completed file: %v", err)
}
if string(fileBytes) != string(correctContent) {
t.Fatalf("on-disk file content mismatch:\ngot: %q\nwant: %q", string(fileBytes), string(correctContent))
}
// Compute hash independently and verify
actualHasher := sha256.New()
actualHasher.Write(fileBytes)
actualHash := hex.EncodeToString(actualHasher.Sum(nil))
if actualHash != correctSha256 {
t.Fatalf("re-computed on-disk hash mismatch: got %s, want %s", actualHash, correctSha256)
}
}
func TestFetcher_ChecksumMismatch_Retry_NonRange(t *testing.T) {
corruptContent := []byte("Corrupt Non-Range Initial Data 33B")
correctContent := []byte("Correct Non-Range Retried Data 33B")
correctSha256Hasher := sha256.New()
correctSha256Hasher.Write(correctContent)
correctSha256 := hex.EncodeToString(correctSha256Hasher.Sum(nil))
var phase atomic.Int32 // 0: corrupt, 1: correct
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
// Server does not support Range (always returns full content 200 OK)
var served []byte
if phase.Load() == 0 {
served = corruptContent
} else {
served = correctContent
}
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(served)))
w.WriteHeader(gohttp.StatusOK)
_, _ = w.Write(served)
}))
defer server.Close()
downloadDir := t.TempDir()
fileName := "test_checksum_nonrange_retry.txt"
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: downloadDir,
Name: fileName,
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "sha256",
Expected: correctSha256,
},
},
}
// 1. Resolve and start initial attempt
if err := f.Resolve(&base.Request{URL: server.URL + "/nonrange_retry.txt"}); err != nil {
t.Fatalf("Resolve error: %v", err)
}
f.Meta().Opts = opts
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
err := f.Wait()
if err == nil {
t.Fatal("expected checksum mismatch error on initial non-range attempt, got nil")
}
if !strings.Contains(err.Error(), "checksum mismatch") {
t.Fatalf("expected 'checksum mismatch' in error, got %v", err)
}
if f.getState() != stateError {
t.Fatalf("expected stateError after checksum mismatch, got %v", f.getState())
}
// 2. Switch phase and retry
phase.Store(1)
if err := f.Start(); err != nil {
t.Fatalf("Start error on non-range retry: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error on non-range retry (expected success): %v", err)
}
if f.getState() != stateDone {
t.Fatalf("expected stateDone after successful non-range retry, got %v", f.getState())
}
// 3. Verify on-disk file content
filePath := f.meta.SingleFilepath()
fileBytes, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("failed to read completed file: %v", err)
}
if string(fileBytes) != string(correctContent) {
t.Fatalf("on-disk file content mismatch:\ngot: %q\nwant: %q", string(fileBytes), string(correctContent))
}
// 4. Compute hash independently and verify
actualHasher := sha256.New()
actualHasher.Write(fileBytes)
actualHash := hex.EncodeToString(actualHasher.Sum(nil))
if actualHash != correctSha256 {
t.Fatalf("re-computed on-disk hash mismatch: got %s, want %s", actualHash, correctSha256)
}
}
@@ -0,0 +1,327 @@
package http
import (
"crypto/md5"
"crypto/sha256"
"encoding/hex"
"fmt"
gohttp "net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"github.com/GopeedLab/gopeed/internal/test"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/protocol/http"
)
func TestFetcher_ChecksumVerification(t *testing.T) {
content := []byte("Hello, Gopeed Checksum Verification Test Data!")
md5Hasher := md5.New()
md5Hasher.Write(content)
correctMd5 := hex.EncodeToString(md5Hasher.Sum(nil))
sha256Hasher := sha256.New()
sha256Hasher.Write(content)
correctSha256 := hex.EncodeToString(sha256Hasher.Sum(nil))
wrongHash := "0123456789abcdef0123456789abcdef"
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(content)))
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
w.WriteHeader(gohttp.StatusOK)
_, _ = w.Write(content)
}))
defer server.Close()
t.Run("Valid MD5", func(t *testing.T) {
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: "test_valid_md5.txt",
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "md5",
Expected: correctMd5,
},
},
}
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}); err != nil {
t.Fatalf("Resolve error: %v", err)
}
f.Meta().Opts = opts
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error (expected success): %v", err)
}
})
t.Run("Valid SHA256", func(t *testing.T) {
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: "test_valid_sha256.txt",
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "sha256",
Expected: correctSha256,
},
},
}
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}); err != nil {
t.Fatalf("Resolve error: %v", err)
}
f.Meta().Opts = opts
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error (expected success): %v", err)
}
})
t.Run("Wrong MD5 mismatch", func(t *testing.T) {
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: "test_wrong_md5.txt",
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "md5",
Expected: wrongHash,
},
},
}
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}); err != nil {
t.Fatalf("Resolve error: %v", err)
}
f.Meta().Opts = opts
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
err := f.Wait()
if err == nil {
t.Fatal("Wait expected checksum mismatch error, got nil")
}
if !strings.Contains(err.Error(), "checksum mismatch") {
t.Fatalf("expected 'checksum mismatch' in error, got %v", err)
}
})
t.Run("Unsupported Algorithm", func(t *testing.T) {
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: "test_unsupported.txt",
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "invalid-algo",
Expected: wrongHash,
},
},
}
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}); err != nil {
t.Fatalf("Resolve error: %v", err)
}
f.Meta().Opts = opts
err := f.Start()
if err == nil {
t.Fatal("Start expected error for unsupported algorithm, got nil")
}
if !strings.Contains(err.Error(), "unsupported checksum algorithm") {
t.Fatalf("expected 'unsupported checksum algorithm' in error, got %v", err)
}
if f.checksumFailed.Load() {
t.Fatal("checksumFailed flag should NOT be set on unsupported algorithm error")
}
})
t.Run("Case-Insensitive Hash Comparison", func(t *testing.T) {
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: "test_case_insensitive.txt",
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "sha256",
Expected: strings.ToUpper(correctSha256),
},
},
}
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}); err != nil {
t.Fatalf("Resolve error: %v", err)
}
f.Meta().Opts = opts
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error (expected success with upper case hash): %v", err)
}
})
t.Run("Empty Expected Checksum (Skipped)", func(t *testing.T) {
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: "test_empty_expected.txt",
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "",
Expected: "",
},
},
}
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}); err != nil {
t.Fatalf("Resolve error: %v", err)
}
f.Meta().Opts = opts
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error (expected skip and success): %v", err)
}
})
t.Run("Large File Multi-Chunk Streaming (>64KB)", func(t *testing.T) {
largeData := make([]byte, 128*1024)
for i := range largeData {
largeData[i] = byte(i % 256)
}
largeSha256Hasher := sha256.New()
largeSha256Hasher.Write(largeData)
largeSha256 := hex.EncodeToString(largeSha256Hasher.Sum(nil))
largeServer := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(largeData)))
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
w.WriteHeader(gohttp.StatusOK)
_, _ = w.Write(largeData)
}))
defer largeServer.Close()
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: "test_large_file.bin",
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "sha256",
Expected: largeSha256,
},
},
}
if err := f.Resolve(&base.Request{URL: largeServer.URL + "/large.bin"}); err != nil {
t.Fatalf("Resolve error: %v", err)
}
f.Meta().Opts = opts
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error (expected large file checksum success): %v", err)
}
})
t.Run("No Checksum (Opt-in backward compatibility)", func(t *testing.T) {
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: test.DownloadName,
Extra: &http.OptsExtra{
Connections: 1,
},
}
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}); err != nil {
t.Fatalf("Resolve error: %v", err)
}
f.Meta().Opts = opts
if err := f.Start(); err != nil {
t.Fatalf("Start error: %v", err)
}
if err := f.Wait(); err != nil {
t.Fatalf("Wait error: %v", err)
}
})
t.Run("Invalid Checksum In Start Makes No Additional Requests", func(t *testing.T) {
var reqReceived atomic.Int32
customServer := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
reqReceived.Add(1)
w.WriteHeader(gohttp.StatusOK)
_, _ = w.Write([]byte("some data"))
}))
defer customServer.Close()
f := buildFetcher()
defer f.Close()
opts := &base.Options{
Path: t.TempDir(),
Name: "test_invalid_checksum.txt",
Extra: &http.OptsExtra{
Connections: 1,
Checksum: &http.ChecksumOption{
Algorithm: "sha256",
Expected: "invalid-hex-chars-not-valid-hex-00000000000000000000000000000000",
},
},
}
if err := f.Resolve(&base.Request{URL: customServer.URL + "/test.txt"}); err != nil {
t.Fatalf("Resolve error: %v", err)
}
requestsAfterResolve := reqReceived.Load()
f.Meta().Opts = opts
err := f.Start()
if err == nil {
t.Fatal("Start expected error for invalid checksum hash, got nil")
}
if got := reqReceived.Load(); got != requestsAfterResolve {
t.Fatalf("invalid checksum in Start issued additional requests: got %d, had %d after Resolve", got, requestsAfterResolve)
}
})
}
+10 -3
View File
@@ -19,10 +19,11 @@ type fetcherData struct {
IfRange string // Strong ETag or Last-Modified validator for safe resume
RangeReprobeEligible bool // Origin advertised Range before falling back to sequential mode
RangeValidatorPinned bool // Recovered Range mode must keep the same If-Range validator
SequentialSizeUnknown bool // Interrupted chunked restart must retry from byte zero
SequentialSizeUnknown bool // Interrupted chunked sequential streams restart from byte zero
SizeKnown *bool // Nil for legacy snapshots; false means Size is only a display estimate
Range *bool // Authoritative Range mode; nil for records saved by older versions
ResourceSize *int64 // Authoritative resource size from the same snapshot as Connections
FileSize *int64 // Authoritative single-file size; nil for older records
ResourceSize *int64 // Display size from the same snapshot; authority is recorded by SizeKnown
FileSize *int64 // Single-file size from the same snapshot; nil for older records
}
// ============================================================================
@@ -93,6 +94,7 @@ func (fm *FetcherManager) Store(f fetcher.Fetcher) (data any, err error) {
rangeReprobeEligible := _f.rangeReprobeEligible
rangeValidatorPinned := _f.rangeValidatorPinned
sequentialSizeUnknown := _f.sequentialSizeUnknown
sizeKnown := _f.hasKnownSize()
var rangeMode *bool
var resourceSize *int64
var fileSize *int64
@@ -115,6 +117,7 @@ func (fm *FetcherManager) Store(f fetcher.Fetcher) (data any, err error) {
RangeReprobeEligible: rangeReprobeEligible,
RangeValidatorPinned: rangeValidatorPinned,
SequentialSizeUnknown: sequentialSizeUnknown,
SizeKnown: &sizeKnown,
Range: rangeMode,
ResourceSize: resourceSize,
FileSize: fileSize,
@@ -168,6 +171,10 @@ func (fm *FetcherManager) Restore() (v any, f func(meta *fetcher.FetcherMeta, v
fetcher.rangeReprobeEligible = fd.RangeReprobeEligible
fetcher.rangeValidatorPinned = fd.RangeValidatorPinned
fetcher.sequentialSizeUnknown = fd.SequentialSizeUnknown
if fd.SizeKnown != nil {
known := *fd.SizeKnown
fetcher.sizeKnown = &known
}
if fd.Range != nil && fetcher.meta.Res != nil {
// Recovery-critical resource fields live in the same persisted snapshot
// as Connections, making them authoritative over task metadata that may
+125 -71
View File
@@ -13,6 +13,7 @@ import (
"net/url"
"os"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@@ -232,10 +233,11 @@ func TestFetcher_ResolveFilenameFromContentType(t *testing.T) {
defer fetcher.Pause()
const opaqueName = "OIP-C.u5nEXTXND3PJF0gNTgw5YgHaFP"
err := fetcher.Resolve(&base.Request{URL: server.URL + "/" + opaqueName}, &base.Options{
fetcher.Meta().Opts = &base.Options{
Name: test.DownloadName,
Path: test.Dir,
})
}
err := fetcher.Resolve(&base.Request{URL: server.URL + "/" + opaqueName})
if err != nil {
t.Fatal(err)
}
@@ -250,6 +252,10 @@ func TestFetcher_ResolveWithHostHeader(t *testing.T) {
defer listener.Close()
fetcher := buildFetcher()
fetcher.Meta().Opts = &base.Options{
Name: test.DownloadName,
Path: test.Dir,
}
err := fetcher.Resolve(&base.Request{
URL: "http://" + listener.Addr().String() + "/",
Extra: &http.ReqExtra{
@@ -257,9 +263,6 @@ func TestFetcher_ResolveWithHostHeader(t *testing.T) {
"Host": "test",
},
},
}, &base.Options{
Name: test.DownloadName,
Path: test.Dir,
})
// The server should return 400 for invalid Host header
if err == nil || !strings.Contains(err.Error(), "400") {
@@ -273,6 +276,10 @@ func TestFetcher_ResolveWithInvalidHeader(t *testing.T) {
fetcher := buildFetcher()
defer fetcher.Pause() // Close the resolve response to allow server shutdown
fetcher.Meta().Opts = &base.Options{
Name: test.DownloadName,
Path: test.Dir,
}
err := fetcher.Resolve(&base.Request{
URL: "http://" + listener.Addr().String() + "/",
Extra: &http.ReqExtra{
@@ -280,9 +287,6 @@ func TestFetcher_ResolveWithInvalidHeader(t *testing.T) {
"Referer": "\rtest",
},
},
}, &base.Options{
Name: test.DownloadName,
Path: test.Dir,
})
// Invalid header with \r should be sanitized by Go's http client, allowing the request to succeed
if err != nil {
@@ -302,12 +306,13 @@ func TestFetcher_ResolveAutomaticallyFallsBackToBrowserFingerprint(t *testing.T)
fetcher := buildFetcher()
defer fetcher.Pause()
fetcher.Meta().Opts = &base.Options{
Name: test.DownloadName,
Path: test.Dir,
}
err := fetcher.Resolve(&base.Request{
URL: server.URL + "/file.bin",
SkipVerifyCert: true,
}, &base.Options{
Name: test.DownloadName,
Path: test.Dir,
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
@@ -368,11 +373,12 @@ func testResolve(startTestServer func() net.Listener, path string, t *testing.T,
defer listener.Close()
fetcher := buildFetcher()
defer fetcher.Pause() // Close the resolve response to allow server shutdown
err := fetcher.Resolve(&base.Request{
URL: "http://" + listener.Addr().String() + "/" + path,
}, &base.Options{
fetcher.Meta().Opts = &base.Options{
Name: test.DownloadName,
Path: test.Dir,
}
err := fetcher.Resolve(&base.Request{
URL: "http://" + listener.Addr().String() + "/" + path,
})
want, err := wantFn(err)
if err != nil {
@@ -424,13 +430,14 @@ func TestFetcher_PauseBeforeStartDiscardsPrefetchProgress(t *testing.T) {
defer server.Close()
fetcher := buildFetcher()
if err := fetcher.Resolve(&base.Request{URL: server.URL + "/" + test.BuildName}, &base.Options{
fetcher.Meta().Opts = &base.Options{
Path: t.TempDir(),
Name: test.DownloadName,
Extra: &http.OptsExtra{
Connections: 2,
},
}); err != nil {
}
if err := fetcher.Resolve(&base.Request{URL: server.URL + "/" + test.BuildName}); err != nil {
t.Fatal(err)
}
select {
@@ -771,13 +778,14 @@ func TestFetcher_SequentialRetryReprobesRange(t *testing.T) {
defer server.Close()
f := buildFetcher()
if err := f.Resolve(&base.Request{URL: server.URL + "/dynamic-range.data"}, &base.Options{
f.Meta().Opts = &base.Options{
Path: t.TempDir(),
Name: "dynamic-range.data",
Extra: &http.OptsExtra{
Connections: 4,
},
}); err != nil {
}
if err := f.Resolve(&base.Request{URL: server.URL + "/dynamic-range.data"}); err != nil {
t.Fatal(err)
}
if err := f.Start(); err != nil {
@@ -853,31 +861,44 @@ func TestFetcher_InitialIgnoredRangeUsesResponseSize(t *testing.T) {
resolvePayload := bytes.Repeat([]byte("resolve"), 64*1024)
replacement := bytes.Repeat([]byte("replacement"), 24*1024)
var rangeRequests atomic.Int32
rangeStarted := make(chan struct{})
releaseResolve := sync.OnceFunc(func() { close(rangeStarted) })
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
if r.Header.Get(base.HttpHeaderRange) == "" {
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(resolvePayload)))
w.WriteHeader(gohttp.StatusOK)
w.(gohttp.Flusher).Flush()
// Keep Resolve prefetch incomplete until the Range request starts.
// Otherwise Start may reuse the completed body and skip this scenario.
select {
case <-rangeStarted:
case <-r.Context().Done():
return
}
_, _ = w.Write(resolvePayload)
return
}
rangeRequests.Add(1)
releaseResolve()
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(replacement)))
w.WriteHeader(gohttp.StatusOK)
_, _ = w.Write(replacement)
}))
defer server.Close()
defer releaseResolve()
f := buildFetcher()
if err := f.Resolve(&base.Request{URL: server.URL + "/ignored-range.data"}, &base.Options{
f.Meta().Opts = &base.Options{
Path: t.TempDir(),
Name: "ignored-range.data",
Extra: &http.OptsExtra{
Connections: 4,
},
}); err != nil {
}
if err := f.Resolve(&base.Request{URL: server.URL + "/ignored-range.data"}); err != nil {
t.Fatal(err)
}
if err := f.Start(); err != nil {
@@ -968,13 +989,14 @@ func TestFetcher_InterruptedChunkedReplacementRestartsFromZero(t *testing.T) {
defer server.Close()
f := buildFetcher()
if err := f.Resolve(&base.Request{URL: server.URL + "/chunked-replacement.data"}, &base.Options{
f.Meta().Opts = &base.Options{
Path: t.TempDir(),
Name: "chunked-replacement.data",
Extra: &http.OptsExtra{
Connections: 4,
},
}); err != nil {
}
if err := f.Resolve(&base.Request{URL: server.URL + "/chunked-replacement.data"}); err != nil {
t.Fatal(err)
}
if err := f.Start(); err != nil {
@@ -1154,13 +1176,14 @@ func TestFetcher_UsesResolveResponseWhenRangeTakeoverFails(t *testing.T) {
defer server.Close()
f := buildFetcher()
if err := f.Resolve(&base.Request{URL: server.URL + "/resolve-fallback.data"}, &base.Options{
f.Meta().Opts = &base.Options{
Path: t.TempDir(),
Name: "resolve-fallback.data",
Extra: &http.OptsExtra{
Connections: 1,
},
}); err != nil {
}
if err := f.Resolve(&base.Request{URL: server.URL + "/resolve-fallback.data"}); err != nil {
t.Fatal(err)
}
defer f.Close()
@@ -1233,13 +1256,14 @@ func TestFetcher_ValidRangeTakesOverResolveResponse(t *testing.T) {
defer server.Close()
f := buildFetcher()
if err := f.Resolve(&base.Request{URL: server.URL + "/resolve-takeover.data"}, &base.Options{
f.Meta().Opts = &base.Options{
Path: t.TempDir(),
Name: "resolve-takeover.data",
Extra: &http.OptsExtra{
Connections: 4,
},
}); err != nil {
}
if err := f.Resolve(&base.Request{URL: server.URL + "/resolve-takeover.data"}); err != nil {
t.Fatal(err)
}
defer f.Close()
@@ -1263,6 +1287,12 @@ func TestFetcher_ValidRangeTakesOverResolveResponse(t *testing.T) {
}
func TestFetcher_UnknownSizeRangeUsesOpenEndedWorker(t *testing.T) {
for _, hint := range []int64{0, 3, 8 * 1024 * 1024} {
t.Run(fmt.Sprint(hint), func(t *testing.T) { testFetcherUnknownSizeRange(t, hint) })
}
}
func testFetcherUnknownSizeRange(t *testing.T, hint int64) {
const resolveETag = `"unknown-size-v1"`
payload := make([]byte, 4*1024*1024)
for i := range payload {
@@ -1327,18 +1357,20 @@ func TestFetcher_UnknownSizeRangeUsesOpenEndedWorker(t *testing.T) {
defer server.Close()
f := buildFetcher()
if err := f.Resolve(&base.Request{URL: server.URL + "/unknown-size-range.data"}, &base.Options{
f.Meta().Opts = &base.Options{
Size: hint,
Path: t.TempDir(),
Name: "unknown-size-range.data",
Extra: &http.OptsExtra{
Connections: 4,
},
}); err != nil {
}
if err := f.Resolve(&base.Request{URL: server.URL + "/unknown-size-range.data"}); err != nil {
t.Fatal(err)
}
defer f.Close()
if f.meta.Res.Size != 0 || !f.meta.Res.Range {
if f.meta.Res.Size != hint || f.hasKnownSize() || !f.meta.Res.Range {
t.Fatalf("resolved resource = %+v, want unknown size with Range support", f.meta.Res)
}
if err := f.Start(); err != nil {
@@ -1421,13 +1453,14 @@ func TestFetcher_UnknownSizeRangeRetriesShortPartialBody(t *testing.T) {
defer server.Close()
f := buildFetcher()
if err := f.Resolve(&base.Request{URL: server.URL + "/short-range.data"}, &base.Options{
f.Meta().Opts = &base.Options{
Path: t.TempDir(),
Name: "short-range.data",
Extra: &http.OptsExtra{
Connections: 1,
},
}); err != nil {
}
if err := f.Resolve(&base.Request{URL: server.URL + "/short-range.data"}); err != nil {
t.Fatal(err)
}
defer f.Close()
@@ -1480,13 +1513,14 @@ func TestFetcher_UnknownSizeRangeFallsBackToResolve(t *testing.T) {
defer server.Close()
f := buildFetcher()
if err := f.Resolve(&base.Request{URL: server.URL + "/unknown-size-fallback.data"}, &base.Options{
f.Meta().Opts = &base.Options{
Path: t.TempDir(),
Name: "unknown-size-fallback.data",
Extra: &http.OptsExtra{
Connections: 1,
},
}); err != nil {
}
if err := f.Resolve(&base.Request{URL: server.URL + "/unknown-size-fallback.data"}); err != nil {
t.Fatal(err)
}
defer f.Close()
@@ -1567,13 +1601,15 @@ func TestFetcher_UnknownSizeRangeRemainsOpenEndedAfterPause(t *testing.T) {
defer server.Close()
f := buildFetcher()
if err := f.Resolve(&base.Request{URL: server.URL + "/pause-open-range.data"}, &base.Options{
f.Meta().Opts = &base.Options{
Size: 3,
Path: t.TempDir(),
Name: "pause-open-range.data",
Extra: &http.OptsExtra{
Connections: 1,
},
}); err != nil {
}
if err := f.Resolve(&base.Request{URL: server.URL + "/pause-open-range.data"}); err != nil {
t.Fatal(err)
}
defer f.Close()
@@ -1622,13 +1658,14 @@ func TestFetcher_UnknownSizeResolveReadErrorFailsTask(t *testing.T) {
defer server.Close()
f := buildFetcher()
if err := f.Resolve(&base.Request{URL: server.URL + "/broken-chunked.data"}, &base.Options{
f.Meta().Opts = &base.Options{
Path: t.TempDir(),
Name: "broken-chunked.data",
Extra: &http.OptsExtra{
Connections: 1,
},
}); err != nil {
}
if err := f.Resolve(&base.Request{URL: server.URL + "/broken-chunked.data"}); err != nil {
t.Fatal(err)
}
defer f.Close()
@@ -1654,13 +1691,14 @@ func TestFetcher_UnknownSizeResolveCleanEOFUpdatesSize(t *testing.T) {
defer server.Close()
f := buildFetcher()
if err := f.Resolve(&base.Request{URL: server.URL + "/unknown-size.data"}, &base.Options{
f.Meta().Opts = &base.Options{
Path: t.TempDir(),
Name: "unknown-size.data",
Extra: &http.OptsExtra{
Connections: 1,
},
}); err != nil {
}
if err := f.Resolve(&base.Request{URL: server.URL + "/unknown-size.data"}); err != nil {
t.Fatal(err)
}
defer f.Close()
@@ -2256,14 +2294,15 @@ func TestFetcher_DownloadOneTimeURL(t *testing.T) {
defer listener.Close()
fetcher := buildFetcher()
err := fetcher.Resolve(&base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
}, &base.Options{
fetcher.Meta().Opts = &base.Options{
Name: test.DownloadName,
Path: test.Dir,
Extra: &http.OptsExtra{
Connections: 4, // Try to use multiple connections, but only first should work
},
}
err := fetcher.Resolve(&base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
})
if err != nil {
t.Fatal(err)
@@ -2322,14 +2361,15 @@ func TestFetcher_SlowStartExpansion(t *testing.T) {
Connections: tc.maxConns,
})
err := fetcher.Resolve(&base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
}, &base.Options{
fetcher.Meta().Opts = &base.Options{
Name: test.DownloadName,
Path: test.Dir,
Extra: &http.OptsExtra{
Connections: tc.maxConns,
},
}
err := fetcher.Resolve(&base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
})
if err != nil {
cleanup()
@@ -2385,14 +2425,15 @@ func TestFetcher_AsyncPrefetch(t *testing.T) {
defer listener.Close()
fetcher := buildFetcher()
err := fetcher.Resolve(&base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
}, &base.Options{
fetcher.Meta().Opts = &base.Options{
Name: test.DownloadName,
Path: test.Dir,
Extra: &http.OptsExtra{
Connections: 4,
},
}
err := fetcher.Resolve(&base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
})
if err != nil {
t.Fatal(err)
@@ -2456,14 +2497,15 @@ func TestFetcher_AsyncPrefetch(t *testing.T) {
defer listener.Close()
fetcher := buildFetcher()
err := fetcher.Resolve(&base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
}, &base.Options{
fetcher.Meta().Opts = &base.Options{
Name: test.DownloadName,
Path: test.Dir,
Extra: &http.OptsExtra{
Connections: 4,
},
}
err := fetcher.Resolve(&base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
})
if err != nil {
t.Fatal(err)
@@ -2538,14 +2580,15 @@ func TestFetcher_DownloadExpiringRedirectURL(t *testing.T) {
defer listener.Close()
fetcher := buildFetcher()
err := fetcher.Resolve(&base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
}, &base.Options{
fetcher.Meta().Opts = &base.Options{
Name: test.DownloadName,
Path: test.Dir,
Extra: &http.OptsExtra{
Connections: 4, // Use multiple connections to trigger redirect expiration
},
}
err := fetcher.Resolve(&base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
})
if err != nil {
t.Fatal(err)
@@ -2579,14 +2622,15 @@ func TestFetcher_DownloadExpiringRedirectURL(t *testing.T) {
defer listener.Close()
fetcher := buildFetcher()
err := fetcher.Resolve(&base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
}, &base.Options{
fetcher.Meta().Opts = &base.Options{
Name: test.DownloadName,
Path: test.Dir,
Extra: &http.OptsExtra{
Connections: 8,
},
}
err := fetcher.Resolve(&base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
})
if err != nil {
t.Fatal(err)
@@ -2626,14 +2670,15 @@ func TestFetcher_RetryAfterError(t *testing.T) {
defer listener.Close()
fetcher := buildFetcher()
err := fetcher.Resolve(&base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
}, &base.Options{
fetcher.Meta().Opts = &base.Options{
Name: test.DownloadName,
Path: test.Dir,
Extra: &http.OptsExtra{
Connections: 1, // Use single connection to simplify test
},
}
err := fetcher.Resolve(&base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
})
if err != nil {
t.Fatal(err)
@@ -3054,9 +3099,10 @@ func doDownloadReady(f fetcher.Fetcher, listener net.Listener, connections int,
Path: test.Dir,
Extra: extra,
}
f.Meta().Opts = opts
err := f.Resolve(&base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
}, opts)
})
if err != nil {
t.Fatal(err)
}
@@ -3105,7 +3151,8 @@ func downloadPost(listener net.Listener, connections int, t *testing.T) {
Body: fmt.Sprintf(`{"name":"%s"}`, test.BuildName),
},
}
err := f.Resolve(req, opts)
f.Meta().Opts = opts
err := f.Resolve(req)
if err != nil {
t.Fatal(err)
}
@@ -3151,11 +3198,12 @@ func downloadContinue(listener net.Listener, connections int, t *testing.T) {
func downloadError(listener net.Listener, connections int, t *testing.T) {
fetcher := buildFetcher()
err := fetcher.Resolve(&base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
}, &base.Options{
fetcher.Meta().Opts = &base.Options{
Name: test.DownloadName,
Path: test.Dir,
}
err := fetcher.Resolve(&base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
})
// With the new async design, Resolve may succeed (HTTP response received)
// but errors occur during async download or Start/Wait
@@ -3279,7 +3327,8 @@ func TestFetcher_Patch_URLChange(t *testing.T) {
}
// Step 1: Try to resolve with bad URL - should fail with error
err := f.Resolve(&base.Request{URL: badURL}, opts)
f.Meta().Opts = opts
err := f.Resolve(&base.Request{URL: badURL})
if err == nil {
t.Fatal("Expected error for bad URL, got nil")
}
@@ -3289,7 +3338,8 @@ func TestFetcher_Patch_URLChange(t *testing.T) {
f2 := buildFetcher()
// First resolve with good URL to create a valid fetcher state
err = f2.Resolve(&base.Request{URL: goodURL}, opts)
f2.Meta().Opts = opts
err = f2.Resolve(&base.Request{URL: goodURL})
if err != nil {
t.Fatal(err)
}
@@ -3341,13 +3391,14 @@ func TestFetcher_Patch_Labels(t *testing.T) {
Extra: &http.OptsExtra{Connections: 1},
}
f.Meta().Opts = opts
err := f.Resolve(&base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
Labels: map[string]string{
"key1": "value1",
"key3": "value3",
},
}, opts)
})
if err != nil {
t.Fatal(err)
}
@@ -3398,6 +3449,7 @@ func TestFetcher_Patch_Extra(t *testing.T) {
}
// Resolve with initial Extra
f.Meta().Opts = opts
err := f.Resolve(&base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
Extra: &http.ReqExtra{
@@ -3408,7 +3460,7 @@ func TestFetcher_Patch_Extra(t *testing.T) {
"X-Custom": "original",
},
},
}, opts)
})
if err != nil {
t.Fatal(err)
}
@@ -3486,9 +3538,10 @@ func TestFetcher_Patch_NilData(t *testing.T) {
Extra: &http.OptsExtra{Connections: 1},
}
f.Meta().Opts = opts
err := f.Resolve(&base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
}, opts)
})
if err != nil {
t.Fatal(err)
}
@@ -3538,6 +3591,7 @@ func TestFetcher_Patch_CookieExpired(t *testing.T) {
// Step 1: Resolve with old_token - should succeed (first request accepts old_token)
f := buildFetcher()
f.Meta().Opts = opts
err := f.Resolve(&base.Request{
URL: downloadURL,
Extra: &http.ReqExtra{
@@ -3545,7 +3599,7 @@ func TestFetcher_Patch_CookieExpired(t *testing.T) {
"Cookie": "session=old_token",
},
},
}, opts)
})
if err != nil {
t.Fatalf("Resolve should succeed with old_token: %v", err)
}
@@ -0,0 +1,121 @@
package http
import (
"bytes"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/GopeedLab/gopeed/pkg/base"
fhttp "github.com/GopeedLab/gopeed/pkg/protocol/http"
)
func TestResolveResponseSize(t *testing.T) {
for _, tc := range []struct {
name string
status int
length, contentRange string
size int64
invalid bool
}{
{"full", 200, "801571015", "", 801571015, false},
{"partial_prefix", 206, "472321339", "bytes 0-472321338/801571015", 801571015, false},
{"partial_nonzero", 206, "100000", "bytes 100000-199999/801571015", 801571015, false},
{"second_report", 206, "335134409", "bytes 0-335134408/552140234", 552140234, false},
{"length_mismatch", 206, "10", "bytes 0-19/100", 0, true},
{"unknown_total", 206, "472321339", "bytes 0-472321338/*", 0, true},
{"missing_range", 206, "10", "", 0, true},
{"malformed_range", 206, "10", "bytes invalid", 0, true},
{"invalid_bounds", 206, "10", "bytes 20-10/100", 0, true},
{"invalid_total", 206, "10", "bytes 0-9/9", 0, true},
{"overflow", 206, "10", "bytes 0-9/9223372036854775808", 0, true},
{"chunked_partial", 206, "", "bytes 0-9/100", 100, false},
{"unknown_full", 200, "", "", 0, false},
} {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if tc.length != "" {
w.Header().Set("Content-Length", tc.length)
}
if tc.contentRange != "" {
w.Header().Set("Content-Range", tc.contentRange)
}
w.WriteHeader(tc.status)
w.(http.Flusher).Flush()
}))
defer server.Close()
f := buildFetcher()
defer f.Close()
f.Meta().Opts = &base.Options{Path: t.TempDir()}
err := f.Resolve(&base.Request{URL: server.URL + "/file"})
if tc.invalid {
if !errors.Is(err, errInvalidRangeResponse) {
t.Fatalf("Resolve error = %v, want invalid range", err)
}
if f.meta.Res != nil || f.resolveResp != nil || f.getState() != stateError {
t.Fatal("invalid response was retained or published")
}
return
}
if err != nil {
t.Fatal(err)
}
if f.meta.Res.Size != tc.size || f.meta.Res.Files[0].Size != tc.size {
t.Fatalf("resource/file size = %d/%d, want %d", f.meta.Res.Size, f.meta.Res.Files[0].Size, tc.size)
}
if tc.status == 206 && !f.meta.Res.Range {
t.Fatal("partial response must enable Range downloads")
}
})
}
}
func TestDownloadPartialResolve(t *testing.T) {
payload := make([]byte, 256*1024)
for i := range payload {
payload[i] = byte(i*31 + i/251)
}
for _, start := range []int{0, 100000} {
for _, connections := range []int{1, 4} {
t.Run(fmt.Sprintf("start_%d_connections_%d", start, connections), func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
begin, end := start, start+9999
if h := r.Header.Get("Range"); h != "" {
if _, err := fmt.Sscanf(h, "bytes=%d-%d", &begin, &end); err != nil {
t.Errorf("invalid request range %q: %v", h, err)
w.WriteHeader(400)
return
}
}
w.Header().Set("Content-Length", fmt.Sprint(end-begin+1))
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", begin, end, len(payload)))
w.WriteHeader(206)
w.Write(payload[begin : end+1])
}))
defer server.Close()
f := buildFetcher()
defer f.Close()
f.Meta().Opts = &base.Options{Path: t.TempDir(), Name: "file", Extra: &fhttp.OptsExtra{Connections: connections}}
if err := f.Resolve(&base.Request{URL: server.URL + "/file"}); err != nil {
t.Fatal(err)
}
if err := f.Start(); err != nil {
t.Fatal(err)
}
if err := f.Wait(); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(f.meta.SingleFilepath())
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, payload) {
t.Fatalf("download content mismatch: got %d bytes, want %d", len(got), len(payload))
}
})
}
}
}
@@ -0,0 +1,79 @@
package http
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
"github.com/GopeedLab/gopeed/internal/controller"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/internal/test"
"github.com/GopeedLab/gopeed/pkg/base"
fhttp "github.com/GopeedLab/gopeed/pkg/protocol/http"
)
func TestFetcher_RestoredStartSkipsAbsentPrefetch(t *testing.T) {
data := bytes.Repeat([]byte("0123456789abcdef"), 64*1024)
var firstRequest atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
firstRequest.CompareAndSwap(0, time.Now().UnixNano())
http.ServeContent(w, r, "restored.bin", time.Time{}, bytes.NewReader(data))
}))
defer server.Close()
dir := t.TempDir()
size := int64(len(data))
half := size / 2
file := filepath.Join(dir, "restored.bin")
if err := os.WriteFile(file, data[:half], 0600); err != nil {
t.Fatal(err)
}
conn := &connection{ID: 0, Role: rolePrimary, State: connNotStarted, Chunk: newChunk(0, size-1), Downloaded: half}
conn.Chunk.Downloaded = half
rangeMode := true
saved := &fetcherData{Connections: []*connection{conn}, Range: &rangeMode, ResourceSize: &size, FileSize: &size}
meta := &fetcher.FetcherMeta{
Req: &base.Request{URL: server.URL + "/restored.bin"},
Res: &base.Resource{Size: size, Range: true, Files: []*base.FileInfo{{Name: "restored.bin", Size: size}}},
Opts: &base.Options{Path: dir, Name: "restored.bin", Extra: &fhttp.OptsExtra{Connections: 1}},
}
fm := new(FetcherManager)
_, restore := fm.Restore()
f := restore(meta, saved).(*Fetcher)
ctl := controller.NewController()
ctl.GetConfig = func(v any) {
if err := json.Unmarshal([]byte(test.ToJson(fm.DefaultConfig())), v); err != nil {
t.Error(err)
}
}
f.Setup(ctl)
defer f.Close()
started := time.Now()
if err := f.Start(); err != nil {
t.Fatal(err)
}
if err := f.Wait(); err != nil {
t.Fatal(err)
}
actual, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(actual, data) {
t.Fatal("restored download differs from the complete server content")
}
if firstRequest.Load() == 0 {
t.Fatal("restored download did not request the remaining content")
}
delay := time.Duration(firstRequest.Load() - started.UnixNano())
t.Logf("first restored request after %s", delay)
if delay > 2*time.Second {
t.Errorf("restored download waited %s before its first request", delay)
}
}
+245
View File
@@ -0,0 +1,245 @@
package http
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync/atomic"
"testing"
"github.com/GopeedLab/gopeed/pkg/base"
)
func TestFetcher_OptionsSizeHintAndPatch(t *testing.T) {
for _, known := range []bool{false, true} {
for _, payload := range []string{"", "0123456789"} {
t.Run(fmt.Sprintf("known=%v/bytes=%d", known, len(payload)), func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if known {
w.Header().Set("Content-Length", fmt.Sprint(len(payload)))
}
w.(http.Flusher).Flush()
fmt.Fprint(w, payload)
}))
defer server.Close()
f := buildFetcher()
defer f.Close()
f.Meta().Opts = &base.Options{Size: 7, Path: t.TempDir(), Name: "output.bin"}
if err := f.Resolve(&base.Request{URL: server.URL}); err != nil {
t.Fatal(err)
}
want := int64(7)
if known {
want = int64(len(payload))
}
if f.meta.Res.Size != want || f.hasKnownSize() != known {
t.Fatalf("resolved size=%d known=%v", f.meta.Res.Size, f.hasKnownSize())
}
if err := f.Patch(nil, &base.Options{Size: 9}); err != nil {
t.Fatal(err)
}
if !known {
want = 9
}
if f.meta.Res.Size != want {
t.Fatal("hint update overrode a real size or failed to update an estimate")
}
if err := f.Start(); err != nil {
t.Fatal(err)
}
if err := f.Wait(); err != nil {
t.Fatal(err)
}
if f.meta.Res.Size != int64(len(payload)) || !f.hasKnownSize() {
t.Fatalf("final size=%d known=%v", f.meta.Res.Size, f.hasKnownSize())
}
snapshot, err := new(FetcherManager).Store(f)
if err != nil {
t.Fatal(err)
}
fd := snapshot.(*fetcherData)
if fd.SizeKnown == nil || !*fd.SizeKnown {
t.Fatal("snapshot lost confirmed size authority")
}
_, restore := new(FetcherManager).Restore()
restored := restore(f.meta, fd).(*Fetcher)
if !restored.hasKnownSize() || restored.knownSize() != int64(len(payload)) {
t.Fatal("restored size lost authority, including known zero")
}
})
}
}
}
func TestFetcher_SizeHintCannotCompleteForbiddenUnknownRange(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Range") != "" {
w.WriteHeader(http.StatusForbidden)
return
}
w.Header().Set("Accept-Ranges", "bytes")
w.(http.Flusher).Flush()
fmt.Fprint(w, "0123456789")
w.(http.Flusher).Flush()
<-r.Context().Done()
}))
defer server.Close()
f := buildFetcher()
defer f.Close()
f.Meta().Opts = &base.Options{Size: 3, Path: t.TempDir(), Name: "output.bin"}
if err := f.Resolve(&base.Request{URL: server.URL}); err != nil {
t.Fatal(err)
}
if err := f.Start(); err != nil {
t.Fatal(err)
}
if err := f.Wait(); err == nil {
t.Fatal("a failed unknown-length Range request was completed from an estimate")
}
}
func TestFetcher_SizeHintPreservesHTTPIntegrity(t *testing.T) {
for _, chunked := range []bool{false, true} {
for _, broken := range []bool{false, true} {
t.Run(fmt.Sprintf("chunked=%v/broken=%v", chunked, broken), func(t *testing.T) {
payload := []byte("0123456789")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if chunked && broken {
conn, rw, err := w.(http.Hijacker).Hijack()
if err != nil {
t.Error(err)
return
}
defer conn.Close()
fmt.Fprint(rw, "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\na\r\n0123456789\r\n")
rw.Flush() // Omit the final chunk to simulate a connection interruption.
return
}
if !chunked {
length := len(payload)
if broken {
length++
}
w.Header().Set("Content-Length", fmt.Sprint(length))
}
w.(http.Flusher).Flush()
w.Write(payload)
}))
defer server.Close()
f := buildFetcher()
defer f.Close()
f.Meta().Opts = &base.Options{Size: 3, Path: t.TempDir(), Name: "output.bin"}
if err := f.Resolve(&base.Request{URL: server.URL}); err != nil {
t.Fatal(err)
}
if !chunked {
length := int64(len(payload))
if broken {
length++
}
if f.meta.Res.Size != length || f.sequentialSizeUnknown {
t.Fatal("HTTP length did not override size hint")
}
} else if f.meta.Res.Size != 3 || !f.sequentialSizeUnknown {
t.Fatal("unknown HTTP length did not retain the display hint")
}
if err := f.Start(); err != nil {
t.Fatal(err)
}
err := f.Wait()
if broken {
if err == nil {
t.Fatal("truncated HTTP response succeeded after exceeding the size hint")
}
return
}
if err != nil {
t.Fatal(err)
}
if f.meta.Res.Size != int64(len(payload)) || f.sequentialSizeUnknown {
t.Fatalf("completed size=%d unknown=%v", f.meta.Res.Size, f.sequentialSizeUnknown)
}
})
}
}
}
func TestFetcher_SizeHintSurvivesRestore(t *testing.T) {
for _, partial := range []bool{false, true} {
for _, fixedLength := range []bool{false, true} {
t.Run(fmt.Sprintf("partial=%v/fixedLength=%v", partial, fixedLength), func(t *testing.T) {
payload := []byte("0123456789")
var requests atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
request := requests.Add(1)
if got := r.Header.Get("Range"); got != "" {
t.Errorf("estimated sizes must not be used for resume ranges: %s", got)
}
if fixedLength && request > 1 {
w.Header().Set("Content-Length", fmt.Sprint(len(payload)))
}
w.(http.Flusher).Flush()
if fixedLength && request == 2 {
w.Write(payload[:4]) // Retry must discard this interrupted attempt.
return
}
w.Write(payload)
}))
defer server.Close()
f := buildFetcher()
opts := &base.Options{Size: 3, Path: t.TempDir(), Name: "output.bin"}
f.Meta().Opts = opts
if err := f.Resolve(&base.Request{URL: server.URL}); err != nil {
t.Fatal(err)
}
fm := new(FetcherManager)
snapshot, err := fm.Store(f)
if err != nil {
t.Fatal(err)
}
data, err := json.Marshal(snapshot)
if err != nil {
t.Fatal(err)
}
var saved fetcherData
if err := json.Unmarshal(data, &saved); err != nil {
t.Fatal(err)
}
if !saved.SequentialSizeUnknown || saved.SizeKnown == nil || *saved.SizeKnown || *saved.ResourceSize != 3 {
t.Fatal("snapshot lost the size hint or its advisory state")
}
f.Close()
// An interrupted earlier attempt left a tail longer than the new output.
if err := os.WriteFile(filepath.Join(opts.Path, opts.Name), bytes.Repeat([]byte("old"), 100), 0600); err != nil {
t.Fatal(err)
}
if partial {
chunk := newChunk(0, 0)
chunk.Downloaded = 7 // Already exceeds the display estimate.
saved.Connections = []*connection{{ID: 0, Role: rolePrimary, State: connNotStarted, Chunk: chunk, Downloaded: 7}}
}
_, restore := fm.Restore()
restored := restore(f.meta, &saved).(*Fetcher)
restored.Setup(f.ctl)
defer restored.Close()
if err := restored.Start(); err != nil {
t.Fatal(err)
}
if err := restored.Wait(); err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(restored.meta.SingleFilepath())
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(body, payload) || restored.meta.Res.Size != int64(len(payload)) || restored.Progress().TotalDownloaded() != int64(len(payload)) {
t.Fatalf("restored stream retained an estimate or stale bytes: body=%q size=%d progress=%d", body, restored.meta.Res.Size, restored.Progress().TotalDownloaded())
}
})
}
}
}
+6 -1
View File
@@ -2,6 +2,7 @@ package api
import (
"fmt"
"net/url"
pathpkg "path"
"strings"
)
@@ -75,7 +76,11 @@ func (r RouteSpec) Match(rawPath string) (map[string]string, bool) {
params := make(map[string]string)
for index, part := range r.parts {
if part.param != "" {
params[part.param] = segments[index]
value, err := url.PathUnescape(segments[index])
if err != nil {
return nil, false
}
params[part.param] = value
continue
}
if part.literal != segments[index] {
+36 -2
View File
@@ -2,7 +2,9 @@ package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
@@ -62,6 +64,8 @@ func NewService(downloader *download.Downloader) (*Service, error) {
newRoute(http.MethodGet, "/api/v1/tasks/{id}/status", service.getTaskStatus),
newRoute(http.MethodGet, "/api/v1/tasks", service.getTasks),
newRoute(http.MethodGet, "/api/v1/tasks/{id}/stats", service.getStats),
newRoute(http.MethodPost, "/api/v1/protocols/{protocol}/jobs/{name}/trigger", service.triggerProtocolJob),
newRoute(http.MethodPost, "/api/v1/mirrors/{provider}/candidates", service.mirrorCandidates),
newRoute(http.MethodGet, "/api/v1/config", service.getConfig),
newRoute(http.MethodPut, "/api/v1/config", service.putConfig),
newRoute(http.MethodPost, "/api/v1/extensions", service.installExtension),
@@ -139,7 +143,7 @@ func (s *Service) resolve(ctx *Context) *Response {
if response := decodeRequest(ctx.Body, &request); response != nil {
return response
}
result, err := s.Downloader.Resolve(request.Req, request.Opts)
result, err := s.Downloader.Resolve(request.Req)
if err != nil {
return newErrorResponse(err.Error())
}
@@ -188,7 +192,7 @@ func (s *Service) patchTask(ctx *Context) *Response {
if taskID == "" {
return invalidIDResponse()
}
var request restmodel.ResolveTask
var request restmodel.PatchTask
if response := decodeRequest(ctx.Body, &request); response != nil {
return response
}
@@ -294,6 +298,36 @@ func (s *Service) getStats(ctx *Context) *Response {
return newOKResponse(stats)
}
func (s *Service) triggerProtocolJob(ctx *Context) *Response {
if err := s.Downloader.TriggerProtocolJob(context.Background(), ctx.PathParam("protocol"), ctx.PathParam("name")); err != nil {
status := http.StatusBadRequest
if errors.Is(err, download.ErrUnSupportedProtocol) || errors.Is(err, download.ErrProtocolJobNotFound) {
status = http.StatusNotFound
}
return newStatusResponse(status, restmodel.NewErrorResult(err.Error()))
}
return newNilResponse()
}
func (s *Service) mirrorCandidates(ctx *Context) *Response {
var request restmodel.MirrorCandidatesReq
if response := decodeRequest(ctx.Body, &request); response != nil {
return response
}
urls, err := s.Downloader.GetMirrorCandidates(ctx.PathParam("provider"), request.URL)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, download.ErrMirrorProviderNotFound):
status = http.StatusNotFound
case errors.Is(err, download.ErrInvalidMirrorURL):
status = http.StatusBadRequest
}
return newStatusResponse(status, restmodel.NewErrorResult(err.Error()))
}
return newOKResponse(&restmodel.MirrorCandidatesResp{URLs: urls})
}
func (s *Service) getConfig(_ *Context) *Response {
config, err := s.Downloader.GetConfig()
if err != nil {
+269
View File
@@ -0,0 +1,269 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"testing"
"time"
"github.com/GopeedLab/gopeed/internal/fetcher"
httpProtocol "github.com/GopeedLab/gopeed/internal/protocol/http"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download"
restmodel "github.com/GopeedLab/gopeed/pkg/rest/model"
)
func TestResolveThenCreateWithDownloadOptions(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Disposition", `attachment; filename="payload.txt"`)
w.Header().Set("Content-Length", "4")
_, _ = w.Write([]byte("test"))
}))
t.Cleanup(upstream.Close)
d := download.NewDownloader(&download.DownloaderConfig{StorageDir: t.TempDir()})
if err := d.Setup(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := d.Close(); err != nil {
t.Error(err)
}
})
// Resolving must work even when the default download directory is unusable.
blocked := filepath.Join(t.TempDir(), "not-a-directory")
if err := os.WriteFile(blocked, nil, 0600); err != nil {
t.Fatal(err)
}
current, err := d.GetConfig()
if err != nil {
t.Fatal(err)
}
config := *current
config.DownloadDir = blocked
config.MaxRunning = 0 // Inspect the created task before it starts downloading.
if err := d.PutConfig(&config); err != nil {
t.Fatal(err)
}
service, err := NewService(d)
if err != nil {
t.Fatal(err)
}
resolved := serviceTestRequest[*download.ResolveResult](t, service, http.MethodPost, "/api/v1/resolve", &restmodel.ResolveTask{
Req: &base.Request{URL: upstream.URL},
})
if resolved.ID == "" || resolved.Res.Size != 4 || resolved.Res.Files[0].Name != "payload.txt" {
t.Fatalf("unexpected resolved resource: %+v", resolved)
}
dir := filepath.Join(t.TempDir(), "chosen-after-resolve")
opts := &base.Options{Path: dir, Name: "renamed.txt", SelectFiles: []int{0}}
id := serviceTestRequest[string](t, service, http.MethodPost, "/api/v1/tasks", &restmodel.CreateTask{Rid: resolved.ID, Opts: opts})
task := serviceTestRequest[*download.Task](t, service, http.MethodGet, "/api/v1/tasks/"+id, nil)
if task.Meta.Opts.Path != dir || task.Meta.Opts.Name != opts.Name || !reflect.DeepEqual(task.Meta.Opts.SelectFiles, opts.SelectFiles) {
t.Fatalf("creation lost submitted download options: %+v", task.Meta.Opts)
}
}
func serviceTestRequest[T any](t *testing.T, service *Service, method, path string, body any) T {
t.Helper()
encoded, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
status, response := service.JSON(method, path, nil, encoded, nil)
var result restmodel.Result[T]
if err := json.Unmarshal(response, &result); err != nil {
t.Fatal(err)
}
if status != http.StatusOK || result.Code != restmodel.CodeOk {
t.Fatalf("%s %s failed: status=%d response=%s", method, path, status, response)
}
return result.Data
}
func TestFullConfigDispatch(t *testing.T) {
d := download.NewDownloader(&download.DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{&httpProtocol.FetcherManager{}}})
if err := d.Setup(); err != nil {
t.Fatal(err)
}
defer d.Close()
service, err := NewService(d)
if err != nil {
t.Fatal(err)
}
response := service.Dispatch(http.MethodPut, "/api/v1/config", nil, []byte(`{"protocolConfig":{"http":{"connections":3,"useServerCtime":false}}}`), nil)
if response.StatusCode != http.StatusOK {
t.Fatalf("%+v", response)
}
status, body := service.JSON(http.MethodGet, "/api/v1/config", nil, nil, nil)
var result struct {
Data struct {
ProtocolConfig map[string]struct {
Connections int `json:"connections"`
} `json:"protocolConfig"`
} `json:"data"`
}
if err := json.Unmarshal(body, &result); err != nil {
t.Fatal(err)
}
if status != http.StatusOK || result.Data.ProtocolConfig["http"].Connections != 3 {
t.Fatalf("%d %s", status, body)
}
if response := service.Dispatch(http.MethodPost, "/api/v1/protocols/http/jobs/missing/trigger", nil, nil, nil); response.StatusCode != http.StatusNotFound {
t.Fatalf("%+v", response)
}
}
func TestNativePathParametersAreDecodedOnce(t *testing.T) {
route := newRoute(http.MethodGet, "/api/v1/tasks/{id}/status", func(c *Context) *Response { return newOKResponse(c.PathParam("id")) })
for _, test := range []struct{ path, want string }{
{"/api/v1/tasks/%E5%BC%80%E5%8F%91%E8%80%85%40name/status", "开发者@name"},
{"/api/v1/tasks/literal%252Fname/status", "literal%2Fname"},
} {
response := (&Service{routes: []RouteSpec{route}}).Dispatch(http.MethodGet, test.path, nil, nil, nil)
raw, _ := json.Marshal(response.Body)
var body struct {
Data string `json:"data"`
}
_ = json.Unmarshal(raw, &body)
if body.Data != test.want {
t.Fatalf("%s => %q", test.path, body.Data)
}
}
}
func TestMirrorCandidates(t *testing.T) {
d := download.NewDownloader(&download.DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{&httpProtocol.FetcherManager{}}})
if err := d.Setup(); err != nil {
t.Fatal(err)
}
defer d.Close()
cfg, _ := d.GetConfig()
cfg.GithubMirror = &base.GithubMirrorConfig{Enabled: true, Mirrors: []base.GithubMirror{
{Type: "jsdelivr", URL: "https://cdn.example/gh"},
{Type: "ghProxy", URL: "https://proxy.example"},
{Type: "ghProxy", URL: "https://proxy.example"},
}}
if err := d.PutConfig(cfg); err != nil {
t.Fatal(err)
}
service, err := NewService(d)
if err != nil {
t.Fatal(err)
}
for _, official := range []string{
"https://github.com/GopeedLab/gopeed/releases/download/v2.0.0/package.zip",
"https://raw.githubusercontent.com/owner/repo/main/trackers.txt",
} {
request, _ := json.Marshal(map[string]string{"url": official})
status, raw := service.JSON(http.MethodPost, "/api/v1/mirrors/github/candidates", nil, request, nil)
var body struct {
Data struct {
URLs []string `json:"urls"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatal(err)
}
want := 2
if official == "https://raw.githubusercontent.com/owner/repo/main/trackers.txt" {
want = 3
}
urls := body.Data.URLs
if status != http.StatusOK || len(urls) != want || urls[0] != official || urls[len(urls)-1] != "https://proxy.example/"+official {
t.Fatalf("%d %s", status, raw)
}
}
for _, invalid := range []string{"https://api.github.com/repos/GopeedLab/gopeed/releases?per_page=10", "", "https://example.com/x", "http://github.com/x", "https://github.com.evil.test/x", "https://[email protected]/x", "https://github.com:443/x", "https://github.com:/x", "https://github.com/x#fragment", "https://github.com/x#", "/relative", "https://github.com%2fevil.test/x"} {
request, _ := json.Marshal(map[string]string{"url": invalid})
if response := service.Dispatch(http.MethodPost, "/api/v1/mirrors/github/candidates", nil, request, nil); response.StatusCode != http.StatusBadRequest {
t.Fatalf("accepted %s: %+v", invalid, response)
}
}
cfg.GithubMirror.Enabled = false
if err := d.PutConfig(cfg); err != nil {
t.Fatal(err)
}
status, raw := service.JSON(http.MethodPost, "/api/v1/mirrors/github/candidates", nil, []byte(`{"url":"https://github.com/owner/repo"}`), nil)
var body struct {
Data struct {
URLs []string `json:"urls"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &body); err != nil || status != http.StatusOK || len(body.Data.URLs) != 1 || body.Data.URLs[0] != "https://github.com/owner/repo" {
t.Fatalf("%d %s", status, raw)
}
for _, provider := range []string{"unknown", "GitHub"} {
if response := service.Dispatch(http.MethodPost, "/api/v1/mirrors/"+provider+"/candidates", nil, []byte(`{"url":"https://github.com/owner/repo"}`), nil); response.StatusCode != http.StatusNotFound {
t.Fatalf("unknown provider accepted: %+v", response)
}
}
}
type apiJobState struct {
Runs int `json:"runs"`
}
type apiJobConfig struct {
fetcher.ConfigState[apiJobState]
Writable int `json:"writable"`
}
type apiStateJobManager struct{ httpProtocol.FetcherManager }
func (*apiStateJobManager) Name() string { return "state-api" }
func (*apiStateJobManager) DefaultConfig() any { return &apiJobConfig{Writable: 7} }
func (*apiStateJobManager) CronJobs(host fetcher.ProtocolHost) []fetcher.CronJob {
return []fetcher.CronJob{{Name: "refresh", Every: time.Hour, Timeout: time.Second, Run: func(_ context.Context, source fetcher.TriggerSource) error {
if source == fetcher.TriggerScheduled {
return fetcher.ErrJobSkipped
}
return fetcher.UpdateState[apiJobConfig, apiJobState](host, func(cfg *apiJobConfig, state *apiJobState) error { cfg.Writable = 999; state.Runs++; return nil })
}}}
}
func TestFullConfigAndJobTriggerShareProtectedProtocolState(t *testing.T) {
d := download.NewDownloader(&download.DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{&apiStateJobManager{}}})
if err := d.Setup(); err != nil {
t.Fatal(err)
}
defer d.Close()
service, err := NewService(d)
if err != nil {
t.Fatal(err)
}
put := []byte(`{"protocolConfig":{"state-api":{"writable":9,"state":"ignore forged state"}}}`)
if response := service.Dispatch(http.MethodPut, "/api/v1/config", nil, put, nil); response.StatusCode != http.StatusOK {
t.Fatalf("%+v", response)
}
if response := service.Dispatch(http.MethodPost, "/api/v1/protocols/state-api/jobs/refresh/trigger", nil, nil, nil); response.StatusCode != http.StatusOK {
t.Fatalf("%+v", response)
}
status, raw := service.JSON(http.MethodGet, "/api/v1/config", nil, nil, nil)
var response struct {
Data struct {
ProtocolConfig map[string]apiJobConfig `json:"protocolConfig"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &response); err != nil {
t.Fatal(err)
}
cfg := response.Data.ProtocolConfig["state-api"]
if status != http.StatusOK || cfg.Writable != 9 || cfg.State.Runs != 1 {
t.Fatalf("%d %s", status, raw)
}
// Replaying an old full PUT must not undo the manual job's state change.
if response := service.Dispatch(http.MethodPut, "/api/v1/config", nil, put, nil); response.StatusCode != http.StatusOK {
t.Fatalf("%+v", response)
}
_, raw = service.JSON(http.MethodGet, "/api/v1/config", nil, nil, nil)
if err := json.Unmarshal(raw, &response); err != nil {
t.Fatal(err)
}
if response.Data.ProtocolConfig["state-api"].State.Runs != 1 {
t.Fatalf("job state overwritten: %s", raw)
}
}
+11
View File
@@ -0,0 +1,11 @@
package base
import "github.com/GopeedLab/gopeed/internal/githubmirror"
// Preserve downloader configuration type names and the existing JSON schema.
type GithubMirror = githubmirror.Mirror
type GithubMirrorConfig = githubmirror.Config
func DefaultGithubMirrorConfig() *GithubMirrorConfig {
return githubmirror.DefaultConfig()
}
+109 -18
View File
@@ -1,9 +1,11 @@
package base
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"path/filepath"
"sync"
"time"
@@ -25,24 +27,6 @@ type Request struct {
SkipVerifyCert bool `json:"skipVerifyCert"`
}
// SetLabels replaces all labels on the request.
func (r *Request) SetLabels(labels map[string]string) {
r.Labels = labels
}
// PutLabel sets a label on the request.
func (r *Request) PutLabel(key, value string) {
if r.Labels == nil {
r.Labels = make(map[string]string)
}
r.Labels[key] = value
}
// DelLabel deletes a label from the request.
func (r *Request) DelLabel(key string) {
delete(r.Labels, key)
}
func (r *Request) Validate() error {
if r.URL == "" {
return fmt.Errorf("invalid request url")
@@ -126,12 +110,18 @@ type FileInfo struct {
// Options for download
type Options struct {
// Size is an optional HTTP download size estimate. HTTP metadata and clean EOF take precedence.
// Other protocols ignore it.
Size int64 `json:"size,omitempty"`
// Download file name
Name string `json:"name"`
// Download file path
Path string `json:"path"`
// Use this path as the default download directory after creation succeeds
AsDefaultPath bool `json:"asDefaultPath,omitempty"`
// SkipAutoCategorize keeps the base download directory of this task instead
// of routing it to the category directory of its resolved file
SkipAutoCategorize bool `json:"skipAutoCategorize,omitempty"`
// Select file indexes to download
SelectFiles []int `json:"selectFiles"`
// Extra info for specific fetcher
@@ -199,12 +189,15 @@ type DownloaderStoreConfig struct {
DownloadDir string `json:"downloadDir"` // DownloadDir is the default directory to save the downloaded files
MaxRunning int `json:"maxRunning"` // MaxRunning is the max running download count
ProtocolConfig map[string]any `json:"protocolConfig"` // ProtocolConfig is special config for each protocol
GithubMirror *GithubMirrorConfig `json:"githubMirror"`
Extra map[string]any `json:"extra"`
Proxy *DownloaderProxyConfig `json:"proxy"`
Webhook *WebhookConfig `json:"webhook"` // Webhook is the webhook configuration
Script *ScriptConfig `json:"script"` // Script is the script execution configuration
AutoTorrent *AutoTorrentConfig `json:"autoTorrent"` // AutoTorrent is the auto torrent task creation configuration
Archive *ArchiveConfig `json:"archive"` // Archive is the archive extraction configuration
Categories []*DownloadCategory `json:"categories"` // Categories is the download directory category configuration
AutoCategorize *bool `json:"autoCategorize,omitempty"` // AutoCategorize routes downloads to category directories by file extension; unset defaults to disabled
API *APIServerConfig `json:"api"` // API is the optional REST server configuration
AutoStartTasks bool `json:"autoStartTasks"` // AutoStartTasks continues all unfinished tasks when the backend starts
AutoDeleteMissingFileTasks bool `json:"autoDeleteMissingFileTasks"` // AutoDeleteMissingFileTasks enables automatic deletion of tasks with missing files
@@ -217,6 +210,9 @@ func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig {
if cfg.ProtocolConfig == nil {
cfg.ProtocolConfig = make(map[string]any)
}
if cfg.GithubMirror == nil {
cfg.GithubMirror = DefaultGithubMirrorConfig()
}
if cfg.Proxy == nil {
cfg.Proxy = &DownloaderProxyConfig{}
}
@@ -238,9 +234,85 @@ func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig {
DeleteAfterExtract: false,
}
}
// Seed the built-in categories for deployments that never run the flutter
// startup flow, e.g. headless api servers.
if len(cfg.Categories) == 0 && cfg.DownloadDir != "" {
cfg.Categories = []*DownloadCategory{
builtinSeedCategory("categoryMusic", "Music", cfg.DownloadDir),
builtinSeedCategory("categoryVideo", "Video", cfg.DownloadDir),
builtinSeedCategory("categoryDocument", "Document", cfg.DownloadDir),
builtinSeedCategory("categoryProgram", "Program", cfg.DownloadDir),
}
}
return cfg
}
// builtinCategoryExtensions mirrors the flutter side
// kDefaultCategoryExtensions, keep both maps in sync.
var builtinCategoryExtensions = map[string][]string{
"categoryProgram": {"exe", "msi", "msix", "apk", "dmg", "deb", "rpm", "pkg", "appimage"},
"categoryVideo": {"mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v", "mpg", "mpeg", "3gp"},
"categoryMusic": {"mp3", "flac", "wav", "aac", "ogg", "m4a", "wma", "ape"},
"categoryDocument": {"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "md", "csv", "epub", "rtf"},
}
func builtinSeedCategory(nameKey, name, downloadDir string) *DownloadCategory {
return &DownloadCategory{
Name: "",
Path: filepath.Join(downloadDir, name),
NameKey: nameKey,
IsBuiltIn: true,
Extensions: append([]string(nil), builtinCategoryExtensions[nameKey]...),
}
}
// MigrateLegacyExtraCategories moves the extra.downloadCategories list written
// by v2.0.0-beta builds into the top level categories field and clears the
// legacy key. The legacy list predates extension based routing, so built-in
// entries regain their default extension lists from their name key, while user
// created folders keep their empty list until edited in settings. It reports
// whether the config changed so the caller can persist the cleared key.
func (cfg *DownloaderStoreConfig) MigrateLegacyExtraCategories() bool {
if len(cfg.Categories) > 0 || cfg.Extra == nil {
return false
}
raw, ok := cfg.Extra["downloadCategories"]
if !ok {
return false
}
data, err := json.Marshal(raw)
if err != nil {
return false
}
var legacy []*DownloadCategory
if err := json.Unmarshal(data, &legacy); err != nil {
return false
}
categories := make([]*DownloadCategory, 0, len(legacy))
for _, category := range legacy {
if category == nil {
continue
}
if extensions, exist := builtinCategoryExtensions[category.NameKey]; exist && len(category.Extensions) == 0 {
category.Extensions = append([]string(nil), extensions...)
}
categories = append(categories, category)
}
if len(categories) == 0 {
return false
}
cfg.Categories = categories
delete(cfg.Extra, "downloadCategories")
return true
}
// AutoCategorizeEnabled reports whether downloads are routed to category
// directories by file extension. The switch is optional: an unset value keeps
// the feature off, so an older config that never saved the field stays off.
func (cfg *DownloaderStoreConfig) AutoCategorizeEnabled() bool {
return cfg.AutoCategorize != nil && *cfg.AutoCategorize
}
func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *DownloaderStoreConfig {
if beforeCfg == nil {
return cfg
@@ -257,6 +329,9 @@ func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *Downl
if cfg.Extra == nil {
cfg.Extra = beforeCfg.Extra
}
if cfg.GithubMirror == nil {
cfg.GithubMirror = beforeCfg.GithubMirror
}
if cfg.Proxy == nil {
cfg.Proxy = beforeCfg.Proxy
}
@@ -272,6 +347,12 @@ func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *Downl
if cfg.Archive == nil {
cfg.Archive = beforeCfg.Archive
}
if cfg.Categories == nil {
cfg.Categories = beforeCfg.Categories
}
if cfg.AutoCategorize == nil {
cfg.AutoCategorize = beforeCfg.AutoCategorize
}
if cfg.API == nil {
cfg.API = beforeCfg.API
}
@@ -320,6 +401,16 @@ type ArchiveConfig struct {
DeleteAfterExtract bool `json:"deleteAfterExtract"` // DeleteAfterExtract deletes the archive after successful extraction
}
// DownloadCategory is a download directory category that files can be routed to by extension
type DownloadCategory struct {
Name string `json:"name"` // Name is the category display name, empty for built-in categories that use NameKey
Path string `json:"path"` // Path is the download directory of the category
NameKey string `json:"nameKey,omitempty"` // NameKey is the i18n key of the built-in category name
IsBuiltIn bool `json:"isBuiltIn,omitempty"` // IsBuiltIn marks the category as built-in
IsDeleted bool `json:"isDeleted,omitempty"` // IsDeleted marks the built-in category as deleted
Extensions []string `json:"extensions,omitempty"` // Extensions is the file extension list routed to the category, without leading dots
}
type DownloaderProxyConfig struct {
Enable bool `json:"enable"`
// System is the flag that use system proxy
+243 -1
View File
@@ -1,8 +1,13 @@
package base
import (
"encoding/json"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/GopeedLab/gopeed/pkg/util"
)
func TestDownloaderStoreConfig_Init(t *testing.T) {
@@ -15,6 +20,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
"Init",
&DownloaderStoreConfig{},
&DownloaderStoreConfig{
GithubMirror: DefaultGithubMirrorConfig(),
MaxRunning: 5,
ProtocolConfig: map[string]any{},
Proxy: &DownloaderProxyConfig{},
@@ -36,6 +42,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
MaxRunning: 10,
},
&DownloaderStoreConfig{
GithubMirror: DefaultGithubMirrorConfig(),
MaxRunning: 10,
ProtocolConfig: map[string]any{},
Proxy: &DownloaderProxyConfig{},
@@ -59,7 +66,8 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
},
},
&DownloaderStoreConfig{
MaxRunning: 5,
GithubMirror: DefaultGithubMirrorConfig(),
MaxRunning: 5,
ProtocolConfig: map[string]any{
"key": "value",
},
@@ -84,6 +92,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
},
},
&DownloaderStoreConfig{
GithubMirror: DefaultGithubMirrorConfig(),
MaxRunning: 5,
ProtocolConfig: map[string]any{},
Proxy: &DownloaderProxyConfig{
@@ -110,6 +119,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
},
},
&DownloaderStoreConfig{
GithubMirror: DefaultGithubMirrorConfig(),
MaxRunning: 5,
ProtocolConfig: map[string]any{},
Proxy: &DownloaderProxyConfig{},
@@ -134,6 +144,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
},
},
&DownloaderStoreConfig{
GithubMirror: DefaultGithubMirrorConfig(),
MaxRunning: 5,
ProtocolConfig: map[string]any{},
Proxy: &DownloaderProxyConfig{},
@@ -149,6 +160,20 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
},
},
},
{
name: "Init explicitly disabled mirrors with empty list",
fields: &DownloaderStoreConfig{GithubMirror: &GithubMirrorConfig{Enabled: false, Mirrors: []GithubMirror{}}},
want: &DownloaderStoreConfig{
MaxRunning: 5,
ProtocolConfig: map[string]any{},
GithubMirror: &GithubMirrorConfig{Enabled: false, Mirrors: []GithubMirror{}},
Proxy: &DownloaderProxyConfig{},
Webhook: &WebhookConfig{},
Script: &ScriptConfig{},
AutoTorrent: &AutoTorrentConfig{},
Archive: &ArchiveConfig{},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -157,6 +182,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
DownloadDir: tt.fields.DownloadDir,
MaxRunning: tt.fields.MaxRunning,
ProtocolConfig: tt.fields.ProtocolConfig,
GithubMirror: tt.fields.GithubMirror,
Extra: tt.fields.Extra,
Proxy: tt.fields.Proxy,
Webhook: tt.fields.Webhook,
@@ -171,6 +197,202 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
}
}
func TestDownloaderStoreConfig_InitSeedsBuiltInCategories(t *testing.T) {
t.Run("fresh config with a download dir gets the built-in categories", func(t *testing.T) {
dir := t.TempDir()
cfg := &DownloaderStoreConfig{DownloadDir: dir}
cfg.Init()
wantKeys := []string{"categoryMusic", "categoryVideo", "categoryDocument", "categoryProgram"}
wantNames := []string{"Music", "Video", "Document", "Program"}
if len(cfg.Categories) != len(wantKeys) {
t.Fatalf("categories = %d, want %d", len(cfg.Categories), len(wantKeys))
}
for i, category := range cfg.Categories {
if category.NameKey != wantKeys[i] {
t.Errorf("category[%d].NameKey = %q, want %q", i, category.NameKey, wantKeys[i])
}
if !category.IsBuiltIn {
t.Errorf("category[%d] %q is not built-in", i, category.NameKey)
}
if category.Path != filepath.Join(dir, wantNames[i]) {
t.Errorf("category[%d].Path = %q, want %q", i, category.Path, filepath.Join(dir, wantNames[i]))
}
if len(category.Extensions) == 0 {
t.Errorf("category[%d] %q has no default extensions", i, category.NameKey)
}
}
})
// Init itself never reads the legacy key: Setup calls
// MigrateLegacyExtraCategories first, and only a config that skipped the
// migration ends up with the built-in seed.
t.Run("legacy extra downloadCategories is not read by Init alone", func(t *testing.T) {
cfg := &DownloaderStoreConfig{
DownloadDir: t.TempDir(),
Extra: map[string]any{
"downloadCategories": []any{map[string]any{"name": "Music", "path": "/old/Music"}},
},
}
cfg.Init()
if len(cfg.Categories) != 4 {
t.Fatalf("categories = %d, want the 4 built-ins seeded anyway", len(cfg.Categories))
}
})
t.Run("existing categories and missing download dirs are untouched", func(t *testing.T) {
existing := []*DownloadCategory{{Name: "Mine", Path: "/downloads/Mine"}}
cfg := &DownloaderStoreConfig{DownloadDir: t.TempDir(), Categories: existing}
cfg.Init()
if len(cfg.Categories) != 1 || cfg.Categories[0].Name != "Mine" {
t.Fatalf("categories = %v, want the existing one", cfg.Categories)
}
empty := &DownloaderStoreConfig{}
empty.Init()
if len(empty.Categories) != 0 {
t.Fatalf("categories = %d, want 0 without a download dir", len(empty.Categories))
}
})
}
func TestDownloaderStoreConfig_AutoCategorizeEnabled(t *testing.T) {
// A config written before the switch existed has no value, and stays off.
if (&DownloaderStoreConfig{}).AutoCategorizeEnabled() {
t.Fatal("unset auto categorize = enabled, want disabled by default")
}
if !(&DownloaderStoreConfig{AutoCategorize: util.BoolPtr(true)}).AutoCategorizeEnabled() {
t.Fatal("explicit on = disabled, want the opt-in preserved")
}
if (&DownloaderStoreConfig{AutoCategorize: util.BoolPtr(false)}).AutoCategorizeEnabled() {
t.Fatal("explicit off = enabled, want the opt-out preserved")
}
}
func TestDownloaderStoreConfig_MigrateLegacyExtraCategories(t *testing.T) {
t.Run("legacy built-ins move to the top level with their default extensions", func(t *testing.T) {
cfg := &DownloaderStoreConfig{
DownloadDir: "/downloads",
Extra: map[string]any{
"downloadCategories": []any{
map[string]any{"name": "", "path": "/old/Music", "isBuiltIn": true, "nameKey": "categoryMusic"},
map[string]any{"name": "Games", "path": "/old/Games"},
},
"themeMode": "dark",
},
}
if !cfg.MigrateLegacyExtraCategories() {
t.Fatal("migration reported no change")
}
if len(cfg.Categories) != 2 {
t.Fatalf("categories = %d, want the 2 legacy entries", len(cfg.Categories))
}
builtIn := cfg.Categories[0]
if builtIn.NameKey != "categoryMusic" || !builtIn.IsBuiltIn || builtIn.Path != "/old/Music" {
t.Fatalf("built-in category = %+v, want the legacy entry", builtIn)
}
if len(builtIn.Extensions) == 0 {
t.Fatal("migrated built-in must regain its default extensions")
}
custom := cfg.Categories[1]
if custom.Name != "Games" || custom.Path != "/old/Games" || len(custom.Extensions) != 0 {
t.Fatalf("custom category = %+v, want the legacy folder untouched", custom)
}
if _, ok := cfg.Extra["downloadCategories"]; ok {
t.Fatal("legacy key must be cleared")
}
if cfg.Extra["themeMode"] != "dark" {
t.Fatalf("unrelated extra entries must survive, got %v", cfg.Extra)
}
// Setup runs Init after the migration, which must not seed over it.
cfg.Init()
if len(cfg.Categories) != 2 {
t.Fatalf("categories after Init = %d, want the migrated list kept", len(cfg.Categories))
}
})
t.Run("existing top level categories are left alone", func(t *testing.T) {
cfg := &DownloaderStoreConfig{
Extra: map[string]any{
"downloadCategories": []any{map[string]any{"name": "Old", "path": "/old/Default"}},
},
Categories: []*DownloadCategory{{Name: "Mine", Path: "/downloads/Mine"}},
}
if cfg.MigrateLegacyExtraCategories() {
t.Fatal("migration must not overwrite existing categories")
}
if len(cfg.Categories) != 1 || cfg.Categories[0].Name != "Mine" {
t.Fatalf("categories = %v, want the existing one", cfg.Categories)
}
if _, ok := cfg.Extra["downloadCategories"]; !ok {
t.Fatal("legacy key must stay for a manual move")
}
})
t.Run("missing, empty and malformed legacy lists are a no-op", func(t *testing.T) {
if (&DownloaderStoreConfig{}).MigrateLegacyExtraCategories() {
t.Fatal("empty config reported a change")
}
unrelated := &DownloaderStoreConfig{
Extra: map[string]any{"themeMode": "dark"},
}
if unrelated.MigrateLegacyExtraCategories() {
t.Fatal("config without the legacy key reported a change")
}
empty := &DownloaderStoreConfig{
Extra: map[string]any{"downloadCategories": []any{}},
}
if empty.MigrateLegacyExtraCategories() {
t.Fatal("empty legacy list reported a change")
}
malformed := &DownloaderStoreConfig{
Extra: map[string]any{"downloadCategories": "not-a-list"},
}
if malformed.MigrateLegacyExtraCategories() {
t.Fatal("malformed legacy list reported a change")
}
if _, ok := malformed.Extra["downloadCategories"]; !ok {
t.Fatal("malformed legacy key must stay untouched")
}
})
t.Run("a nil entry in the legacy list is skipped", func(t *testing.T) {
cfg := &DownloaderStoreConfig{
DownloadDir: "/downloads",
Extra: map[string]any{
"downloadCategories": []any{
nil,
map[string]any{"name": "Games", "path": "/old/Games"},
},
},
}
if !cfg.MigrateLegacyExtraCategories() {
t.Fatal("migration reported no change")
}
if len(cfg.Categories) != 1 || cfg.Categories[0].Name != "Games" {
t.Fatalf("categories = %+v, want the single valid legacy entry", cfg.Categories)
}
})
}
func TestDownloaderStoreConfig_AutoCategorizeJSON(t *testing.T) {
unset, err := json.Marshal(&DownloaderStoreConfig{})
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(unset), `"autoCategorize"`) {
t.Fatalf("unset autoCategorize must be omitted from the api json, got %s", unset)
}
off, err := json.Marshal(&DownloaderStoreConfig{AutoCategorize: util.BoolPtr(false)})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(off), `"autoCategorize":false`) {
t.Fatalf("explicit autoCategorize=false must be emitted, got %s", off)
}
}
func TestDownloaderStoreConfig_Merge(t *testing.T) {
type args struct {
beforeCfg *DownloaderStoreConfig
@@ -215,6 +437,24 @@ func TestDownloaderStoreConfig_Merge(t *testing.T) {
DownloadDir: "after",
},
},
{
"Merge Categories and AutoCategorize Override",
&DownloaderStoreConfig{},
args{
beforeCfg: &DownloaderStoreConfig{
Categories: []*DownloadCategory{
{Name: "Music", Path: "/downloads/Music"},
},
AutoCategorize: util.BoolPtr(true),
},
},
&DownloaderStoreConfig{
Categories: []*DownloadCategory{
{Name: "Music", Path: "/downloads/Music"},
},
AutoCategorize: util.BoolPtr(true),
},
},
{
"Merge MaxRunning No Override",
&DownloaderStoreConfig{
@@ -428,6 +668,8 @@ func TestDownloaderStoreConfig_Merge(t *testing.T) {
Webhook: tt.fields.Webhook,
AutoTorrent: tt.fields.AutoTorrent,
Archive: tt.fields.Archive,
Categories: tt.fields.Categories,
AutoCategorize: tt.fields.AutoCategorize,
}
if got := cfg.Merge(tt.args.beforeCfg); !reflect.DeepEqual(got, tt.want) {
t.Errorf("Merge() = %v, want %v", got, tt.want)
+245
View File
@@ -0,0 +1,245 @@
package download
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/GopeedLab/gopeed/internal/fetcher"
btProtocol "github.com/GopeedLab/gopeed/internal/protocol/bt"
httpProtocol "github.com/GopeedLab/gopeed/internal/protocol/http"
"github.com/GopeedLab/gopeed/pkg/base"
)
type testJobManager struct {
httpProtocol.FetcherManager
job fetcher.CronJob
}
func (m *testJobManager) Name() string { return "test" }
func (m *testJobManager) CronJobs(fetcher.ProtocolHost) []fetcher.CronJob {
return []fetcher.CronJob{m.job}
}
func testBackendDownloader(t *testing.T, managers ...fetcher.FetcherManager) *Downloader {
t.Helper()
d := NewDownloader(&DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: managers})
if err := d.Setup(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := d.Close(); err != nil {
t.Error(err)
}
})
return d
}
func TestProtocolJobCoalescesAndCancelsOnClose(t *testing.T) {
entered := make(chan struct{})
var calls atomic.Int32
manager := &testJobManager{job: fetcher.CronJob{Name: "refresh", Every: time.Hour, Timeout: time.Minute, Run: func(ctx context.Context, _ fetcher.TriggerSource) error {
if calls.Add(1) == 1 {
close(entered)
}
<-ctx.Done()
return ctx.Err()
}}}
d := testBackendDownloader(t, manager)
result := make(chan error, 2)
go func() { result <- d.TriggerProtocolJob(context.Background(), "test", "refresh") }()
<-entered
waiterCtx, cancel := context.WithCancel(context.Background())
// A canceled waiter joins the existing work without canceling it.
cancel()
if err := d.TriggerProtocolJob(waiterCtx, "test", "refresh"); !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
if calls.Load() != 1 {
t.Fatalf("executed %d times", calls.Load())
}
if err := d.Close(); err != nil {
t.Fatal(err)
}
if err := <-result; !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
if err := d.TriggerProtocolJob(context.Background(), "test", "missing"); !errors.Is(err, ErrProtocolJobNotFound) {
t.Fatal(err)
}
}
func TestProtocolJobPersistsSuccess(t *testing.T) {
manager := &testJobManager{job: fetcher.CronJob{Name: "refresh", Every: time.Hour, Timeout: time.Minute, Run: func(context.Context, fetcher.TriggerSource) error { return nil }}}
d := testBackendDownloader(t, manager)
if err := d.TriggerProtocolJob(context.Background(), "test", "refresh"); err != nil {
t.Fatal(err)
}
var state protocolJobState
exists, err := d.storage.Get(bucketProtocolJobs, "test/refresh", &state)
if err != nil || !exists || state.LastSuccess.IsZero() {
t.Fatalf("state %+v: %v", state, err)
}
}
func TestRaceResourceValidatesResponsesAndCancelsLosers(t *testing.T) {
loserCanceled := make(chan struct{})
loserStarted := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodConnect:
http.Error(w, "official unavailable", http.StatusBadGateway)
case strings.HasPrefix(r.URL.Path, "/bad/"):
io.WriteString(w, "<html>proxy landing page</html>")
case strings.HasPrefix(r.URL.Path, "/good/"):
<-loserStarted
io.WriteString(w, `{"ok":true}`)
case strings.HasPrefix(r.URL.Path, "/slow/"):
close(loserStarted)
<-r.Context().Done()
close(loserCanceled)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
d := testBackendDownloader(t, &httpProtocol.FetcherManager{})
cfg, _ := d.GetConfig()
cfg.Proxy = &base.DownloaderProxyConfig{Enable: true, Scheme: "http", Host: strings.TrimPrefix(server.URL, "http://")}
cfg.GithubMirror = &base.GithubMirrorConfig{Enabled: true, Mirrors: []base.GithubMirror{
{Type: "ghProxy", URL: server.URL + "/bad"},
{Type: "ghProxy", URL: server.URL + "/slow"},
{Type: "ghProxy", URL: server.URL + "/good"},
}}
if err := d.PutConfig(cfg); err != nil {
t.Fatal(err)
}
body, err := d.fetchResource(context.Background(), "https://raw.githubusercontent.com/owner/repo/main/test.json", 1024, false, func(body []byte) error {
if !json.Valid(body) {
return errors.New("invalid JSON")
}
return nil
})
if err != nil || string(body) != `{"ok":true}` {
t.Fatalf("%s %v", body, err)
}
select {
case <-loserCanceled:
case <-time.After(time.Second):
t.Fatal("losing request was not canceled")
}
}
func TestProtocolConfigInitializesCurrentSchema(t *testing.T) {
d := NewDownloader(&DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{&httpProtocol.FetcherManager{}}})
d.cfg.DownloaderStoreConfig = &base.DownloaderStoreConfig{
GithubMirror: &base.GithubMirrorConfig{Enabled: false, Mirrors: []base.GithubMirror{}},
ProtocolConfig: map[string]any{"http": map[string]any{"connections": 3}},
}
if err := d.storage.Setup([]string{bucketConfig}); err != nil {
t.Fatal(err)
}
defer d.storage.Close()
if err := d.initProtocolConfigs(); err != nil {
t.Fatal(err)
}
if d.cfg.GithubMirror.Enabled || len(d.cfg.GithubMirror.Mirrors) != 0 {
t.Fatal("explicit disabled/empty mirrors lost")
}
value, err := d.readProtocolConfig("http")
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(value)
var cfg struct {
Connections int `json:"connections"`
}
if err := json.Unmarshal(raw, &cfg); err != nil || cfg.Connections != 3 {
t.Fatalf("current protocol config: %s %v", raw, err)
}
if err := d.initProtocolConfigs(); err != nil {
t.Fatal(err)
}
}
func TestProtocolConfigDoesNotImportOldExtraSettings(t *testing.T) {
d := NewDownloader(&DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{&btProtocol.FetcherManager{}}})
d.cfg.DownloaderStoreConfig = &base.DownloaderStoreConfig{
Extra: map[string]any{
"githubMirror": "obsolete invalid value",
"bt": map[string]any{"customTrackers": []string{"udp://old.example:80/announce"}, "autoUpdateTrackers": true},
},
ProtocolConfig: map[string]any{"bt": map[string]any{"autoUpdateTrackers": false, "listenPort": 6881}},
}
if err := d.storage.Setup([]string{bucketConfig}); err != nil {
t.Fatal(err)
}
defer d.storage.Close()
if err := d.initProtocolConfigs(); err != nil {
t.Fatal(err)
}
if !d.cfg.GithubMirror.Enabled || len(d.cfg.GithubMirror.Mirrors) != 4 {
t.Fatal("missing root mirror config must use new defaults")
}
value, err := d.readProtocolConfig("bt")
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(value)
var cfg struct {
ListenPort int `json:"listenPort"`
AutoUpdateTrackers bool `json:"autoUpdateTrackers"`
CustomTrackers []string `json:"customTrackers"`
}
if err := json.Unmarshal(raw, &cfg); err != nil || cfg.ListenPort != 6881 || cfg.AutoUpdateTrackers || len(cfg.CustomTrackers) != 0 {
t.Fatalf("old extra settings affected protocol config: %s %v", raw, err)
}
}
func TestProtocolJobsRunAutomaticallyAndRestoreSuccess(t *testing.T) {
dir := t.TempDir()
executed := make(chan fetcher.TriggerSource, 4)
manager := func() *testJobManager {
return &testJobManager{job: fetcher.CronJob{Name: "refresh", Every: time.Hour, Timeout: time.Second, Run: func(_ context.Context, source fetcher.TriggerSource) error { executed <- source; return nil }}}
}
makeDownloader := func() *Downloader {
d := NewDownloader(&DownloaderConfig{Storage: NewBoltStorage(dir), StorageDir: dir, TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{manager()}})
if err := d.Setup(); err != nil {
t.Fatal(err)
}
return d
}
d := makeDownloader()
select {
case source := <-executed:
if source != fetcher.TriggerScheduled {
t.Fatal(source)
}
case <-time.After(3 * time.Second):
t.Fatal("overdue job was not run")
}
entry := d.protocolJobs.jobs["test/refresh"]
entry.mu.Lock()
run := entry.active
entry.mu.Unlock()
if run != nil {
<-run.done
}
if err := d.Close(); err != nil {
t.Fatal(err)
}
d = makeDownloader()
defer d.Close()
state := d.protocolJobs.jobs["test/refresh"].state
if state.LastSuccess.IsZero() {
t.Fatal("successful execution not restored")
}
select {
case <-executed:
t.Fatal("recently successful job ran again on restart")
case <-time.After(30 * time.Millisecond):
}
}
+186
View File
@@ -0,0 +1,186 @@
package download
import (
"errors"
"path"
"strings"
"unicode"
"github.com/GopeedLab/gopeed/pkg/base"
)
// categoryForResource returns the category that receives the resolved
// resource, or nil when auto categorize does not apply. Only a single file
// resource is routed: a folder or a multi file resource keeps its directory
// layout, so its files are never matched one by one, not even when only a
// single one of them is selected.
func categoryForResource(cfg *base.DownloaderStoreConfig, res *base.Resource) *base.DownloadCategory {
if !cfg.AutoCategorizeEnabled() || len(cfg.Categories) == 0 {
return nil
}
if res == nil || res.Name != "" || len(res.Files) != 1 {
return nil
}
return matchDownloadCategory(cfg, res.Files[0].Name)
}
// decideDownloadDir returns the download directory of a task. The base
// directory is the one the task asked for, or the configured download
// directory when the task left it empty. A task that did not skip auto
// categorize is routed to the category directory of its resolved resource
// when one matches. Path placeholders are expanded and the white list check
// runs on the result, so a category directory the white list rejects falls
// back to the base directory of this task instead of failing it.
func (d *Downloader) decideDownloadDir(opts *base.Options, res *base.Resource) (string, error) {
if opts == nil {
opts = &base.Options{}
}
cfg, err := d.GetConfig()
if err != nil {
return "", err
}
base := dartTrimSpace(opts.Path)
if base == "" {
base = cfg.DownloadDir
}
// Refuse to fall through to the process working directory when neither the
// task nor the config names a directory.
if base == "" {
return "", errors.New("download directory is empty")
}
target := base
if !opts.SkipAutoCategorize {
if category := categoryForResource(cfg, res); category != nil {
target = dartTrimSpace(category.Path)
}
}
dir, err := d.initDownloadPath(target)
if err != nil && target != base {
d.Logger.Warn().Err(err).Msgf("category directory %q rejected by white list, falling back to %q", target, base)
dir, err = d.initDownloadPath(base)
}
if err != nil {
return "", err
}
return dir, nil
}
// suggestedDownloadDir returns the category directory a task would be routed
// to, or an empty string when auto categorize does not apply. It only reports
// the decision of decideDownloadDir, it expands no directory and writes
// nothing.
func (d *Downloader) suggestedDownloadDir(res *base.Resource) string {
cfg, err := d.GetConfig()
if err != nil {
return ""
}
category := categoryForResource(cfg, res)
if category == nil {
return ""
}
dir, err := d.initDownloadPath(dartTrimSpace(category.Path))
if err != nil {
return ""
}
return dir
}
// asciiLower lowercases the ascii letters of s and leaves the rest alone. The
// ui lowercases the same way, because the unicode case tables of the two
// languages disagree for some code points and would let the two sides pick a
// different category for the same file.
func asciiLower(s string) string {
hasUpper := false
for i := 0; i < len(s); i++ {
if c := s[i]; c >= 'A' && c <= 'Z' {
hasUpper = true
break
}
}
if !hasUpper {
return s
}
b := []byte(s)
for i, c := range b {
if c >= 'A' && c <= 'Z' {
b[i] = c + ('a' - 'A')
}
}
return string(b)
}
// matchDownloadCategory returns the first category whose extension list
// contains the extension of the given file name, or nil when nothing matches.
// An empty extension list never matches.
func matchDownloadCategory(cfg *base.DownloaderStoreConfig, name string) *base.DownloadCategory {
ext := fileExtension(name)
if ext == "" {
return nil
}
for _, category := range cfg.Categories {
if category == nil || category.IsDeleted || dartTrimSpace(category.Path) == "" {
continue
}
for _, e := range normalizeCategoryExtensions(category.Extensions) {
if e == ext {
return category
}
}
}
return nil
}
// normalizeCategoryExtensions mirrors the dart side: entries are split on
// commas and whitespace, trimmed, lowercased, stripped of leading dots and
// deduplicated while keeping the input order, so a stored value like
// "exe, msi" behaves the same in the backend as it does in the ui.
func normalizeCategoryExtensions(raw []string) []string {
var result []string
seen := make(map[string]struct{}, len(raw))
for _, item := range raw {
for _, part := range strings.FieldsFunc(item, func(r rune) bool {
return r == ',' || isECMAScriptSpace(r)
}) {
ext := asciiLower(strings.TrimLeft(dartTrimSpace(part), "."))
if ext == "" {
continue
}
if _, ok := seen[ext]; ok {
continue
}
seen[ext] = struct{}{}
result = append(result, ext)
}
}
return result
}
// fileExtension returns the lower case extension of a file name without the
// leading dot, e.g. "setup.exe" -> "exe". Windows style separators are
// treated like the ui does, so a dot inside a directory segment never counts
// as an extension.
func fileExtension(name string) string {
name = strings.ReplaceAll(name, `\`, "/")
return strings.TrimPrefix(asciiLower(path.Ext(name)), ".")
}
// dartTrimSpace trims the whitespace the dart String.trim() removes, which is
// the unicode white space set plus the byte order mark. The ui trims the
// category paths and the extension lists with it, so the backend uses the same
// set instead of the narrower unicode.IsSpace.
func dartTrimSpace(s string) string {
return strings.TrimFunc(s, func(r rune) bool {
return unicode.IsSpace(r) || r == 0xFEFF
})
}
// isECMAScriptSpace reports whether r belongs to the whitespace class of the
// ECMAScript regular expressions. The ui splits its extension lists with that
// class, so the backend uses the same one instead of the wider unicode.IsSpace.
func isECMAScriptSpace(r rune) bool {
switch r {
case '\t', '\n', '\v', '\f', '\r', ' ', 0x00A0, 0x1680, 0x2028, 0x2029, 0x202F, 0x205F, 0x3000, 0xFEFF:
return true
}
return r >= 0x2000 && r <= 0x200A
}
+566
View File
@@ -0,0 +1,566 @@
package download
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"sync/atomic"
"testing"
"time"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/util"
)
type failNextTaskPutStorage struct {
Storage
fail atomic.Bool
failBucket string
beforeTaskPut func(*Task)
}
func (s *failNextTaskPutStorage) Put(bucket string, key string, value any) error {
if bucket == bucketTask && s.beforeTaskPut != nil {
s.beforeTaskPut(value.(*Task))
}
failBucket := s.failBucket
if failBucket == "" {
failBucket = bucketTask
}
if bucket == failBucket && (bucket != bucketTask || !value.(*Task).NeedsPreparation) && s.fail.CompareAndSwap(true, false) {
return errors.New("task snapshot write failed")
}
return s.Storage.Put(bucket, key, value)
}
func TestDownloader_PreparationSaveFailureCanRetry(t *testing.T) {
for _, failBucket := range []string{bucketSave, bucketTask} {
for _, restart := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/restart=%t", failBucket, restart), func(t *testing.T) {
url := startCategoryFileServer(t, "setup.data", []byte("0123456789"))
downloadDir := t.TempDir()
storageDir := t.TempDir()
storage := &failNextTaskPutStorage{Storage: NewBoltStorage(storageDir), failBucket: failBucket}
downloader := NewDownloader(&DownloaderConfig{Storage: storage, StorageDir: storageDir})
if err := downloader.Setup(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { downloader.Close() })
categoryDir := putCategoryConfig(t, downloader, downloadDir, true, []string{"data"})
if err := os.MkdirAll(categoryDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(categoryDir, "setup.data"), []byte("existing"), 0644); err != nil {
t.Fatal(err)
}
failed := watchTaskError(t, downloader)
storage.fail.Store(true)
id, err := downloader.CreateDirect(&base.Request{URL: url}, &base.Options{Path: downloadDir})
if err != nil {
t.Fatal(err)
}
waitTaskError(t, failed)
task := downloader.GetTask(id)
if !task.NeedsPreparation || task.Meta.Opts.Path != downloadDir || task.Meta.Opts.Name != "" {
t.Fatal("failed preparation changed the original directory, name or flag")
}
if restart {
if err := downloader.Close(); err != nil {
t.Fatal(err)
}
downloader = NewDownloader(&DownloaderConfig{Storage: NewBoltStorage(storageDir), StorageDir: storageDir})
if err := downloader.Setup(); err != nil {
t.Fatal(err)
}
}
done := watchTaskDone(t, downloader)
if err := downloader.Continue(&TaskFilter{IDs: []string{id}}); err != nil {
t.Fatal(err)
}
waitTaskDone(t, done)
task = downloader.GetTask(id)
if task.NeedsPreparation || task.Meta.Opts.Path != categoryDir || task.Meta.Opts.Name != "setup (1).data" {
t.Fatalf("retry did not prepare the final directory and duplicate name: %+v", task.Meta.Opts)
}
data, err := os.ReadFile(filepath.Join(categoryDir, "setup (1).data"))
if err != nil || string(data) != "0123456789" {
t.Fatalf("downloaded content = %q, %v", data, err)
}
})
}
}
}
// watchTaskError subscribes to the error event, so a failing task can be
// awaited instead of polled.
func watchTaskError(t *testing.T, d *Downloader) <-chan struct{} {
t.Helper()
failed := make(chan struct{}, 1)
d.Listener(func(event *Event) {
if event.Key == EventKeyError {
select {
case failed <- struct{}{}:
default:
}
}
})
return failed
}
func waitTaskError(t *testing.T, failed <-chan struct{}) {
t.Helper()
select {
case <-failed:
case <-time.After(30 * time.Second):
t.Fatal("task did not fail")
}
}
// waitTaskStatus waits until the task reaches one of the given statuses.
func waitTaskStatus(t *testing.T, d *Downloader, task *Task, statuses ...base.Status) base.Status {
t.Helper()
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
status := d.taskStatus(task)
for _, want := range statuses {
if status == want {
return status
}
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("task status = %q, want one of %v", d.taskStatus(task), statuses)
return ""
}
// setMaxRunning limits the number of tasks that run at the same time.
func setMaxRunning(t *testing.T, d *Downloader, max int) {
t.Helper()
cfg, err := d.GetConfig()
if err != nil {
t.Fatal(err)
}
cfg.MaxRunning = max
if err := d.PutConfig(cfg); err != nil {
t.Fatal(err)
}
}
// A task that waits in the queue is prepared when it starts, the first
// preparation must not be skipped by the wait status.
func TestDownloader_QueuedTaskIsPreparedWhenItStarts(t *testing.T) {
release := make(chan struct{})
slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-release
w.Header().Set("Content-Disposition", `attachment; filename="slow.bin"`)
w.Write([]byte("slow"))
}))
defer slow.Close()
fast := startCategoryFileServer(t, "setup.data", []byte("0123456789"))
downloadDir := t.TempDir()
downloader := newSetupCategoryDownloader(t)
categoryDir := putCategoryConfig(t, downloader, downloadDir, true, []string{"data"})
setMaxRunning(t, downloader, 1)
// The blocker takes the only running slot.
blockerID, err := downloader.CreateDirect(&base.Request{URL: slow.URL}, &base.Options{Path: downloadDir})
if err != nil {
t.Fatal(err)
}
blocker := downloader.GetTask(blockerID)
if blocker == nil {
t.Fatal("blocker task not found")
}
waitTaskStatus(t, downloader, blocker, base.DownloadStatusRunning)
queuedID, err := downloader.CreateDirect(&base.Request{URL: fast}, &base.Options{Path: downloadDir})
if err != nil {
t.Fatal(err)
}
queued := downloader.GetTask(queuedID)
if queued == nil {
t.Fatal("task not found")
}
if queued.Status != base.DownloadStatusWait {
t.Fatalf("queued task status = %q, want wait", queued.Status)
}
close(release)
waitTaskStatus(t, downloader, queued, base.DownloadStatusDone, base.DownloadStatusError)
if queued.Meta.Opts.Path != categoryDir {
t.Fatalf("queued task path = %q, want the category dir %q", queued.Meta.Opts.Path, categoryDir)
}
if _, err := os.Stat(filepath.Join(categoryDir, "setup.data")); err != nil {
t.Fatalf("categorized file missing: %v", err)
}
}
// A task that failed before it was prepared is prepared when it continues.
func TestDownloader_FailedTaskIsPreparedWhenItContinues(t *testing.T) {
url := startCategoryFileServer(t, "setup.data", []byte("0123456789"))
downloadDir := t.TempDir()
allowedDir := t.TempDir()
downloader := newSetupCategoryDownloader(t)
categoryDir := putCategoryConfig(t, downloader, downloadDir, true, []string{"data"})
// The white list rejects the base directory, so the first start fails.
downloader.cfg.WhiteDownloadDirs = []string{allowedDir}
failed := watchTaskError(t, downloader)
taskID, err := downloader.CreateDirect(&base.Request{URL: url}, &base.Options{Path: downloadDir})
if err != nil {
t.Fatal(err)
}
task := downloader.GetTask(taskID)
if task == nil {
t.Fatal("task not found")
}
waitTaskError(t, failed)
waitTaskStatus(t, downloader, task, base.DownloadStatusError)
if !task.NeedsPreparation {
t.Fatal("a failed preparation must keep the needs preparation flag")
}
// The white list accepts the base directory now, the retry prepares the
// task again.
downloader.cfg.WhiteDownloadDirs = []string{allowedDir, downloadDir, categoryDir}
done := watchTaskDone(t, downloader)
if err := downloader.Continue(&TaskFilter{IDs: []string{taskID}}); err != nil {
t.Fatal(err)
}
waitTaskDone(t, done)
if task.Meta.Opts.Path != categoryDir {
t.Fatalf("retried task path = %q, want the category dir %q", task.Meta.Opts.Path, categoryDir)
}
if task.NeedsPreparation {
t.Fatal("a prepared task must clear the needs preparation flag")
}
}
// A failed preparation save leaves the live task untouched, so the next
// attempt starts from the original base directory and name.
func TestDownloader_PreparationSaveFailureKeepsOriginalData(t *testing.T) {
url := startCategoryFileServer(t, "setup.data", []byte("0123456789"))
downloadDir := t.TempDir()
storage := &failNextTaskPutStorage{Storage: NewMemStorage()}
downloader := NewDownloader(&DownloaderConfig{Storage: storage})
if err := downloader.Setup(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
downloader.Delete(nil, true)
downloader.Clear()
})
categoryDir := putCategoryConfig(t, downloader, downloadDir, true, []string{"data"})
setMaxRunning(t, downloader, 0)
result, err := downloader.Resolve(&base.Request{URL: url})
if err != nil {
t.Fatal(err)
}
taskID, err := downloader.CreateWithOptions(result.ID, &base.Options{
Path: downloadDir,
Name: "renamed?.data",
})
if err != nil {
t.Fatal(err)
}
task := downloader.GetTask(taskID)
if task == nil {
t.Fatal("task not found")
}
originalMeta := util.DeepClone(task.Meta)
originalRequest := task.Meta.Req
storage.beforeTaskPut = func(snapshot *Task) {
if !task.NeedsPreparation || !reflect.DeepEqual(task.Meta, originalMeta) {
t.Error("preparation was published before the snapshot was saved")
}
if snapshot.NeedsPreparation || snapshot.Meta.Opts.Path != categoryDir {
t.Error("the snapshot must contain the prepared directory and cleared flag")
}
}
storage.fail.Store(true)
if err := downloader.prepareTask(task); err == nil {
t.Fatal("prepareTask() succeeded, want a storage error")
}
if !task.NeedsPreparation {
t.Fatal("a failed save must keep the needs preparation flag")
}
if !reflect.DeepEqual(task.Meta, originalMeta) {
t.Fatalf("task metadata changed after a failed save:\n got: %#v\nwant: %#v", task.Meta, originalMeta)
}
if !reflect.DeepEqual(task.fetcher.Meta(), originalMeta) {
t.Fatalf("fetcher metadata changed after a failed save:\n got: %#v\nwant: %#v", task.fetcher.Meta(), originalMeta)
}
if err := downloader.prepareTask(task); err != nil {
t.Fatal(err)
}
if task.NeedsPreparation {
t.Fatal("a successful retry must clear the needs preparation flag")
}
if task.Meta.Opts.Path != categoryDir {
t.Fatalf("retried task path = %q, want %q", task.Meta.Opts.Path, categoryDir)
}
if task.Meta != task.fetcher.Meta() || task.Meta.Req != originalRequest {
t.Fatal("preparation must preserve the shared metadata and resolved request")
}
storage.beforeTaskPut = nil
task.Meta.Res.Size++
var saved Task
if _, err := storage.Get(bucketTask, taskID, &saved); err != nil {
t.Fatal(err)
}
if saved.Meta.Res.Size != originalMeta.Res.Size {
t.Fatal("live metadata changes must not alter the saved task snapshot")
}
}
// The white list is checked on every start, a task that was rejected once
// keeps failing instead of writing into the rejected directory.
func TestDownloader_WhiteListIsCheckedOnEveryStart(t *testing.T) {
url := startCategoryFileServer(t, "setup.data", []byte("0123456789"))
downloadDir := t.TempDir()
allowedDir := t.TempDir()
downloader := newSetupCategoryDownloader(t)
putCategoryConfig(t, downloader, downloadDir, true, []string{"data"})
downloader.cfg.WhiteDownloadDirs = []string{allowedDir}
failed := watchTaskError(t, downloader)
taskID, err := downloader.CreateDirect(&base.Request{URL: url}, &base.Options{Path: downloadDir})
if err != nil {
t.Fatal(err)
}
task := downloader.GetTask(taskID)
if task == nil {
t.Fatal("task not found")
}
waitTaskError(t, failed)
waitTaskStatus(t, downloader, task, base.DownloadStatusError)
// The task is continued, the white list still rejects the directory.
failed = watchTaskError(t, downloader)
if err := downloader.Continue(&TaskFilter{IDs: []string{taskID}}); err != nil {
t.Fatal(err)
}
waitTaskError(t, failed)
waitTaskStatus(t, downloader, task, base.DownloadStatusError)
if _, err := os.Stat(filepath.Join(downloadDir, "setup.data")); err == nil {
t.Fatalf("the file was written into the rejected dir %q", downloadDir)
}
}
// The white list is checked on every start, even for a task that was prepared
// before the white list changed.
func TestDownloader_WhiteListIsCheckedForAPreparedTask(t *testing.T) {
// The first request is truncated, so the download fails after the task was
// prepared and the retry can show that the directory is checked again.
var requests int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Disposition", `attachment; filename="setup.data"`)
w.Header().Set("Content-Length", "8")
w.WriteHeader(http.StatusOK)
if atomic.AddInt32(&requests, 1) == 1 {
w.Write([]byte("0123"))
return
}
w.Write([]byte("01234567"))
}))
defer server.Close()
downloadDir := t.TempDir()
allowedDir := t.TempDir()
downloader := newSetupCategoryDownloader(t)
categoryDir := putCategoryConfig(t, downloader, downloadDir, true, []string{"data"})
downloader.cfg.WhiteDownloadDirs = []string{allowedDir, downloadDir, categoryDir}
failed := watchTaskError(t, downloader)
taskID, err := downloader.CreateDirect(&base.Request{URL: server.URL}, &base.Options{Path: downloadDir})
if err != nil {
t.Fatal(err)
}
task := downloader.GetTask(taskID)
if task == nil {
t.Fatal("task not found")
}
waitTaskError(t, failed)
waitTaskStatus(t, downloader, task, base.DownloadStatusError)
if task.Meta.Opts.Path != categoryDir {
t.Fatalf("task path = %q, want the category dir %q", task.Meta.Opts.Path, categoryDir)
}
// The white list no longer accepts the prepared directory, the task must
// not download into it.
downloader.cfg.WhiteDownloadDirs = []string{allowedDir}
requestsBeforeRetry := atomic.LoadInt32(&requests)
failed = watchTaskError(t, downloader)
if err := downloader.Continue(&TaskFilter{IDs: []string{taskID}}); err != nil {
t.Fatal(err)
}
waitTaskError(t, failed)
waitTaskStatus(t, downloader, task, base.DownloadStatusError)
if atomic.LoadInt32(&requests) != requestsBeforeRetry {
t.Fatalf("the task downloaded into the rejected dir %q", categoryDir)
}
}
// A prepared task keeps its directory, a later start never classifies it
// again.
func TestDownloader_PreparedTaskKeepsItsDirectory(t *testing.T) {
// The first request is truncated, so the download fails after the task was
// prepared and the retry can show that the directory is kept.
var requests int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Disposition", `attachment; filename="setup.data"`)
w.Header().Set("Content-Length", "8")
w.WriteHeader(http.StatusOK)
if atomic.AddInt32(&requests, 1) == 1 {
w.Write([]byte("0123"))
return
}
w.Write([]byte("01234567"))
}))
defer server.Close()
downloadDir := t.TempDir()
downloader := newSetupCategoryDownloader(t)
categoryDir := putCategoryConfig(t, downloader, downloadDir, true, []string{"data"})
failed := watchTaskError(t, downloader)
taskID, err := downloader.CreateDirect(&base.Request{URL: server.URL}, &base.Options{Path: downloadDir})
if err != nil {
t.Fatal(err)
}
task := downloader.GetTask(taskID)
if task == nil {
t.Fatal("task not found")
}
waitTaskError(t, failed)
waitTaskStatus(t, downloader, task, base.DownloadStatusError)
if task.Meta.Opts.Path != categoryDir {
t.Fatalf("task path = %q, want the category dir %q", task.Meta.Opts.Path, categoryDir)
}
if task.NeedsPreparation {
t.Fatal("a prepared task must clear the needs preparation flag")
}
// The category no longer matches the file, the prepared task keeps the
// directory it was prepared with.
putCategoryConfig(t, downloader, downloadDir, true, []string{"iso"})
done := watchTaskDone(t, downloader)
if err := downloader.Continue(&TaskFilter{IDs: []string{taskID}}); err != nil {
t.Fatal(err)
}
waitTaskDone(t, done)
if task.Meta.Opts.Path != categoryDir {
t.Fatalf("task path = %q, want the prepared dir %q", task.Meta.Opts.Path, categoryDir)
}
if _, err := os.Stat(filepath.Join(categoryDir, "setup.data")); err != nil {
t.Fatalf("the file was not written into the prepared dir: %v", err)
}
}
// The global default download directory is only saved when the user named the
// directory explicitly, an auto categorized task never becomes the default.
func TestDownloader_DefaultPathNeedsTheSkipFlag(t *testing.T) {
url := startCategoryFileServer(t, "setup.data", []byte("0123456789"))
downloadDir := t.TempDir()
chosenDir := t.TempDir()
downloader := newSetupCategoryDownloader(t)
putCategoryConfig(t, downloader, downloadDir, true, []string{"data"})
done := watchTaskDone(t, downloader)
// The skip flag is missing, the task keeps the auto categorize decision
// and the global default stays untouched.
if _, err := downloader.CreateDirect(&base.Request{URL: url}, &base.Options{
Path: chosenDir,
AsDefaultPath: true,
}); err != nil {
t.Fatal(err)
}
waitTaskDone(t, done)
cfg, err := downloader.GetConfig()
if err != nil {
t.Fatal(err)
}
if cfg.DownloadDir == chosenDir {
t.Fatalf("default download dir = %q, want it unchanged", cfg.DownloadDir)
}
// The user named the directory, so it becomes the global default.
if _, err := downloader.CreateDirect(&base.Request{URL: url}, &base.Options{
Path: chosenDir,
AsDefaultPath: true,
SkipAutoCategorize: true,
}); err != nil {
t.Fatal(err)
}
waitTaskDone(t, done)
cfg, err = downloader.GetConfig()
if err != nil {
t.Fatal(err)
}
if cfg.DownloadDir != chosenDir {
t.Fatalf("default download dir = %q, want %q", cfg.DownloadDir, chosenDir)
}
}
// Every task of a batch decides its own directory, the shared options of the
// batch do not leak into the single tasks.
func TestDownloader_BatchTasksDecideTheirOwnDirectory(t *testing.T) {
url := startCategoryFileServer(t, "setup.data", []byte("0123456789"))
downloadDir := t.TempDir()
chosenDir := t.TempDir()
downloader := newSetupCategoryDownloader(t)
categoryDir := putCategoryConfig(t, downloader, downloadDir, true, []string{"data"})
done := watchTaskDone(t, downloader)
taskIDs, err := downloader.CreateDirectBatch(&base.CreateTaskBatch{
Reqs: []*base.CreateTaskBatchItem{
{
Req: &base.Request{URL: url},
Opts: &base.Options{Path: chosenDir, SkipAutoCategorize: true},
},
{
Req: &base.Request{URL: url},
Opts: &base.Options{Path: downloadDir},
},
},
Opts: &base.Options{Path: downloadDir},
})
if err != nil {
t.Fatal(err)
}
if len(taskIDs) != 2 {
t.Fatalf("task count = %d, want 2", len(taskIDs))
}
waitTaskDone(t, done)
waitTaskDone(t, done)
skipped := downloader.GetTask(taskIDs[0])
if skipped == nil {
t.Fatal("task not found")
}
if skipped.Meta.Opts.Path != chosenDir {
t.Fatalf("skipped task path = %q, want %q", skipped.Meta.Opts.Path, chosenDir)
}
categorized := downloader.GetTask(taskIDs[1])
if categorized == nil {
t.Fatal("task not found")
}
if categorized.Meta.Opts.Path != categoryDir {
t.Fatalf("categorized task path = %q, want %q", categorized.Meta.Opts.Path, categoryDir)
}
}
+695
View File
@@ -0,0 +1,695 @@
package download
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"slices"
"testing"
"time"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/util"
)
func newTestCategoryConfig() *base.DownloaderStoreConfig {
return &base.DownloaderStoreConfig{
DownloadDir: "./downloads",
AutoCategorize: util.BoolPtr(true),
Categories: []*base.DownloadCategory{
{Name: "Music", Path: filepath.Join("./downloads", "Music"), Extensions: []string{"mp3", "flac"}},
{Name: "Video", Path: filepath.Join("./downloads", "Video"), Extensions: []string{"mp4", "mkv"}},
{Name: "Program", Path: filepath.Join("./downloads", "Program"), Extensions: []string{"exe", "msi"}},
},
}
}
// singleFileResource is the resource shape of a one file task: no folder name
// and a single file entry.
func singleFileResource(name string) *base.Resource {
return &base.Resource{Files: []*base.FileInfo{{Name: name, Size: 1}}}
}
// folderResource is the resource shape of a multi file task: a folder name and
// one entry per file, even when only one of them is selected later.
func folderResource(name string, files ...string) *base.Resource {
res := &base.Resource{Name: name}
for _, file := range files {
res.Files = append(res.Files, &base.FileInfo{Name: file, Size: 1})
}
return res
}
func newUnitCategoryDownloader(cfg *base.DownloaderStoreConfig) *Downloader {
d := NewDownloader(nil)
d.cfg.DownloaderStoreConfig = cfg
return d
}
func TestDecideDownloadDir(t *testing.T) {
programDir := filepath.Join("./downloads", "Program")
tests := []struct {
name string
cfg func(cfg *base.DownloaderStoreConfig)
opts *base.Options
res *base.Resource
want string
}{
{
name: "matching single file routes to the category",
opts: &base.Options{},
res: singleFileResource("setup.exe"),
want: programDir,
},
{
name: "an explicit base directory is routed too",
opts: &base.Options{Path: "./elsewhere"},
res: singleFileResource("setup.exe"),
want: programDir,
},
{
name: "skip flag keeps the configured download directory",
opts: &base.Options{Path: "./downloads", SkipAutoCategorize: true},
res: singleFileResource("setup.exe"),
want: "./downloads",
},
{
name: "skip flag keeps an explicit directory",
opts: &base.Options{Path: "./elsewhere", SkipAutoCategorize: true},
res: singleFileResource("setup.exe"),
want: "./elsewhere",
},
{
name: "switch off keeps the base directory",
cfg: func(cfg *base.DownloaderStoreConfig) { cfg.AutoCategorize = util.BoolPtr(false) },
opts: &base.Options{},
res: singleFileResource("setup.exe"),
want: "./downloads",
},
{
name: "no categories keeps the base directory",
cfg: func(cfg *base.DownloaderStoreConfig) { cfg.Categories = nil },
opts: &base.Options{},
res: singleFileResource("setup.exe"),
want: "./downloads",
},
{
name: "unknown extension keeps the base directory",
opts: &base.Options{},
res: singleFileResource("archive.zip"),
want: "./downloads",
},
{
name: "a name without an extension keeps the base directory",
opts: &base.Options{},
res: singleFileResource("README"),
want: "./downloads",
},
{
name: "a deleted category is skipped",
cfg: func(cfg *base.DownloaderStoreConfig) {
cfg.Categories = []*base.DownloadCategory{
{Name: "Program", Path: programDir, Extensions: []string{"exe"}, IsDeleted: true},
}
},
opts: &base.Options{},
res: singleFileResource("setup.exe"),
want: "./downloads",
},
{
name: "a category without a path is skipped",
cfg: func(cfg *base.DownloaderStoreConfig) {
cfg.Categories = []*base.DownloadCategory{
{Name: "Program", Path: " ", Extensions: []string{"exe"}},
}
},
opts: &base.Options{},
res: singleFileResource("setup.exe"),
want: "./downloads",
},
{
name: "a multi file resource keeps its layout",
opts: &base.Options{},
res: folderResource("bundle", "setup.exe", "readme.txt"),
want: "./downloads",
},
{
name: "a multi file resource with one match still keeps its layout",
opts: &base.Options{SelectFiles: []int{0}},
res: folderResource("bundle", "setup.exe", "readme.txt"),
want: "./downloads",
},
{
name: "a single file inside a folder resource is not routed",
opts: &base.Options{},
res: folderResource("bundle", "setup.exe"),
want: "./downloads",
},
{
name: "a missing resource keeps the base directory",
opts: &base.Options{},
res: nil,
want: "./downloads",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := newTestCategoryConfig()
if tt.cfg != nil {
tt.cfg(cfg)
}
d := newUnitCategoryDownloader(cfg)
got, err := d.decideDownloadDir(tt.opts, tt.res)
if err != nil {
t.Fatalf("decideDownloadDir() error = %v", err)
}
if got != tt.want {
t.Fatalf("decideDownloadDir() = %q, want %q", got, tt.want)
}
})
}
}
func TestDecideDownloadDirWhiteList(t *testing.T) {
defaultDir := filepath.ToSlash(t.TempDir())
categoryDir := defaultDir + "/Program"
newConfig := func() *base.DownloaderStoreConfig {
cfg := newTestCategoryConfig()
cfg.DownloadDir = defaultDir
cfg.Categories = []*base.DownloadCategory{
{Name: "Program", Path: categoryDir, Extensions: []string{"exe"}},
}
return cfg
}
t.Run("a rejected category directory falls back to the base directory", func(t *testing.T) {
d := newUnitCategoryDownloader(newConfig())
d.cfg.WhiteDownloadDirs = []string{defaultDir}
got, err := d.decideDownloadDir(&base.Options{Path: defaultDir}, singleFileResource("setup.exe"))
if err != nil {
t.Fatalf("expected white list fallback, got %v", err)
}
if got != defaultDir {
t.Fatalf("path = %q, want the base directory %q", got, defaultDir)
}
})
t.Run("a rejected base directory is an error", func(t *testing.T) {
d := newUnitCategoryDownloader(newConfig())
d.cfg.WhiteDownloadDirs = []string{defaultDir}
_, err := d.decideDownloadDir(&base.Options{Path: defaultDir + "/Other", SkipAutoCategorize: true}, singleFileResource("setup.exe"))
if err == nil {
t.Fatal("expected a white list error for an explicit directory")
}
})
t.Run("equivalent category paths still fall back to the allowed spelling", func(t *testing.T) {
cfg := newConfig()
cfg.Categories[0].Path = defaultDir + "/."
d := newUnitCategoryDownloader(cfg)
d.cfg.WhiteDownloadDirs = []string{defaultDir}
got, err := d.decideDownloadDir(&base.Options{Path: defaultDir}, singleFileResource("setup.exe"))
if err != nil || got != defaultDir {
t.Fatalf("decideDownloadDir() = %q, %v; want %q, nil", got, err, defaultDir)
}
})
t.Run("an empty base directory is an error", func(t *testing.T) {
cfg := newConfig()
cfg.DownloadDir = ""
d := newUnitCategoryDownloader(cfg)
_, err := d.decideDownloadDir(&base.Options{SkipAutoCategorize: true}, singleFileResource("setup.exe"))
if err == nil {
t.Fatal("expected an error for an empty download directory")
}
})
}
func TestSuggestedDownloadDir(t *testing.T) {
programDir := filepath.Join("./downloads", "Program")
tests := []struct {
name string
cfg func(cfg *base.DownloaderStoreConfig)
res *base.Resource
want string
}{
{name: "a matching single file is suggested", res: singleFileResource("setup.exe"), want: programDir},
{
name: "a padded category path matches the creation decision",
cfg: func(cfg *base.DownloaderStoreConfig) { cfg.Categories[2].Path = " " + programDir + " " },
res: singleFileResource("setup.exe"),
want: programDir,
},
{
name: "the switch off suggests nothing",
cfg: func(cfg *base.DownloaderStoreConfig) { cfg.AutoCategorize = util.BoolPtr(false) },
res: singleFileResource("setup.exe"),
want: "",
},
{name: "a multi file resource suggests nothing", res: folderResource("bundle", "setup.exe"), want: ""},
{name: "a folder resource suggests nothing", res: folderResource("bundle", "setup.exe", "a.txt"), want: ""},
{name: "an unknown extension suggests nothing", res: singleFileResource("archive.zip"), want: ""},
{name: "a missing resource suggests nothing", res: nil, want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := newTestCategoryConfig()
if tt.cfg != nil {
tt.cfg(cfg)
}
d := newUnitCategoryDownloader(cfg)
if got := d.suggestedDownloadDir(tt.res); got != tt.want {
t.Fatalf("suggestedDownloadDir() = %q, want %q", got, tt.want)
}
})
}
t.Run("a rejected category directory suggests nothing", func(t *testing.T) {
dir := filepath.ToSlash(t.TempDir())
cfg := newTestCategoryConfig()
cfg.DownloadDir = dir
cfg.Categories = []*base.DownloadCategory{
{Name: "Program", Path: dir + "/Program", Extensions: []string{"exe"}},
}
d := newUnitCategoryDownloader(cfg)
d.cfg.WhiteDownloadDirs = []string{dir}
if got := d.suggestedDownloadDir(singleFileResource("setup.exe")); got != "" {
t.Fatalf("suggestedDownloadDir() = %q, want an empty suggestion", got)
}
})
}
func TestNormalizeCategoryExtensions(t *testing.T) {
tests := []struct {
raw []string
want []string
}{
{[]string{"exe, msi"}, []string{"exe", "msi"}},
{[]string{" mp3 ", "..exe", "MUSIC"}, []string{"mp3", "exe", "music"}},
{[]string{"zip", "ZIP", "zip"}, []string{"zip"}},
{[]string{" ", "..", ""}, nil},
{nil, nil},
}
for _, tt := range tests {
got := normalizeCategoryExtensions(tt.raw)
if len(got) != len(tt.want) {
t.Fatalf("normalizeCategoryExtensions(%q) = %q, want %q", tt.raw, got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Fatalf("normalizeCategoryExtensions(%q) = %q, want %q", tt.raw, got, tt.want)
}
}
}
}
// startCategoryFileServer serves a nameless url whose real file name only
// shows up in the Content-Disposition header.
func startCategoryFileServer(t *testing.T, fileName string, data []byte) string {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Disposition", `attachment; filename="`+fileName+`"`)
w.Header().Set("Content-Type", "application/octet-stream")
http.ServeContent(w, r, fileName, time.Time{}, bytes.NewReader(data))
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
return server.URL + "/download"
}
func newSetupCategoryDownloader(t *testing.T) *Downloader {
t.Helper()
downloader := NewDownloader(nil)
if err := downloader.Setup(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
downloader.Delete(nil, true)
downloader.Clear()
})
return downloader
}
func putCategoryConfig(t *testing.T, d *Downloader, downloadDir string, autoCategorize bool, extensions []string) string {
t.Helper()
categoryDir := filepath.Join(downloadDir, "Program")
cfg, err := d.GetConfig()
if err != nil {
t.Fatal(err)
}
cfg.DownloadDir = downloadDir
cfg.AutoCategorize = util.BoolPtr(autoCategorize)
cfg.Categories = []*base.DownloadCategory{
{Name: "Program", Path: categoryDir, Extensions: extensions},
}
if err := d.PutConfig(cfg); err != nil {
t.Fatal(err)
}
return categoryDir
}
// watchTaskDone subscribes to the done event before the task is created, so a
// fast task can not finish before the test starts listening.
func watchTaskDone(t *testing.T, d *Downloader) <-chan struct{} {
t.Helper()
done := make(chan struct{}, 1)
d.Listener(func(event *Event) {
if event.Key == EventKeyDone {
select {
case done <- struct{}{}:
default:
}
}
})
return done
}
func waitTaskDone(t *testing.T, done <-chan struct{}) {
t.Helper()
select {
case <-done:
case <-time.After(30 * time.Second):
t.Fatal("task did not finish")
}
}
func TestDownloader_CreateDirectAutoCategorize(t *testing.T) {
// The url carries no extension, the category is matched on the file name
// of the resolved resource.
url := startCategoryFileServer(t, "setup.data", []byte("0123456789"))
downloadDir := t.TempDir()
downloader := newSetupCategoryDownloader(t)
categoryDir := putCategoryConfig(t, downloader, downloadDir, true, []string{"data"})
done := watchTaskDone(t, downloader)
taskID, err := downloader.CreateDirect(&base.Request{URL: url}, &base.Options{Path: downloadDir})
if err != nil {
t.Fatal(err)
}
task := downloader.GetTask(taskID)
if task == nil {
t.Fatal("task not found")
}
waitTaskDone(t, done)
if task.Meta.Opts.Path != categoryDir {
t.Fatalf("task path = %q, want the category dir %q", task.Meta.Opts.Path, categoryDir)
}
if _, err := os.Stat(filepath.Join(categoryDir, "setup.data")); err != nil {
t.Fatalf("categorized file missing: %v", err)
}
}
func TestDownloader_CreateDirectDoesNotMatchTheRename(t *testing.T) {
url := startCategoryFileServer(t, "setup.data", []byte("0123456789"))
downloadDir := t.TempDir()
downloader := newSetupCategoryDownloader(t)
putCategoryConfig(t, downloader, downloadDir, true, []string{"exe"})
done := watchTaskDone(t, downloader)
// opts.Name only renames the saved file, it never decides the category.
taskID, err := downloader.CreateDirect(&base.Request{URL: url}, &base.Options{
Path: downloadDir,
Name: "renamed.exe",
})
if err != nil {
t.Fatal(err)
}
task := downloader.GetTask(taskID)
if task == nil {
t.Fatal("task not found")
}
waitTaskDone(t, done)
if task.Meta.Opts.Path != downloadDir {
t.Fatalf("task path = %q, want the base dir %q", task.Meta.Opts.Path, downloadDir)
}
if _, err := os.Stat(filepath.Join(downloadDir, "renamed.exe")); err != nil {
t.Fatalf("renamed file missing: %v", err)
}
}
func TestDownloader_CreateDirectSkipAutoCategorize(t *testing.T) {
url := startCategoryFileServer(t, "setup.data", []byte("0123456789"))
downloadDir := t.TempDir()
chosenDir := t.TempDir()
downloader := newSetupCategoryDownloader(t)
putCategoryConfig(t, downloader, downloadDir, true, []string{"data"})
done := watchTaskDone(t, downloader)
taskID, err := downloader.CreateDirect(&base.Request{URL: url}, &base.Options{
Path: chosenDir,
SkipAutoCategorize: true,
})
if err != nil {
t.Fatal(err)
}
task := downloader.GetTask(taskID)
if task == nil {
t.Fatal("task not found")
}
waitTaskDone(t, done)
if task.Meta.Opts.Path != chosenDir {
t.Fatalf("task path = %q, want the chosen dir %q", task.Meta.Opts.Path, chosenDir)
}
if _, err := os.Stat(filepath.Join(chosenDir, "setup.data")); err != nil {
t.Fatalf("file missing in the chosen dir: %v", err)
}
}
func TestDownloader_CreateWithOptionsAutoCategorize(t *testing.T) {
url := startCategoryFileServer(t, "setup.data", []byte("0123456789"))
downloadDir := t.TempDir()
downloader := newSetupCategoryDownloader(t)
categoryDir := putCategoryConfig(t, downloader, downloadDir, true, []string{"data"})
done := watchTaskDone(t, downloader)
rr, err := downloader.Resolve(&base.Request{URL: url})
if err != nil {
t.Fatal(err)
}
if rr.SuggestedDownloadDir != categoryDir {
t.Fatalf("suggested dir = %q, want %q", rr.SuggestedDownloadDir, categoryDir)
}
taskID, err := downloader.CreateWithOptions(rr.ID, &base.Options{Path: downloadDir})
if err != nil {
t.Fatal(err)
}
task := downloader.GetTask(taskID)
if task == nil {
t.Fatal("task not found")
}
waitTaskDone(t, done)
if task.Meta.Opts.Path != categoryDir {
t.Fatalf("task path = %q, want the category dir %q", task.Meta.Opts.Path, categoryDir)
}
}
func TestDownloader_CreateWithOptionsKeepsTheDefaultDirectory(t *testing.T) {
url := startCategoryFileServer(t, "setup.data", []byte("0123456789"))
downloadDir := t.TempDir()
downloader := newSetupCategoryDownloader(t)
putCategoryConfig(t, downloader, downloadDir, true, []string{"data"})
done := watchTaskDone(t, downloader)
rr, err := downloader.Resolve(&base.Request{URL: url})
if err != nil {
t.Fatal(err)
}
taskID, err := downloader.CreateWithOptions(rr.ID, &base.Options{
Path: downloadDir,
AsDefaultPath: true,
})
if err != nil {
t.Fatal(err)
}
waitTaskDone(t, done)
// The task lands in the category directory, but that must not become the
// global default download directory.
task := downloader.GetTask(taskID)
if task == nil {
t.Fatal("task not found")
}
if task.Meta.Opts.Path == downloadDir {
t.Fatalf("task path = %q, want the category dir", task.Meta.Opts.Path)
}
cfg, err := downloader.GetConfig()
if err != nil {
t.Fatal(err)
}
if cfg.DownloadDir != downloadDir {
t.Fatalf("default download dir = %q, want the base dir %q", cfg.DownloadDir, downloadDir)
}
}
func TestDownloader_ResolveSuggestsWithoutSideEffects(t *testing.T) {
url := startCategoryFileServer(t, "setup.data", []byte("0123456789"))
downloadDir := t.TempDir()
downloader := newSetupCategoryDownloader(t)
categoryDir := putCategoryConfig(t, downloader, downloadDir, true, []string{"data"})
rr, err := downloader.Resolve(&base.Request{URL: url})
if err != nil {
t.Fatal(err)
}
if rr.SuggestedDownloadDir != categoryDir {
t.Fatalf("suggested dir = %q, want %q", rr.SuggestedDownloadDir, categoryDir)
}
if _, err := os.Stat(categoryDir); !os.IsNotExist(err) {
t.Fatalf("resolve created the suggested directory: %v", err)
}
cfg, err := downloader.GetConfig()
if err != nil {
t.Fatal(err)
}
if cfg.DownloadDir != downloadDir {
t.Fatalf("resolve changed the download dir to %q", cfg.DownloadDir)
}
}
func TestDownloader_ResolveSuggestsNothingWhenTheSwitchIsOff(t *testing.T) {
url := startCategoryFileServer(t, "setup.data", []byte("0123456789"))
downloadDir := t.TempDir()
downloader := newSetupCategoryDownloader(t)
putCategoryConfig(t, downloader, downloadDir, false, []string{"data"})
rr, err := downloader.Resolve(&base.Request{URL: url})
if err != nil {
t.Fatal(err)
}
if rr.SuggestedDownloadDir != "" {
t.Fatalf("suggested dir = %q, want an empty suggestion", rr.SuggestedDownloadDir)
}
}
func TestDownloader_SetupMigratesLegacyExtraCategories(t *testing.T) {
storage := NewMemStorage()
if err := storage.Setup([]string{bucketConfig}); err != nil {
t.Fatal(err)
}
legacy := &base.DownloaderStoreConfig{
DownloadDir: t.TempDir(),
Extra: map[string]any{
"downloadCategories": []any{
map[string]any{"name": "", "path": filepath.Join(t.TempDir(), "Music"), "isBuiltIn": true, "nameKey": "categoryMusic"},
},
},
}
if err := storage.Put(bucketConfig, "config", legacy); err != nil {
t.Fatal(err)
}
downloader := NewDownloader(&DownloaderConfig{Storage: storage, StorageDir: t.TempDir()})
if err := downloader.Setup(); err != nil {
t.Fatal(err)
}
defer func() {
downloader.Delete(nil, true)
downloader.Clear()
}()
cfg, err := downloader.GetConfig()
if err != nil {
t.Fatal(err)
}
if len(cfg.Categories) != 1 || cfg.Categories[0].NameKey != "categoryMusic" || len(cfg.Categories[0].Extensions) == 0 {
t.Fatalf("categories = %+v, want the migrated built-in with its default extensions", cfg.Categories)
}
if _, ok := cfg.Extra["downloadCategories"]; ok {
t.Fatal("in memory config still carries the legacy key")
}
// The cleared key has to be persisted, not only held in memory.
var stored base.DownloaderStoreConfig
exist, err := storage.Get(bucketConfig, "config", &stored)
if err != nil {
t.Fatal(err)
}
if !exist {
t.Fatal("stored config missing")
}
if _, ok := stored.Extra["downloadCategories"]; ok {
t.Fatal("stored config still carries the legacy key")
}
if len(stored.Categories) != 1 || stored.Categories[0].NameKey != "categoryMusic" {
t.Fatalf("stored categories = %+v, want the migrated list", stored.Categories)
}
}
// readOnlyConfigStorage refuses every config write, so the setup can not
// persist a migrated category list.
type readOnlyConfigStorage struct {
Storage
}
func (s *readOnlyConfigStorage) Put(bucket string, key string, v any) error {
if bucket == bucketConfig {
return errors.New("config storage is read only")
}
return s.Storage.Put(bucket, key, v)
}
func TestDownloader_SetupFailsWhenTheMigrationCanNotBePersisted(t *testing.T) {
storage := NewMemStorage()
if err := storage.Setup([]string{bucketConfig}); err != nil {
t.Fatal(err)
}
legacy := &base.DownloaderStoreConfig{
DownloadDir: t.TempDir(),
Extra: map[string]any{
"downloadCategories": []any{
map[string]any{"name": "Music", "path": filepath.Join(t.TempDir(), "Music")},
},
},
}
if err := storage.Put(bucketConfig, "config", legacy); err != nil {
t.Fatal(err)
}
// The migrated list can not be persisted, so the setup reports the write
// error instead of continuing with a category list that is lost on restart.
downloader := NewDownloader(&DownloaderConfig{Storage: &readOnlyConfigStorage{Storage: storage}, StorageDir: t.TempDir()})
if err := downloader.Setup(); err == nil {
t.Fatal("setup reported no error, want the failed config write")
}
}
// categoryMatchingCases is the shared extension rule table of the backend and
// the ui. The ui runs the same file, see
// ui/flutter/test/api/downloader_config_test.dart, so a rule that only changes
// on one side breaks the other side's test.
type categoryMatchingCases struct {
NormalizeExtensions []struct {
Input []string `json:"input"`
Expected []string `json:"expected"`
} `json:"normalizeExtensions"`
}
func TestSharedCategoryMatchingRules(t *testing.T) {
data, err := os.ReadFile(filepath.Join("testdata", "category_matching_cases.json"))
if err != nil {
t.Fatal(err)
}
var cases categoryMatchingCases
if err := json.Unmarshal(data, &cases); err != nil {
t.Fatal(err)
}
for _, c := range cases.NormalizeExtensions {
got := normalizeCategoryExtensions(c.Input)
if !slices.Equal(got, c.Expected) {
t.Errorf("normalizeCategoryExtensions(%q) = %q, want %q", c.Input, got, c.Expected)
}
}
}
+277 -88
View File
@@ -2,6 +2,7 @@ package download
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
@@ -42,6 +43,7 @@ const (
bucketTaskStats = "task_stats"
// protocol-level shared client state bucket
bucketProtocolState = "protocol_state"
bucketProtocolJobs = "protocol_jobs"
// downloader config bucket
bucketConfig = "config"
// downloader extension bucket
@@ -99,6 +101,9 @@ type Progress struct {
}
type Downloader struct {
backgroundCtx context.Context
cancelBackground context.CancelFunc
protocolJobs *protocolScheduler
tempFiles *tempfiles.Scope
webviewProfilesLock sync.Mutex
webviewProfiles map[string]*extensionWebViewProfile
@@ -125,8 +130,10 @@ type Downloader struct {
// Key: fullBaseName (e.g., "/path/archive.7z"), Value: taskID that claimed it
claimedExtractions sync.Map
extensions []*Extension
blob *internalblob.Registry
extensions []*Extension
extensionGitLock sync.Mutex
extensionGitWG sync.WaitGroup
blob *internalblob.Registry
}
func NewDownloader(cfg *DownloaderConfig) *Downloader {
@@ -135,11 +142,14 @@ func NewDownloader(cfg *DownloaderConfig) *Downloader {
}
cfg.Init()
backgroundCtx, cancelBackground := context.WithCancel(context.Background())
d := &Downloader{
cfg: cfg,
fetcherCache: make(map[string]fetcher.Fetcher),
waitTasks: make([]*Task, 0),
storage: cfg.Storage,
backgroundCtx: backgroundCtx,
cancelBackground: cancelBackground,
cfg: cfg,
fetcherCache: make(map[string]fetcher.Fetcher),
waitTasks: make([]*Task, 0),
storage: cfg.Storage,
lock: &sync.Mutex{},
configLock: &sync.RWMutex{},
@@ -165,7 +175,7 @@ func (d *Downloader) Setup() error {
d.blob = internalblob.NewRegistry("")
// setup storage
if err := d.storage.Setup([]string{bucketTask, bucketSave, bucketTaskStats, bucketProtocolState, bucketConfig, bucketExtension, bucketExtensionStorage}); err != nil {
if err := d.storage.Setup([]string{bucketTask, bucketSave, bucketTaskStats, bucketProtocolState, bucketProtocolJobs, bucketConfig, bucketExtension, bucketExtensionStorage}); err != nil {
return err
}
// load config from storage
@@ -181,14 +191,22 @@ func (d *Downloader) Setup() error {
FirstLoad: true,
}
}
// init default config
d.cfg.DownloaderStoreConfig.Init()
// init protocol config, if not exist, use default config
// Move the legacy extra.downloadCategories list written by v2.0.0-beta
// builds into the top level categories field and persist the cleared key,
// before Init seeds the built-in categories. The config is written
// directly, PutConfig would validate a config that is not initialized yet.
if d.cfg.DownloaderStoreConfig.MigrateLegacyExtraCategories() {
if err := d.storage.Put(bucketConfig, "config", d.cfg.DownloaderStoreConfig); err != nil {
return err
}
}
// Initialize the current backend configuration schema and defaults.
if err := d.initProtocolConfigs(); err != nil {
return err
}
// Attach optional protocol state stores after configuration initialization.
for _, fm := range d.cfg.FetchManagers {
protocol := fm.Name()
if _, ok := d.cfg.DownloaderStoreConfig.ProtocolConfig[protocol]; !ok {
d.cfg.DownloaderStoreConfig.ProtocolConfig[protocol] = fm.DefaultConfig()
}
if sfm, ok := fm.(fetcher.StatefulFetcherManager); ok {
sfm.SetStateStore(&protocolStateStore{
storage: d.storage,
@@ -197,6 +215,10 @@ func (d *Downloader) Setup() error {
}
}
if err := d.setupProtocolJobs(); err != nil {
return err
}
// load tasks from storage
var tasks []*Task
if err = d.storage.List(bucketTask, &tasks); err != nil {
@@ -244,16 +266,25 @@ func (d *Downloader) Setup() error {
tasks := append([]*Task(nil), d.tasks...)
d.lock.Unlock()
for _, task := range tasks {
if task.Status == base.DownloadStatusDone && task.Uploading {
func() {
task.lock.Lock()
defer task.lock.Unlock()
if d.closed.Load() {
return
}
if d.taskStatus(task) != base.DownloadStatusDone || !task.Uploading {
return
}
if err := d.restoreTask(task); err != nil {
d.Logger.Error().Stack().Err(err).Msgf("task upload restore fetcher failed, task id: %s", task.ID)
d.Logger.Error().Err(err).Msgf("task upload restore fetcher failed, task id: %s", task.ID)
return
}
if uploader, ok := task.fetcher.(fetcher.Uploader); ok {
if err := uploader.Upload(); err != nil {
d.Logger.Error().Stack().Err(err).Msgf("task upload failed, task id: %s", task.ID)
d.Logger.Error().Err(err).Msgf("task upload failed, task id: %s", task.ID)
}
}
}
}()
}
}()
@@ -356,6 +387,7 @@ func (d *Downloader) Setup() error {
time.Sleep(time.Millisecond * time.Duration(d.cfg.RefreshInterval))
}
}()
d.protocolJobs.start()
return nil
}
@@ -467,7 +499,10 @@ func (d *Downloader) saveTask(task *Task) error {
return err
}
}
if err := d.storage.Put(bucketTask, task.ID, task); err != nil {
task.statusLock.Lock()
snapshot := task.clone()
task.statusLock.Unlock()
if err := d.storage.Put(bucketTask, task.ID, snapshot); err != nil {
d.Logger.Error().Stack().Err(err).Msgf("persist task failed: %s", task.ID)
return err
}
@@ -498,7 +533,7 @@ func ensureResourceRequestRawURLs(parentReq *base.Request, res *base.Resource) {
}
}
func (d *Downloader) Resolve(req *base.Request, opts *base.Options) (rr *ResolveResult, err error) {
func (d *Downloader) Resolve(req *base.Request) (rr *ResolveResult, err error) {
rrId, err := gonanoid.New()
if err != nil {
return
@@ -511,7 +546,8 @@ func (d *Downloader) Resolve(req *base.Request, opts *base.Options) (rr *Resolve
}
if res != nil && len(res.Files) > 0 {
rr = &ResolveResult{
Res: res,
Res: res,
SuggestedDownloadDir: d.suggestedDownloadDir(res),
}
return
}
@@ -520,11 +556,7 @@ func (d *Downloader) Resolve(req *base.Request, opts *base.Options) (rr *Resolve
if err != nil {
return
}
initOpt, err := d.initOptions(opts)
if err != nil {
return
}
err = fetcher.Resolve(req, initOpt)
err = fetcher.Resolve(req)
if err != nil {
return
}
@@ -532,8 +564,9 @@ func (d *Downloader) Resolve(req *base.Request, opts *base.Options) (rr *Resolve
d.fetcherCache[rrId] = fetcher
d.fetcherMapLock.Unlock()
rr = &ResolveResult{
ID: rrId,
Res: fetcher.Meta().Res,
ID: rrId,
Res: fetcher.Meta().Res,
SuggestedDownloadDir: d.suggestedDownloadDir(fetcher.Meta().Res),
}
return
}
@@ -558,7 +591,7 @@ func (d *Downloader) notifyRunning() {
func (d *Downloader) remainRunningCount() int {
runningCount := 0
for _, t := range d.tasks {
if t.Status == base.DownloadStatusRunning {
if d.taskStatus(t) == base.DownloadStatusRunning {
runningCount++
}
}
@@ -585,7 +618,7 @@ func (d *Downloader) createDirect(req *base.Request, opts *base.Options) (taskId
return
}
fetcher.Meta().Req = req
initOpt, err := d.initOptions(opts)
initOpt, err := d.initOptions(opts, req.URL)
if err != nil {
return
}
@@ -621,7 +654,7 @@ func (d *Downloader) Create(rrId string) (taskId string, err error) {
}
// CreateWithOptions creates a resolved task using the submitted options, or the
// resolve-time options when opts is nil.
// default download options when opts is nil.
func (d *Downloader) CreateWithOptions(rrId string, opts *base.Options) (taskId string, err error) {
d.fetcherMapLock.RLock()
fetcher, ok := d.fetcherCache[rrId]
@@ -629,33 +662,43 @@ func (d *Downloader) CreateWithOptions(rrId string, opts *base.Options) (taskId
if !ok {
return "", errors.New("invalid resource id")
}
if opts != nil {
opts, err = d.initOptions(opts.Clone())
if err != nil {
return "", err
}
if res := fetcher.Meta().Res; res != nil {
for _, index := range opts.SelectFiles {
if index < 0 || index >= len(res.Files) {
return "", fmt.Errorf("invalid selected file index: %d", index)
}
}
opts.InitSelectFiles(len(res.Files))
}
fetcher.Meta().Opts = opts
if opts == nil {
opts = &base.Options{}
}
// The options of the caller decide the global default download directory,
// the task options are prepared later, when the task starts.
defaultPath, err := d.prepareDefaultPath(opts)
if err != nil {
return "", err
}
taskOpts, err := d.initOptions(opts.Clone(), fetcher.Meta().Req.URL)
if err != nil {
return "", err
}
if res := fetcher.Meta().Res; res != nil {
for _, index := range taskOpts.SelectFiles {
if index < 0 || index >= len(res.Files) {
return "", fmt.Errorf("invalid selected file index: %d", index)
}
}
taskOpts.InitSelectFiles(len(res.Files))
}
fetcher.Meta().Opts = taskOpts
if res := fetcher.Meta().Res; res != nil {
res.CalcSize(fetcher.Meta().Opts.SelectFiles)
}
// Resolve has no creation options. Publish a submitted HTTP estimate now
// while preserving any size already confirmed by the response.
if taskOpts.Size > 0 {
if err := fetcher.Patch(nil, &base.Options{Size: taskOpts.Size}); err != nil {
return "", err
}
}
defer func() {
d.fetcherMapLock.Lock()
delete(d.fetcherCache, rrId)
d.fetcherMapLock.Unlock()
}()
defaultPath, err := d.prepareDefaultPath(fetcher.Meta().Opts)
if err != nil {
return "", err
}
taskId, err = d.doCreate(fetcher, nil)
if err == nil {
d.persistDefaultPathBestEffort(defaultPath)
@@ -689,7 +732,7 @@ func (d *Downloader) Patch(id string, req *base.Request, opts *base.Options) err
}
// Call the fetcher's Patch method
if err := task.fetcher.Patch(req, opts); err != nil {
if err := task.fetcher.Patch(req, httpSizeOptions(opts, task.Protocol)); err != nil {
return err
}
@@ -1100,6 +1143,9 @@ func (d *Downloader) RuntimeStatus(id string) (*TaskRuntimeStatus, error) {
result.ExtractProgress = task.Progress.ExtractProgress
}
if task.Meta == nil || task.Meta.Res == nil {
if task.Meta != nil && task.Meta.Opts != nil && task.Protocol == "http" {
result.Total = max(0, task.Meta.Opts.Size)
}
return result, nil
}
@@ -1172,7 +1218,14 @@ func (d *Downloader) doDelete(task *Task, force bool) (err error) {
}
func (d *Downloader) Close() error {
d.closed.Store(true)
if d.closed.Swap(true) {
return nil
}
d.cancelBackground()
d.waitForExtensionGit()
if d.protocolJobs != nil {
d.protocolJobs.close()
}
d.webviewProxyLock.Lock()
if d.webviewProxy != nil {
_ = d.webviewProxy.Close()
@@ -1327,7 +1380,16 @@ func (d *Downloader) GetTasksByFilter(filter *TaskFilter) []*Task {
func (d *Downloader) GetConfig() (*base.DownloaderStoreConfig, error) {
d.configLock.RLock()
defer d.configLock.RUnlock()
return d.cfg.DownloaderStoreConfig, nil
return util.DeepClone(d.cfg.DownloaderStoreConfig), nil
}
// IsFirstLoad reports whether Setup created the config instead of loading it
// from storage. FirstLoad is not serialized, so the clone GetConfig returns
// always reports false.
func (d *Downloader) IsFirstLoad() bool {
d.configLock.RLock()
defer d.configLock.RUnlock()
return d.cfg.FirstLoad
}
func (d *Downloader) PutConfig(v *base.DownloaderStoreConfig) error {
@@ -1335,17 +1397,68 @@ func (d *Downloader) PutConfig(v *base.DownloaderStoreConfig) error {
return errors.New("config is nil")
}
next := util.DeepClone(v)
changedProtocols := []string{}
d.configLock.Lock()
defer d.configLock.Unlock()
if d.closed.Load() {
d.configLock.Unlock()
return errors.New("downloader is closed")
}
if next.GithubMirror == nil {
next.GithubMirror = util.DeepClone(d.cfg.GithubMirror)
}
if err := next.GithubMirror.Validate(); err != nil {
d.configLock.Unlock()
return err
}
if next.ProtocolConfig == nil {
next.ProtocolConfig = map[string]any{}
}
for name := range next.ProtocolConfig {
if _, err := d.protocolManager(name); err != nil {
d.configLock.Unlock()
return err
}
}
for _, fm := range d.cfg.FetchManagers {
name := fm.Name()
value, exists := next.ProtocolConfig[name]
if !exists {
value = d.cfg.ProtocolConfig[name]
}
value, err := preserveProtocolState(value, d.cfg.ProtocolConfig[name])
if err == nil {
value, err = normalizeProtocol(fm, value)
}
if err != nil {
d.configLock.Unlock()
return err
}
next.ProtocolConfig[name] = value
oldJSON, _ := json.Marshal(d.cfg.ProtocolConfig[name])
newJSON, _ := json.Marshal(value)
if !bytes.Equal(oldJSON, newJSON) {
changedProtocols = append(changedProtocols, name)
}
}
if err := d.storage.Put(bucketConfig, "config", next); err != nil {
d.configLock.Unlock()
return err
}
d.cfg.DownloaderStoreConfig = next
d.configLock.Unlock()
if d.protocolJobs != nil {
for _, name := range changedProtocols {
d.protocolJobs.changed(name)
}
}
return nil
}
func (d *Downloader) prepareDefaultPath(opts *base.Options) (string, error) {
if opts == nil || !opts.AsDefaultPath || strings.TrimSpace(opts.Path) == "" {
// Only a directory the user named explicitly becomes the global default
// download directory. A task that keeps the auto categorize decision owns
// its directory, the backend must not turn it into the global default.
if opts == nil || !opts.SkipAutoCategorize || !opts.AsDefaultPath || strings.TrimSpace(opts.Path) == "" {
return "", nil
}
selectedPath := strings.TrimSpace(opts.Path)
@@ -1469,7 +1582,10 @@ func (d *Downloader) watch(task *Task) {
if err := d.captureTaskStats(task, false, true); err != nil {
d.Logger.Error().Stack().Err(err).Msgf("persist completed task stats failed, task id: %s", task.ID)
}
d.storage.Put(bucketTask, task.ID, task.clone())
task.statusLock.Lock()
snapshot := task.clone()
task.statusLock.Unlock()
d.storage.Put(bucketTask, task.ID, snapshot)
d.emit(EventKeyDone, task)
d.emit(EventKeyFinally, task, err)
d.notifyRunning()
@@ -1689,6 +1805,9 @@ func (d *Downloader) doCreate(f fetcher.Fetcher, opts *base.Options) (taskId str
task.Meta = f.Meta()
task.Progress = &Progress{}
_, task.Uploading = f.(fetcher.Uploader)
// A new task still needs its first preparation, it decides the download
// directory and the final name of the task.
task.NeedsPreparation = true
initTask(task)
if err = d.syncBlobTaskLease(task); err != nil {
return "", err
@@ -1722,25 +1841,41 @@ func (d *Downloader) doCreate(f fetcher.Fetcher, opts *base.Options) (taskId str
return
}
func (d *Downloader) initOptions(opts *base.Options) (*base.Options, error) {
// httpSizeOptions keeps estimates out of other protocols, including HLS URLs
// served over HTTP. Clone before filtering because batch tasks may share options.
func httpSizeOptions(opts *base.Options, protocol string) *base.Options {
if opts != nil && opts.Size != 0 && (protocol != "http" || opts.Size < 0) {
opts = opts.Clone()
opts.Size = 0
}
return opts
}
// initOptions makes sure the option set is usable: a nil option set becomes
// an empty one, the selection is never nil and the base download directory
// falls back to the configured one. The directory is decided and checked
// against the white list when the task starts.
func (d *Downloader) initOptions(opts *base.Options, requestURL string) (*base.Options, error) {
if opts == nil {
opts = &base.Options{}
}
if opts.Size != 0 {
fm, err := d.parseFm(requestURL)
if err != nil {
return nil, err
}
opts = httpSizeOptions(opts, fm.Name())
}
if opts.SelectFiles == nil {
opts.SelectFiles = make([]int, 0)
}
if opts.Path == "" {
storeConfig, err := d.GetConfig()
cfg, err := d.GetConfig()
if err != nil {
return nil, err
}
opts.Path = storeConfig.DownloadDir
opts.Path = cfg.DownloadDir
}
path, err := d.initDownloadPath(opts.Path)
if err != nil {
return nil, err
}
opts.Path = path
return opts, nil
}
@@ -1771,8 +1906,68 @@ func (d *Downloader) statusMut(task *Task, fn func() (bool, error)) (bool, error
return fn()
}
// prepareTask runs the first preparation of a task: it decides the download
// directory, resolves a duplicate name in it and persists both. The task keeps
// its NeedsPreparation flag when the preparation fails, so a retry prepares it
// again. The caller holds checkDuplicateLock when the fetcher manager renames
// duplicates, the lock has to stay held until the fetcher created the file.
func (d *Downloader) prepareTask(task *Task) error {
if task.Meta.Res == nil {
return errors.New("task resource is not resolved")
}
preparedMeta := *task.Meta
preparedOpts := *task.Meta.Opts
preparedMeta.Opts = &preparedOpts
preparedMeta.Res = util.DeepClone(task.Meta.Res)
dir, err := d.decideDownloadDir(preparedMeta.Opts, preparedMeta.Res)
if err != nil {
return err
}
preparedMeta.Opts.Path = dir
if task.fetcherManager.AutoRename() {
preparedMeta.Opts.Name = util.SafeFilename(preparedMeta.Opts.Name)
// check if the download file is duplicated and rename it automatically.
if preparedMeta.Res.Name != "" {
preparedMeta.Res.Name = util.SafeFilename(preparedMeta.Res.Name)
fullDirPath := preparedMeta.FolderPath()
newName, err := util.CheckDuplicateAndRename(fullDirPath)
if err != nil {
return err
}
preparedMeta.Opts.Name = newName
} else {
preparedMeta.Res.Files[0].Name = util.SafeFilename(preparedMeta.Res.Files[0].Name)
fullFilePath := preparedMeta.SingleFilepath()
newName, err := util.CheckDuplicateAndRename(fullFilePath)
if err != nil {
return err
}
preparedMeta.Opts.Name = newName
}
}
preparedMeta.Res.CalcSize(preparedMeta.Opts.SelectFiles)
// Save a candidate task before publishing its metadata. The fetcher keeps
// its resolved protocol state, so a failed save cannot change the live
// directory, name or preparation flag.
task.statusLock.Lock()
preparedTask := *task
task.statusLock.Unlock()
preparedTask.Meta = &preparedMeta
preparedTask.NeedsPreparation = false
if err := d.saveTask(&preparedTask); err != nil {
return err
}
task.statusLock.Lock()
*task.Meta = preparedMeta
task.NeedsPreparation = false
task.statusLock.Unlock()
return nil
}
func (d *Downloader) doStart(task *Task) (err error) {
var isCreate bool
var generation uint64
isReturn, err := d.statusMut(task, func() (isReturn bool, err error) {
if task.Status == base.DownloadStatusRunning || task.Status == base.DownloadStatusDone {
@@ -1785,7 +1980,6 @@ func (d *Downloader) doStart(task *Task) (err error) {
d.Logger.Error().Stack().Err(err).Msgf("restore fetcher failed, task id: %s", task.ID)
return
}
isCreate = task.Status == base.DownloadStatusReady
task.updateStatus(base.DownloadStatusRunning)
task.runGeneration++
generation = task.runGeneration
@@ -1825,40 +2019,34 @@ func (d *Downloader) doStart(task *Task) (err error) {
if err := d.syncBlobTaskLease(task); err != nil {
return err
}
if task.Meta.Res == nil {
err := task.fetcher.Resolve(task.Meta.Req, task.Meta.Opts)
resolvedHere := task.Meta.Res == nil
if resolvedHere {
err := task.fetcher.Resolve(task.Meta.Req)
if err != nil {
return err
}
task.Meta.Res = task.fetcher.Meta().Res
}
if isCreate {
if task.NeedsPreparation {
if task.fetcherManager.AutoRename() {
// The duplicate name is resolved from the files on disk, but
// the file only appears when the fetcher starts, so the lock
// has to cover the start as well.
d.checkDuplicateLock.Lock()
defer d.checkDuplicateLock.Unlock()
task.Meta.Opts.Name = util.SafeFilename(task.Meta.Opts.Name)
// check if the download file is duplicated and rename it automatically.
if task.Meta.Res.Name != "" {
task.Meta.Res.Name = util.SafeFilename(task.Meta.Res.Name)
fullDirPath := task.Meta.FolderPath()
newName, err := util.CheckDuplicateAndRename(fullDirPath)
if err != nil {
return err
}
task.Meta.Opts.Name = newName
} else {
task.Meta.Res.Files[0].Name = util.SafeFilename(task.Meta.Res.Files[0].Name)
fullFilePath := task.Meta.SingleFilepath()
newName, err := util.CheckDuplicateAndRename(fullFilePath)
if err != nil {
return err
}
task.Meta.Opts.Name = newName
}
}
task.Meta.Res.CalcSize(task.Meta.Opts.SelectFiles)
// The first preparation decides the download directory and the
// final name of the task. It runs for every task that has not
// been prepared yet, no matter whether the task waited in the
// queue, was paused or failed before.
if err := d.prepareTask(task); err != nil {
return err
}
} else if _, err := d.initDownloadPath(task.Meta.Opts.Path); err != nil {
// A prepared task keeps its directory, but the directory still has
// to pass the white list on every start.
return err
}
task.statusLock.Lock()
@@ -1912,6 +2100,7 @@ func (d *Downloader) doPause(task *Task) (err error) {
}
if handled {
d.emit(EventKeyPause, task)
d.triggerWebhooks(WebhookEventDownloadPause, task, nil)
}
return nil
}
+115 -32
View File
@@ -45,6 +45,32 @@ type generationTestManager struct {
func TestExtensionTaskMethodsMatchEventCapabilities(t *testing.T) {
taskType := reflect.TypeOf((*Task)(nil))
for _, typ := range []reflect.Type{taskType, reflect.TypeOf((*ExtensionTask)(nil)), reflect.TypeOf((*OnErrorExtensionTask)(nil))} {
for _, name := range []string{"SetUrl", "SetExtra", "PatchExtra", "PatchHeaders", "SetMethod", "SetBody", "SetHeaders", "PutHeader", "DelHeader", "SetTrackers"} {
if _, ok := typ.MethodByName(name); ok {
t.Fatalf("%v unexpectedly exposes %s", typ, name)
}
}
}
requestType := reflect.TypeOf((*ExtensionTaskRequest)(nil))
for _, name := range []string{"SetUrl", "SetLabels", "PutLabel", "DelLabel", "SetMethod", "SetBody", "SetHeaders", "PutHeader", "DelHeader", "SetTrackers"} {
if _, ok := requestType.MethodByName(name); !ok {
t.Fatalf("request wrapper does not expose %s", name)
}
}
for _, typ := range []reflect.Type{reflect.TypeOf(ExtensionTask{}), reflect.TypeOf(OnErrorExtensionTask{})} {
if _, ok := typ.FieldByName("Req"); ok {
t.Fatalf("%v exposes the removed task.req entry", typ)
}
field, ok := typ.FieldByName("Meta")
if !ok || field.Type != reflect.TypeOf((*ExtensionTaskMeta)(nil)) {
t.Fatalf("%v has no metadata wrapper", typ)
}
}
field, ok := reflect.TypeOf(ExtensionTaskMeta{}).FieldByName("Req")
if !ok || field.Type != requestType {
t.Fatal("meta.req has no request wrapper")
}
if _, ok := taskType.MethodByName("Continue"); ok {
t.Fatal("regular task unexpectedly exposes Continue")
}
@@ -53,17 +79,11 @@ func TestExtensionTaskMethodsMatchEventCapabilities(t *testing.T) {
}
extensionTaskType := reflect.TypeOf((*ExtensionTask)(nil))
if _, ok := extensionTaskType.MethodByName("SetUrl"); !ok {
t.Fatal("extension task does not expose SetUrl")
}
if _, ok := extensionTaskType.MethodByName("Continue"); ok {
t.Fatal("non-error extension task unexpectedly exposes Continue")
}
errorTaskType := reflect.TypeOf((*OnErrorExtensionTask)(nil))
if _, ok := errorTaskType.MethodByName("SetUrl"); !ok {
t.Fatal("onError task does not expose SetUrl")
}
if _, ok := errorTaskType.MethodByName("Continue"); !ok {
t.Fatal("onError task does not expose Continue")
}
@@ -112,7 +132,7 @@ func (f *generationTestFetcher) Setup(*controller.Controller) {
f.done = make(chan error, 2)
}
}
func (f *generationTestFetcher) Resolve(req *base.Request, opts *base.Options) error {
func (f *generationTestFetcher) Resolve(req *base.Request) error {
if f.manager.resolveStarted != nil {
f.manager.resolveOnce.Do(func() { close(f.manager.resolveStarted) })
}
@@ -123,7 +143,7 @@ func (f *generationTestFetcher) Resolve(req *base.Request, opts *base.Options) e
return f.manager.resolveErr
}
f.meta.Req = req
f.meta.Opts = opts
f.meta.Res = &base.Resource{Files: []*base.FileInfo{{Name: "generation.bin", Size: 1}}}
return nil
}
@@ -225,7 +245,7 @@ func TestDownloader_Resolve(t *testing.T) {
req := &base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
}
rr, err := downloader.Resolve(req, nil)
rr, err := downloader.Resolve(req)
if err != nil {
t.Fatal(err)
}
@@ -752,7 +772,7 @@ func TestDownloader_Create(t *testing.T) {
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
}
opts := newTestDownloadOpt(t)
rr, err := downloader.Resolve(req, opts)
rr, err := downloader.Resolve(req)
if err != nil {
t.Fatal(err)
}
@@ -764,7 +784,7 @@ func TestDownloader_Create(t *testing.T) {
wg.Done()
}
})
_, err = downloader.Create(rr.ID)
_, err = downloader.CreateWithOptions(rr.ID, opts)
if err != nil {
t.Fatal(err)
}
@@ -821,6 +841,9 @@ func TestDownloader_CreateDirectUsesPathAsDefault(t *testing.T) {
selectedRoot := t.TempDir()
defaultPathOpts := newTestDownloadOptAt(selectedRoot)
defaultPathOpts.AsDefaultPath = true
// The global default download directory is only saved for a directory the
// user named explicitly, so the task skips the auto categorize decision.
defaultPathOpts.SkipAutoCategorize = true
createAndWait(defaultPathOpts)
cfg, err = downloader.GetConfig()
if err != nil {
@@ -858,14 +881,29 @@ func TestDownloader_CreateNotInWhite(t *testing.T) {
req := &base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
}
// With new fetcher design, white list check happens during Resolve (not Create)
// because Resolve now requires Options which includes the download path
_, err := downloader.Resolve(req, newTestDownloadOpt(t))
if err == nil {
t.Error("TestDownloader_CreateNotInWhite() expected error but got nil")
rr, err := downloader.Resolve(req)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(err.Error(), "white") {
t.Errorf("TestDownloader_CreateNotInWhite() got = %v, want error containing 'white'", err.Error())
// The directory is decided when the task starts, so a directory outside
// the allowlist fails the task instead of the create call.
taskId, err := downloader.CreateWithOptions(rr.ID, newTestDownloadOpt(t))
if err != nil {
t.Fatal(err)
}
task := downloader.GetTask(taskId)
if task == nil {
t.Fatal("task not found")
}
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if task.Status == base.DownloadStatusError {
break
}
time.Sleep(20 * time.Millisecond)
}
if task.Status != base.DownloadStatusError {
t.Fatalf("task status = %q, want the task to be rejected by the allowlist", task.Status)
}
}
@@ -1052,7 +1090,7 @@ func doTestDownloaderCreateWithProxy(t *testing.T, auth bool, buildReqProxy func
Pwd: pwd,
})
}
rr, err := downloader.Resolve(req, nil)
rr, err := downloader.Resolve(req)
if err != nil {
if errHandler == nil {
t.Fatal(err)
@@ -1122,6 +1160,33 @@ func TestDownloader_CreateRename(t *testing.T) {
}
}
func TestDownloader_IsFirstLoadSurvivesTheConfigClone(t *testing.T) {
d := NewDownloader(&DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir()})
if err := d.Setup(); err != nil {
t.Fatal(err)
}
defer d.Close()
if !d.IsFirstLoad() {
t.Fatal("a downloader that created its config reports IsFirstLoad() = false")
}
// GetConfig clones through json and FirstLoad is not serialized, so the cmd
// layer cannot read the flag from the returned config.
cfg, err := d.GetConfig()
if err != nil {
t.Fatal(err)
}
if cfg.FirstLoad {
t.Fatal("the cloned config must not report the first load")
}
if err := d.PutConfig(cfg); err != nil {
t.Fatal(err)
}
if d.IsFirstLoad() {
t.Fatal("a downloader that stored its config reports IsFirstLoad() = true")
}
}
func TestDownloader_StoreAndRestore(t *testing.T) {
listener := test.StartTestSlowFileServer(time.Millisecond * 2000)
defer listener.Close()
@@ -1138,7 +1203,7 @@ func TestDownloader_StoreAndRestore(t *testing.T) {
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
}
opts := newTestDownloadOpt(t)
rr, err := downloader.Resolve(req, opts)
rr, err := downloader.Resolve(req)
if err != nil {
t.Fatal(err)
}
@@ -1152,7 +1217,7 @@ func TestDownloader_StoreAndRestore(t *testing.T) {
})
}
})
id, err := downloader.Create(rr.ID)
id, err := downloader.CreateWithOptions(rr.ID, opts)
if err != nil {
t.Fatal(err)
}
@@ -1221,7 +1286,7 @@ func TestDownloader_Protocol_Config(t *testing.T) {
"trackerSubscribeUrls": []string{
"https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/best.txt",
},
"trackers": []string{
"customTrackers": []string{
"udp://tracker.coppersurfer.tk:6969/announce",
"udp://tracker.leechers-paradise.org:6969/announce",
},
@@ -1241,8 +1306,20 @@ func TestDownloader_Protocol_Config(t *testing.T) {
t.Fatal(err)
}
if !test.JsonEqual(storeCfg, newStoreCfg) {
t.Errorf("GetConfig() got = %v, want %v", test.ToJson(storeCfg), test.ToJson(newStoreCfg))
if newStoreCfg.DownloadDir != storeCfg.DownloadDir || newStoreCfg.Extra["theme"] != "dark" {
t.Fatalf("downloader config was not saved: %+v", newStoreCfg)
}
var savedHTTP struct {
Connections int `json:"connections"`
}
if err := util.MapToStruct(newStoreCfg.ProtocolConfig["http"], &savedHTTP); err != nil || savedHTTP.Connections != 4 {
t.Fatalf("HTTP config: %+v %v", savedHTTP, err)
}
var savedBT struct {
CustomTrackers []string `json:"customTrackers"`
}
if err := util.MapToStruct(newStoreCfg.ProtocolConfig["bt"], &savedBT); err != nil || len(savedBT.CustomTrackers) != 2 {
t.Fatalf("BT derived trackers: %+v %v", savedBT, err)
}
}
@@ -1414,7 +1491,7 @@ func TestDownloader_Stats(t *testing.T) {
req := &base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
}
rr, err := downloader.Resolve(req, newTestDownloadOpt(t))
rr, err := downloader.Resolve(req)
if err != nil {
t.Fatal(err)
}
@@ -1427,7 +1504,7 @@ func TestDownloader_Stats(t *testing.T) {
}
})
taskId, err := downloader.Create(rr.ID)
taskId, err := downloader.CreateWithOptions(rr.ID, newTestDownloadOpt(t))
if err != nil {
t.Fatal(err)
}
@@ -1657,7 +1734,7 @@ func TestDownloader_CompletedHTTPStatsSurviveRestore(t *testing.T) {
t.Fatal(err)
}
req := &base.Request{URL: "http://" + listener.Addr().String() + "/" + test.BuildName}
rr, err := downloader.Resolve(req, newTestDownloadOpt(t))
rr, err := downloader.Resolve(req)
if err != nil {
t.Fatal(err)
}
@@ -1668,7 +1745,7 @@ func TestDownloader_CompletedHTTPStatsSurviveRestore(t *testing.T) {
done <- struct{}{}
}
})
taskID, err := downloader.Create(rr.ID)
taskID, err := downloader.CreateWithOptions(rr.ID, newTestDownloadOpt(t))
if err != nil {
t.Fatal(err)
}
@@ -1841,11 +1918,11 @@ func TestDownloader_PauseAndContinue(t *testing.T) {
req := &base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
}
rr, err := downloader.Resolve(req, newTestDownloadOpt(t))
rr, err := downloader.Resolve(req)
if err != nil {
t.Fatal(err)
}
taskId, err := downloader.Create(rr.ID)
taskId, err := downloader.CreateWithOptions(rr.ID, newTestDownloadOpt(t))
if err != nil {
t.Fatal(err)
}
@@ -1982,6 +2059,9 @@ func TestDownloader_ContinueOnStartupUsesContinueAllSemantics(t *testing.T) {
t.Fatal(err)
}
cfg.Extra = map[string]any{"autoStartTasks": true}
if err := downloader.PutConfig(cfg); err != nil {
t.Fatal(err)
}
if err := downloader.ContinueOnStartup(); err != nil {
t.Fatal(err)
}
@@ -1990,6 +2070,9 @@ func TestDownloader_ContinueOnStartupUsesContinueAllSemantics(t *testing.T) {
}
cfg.AutoStartTasks = true
if err := downloader.PutConfig(cfg); err != nil {
t.Fatal(err)
}
if err := downloader.ContinueOnStartup(); err != nil {
t.Fatal(err)
}
@@ -2025,11 +2108,11 @@ func TestDownloader_PauseAllAndContinueAll(t *testing.T) {
req := &base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
}
rr, err := downloader.Resolve(req, newTestDownloadOpt(t))
rr, err := downloader.Resolve(req)
if err != nil {
t.Fatal(err)
}
taskId, err := downloader.Create(rr.ID)
taskId, err := downloader.CreateWithOptions(rr.ID, newTestDownloadOpt(t))
if err != nil {
t.Fatal(err)
}
@@ -0,0 +1,222 @@
package engine_test
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/GopeedLab/gopeed/pkg/download/engine"
)
// Exercise server-side header permissions, including formerly forbidden names,
// download authentication, metadata and non-safelisted no-cors values.
var extensionRequestHeaders = [][2]string{
{"Accept-Charset", "utf-8"}, {"Accept-Encoding", "identity"},
{"Access-Control-Request-Headers", "authorization, x-download"},
{"Access-Control-Request-Method", "POST"}, {"Connection", "close"},
{"Connection", "keep-alive"},
{"Content-Length", "4"}, {"Cookie", "sid=manual"}, {"Cookie2", "legacy=1"},
{"Date", "Fri, 09 Oct 2026 00:00:00 GMT"}, {"DNT", "1"},
{"Host", "download.example:1234"}, {"Origin", "https://download.example"},
{"Permissions-Policy", "camera=()"}, {"Referer", "https://download.example/file"},
{"TE", "trailers"}, {"Trailer", "X-Checksum"}, {"Set-Cookie", "sid=request"},
{"Set-Cookie2", "legacy=request"}, {"Via", "1.1 proxy"},
{"Proxy-Authorization", "Basic dGVzdA=="}, {"Proxy-", "custom"},
{"Sec-Fetch-Site", "same-origin"}, {"Sec-Fetch-Mode", "custom"},
{"Sec-CH-UA", `"Download client"`}, {"Sec-", "custom"},
{"X-HTTP-Method", "TRACE"}, {"X-HTTP-Method-Override", "CONNECT"},
{"X-Method-Override", "TRACK"}, {"Authorization", "Bearer token"},
{"User-Agent", "Gopeed extension"}, {"Content-Type", "application/json"},
{"Range", "bytes=0-1,3-4"}, {"X-Download-Token", "custom"},
{"Accept-Language", strings.Repeat("x", 129)},
}
func TestExtensionRequestsSendNodeHeaders(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
body, err := io.ReadAll(request.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
name := request.URL.Query().Get("header")
value := request.Header.Get(name)
if name == "Host" {
value = request.Host
}
_ = json.NewEncoder(w).Encode([]string{value, string(body)})
}))
t.Cleanup(server.Close)
clients := []struct{ name, script string }{
{"fetch record", `const response = await fetch(url, {method: "POST", body: "test", headers: {[name]: value}}); return (await response.text()).trim();`},
{"fetch no-cors Headers", `const response = await fetch(url, {method: "POST", body: "test", mode: "no-cors", headers: new Headers([[name.toLowerCase(), value]])}); return (await response.text()).trim();`},
{"fetch Request clone", `const request = new Request(url, {method: "POST", body: "test", mode: "same-origin", headers: [[name.toUpperCase(), value]]}); const response = await fetch(request.clone()); return (await response.text()).trim();`},
}
for _, credentials := range []bool{false, true} {
clients = append(clients, struct{ name, script string }{
fmt.Sprintf("XHR credentials=%t", credentials),
fmt.Sprintf(`return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", url);
xhr.withCredentials = %t;
xhr.setRequestHeader(name.toLowerCase(), value);
xhr.onload = () => resolve(xhr.responseText.trim());
xhr.onerror = () => reject(new Error("XHR failed"));
xhr.send("test");
});`, credentials),
})
}
for _, header := range extensionRequestHeaders {
for _, client := range clients {
t.Run(header[0]+"/"+client.name, func(t *testing.T) {
runtime := engine.NewEngine(nil)
t.Cleanup(runtime.Close)
value, err := runtime.RunString(fmt.Sprintf(`(async () => {
const name = %q, value = %q;
const url = %q + "?header=" + encodeURIComponent(name);
%s
})()`, header[0], header[1], server.URL, client.script))
if err != nil {
t.Fatal(err)
}
want, _ := json.Marshal([]string{header[1], "test"})
if value != string(want) {
t.Fatalf("server received %#v, want %s", value, want)
}
})
}
}
}
func TestExtensionHeaderMutationsMatchNode(t *testing.T) {
headers := append([][2]string(nil), extensionRequestHeaders...)
// Node stores these in Headers/Request, but rejects them during fetch.
headers = append(headers, [2]string{"Expect", "100-continue"}, [2]string{"Keep-Alive", "timeout=5"}, [2]string{"Transfer-Encoding", "chunked"}, [2]string{"Upgrade", "websocket"})
data, _ := json.Marshal(headers)
runtime := engine.NewEngine(nil)
t.Cleanup(runtime.Close)
_, err := runtime.RunString(fmt.Sprintf(`(() => {
for (const mode of ["cors", "same-origin", "no-cors"]) {
for (const [name, value] of %s) {
const request = new Request("http://example.com", {mode, headers: {[name]: value}});
const headers = request.headers;
if (headers.get(name) !== value) throw new Error("Filtered init " + name + " in " + mode);
headers.delete(name.toUpperCase());
if (headers.has(name)) throw new Error("Cannot delete " + name);
headers.append(name, value);
if (headers.get(name) !== value) throw new Error("Cannot append " + name);
headers.set(name, value);
if (request.clone().headers.get(name) !== value) throw new Error("Cannot clone " + name);
}
}
return true;
})()`, data))
if err != nil {
t.Fatal(err)
}
}
func TestExtensionRequestsIgnoreUnsupportedTransportHeaders(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
body, err := io.ReadAll(request.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
value := request.Header.Get(request.URL.Query().Get("header"))
_ = json.NewEncoder(w).Encode([]string{value, string(body)})
}))
t.Cleanup(server.Close)
for _, header := range [][2]string{
{"Expect", "100-continue"}, {"Keep-Alive", "timeout=5"},
{"Transfer-Encoding", "chunked"}, {"Upgrade", "websocket"},
{"Connection", "upgrade"}, {"Content-Length", "3"},
{"Content-Length", "5"}, {"Content-Length", "invalid"},
{"Content-Length", "-1"}, {"Content-Length", ""},
} {
for _, xhr := range []bool{false, true} {
t.Run(fmt.Sprintf("%s=%s/XHR=%t", header[0], header[1], xhr), func(t *testing.T) {
runtime := engine.NewEngine(nil)
t.Cleanup(runtime.Close)
value, err := runtime.RunString(fmt.Sprintf(`(async () => {
const name = %q, value = %q;
const url = %q + "?header=" + encodeURIComponent(name);
if (%t) return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", url);
xhr.setRequestHeader(name, value);
xhr.onload = () => resolve(xhr.responseText.trim());
xhr.onerror = () => reject(new Error("XHR failed"));
xhr.send("test");
});
const response = await fetch(url, {method: "POST", body: "test", headers: {[name]: value}});
return (await response.text()).trim();
})()`, header[0], header[1], server.URL, xhr))
if err != nil {
t.Fatal(err)
}
wireValue := ""
if header[0] == "Content-Length" {
wireValue = "4"
}
want, _ := json.Marshal([]string{wireValue, "test"})
if value != string(want) {
t.Fatalf("server received %#v, want %s", value, want)
}
})
}
}
}
func TestExtensionResponseExposesSetCookie(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Add("Set-Cookie", "sid=one; HttpOnly")
w.Header().Add("Set-Cookie", "token=two")
w.Header().Set("Set-Cookie2", "legacy=three")
_, _ = w.Write([]byte("ok"))
}))
t.Cleanup(server.Close)
runtime := engine.NewEngine(nil)
t.Cleanup(runtime.Close)
value, err := runtime.RunString(fmt.Sprintf(`(async () => {
const response = await fetch(%q);
const clone = response.clone();
await response.text(); await clone.text();
let immutable = false;
try { response.headers.set("Set-Cookie", "changed=1"); } catch (error) { immutable = error instanceof TypeError; }
const synthetic = new Response(null, {headers: {"Set-Cookie": "initial=1"}});
synthetic.headers.append("Set-Cookie", "appended=2");
return JSON.stringify({cookies: clone.headers.getSetCookie(), legacy: response.headers.get("set-cookie2"), immutable, synthetic: synthetic.headers.getSetCookie()});
})()`, server.URL))
if err != nil {
t.Fatal(err)
}
const want = `{"cookies":["sid=one; HttpOnly","token=two"],"legacy":"legacy=three","immutable":true,"synthetic":["initial=1","appended=2"]}`
if value != want {
t.Fatalf("unexpected response headers: got %#v want %s", value, want)
}
}
func TestExtensionHeadersRejectInvalidSyntax(t *testing.T) {
runtime := engine.NewEngine(nil)
t.Cleanup(runtime.Close)
_, err := runtime.RunString(`(() => {
for (const [name, value] of [["Bad Header", "ok"], ["", "ok"], ["X:Header", "ok"],
["X-Test", "a\nb"], ["X-Test", "a\rb"], ["X-Test", "\0"], ["X-Test", "\u0100"]]) {
for (const create of [() => new Headers([[name, value]]),
() => new Request("http://example.com", {mode: "no-cors", headers: {[name]: value}}),
() => { const xhr = new XMLHttpRequest(); xhr.open("GET", "http://example.com"); xhr.setRequestHeader(name, value); }]) {
let rejected = false;
try { create(); } catch (error) { rejected = error instanceof TypeError; }
if (!rejected) throw new Error("Accepted invalid HTTP header syntax");
}
}
return true;
})()`)
if err != nil {
t.Fatal(err)
}
}
@@ -105,6 +105,105 @@ func TestXMLHttpRequestUsesFetch(t *testing.T) {
}
}
func TestExtensionRequestsSendExplicitCookie(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
_, _ = w.Write([]byte(request.Header.Get("Cookie")))
}))
t.Cleanup(server.Close)
type requestTest struct {
name string
script string
}
tests := []requestTest{
{"fetch record", `const response = await fetch(url, {headers: {Cookie: cookie}}); return response.text();`},
{"fetch Headers", `const response = await fetch(url, {headers: new Headers({cookie})}); return response.text();`},
{"fetch pairs", `const response = await fetch(url, {headers: [["cOoKiE", cookie]]}); return response.text();`},
{"fetch repeated Cookie", `const headers = new Headers([["Cookie", "sid=manual"], ["cookie", "token=provided"]]); if (headers.get("cookie") !== cookie) throw new Error("Incorrect Cookie separator"); const response = await fetch(url, {headers}); return response.text();`},
{"fetch Request clone", `const request = new Request(url, {headers: {Cookie: cookie}}); const response = await fetch(request.clone()); return response.text();`},
{"fetch Request set", `const request = new Request(url); request.headers.set("Cookie", cookie); const response = await fetch(request); return response.text();`},
{"fetch Request append", `const request = new Request(url); request.headers.append("Cookie", cookie); const response = await fetch(request); return response.text();`},
{"fetch no-cors mutations", `const request = new Request(url, {mode: "no-cors"}); request.headers.set("Cookie", "removed=1"); request.headers.delete("COOKIE"); if (request.headers.has("cookie")) throw new Error("Cookie was not deleted"); request.headers.append("Cookie", cookie); const response = await fetch(request.clone()); return response.text();`},
}
for _, mode := range []string{"cors", "same-origin", "no-cors"} {
for _, credentials := range []string{"omit", "same-origin", "include"} {
tests = append(tests, requestTest{
name: "fetch " + mode + " " + credentials,
script: fmt.Sprintf(`const response = await fetch(url, {headers: {Cookie: cookie}, mode: %q, credentials: %q}); return response.text();`, mode, credentials),
})
}
}
for _, withCredentials := range []bool{false, true} {
tests = append(tests, requestTest{
name: fmt.Sprintf("XHR withCredentials=%t", withCredentials),
script: fmt.Sprintf(`return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.withCredentials = %t;
xhr.setRequestHeader("cOoKiE", cookie);
xhr.onload = () => resolve(xhr.responseText);
xhr.onerror = () => reject(new Error("XHR failed"));
xhr.send();
});`, withCredentials),
})
}
tests = append(tests, requestTest{"XHR repeated Cookie", `return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.setRequestHeader("Cookie", "sid=manual");
xhr.setRequestHeader("cookie", "token=provided");
xhr.onload = () => resolve(xhr.responseText);
xhr.onerror = () => reject(new Error("XHR failed"));
xhr.send();
});`})
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
runtime := engine.NewEngine(nil)
t.Cleanup(runtime.Close)
const cookie = "sid=manual; token=provided"
value, err := runtime.RunString(fmt.Sprintf(`(async () => {
const url = %q;
const cookie = %q;
%s
})()`, server.URL, cookie, test.script))
if err != nil {
t.Fatal(err)
}
if value != cookie {
t.Fatalf("server received Cookie %q, want %q", value, cookie)
}
})
}
}
func TestFetchExplicitCookieWithCookieJar(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
if request.URL.Path == "/seed" {
http.SetCookie(w, &http.Cookie{Name: "stored", Value: "jar", Path: "/"})
}
_, _ = w.Write([]byte(request.Header.Get("Cookie")))
}))
t.Cleanup(server.Close)
runtime := engine.NewEngine(nil)
t.Cleanup(runtime.Close)
value, err := runtime.RunString(fmt.Sprintf(`(async () => {
const url = %q;
await (await fetch(url + "/seed")).text();
const automatic = await (await fetch(url)).text();
const combined = await (await fetch(url, {headers: {Cookie: "manual=provided"}})).text();
const omitted = await (await fetch(url, {credentials: "omit", headers: {Cookie: "manual=provided"}})).text();
const empty = await (await fetch(url, {credentials: "omit"})).text();
return JSON.stringify({automatic, combined, omitted, empty});
})()`, server.URL))
if err != nil {
t.Fatal(err)
}
const want = `{"automatic":"stored=jar","combined":"manual=provided; stored=jar","omitted":"manual=provided","empty":""}`
if value != want {
t.Fatalf("unexpected CookieJar result: got %#v want %s", value, want)
}
}
func TestFetchResponseAndRequestCloneHaveIndependentBodies(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Method == http.MethodPost {
+17 -1
View File
@@ -30,6 +30,21 @@ support must remain disabled. Do not declare the sum of the input sizes as the
output size. The downloader reads the merged output as one file; FFmpeg errors
propagate through the stream instead of completing a partial download.
Extension resolve results can set `files[].size` to an estimated output size,
for example the sum of the selected video and audio input lengths. Clients forward
this value as the optional task creation `opts.size` for HTTP downloads only
(including Blob URLs served over HTTP). Other protocols, including HLS, ignore
this option. The estimate is persisted
and displayed while queued and downloading. A valid HTTP total length (including
zero) always overrides it; otherwise a clean EOF records the actual output length.
Estimates never determine byte ranges, file allocation, integrity checks, or task
completion. Size authority is persisted alongside HTTP connection state for
pause/retry and process restart.
Blob options are unchanged: `createObjectURL`'s `size` is an exact source length,
not an estimate. Leave it unset for generated merge output and pass any estimate
through the extension resolve file instead.
## JS inputs, including SABR
Stream inputs **must** be created by an `inputs({ signal })` callback. It returns
@@ -130,7 +145,8 @@ associates its input progress with that Blob. No public progress callback or
extra `createObjectURL` argument is required. Return the merge stream itself;
wrapping it in another stream loses this association. Downloaded bytes count
unique input offsets; speed counts received input bytes, including re-fetches.
The task stays downloading with unknown total size while queued or merging;
The task stays downloading while queued or merging, displaying the optional
`files[].size` estimate (or an unknown total when omitted);
once input traffic stops the displayed speed settles to zero. Completion uses
the actual merged output size. Generated output waits are governed by producer
cancellation/errors rather than the HTTP body idle timeout; HTTP media inputs
+15 -60
View File
@@ -122,7 +122,7 @@
if (name === "set-cookie") {
for (const value of values) result.push([name, value]);
} else {
result.push([name, values.join(", ")]);
result.push([name, values.join(name === "cookie" ? "; " : ", ")]);
}
}
return result;
@@ -158,49 +158,10 @@
return iterator;
}
function forbiddenRequestHeader(name, value) {
if (name.startsWith("proxy-") || name.startsWith("sec-")) return true;
if ([
"accept-charset", "accept-encoding", "access-control-request-headers",
"access-control-request-method", "connection", "content-length", "cookie",
"cookie2", "date", "dnt", "expect", "host", "keep-alive", "origin",
"permissions-policy", "referer", "te", "trailer", "transfer-encoding",
"set-cookie", "upgrade", "via",
].includes(name)) return true;
if (["x-http-method", "x-http-method-override", "x-method-override"].includes(name)) {
return String(value).split(",").some((method) => {
method = method.trim().toUpperCase();
return method === "CONNECT" || method === "TRACE" || method === "TRACK";
});
}
return false;
}
function noCORSSafelistedRequestHeader(name, value) {
if (value.length > 128) return false;
const hasUnsafeByte = /[\x00-\x08\x0A-\x1F\x7F"():<>?@\[\\\]{}]/.test(value);
if (name === "accept") return !hasUnsafeByte;
if (name === "accept-language" || name === "content-language") {
return /^[0-9A-Za-z *,\-.;=]*$/.test(value);
}
if (name === "content-type") {
if (hasUnsafeByte) return false;
const essence = value.split(";", 1)[0].trim().toLowerCase();
return essence === "application/x-www-form-urlencoded" ||
essence === "multipart/form-data" || essence === "text/plain";
}
if (name === "range") return /^bytes=[0-9]+-[0-9]*$/.test(value);
return false;
}
function blockedByGuard(headers, name, value) {
function assertMutableHeaders(headers) {
// Match Node/Undici: HTTP syntax and immutability still apply, but browser
// forbidden-header and no-cors safelist rules do not apply to extensions.
if (headers._guard === "immutable") throw new TypeError("Headers are immutable");
if (headers._guard === "request") return forbiddenRequestHeader(name, value);
if (headers._guard === "request-no-cors") {
return forbiddenRequestHeader(name, value) || !noCORSSafelistedRequestHeader(name, value);
}
if (headers._guard === "response") return name === "set-cookie" || name === "set-cookie2";
return false;
}
class Headers {
@@ -235,27 +196,21 @@
name = normalizeHeaderName(name);
value = normalizeHeaderValue(value);
const values = this._values.get(name);
const combinedValue = values && name !== "set-cookie" ? values.join(", ") + ", " + value : value;
if (blockedByGuard(this, name, combinedValue)) return;
assertMutableHeaders(this);
if (values) values.push(value);
else this._values.set(name, [value]);
}
delete(name) {
name = normalizeHeaderName(name);
if (this._guard === "immutable") throw new TypeError("Headers are immutable");
if (this._guard === "request-no-cors") {
if (forbiddenRequestHeader(name, "") || !["accept", "accept-language", "content-language", "content-type", "range"].includes(name)) return;
this._values.delete(name);
return;
}
if (blockedByGuard(this, name, "")) return;
name = normalizeHeaderName(name);
assertMutableHeaders(this);
this._values.delete(name);
}
get(name) {
const values = this._values.get(normalizeHeaderName(name));
return values ? values.join(", ") : null;
name = normalizeHeaderName(name);
const values = this._values.get(name);
return values ? values.join(name === "cookie" ? "; " : ", ") : null;
}
getSetCookie() {
@@ -268,10 +223,10 @@
}
set(name, value) {
name = normalizeHeaderName(name);
value = normalizeHeaderValue(value);
if (blockedByGuard(this, name, value)) return;
this._values.set(name, [value]);
name = normalizeHeaderName(name);
value = normalizeHeaderValue(value);
assertMutableHeaders(this);
this._values.set(name, [value]);
}
keys() { return createHeadersIterator(this, "key"); }
@@ -484,7 +439,7 @@
}
this._duplex = "half";
const headerInit = init.headers !== undefined ? init.headers : (source ? source.headers : undefined);
this._headers = new Headers(headerInit, this._mode === "no-cors" ? "request-no-cors" : "request");
this._headers = new Headers(headerInit, "request");
let body = init.body !== undefined ? init.body : (source ? source._bodyInit : null);
if (source && !bodyWasOverridden && source.body) body = transferReadableStream(source.body);
const bodyIsStream = typeof ReadableStream !== "undefined" && body instanceof ReadableStream;
@@ -302,6 +302,23 @@ func (r *registry) Open(fingerprint string, proxyHandler func(*http.Request) (*u
return nil, err
}
for _, header := range metadata.Headers {
// Ignore transport controls unsupported by Node/Undici rather than
// applying a browser forbidden-header list or failing the whole request.
switch strings.ToLower(header[0]) {
case "expect", "keep-alive", "transfer-encoding", "upgrade", "content-length":
// Content-Length is generated from the buffered request body below.
continue
case "connection":
if value := strings.ToLower(header[1]); value != "close" && value != "keep-alive" {
continue
}
case "trailer":
// Fetch supplies a declaration, not a Go Request.Trailer payload.
// Keep its lower-case wire name so Go's writer does not omit it as
// the canonical Trailer field it normally generates itself.
httpRequest.Header["trailer"] = append(httpRequest.Header["trailer"], header[1])
continue
}
httpRequest.Header.Add(header[0], header[1])
}
if host := httpRequest.Header.Get("Host"); host != "" {
+26 -2
View File
@@ -9,7 +9,7 @@ The files under `fetch/` are copied from the official
Gopeed executes applicable worker-style Fetch API tests at the extension
JavaScript boundary. The checked-in runner currently executes unchanged WPT
tests for Headers, no-CORS header guards, Request construction and body
tests for Headers syntax and iteration, Request construction and body
consumption, ReadableStream bodies, keepalive, Request errors, Response
construction and body consumption, immutable network response headers, and
the Response static constructors.
@@ -19,10 +19,34 @@ a claim that Gopeed is a browser. Browser-only suites that require a document,
CORS origin enforcement or preflight, CSP, Mixed Content, Service Workers,
browser HTTP cache, navigation, browser authentication UI, or WPT's
multi-origin server infrastructure are intentionally excluded. Gopeed also
keeps one extension-specific behavior: `redirect: "manual"` exposes the HTTP
keeps extension-specific behavior: `redirect: "manual"` exposes the HTTP
redirect response so download extensions can inspect `Location`, instead of
returning a browser `opaqueredirect` response.
Header permissions follow [Node/Undici's server-side Fetch API](https://github.com/nodejs/undici/blob/main/README.md#forbidden-and-safelisted-header-names). Gopeed does not
apply browser forbidden request/response header lists or no-CORS safelists.
Extensions may supply authentication, referrer, origin, host, proxy and fetch
metadata headers, and read `Set-Cookie` response headers. Network response
headers remain immutable; invalid header syntax still raises an error.
Repeated `Cookie` values are combined with `; `, as in Node/Undici.
The runner excludes the browser-only `headers-no-cors.any.js` and
`headers-forbidden-override.any.js` suites, the filtering assertions within
`request-headers.any.js`, and the forbidden-response-cookie assertion within
`header-setcookie.any.js`. Gopeed's `extension_headers_test.go` replaces those
restrictions with integration tests that check headers actually received by
an HTTP server through fetch and XMLHttpRequest, plus header mutations,
cloning and response cookies. The upstream test files remain unchanged.
Gopeed deliberately ignores transport controls that Node/Undici rejects:
`Expect`, `Keep-Alive`, `Transfer-Encoding`, `Upgrade`, and `Connection` values
other than `close` or `keep-alive`. `Content-Length` is generated from the
actual request body, ignoring a supplied length. Explicit `Host` and
`Sec-Fetch-Mode` values are preserved rather than replaced by defaults.
These transport behaviors are also covered by integration tests.
`credentials: "omit"` disables automatic CookieJar cookies but preserves
explicit headers; Gopeed retains its extension CookieJar support.
`wpt_harness.js` is a small Goja adapter maintained by Gopeed; it is not copied
from WPT. Test logic below `fetch/` is unchanged; the checked-in copy may only
normalize a final newline.
+25 -17
View File
@@ -20,9 +20,6 @@ var wptFetchUtils string
//go:embed testdata/wpt/fetch/api/request/request-error.js
var wptRequestError string
//go:embed testdata/wpt/fetch/api/cors/resources/not-cors-safelisted.json
var wptNoCORSSafelistedHeaders []byte
//go:embed testdata/wpt/fetch/api/headers/*.any.js
var wptHeaders embed.FS
@@ -30,31 +27,19 @@ var wptHeaders embed.FS
var wptRequestResponse embed.FS
func TestWPTHeaders(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
response.Header().Set("Content-Type", "application/json")
_, _ = response.Write(wptNoCORSSafelistedHeaders)
}))
t.Cleanup(server.Close)
tests := []string{
"header-setcookie.any.js",
"headers-basic.any.js",
"headers-casing.any.js",
"headers-combine.any.js",
"headers-errors.any.js",
"headers-forbidden-override.any.js",
"headers-normalize.any.js",
"headers-no-cors.any.js",
"headers-record.any.js",
"headers-structure.any.js",
}
for _, name := range tests {
t.Run(name, func(t *testing.T) {
setup := ""
if name == "headers-no-cors.any.js" {
setup = fmt.Sprintf("globalThis.location = new URL(%q);", server.URL+"/fetch/api/headers/test.any.js")
}
runWPTFile(t, wptHeaders, path.Join("testdata/wpt/fetch/api/headers", name), setup)
runWPTFile(t, wptHeaders, path.Join("testdata/wpt/fetch/api/headers", name), "")
})
}
}
@@ -110,7 +95,30 @@ func runWPTFile(t *testing.T, files embed.FS, name, setup string) {
}
runtime := engine.NewEngine(nil)
t.Cleanup(runtime.Close)
value, err := runtime.RunString(setup + "\n" + wptHarness + "\n" + wptFetchUtils + "\n" + wptRequestError + "\n" + string(source) + "\n__wptFinish();")
adaptation := ""
if name == "testdata/wpt/fetch/api/request/request-headers.any.js" {
// Node-style header permissions are covered by extension integration tests.
// Keep upstream tests unchanged and exclude browser-only restrictions.
adaptation = `
const upstreamTest = globalThis.test;
globalThis.test = function (callback, name) {
if (name.startsWith('Adding invalid request header ') ||
name.startsWith('Adding invalid no-cors request header ') ||
name.startsWith('Check that request constructor is filtering headers ') ||
name.startsWith('Check that no-cors request constructor is filtering headers ')) return;
upstreamTest(callback, name);
};
`
} else if name == "testdata/wpt/fetch/api/headers/header-setcookie.any.js" {
adaptation = `
const upstreamTest = globalThis.test;
globalThis.test = function (callback, name) {
if (name === 'Set-Cookie is a forbidden response header') return;
upstreamTest(callback, name);
};
`
}
value, err := runtime.RunString(setup + "\n" + wptHarness + "\n" + wptFetchUtils + "\n" + wptRequestError + "\n" + adaptation + "\n" + string(source) + "\n__wptFinish();")
if err != nil {
t.Fatal(err)
}
+94 -42
View File
@@ -1,16 +1,19 @@
package download
import (
"context"
"encoding/json"
"fmt"
"io"
"net/url"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/internal/githubmirror"
"github.com/GopeedLab/gopeed/internal/logger"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download/engine"
@@ -195,50 +198,85 @@ func (d *Downloader) getExtension(identity string) *Extension {
return nil
}
func (d *Downloader) fetchExtensionByGit(url string, handler func(tempExtPath string, devMode bool) (*Extension, error)) (*Extension, error) {
if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") {
url = "https://" + url
}
// waitForExtensionGit waits for clones and their install/check handlers before
// shutdown releases storage and temporary files. Close cancels backgroundCtx first.
func (d *Downloader) waitForExtensionGit() {
d.extensionGitLock.Lock()
d.extensionGitLock.Unlock()
d.extensionGitWG.Wait()
}
// resolve project path
parentPath, projectPath := path.Split(url)
// resolve project name and sub path
pathArr := strings.SplitN(projectPath, "#", 2)
projectPath = strings.TrimSuffix(pathArr[0], gitSuffix)
subPath := ""
if len(pathArr) > 1 {
subPath = pathArr[1]
func (d *Downloader) fetchExtensionByGit(rawURL string, handler func(string, bool) (*Extension, error)) (*Extension, error) {
// Pair admission with shutdown so no operation is added after its wait starts.
d.extensionGitLock.Lock()
if d.closed.Load() {
d.extensionGitLock.Unlock()
return nil, fmt.Errorf("downloader is closed")
}
// Use unique suffix to avoid concurrent conflicts when multiple git operations
// target the same extension (e.g., install and update check happening simultaneously)
tempExtDir, err := os.MkdirTemp(d.cfg.TempDir, "gopeed-extension-")
d.extensionGitWG.Add(1)
d.extensionGitLock.Unlock()
defer d.extensionGitWG.Done()
ctx := d.backgroundCtx
if !strings.HasPrefix(rawURL, "https://") && !strings.HasPrefix(rawURL, "http://") {
rawURL = "https://" + rawURL
}
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Host == "" {
return nil, fmt.Errorf("invalid extension repository URL")
}
subPath := filepath.FromSlash(parsed.Fragment)
if filepath.IsAbs(subPath) || subPath == ".." || strings.HasPrefix(filepath.Clean(subPath), ".."+string(filepath.Separator)) {
return nil, fmt.Errorf("invalid extension directory")
}
parsed.Fragment = ""
parsed.Path = strings.TrimSuffix(strings.TrimRight(parsed.Path, "/"), gitSuffix) + gitSuffix
officialURL := parsed.String()
cfg, err := d.GetConfig()
if err != nil {
return nil, err
}
if err := d.tempFiles.Track(tempExtDir); err != nil {
return nil, err
}
defer d.tempFiles.Remove(tempExtDir)
proxyOptions := transport.ProxyOptions{}
proxyUrl := d.cfg.DownloaderStoreConfig.Proxy.ToUrl()
if proxyUrl != nil {
proxyOptions.URL = proxyUrl.Scheme + "://" + proxyUrl.Host
proxyOptions.Username = proxyUrl.User.Username()
proxyOptions.Password, _ = proxyUrl.User.Password()
if proxyURL := cfg.Proxy.ToUrl(); proxyURL != nil {
proxyOptions.URL = proxyURL.Scheme + "://" + proxyURL.Host
if proxyURL.User != nil {
proxyOptions.Username = proxyURL.User.Username()
proxyOptions.Password, _ = proxyURL.User.Password()
}
}
// clone project to extension temp dir
gitUrl := parentPath + projectPath + gitSuffix
if _, err := git.PlainClone(tempExtDir, false, &git.CloneOptions{
URL: gitUrl,
Depth: 1,
ProxyOptions: proxyOptions,
}); err != nil {
tempDir, err := os.MkdirTemp(d.cfg.TempDir, "gopeed-extension-")
if err != nil {
return nil, err
}
return handler(filepath.Join(tempExtDir, subPath), false)
if err = d.tempFiles.Track(tempDir); err != nil {
_ = os.RemoveAll(tempDir)
return nil, err
}
defer d.tempFiles.Remove(tempDir)
repoDir := filepath.Join(tempDir, "repo")
repo, err := githubmirror.Fallback(ctx, cfg.GithubMirror, officialURL,
githubmirror.Options[*git.Repository]{Kind: githubmirror.Repository},
func(ctx context.Context, candidate string) (*git.Repository, error) {
if err := os.RemoveAll(repoDir); err != nil {
return nil, err
}
return git.PlainCloneContext(ctx, repoDir, false, &git.CloneOptions{URL: candidate, Depth: 1, ProxyOptions: proxyOptions})
},
)
if err != nil {
return nil, err
}
// Preserve the canonical source instead of persisting a selected mirror.
repoConfig, err := repo.Config()
if err != nil {
return nil, err
}
if remote := repoConfig.Remotes["origin"]; remote != nil {
remote.URLs = []string{officialURL}
}
if err = repo.SetConfig(repoConfig); err != nil {
return nil, err
}
return handler(filepath.Join(repoDir, subPath), false)
}
func (d *Downloader) parseExtensionByPath(path string) (*Extension, error) {
@@ -733,6 +771,14 @@ type OnDoneContext struct {
// only some fields can be modified, such as request info.
type ExtensionTask struct {
*Task
Meta *ExtensionTaskMeta `json:"meta"`
}
// ExtensionTaskMeta preserves metadata fields while exposing request operations
// at the existing meta.req path.
type ExtensionTaskMeta struct {
*fetcher.FetcherMeta
Req *ExtensionTaskRequest `json:"req"`
}
// OnErrorExtensionTask adds error-recovery controls to ExtensionTask.
@@ -744,13 +790,24 @@ type OnErrorExtensionTask struct {
}
func cloneExtensionTask(task *Task) *Task {
if task.statusLock != nil {
task.statusLock.Lock()
defer task.statusLock.Unlock()
}
newTask := task.clone()
newTask.Meta.Req = task.Meta.Req
return newTask
}
func newExtensionTask(task *Task) *ExtensionTask {
return &ExtensionTask{Task: cloneExtensionTask(task)}
view := cloneExtensionTask(task)
return &ExtensionTask{
Task: view,
Meta: &ExtensionTaskMeta{
FetcherMeta: view.Meta,
Req: &ExtensionTaskRequest{Request: view.Meta.Req, task: view},
},
}
}
func newOnStartExtensionTask(task *Task) *ExtensionTask {
@@ -768,11 +825,6 @@ func newOnDoneExtensionTask(task *Task) *Task {
return cloneExtensionTask(task)
}
// SetUrl replaces the task request URL.
func (t *ExtensionTask) SetUrl(url string) {
t.Meta.Req.URL = url
}
func (t *OnErrorExtensionTask) Continue() error {
return t.download.Continue(&TaskFilter{
IDs: []string{t.ID},
@@ -0,0 +1,188 @@
package download
import (
"fmt"
"reflect"
"testing"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/pkg/base"
btProtocol "github.com/GopeedLab/gopeed/pkg/protocol/bt"
httpProtocol "github.com/GopeedLab/gopeed/pkg/protocol/http"
"github.com/dop251/goja"
)
func TestExtensionProtocolMethods(t *testing.T) {
for _, extra := range []any{nil, map[string]any{"header": map[string]any{"Keep": "yes"}}, &httpProtocol.ReqExtra{Header: map[string]string{"Keep": "yes"}}} {
vm := goja.New()
vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
req := &base.Request{Extra: extra}
task := &Task{Protocol: "http", Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}}
if err := vm.Set("task", newOnStartExtensionTask(task)); err != nil {
t.Fatal(err)
}
_, err := vm.RunString(`task.meta.req.setMethod('POST'); task.meta.req.setBody('payload'); task.meta.req.putHeader('Authorization','Bearer token'); task.meta.req.putHeader('Remove','remove'); task.meta.req.delHeader('REMOVE');`)
if err != nil {
t.Fatal(err)
}
if err := base.ParseReqExtra[httpProtocol.ReqExtra](req); err != nil {
t.Fatal(err)
}
got := req.Extra.(*httpProtocol.ReqExtra)
if got.Method != "POST" || got.Body != "payload" || got.Header["Authorization"] != "Bearer token" {
t.Fatalf("mutation not applied: %#v", got)
}
if _, ok := got.Header["Remove"]; ok {
t.Fatal("header deletion failed")
}
if extra != nil && got.Header["Keep"] != "yes" {
t.Fatal("existing header lost")
}
}
vm := goja.New()
vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
req := &base.Request{Extra: &btProtocol.ReqExtra{Trackers: []string{"old"}}}
task := &Task{Protocol: "bt", Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}}
if err := vm.Set("task", newOnStartExtensionTask(task)); err != nil {
t.Fatal(err)
}
if _, err := vm.RunString(`task.meta.req.setTrackers(['new','another']);`); err != nil {
t.Fatal(err)
}
got := req.Extra.(*btProtocol.ReqExtra).Trackers
if len(got) != 2 || got[0] != "new" || got[1] != "another" {
t.Fatalf("trackers = %#v", got)
}
}
func TestExtensionProtocolMethodsRejectInvalidMutation(t *testing.T) {
vm := goja.New()
vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
original := &httpProtocol.ReqExtra{Method: "POST", Header: map[string]string{"authorization": "old", "Keep": "yes"}}
req := &base.Request{Extra: original}
task := newOnStartExtensionTask(&Task{Protocol: "http", Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}})
if err := vm.Set("task", task); err != nil {
t.Fatal(err)
}
for _, script := range []string{`task.meta.req.setHeaders({Keep:null})`, `task.meta.req.setHeaders({Keep:123})`, `task.meta.req.setHeaders({a:'1',A:'2'})`} {
if _, err := vm.RunString(script); err == nil {
t.Fatalf("accepted invalid mutation: %s", script)
}
if req.Extra != original || original.Header["Keep"] != "yes" {
t.Fatal("invalid operation changed original extra")
}
}
if _, err := vm.RunString(`task.meta.req.putHeader('Authorization','new');`); err != nil {
t.Fatal(err)
}
got := req.Extra.(*httpProtocol.ReqExtra)
if len(got.Header) != 2 || got.Header["Authorization"] != "new" || original.Header["authorization"] != "old" {
t.Fatalf("header update = %#v", got.Header)
}
if _, err := vm.RunString(`task.meta.req.setHeaders({Only:'one'});`); err != nil {
t.Fatal(err)
}
got = req.Extra.(*httpProtocol.ReqExtra)
if got.Method != "POST" || len(got.Header) != 1 {
t.Fatalf("setHeaders = %#v", got)
}
req.Extra = map[string]any{"header": 123}
if _, err := vm.RunString(`task.meta.req.setBody('new')`); err == nil {
t.Fatal("invalid protocol extra accepted")
}
if req.Extra.(map[string]any)["header"] != 123 {
t.Fatal("conversion failure mutated extra")
}
}
func TestExtensionProtocolMethodsOtherProtocolsAreNoOp(t *testing.T) {
for _, protocol := range []string{"http", "bt", "hls"} {
for _, onError := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/onError=%v", protocol, onError), func(t *testing.T) {
vm := goja.New()
vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
// An invalid extra proves that unrelated methods skip conversion entirely.
original := map[string]any{"unchanged": true, "header": 123}
req := &base.Request{Extra: original}
task := &Task{Protocol: protocol, Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}}
var wrapper any = newOnStartExtensionTask(task)
if onError {
wrapper = newOnErrorExtensionTask(nil, task)
}
if err := vm.Set("task", wrapper); err != nil {
t.Fatal(err)
}
if protocol != "http" {
if _, err := vm.RunString(`task.meta.req.setMethod('POST');task.meta.req.setBody('body');task.meta.req.setHeaders({a:null});task.meta.req.putHeader('A','value');task.meta.req.delHeader('A');`); err != nil {
t.Fatal(err)
}
}
if protocol != "bt" {
if _, err := vm.RunString(`task.meta.req.setTrackers(['udp://example.com']);`); err != nil {
t.Fatal(err)
}
}
if !reflect.DeepEqual(req.Extra, original) {
t.Fatalf("extra changed: %#v", req.Extra)
}
original["same-object"] = true
if req.Extra.(map[string]any)["same-object"] != true {
t.Fatal("no-op replaced extra")
}
})
}
}
}
func TestExtensionOnErrorRequestWritesToOriginalTask(t *testing.T) {
vm := goja.New()
vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
req := &base.Request{URL: "https://example.com", Extra: &httpProtocol.ReqExtra{Header: map[string]string{"Keep": "yes"}}}
task := &Task{Protocol: "http", Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}}
if err := vm.Set("task", newOnErrorExtensionTask(nil, task)); err != nil {
t.Fatal(err)
}
if _, err := vm.RunString(`
if (typeof task.continue !== 'function') throw new Error('missing continue');
if (typeof task.putHeader !== 'undefined') throw new Error('task still exposes protocol methods');
if (typeof task.req !== 'undefined') throw new Error('legacy task.req');
const request = task.meta.req;
if (request.url !== 'https://example.com' || request.extra.header.Keep !== 'yes') throw new Error('request data missing');
if (!task.meta.opts) throw new Error('metadata fields missing');
request.setHeaders({Keep:'new'});
request.putHeader('Authorization','Bearer new');
request.setMethod('POST');
if (typeof task.setUrl !== 'undefined' || typeof task.extra !== 'undefined') throw new Error('legacy task API');
request.setUrl('https://example.com/new');
request.setLabels({keep:'yes',remove:'yes'});
request.putLabel('new','value');
request.delLabel('remove');
if (request.url !== 'https://example.com/new' || request.labels.new !== 'value' || request.extra.method !== 'POST' || request.extra.header.Authorization !== 'Bearer new') throw new Error('request data did not refresh');
`); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(req.Labels, map[string]string{"keep": "yes", "new": "value"}) {
t.Fatalf("labels = %#v", req.Labels)
}
got := req.Extra.(*httpProtocol.ReqExtra)
if got.Method != "POST" || got.Header["Keep"] != "new" || got.Header["Authorization"] != "Bearer new" || req.URL != "https://example.com/new" {
t.Fatalf("original request not updated: %#v, %#v", req, got)
}
}
func TestExtensionBTRequestInitializesNilExtra(t *testing.T) {
vm := goja.New()
vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true))
req := &base.Request{}
task := &Task{Protocol: "bt", Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}}
if err := vm.Set("task", newOnStartExtensionTask(task)); err != nil {
t.Fatal(err)
}
if _, err := vm.RunString(`task.meta.req.setTrackers(['udp://example.com']);`); err != nil {
t.Fatal(err)
}
got, ok := req.Extra.(*btProtocol.ReqExtra)
if !ok || !reflect.DeepEqual(got.Trackers, []string{"udp://example.com"}) {
t.Fatalf("extra = %#v", req.Extra)
}
}
+104
View File
@@ -0,0 +1,104 @@
package download
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/GopeedLab/gopeed/pkg/base"
httpProtocol "github.com/GopeedLab/gopeed/pkg/protocol/http"
)
func TestExtensionExtraMutationHTTP(t *testing.T) {
for _, resolved := range []bool{false, true} {
for _, invalid := range []bool{false, true} {
t.Run(fmt.Sprintf("resolved=%v/invalid=%v", resolved, invalid), func(t *testing.T) {
var updatedRequests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-New") == "new" {
updatedRequests.Add(1)
if r.Header.Get("X-Old") != "" {
t.Error("extra mutation retained the old header")
}
}
http.ServeContent(w, r, "test.txt", time.Time{}, strings.NewReader("extra mutation payload"))
}))
defer server.Close()
d := NewDownloader(&DownloaderConfig{Storage: NewMemStorage(), StorageDir: t.TempDir()})
if err := d.Setup(); err != nil {
t.Fatal(err)
}
defer d.Clear()
extDir := t.TempDir()
manifest := `{"name":"http-methods","title":"HTTP methods test","version":"0.0.1","scripts":[{"event":"onStart","match":{"labels":["test"]},"entry":"index.js"}]}`
mutation := `await ctx.task.meta.req.setMethod('GET');
await ctx.task.meta.req.setBody('');
await ctx.task.meta.req.delHeader('x-old');
await ctx.task.meta.req.putHeader('X-New', 'new');`
if invalid {
mutation = `try { await ctx.task.meta.req.setHeaders({'X-New':123}); }
catch (err) { await ctx.task.meta.req.putLabel('rejected','true'); } ` + mutation
}
script := `gopeed.events.onStart(async ctx => { ` + mutation + ` });`
for name, content := range map[string]string{"manifest.json": manifest, "index.js": script} {
if err := os.WriteFile(filepath.Join(extDir, name), []byte(content), 0600); err != nil {
t.Fatal(err)
}
}
if _, err := d.InstallExtensionByFolder(extDir, false); err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
d.Listener(func(event *Event) {
if event.Key == EventKeyFinally {
done <- event.Err
}
})
req := &base.Request{URL: server.URL + "/test.txt", Labels: map[string]string{"test": "true"}, Extra: &httpProtocol.ReqExtra{Header: map[string]string{"X-Old": "old"}}}
opts := &base.Options{Path: t.TempDir()}
var id string
var err error
if resolved {
rr, resolveErr := d.Resolve(req)
if resolveErr != nil {
t.Fatal(resolveErr)
}
id, err = d.CreateWithOptions(rr.ID, opts)
} else {
id, err = d.CreateDirect(req, opts)
}
if err != nil {
t.Fatal(err)
}
select {
case err = <-done:
case <-time.After(10 * time.Second):
t.Fatal("download timed out")
}
if invalid && d.GetTask(id).Meta.Req.Labels["rejected"] != "true" {
t.Fatal("invalid header was not rejected")
}
if err != nil {
t.Fatal(err)
}
got, ok := d.GetTask(id).Meta.Req.Extra.(*httpProtocol.ReqExtra)
if !ok || got.Header["X-New"] != "new" || len(got.Header) != 1 {
t.Fatalf("unexpected protocol extra: %#v", d.GetTask(id).Meta.Req.Extra)
}
if !resolved && updatedRequests.Load() == 0 {
t.Fatal("download did not use the extension headers")
}
data, err := os.ReadFile(filepath.Join(opts.Path, "test.txt"))
if err != nil || string(data) != "extra mutation payload" {
t.Fatalf("download content = %q, error = %v", data, err)
}
})
}
}
}
+210
View File
@@ -0,0 +1,210 @@
package download
import (
"encoding/json"
"io"
"net/http"
"net/http/cgi"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
httpProtocol "github.com/GopeedLab/gopeed/internal/protocol/http"
"github.com/GopeedLab/gopeed/pkg/base"
)
// Exercise real Git clone/install/check/update without depending on GitHub or
// public mirror availability. The proxy deliberately rejects the official URL.
// github.com/gopeed-test/repo is a placeholder: the successful mirror serves
// the temporary local repository through git http-backend instead.
func newExtensionGitFixture(t *testing.T) (*Downloader, func(string), *atomic.Int32, *atomic.Int32) {
t.Helper()
git, err := exec.LookPath("git")
if err != nil {
t.Skip("Git executable required for HTTP Git fixture")
}
root := t.TempDir()
bare := filepath.Join(root, "repo.git")
work := t.TempDir()
command := func(args ...string) {
t.Helper()
cmd := exec.Command(git, args...)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v: %s", args, err, output)
}
}
command("-c", "init.defaultBranch=main", "init", "--bare", bare)
command("clone", bare, work)
command("-C", work, "config", "user.name", "Gopeed Test")
command("-C", work, "config", "user.email", "[email protected]")
publish := func(version string) {
t.Helper()
directory := filepath.Join(work, "nested", "extension")
if err := os.MkdirAll(directory, 0700); err != nil {
t.Fatal(err)
}
manifest := map[string]any{"name": "mirror-test", "author": "test", "title": "Mirror test", "version": version, "repository": map[string]string{"url": "https://github.com/gopeed-test/repo", "directory": "nested/extension"}}
body, _ := json.Marshal(manifest)
if err := os.WriteFile(filepath.Join(directory, "manifest.json"), body, 0600); err != nil {
t.Fatal(err)
}
command("-C", work, "add", ".")
command("-C", work, "commit", "-m", version)
command("-C", work, "push", "origin", "HEAD:main")
}
publish("1.0.0")
var official, badMirror atomic.Int32
backend := &cgi.Handler{Path: git, Args: []string{"http-backend"}, Env: []string{"GIT_PROJECT_ROOT=" + root, "GIT_HTTP_EXPORT_ALL=1"}}
mirror := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/https://github.com/gopeed-test")
r.RequestURI = r.URL.RequestURI()
backend.ServeHTTP(w, r)
}))
t.Cleanup(mirror.Close)
failed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { badMirror.Add(1); http.Error(w, "mirror offline", 502) }))
t.Cleanup(failed.Close)
direct := &http.Transport{}
t.Cleanup(direct.CloseIdleConnections)
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodConnect || r.URL.Hostname() == "github.com" {
official.Add(1)
http.Error(w, "official unavailable", 502)
return
}
request := r.Clone(r.Context())
request.RequestURI = ""
response, err := direct.RoundTrip(request)
if err != nil {
http.Error(w, err.Error(), 502)
return
}
defer response.Body.Close()
for key, values := range response.Header {
for _, value := range values {
w.Header().Add(key, value)
}
}
w.WriteHeader(response.StatusCode)
_, _ = io.Copy(w, response.Body)
}))
t.Cleanup(proxy.Close)
d := testBackendDownloader(t, &httpProtocol.FetcherManager{})
config, _ := d.GetConfig()
config.Proxy = &base.DownloaderProxyConfig{Enable: true, Scheme: "http", Host: strings.TrimPrefix(proxy.URL, "http://")}
config.GithubMirror = &base.GithubMirrorConfig{Enabled: true, Mirrors: []base.GithubMirror{{Type: "ghProxy", URL: failed.URL}, {Type: "ghProxy", URL: mirror.URL}}}
if err := d.PutConfig(config); err != nil {
t.Fatal(err)
}
return d, publish, &official, &badMirror
}
func testInstallExtensionGitURL(t *testing.T, raw string) {
d, _, official, badMirror := newExtensionGitFixture(t)
extension, err := d.InstallExtensionByGit(raw)
if err != nil {
t.Fatal(err)
}
if extension.Version != "1.0.0" || extension.Identity != "test@mirror-test" {
t.Fatalf("%+v", extension)
}
if official.Load() == 0 || badMirror.Load() == 0 {
t.Fatal("ordered fallback was not exercised")
}
if _, err := os.Stat(filepath.Join(d.ExtensionPath(extension), "manifest.json")); err != nil {
t.Fatal(err)
}
}
func TestExtensionGitMirrorInstallAndUpdate(t *testing.T) {
d, publish, official, badMirror := newExtensionGitFixture(t)
extension, err := d.InstallExtensionByGit("https://github.com/gopeed-test/repo#nested/extension")
if err != nil {
t.Fatal(err)
}
publish("1.1.0")
version, err := d.UpgradeCheckExtension(extension.Identity)
if err != nil || version != "1.1.0" {
t.Fatalf("%s %v", version, err)
}
if err = d.UpgradeExtension(extension.Identity); err != nil {
t.Fatal(err)
}
updated, err := d.GetExtension(extension.Identity)
if err != nil || updated.Version != "1.1.0" {
t.Fatalf("%+v %v", updated, err)
}
if updated.Repository.Url != "https://github.com/gopeed-test/repo" {
t.Fatal("mirror URL replaced canonical repository")
}
if official.Load() != 3 || badMirror.Load() != 3 {
t.Fatalf("attempts official=%d mirror=%d", official.Load(), badMirror.Load())
}
}
func TestExtensionGitShutdownWaitsForHandler(t *testing.T) {
d, _, _, _ := newExtensionGitFixture(t)
started := make(chan string, 1)
release := make(chan struct{})
t.Cleanup(func() {
select {
case <-release:
default:
close(release)
}
})
finished := make(chan error, 1)
go func() {
_, err := d.fetchExtensionByGit("https://github.com/gopeed-test/repo#nested/extension", func(path string, _ bool) (*Extension, error) {
started <- path
<-release
return d.parseExtensionByPath(path)
})
finished <- err
}()
var path string
select {
case path = <-started:
case err := <-finished:
t.Fatalf("Git operation finished before handler: %v", err)
case <-time.After(5 * time.Second):
t.Fatal("Git handler did not start")
}
closed := make(chan error, 1)
go func() { closed <- d.Close() }()
select {
case <-d.backgroundCtx.Done():
case <-time.After(5 * time.Second):
t.Fatal("shutdown did not cancel Git context")
}
select {
case err := <-closed:
t.Fatalf("shutdown returned before handler finished: %v", err)
case <-time.After(100 * time.Millisecond):
}
if _, err := os.Stat(filepath.Join(path, "manifest.json")); err != nil {
t.Fatalf("shutdown removed files still used by handler: %v", err)
}
if _, err := d.InstallExtensionByGit("https://github.com/gopeed-test/repo"); err == nil || !strings.Contains(err.Error(), "downloader is closed") {
t.Fatalf("expected new Git operations to be rejected during shutdown: %v", err)
}
close(release)
select {
case err := <-finished:
if err != nil {
t.Fatal(err)
}
case <-time.After(5 * time.Second):
t.Fatal("Git handler did not finish")
}
select {
case err := <-closed:
if err != nil {
t.Fatal(err)
}
case <-time.After(5 * time.Second):
t.Fatal("shutdown did not finish after Git handler")
}
}
+62
View File
@@ -0,0 +1,62 @@
package download
import (
"context"
"net"
"net/http"
"time"
gitclient "github.com/go-git/go-git/v5/plumbing/transport/client"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
)
func init() {
// go-git exposes a process-wide protocol registry. Register once during
// initialization, rather than changing it during concurrent clones. Proxy
// settings still come from each clone's ProxyOptions.
transport := githttp.NewClient(newExtensionGitHTTPClient(10*time.Second, 60*time.Second))
gitclient.InstallProtocol("http", transport)
gitclient.InstallProtocol("https", transport)
}
func newExtensionGitHTTPClient(connectTimeout, idleTimeout time.Duration) *http.Client {
dialer := &net.Dialer{Timeout: connectTimeout, KeepAlive: 30 * time.Second}
// A socket read deadline must apply to one transfer, rather than a shared
// HTTP/2 connection carrying traffic from other requests.
protocols := new(http.Protocols)
protocols.SetHTTP1(true)
return &http.Client{Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
conn, err := dialer.DialContext(ctx, network, address)
if err != nil {
return nil, err
}
return &gitIdleConn{Conn: conn, timeout: idleTimeout}, nil
},
TLSHandshakeTimeout: connectTimeout,
IdleConnTimeout: idleTimeout,
Protocols: protocols,
}}
}
type gitIdleConn struct {
net.Conn
timeout time.Duration
}
func (c *gitIdleConn) Read(p []byte) (int, error) {
if err := c.Conn.SetReadDeadline(time.Now().Add(c.timeout)); err != nil {
return 0, err
}
return c.Conn.Read(p)
}
// Refresh an already-pending read when a pooled HTTP/1 connection starts the
// next request; time spent idle in the pool must not consume its read allowance.
func (c *gitIdleConn) Write(p []byte) (int, error) {
if err := c.Conn.SetReadDeadline(time.Now().Add(c.timeout)); err != nil {
return 0, err
}
return c.Conn.Write(p)
}
@@ -0,0 +1,189 @@
package download
import (
"context"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"net/http/httptrace"
"net/url"
"reflect"
"testing"
"time"
"github.com/GopeedLab/gopeed/internal/githubmirror"
)
func TestExtensionGitHTTPTransportAllowsSlowStreaming(t *testing.T) {
const chunks = 24
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for range chunks {
if _, err := io.WriteString(w, "x"); err != nil {
return
}
w.(http.Flusher).Flush()
select {
case <-time.After(50 * time.Millisecond):
case <-r.Context().Done():
return
}
}
}))
defer server.Close()
client := newExtensionGitHTTPClient(time.Second, time.Second)
defer client.CloseIdleConnections()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
request, _ := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
response, err := client.Do(request)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
// A regularly streaming response lasts longer than the idle allowance.
body, err := io.ReadAll(response.Body)
if err != nil || len(body) != chunks {
t.Fatalf("received %d bytes, error=%v", len(body), err)
}
}
func TestExtensionGitHTTPTransportTimesOutStalledReads(t *testing.T) {
for _, bodyStarted := range []bool{false, true} {
name := "headers"
if bodyStarted {
name = "body"
}
t.Run(name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if bodyStarted {
_, _ = io.WriteString(w, "partial")
w.(http.Flusher).Flush()
}
<-r.Context().Done()
}))
defer server.Close()
client := newExtensionGitHTTPClient(time.Second, 100*time.Millisecond)
defer client.CloseIdleConnections()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
request, _ := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
response, err := client.Do(request)
if err == nil {
defer response.Body.Close()
_, err = io.ReadAll(response.Body)
}
var timeout net.Error
if !errors.As(err, &timeout) || !timeout.Timeout() || ctx.Err() != nil {
t.Fatalf("expected idle timeout before parent deadline: error=%v context=%v", err, ctx.Err())
}
})
}
}
func TestExtensionGitIdleTimeoutFallsBackThroughProxy(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Hostname() == "github.com" {
_, _ = io.WriteString(w, "partial")
w.(http.Flusher).Flush()
<-r.Context().Done()
return
}
_, _ = io.WriteString(w, "complete")
}))
defer server.Close()
client := newExtensionGitHTTPClient(time.Second, 100*time.Millisecond)
defer client.CloseIdleConnections()
proxy, _ := url.Parse(server.URL)
client.Transport.(*http.Transport).Proxy = http.ProxyURL(proxy)
config := &githubmirror.Config{Enabled: true, Mirrors: []githubmirror.Mirror{{Type: "ghProxy", URL: server.URL}}}
source := "http://github.com/test/repo.git"
var tried []string
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
result, err := githubmirror.Fallback(ctx, config, source, githubmirror.Options[string]{Kind: githubmirror.Repository}, func(ctx context.Context, candidate string) (string, error) {
tried = append(tried, candidate)
request, _ := http.NewRequestWithContext(ctx, http.MethodGet, candidate, nil)
response, err := client.Do(request)
if err != nil {
return "", err
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
return string(body), err
})
if err != nil || result != "complete" || !reflect.DeepEqual(tried, []string{source, server.URL + "/" + source}) {
t.Fatalf("result=%q tried=%v error=%v", result, tried, err)
}
}
func TestExtensionGitHTTPTransportCancelsStalledBody(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.(http.Flusher).Flush()
<-r.Context().Done()
}))
defer server.Close()
client := newExtensionGitHTTPClient(time.Second, time.Minute)
defer client.CloseIdleConnections()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
request, _ := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
response, err := client.Do(request)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
cancel()
done := make(chan error, 1)
go func() { _, err := io.ReadAll(response.Body); done <- err }()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("error=%v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("parent cancellation did not interrupt the body")
}
}
func TestExtensionGitHTTPTransportRefreshesReusedConnection(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/slow" {
select {
case <-time.After(500 * time.Millisecond):
case <-r.Context().Done():
return
}
}
_, _ = io.WriteString(w, "ok")
}))
defer server.Close()
client := newExtensionGitHTTPClient(time.Second, time.Second)
defer client.CloseIdleConnections()
response, err := client.Get(server.URL)
if err != nil {
t.Fatal(err)
}
_, err = io.Copy(io.Discard, response.Body)
_ = response.Body.Close()
if err != nil {
t.Fatal(err)
}
time.Sleep(700 * time.Millisecond)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
reused := false
ctx = httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{GotConn: func(info httptrace.GotConnInfo) { reused = info.Reused }})
request, _ := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/slow", nil)
response, err = client.Do(request)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil || string(body) != "ok" || !reused {
t.Fatalf("reused=%v body=%q error=%v", reused, body, err)
}
}
+158
View File
@@ -0,0 +1,158 @@
package download
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/GopeedLab/gopeed/pkg/base"
)
func TestExtensionResolveSizeThroughTaskCreation(t *testing.T) {
for _, mode := range []string{"http", "blob"} {
for _, known := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/known=%v", mode, known), func(t *testing.T) {
payload := []byte("0123456789")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body := payload
if r.URL.Path == "/empty" {
body = nil
}
if known {
w.Header().Set("Content-Length", fmt.Sprint(len(body)))
}
w.(http.Flusher).Flush()
w.Write(body)
}))
defer server.Close()
d := NewDownloader(&DownloaderConfig{Storage: NewMemStorage(), StorageDir: t.TempDir(), TempDir: t.TempDir()})
if err := d.Setup(); err != nil {
t.Fatal(err)
}
defer d.Clear()
d.cfg.MaxRunning = 0 // Inspect persisted queued tasks before HTTP resolution.
extDir := t.TempDir()
manifest := `{"name":"size-hint","author":"test","title":"Size hint","version":"1.0.0","scripts":[{"event":"onResolve","match":{"urls":["https://example.com/size-hint"]},"entry":"index.js"}]}`
script := fmt.Sprintf(`gopeed.events.onResolve(async ctx=>{
const mode=%q, origin=%q;
const file=async(name,size,empty)=>({name,size,req:{url:mode==='http'?origin+(empty?'/empty':'/data'):
await gopeed.runtime.blob.createObjectURL(()=>new ReadableStream({start(c){if(!empty)c.enqueue(new Uint8Array([48,49,50,51,52,53,54,55,56,57]));c.close();}}))}});
ctx.res={files:[await file('under.bin',3,false),await file('over.bin',1048576,false),await file('empty.bin',99,true)]};
});`, mode, server.URL)
for name, contents := range map[string]string{"manifest.json": manifest, "index.js": script} {
if err := os.WriteFile(filepath.Join(extDir, name), []byte(contents), 0600); err != nil {
t.Fatal(err)
}
}
if _, err := d.InstallExtensionByFolder(extDir, true); err != nil {
t.Fatal(err)
}
rr, err := d.Resolve(&base.Request{URL: "https://example.com/size-hint"})
if err != nil {
t.Fatal(err)
}
output := t.TempDir()
batch := &base.CreateTaskBatch{}
for _, file := range rr.Res.Files {
batch.Reqs = append(batch.Reqs, &base.CreateTaskBatchItem{Req: file.Req, Opts: &base.Options{Size: file.Size, Name: file.Name, Path: output}})
}
wire, err := json.Marshal(batch)
if err != nil {
t.Fatal(err)
}
var submitted base.CreateTaskBatch
if err := json.Unmarshal(wire, &submitted); err != nil {
t.Fatal(err)
}
ids, err := d.CreateDirectBatch(&submitted)
if err != nil {
t.Fatal(err)
}
for i, id := range ids {
task := d.GetTask(id)
if task.Meta.Res != nil {
t.Fatal("a size estimate must not mark the task resolved")
}
status, err := d.RuntimeStatus(id)
if err != nil || status.Total != rr.Res.Files[i].Size {
t.Fatalf("queued size lost: status=%+v err=%v", status, err)
}
var saved Task
if _, err := d.storage.Get(bucketTask, id, &saved); err != nil || saved.Meta.Opts.Size != status.Total {
t.Fatal("queued estimate was not persisted", err)
}
if err := d.doStart(task); err != nil {
t.Fatal(err)
}
waitForTaskTerminal(t, d, id, 5*time.Second)
body, err := os.ReadFile(filepath.Join(output, rr.Res.Files[i].Name))
if err != nil {
t.Fatal(err)
}
want := payload
if i == 2 {
want = nil
}
if !bytes.Equal(body, want) {
t.Fatalf("estimate changed output: %q", body)
}
status, err = d.RuntimeStatus(id)
if err != nil || status.Total != int64(len(want)) || status.Downloaded != int64(len(want)) || task.Meta.Res.Files[0].Size != int64(len(want)) {
t.Fatalf("final size not updated: %+v err=%v", status, err)
}
}
})
}
}
}
func TestResolvedHTTPSizeEstimateAtCreation(t *testing.T) {
for _, known := range []bool{false, true} {
t.Run(fmt.Sprintf("known=%v", known), func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if known {
w.Header().Set("Content-Length", "10")
}
w.(http.Flusher).Flush()
w.Write([]byte("0123456789"))
}))
defer server.Close()
d := NewDownloader(&DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir()})
if err := d.Setup(); err != nil {
t.Fatal(err)
}
defer d.Clear()
d.cfg.MaxRunning = 0
rr, err := d.Resolve(&base.Request{URL: server.URL})
if err != nil {
t.Fatal(err)
}
id, err := d.CreateWithOptions(rr.ID, &base.Options{Size: 3, Path: t.TempDir(), Name: "output.bin"})
if err != nil {
t.Fatal(err)
}
want := int64(3)
if known {
want = 10
}
status, err := d.RuntimeStatus(id)
if err != nil || status.Total != want || d.GetTask(id).Meta.Res.Files[0].Size != want {
t.Fatalf("resolve-before-create size=%+v want=%d err=%v", status, want, err)
}
if err := d.doStart(d.GetTask(id)); err != nil {
t.Fatal(err)
}
waitForTaskTerminal(t, d, id, 5*time.Second)
task := d.GetTask(id)
if task.Status != base.DownloadStatusDone || task.Meta.Res.Size != 10 {
t.Fatalf("estimate changed completion: status=%s size=%d", task.Status, task.Meta.Res.Size)
}
})
}
}
+212
View File
@@ -0,0 +1,212 @@
package download
import (
"bytes"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/GopeedLab/gopeed/pkg/base"
)
func TestExtensionFileSizeIsAdvisory(t *testing.T) {
video, err := os.ReadFile("engine/testdata/ffmpeg/video.mp4")
if err != nil {
t.Fatal(err)
}
audio, err := os.ReadFile("engine/testdata/ffmpeg/audio.mp4")
if err != nil {
t.Fatal(err)
}
for _, mode := range []string{"plain", "merge", "wrapped-merge"} {
var expected []byte
for _, size := range []int64{0, 3, 1 << 20} {
t.Run(fmt.Sprintf("%s/size=%d", mode, size), func(t *testing.T) {
d := NewDownloader(&DownloaderConfig{Storage: NewMemStorage(), StorageDir: t.TempDir(), TempDir: t.TempDir(), RefreshInterval: 50})
if err := d.Setup(); err != nil {
t.Fatal(err)
}
defer d.Clear()
e, _ := d.newExtensionEngine()
defer e.Close()
e.Runtime.Set("videoBytes", e.Runtime.NewArrayBuffer(video))
e.Runtime.Set("audioBytes", e.Runtime.NewArrayBuffer(audio))
e.Runtime.Set("mode", mode)
result, err := e.RunString(`__gopeed_blob_create_object_url(()=>{
const source=b=>new ReadableStream({start(c){c.enqueue(new Uint8Array(b));c.close();}});
if (mode==='plain') return source([0,1,2,3,4,5,6,7,8,9]);
const output=__gopeed_ffmpeg.merge({inputs:()=>({video:source(videoBytes),audio:source(audioBytes)})});
if (mode==='merge') return output;
const reader=output.getReader();
return new ReadableStream({async pull(c){const {done,value}=await reader.read();if(done)c.close();else c.enqueue(value);},cancel(r){return reader.cancel(r);}});
},{contentType:'video/mp4',range:false})`)
if err != nil {
t.Fatal(err)
}
output := t.TempDir()
id, err := d.CreateDirect(&base.Request{URL: fmt.Sprint(result)}, &base.Options{Size: size, Path: output, Name: "output.bin"})
if err != nil {
t.Fatal(err)
}
waitForTaskTerminal(t, d, id, 30*time.Second)
body, err := os.ReadFile(filepath.Join(output, "output.bin"))
if err != nil {
t.Fatal(err)
}
if size == 0 {
expected = body
if mode == "plain" && !bytes.Equal(body, []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}) {
t.Fatalf("unexpected stream output: %v", body)
}
if mode != "plain" && !bytes.Contains(body, []byte("moof")) {
t.Fatal("invalid merged output")
}
} else if !bytes.Equal(body, expected) {
t.Fatalf("size hint changed output: got %d bytes, want %d", len(body), len(expected))
}
task := d.GetTask(id)
task.lock.Lock()
defer task.lock.Unlock()
if task.Meta.Res.Size != int64(len(body)) || task.Meta.Res.Files[0].Size != int64(len(body)) {
t.Fatalf("final metadata must use actual size: %#v", task.Meta.Res)
}
})
}
}
}
func TestExtensionFileSizeYieldsToBlobContentLength(t *testing.T) {
for _, materialized := range []bool{false, true} {
for _, size := range []int64{3, 1 << 20} {
t.Run(fmt.Sprintf("materialized=%v/estimate=%d", materialized, size), func(t *testing.T) {
d := NewDownloader(&DownloaderConfig{Storage: NewMemStorage(), StorageDir: t.TempDir(), TempDir: t.TempDir()})
if err := d.Setup(); err != nil {
t.Fatal(err)
}
defer d.Clear()
e, _ := d.newExtensionEngine()
defer e.Close()
e.Runtime.Set("materialized", materialized)
result, err := e.RunString(`(()=>{
const payload=new Uint8Array([0,1,2,3,4,5,6,7,8,9]);
const source=materialized?new Blob([payload]):()=>new ReadableStream({start(c){c.enqueue(payload);c.close();}});
return __gopeed_blob_create_object_url(source,{size:10});
})()`)
if err != nil {
t.Fatal(err)
}
output := t.TempDir()
id, err := d.CreateDirect(&base.Request{URL: fmt.Sprint(result)}, &base.Options{Size: size, Path: output, Name: "output.bin"})
if err != nil {
t.Fatal(err)
}
waitForTaskTerminal(t, d, id, 5*time.Second)
body, err := os.ReadFile(filepath.Join(output, "output.bin"))
if err != nil || !bytes.Equal(body, []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}) {
t.Fatalf("unexpected body=%v err=%v", body, err)
}
task := d.GetTask(id)
if task.Meta.Res.Size != 10 || task.Meta.Res.Files[0].Size != 10 {
t.Fatalf("Blob Content-Length did not override estimate: %+v", task.Meta.Res)
}
})
}
}
}
func TestExtensionFileSizeHintWaitsForEOF(t *testing.T) {
for _, fail := range []bool{false, true} {
for _, size := range []int64{3, 1 << 20} {
t.Run(fmt.Sprintf("fail=%v/size=%d", fail, size), func(t *testing.T) {
d := NewDownloader(&DownloaderConfig{Storage: NewMemStorage(), StorageDir: t.TempDir(), TempDir: t.TempDir(), RefreshInterval: 50})
if err := d.Setup(); err != nil {
t.Fatal(err)
}
defer d.Clear()
e, _ := d.newExtensionEngine()
defer e.Close()
e.Runtime.Set("fail", fail)
result, err := e.RunString(`__gopeed_blob_create_object_url(()=>new ReadableStream({
start(c){c.enqueue(new Uint8Array([0,1,2,3,4,5,6,7,8,9]));globalThis.finish=()=>{if(fail)c.error(new Error('upstream failed'));else c.close();};}
}),{})`)
if err != nil {
t.Fatal(err)
}
output := t.TempDir()
id, err := d.CreateDirect(&base.Request{URL: fmt.Sprint(result)}, &base.Options{Size: size, Path: output, Name: "output.bin"})
if err != nil {
t.Fatal(err)
}
waitForTaskDownloadedBetween(t, d, id, 10, 10, 5*time.Second)
task := d.GetTask(id)
task.lock.Lock()
if task.Meta.Res.Size != size || d.taskStatus(task) != base.DownloadStatusRunning {
t.Errorf("hint must remain visible until EOF: size=%d status=%s", task.Meta.Res.Size, d.taskStatus(task))
}
task.lock.Unlock()
stat, err := os.Stat(filepath.Join(output, "output.bin"))
if err != nil || stat.Size() != 10 {
t.Fatalf("hint must not preallocate or truncate output: stat=%v err=%v", stat, err)
}
if _, err := e.RunString(`finish()`); err != nil {
t.Fatal(err)
}
if fail {
waitForTaskStatus(t, d, id, base.DownloadStatusError, 5*time.Second)
} else {
waitForTaskTerminal(t, d, id, 5*time.Second)
}
})
}
}
}
func TestExtensionFileSizeHintPauseContinue(t *testing.T) {
for _, size := range []int64{3, 1 << 20} {
t.Run(fmt.Sprint(size), func(t *testing.T) {
d := NewDownloader(&DownloaderConfig{Storage: NewMemStorage(), StorageDir: t.TempDir(), TempDir: t.TempDir(), RefreshInterval: 50})
if err := d.Setup(); err != nil {
t.Fatal(err)
}
defer d.Clear()
e, _ := d.newExtensionEngine()
defer e.Close()
result, err := e.RunString(`(()=>{
let opens=0;
return __gopeed_blob_create_object_url(()=>new ReadableStream({start(c){
if (++opens===1) c.enqueue(new Uint8Array([0,1,2,3,4,5,6,7,8,9]));
else {c.enqueue(new Uint8Array([5,6]));c.close();}
}}),{});
})()`)
if err != nil {
t.Fatal(err)
}
output := t.TempDir()
id, err := d.CreateDirect(&base.Request{URL: fmt.Sprint(result)}, &base.Options{Size: size, Path: output, Name: "output.bin"})
if err != nil {
t.Fatal(err)
}
waitForTaskDownloadedBetween(t, d, id, 10, 10, 5*time.Second)
filter := &TaskFilter{IDs: []string{id}}
if err := d.Pause(filter); err != nil {
t.Fatal(err)
}
if err := d.Continue(filter); err != nil {
t.Fatal(err)
}
waitForTaskTerminal(t, d, id, 5*time.Second)
body, err := os.ReadFile(filepath.Join(output, "output.bin"))
if err != nil {
t.Fatal(err)
}
task := d.GetTask(id)
task.lock.Lock()
defer task.lock.Unlock()
if !bytes.Equal(body, []byte{5, 6}) || task.Meta.Res.Size != 2 {
t.Fatalf("resumed stream kept an estimate or stale bytes: body=%v size=%d", body, task.Meta.Res.Size)
}
})
}
}
+140
View File
@@ -0,0 +1,140 @@
package download
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/GopeedLab/gopeed/pkg/base"
btProtocol "github.com/GopeedLab/gopeed/pkg/protocol/bt"
httpProtocol "github.com/GopeedLab/gopeed/pkg/protocol/http"
"github.com/GopeedLab/gopeed/pkg/util"
"github.com/dop251/goja"
)
// ExtensionTaskRequest exposes request operations without exposing the backing task.
// It always reads the current request so a retained handle sees subsequent updates.
type ExtensionTaskRequest struct {
*base.Request
task *Task
}
// SetUrl replaces the task request URL.
func (r *ExtensionTaskRequest) SetUrl(url string) { r.task.Meta.Req.URL = url }
// SetLabels replaces all labels on the request.
func (r *ExtensionTaskRequest) SetLabels(labels map[string]string) {
r.Labels = labels
}
// PutLabel sets a label on the request.
func (r *ExtensionTaskRequest) PutLabel(key, value string) {
if r.Labels == nil {
r.Labels = make(map[string]string)
}
r.Labels[key] = value
}
// DelLabel deletes a label from the request.
func (r *ExtensionTaskRequest) DelLabel(key string) {
delete(r.Labels, key)
}
// mutateTaskExtra converts into an independent protocol value before mutation.
// Other protocols are a no-op. Failed conversion or mutation must not change the live request.
func mutateTaskExtra[E any](task *Task, protocol string, mutate func(*E) error) error {
if task.Protocol != protocol {
return nil
}
if task.Meta == nil || task.Meta.Req == nil {
return fmt.Errorf("task request is missing")
}
var extra E
if err := util.MapToStruct(task.Meta.Req.Extra, &extra); err != nil {
return err
}
if err := mutate(&extra); err != nil {
return err
}
task.Meta.Req.Extra = &extra
return nil
}
func (t *ExtensionTaskRequest) SetMethod(method string) error {
return mutateTaskExtra(t.task, "http", func(extra *httpProtocol.ReqExtra) error { extra.Method = method; return nil })
}
func (t *ExtensionTaskRequest) SetBody(body string) error {
return mutateTaskExtra(t.task, "http", func(extra *httpProtocol.ReqExtra) error { extra.Body = body; return nil })
}
// decodeHeaders omits undefined properties and rejects non-string values.
func decodeHeaders(value *goja.Object) (map[string]string, error) {
if value == nil {
return nil, fmt.Errorf("headers must be an object")
}
data, err := value.MarshalJSON()
if err != nil {
return nil, err
}
var input map[string]*string
if err := json.Unmarshal(data, &input); err != nil {
return nil, err
}
if input == nil {
return nil, fmt.Errorf("headers must be an object")
}
headers := make(map[string]string, len(input))
seen := make(map[string]bool, len(input))
for name, value := range input {
if value == nil {
return nil, fmt.Errorf("header %q must be a string", name)
}
lower := strings.ToLower(name)
if seen[lower] {
return nil, fmt.Errorf("duplicate header name %q", name)
}
seen[lower] = true
headers[http.CanonicalHeaderKey(name)] = *value
}
return headers, nil
}
func (t *ExtensionTaskRequest) SetHeaders(headers *goja.Object) error {
return mutateTaskExtra(t.task, "http", func(extra *httpProtocol.ReqExtra) error {
values, err := decodeHeaders(headers)
if err != nil {
return err
}
extra.Header = values
return nil
})
}
func (t *ExtensionTaskRequest) PutHeader(name, value string) error {
return mutateTaskExtra(t.task, "http", func(extra *httpProtocol.ReqExtra) error {
if extra.Header == nil {
extra.Header = make(map[string]string)
}
deleteHeader(extra.Header, name)
extra.Header[http.CanonicalHeaderKey(name)] = value
return nil
})
}
func (t *ExtensionTaskRequest) DelHeader(name string) error {
return mutateTaskExtra(t.task, "http", func(extra *httpProtocol.ReqExtra) error { deleteHeader(extra.Header, name); return nil })
}
func deleteHeader(headers map[string]string, name string) {
for key := range headers {
if strings.EqualFold(key, name) {
delete(headers, key)
}
}
}
func (t *ExtensionTaskRequest) SetTrackers(trackers []string) error {
return mutateTaskExtra(t.task, "bt", func(extra *btProtocol.ReqExtra) error { extra.Trackers = append([]string{}, trackers...); return nil })
}
+60 -66
View File
@@ -14,10 +14,12 @@ import (
"testing"
"time"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/internal/logger"
"github.com/GopeedLab/gopeed/pkg/base"
gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error"
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
httpProtocol "github.com/GopeedLab/gopeed/pkg/protocol/http"
"github.com/dop251/goja"
)
@@ -30,7 +32,7 @@ func TestDownloader_InstallExtensionByFolder(t *testing.T) {
URL: "https://github.com/test",
Labels: map[string]string{"original": "true"},
}
rr, err := downloader.Resolve(req, nil)
rr, err := downloader.Resolve(req)
if err != nil {
t.Fatal(err)
}
@@ -38,13 +40,13 @@ func TestDownloader_InstallExtensionByFolder(t *testing.T) {
t.Fatal("resolve error")
}
if req.Labels["replaced"] != "true" || req.Labels["modified"] != "true" {
t.Fatalf("request label methods did not update labels: %#v", req.Labels)
t.Fatalf("declarative labels did not update: %#v", req.Labels)
}
if _, ok := req.Labels["original"]; ok {
t.Fatalf("setLabels did not replace existing labels: %#v", req.Labels)
t.Fatalf("label assignment did not replace existing labels: %#v", req.Labels)
}
if _, ok := req.Labels["removed"]; ok {
t.Fatalf("delLabel did not remove label: %#v", req.Labels)
t.Fatalf("declarative label deletion failed: %#v", req.Labels)
}
})
}
@@ -56,7 +58,7 @@ func TestDownloader_InstallExtensionByFolderDevMode(t *testing.T) {
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/test",
}, nil)
})
if err != nil {
t.Fatal(err)
}
@@ -74,7 +76,7 @@ func TestDownloader_Extension_BlobBlob(t *testing.T) {
rr, err := downloader.Resolve(&base.Request{
URL: "https://example.com/blob",
}, nil)
})
if err != nil {
t.Fatal(err)
}
@@ -128,7 +130,7 @@ func TestDownloader_Extension_BlobFunctionOpener(t *testing.T) {
rr, err := downloader.Resolve(&base.Request{
URL: "https://example.com/opener",
}, nil)
})
if err != nil {
t.Fatal(err)
}
@@ -164,7 +166,7 @@ func TestDownloader_Extension_BlobFunctionOpenerUnknownSize(t *testing.T) {
rr, err := downloader.Resolve(&base.Request{
URL: "https://example.com/opener-unknown",
}, nil)
})
if err != nil {
t.Fatal(err)
}
@@ -220,7 +222,7 @@ func TestDownloader_Extension_BlobSourceSizePropagatesToCreatedTask(t *testing.T
rr, err := downloader.Resolve(&base.Request{
URL: "https://example.com/opener",
}, nil)
})
if err != nil {
t.Fatal(err)
}
@@ -301,7 +303,7 @@ func TestDownloader_Extension_BlobFetchDrainedStreamReportsEarlyProgress(t *test
rr, err := downloader.Resolve(&base.Request{
URL: fmt.Sprintf("%s/single-fetch-stream?size=%d", server.URL, len(payload)),
}, nil)
})
if err != nil {
t.Fatal(err)
}
@@ -417,7 +419,7 @@ func TestDownloader_Extension_BlobRangeFetchDrainedStreamReportsEarlyProgress(t
rr, err := downloader.Resolve(&base.Request{
URL: fmt.Sprintf("%s/range-fetch-stream?size=%d", server.URL, len(payload)),
}, nil)
})
if err != nil {
t.Fatal(err)
}
@@ -492,7 +494,7 @@ func TestDownloader_Extension_BlobMultiplexFetchStreamsReportEarlyProgress(t *te
rr, err := downloader.Resolve(&base.Request{
URL: server.URL + "/multiplex-fetch-stream",
}, nil)
})
if err != nil {
t.Fatal(err)
}
@@ -548,7 +550,7 @@ func TestDownloader_Extension_BlobFunctionOpenerRangeResume(t *testing.T) {
rr, err := downloader.Resolve(&base.Request{
URL: "https://example.com/opener-range",
}, nil)
})
if err != nil {
t.Fatal(err)
}
@@ -681,7 +683,7 @@ func TestDownloader_Extension_BlobResumeAfterRestartViaOnError(t *testing.T) {
rr, err := downloader.Resolve(&base.Request{
URL: server.URL + "/restart-range",
}, nil)
})
if err != nil {
t.Fatal(err)
}
@@ -853,7 +855,7 @@ func TestDownloader_Extension_BlobPauseContinueKeepsSource(t *testing.T) {
rr, err := downloader.Resolve(&base.Request{
URL: tc.url,
}, nil)
})
if err != nil {
t.Fatal(err)
}
@@ -930,7 +932,7 @@ func TestDownloader_Extension_BlobUnknownSizePauseContinueKeepsSource(t *testing
rr, err := downloader.Resolve(&base.Request{
URL: "https://example.com/opener-unknown",
}, nil)
})
if err != nil {
t.Fatal(err)
}
@@ -1007,7 +1009,7 @@ func TestDownloader_Extension_BlobRecoverOnError(t *testing.T) {
path := path
t.Run(path, func(t *testing.T) {
rawURL := "https://example.com/" + path
rr, err := downloader.Resolve(&base.Request{URL: rawURL}, nil)
rr, err := downloader.Resolve(&base.Request{URL: rawURL})
if err != nil {
t.Fatal(err)
}
@@ -1068,54 +1070,15 @@ func TestDownloader_Extension_BlobRecoverOnError(t *testing.T) {
}
func TestDownloader_InstallExtensionByGit(t *testing.T) {
setupDownloader(func(downloader *Downloader) {
if _, err := downloader.InstallExtensionByGit("https://github.com/GopeedLab/gopeed-extension-samples#github-release-sample"); err != nil {
t.Fatal(err)
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/GopeedLab/gopeed/releases",
}, nil)
if err != nil {
t.Fatal(err)
}
if len(rr.Res.Files) == 1 {
t.Fatal("resolve error")
}
})
testInstallExtensionGitURL(t, "https://github.com/gopeed-test/repo#nested/extension")
}
func TestDownloader_InstallExtensionByGitSimple(t *testing.T) {
setupDownloader(func(downloader *Downloader) {
if _, err := downloader.InstallExtensionByGit("github.com/GopeedLab/gopeed-extension-samples#github-release-sample"); err != nil {
t.Fatal(err)
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/GopeedLab/gopeed/releases",
}, nil)
if err != nil {
t.Fatal(err)
}
if len(rr.Res.Files) == 1 {
t.Fatal("resolve error")
}
})
testInstallExtensionGitURL(t, "github.com/gopeed-test/repo#nested/extension")
}
func TestDownloader_InstallExtensionByGitFull(t *testing.T) {
setupDownloader(func(downloader *Downloader) {
if _, err := downloader.InstallExtensionByGit("https://github.com/GopeedLab/gopeed-extension-samples.git#github-release-sample"); err != nil {
t.Fatal(err)
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/GopeedLab/gopeed/releases",
}, nil)
if err != nil {
t.Fatal(err)
}
if len(rr.Res.Files) == 1 {
t.Fatal("resolve error")
}
})
testInstallExtensionGitURL(t, "https://github.com/gopeed-test/repo.git#nested/extension")
}
func TestDownloader_UpgradeExtension(t *testing.T) {
@@ -1178,7 +1141,7 @@ func TestDownloader_UpgradeExtension(t *testing.T) {
rr, err := downloader.Resolve(&base.Request{
URL: "https://test.com",
}, nil)
})
if err != nil {
t.Fatal(err)
}
@@ -1188,6 +1151,37 @@ func TestDownloader_UpgradeExtension(t *testing.T) {
})
}
func TestDownloader_Extension_OnStartExtraMutation(t *testing.T) {
d := NewDownloader(&DownloaderConfig{Storage: NewMemStorage(), StorageDir: t.TempDir()})
if err := d.Setup(); err != nil {
t.Fatal(err)
}
defer d.Clear()
if _, err := d.InstallExtensionByFolder("./testdata/extensions/on_start", false); err != nil {
t.Fatal(err)
}
for _, extra := range []any{nil, &httpProtocol.ReqExtra{
Method: "POST", Body: "old body", Header: map[string]string{"Old": "value"},
}} {
req := &base.Request{URL: "https://github.com/test", Extra: extra}
task := &Task{Protocol: "http", Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}}
d.triggerOnStart(task)
if err := base.ParseReqExtra[httpProtocol.ReqExtra](req); err != nil {
t.Fatal(err)
}
got, ok := req.Extra.(*httpProtocol.ReqExtra)
if !ok || got.Header["X-Gopeed-Test"] != "on-start" {
t.Fatalf("setHeaders did not update the original request: %#v", req.Extra)
}
if len(got.Header) != 1 {
t.Fatalf("headers not replaced: %#v", got)
}
if original, ok := extra.(*httpProtocol.ReqExtra); ok && (got.Method != original.Method || got.Body != original.Body) {
t.Fatalf("setHeaders changed unrelated fields: %#v", got)
}
}
}
func TestDownloader_Extension_OnStart(t *testing.T) {
downloadAndCheck := func(req *base.Request) {
setupDownloader(func(downloader *Downloader) {
@@ -1322,7 +1316,7 @@ func TestDownloader_Extension_Errors(t *testing.T) {
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/test",
}, nil)
})
if err != nil {
t.Fatal(err)
}
@@ -1337,7 +1331,7 @@ func TestDownloader_Extension_Errors(t *testing.T) {
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/test",
}, nil)
})
if err != nil {
t.Fatal(err)
}
@@ -1352,7 +1346,7 @@ func TestDownloader_Extension_Errors(t *testing.T) {
}
_, err := downloader.Resolve(&base.Request{
URL: "https://github.com/test",
}, nil)
})
if err == nil {
t.Fatalf("except error, but got nil")
}
@@ -1374,7 +1368,7 @@ func TestDownloader_Extension_Settings(t *testing.T) {
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/test",
}, nil)
})
if err != nil {
t.Fatal(err)
}
@@ -1395,7 +1389,7 @@ func TestDownloader_Extension_Settings(t *testing.T) {
})
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/test",
}, nil)
})
if err != nil {
t.Fatal(err)
}
@@ -1412,7 +1406,7 @@ func TestDownloader_ExtensionStorage(t *testing.T) {
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/test",
}, nil)
})
if err != nil {
t.Fatal(err)
}
@@ -24,7 +24,7 @@ func TestExtensionUnnamedResource(t *testing.T) {
if _, err = d.InstallExtensionByFolder(dir, true); err != nil {
t.Fatal(err)
}
res, err := d.Resolve(&base.Request{URL: "https://example.com/unnamed"}, nil)
res, err := d.Resolve(&base.Request{URL: "https://example.com/unnamed"})
if err != nil {
t.Fatal(err)
}
+151
View File
@@ -0,0 +1,151 @@
package download
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/GopeedLab/gopeed/internal/githubmirror"
"github.com/GopeedLab/gopeed/internal/httpclient"
)
func (d *Downloader) resourceClient(publicOnly bool) (*http.Client, error) {
cfg, err := d.GetConfig()
if err != nil {
return nil, err
}
options := httpclient.Options{
Client: httpclient.ClientOptions{Timeout: 30 * time.Second},
Transport: httpclient.TransportOptions{Proxy: cfg.Proxy.ToHandler(), TLSHandshakeTimeout: 10 * time.Second},
}
if publicOnly {
// A configured upstream proxy is trusted, but target URLs are still checked.
options.Client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("too many redirects")
}
return validatePublicURL(req.Context(), req.URL.String())
}
if cfg.Proxy == nil || !cfg.Proxy.Enable {
options.Transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
ips, err := publicIPs(ctx, host)
if err != nil {
return nil, err
}
var last error
for _, ip := range ips {
conn, err := (&net.Dialer{Timeout: 10 * time.Second}).DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
if err == nil {
return conn, nil
}
last = err
}
return nil, last
}
}
}
return httpclient.NewClient(options)
}
func publicIPs(ctx context.Context, host string) ([]net.IPAddr, error) {
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
if len(ips) == 0 {
return nil, errors.New("target has no addresses")
}
for _, ip := range ips {
if !ip.IP.IsGlobalUnicast() || ip.IP.IsPrivate() || ip.IP.IsLoopback() || ip.IP.IsLinkLocalUnicast() || ip.IP.IsUnspecified() {
return nil, errors.New("private or local resource target is not allowed")
}
}
return ips, nil
}
func validatePublicURL(ctx context.Context, raw string) error {
u, err := url.Parse(raw)
if err != nil || u == nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User != nil {
return errors.New("invalid resource URL")
}
_, err = publicIPs(ctx, u.Hostname())
return err
}
func (d *Downloader) fetchPublicResource(ctx context.Context, raw string, limit int64, validate func([]byte) error) ([]byte, error) {
return d.fetchResource(ctx, raw, limit, true, validate)
}
// fetchResource races complete, validated responses. A successful HEAD alone
// cannot win, and failed candidates never discard a usable official response.
func (d *Downloader) fetchResource(ctx context.Context, raw string, limit int64, publicOnly bool, validate func([]byte) error) ([]byte, error) {
cfg, err := d.GetConfig()
if err != nil {
return nil, err
}
client, err := d.resourceClient(publicOnly)
if err != nil {
return nil, err
}
defer client.CloseIdleConnections()
return githubmirror.Race(ctx, cfg.GithubMirror, raw,
githubmirror.Options[[]byte]{Kind: githubmirror.Resource, Timeout: 30 * time.Second},
func(ctx context.Context, candidate string) ([]byte, error) {
if publicOnly {
if err := validatePublicURL(ctx, candidate); err != nil {
return nil, err
}
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, candidate, nil)
if err != nil {
return nil, err
}
response, err := client.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("resource returned HTTP %d", response.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(response.Body, limit+1))
if err != nil {
return nil, err
}
if int64(len(body)) > limit {
return nil, errors.New("resource exceeds size limit")
}
if len(body) == 0 {
return nil, errors.New("empty resource")
}
if validate != nil {
if err := validate(body); err != nil {
return nil, err
}
}
return body, nil
},
)
}
// githubResourceCandidates accepts official GitHub HTTPS resource URLs only.
func githubResourceCandidates(config *githubmirror.Config, raw string) ([]string, error) {
u, err := url.Parse(raw)
if err != nil || u == nil || u.Scheme != "https" || u.User != nil || u.Port() != "" || u.Fragment != "" || strings.Contains(raw, "#") {
return nil, ErrInvalidMirrorURL
}
u.Host = strings.ToLower(u.Host)
switch u.Host {
case "github.com", "raw.githubusercontent.com":
default:
return nil, ErrInvalidMirrorURL
}
return githubmirror.Candidates(config, u.String(), githubmirror.Resource), nil
}
+1 -1
View File
@@ -40,7 +40,7 @@ func TestDownloader_HlsLiveSite(t *testing.T) {
if referer != "" {
req.Extra = map[string]any{"header": map[string]string{"Referer": referer}}
}
rr, err := downloader.Resolve(req, nil)
rr, err := downloader.Resolve(req)
if err != nil {
t.Fatalf("resolve: %v", err)
}
+1 -1
View File
@@ -89,7 +89,7 @@ func TestDownloader_HlsProtocolEndToEnd(t *testing.T) {
req := hlsResolveReferer()
req.URL = server.URL + "/master.m3u8"
rr, err := downloader.Resolve(req, nil)
rr, err := downloader.Resolve(req)
if err != nil {
t.Fatal(err)
}
+35
View File
@@ -0,0 +1,35 @@
package download
import (
"errors"
"github.com/GopeedLab/gopeed/pkg/base"
)
var (
ErrMirrorProviderNotFound = errors.New("mirror provider not found")
ErrInvalidMirrorURL = errors.New("invalid mirror resource URL")
)
// Each provider owns URL validation and candidate generation using its own
// configuration. Providers only generate URLs; they must not fetch resources.
type mirrorCandidateProvider func(config *base.DownloaderStoreConfig, raw string) ([]string, error)
// Add providers here without changing the public API or its client transport.
var mirrorCandidateProviders = map[string]mirrorCandidateProvider{
"github": func(config *base.DownloaderStoreConfig, raw string) ([]string, error) {
return githubResourceCandidates(config.GithubMirror, raw)
},
}
func (d *Downloader) GetMirrorCandidates(provider, raw string) ([]string, error) {
candidates, ok := mirrorCandidateProviders[provider]
if !ok {
return nil, ErrMirrorProviderNotFound
}
config, err := d.GetConfig()
if err != nil {
return nil, err
}
return candidates(config, raw)
}
+26
View File
@@ -21,8 +21,16 @@ import (
type ResolveResult struct {
ID string `json:"id"`
Res *base.Resource `json:"res"`
// SuggestedDownloadDir is the category directory the created task would be
// routed to. It is only set when auto categorize is enabled, the resource is
// a single file, a category matches and its directory passes the white list
// check. It is a hint for the ui, the create call decides the final path.
SuggestedDownloadDir string `json:"suggestedDownloadDir,omitempty"`
}
// Task represents a download unit.
// Options flow path:
// REST Request (model.CreateTask/ResolveTask) -> pkg/download Downloader -> Task.Meta.Opts (base.Options) -> Fetcher.Resolve/Start (e.g. internal/protocol/http/fetcher.go)
type Task struct {
ID string `json:"id"`
Protocol string `json:"protocol"`
@@ -32,6 +40,18 @@ type Task struct {
Progress *Progress `json:"progress"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// DoneAt is the time when the task completed; nil if it has never
// finished. A done task never leaves the done state, so the value is
// written once and kept.
DoneAt *time.Time `json:"doneAt"`
// NeedsPreparation marks a task that has not run its first preparation
// yet. The preparation decides the download directory of the task and
// resolves the duplicate name in it. It runs once, after the resource is
// resolved, and the flag is cleared afterwards, so a queued, paused or
// retried task is still prepared while a prepared task is never
// classified or renamed again. A task stored before this flag existed
// does not have it and keeps its directory and name.
NeedsPreparation bool `json:"needsPreparation,omitempty"`
producer production.Source
producerReceived int64
@@ -134,6 +154,12 @@ func (t *Task) MarshalJSON() ([]byte, error) {
func (t *Task) updateStatus(status base.Status) {
t.UpdatedAt = time.Now()
t.Status = status
// A done task never leaves the done state, so there is no branch to clear
// DoneAt on other transitions.
if status == base.DownloadStatusDone {
doneAt := time.Now()
t.DoneAt = &doneAt
}
}
func (t *Task) clone() *Task {
+172
View File
@@ -0,0 +1,172 @@
package download
import (
"encoding/json"
"errors"
"fmt"
"reflect"
"strings"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/pkg/util"
)
func (d *Downloader) protocolManager(name string) (fetcher.FetcherManager, error) {
for _, fm := range d.cfg.FetchManagers {
if fm.Name() == name {
return fm, nil
}
}
return nil, fmt.Errorf("%w: %s", ErrUnSupportedProtocol, name)
}
func jsonObject(v any) (map[string]json.RawMessage, error) {
raw, err := json.Marshal(v)
if err != nil {
return nil, err
}
var object map[string]json.RawMessage
if err = json.Unmarshal(raw, &object); err != nil {
return nil, err
}
if object == nil {
object = map[string]json.RawMessage{}
}
return object, nil
}
func normalizeProtocol(fm fetcher.FetcherManager, v any) (any, error) {
raw, err := json.Marshal(v)
if err != nil {
return nil, err
}
target := fm.DefaultConfig()
if target == nil {
return nil, errors.New("protocol does not expose configuration")
}
// Managers may return either typed pointers or value/map defaults.
valueDefault := reflect.TypeOf(target).Kind() != reflect.Pointer
if valueDefault {
pointer := reflect.New(reflect.TypeOf(target))
defaults, err := json.Marshal(target)
if err != nil {
return nil, err
}
if err = json.Unmarshal(defaults, pointer.Interface()); err != nil {
return nil, err
}
target = pointer.Interface()
}
if err = json.Unmarshal(raw, target); err != nil {
return nil, err
}
if normalizer, ok := fm.(fetcher.ConfigNormalizer); ok {
raw, err = json.Marshal(target)
if err != nil {
return nil, err
}
return normalizer.NormalizeConfig(raw)
}
if valueDefault {
return reflect.ValueOf(target).Elem().Interface(), nil
}
return target, nil
}
// preserveProtocolState ignores all client-supplied state, including null or
// malformed state values, before decoding/validating the writable config.
func preserveProtocolState(next, previous any) (any, error) {
object, err := jsonObject(next)
if err != nil {
return nil, err
}
before, err := jsonObject(previous)
if err != nil {
return nil, err
}
// Match encoding/json field folding as well as the canonical spelling.
for key := range object {
if strings.EqualFold(key, "state") {
delete(object, key)
}
}
if state, ok := before["state"]; ok {
object["state"] = state
}
return object, nil
}
func (d *Downloader) readProtocolConfig(name string) (any, error) {
if _, err := d.protocolManager(name); err != nil {
return nil, err
}
d.configLock.RLock()
defer d.configLock.RUnlock()
value := d.cfg.ProtocolConfig[name]
return *util.DeepClone(&value), nil
}
// updateProtocolState is the only internal write path for backend-owned state.
// It never publishes callback edits outside the reserved state section.
func (d *Downloader) updateProtocolState(name string, change func(config, state json.RawMessage) (any, error)) error {
fm, err := d.protocolManager(name)
if err != nil {
return err
}
d.configLock.Lock()
defer d.configLock.Unlock()
if d.closed.Load() {
return errors.New("downloader is closed")
}
next := util.DeepClone(d.cfg.DownloaderStoreConfig)
object, err := jsonObject(next.ProtocolConfig[name])
if err != nil {
return err
}
state, ok := object["state"]
if !ok {
return fmt.Errorf("protocol %s does not expose persistent state", name)
}
raw, err := json.Marshal(object)
if err != nil {
return err
}
value, err := change(raw, state)
if err != nil {
return err
}
// Keep the transaction's original config isolated from callback mutations.
object["state"], err = json.Marshal(value)
if err != nil {
return err
}
normalized, err := normalizeProtocol(fm, object)
if err != nil {
return err
}
next.ProtocolConfig[name] = normalized
if err = d.storage.Put(bucketConfig, "config", next); err != nil {
return err
}
d.cfg.DownloaderStoreConfig.ProtocolConfig = next.ProtocolConfig
return nil
}
// initProtocolConfigs loads the current schema and initializes missing defaults.
// This release intentionally does not import settings from older config layouts.
func (d *Downloader) initProtocolConfigs() error {
cfg := d.cfg.DownloaderStoreConfig
cfg.Init()
if err := cfg.GithubMirror.Validate(); err != nil {
return err
}
for _, fm := range d.cfg.FetchManagers {
value, ok := cfg.ProtocolConfig[fm.Name()]
if !ok {
value = map[string]any{}
}
normalized, err := normalizeProtocol(fm, value)
if err != nil {
return fmt.Errorf("%s config: %w", fm.Name(), err)
}
cfg.ProtocolConfig[fm.Name()] = normalized
}
return d.storage.Put(bucketConfig, "config", cfg)
}
+250
View File
@@ -0,0 +1,250 @@
package download
import (
"context"
"encoding/json"
"errors"
"fmt"
"regexp"
"sync"
"sync/atomic"
"time"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/pkg/util"
)
var ErrProtocolJobNotFound = errors.New("protocol job not found")
var jobNamePattern = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
type protocolJobState struct {
LastSuccess time.Time `json:"lastSuccess"`
Failures int `json:"failures"`
}
type protocolJobRun struct {
done chan struct{}
err error
source fetcher.TriggerSource
}
type registeredJob struct {
protocol string
spec fetcher.CronJob
mu sync.Mutex
state protocolJobState
active *protocolJobRun
next time.Time
wake chan struct{}
force atomic.Bool
}
type protocolScheduler struct {
d *Downloader
jobs map[string]*registeredJob
wg sync.WaitGroup
// gate prevents adding work while shutdown waits for it.
gate sync.Mutex
stopped bool
}
func (d *Downloader) setupProtocolJobs() error {
scheduler := &protocolScheduler{d: d, jobs: map[string]*registeredJob{}}
for _, fm := range d.cfg.FetchManagers {
provider, ok := fm.(fetcher.CronProvider)
if !ok {
continue
}
name := fm.Name()
host := fetcher.ProtocolHost{
Read: func(dst any) error {
value, err := d.readProtocolConfig(name)
if err != nil {
return err
}
return util.MapToStruct(value, dst)
},
UpdateState: func(change func(config, state json.RawMessage) (any, error)) error {
return d.updateProtocolState(name, change)
},
Fetch: func(ctx context.Context, raw string, limit int64, validate func([]byte) error) ([]byte, error) {
return d.fetchPublicResource(ctx, raw, limit, validate)
},
}
for _, spec := range provider.CronJobs(host) {
if !jobNamePattern.MatchString(spec.Name) || spec.Every <= 0 || spec.Timeout <= 0 || spec.Run == nil {
return fmt.Errorf("invalid protocol job: %s/%s", name, spec.Name)
}
key := name + "/" + spec.Name
if _, ok := scheduler.jobs[key]; ok {
return fmt.Errorf("duplicate protocol job: %s", key)
}
entry := &registeredJob{protocol: name, spec: spec, wake: make(chan struct{}, 1)}
if _, err := d.storage.Get(bucketProtocolJobs, key, &entry.state); err != nil {
return err
}
scheduler.jobs[key] = entry
}
}
d.protocolJobs = scheduler
return nil
}
func (s *protocolScheduler) start() {
for key, entry := range s.jobs {
s.wg.Add(1)
go s.loop(key, entry)
}
}
func (s *protocolScheduler) signal(entry *registeredJob) {
select {
case entry.wake <- struct{}{}:
default:
}
}
func (s *protocolScheduler) changed(protocol string) {
for _, entry := range s.jobs {
if protocol == "" || entry.protocol == protocol {
entry.force.Store(true)
s.signal(entry)
}
}
}
func (s *protocolScheduler) loop(key string, entry *registeredJob) {
defer s.wg.Done()
entry.mu.Lock()
if entry.next.IsZero() {
entry.next = entry.state.LastSuccess.Add(entry.spec.Every)
if entry.next.Before(time.Now()) {
entry.next = time.Now().Add(time.Second)
}
}
next := entry.next
entry.mu.Unlock()
timer := time.NewTimer(time.Until(next))
defer timer.Stop()
for {
select {
case <-s.d.backgroundCtx.Done():
return
case <-entry.wake:
entry.mu.Lock()
next = entry.next
entry.mu.Unlock()
if entry.force.Swap(false) {
next = time.Now()
}
case <-timer.C:
_ = s.trigger(s.d.backgroundCtx, key, fetcher.TriggerScheduled)
entry.mu.Lock()
next = entry.next
entry.mu.Unlock()
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(max(time.Until(next), time.Millisecond))
}
}
func (s *protocolScheduler) trigger(ctx context.Context, key string, source fetcher.TriggerSource) error {
entry, ok := s.jobs[key]
if !ok {
return fmt.Errorf("%w: %s", ErrProtocolJobNotFound, key)
}
for {
if err := ctx.Err(); err != nil {
return err
}
s.gate.Lock()
if s.stopped || s.d.closed.Load() {
s.gate.Unlock()
return errors.New("downloader is closed")
}
entry.mu.Lock()
run := entry.active
if run == nil {
run = &protocolJobRun{done: make(chan struct{}), source: source}
entry.active = run
s.wg.Add(1)
go s.execute(key, entry, run, source)
}
entry.mu.Unlock()
s.gate.Unlock()
select {
case <-ctx.Done():
return ctx.Err()
case <-run.done:
// A manual request must still run when the scheduled attempt it
// joined was skipped (for example, automatic refresh is disabled).
if source == fetcher.TriggerManual && run.source == fetcher.TriggerScheduled && errors.Is(run.err, fetcher.ErrJobSkipped) {
continue
}
return run.err
}
}
}
func (s *protocolScheduler) execute(key string, entry *registeredJob, run *protocolJobRun, source fetcher.TriggerSource) {
defer s.wg.Done()
ctx, cancel := context.WithTimeout(s.d.backgroundCtx, entry.spec.Timeout)
defer cancel()
err := func() (err error) {
defer func() {
if v := recover(); v != nil {
err = fmt.Errorf("protocol job panic: %v", v)
}
}()
return entry.spec.Run(ctx, source)
}()
if err == nil {
err = ctx.Err()
}
entry.mu.Lock()
switch {
case err == nil:
entry.state.LastSuccess = time.Now().UTC()
entry.state.Failures = 0
case errors.Is(err, fetcher.ErrJobSkipped), errors.Is(err, fetcher.ErrConfigChanged), errors.Is(err, context.Canceled):
default:
entry.state.Failures++
}
if storeErr := s.d.storage.Put(bucketProtocolJobs, key, entry.state); storeErr != nil {
err = errors.Join(err, storeErr)
}
switch {
case err == nil:
entry.next = entry.state.LastSuccess.Add(entry.spec.Every)
case errors.Is(err, fetcher.ErrJobSkipped):
entry.next = time.Now().Add(entry.spec.Every)
case errors.Is(err, fetcher.ErrConfigChanged):
entry.next = time.Now().Add(time.Second)
default:
retry := time.Minute * time.Duration(1<<min(max(entry.state.Failures-1, 0), 6))
entry.next = time.Now().Add(retry)
}
run.err = err
entry.active = nil
close(run.done)
entry.mu.Unlock()
if err != nil && !errors.Is(err, fetcher.ErrJobSkipped) && !errors.Is(err, context.Canceled) {
s.d.Logger.Warn().Err(err).Str("job", key).Str("source", string(source)).Msg("protocol job failed")
}
if source == fetcher.TriggerManual {
s.signal(entry)
}
}
func (s *protocolScheduler) close() {
s.gate.Lock()
s.stopped = true
s.gate.Unlock()
s.wg.Wait()
}
func (d *Downloader) TriggerProtocolJob(ctx context.Context, protocol, name string) error {
if d.protocolJobs == nil {
return ErrProtocolJobNotFound
}
err := d.protocolJobs.trigger(ctx, protocol+"/"+name, fetcher.TriggerManual)
if errors.Is(err, fetcher.ErrJobSkipped) {
return nil
}
return err
}
+89
View File
@@ -0,0 +1,89 @@
package download
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/GopeedLab/gopeed/internal/fetcher"
)
type jobWaitContext struct {
context.Context
waiting chan struct{}
once sync.Once
}
func (c *jobWaitContext) Done() <-chan struct{} {
c.once.Do(func() { close(c.waiting) })
return c.Context.Done()
}
func TestManualProtocolJobRunsAfterJoinedScheduledSkip(t *testing.T) {
entered := make(chan struct{})
release := make(chan struct{})
var manualCalls atomic.Int32
manager := &testJobManager{job: fetcher.CronJob{Name: "refresh", Every: time.Hour, Timeout: time.Second,
Run: func(ctx context.Context, source fetcher.TriggerSource) error {
if source == fetcher.TriggerManual {
manualCalls.Add(1)
return nil
}
close(entered)
select {
case <-release:
return fetcher.ErrJobSkipped
case <-ctx.Done():
return ctx.Err()
}
},
}}
d := testBackendDownloader(t, manager)
scheduled := make(chan error, 1)
go func() {
scheduled <- d.protocolJobs.trigger(context.Background(), "test/refresh", fetcher.TriggerScheduled)
}()
<-entered
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
waiter := &jobWaitContext{Context: ctx, waiting: make(chan struct{})}
manual := make(chan error, 1)
go func() { manual <- d.TriggerProtocolJob(waiter, "test", "refresh") }()
<-waiter.waiting
close(release)
if err := <-scheduled; !errors.Is(err, fetcher.ErrJobSkipped) {
t.Fatal(err)
}
if err := <-manual; err != nil {
t.Fatal(err)
}
if manualCalls.Load() != 1 {
t.Fatal("manual refresh was swallowed by skipped automatic work")
}
}
func TestManualProtocolJobFailureKeepsScheduledBackoff(t *testing.T) {
var calls atomic.Int32
failure := errors.New("offline")
manager := &testJobManager{job: fetcher.CronJob{Name: "refresh", Every: time.Hour, Timeout: time.Second,
Run: func(context.Context, fetcher.TriggerSource) error { calls.Add(1); return failure },
}}
d := testBackendDownloader(t, manager)
if err := d.TriggerProtocolJob(context.Background(), "test", "refresh"); !errors.Is(err, failure) {
t.Fatal(err)
}
entry := d.protocolJobs.jobs["test/refresh"]
entry.mu.Lock()
next := entry.next
entry.mu.Unlock()
if time.Until(next) < 59*time.Second {
t.Fatal("manual failure did not schedule retry backoff")
}
time.Sleep(50 * time.Millisecond)
if calls.Load() != 1 {
t.Fatalf("manual failure immediately retried %d times", calls.Load())
}
}
+340
View File
@@ -0,0 +1,340 @@
package download
import (
"encoding/json"
"errors"
"reflect"
"sync"
"testing"
"time"
"github.com/GopeedLab/gopeed/internal/fetcher"
btProtocol "github.com/GopeedLab/gopeed/internal/protocol/bt"
httpProtocol "github.com/GopeedLab/gopeed/internal/protocol/http"
"github.com/GopeedLab/gopeed/pkg/base"
)
// State tests initialize config without starting network clients or cron loops.
func protocolConfigDownloader(t *testing.T, storage Storage, managers ...fetcher.FetcherManager) *Downloader {
t.Helper()
if storage == nil {
storage = NewMemStorage()
}
d := NewDownloader(&DownloaderConfig{Storage: storage, StorageDir: t.TempDir(), FetchManagers: managers})
d.cfg.DownloaderStoreConfig = &base.DownloaderStoreConfig{}
if err := storage.Setup([]string{bucketConfig}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { d.cancelBackground(); _ = storage.Close() })
if err := d.initProtocolConfigs(); err != nil {
t.Fatal(err)
}
return d
}
func protocolObject(t *testing.T, d *Downloader, name string) map[string]any {
t.Helper()
cfg, err := d.GetConfig()
if err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(cfg.ProtocolConfig[name])
if err != nil {
t.Fatal(err)
}
var result map[string]any
if err := json.Unmarshal(raw, &result); err != nil {
t.Fatal(err)
}
return result
}
func configureBT(t *testing.T, d *Downloader, urls ...string) {
t.Helper()
cfg, _ := d.GetConfig()
cfg.ProtocolConfig["bt"] = map[string]any{"autoUpdateTrackers": false, "trackerSubscribeUrls": urls, "customTrackers": []string{"udp://custom.example:80/announce"}}
if err := d.PutConfig(cfg); err != nil {
t.Fatal(err)
}
}
func seedBTState(t *testing.T, d *Downloader) {
t.Helper()
if err := d.updateProtocolState("bt", func(_, _ json.RawMessage) (any, error) {
return map[string]any{
"trackerSources": map[string][]string{"https://example.com/list": {"udp://cached.example:80/announce"}},
"trackerSubscriptionStatuses": map[string]any{"https://example.com/list": map[string]any{"success": true, "trackerCount": 1, "lastFetchTime": time.Date(2026, 10, 19, 1, 2, 3, 0, time.UTC)}},
"lastTrackerUpdateTime": time.Date(2026, 10, 19, 1, 2, 3, 0, time.UTC),
}, nil
}); err != nil {
t.Fatal(err)
}
}
func TestFullConfigValidatesProtocolsAndPreservesExplicitValues(t *testing.T) {
d := protocolConfigDownloader(t, nil, &httpProtocol.FetcherManager{}, &btProtocol.FetcherManager{})
configureBT(t, d)
cfg, _ := d.GetConfig()
cfg.ProtocolConfig["http"] = map[string]any{"connections": 0, "useServerCtime": false}
if err := d.PutConfig(cfg); err != nil {
t.Fatal(err)
}
got := protocolObject(t, d, "http")
if got["connections"] != float64(0) || got["useServerCtime"] != false {
t.Fatal(got)
}
for _, invalid := range []any{map[string]any{"connections": "bad"}, map[string]any{"useServerCtime": "bad"}} {
cfg, _ := d.GetConfig()
cfg.ProtocolConfig["http"] = invalid
if err := d.PutConfig(cfg); err == nil {
t.Fatalf("invalid protocol config accepted: %v", invalid)
}
}
cfg, _ = d.GetConfig()
cfg.DownloadDir = "must-not-publish"
cfg.ProtocolConfig["bt"] = map[string]any{"listenPort": -1}
if err := d.PutConfig(cfg); err == nil {
t.Fatal("invalid BT config accepted")
}
saved, _ := d.GetConfig()
if saved.DownloadDir == "must-not-publish" {
t.Fatal("invalid full PUT partially published")
}
cfg, _ = d.GetConfig()
cfg.ProtocolConfig["unknown"] = map[string]any{"state": map[string]any{"injected": true}}
if err := d.PutConfig(cfg); err == nil {
t.Fatal("unregistered protocol accepted")
}
// Returned configs are detached snapshots.
snapshot, _ := d.GetConfig()
snapshot.ProtocolConfig["http"] = map[string]any{"connections": 999}
if !reflect.DeepEqual(protocolObject(t, d, "http"), got) {
t.Fatal("snapshot changed live config")
}
}
func TestProtocolConfigIgnoresUnknownFields(t *testing.T) {
for _, mode := range []string{"stored", "put"} {
t.Run(mode, func(t *testing.T) {
d := protocolConfigDownloader(t, nil, &btProtocol.FetcherManager{})
cfg, _ := d.GetConfig()
cfg.ProtocolConfig["bt"] = map[string]any{
"listenPort": 6881,
"autoUpdateTrackers": false,
"trackers": []string{"udp://obsolete.example:80/announce"},
"state": map[string]any{"obsolete": true},
}
if mode == "stored" {
if err := d.storage.Put(bucketConfig, "config", cfg); err != nil {
t.Fatal(err)
}
var restored base.DownloaderStoreConfig
if exists, err := d.storage.Get(bucketConfig, "config", &restored); err != nil || !exists {
t.Fatalf("load stored config: %v %v", exists, err)
}
d.cfg.DownloaderStoreConfig = &restored
if err := d.initProtocolConfigs(); err != nil {
t.Fatalf("load config containing removed fields: %v", err)
}
} else if err := d.PutConfig(cfg); err != nil {
t.Fatalf("save config containing unknown fields: %v", err)
}
actual := protocolObject(t, d, "bt")
if actual["listenPort"] != float64(6881) || actual["autoUpdateTrackers"] != false {
t.Fatalf("known fields changed: %v", actual)
}
if _, exists := actual["trackers"]; exists {
t.Fatal("removed field retained")
}
if len(actual["customTrackers"].([]any)) != 0 {
t.Fatal("removed trackers migrated into custom trackers")
}
if _, exists := actual["state"].(map[string]any)["obsolete"]; exists {
t.Fatal("unknown state field retained")
}
})
}
}
func TestBTStateSurvivesOmittedNullForgedAndStaleFullConfig(t *testing.T) {
for _, mode := range []string{"omitted", "null", "forged", "wrong-type", "stale", "omitted-protocol", "omitted-timestamp", "case-variant", "unicode-variant"} {
t.Run(mode, func(t *testing.T) {
d := protocolConfigDownloader(t, nil, &btProtocol.FetcherManager{})
configureBT(t, d, "https://example.com/list")
stale, _ := d.GetConfig()
seedBTState(t, d)
expected := protocolObject(t, d, "bt")["state"]
cfg, _ := d.GetConfig()
bt := protocolObject(t, d, "bt")
switch mode {
case "omitted":
delete(bt, "state")
case "null":
bt["state"] = nil
case "forged":
bt["state"] = map[string]any{"lastTrackerUpdateTime": "2000-01-01T00:00:00Z", "trackerSources": map[string]any{}, "unknown": true}
case "wrong-type":
bt["state"] = "ignore this before decoding"
case "stale":
cfg = stale
bt = map[string]any{"autoUpdateTrackers": false, "trackerSubscribeUrls": []string{"https://example.com/list"}, "customTrackers": []string{"udp://custom.example:80/announce"}, "state": map[string]any{}}
case "omitted-protocol":
delete(cfg.ProtocolConfig, "bt")
case "case-variant":
bt["STATE"] = "ignore this alias"
case "unicode-variant":
bt["ſtate"] = "ignore Unicode case folding"
case "omitted-timestamp":
bt["state"] = map[string]any{"trackerSources": map[string]any{}}
}
if mode != "omitted-protocol" {
bt["listenPort"] = 6881
cfg.ProtocolConfig["bt"] = bt
}
if err := d.PutConfig(cfg); err != nil {
t.Fatal(err)
}
actual := protocolObject(t, d, "bt")
if !reflect.DeepEqual(actual["state"], expected) {
t.Fatalf("backend state replaced: %v", actual)
}
if _, exists := actual["trackers"]; exists {
t.Fatal("derived tracker list persisted")
}
if mode != "omitted-protocol" && actual["listenPort"] != float64(6881) {
t.Fatal("writable config did not change")
}
})
}
}
func TestBTConfigRemovalPrunesOnlyRemovedState(t *testing.T) {
d := protocolConfigDownloader(t, nil, &btProtocol.FetcherManager{})
configureBT(t, d, "https://example.com/list")
seedBTState(t, d)
configureBT(t, d, "https://new.example/list")
state := protocolObject(t, d, "bt")["state"].(map[string]any)
if len(state["trackerSources"].(map[string]any)) != 0 || len(state["trackerSubscriptionStatuses"].(map[string]any)) != 0 {
t.Fatal("removed subscription retained state")
}
}
type counterState struct {
Count int `json:"count"`
Other int `json:"other"`
}
type counterConfig struct {
fetcher.ConfigState[counterState]
Writable int `json:"writable"`
}
type counterManager struct{ httpProtocol.FetcherManager }
func (*counterManager) Name() string { return "counter" }
func (*counterManager) DefaultConfig() any { return &counterConfig{Writable: 7} }
func counterHost(d *Downloader) fetcher.ProtocolHost {
return fetcher.ProtocolHost{UpdateState: func(change func(config, state json.RawMessage) (any, error)) error {
return d.updateProtocolState("counter", change)
}}
}
func TestProtocolUpdateStateCommitsOnlyStateAndUsesLatestConfig(t *testing.T) {
d := protocolConfigDownloader(t, nil, &counterManager{})
host := counterHost(d)
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if err := fetcher.UpdateState[counterConfig, counterState](host, func(cfg *counterConfig, state *counterState) error { cfg.Writable = 999; state.Count++; return nil }); err != nil {
t.Error(err)
}
}()
}
wg.Wait()
actual := protocolObject(t, d, "counter")
if actual["writable"] != float64(7) || actual["state"].(map[string]any)["count"] != float64(20) {
t.Fatal(actual)
}
// Rejecting a callback must not publish its partial mutation.
err := fetcher.UpdateState[counterConfig, counterState](host, func(_ *counterConfig, state *counterState) error { state.Count = 1000; return errors.New("reject") })
if err == nil || !reflect.DeepEqual(protocolObject(t, d, "counter"), actual) {
t.Fatal("failed callback published state")
}
if err := d.updateProtocolState("counter", func(_, _ json.RawMessage) (any, error) { return map[string]any{"count": "bad"}, nil }); err == nil {
t.Fatal("invalid state accepted")
}
}
func TestProtocolStateAndFullPutDoNotLoseConcurrentUpdates(t *testing.T) {
d := protocolConfigDownloader(t, nil, &counterManager{})
snapshot, _ := d.GetConfig()
snapshot.ProtocolConfig["counter"] = map[string]any{"writable": 21}
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(2)
go func() {
defer wg.Done()
if err := d.PutConfig(snapshot); err != nil {
t.Error(err)
}
}()
go func() {
defer wg.Done()
if err := fetcher.UpdateState[counterConfig, counterState](counterHost(d), func(cfg *counterConfig, state *counterState) error { state.Count++; return nil }); err != nil {
t.Error(err)
}
}()
}
wg.Wait()
actual := protocolObject(t, d, "counter")
if actual["writable"] != float64(21) || actual["state"].(map[string]any)["count"] != float64(20) {
t.Fatal(actual)
}
}
func TestBTSubscriptionStatusesSurviveStorageRestart(t *testing.T) {
dir := t.TempDir()
storage := NewBoltStorage(dir)
d := protocolConfigDownloader(t, storage, &btProtocol.FetcherManager{})
configureBT(t, d, "https://example.com/list")
seedBTState(t, d)
expected := protocolObject(t, d, "bt")["state"]
if err := storage.Close(); err != nil {
t.Fatal(err)
}
reopened := NewBoltStorage(dir)
if err := reopened.Setup([]string{bucketConfig}); err != nil {
t.Fatal(err)
}
defer reopened.Close()
var persisted base.DownloaderStoreConfig
if exists, err := reopened.Get(bucketConfig, "config", &persisted); err != nil || !exists {
t.Fatalf("reload persisted config: %v %v", exists, err)
}
raw, _ := json.Marshal(persisted.ProtocolConfig["bt"])
var cfg map[string]any
if err := json.Unmarshal(raw, &cfg); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(cfg["state"], expected) {
t.Fatalf("state lost after reopening storage: %+v", cfg)
}
}
type rejectedConfigStorage struct{ Storage }
func (*rejectedConfigStorage) Put(string, string, any) error {
return errors.New("storage unavailable")
}
func TestProtocolStateAndPutStorageFailureDoNotPublish(t *testing.T) {
d := protocolConfigDownloader(t, nil, &counterManager{})
before := protocolObject(t, d, "counter")
d.storage = &rejectedConfigStorage{Storage: d.storage}
err := fetcher.UpdateState[counterConfig, counterState](counterHost(d), func(_ *counterConfig, state *counterState) error { state.Count = 99; return nil })
if err == nil || !reflect.DeepEqual(protocolObject(t, d, "counter"), before) {
t.Fatal("failed state persistence published a change")
}
cfg, _ := d.GetConfig()
cfg.ProtocolConfig["counter"] = map[string]any{"writable": 99}
if err := d.PutConfig(cfg); err == nil || !reflect.DeepEqual(protocolObject(t, d, "counter"), before) {
t.Fatal("failed config persistence published a change")
}
}
+12 -9
View File
@@ -191,13 +191,16 @@ func ensureScriptExecutable(t *testing.T, scriptPath string) {
}
func setupScriptTest(t *testing.T, fn func(downloader *Downloader)) {
defaultDownloader.Setup()
defaultDownloader.cfg.StorageDir = ".test_storage"
defaultDownloader.cfg.DownloadDir = ".test_download"
defer func() {
defaultDownloader.Clear()
os.RemoveAll(defaultDownloader.cfg.StorageDir)
os.RemoveAll(defaultDownloader.cfg.DownloadDir)
}()
fn(defaultDownloader)
t.Helper()
// Each test owns its runtime; a closed Downloader cannot be reused.
d := NewDownloader(&DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir()})
if err := d.Setup(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := d.Close(); err != nil {
t.Error(err)
}
})
fn(d)
}
+4 -4
View File
@@ -18,7 +18,7 @@ func TestCreateResolvedFileSelection(t *testing.T) {
}{
{"partial", []int{2, 0}, true, []int{2, 0}, 40},
{"default all", nil, true, []int{0, 1, 2}, 60},
{"legacy retains resolve options", nil, false, []int{1}, 20},
{"default creation options", nil, false, []int{0, 1, 2}, 60},
} {
t.Run(tc.name, func(t *testing.T) {
manager := &generationTestManager{}
@@ -28,8 +28,8 @@ func TestCreateResolvedFileSelection(t *testing.T) {
}
defer d.Clear()
d.cfg.MaxRunning = 0
original := &base.Options{Path: t.TempDir(), SelectFiles: []int{1}}
rr, err := d.Resolve(&base.Request{URL: "generation://selection"}, original)
original := &base.Options{Path: t.TempDir()}
rr, err := d.Resolve(&base.Request{URL: "generation://selection"})
if err != nil {
t.Fatal(err)
}
@@ -40,7 +40,7 @@ func TestCreateResolvedFileSelection(t *testing.T) {
if tc.submit {
id, err = d.CreateWithOptions(rr.ID, &base.Options{Path: original.Path, SelectFiles: tc.selection})
} else {
id, err = d.Create(rr.ID)
id, err = d.CreateWithOptions(rr.ID, original)
}
if err != nil {
t.Fatal(err)
+59
View File
@@ -0,0 +1,59 @@
{
"normalizeExtensions": [
{
"input": [
"exe, msi",
" ZIP ",
".tar.gz",
"",
"exe"
],
"expected": [
"exe",
"msi",
"zip",
"tar.gz"
]
},
{
"input": [],
"expected": []
},
{
"input": [
" "
],
"expected": []
},
{
"input": [
"video/mp4"
],
"expected": [
"video/mp4"
]
},
{
"input": [
".exe",
"..msi"
],
"expected": [
"exe",
"msi"
]
},
{
"input": [
"EXE",
"ſ",
"Ä"
],
"expected": [
"exe",
"ſ",
"Ä"
]
}
]
}
+6 -3
View File
@@ -1,7 +1,10 @@
gopeed.events.onResolve(async function (ctx) {
await ctx.req.setLabels({ replaced: "true", removed: "true" });
await ctx.req.putLabel("modified", "true");
await ctx.req.delLabel("removed");
for (const method of ['setUrl', 'setLabels', 'putLabel', 'delLabel', 'setMethod', 'putHeader', 'setTrackers']) {
if (typeof ctx.req[method] !== 'undefined') throw new Error(`onResolve exposes ${method}`);
}
ctx.req.labels = { replaced: "true", removed: "true" };
ctx.req.labels.modified = "true";
delete ctx.req.labels.removed;
ctx.res = {
name: "test",
files: Array(2).fill(true).map((_, i) => ({
@@ -132,7 +132,7 @@ gopeed.events.onError(async function (ctx) {
return;
}
await ctx.task.setUrl(await createPayloadURL(source, false));
await ctx.task.meta.req.setUrl(await createPayloadURL(source, false));
await req.putLabel('started', 'true');
await req.putLabel('rebuilt', 'true');
await ctx.task.continue();
+1 -1
View File
@@ -41,6 +41,6 @@ gopeed.events.onError(async function (ctx) {
}
await req.putLabel("recovered", "true");
await ctx.task.setUrl(await gopeed.runtime.blob.createObjectURL(new Blob(["ok\n"], { type: "text/plain" })));
await ctx.task.meta.req.setUrl(await gopeed.runtime.blob.createObjectURL(new Blob(["ok\n"], { type: "text/plain" })));
await ctx.task.continue();
});
+1 -1
View File
@@ -46,7 +46,7 @@ gopeed.events.onError(async function (ctx) {
}
try {
await ctx.task.setUrl(await createRangeBlobUrl(req.rawUrl));
await ctx.task.meta.req.setUrl(await createRangeBlobUrl(req.rawUrl));
await req.putLabel('started', 'true');
await req.putLabel('rebuilt', 'true');
await ctx.task.continue();
+1 -1
View File
@@ -1,6 +1,6 @@
gopeed.events.onError(async function (ctx) {
gopeed.logger.info("url", ctx.task.meta.req.url);
gopeed.logger.info("error", ctx.error);
await ctx.task.setUrl("https://github.com");
await ctx.task.meta.req.setUrl("https://github.com");
await ctx.task.continue();
});
+2 -1
View File
@@ -1,5 +1,6 @@
gopeed.events.onStart(async function (ctx) {
gopeed.logger.info("url", ctx.task.meta.req.url);
await ctx.task.setUrl("https://github.com");
await ctx.task.meta.req.setUrl("https://github.com");
await ctx.task.meta.req.setHeaders({ "X-Gopeed-Test": "on-start" });
await ctx.task.meta.req.putLabel('modified', 'true');
});
+1
View File
@@ -21,6 +21,7 @@ type WebhookEvent string
const (
WebhookEventDownloadDone WebhookEvent = "DOWNLOAD_DONE"
WebhookEventDownloadError WebhookEvent = "DOWNLOAD_ERROR"
WebhookEventDownloadPause WebhookEvent = "DOWNLOAD_PAUSE"
)
// WebhookData is the data sent to webhook URLs
+55 -10
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
@@ -814,14 +813,60 @@ func TestWebhook_TestWebhookUrl_VerifyTestPayload(t *testing.T) {
})
}
func TestWebhook_TriggerOnPause(t *testing.T) {
receivedData := make(chan *WebhookData, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var data WebhookData
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
t.Errorf("Failed to decode data: %v", err)
return
}
receivedData <- &data
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
setupWebhookTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Webhook = &base.WebhookConfig{
Enable: true,
URLs: []string{server.URL},
}
downloader.PutConfig(cfg)
task := NewTask()
task.Protocol = "http"
task.Meta = &mockFetcherMeta
downloader.triggerWebhooks(WebhookEventDownloadPause, task, nil)
select {
case data := <-receivedData:
if data.Event != WebhookEventDownloadPause {
t.Errorf("Expected event 'DOWNLOAD_PAUSE', got '%s'", data.Event)
}
if data.Payload == nil || data.Payload.Task == nil {
t.Error("Expected payload.task to be present")
} else if data.Payload.Task.ID != task.ID {
t.Errorf("Expected task ID '%s', got '%s'", task.ID, data.Payload.Task.ID)
}
case <-time.After(2 * time.Second):
t.Error("Timeout waiting for webhook")
}
})
}
func setupWebhookTest(t *testing.T, fn func(downloader *Downloader)) {
defaultDownloader.Setup()
defaultDownloader.cfg.StorageDir = ".test_storage"
defaultDownloader.cfg.DownloadDir = ".test_download"
defer func() {
defaultDownloader.Clear()
os.RemoveAll(defaultDownloader.cfg.StorageDir)
os.RemoveAll(defaultDownloader.cfg.DownloadDir)
}()
fn(defaultDownloader)
t.Helper()
// Each test owns its runtime; a closed Downloader cannot be reused.
d := NewDownloader(&DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir()})
if err := d.Setup(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := d.Close(); err != nil {
t.Error(err)
}
})
fn(d)
}
+88 -20
View File
@@ -12,6 +12,7 @@ import (
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download"
phttp "github.com/GopeedLab/gopeed/pkg/protocol/http"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@@ -65,12 +66,13 @@ type btRequestExtra struct {
}
type httpTaskExtra struct {
Connections int `json:"connections,omitempty" jsonschema:"Number of HTTP download connections"`
AutoTorrent *bool `json:"autoTorrent,omitempty" jsonschema:"Automatically create a BitTorrent task after downloading a torrent file"`
DeleteTorrentAfterDownload *bool `json:"deleteTorrentAfterDownload,omitempty" jsonschema:"Delete the torrent file after automatically creating its BitTorrent task"`
AutoExtract *bool `json:"autoExtract,omitempty" jsonschema:"Automatically extract the downloaded archive"`
ArchivePassword string `json:"archivePassword,omitempty" jsonschema:"Password for an encrypted archive"`
DeleteAfterExtract bool `json:"deleteAfterExtract,omitempty" jsonschema:"Delete archive files after successful extraction"`
Connections int `json:"connections,omitempty" jsonschema:"Number of HTTP download connections"`
AutoTorrent *bool `json:"autoTorrent,omitempty" jsonschema:"Automatically create a BitTorrent task after downloading a torrent file"`
DeleteTorrentAfterDownload *bool `json:"deleteTorrentAfterDownload,omitempty" jsonschema:"Delete the torrent file after automatically creating its BitTorrent task"`
AutoExtract *bool `json:"autoExtract,omitempty" jsonschema:"Automatically extract the downloaded archive"`
ArchivePassword string `json:"archivePassword,omitempty" jsonschema:"Password for an encrypted archive"`
DeleteAfterExtract bool `json:"deleteAfterExtract,omitempty" jsonschema:"Delete archive files after successful extraction"`
Checksum *checksumOption `json:"checksum,omitempty" jsonschema:"Optional checksum verification settings"`
}
// downloadRequest is the MCP-facing form of base.Request. The REST model uses
@@ -129,14 +131,20 @@ func isBitTorrentRequest(rawURL string) bool {
strings.HasPrefix(upperURL, "DATA:APPLICATION/X-BITTORRENT;BASE64,")
}
type checksumOption struct {
Algorithm string `json:"algorithm" jsonschema:"Checksum algorithm: md5, sha1, or sha256"`
Expected string `json:"expected" jsonschema:"Expected hex-encoded checksum hash"`
}
// downloadOptions is the MCP-facing form of base.Options. Only HTTP currently
// defines protocol-specific task options, so Extra has one concrete schema.
type downloadOptions struct {
Name string `json:"name,omitempty" jsonschema:"Optional output file or directory name"`
Path string `json:"path,omitempty" jsonschema:"Optional download directory"`
AsDefaultPath bool `json:"asDefaultPath,omitempty" jsonschema:"Save path as the default download directory after task creation"`
SelectFiles []int `json:"selectFiles,omitempty" jsonschema:"Optional zero-based file indexes to download from a multi-file resource"`
Extra *httpTaskExtra `json:"extra,omitempty" jsonschema:"Optional HTTP task settings"`
Name string `json:"name,omitempty" jsonschema:"Optional output file or directory name"`
Path string `json:"path,omitempty" jsonschema:"Optional base download directory"`
AsDefaultPath bool `json:"asDefaultPath,omitempty" jsonschema:"Save path as the default download directory after task creation"`
SkipAutoCategorize bool `json:"skipAutoCategorize,omitempty" jsonschema:"Keep the base download directory instead of routing the task to its category directory"`
SelectFiles []int `json:"selectFiles,omitempty" jsonschema:"Optional zero-based file indexes to download from a multi-file resource"`
Extra *httpTaskExtra `json:"extra,omitempty" jsonschema:"Optional HTTP task settings"`
}
func requestExtraSchema() *jsonschema.Schema {
@@ -186,23 +194,45 @@ func (o *downloadOptions) baseOptions() *base.Options {
if o == nil {
return nil
}
var extra any
if o.Extra != nil {
var checksum *phttp.ChecksumOption
if o.Extra.Checksum != nil {
checksum = &phttp.ChecksumOption{
Algorithm: o.Extra.Checksum.Algorithm,
Expected: o.Extra.Checksum.Expected,
}
}
extra = &phttp.OptsExtra{
Connections: o.Extra.Connections,
AutoTorrent: o.Extra.AutoTorrent,
DeleteTorrentAfterDownload: o.Extra.DeleteTorrentAfterDownload,
AutoExtract: o.Extra.AutoExtract,
ArchivePassword: o.Extra.ArchivePassword,
DeleteAfterExtract: o.Extra.DeleteAfterExtract,
Checksum: checksum,
}
}
return &base.Options{
Name: o.Name,
Path: o.Path,
AsDefaultPath: o.AsDefaultPath,
SelectFiles: o.SelectFiles,
Extra: o.Extra,
Name: o.Name,
Path: o.Path,
AsDefaultPath: o.AsDefaultPath,
SkipAutoCategorize: o.SkipAutoCategorize,
SelectFiles: o.SelectFiles,
Extra: extra,
}
}
type resolveTaskInput struct {
Req *downloadRequest `json:"req" jsonschema:"Download request to resolve"`
Opts *downloadOptions `json:"opts,omitempty" jsonschema:"Optional download settings used while resolving"`
Req *downloadRequest `json:"req" jsonschema:"Download request to resolve"`
}
type resolveTaskOutput struct {
ID string `json:"id,omitempty"`
Res *base.Resource `json:"res"`
// SuggestedDownloadDir is the category directory the created task would be
// routed to, empty when auto categorize does not apply.
SuggestedDownloadDir string `json:"suggestedDownloadDir,omitempty"`
}
type createTaskInput struct {
@@ -270,6 +300,13 @@ type getTaskStatsOutput struct {
Stats any `json:"stats"`
}
type getDownloadSpeedInput struct{}
type getDownloadSpeedOutput struct {
TotalSpeed int64 `json:"totalSpeed"`
ActiveTaskCount int `json:"activeTaskCount"`
}
type taskActionOutput struct {
ID string `json:"id"`
Success bool `json:"success"`
@@ -295,11 +332,15 @@ func registerTools(server *mcp.Server, downloader *download.Downloader) {
if err != nil {
return nil, nil, err
}
result, err := downloader.Resolve(req, input.Opts.baseOptions())
result, err := downloader.Resolve(req)
if err != nil {
return nil, nil, err
}
return nil, &resolveTaskOutput{ID: result.ID, Res: result.Res}, nil
return nil, &resolveTaskOutput{
ID: result.ID,
Res: result.Res,
SuggestedDownloadDir: result.SuggestedDownloadDir,
}, nil
})
mcp.AddTool(server, &mcp.Tool{
@@ -416,6 +457,33 @@ func registerTools(server *mcp.Server, downloader *download.Downloader) {
return nil, &getTaskStatsOutput{Stats: stats}, nil
})
mcp.AddTool(server, &mcp.Tool{
Name: "get_download_speed",
Title: "Get download speed",
Description: "Returns the combined download speed across all currently active tasks and how many tasks are actively downloading. Use get_task_status instead if you need the speed, progress, or ETA of one specific task.",
Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: &closedWorld},
}, func(_ context.Context, _ *mcp.CallToolRequest, _ *getDownloadSpeedInput) (*mcp.CallToolResult, *getDownloadSpeedOutput, error) {
allTasks := downloader.GetTasks()
var totalSpeed int64
activeCount := 0
for _, task := range allTasks {
status, err := downloader.RuntimeStatus(task.ID)
if err != nil {
continue
}
if status.Status == base.DownloadStatusRunning {
activeCount++
totalSpeed += status.Speed
}
}
return nil, &getDownloadSpeedOutput{
TotalSpeed: totalSpeed,
ActiveTaskCount: activeCount,
}, nil
})
registerTaskAction(server, downloader, "pause_task", "Pause task", "Pause one Gopeed task.", &nondestructive, &closedWorld, downloader.Pause)
registerTaskAction(server, downloader, "continue_task", "Continue task", "Continue a paused or failed Gopeed task.", &nondestructive, &openWorld, downloader.Continue)
+23
View File
@@ -64,6 +64,7 @@ func TestStreamableHTTPTools(t *testing.T) {
"continue_task",
"create_task",
"delete_task",
"get_download_speed",
"get_task",
"get_task_stats",
"get_task_status",
@@ -100,10 +101,32 @@ func TestStreamableHTTPTools(t *testing.T) {
if reqExtra["type"] != "object" || !ok || len(branches) != 2 {
t.Fatalf("%s req.extra schema = %#v, want an object with two oneOf branches", tool.Name, reqExtra)
}
if tool.Name == "resolve_task" {
properties := schema["properties"].(map[string]any)
if _, exists := properties["opts"]; exists || len(properties) != 1 {
t.Fatalf("resolve_task must expose only req: %s", inputSchema)
}
continue
}
optsExtra := nestedSchemaProperty(t, schema, "opts", "extra")
if !schemaIncludesType(optsExtra, "object") || optsExtra["additionalProperties"] != false {
t.Fatalf("%s opts.extra schema = %#v, want concrete HTTP task settings", tool.Name, optsExtra)
}
opts := nestedSchemaProperty(t, schema, "opts")
optsProperties, ok := opts["properties"].(map[string]any)
if !ok {
t.Fatalf("%s opts schema has no properties: %s", tool.Name, inputSchema)
}
if _, exists := optsProperties["skipAutoCategorize"]; !exists {
t.Fatalf("%s opts schema is missing skipAutoCategorize: %s", tool.Name, inputSchema)
}
}
resolveOutput, err := json.Marshal(resolveTaskTool.OutputSchema)
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(resolveOutput, []byte(`"suggestedDownloadDir"`)) {
t.Fatalf("resolve_task output schema is missing suggestedDownloadDir: %s", resolveOutput)
}
outputSchema, err := json.Marshal(getTaskTool.OutputSchema)
if err != nil {
+103
View File
@@ -0,0 +1,103 @@
package mcpserver
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func TestGetDownloadSpeedTool(t *testing.T) {
// Serve a test payload that takes a little bit of time or streams bytes
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", "1048576")
w.WriteHeader(http.StatusOK)
chunk := make([]byte, 8192)
for i := 0; i < 128; i++ {
_, _ = w.Write(chunk)
time.Sleep(10 * time.Millisecond)
}
}))
defer testServer.Close()
downloader := download.NewDownloader(&download.DownloaderConfig{
Storage: download.NewMemStorage(),
StorageDir: t.TempDir(),
})
if err := downloader.Setup(); err != nil {
t.Fatal(err)
}
defer downloader.Close()
httpServer := httptest.NewServer(NewHandler(downloader))
defer httpServer.Close()
client := mcp.NewClient(&mcp.Implementation{Name: "gopeed-test", Version: "1.0.0"}, nil)
session, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{Endpoint: httpServer.URL}, nil)
if err != nil {
t.Fatal(err)
}
defer session.Close()
// 1. Zero tasks test (returns 0 totalSpeed and 0 activeTaskCount)
result, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: "get_download_speed",
Arguments: map[string]any{},
})
if err != nil {
t.Fatal(err)
}
if result.IsError {
t.Fatalf("get_download_speed returned error: %+v", result.Content)
}
structured, ok := result.StructuredContent.(map[string]any)
if !ok {
t.Fatalf("structured content type = %T", result.StructuredContent)
}
if activeCount, ok := structured["activeTaskCount"].(float64); !ok || activeCount != 0 {
t.Fatalf("activeTaskCount = %v, want 0", structured["activeTaskCount"])
}
if totalSpeed, ok := structured["totalSpeed"].(float64); !ok || totalSpeed != 0 {
t.Fatalf("totalSpeed = %v, want 0", structured["totalSpeed"])
}
// 2. Start a real download task
taskID, err := downloader.CreateDirect(&base.Request{
URL: testServer.URL,
}, &base.Options{
Path: t.TempDir(),
})
if err != nil {
t.Fatal(err)
}
defer func() {
_ = downloader.Delete(&download.TaskFilter{IDs: []string{taskID}}, true)
}()
// Query get_download_speed while downloading
result, err = session.CallTool(context.Background(), &mcp.CallToolParams{
Name: "get_download_speed",
Arguments: map[string]any{},
})
if err != nil {
t.Fatal(err)
}
if result.IsError {
t.Fatalf("get_download_speed returned error: %+v", result.Content)
}
structured, ok = result.StructuredContent.(map[string]any)
if !ok {
t.Fatalf("structured content type = %T", result.StructuredContent)
}
if _, ok := structured["activeTaskCount"].(float64); !ok {
t.Fatalf("activeTaskCount missing or wrong type: %v", structured["activeTaskCount"])
}
if _, ok := structured["totalSpeed"].(float64); !ok {
t.Fatalf("totalSpeed missing or wrong type: %v", structured["totalSpeed"])
}
}
+74
View File
@@ -1,11 +1,83 @@
package http
import (
"encoding/hex"
"fmt"
"strings"
)
type ReqExtra struct {
Method string `json:"method"`
Header map[string]string `json:"header"`
Body string `json:"body"`
}
// ChecksumOption specifies an expected checksum to verify against
// the completed download. Only supported for HTTP downloads.
type ChecksumOption struct {
Algorithm string `json:"algorithm"` // "md5" | "sha1" | "sha256"
Expected string `json:"expected"` // hex-encoded expected hash
}
// IsEmpty returns true when chk is nil, or when both Algorithm and Expected
// are blank after trimming whitespace.
func (chk *ChecksumOption) IsEmpty() bool {
if chk == nil {
return true
}
return strings.TrimSpace(chk.Algorithm) == "" && strings.TrimSpace(chk.Expected) == ""
}
// NormalizedAlgorithm returns the canonical lower-cased algorithm ("md5", "sha1", "sha256")
// and true if recognized, or ("", false) if unsupported.
func (chk *ChecksumOption) NormalizedAlgorithm() (string, bool) {
if chk == nil {
return "", false
}
switch strings.ToLower(strings.TrimSpace(chk.Algorithm)) {
case "md5":
return "md5", true
case "sha1", "sha-1":
return "sha1", true
case "sha256", "sha-256":
return "sha256", true
default:
return "", false
}
}
// Validate checks whether the checksum option is valid.
// Empty options return nil (no verification).
func (chk *ChecksumOption) Validate() error {
if chk.IsEmpty() {
return nil
}
algo, ok := chk.NormalizedAlgorithm()
if !ok {
return fmt.Errorf("unsupported checksum algorithm: %s", chk.Algorithm)
}
expected := strings.TrimSpace(chk.Expected)
if expected == "" {
return fmt.Errorf("checksum expected hash is required when algorithm is set")
}
var expectedLen int
switch algo {
case "md5":
expectedLen = 32
case "sha1":
expectedLen = 40
case "sha256":
expectedLen = 64
}
if len(expected) != expectedLen {
return fmt.Errorf("invalid checksum hash length for %s: expected %d characters, got %d", algo, expectedLen, len(expected))
}
if _, err := hex.DecodeString(expected); err != nil {
return fmt.Errorf("invalid checksum hash for %s: expected valid hexadecimal string", algo)
}
return nil
}
type OptsExtra struct {
Connections int `json:"connections"`
// AutoTorrent when task download complete, and it is a .torrent file, it will be auto create a new task for the torrent file
@@ -21,6 +93,8 @@ type OptsExtra struct {
ArchivePassword string `json:"archivePassword"`
// DeleteAfterExtract when true, deletes the archive file after successful extraction
DeleteAfterExtract bool `json:"deleteAfterExtract"`
// Checksum verifies the completed file against an expected hash after download
Checksum *ChecksumOption `json:"checksum,omitempty"`
}
// Stats for download
+162
View File
@@ -0,0 +1,162 @@
package http
import (
"strings"
"testing"
)
func TestOptsExtra_Checksum(t *testing.T) {
extra := &OptsExtra{
Checksum: &ChecksumOption{
Algorithm: "sha256",
Expected: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
},
}
if extra.Checksum.Algorithm != "sha256" || extra.Checksum.Expected != "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" {
t.Fatalf("unexpected checksum: %+v", extra.Checksum)
}
}
func TestChecksumOption_Validate(t *testing.T) {
validMd5 := "0123456789abcdef0123456789abcdef"
validSha1 := "0123456789abcdef0123456789abcdef01234567"
validSha256 := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
tests := []struct {
name string
option *ChecksumOption
wantErr bool
errSubstr string
}{
{
name: "nil option",
option: nil,
wantErr: false,
},
{
name: "empty option (empty strings)",
option: &ChecksumOption{Algorithm: "", Expected: ""},
wantErr: false,
},
{
name: "empty option (whitespace strings)",
option: &ChecksumOption{Algorithm: " ", Expected: " "},
wantErr: false,
},
{
name: "valid md5 lowercase",
option: &ChecksumOption{Algorithm: "md5", Expected: validMd5},
wantErr: false,
},
{
name: "valid md5 uppercase",
option: &ChecksumOption{Algorithm: "MD5", Expected: strings.ToUpper(validMd5)},
wantErr: false,
},
{
name: "valid sha1 lowercase",
option: &ChecksumOption{Algorithm: "sha1", Expected: validSha1},
wantErr: false,
},
{
name: "valid sha-1 alias",
option: &ChecksumOption{Algorithm: "SHA-1", Expected: strings.ToUpper(validSha1)},
wantErr: false,
},
{
name: "valid sha256 lowercase",
option: &ChecksumOption{Algorithm: "sha256", Expected: validSha256},
wantErr: false,
},
{
name: "valid sha-256 alias",
option: &ChecksumOption{Algorithm: "sha-256", Expected: validSha256},
wantErr: false,
},
{
name: "valid sha256 with whitespace around hash and algorithm",
option: &ChecksumOption{Algorithm: " sha256 ", Expected: " " + validSha256 + " "},
wantErr: false,
},
{
name: "unknown algorithm",
option: &ChecksumOption{Algorithm: "crc32", Expected: "12345678"},
wantErr: true,
errSubstr: "unsupported checksum algorithm",
},
{
name: "algorithm set but hash blank",
option: &ChecksumOption{Algorithm: "sha256", Expected: " "},
wantErr: true,
errSubstr: "checksum expected hash is required",
},
{
name: "sha256 with abc (short length)",
option: &ChecksumOption{Algorithm: "sha256", Expected: "abc"},
wantErr: true,
errSubstr: "expected 64 characters, got 3",
},
{
name: "sha256 with 64 x g (non-hex)",
option: &ChecksumOption{Algorithm: "sha256", Expected: strings.Repeat("g", 64)},
wantErr: true,
errSubstr: "expected valid hexadecimal string",
},
{
name: "sha256 with 63 x a (off-by-one short)",
option: &ChecksumOption{Algorithm: "sha256", Expected: strings.Repeat("a", 63)},
wantErr: true,
errSubstr: "expected 64 characters, got 63",
},
{
name: "sha256 with 65 x a (off-by-one long)",
option: &ChecksumOption{Algorithm: "sha256", Expected: strings.Repeat("a", 65)},
wantErr: true,
errSubstr: "expected 64 characters, got 65",
},
{
name: "md5 with 31 chars",
option: &ChecksumOption{Algorithm: "md5", Expected: strings.Repeat("a", 31)},
wantErr: true,
errSubstr: "expected 32 characters, got 31",
},
{
name: "md5 with 33 chars",
option: &ChecksumOption{Algorithm: "md5", Expected: strings.Repeat("a", 33)},
wantErr: true,
errSubstr: "expected 32 characters, got 33",
},
{
name: "md5 with sha256-length hash (64 chars)",
option: &ChecksumOption{Algorithm: "md5", Expected: validSha256},
wantErr: true,
errSubstr: "expected 32 characters, got 64",
},
{
name: "sha1 with 39 chars",
option: &ChecksumOption{Algorithm: "sha1", Expected: strings.Repeat("a", 39)},
wantErr: true,
errSubstr: "expected 40 characters, got 39",
},
{
name: "sha1 with 41 chars",
option: &ChecksumOption{Algorithm: "sha1", Expected: strings.Repeat("a", 41)},
wantErr: true,
errSubstr: "expected 40 characters, got 41",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.option.Validate()
if (err != nil) != tt.wantErr {
t.Fatalf("Validate() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr && tt.errSubstr != "" {
if err == nil || !strings.Contains(err.Error(), tt.errSubstr) {
t.Fatalf("Validate() error = %v, want substring %q", err, tt.errSubstr)
}
}
})
}
}
+2 -41
View File
@@ -1,9 +1,7 @@
package rest
import (
"io"
"net/http"
"net/url"
"runtime"
"github.com/GopeedLab/gopeed/pkg/base"
@@ -26,7 +24,7 @@ func Info(w http.ResponseWriter, r *http.Request) {
func Resolve(w http.ResponseWriter, r *http.Request) {
var req model.ResolveTask
if ReadJson(r, w, &req) {
rr, err := Downloader.Resolve(req.Req, req.Opts)
rr, err := Downloader.Resolve(req.Req)
if err != nil {
WriteJson(w, model.NewErrorResult(err.Error()))
return
@@ -82,7 +80,7 @@ func PatchTask(w http.ResponseWriter, r *http.Request) {
return
}
var req model.ResolveTask
var req model.PatchTask
if ReadJson(r, w, &req) {
if err := Downloader.Patch(taskId, req.Req, req.Opts); err != nil {
if err == download.ErrTaskNotFound {
@@ -337,43 +335,6 @@ func UpdateExtension(w http.ResponseWriter, r *http.Request) {
WriteJson(w, model.NewNilResult())
}
func DoProxy(w http.ResponseWriter, r *http.Request) {
target := r.Header.Get("X-Target-Uri")
if target == "" {
writeError(w, "param invalid: X-Target-Uri")
return
}
targetUrl, err := url.Parse(target)
if err != nil {
writeError(w, err.Error())
return
}
r.RequestURI = ""
r.URL = targetUrl
r.Host = targetUrl.Host
r.Header.Del("Cookie")
r.Header.Del("X-Api-Token")
r.Header.Del("X-Target-Uri")
resp, err := http.DefaultClient.Do(r)
if err != nil {
writeError(w, err.Error())
return
}
defer resp.Body.Close()
for k, vv := range resp.Header {
for _, v := range vv {
w.Header().Set(k, v)
}
}
w.WriteHeader(resp.StatusCode)
buf, err := io.ReadAll(resp.Body)
if err != nil {
writeError(w, err.Error())
return
}
w.Write(buf)
}
func GetStats(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
taskId := vars["id"]
+9
View File
@@ -0,0 +1,9 @@
package model
type MirrorCandidatesReq struct {
URL string `json:"url"`
}
type MirrorCandidatesResp struct {
URLs []string `json:"urls"`
}
+4
View File
@@ -3,6 +3,10 @@ package model
import "github.com/GopeedLab/gopeed/pkg/base"
type ResolveTask struct {
Req *base.Request `json:"req"`
}
type PatchTask struct {
Req *base.Request `json:"req"`
Opts *base.Options `json:"opts"`
}
Loaded 100 of 218 files, more files were not shown because too many files have changed in this diff. Show more