mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-13 11:53:41 +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") {
|
if ("${{ matrix.arch }}" -eq "amd64") {
|
||||||
# for amd64
|
# for amd64
|
||||||
$release = "build\windows\x64\runner\Release\"
|
$release = "build\windows\x64\runner\Release\"
|
||||||
$mingw = "C:\Program Files\Git\mingw64\bin"
|
# Use the same toolchain as cgo instead of Git's bundled DLLs.
|
||||||
cp $mingw\libstdc++-6.dll $release
|
$cc = (Get-Command -Name (go env CC) -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source
|
||||||
cp $mingw\libgcc_s_seh-1.dll $release
|
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 {
|
} else {
|
||||||
# for ARM64
|
# for ARM64
|
||||||
$release = "build\windows\arm64\runner\Release\"
|
$release = "build\windows\arm64\runner\Release\"
|
||||||
$mingw = "$env:CLANGARM64_ROOT\aarch64-w64-mingw32\bin"
|
$mingw = "$env:CLANGARM64_ROOT\aarch64-w64-mingw32\bin"
|
||||||
cp $mingw\libc++.dll $release
|
cp $mingw\libc++.dll $release
|
||||||
cp $mingw\libunwind.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\msvcp140.dll $release
|
||||||
cp $system\vcruntime140.dll $release
|
cp $system\vcruntime140.dll $release
|
||||||
cp $system\vcruntime140_1.dll $release
|
cp $system\vcruntime140_1.dll $release
|
||||||
|
|||||||
@@ -69,6 +69,23 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
files: ./coverage.txt
|
files: ./coverage.txt
|
||||||
token: ${{ secrets.CODECOV_UPLOAD_TOKEN }}
|
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:
|
test-webview-integration:
|
||||||
name: Test webview integration (${{ matrix.name }})
|
name: Test webview integration (${{ matrix.name }})
|
||||||
strategy:
|
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.
|
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.
|
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 は現在パブリックベータ版です。刷新されたインターフェース、デスクトップおよびモバイルから 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 正式版を公開します。ベータ版のユーザーは正式版へ直接アップデートできます。既存の安定版ユーザーが自動的にベータチャンネルへ移行することはありません。
|
機能と安定性が正式リリースの基準に達した時点で、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 đề.
|
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.
|
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 测试阶段,引入了全新界面、通过 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 渠道。
|
当功能完整度和稳定性达到正式发布标准后,我们会发布 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 測試階段,引入了全新介面、透過 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 頻道。
|
當功能完整度與穩定性達到正式發布標準後,我們會發布 Gopeed 2.0.0 正式版。已安裝 Beta 版本的使用者可以直接升級至最終正式版,現有穩定版使用者則不會被自動切換至 Beta 頻道。
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -166,7 +166,7 @@ func runResolve(args []string) error {
|
|||||||
result, err := d.Resolve(&base.Request{
|
result, err := d.Resolve(&base.Request{
|
||||||
URL: url,
|
URL: url,
|
||||||
Labels: map[string]string(labels),
|
Labels: map[string]string(labels),
|
||||||
}, nil)
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -250,7 +250,7 @@ func runDownload(args []string) error {
|
|||||||
result, err := d.Resolve(&base.Request{
|
result, err := d.Resolve(&base.Request{
|
||||||
URL: url,
|
URL: url,
|
||||||
Labels: map[string]string(labels),
|
Labels: map[string]string(labels),
|
||||||
}, nil)
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ type args struct {
|
|||||||
url string
|
url string
|
||||||
connections *int
|
connections *int
|
||||||
dir *string
|
dir *string
|
||||||
|
checksum *string
|
||||||
|
checksumAlgo *string
|
||||||
}
|
}
|
||||||
|
|
||||||
func parse() *args {
|
func parse() *args {
|
||||||
@@ -20,6 +22,8 @@ func parse() *args {
|
|||||||
var args args
|
var args args
|
||||||
args.connections = flag.Int("C", 16, "Concurrent connections.")
|
args.connections = flag.Int("C", 16, "Concurrent connections.")
|
||||||
args.dir = flag.String("D", dir, "Store directory.")
|
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()
|
flag.Parse()
|
||||||
t := flag.Args()
|
t := flag.Args()
|
||||||
if len(t) > 0 {
|
if len(t) > 0 {
|
||||||
|
|||||||
+16
-1
@@ -19,6 +19,18 @@ func main() {
|
|||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(1)
|
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().
|
_, err := download.Boot().
|
||||||
URL(args.url).
|
URL(args.url).
|
||||||
Listener(func(event *download.Event) {
|
Listener(func(event *download.Event) {
|
||||||
@@ -44,7 +56,10 @@ func main() {
|
|||||||
}).
|
}).
|
||||||
Create(&base.Options{
|
Create(&base.Options{
|
||||||
Path: *args.dir,
|
Path: *args.dir,
|
||||||
Extra: http.OptsExtra{Connections: *args.connections},
|
Extra: http.OptsExtra{
|
||||||
|
Connections: *args.connections,
|
||||||
|
Checksum: checksumOpt,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
|
|||||||
+7
-1
@@ -26,7 +26,7 @@ func Start(cfg *model.StartConfig) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
if downloadCfg.FirstLoad {
|
if rest.Downloader.IsFirstLoad() {
|
||||||
// Set default download config
|
// Set default download config
|
||||||
if cfg.DownloadConfig != nil {
|
if cfg.DownloadConfig != nil {
|
||||||
cfg.DownloadConfig.Merge(downloadCfg)
|
cfg.DownloadConfig.Merge(downloadCfg)
|
||||||
@@ -51,6 +51,12 @@ func Start(cfg *model.StartConfig) {
|
|||||||
rest.Downloader.PutConfig(downloadCfg)
|
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()
|
watchExit()
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ require (
|
|||||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||||
go.uber.org/mock v0.5.1 // indirect
|
go.uber.org/mock v0.5.1 // indirect
|
||||||
go4.org v0.0.0-20230225012048-214862532bf5 // 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/oauth2 v0.35.0 // indirect
|
||||||
golang.org/x/sync v0.20.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/metric v1.39.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||||
golang.org/x/crypto v0.48.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/net v0.50.0
|
||||||
golang.org/x/sys v0.44.0 // indirect
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
golang.org/x/text v0.34.0
|
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
|
// Each download task will have a corresponding Fetcher instance for the management of the download task
|
||||||
type Fetcher interface {
|
type Fetcher interface {
|
||||||
Setup(ctl *controller.Controller)
|
Setup(ctl *controller.Controller)
|
||||||
Resolve(req *base.Request, opts *base.Options) error
|
Resolve(req *base.Request) error
|
||||||
Start() error
|
Start() error
|
||||||
// Patch modifies task-specific data based on the protocol.
|
// Patch modifies task-specific data based on the protocol.
|
||||||
// For HTTP: can modify Request info (URL, headers, etc.)
|
// 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
|
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 {
|
type config struct {
|
||||||
|
fetcher.ConfigState[state]
|
||||||
|
TrackerSubscribeURLs []string `json:"trackerSubscribeUrls"`
|
||||||
|
AutoUpdateTrackers bool `json:"autoUpdateTrackers"`
|
||||||
|
CustomTrackers []string `json:"customTrackers"`
|
||||||
ListenPort int `json:"listenPort"`
|
ListenPort int `json:"listenPort"`
|
||||||
Trackers []string `json:"trackers"`
|
|
||||||
// SeedKeep is always keep seeding after downloading is complete, unless manually stopped.
|
// SeedKeep is always keep seeding after downloading is complete, unless manually stopped.
|
||||||
SeedKeep bool `json:"seedKeep"`
|
SeedKeep bool `json:"seedKeep"`
|
||||||
// SeedRatio is the ratio of uploaded data to downloaded data to seed.
|
// SeedRatio is the ratio of uploaded data to downloaded data to seed.
|
||||||
SeedRatio float64 `json:"seedRatio"`
|
SeedRatio float64 `json:"seedRatio"`
|
||||||
// SeedTime is the time in seconds to seed after downloading is complete.
|
// SeedTime is the time in seconds to seed after downloading is complete.
|
||||||
SeedTime int64 `json:"seedTime"`
|
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)
|
||||||
|
}
|
||||||
+226
-102
File diff suppressed because it is too large.
Load diff
@@ -18,11 +18,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestFetcher_Resolve_Torrent(t *testing.T) {
|
func TestFetcher_Resolve_Torrent(t *testing.T) {
|
||||||
doResolve(t, buildFetcher())
|
doResolve(t, buildFetcher(t))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFetcher_Resolve_DataUri_Torrent(t *testing.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")
|
buf, err := os.ReadFile("./testdata/ubuntu-22.04-live-server-amd64.iso.torrent")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
dataUri := "data:application/x-bittorrent;base64," + base64.StdEncoding.EncodeToString(buf)
|
||||||
err = fetcher.Resolve(&base.Request{
|
err = fetcher.Resolve(&base.Request{
|
||||||
URL: dataUri,
|
URL: dataUri,
|
||||||
}, nil)
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ func TestFetcher_Resolve_DataUri_Torrent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestFetcher_Config(t *testing.T) {
|
func TestFetcher_Config(t *testing.T) {
|
||||||
doResolve(t, buildConfigFetcher(nil))
|
doResolve(t, buildConfigFetcher(t, nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFetcher_ResolveWithProxy(t *testing.T) {
|
func TestFetcher_ResolveWithProxy(t *testing.T) {
|
||||||
@@ -61,7 +61,7 @@ func TestFetcher_ResolveWithProxy(t *testing.T) {
|
|||||||
proxyListener := test.StartSocks5Server(usr, pwd)
|
proxyListener := test.StartSocks5Server(usr, pwd)
|
||||||
defer proxyListener.Close()
|
defer proxyListener.Close()
|
||||||
|
|
||||||
doResolve(t, buildConfigFetcher(&base.DownloaderProxyConfig{
|
doResolve(t, buildConfigFetcher(t, &base.DownloaderProxyConfig{
|
||||||
Enable: true,
|
Enable: true,
|
||||||
System: false,
|
System: false,
|
||||||
Scheme: "socks5",
|
Scheme: "socks5",
|
||||||
@@ -81,7 +81,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
|
|||||||
"udp://tracker.bitsearch.to:1337/announce",
|
"udp://tracker.bitsearch.to:1337/announce",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, nil)
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
@@ -111,7 +111,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
|
|||||||
"udp://tracker.bitsearch.to:1337/announce",
|
"udp://tracker.bitsearch.to:1337/announce",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, nil)
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
@@ -145,7 +145,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
|
|||||||
t.Run("Resolve Unclean Torrent", func(t *testing.T) {
|
t.Run("Resolve Unclean Torrent", func(t *testing.T) {
|
||||||
err := fetcher.Resolve(&base.Request{
|
err := fetcher.Resolve(&base.Request{
|
||||||
URL: "./testdata/test.unclean.torrent",
|
URL: "./testdata/test.unclean.torrent",
|
||||||
}, nil)
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Resolve Unclean Torrent Resolve() got = %v, want nil", err)
|
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
|
uri := "file:///" + file
|
||||||
err := fetcher.Resolve(&base.Request{
|
err := fetcher.Resolve(&base.Request{
|
||||||
URL: uri,
|
URL: uri,
|
||||||
}, nil)
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Resolve file scheme Torrent Resolve() got = %v, want nil", err)
|
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)
|
fb := new(FetcherManager)
|
||||||
fetcher := fb.Build()
|
fetcher := fb.Build()
|
||||||
newController := controller.NewController()
|
newController := controller.NewController()
|
||||||
@@ -219,14 +219,15 @@ func buildFetcher() fetcher.Fetcher {
|
|||||||
json.Unmarshal([]byte(test.ToJson(fb.DefaultConfig())), v)
|
json.Unmarshal([]byte(test.ToJson(fb.DefaultConfig())), v)
|
||||||
}
|
}
|
||||||
fetcher.Setup(newController)
|
fetcher.Setup(newController)
|
||||||
|
t.Cleanup(func() { fetcher.Close(); closeClient() })
|
||||||
return fetcher
|
return fetcher
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildConfigFetcher(proxyConfig *base.DownloaderProxyConfig) fetcher.Fetcher {
|
func buildConfigFetcher(t testing.TB, proxyConfig *base.DownloaderProxyConfig) fetcher.Fetcher {
|
||||||
fetcher := new(FetcherManager).Build()
|
fetcher := new(FetcherManager).Build()
|
||||||
newController := controller.NewController()
|
newController := controller.NewController()
|
||||||
mockCfg := config{
|
mockCfg := config{
|
||||||
Trackers: []string{
|
CustomTrackers: []string{
|
||||||
"udp://tracker.birkenwald.de:6969/announce",
|
"udp://tracker.birkenwald.de:6969/announce",
|
||||||
"udp://tracker.bitsearch.to:1337/announce",
|
"udp://tracker.bitsearch.to:1337/announce",
|
||||||
}}
|
}}
|
||||||
@@ -237,29 +238,30 @@ func buildConfigFetcher(proxyConfig *base.DownloaderProxyConfig) fetcher.Fetcher
|
|||||||
return proxyConfig.ToHandler()
|
return proxyConfig.ToHandler()
|
||||||
}
|
}
|
||||||
fetcher.Setup(newController)
|
fetcher.Setup(newController)
|
||||||
|
t.Cleanup(func() { fetcher.Close(); closeClient() })
|
||||||
return fetcher
|
return fetcher
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFetcher_Patch tests the Patch functionality for BT fetcher.
|
// TestFetcher_Patch tests the Patch functionality for BT fetcher.
|
||||||
// It tests modifying selected files after Resolve (without downloading).
|
// It tests modifying selected files after Resolve (without downloading).
|
||||||
func TestFetcher_Patch(t *testing.T) {
|
func TestFetcher_Patch(t *testing.T) {
|
||||||
f := buildFetcher()
|
f := buildFetcher(t)
|
||||||
|
|
||||||
// Resolve a multi-file torrent
|
// Resolve a multi-file torrent
|
||||||
err := f.Resolve(&base.Request{
|
err := f.Resolve(&base.Request{
|
||||||
URL: "./testdata/test.torrent",
|
URL: "./testdata/test.torrent",
|
||||||
}, nil)
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Resolve failed: %v", err)
|
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()
|
meta := f.Meta()
|
||||||
if len(meta.Res.Files) != 3 {
|
if len(meta.Res.Files) != 3 {
|
||||||
t.Fatalf("Expected 3 files, got %d", len(meta.Res.Files))
|
t.Fatalf("Expected 3 files, got %d", len(meta.Res.Files))
|
||||||
}
|
}
|
||||||
if len(meta.Opts.SelectFiles) != 3 {
|
if meta.Opts != nil {
|
||||||
t.Fatalf("Expected 3 selected files, got %d", len(meta.Opts.SelectFiles))
|
t.Fatal("Resolve should not assign download options")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Total size of all files: 107484864
|
// 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"
|
"github.com/anacrolix/torrent/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestStartUsesSelectionSubmittedAfterResolve(t *testing.T) {
|
func TestStartDoesNotPublishUnverifiedCompletion(t *testing.T) {
|
||||||
config := torrent.TestingConfig(t)
|
cfg := torrent.TestingConfig(t)
|
||||||
config.DataDir = t.TempDir()
|
cfg.DisableTCP = true
|
||||||
config.DisableTCP = true
|
cfg.DisableUTP = true
|
||||||
config.DisableUTP = true
|
c, err := torrent.NewClient(cfg)
|
||||||
c, err := torrent.NewClient(config)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
defer c.Close()
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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.torrentReady.Store(true)
|
||||||
f.updateRes()
|
f.updateRes()
|
||||||
// Resolve initializes all files; creation replaces the selection before Start.
|
dir := t.TempDir()
|
||||||
f.meta.Opts = &base.Options{SelectFiles: []int{2}}
|
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 {
|
if err := f.Start(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -83,10 +143,15 @@ func TestWaitCleansUnselectedFilesFromSharedPiece(t *testing.T) {
|
|||||||
{Length: 4, Path: []string{"keep.txt"}},
|
{Length: 4, Path: []string{"keep.txt"}},
|
||||||
{Length: 4, Path: []string{"nested", "skip.txt"}},
|
{Length: 4, Path: []string{"nested", "skip.txt"}},
|
||||||
}}
|
}}
|
||||||
store := storage.NewFileOpts(storage.NewFileClientOpts{
|
storageOpts := storage.NewFileClientOpts{
|
||||||
ClientBaseDir: dir,
|
ClientBaseDir: dir,
|
||||||
TorrentDirMaker: func(baseDir string, _ *metainfo.Info, _ metainfo.Hash) string { return baseDir },
|
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()
|
defer store.Close()
|
||||||
config := torrent.TestingConfig(t)
|
config := torrent.TestingConfig(t)
|
||||||
config.DisableTCP = true
|
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)
|
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)
|
link, err := parseLink(req.URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
f.meta.Req = req
|
f.meta.Req = req
|
||||||
f.meta.Opts = opts
|
|
||||||
if f.meta.Opts == nil {
|
|
||||||
f.meta.Opts = &base.Options{}
|
|
||||||
}
|
|
||||||
f.meta.Res = buildResource(link)
|
f.meta.Res = buildResource(link)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ func TestFetcher_Resolve(t *testing.T) {
|
|||||||
f := (&FetcherManager{}).Build()
|
f := (&FetcherManager{}).Build()
|
||||||
f.Setup(controller.NewController())
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Resolve() error = %v", err)
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,11 +172,11 @@ func (f *Fetcher) Meta() *fetcher.FetcherMeta {
|
|||||||
return f.meta
|
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()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
f.meta.Req = req
|
f.meta.Req = req
|
||||||
f.meta.Opts = opts
|
|
||||||
f.extra = &ReqExtra{}
|
f.extra = &ReqExtra{}
|
||||||
if err = base.ParseReqExtra[ReqExtra](req); err != nil {
|
if err = base.ParseReqExtra[ReqExtra](req); err != nil {
|
||||||
return
|
return
|
||||||
@@ -194,10 +194,6 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) (err error) {
|
|||||||
}
|
}
|
||||||
state.StagingID = randomStagingID()
|
state.StagingID = randomStagingID()
|
||||||
f.state = state
|
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)
|
size := planTotalSize(state.Segments)
|
||||||
f.meta.Res = &base.Resource{
|
f.meta.Res = &base.Resource{
|
||||||
Range: false,
|
Range: false,
|
||||||
|
|||||||
@@ -80,7 +80,8 @@ func mustResolve(t *testing.T, f *Fetcher, playlistURL, referer string) {
|
|||||||
if referer != "" {
|
if referer != "" {
|
||||||
req.Extra = &ReqExtra{Header: map[string]string{"Referer": 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)
|
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).
|
// Without Referer the resolve itself must fail (403 on the playlist).
|
||||||
blocked := newTestFetcher(t, testConfig())
|
blocked := newTestFetcher(t, testConfig())
|
||||||
req := &base.Request{URL: server.URL + "/video.m3u8"}
|
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")
|
t.Fatal("resolve without Referer should fail")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +219,8 @@ func TestFetcherE2E_RefererEnforced(t *testing.T) {
|
|||||||
URL: server.URL + "/video.m3u8",
|
URL: server.URL + "/video.m3u8",
|
||||||
Extra: &ReqExtra{Header: map[string]string{"Referer": page}},
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
mustRun(t, allowed)
|
mustRun(t, allowed)
|
||||||
@@ -560,7 +563,8 @@ func TestFetcherE2E_StoreRestoreAuthenticated(t *testing.T) {
|
|||||||
URL: server.URL + "/video.m3u8",
|
URL: server.URL + "/video.m3u8",
|
||||||
Extra: &ReqExtra{Header: map[string]string{"Referer": page}},
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := f.Start(); err != nil {
|
if err := f.Start(); err != nil {
|
||||||
@@ -630,7 +634,8 @@ func TestFetcherE2E_PatchPartialUpdate(t *testing.T) {
|
|||||||
Extra: &ReqExtra{Header: map[string]string{"Referer": page}},
|
Extra: &ReqExtra{Header: map[string]string{"Referer": page}},
|
||||||
}
|
}
|
||||||
opts := &base.Options{Path: t.TempDir()}
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -713,7 +718,8 @@ func TestFetcherE2E_PatchWhileRunningRejected(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
f := newTestFetcher(t, testConfig())
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := f.Start(); err != nil {
|
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.MaxRetries = 0 // fail the run on the first bad segment
|
||||||
cfg.PrefetchContentLength = false
|
cfg.PrefetchContentLength = false
|
||||||
f := newTestFetcher(t, cfg)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := f.Start(); err != nil {
|
if err := f.Start(); err != nil {
|
||||||
@@ -828,7 +835,8 @@ func TestFetcherE2E_StateMissingSelfHeal(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
f := newTestFetcher(t, testConfig())
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// Simulate the crash window where the engine persisted the task with its
|
// 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()
|
defer server.Close()
|
||||||
|
|
||||||
f := newTestFetcher(t, testConfig())
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := f.Start(); err != nil {
|
if err := f.Start(); err != nil {
|
||||||
@@ -225,7 +226,8 @@ func TestHLSRealMedia_FMP4(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
f := newTestFetcher(t, testConfig())
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := f.Start(); err != nil {
|
if err := f.Start(); err != nil {
|
||||||
@@ -274,7 +276,8 @@ func TestHLSRealMedia_AES128(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
f := newTestFetcher(t, testConfig())
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if f.meta.Res.Size != 0 {
|
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
|
// user duplicating a task. The engine would rename the second output
|
||||||
// (AutoRename); mirror that here.
|
// (AutoRename); mirror that here.
|
||||||
f1 := newTestFetcher(t, cfg)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
f2 := newTestFetcher(t, cfg)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if f1.state.StagingDir == "" || f1.state.StagingDir == f2.state.StagingDir {
|
if f1.state.StagingID == "" || f1.state.StagingID == f2.state.StagingID {
|
||||||
t.Fatalf("staging dirs must be distinct per task: %q vs %q", f1.state.StagingDir, f2.state.StagingDir)
|
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.
|
// Start task 1, let it stage some segments, then pause it.
|
||||||
@@ -135,7 +140,8 @@ func TestFetcherE2E_ForeignJournalDiscarded(t *testing.T) {
|
|||||||
cfg := testConfig()
|
cfg := testConfig()
|
||||||
cfg.PrefetchContentLength = false
|
cfg.PrefetchContentLength = false
|
||||||
f := newTestFetcher(t, cfg)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// Plant a journal that does not belong to this plan (wrong fingerprint).
|
// 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()
|
defer server.Close()
|
||||||
|
|
||||||
f := newTestFetcher(t, testConfig())
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
run := &hlsRun{
|
run := &hlsRun{
|
||||||
@@ -250,7 +257,8 @@ func TestFetcherE2E_ReMergeOverExistingOutput(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
f := newTestFetcher(t, testConfig())
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
tempDir := f.resolveStagingDirLocked()
|
tempDir := f.resolveStagingDirLocked()
|
||||||
|
|||||||
+248
-142
File diff suppressed because it is too large.
Load diff
@@ -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
|
IfRange string // Strong ETag or Last-Modified validator for safe resume
|
||||||
RangeReprobeEligible bool // Origin advertised Range before falling back to sequential mode
|
RangeReprobeEligible bool // Origin advertised Range before falling back to sequential mode
|
||||||
RangeValidatorPinned bool // Recovered Range mode must keep the same If-Range validator
|
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
|
Range *bool // Authoritative Range mode; nil for records saved by older versions
|
||||||
ResourceSize *int64 // Authoritative resource size from the same snapshot as Connections
|
ResourceSize *int64 // Display size from the same snapshot; authority is recorded by SizeKnown
|
||||||
FileSize *int64 // Authoritative single-file size; nil for older records
|
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
|
rangeReprobeEligible := _f.rangeReprobeEligible
|
||||||
rangeValidatorPinned := _f.rangeValidatorPinned
|
rangeValidatorPinned := _f.rangeValidatorPinned
|
||||||
sequentialSizeUnknown := _f.sequentialSizeUnknown
|
sequentialSizeUnknown := _f.sequentialSizeUnknown
|
||||||
|
sizeKnown := _f.hasKnownSize()
|
||||||
var rangeMode *bool
|
var rangeMode *bool
|
||||||
var resourceSize *int64
|
var resourceSize *int64
|
||||||
var fileSize *int64
|
var fileSize *int64
|
||||||
@@ -115,6 +117,7 @@ func (fm *FetcherManager) Store(f fetcher.Fetcher) (data any, err error) {
|
|||||||
RangeReprobeEligible: rangeReprobeEligible,
|
RangeReprobeEligible: rangeReprobeEligible,
|
||||||
RangeValidatorPinned: rangeValidatorPinned,
|
RangeValidatorPinned: rangeValidatorPinned,
|
||||||
SequentialSizeUnknown: sequentialSizeUnknown,
|
SequentialSizeUnknown: sequentialSizeUnknown,
|
||||||
|
SizeKnown: &sizeKnown,
|
||||||
Range: rangeMode,
|
Range: rangeMode,
|
||||||
ResourceSize: resourceSize,
|
ResourceSize: resourceSize,
|
||||||
FileSize: fileSize,
|
FileSize: fileSize,
|
||||||
@@ -168,6 +171,10 @@ func (fm *FetcherManager) Restore() (v any, f func(meta *fetcher.FetcherMeta, v
|
|||||||
fetcher.rangeReprobeEligible = fd.RangeReprobeEligible
|
fetcher.rangeReprobeEligible = fd.RangeReprobeEligible
|
||||||
fetcher.rangeValidatorPinned = fd.RangeValidatorPinned
|
fetcher.rangeValidatorPinned = fd.RangeValidatorPinned
|
||||||
fetcher.sequentialSizeUnknown = fd.SequentialSizeUnknown
|
fetcher.sequentialSizeUnknown = fd.SequentialSizeUnknown
|
||||||
|
if fd.SizeKnown != nil {
|
||||||
|
known := *fd.SizeKnown
|
||||||
|
fetcher.sizeKnown = &known
|
||||||
|
}
|
||||||
if fd.Range != nil && fetcher.meta.Res != nil {
|
if fd.Range != nil && fetcher.meta.Res != nil {
|
||||||
// Recovery-critical resource fields live in the same persisted snapshot
|
// Recovery-critical resource fields live in the same persisted snapshot
|
||||||
// as Connections, making them authoritative over task metadata that may
|
// as Connections, making them authoritative over task metadata that may
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
pathpkg "path"
|
pathpkg "path"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -75,7 +76,11 @@ func (r RouteSpec) Match(rawPath string) (map[string]string, bool) {
|
|||||||
params := make(map[string]string)
|
params := make(map[string]string)
|
||||||
for index, part := range r.parts {
|
for index, part := range r.parts {
|
||||||
if part.param != "" {
|
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
|
continue
|
||||||
}
|
}
|
||||||
if part.literal != segments[index] {
|
if part.literal != segments[index] {
|
||||||
|
|||||||
+36
-2
@@ -2,7 +2,9 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"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/{id}/status", service.getTaskStatus),
|
||||||
newRoute(http.MethodGet, "/api/v1/tasks", service.getTasks),
|
newRoute(http.MethodGet, "/api/v1/tasks", service.getTasks),
|
||||||
newRoute(http.MethodGet, "/api/v1/tasks/{id}/stats", service.getStats),
|
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.MethodGet, "/api/v1/config", service.getConfig),
|
||||||
newRoute(http.MethodPut, "/api/v1/config", service.putConfig),
|
newRoute(http.MethodPut, "/api/v1/config", service.putConfig),
|
||||||
newRoute(http.MethodPost, "/api/v1/extensions", service.installExtension),
|
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 {
|
if response := decodeRequest(ctx.Body, &request); response != nil {
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
result, err := s.Downloader.Resolve(request.Req, request.Opts)
|
result, err := s.Downloader.Resolve(request.Req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return newErrorResponse(err.Error())
|
return newErrorResponse(err.Error())
|
||||||
}
|
}
|
||||||
@@ -188,7 +192,7 @@ func (s *Service) patchTask(ctx *Context) *Response {
|
|||||||
if taskID == "" {
|
if taskID == "" {
|
||||||
return invalidIDResponse()
|
return invalidIDResponse()
|
||||||
}
|
}
|
||||||
var request restmodel.ResolveTask
|
var request restmodel.PatchTask
|
||||||
if response := decodeRequest(ctx.Body, &request); response != nil {
|
if response := decodeRequest(ctx.Body, &request); response != nil {
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
@@ -294,6 +298,36 @@ func (s *Service) getStats(ctx *Context) *Response {
|
|||||||
return newOKResponse(stats)
|
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 {
|
func (s *Service) getConfig(_ *Context) *Response {
|
||||||
config, err := s.Downloader.GetConfig()
|
config, err := s.Downloader.GetConfig()
|
||||||
if err != nil {
|
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
|
package base
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -25,24 +27,6 @@ type Request struct {
|
|||||||
SkipVerifyCert bool `json:"skipVerifyCert"`
|
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 {
|
func (r *Request) Validate() error {
|
||||||
if r.URL == "" {
|
if r.URL == "" {
|
||||||
return fmt.Errorf("invalid request url")
|
return fmt.Errorf("invalid request url")
|
||||||
@@ -126,12 +110,18 @@ type FileInfo struct {
|
|||||||
|
|
||||||
// Options for download
|
// Options for download
|
||||||
type Options struct {
|
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
|
// Download file name
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
// Download file path
|
// Download file path
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
// Use this path as the default download directory after creation succeeds
|
// Use this path as the default download directory after creation succeeds
|
||||||
AsDefaultPath bool `json:"asDefaultPath,omitempty"`
|
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
|
// Select file indexes to download
|
||||||
SelectFiles []int `json:"selectFiles"`
|
SelectFiles []int `json:"selectFiles"`
|
||||||
// Extra info for specific fetcher
|
// 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
|
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
|
MaxRunning int `json:"maxRunning"` // MaxRunning is the max running download count
|
||||||
ProtocolConfig map[string]any `json:"protocolConfig"` // ProtocolConfig is special config for each protocol
|
ProtocolConfig map[string]any `json:"protocolConfig"` // ProtocolConfig is special config for each protocol
|
||||||
|
GithubMirror *GithubMirrorConfig `json:"githubMirror"`
|
||||||
Extra map[string]any `json:"extra"`
|
Extra map[string]any `json:"extra"`
|
||||||
Proxy *DownloaderProxyConfig `json:"proxy"`
|
Proxy *DownloaderProxyConfig `json:"proxy"`
|
||||||
Webhook *WebhookConfig `json:"webhook"` // Webhook is the webhook configuration
|
Webhook *WebhookConfig `json:"webhook"` // Webhook is the webhook configuration
|
||||||
Script *ScriptConfig `json:"script"` // Script is the script execution configuration
|
Script *ScriptConfig `json:"script"` // Script is the script execution configuration
|
||||||
AutoTorrent *AutoTorrentConfig `json:"autoTorrent"` // AutoTorrent is the auto torrent task creation configuration
|
AutoTorrent *AutoTorrentConfig `json:"autoTorrent"` // AutoTorrent is the auto torrent task creation configuration
|
||||||
Archive *ArchiveConfig `json:"archive"` // Archive is the archive extraction 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
|
API *APIServerConfig `json:"api"` // API is the optional REST server configuration
|
||||||
AutoStartTasks bool `json:"autoStartTasks"` // AutoStartTasks continues all unfinished tasks when the backend starts
|
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
|
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 {
|
if cfg.ProtocolConfig == nil {
|
||||||
cfg.ProtocolConfig = make(map[string]any)
|
cfg.ProtocolConfig = make(map[string]any)
|
||||||
}
|
}
|
||||||
|
if cfg.GithubMirror == nil {
|
||||||
|
cfg.GithubMirror = DefaultGithubMirrorConfig()
|
||||||
|
}
|
||||||
if cfg.Proxy == nil {
|
if cfg.Proxy == nil {
|
||||||
cfg.Proxy = &DownloaderProxyConfig{}
|
cfg.Proxy = &DownloaderProxyConfig{}
|
||||||
}
|
}
|
||||||
@@ -238,9 +234,85 @@ func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig {
|
|||||||
DeleteAfterExtract: false,
|
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
|
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 {
|
func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *DownloaderStoreConfig {
|
||||||
if beforeCfg == nil {
|
if beforeCfg == nil {
|
||||||
return cfg
|
return cfg
|
||||||
@@ -257,6 +329,9 @@ func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *Downl
|
|||||||
if cfg.Extra == nil {
|
if cfg.Extra == nil {
|
||||||
cfg.Extra = beforeCfg.Extra
|
cfg.Extra = beforeCfg.Extra
|
||||||
}
|
}
|
||||||
|
if cfg.GithubMirror == nil {
|
||||||
|
cfg.GithubMirror = beforeCfg.GithubMirror
|
||||||
|
}
|
||||||
if cfg.Proxy == nil {
|
if cfg.Proxy == nil {
|
||||||
cfg.Proxy = beforeCfg.Proxy
|
cfg.Proxy = beforeCfg.Proxy
|
||||||
}
|
}
|
||||||
@@ -272,6 +347,12 @@ func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *Downl
|
|||||||
if cfg.Archive == nil {
|
if cfg.Archive == nil {
|
||||||
cfg.Archive = beforeCfg.Archive
|
cfg.Archive = beforeCfg.Archive
|
||||||
}
|
}
|
||||||
|
if cfg.Categories == nil {
|
||||||
|
cfg.Categories = beforeCfg.Categories
|
||||||
|
}
|
||||||
|
if cfg.AutoCategorize == nil {
|
||||||
|
cfg.AutoCategorize = beforeCfg.AutoCategorize
|
||||||
|
}
|
||||||
if cfg.API == nil {
|
if cfg.API == nil {
|
||||||
cfg.API = beforeCfg.API
|
cfg.API = beforeCfg.API
|
||||||
}
|
}
|
||||||
@@ -320,6 +401,16 @@ type ArchiveConfig struct {
|
|||||||
DeleteAfterExtract bool `json:"deleteAfterExtract"` // DeleteAfterExtract deletes the archive after successful extraction
|
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 {
|
type DownloaderProxyConfig struct {
|
||||||
Enable bool `json:"enable"`
|
Enable bool `json:"enable"`
|
||||||
// System is the flag that use system proxy
|
// System is the flag that use system proxy
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
package base
|
package base
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDownloaderStoreConfig_Init(t *testing.T) {
|
func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||||
@@ -15,6 +20,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
|||||||
"Init",
|
"Init",
|
||||||
&DownloaderStoreConfig{},
|
&DownloaderStoreConfig{},
|
||||||
&DownloaderStoreConfig{
|
&DownloaderStoreConfig{
|
||||||
|
GithubMirror: DefaultGithubMirrorConfig(),
|
||||||
MaxRunning: 5,
|
MaxRunning: 5,
|
||||||
ProtocolConfig: map[string]any{},
|
ProtocolConfig: map[string]any{},
|
||||||
Proxy: &DownloaderProxyConfig{},
|
Proxy: &DownloaderProxyConfig{},
|
||||||
@@ -36,6 +42,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
|||||||
MaxRunning: 10,
|
MaxRunning: 10,
|
||||||
},
|
},
|
||||||
&DownloaderStoreConfig{
|
&DownloaderStoreConfig{
|
||||||
|
GithubMirror: DefaultGithubMirrorConfig(),
|
||||||
MaxRunning: 10,
|
MaxRunning: 10,
|
||||||
ProtocolConfig: map[string]any{},
|
ProtocolConfig: map[string]any{},
|
||||||
Proxy: &DownloaderProxyConfig{},
|
Proxy: &DownloaderProxyConfig{},
|
||||||
@@ -59,6 +66,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
&DownloaderStoreConfig{
|
&DownloaderStoreConfig{
|
||||||
|
GithubMirror: DefaultGithubMirrorConfig(),
|
||||||
MaxRunning: 5,
|
MaxRunning: 5,
|
||||||
ProtocolConfig: map[string]any{
|
ProtocolConfig: map[string]any{
|
||||||
"key": "value",
|
"key": "value",
|
||||||
@@ -84,6 +92,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
&DownloaderStoreConfig{
|
&DownloaderStoreConfig{
|
||||||
|
GithubMirror: DefaultGithubMirrorConfig(),
|
||||||
MaxRunning: 5,
|
MaxRunning: 5,
|
||||||
ProtocolConfig: map[string]any{},
|
ProtocolConfig: map[string]any{},
|
||||||
Proxy: &DownloaderProxyConfig{
|
Proxy: &DownloaderProxyConfig{
|
||||||
@@ -110,6 +119,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
&DownloaderStoreConfig{
|
&DownloaderStoreConfig{
|
||||||
|
GithubMirror: DefaultGithubMirrorConfig(),
|
||||||
MaxRunning: 5,
|
MaxRunning: 5,
|
||||||
ProtocolConfig: map[string]any{},
|
ProtocolConfig: map[string]any{},
|
||||||
Proxy: &DownloaderProxyConfig{},
|
Proxy: &DownloaderProxyConfig{},
|
||||||
@@ -134,6 +144,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
&DownloaderStoreConfig{
|
&DownloaderStoreConfig{
|
||||||
|
GithubMirror: DefaultGithubMirrorConfig(),
|
||||||
MaxRunning: 5,
|
MaxRunning: 5,
|
||||||
ProtocolConfig: map[string]any{},
|
ProtocolConfig: map[string]any{},
|
||||||
Proxy: &DownloaderProxyConfig{},
|
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 {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -157,6 +182,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
|||||||
DownloadDir: tt.fields.DownloadDir,
|
DownloadDir: tt.fields.DownloadDir,
|
||||||
MaxRunning: tt.fields.MaxRunning,
|
MaxRunning: tt.fields.MaxRunning,
|
||||||
ProtocolConfig: tt.fields.ProtocolConfig,
|
ProtocolConfig: tt.fields.ProtocolConfig,
|
||||||
|
GithubMirror: tt.fields.GithubMirror,
|
||||||
Extra: tt.fields.Extra,
|
Extra: tt.fields.Extra,
|
||||||
Proxy: tt.fields.Proxy,
|
Proxy: tt.fields.Proxy,
|
||||||
Webhook: tt.fields.Webhook,
|
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) {
|
func TestDownloaderStoreConfig_Merge(t *testing.T) {
|
||||||
type args struct {
|
type args struct {
|
||||||
beforeCfg *DownloaderStoreConfig
|
beforeCfg *DownloaderStoreConfig
|
||||||
@@ -215,6 +437,24 @@ func TestDownloaderStoreConfig_Merge(t *testing.T) {
|
|||||||
DownloadDir: "after",
|
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",
|
"Merge MaxRunning No Override",
|
||||||
&DownloaderStoreConfig{
|
&DownloaderStoreConfig{
|
||||||
@@ -428,6 +668,8 @@ func TestDownloaderStoreConfig_Merge(t *testing.T) {
|
|||||||
Webhook: tt.fields.Webhook,
|
Webhook: tt.fields.Webhook,
|
||||||
AutoTorrent: tt.fields.AutoTorrent,
|
AutoTorrent: tt.fields.AutoTorrent,
|
||||||
Archive: tt.fields.Archive,
|
Archive: tt.fields.Archive,
|
||||||
|
Categories: tt.fields.Categories,
|
||||||
|
AutoCategorize: tt.fields.AutoCategorize,
|
||||||
}
|
}
|
||||||
if got := cfg.Merge(tt.args.beforeCfg); !reflect.DeepEqual(got, tt.want) {
|
if got := cfg.Merge(tt.args.beforeCfg); !reflect.DeepEqual(got, tt.want) {
|
||||||
t.Errorf("Merge() = %v, want %v", 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
|
||||||
|
}
|
||||||
Loaded 50 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