mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-10 10:30:28 +00:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44e2272ca4 | ||
|
|
c51c5b284d | ||
|
|
4d65948934 | ||
|
|
c275bc2e46 | ||
|
|
9aae13453a | ||
|
|
d125d4ab75 | ||
|
|
4214765d39 | ||
|
|
47cafb7575 | ||
|
|
de65149b29 | ||
|
|
5462b14705 | ||
|
|
eee39e012f | ||
|
|
12860bb207 | ||
|
|
b86c2c18e7 | ||
|
|
6f20790b17 | ||
|
|
fa622a4063 | ||
|
|
c6619794fc | ||
|
|
87a8f87b70 | ||
|
|
ba84a04a43 | ||
|
|
224b488087 | ||
|
|
7ce4fb7f0e | ||
|
|
8d1ae93b03 | ||
|
|
363153fa33 | ||
|
|
f0dab90e09 | ||
|
|
6e60889ce8 | ||
|
|
88d150b933 | ||
|
|
694b43e3f9 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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...))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 := ®isteredJob{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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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');
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package model
|
||||
|
||||
type MirrorCandidatesReq struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type MirrorCandidatesResp struct {
|
||||
URLs []string `json:"urls"`
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user