mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-13 11:53:41 +00:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebfd113360 | ||
|
|
d69874e13e | ||
|
|
b0d45226d7 | ||
|
|
cd19772099 | ||
|
|
1e36599d52 | ||
|
|
fc0a1e0ef9 | ||
|
|
8719166dbf | ||
|
|
c0b70d319d | ||
|
|
e0951de270 | ||
|
|
be0fb05edd | ||
|
|
91edba9af6 | ||
|
|
7d423e2182 | ||
|
|
903c5c46c4 | ||
|
|
b8ff5ff97f | ||
|
|
c51c5b284d | ||
|
|
4d65948934 | ||
|
|
c275bc2e46 | ||
|
|
9aae13453a | ||
|
|
d125d4ab75 | ||
|
|
4214765d39 | ||
|
|
47cafb7575 | ||
|
|
de65149b29 | ||
|
|
5462b14705 | ||
|
|
eee39e012f | ||
|
|
12860bb207 | ||
|
|
b86c2c18e7 | ||
|
|
6f20790b17 | ||
|
|
fa622a4063 | ||
|
|
c6619794fc | ||
|
|
87a8f87b70 |
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,49 @@ 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-flutter-task-context-menu:
|
||||||
|
name: Test task context menus and text inputs
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
env:
|
||||||
|
CHROME_EXECUTABLE: /usr/bin/google-chrome
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
- name: Prepare Flutter tests
|
||||||
|
working-directory: ui/flutter
|
||||||
|
run: |
|
||||||
|
flutter pub get
|
||||||
|
flutter gen-l10n
|
||||||
|
- name: Test task menu actions and native input policy
|
||||||
|
working-directory: ui/flutter
|
||||||
|
run: |
|
||||||
|
flutter test --no-pub test/features/tasks/presentation/widgets/task_context_menu_test.dart test/shared/widgets/app_text_field_test.dart
|
||||||
|
flutter test --no-pub test/widget_test.dart --plain-name 'task context menu exposes'
|
||||||
|
- name: Test browser menu cancellation and input policy
|
||||||
|
working-directory: ui/flutter
|
||||||
|
run: |
|
||||||
|
"$CHROME_EXECUTABLE" --version
|
||||||
|
flutter test --no-pub --platform chrome test/features/tasks/presentation/widgets/task_context_menu_test.dart test/shared/widgets/app_text_field_test.dart
|
||||||
test-webview-integration:
|
test-webview-integration:
|
||||||
name: Test webview integration (${{ matrix.name }})
|
name: Test webview integration (${{ matrix.name }})
|
||||||
strategy:
|
strategy:
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Beyond core download management, Gopeed offers browser integration, JavaScript e
|
|||||||
|
|
||||||
Visit ✈ [Official Website](https://gopeed.com)
|
Visit ✈ [Official Website](https://gopeed.com)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ Gopeed(**Go Speed** の略)は、Go と Flutter で開発された高速で
|
|||||||
|
|
||||||
✈ [公式サイト](https://gopeed.com)
|
✈ [公式サイト](https://gopeed.com)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## ✨ 主な機能
|
## ✨ 主な機能
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ Ngoài việc quản lý tải xuống hằng ngày, Gopeed còn hỗ trợ tíc
|
|||||||
|
|
||||||
Truy cập ✈ [Trang web chính thức](https://gopeed.com)
|
Truy cập ✈ [Trang web chính thức](https://gopeed.com)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## ✨ Tính năng chính
|
## ✨ Tính năng chính
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ Gopeed(全称 **Go Speed**,也叫“够快下载器”)是一款使用 Go
|
|||||||
|
|
||||||
访问 ✈ [官方网站](https://gopeed.com)
|
访问 ✈ [官方网站](https://gopeed.com)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## ✨ 主要特性
|
## ✨ 主要特性
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ Gopeed(**Go Speed** 的縮寫)是一款以 Go 與 Flutter 開發的高速、
|
|||||||
|
|
||||||
瀏覽 ✈ [官方網站](https://gopeed.com)
|
瀏覽 ✈ [官方網站](https://gopeed.com)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## ✨ 主要功能
|
## ✨ 主要功能
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 74 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 77 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 792 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 787 KiB |
@@ -0,0 +1,52 @@
|
|||||||
|
# Batch task creation
|
||||||
|
|
||||||
|
`POST /api/v1/tasks/batch` accepts ordinary and grouped batches:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"group": true,
|
||||||
|
"groupName": "album",
|
||||||
|
"opts": {"path": "/downloads"},
|
||||||
|
"reqs": [
|
||||||
|
{"rid": "resolved-resource-id", "opts": {"selectFiles": [1]}},
|
||||||
|
{"req": {"url": "https://example.com/cover.jpg"}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each member accepts `rid` or `req`. A nonempty `rid` takes priority and creates a
|
||||||
|
task from the cached resolve result. A member's `opts`, when present, replaces
|
||||||
|
batch `opts` as a whole; omitted fields do not inherit individual batch values.
|
||||||
|
Members without `opts` use batch `opts`. BT file selections use the indexes from
|
||||||
|
the resolve result. Requests without `rid` continue to work.
|
||||||
|
|
||||||
|
For groups, batch `opts.path` selects the parent directory. Members inheriting
|
||||||
|
batch options, or with an empty path, use the reserved group directory. Explicit
|
||||||
|
relative member paths are resolved under that directory; do not prefix them with
|
||||||
|
the batch parent path. For example, parent `downloads` and member path `nested`
|
||||||
|
place the member in `downloads/<group name>/nested`. Absolute paths under the
|
||||||
|
parent directory preserve their relative structure within the group. In ordinary
|
||||||
|
batches, an empty member path uses the configured default download directory.
|
||||||
|
|
||||||
|
All members are attempted. If at least one task is persisted, the response is
|
||||||
|
successful and contains the created task IDs. A task that fails to start after
|
||||||
|
being persisted is included; use the existing task controls to retry it.
|
||||||
|
Successful tasks are kept when other members fail. If every member fails, the
|
||||||
|
API returns an error and removes any newly reserved empty group and directory.
|
||||||
|
|
||||||
|
Both ordinary and grouped batches now return an object in `data`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"code": 0, "msg": "", "data": {"taskIds": ["task-1", "task-2"], "parentId": "group-id"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ordinary batches omit `parentId`. **This replaces the former array response for
|
||||||
|
ordinary batches.** The outer `code`/`msg`/`data` envelope is unchanged. Clients
|
||||||
|
must read `data.taskIds`; there are no per-member results. A successful response
|
||||||
|
completes the batch, including partial success, so clients must not automatically
|
||||||
|
resubmit missing members.
|
||||||
|
|
||||||
|
With `group: true`, the server generates the group ID and reserves its directory.
|
||||||
|
Creation does not accept a client group ID or append to an existing group. Group
|
||||||
|
metadata remains available through the existing task list and detail responses;
|
||||||
|
`parentId` identifies membership for group operations.
|
||||||
+7
-1
@@ -1,6 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/GopeedLab/gopeed/cmd"
|
"github.com/GopeedLab/gopeed/cmd"
|
||||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||||
)
|
)
|
||||||
@@ -13,5 +16,8 @@ func main() {
|
|||||||
Storage: model.StorageBolt,
|
Storage: model.StorageBolt,
|
||||||
WebEnable: true,
|
WebEnable: true,
|
||||||
}
|
}
|
||||||
cmd.Start(cfg)
|
if err := cmd.Start(cfg); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+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)
|
||||||
|
|||||||
+14
-6
@@ -16,17 +16,18 @@ import (
|
|||||||
//go:embed banner.txt
|
//go:embed banner.txt
|
||||||
var banner string
|
var banner string
|
||||||
|
|
||||||
func Start(cfg *model.StartConfig) {
|
func Start(cfg *model.StartConfig) error {
|
||||||
fmt.Println(banner)
|
|
||||||
srv, listener, err := rest.BuildServer(cfg)
|
srv, listener, err := rest.BuildServer(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
return err
|
||||||
}
|
}
|
||||||
|
fmt.Println(banner)
|
||||||
downloadCfg, err := rest.Downloader.GetConfig()
|
downloadCfg, err := rest.Downloader.GetConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
listener.Close()
|
||||||
|
return 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,13 +52,20 @@ 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()
|
||||||
|
|
||||||
fmt.Printf("Server start success on http://%s\n", listener.Addr().String())
|
fmt.Printf("Server start success on http://%s\n", listener.Addr().String())
|
||||||
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
|
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||||
panic(err)
|
return err
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func watchExit() {
|
func watchExit() {
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStartInvalidWebAPITokenReturnsError(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
webAuth *model.WebAuth
|
||||||
|
}{
|
||||||
|
{name: "no credentials"},
|
||||||
|
{name: "missing username", webAuth: &model.WebAuth{Password: "secret"}},
|
||||||
|
{name: "missing password", webAuth: &model.WebAuth{Username: "gopeed"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := Start(&model.StartConfig{
|
||||||
|
WebEnable: true,
|
||||||
|
ApiToken: "test-token",
|
||||||
|
WebAuth: tt.webAuth,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Start() error = nil, want web authentication validation error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "web authentication username and password are required") {
|
||||||
|
t.Fatalf("Start() error = %q, want missing web authentication credentials", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -52,8 +52,8 @@ func loadCliArgs() *args {
|
|||||||
flag.StringVar(cfg.Username, "username", "gopeed", "Web Authentication Username")
|
flag.StringVar(cfg.Username, "username", "gopeed", "Web Authentication Username")
|
||||||
cfg.Password = flag.String("p", "", "Web Authentication Password, if no password is set, web authentication will not be enabled")
|
cfg.Password = flag.String("p", "", "Web Authentication Password, if no password is set, web authentication will not be enabled")
|
||||||
flag.StringVar(cfg.Password, "password", "", "Web Authentication Password, if no password is set, web authentication will not be enabled")
|
flag.StringVar(cfg.Password, "password", "", "Web Authentication Password, if no password is set, web authentication will not be enabled")
|
||||||
cfg.ApiToken = flag.String("T", "", "API token, it must be configured when using HTTP API in the case of enabling web authentication")
|
cfg.ApiToken = flag.String("T", "", "API token (requires web authentication with username and password)")
|
||||||
flag.StringVar(cfg.ApiToken, "api-token", "", "API token, it must be configured when using HTTP API in the case of enabling web authentication")
|
flag.StringVar(cfg.ApiToken, "api-token", "", "API token (requires web authentication with username and password)")
|
||||||
flag.BoolVar(&cfg.MCPEnable, "mcp-enable", false, "Enable MCP endpoint")
|
flag.BoolVar(&cfg.MCPEnable, "mcp-enable", false, "Enable MCP endpoint")
|
||||||
cfg.TempDir = flag.String("temp-dir", "", "Temporary directory (defaults to the OS temporary directory)")
|
cfg.TempDir = flag.String("temp-dir", "", "Temporary directory (defaults to the OS temporary directory)")
|
||||||
cfg.StorageDir = flag.String("d", "", "Storage directory")
|
cfg.StorageDir = flag.String("d", "", "Storage directory")
|
||||||
|
|||||||
+4
-1
@@ -62,7 +62,10 @@ func main() {
|
|||||||
WebFS: sub,
|
WebFS: sub,
|
||||||
WebAuth: webAuth,
|
WebAuth: webAuth,
|
||||||
}
|
}
|
||||||
cmd.Start(cfg)
|
if err := cmd.Start(cfg); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func isNotBlank(str *string) bool {
|
func isNotBlank(str *string) bool {
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ require (
|
|||||||
github.com/bodgit/sevenzip v1.6.1
|
github.com/bodgit/sevenzip v1.6.1
|
||||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
||||||
github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc
|
github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc
|
||||||
|
github.com/fclairamb/ftpserverlib v0.31.0
|
||||||
github.com/go-git/go-git/v5 v5.8.1
|
github.com/go-git/go-git/v5 v5.8.1
|
||||||
github.com/google/jsonschema-go v0.4.3
|
github.com/google/jsonschema-go v0.4.3
|
||||||
github.com/gorilla/handlers v1.5.1
|
github.com/gorilla/handlers v1.5.1
|
||||||
github.com/gorilla/mux v1.8.0
|
github.com/gorilla/mux v1.8.0
|
||||||
github.com/imroc/req/v3 v3.52.2
|
github.com/imroc/req/v3 v3.52.2
|
||||||
|
github.com/jlaffaye/ftp v0.2.4
|
||||||
github.com/matoous/go-nanoid/v2 v2.0.0
|
github.com/matoous/go-nanoid/v2 v2.0.0
|
||||||
github.com/mattn/go-ieproxy v0.0.12
|
github.com/mattn/go-ieproxy v0.0.12
|
||||||
github.com/mholt/archives v0.1.5
|
github.com/mholt/archives v0.1.5
|
||||||
@@ -23,6 +25,7 @@ require (
|
|||||||
github.com/pkg/errors v0.9.1
|
github.com/pkg/errors v0.9.1
|
||||||
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529
|
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529
|
||||||
github.com/rs/zerolog v1.31.0
|
github.com/rs/zerolog v1.31.0
|
||||||
|
github.com/spf13/afero v1.15.0
|
||||||
github.com/tetratelabs/wazero v1.12.0
|
github.com/tetratelabs/wazero v1.12.0
|
||||||
github.com/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad
|
github.com/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad
|
||||||
go.etcd.io/bbolt v1.4.3
|
go.etcd.io/bbolt v1.4.3
|
||||||
@@ -59,13 +62,13 @@ require (
|
|||||||
github.com/segmentio/asm v1.1.3 // indirect
|
github.com/segmentio/asm v1.1.3 // indirect
|
||||||
github.com/segmentio/encoding v0.5.4 // indirect
|
github.com/segmentio/encoding v0.5.4 // indirect
|
||||||
github.com/sorairolake/lzip-go v0.3.8 // indirect
|
github.com/sorairolake/lzip-go v0.3.8 // indirect
|
||||||
github.com/spf13/afero v1.15.0 // indirect
|
|
||||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
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 +159,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
|
||||||
|
|||||||
@@ -205,6 +205,8 @@ github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc
|
|||||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
|
github.com/fclairamb/ftpserverlib v0.31.0 h1:ws4fLJvuU/mgg1uIgRjCJfc6CrFSgIBAOxVsnawXegE=
|
||||||
|
github.com/fclairamb/ftpserverlib v0.31.0/go.mod h1:C5hUT0sd9P5XsHMxsmpcrT6wdZexu3qtUu1z0Ih6aG0=
|
||||||
github.com/felixge/fgprof v0.9.5 h1:8+vR6yu2vvSKn08urWyEuxx75NWPEvybbkBirEpsbVY=
|
github.com/felixge/fgprof v0.9.5 h1:8+vR6yu2vvSKn08urWyEuxx75NWPEvybbkBirEpsbVY=
|
||||||
github.com/felixge/fgprof v0.9.5/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM=
|
github.com/felixge/fgprof v0.9.5/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM=
|
||||||
github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ=
|
github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ=
|
||||||
@@ -347,6 +349,8 @@ github.com/imroc/req/v3 v3.52.2 h1:xJocr1aIv0a2K9knfBQ4JnZHk+kWTITdjf0mgDg229I=
|
|||||||
github.com/imroc/req/v3 v3.52.2/go.mod h1:dBGsDloOSZJcFs6PnTjZXYBJK70OXbZpizHBLNqcH2k=
|
github.com/imroc/req/v3 v3.52.2/go.mod h1:dBGsDloOSZJcFs6PnTjZXYBJK70OXbZpizHBLNqcH2k=
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||||
|
github.com/jlaffaye/ftp v0.2.4 h1:JqI85DdkfZj8ntaHk8W9U2SC3jNfiPUU70+wtIWmlfE=
|
||||||
|
github.com/jlaffaye/ftp v0.2.4/go.mod h1:Y1ZnkzxownGIuX7xQ1mQzzkZ21+DbjVIyeKL/V+IIz4=
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||||
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||||
@@ -527,6 +531,8 @@ github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWR
|
|||||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
||||||
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8=
|
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8=
|
||||||
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
|
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
|
||||||
|
github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
|
||||||
|
github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY=
|
||||||
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||||
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||||
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||||
@@ -566,6 +572,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
|
|||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
|
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||||
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
|
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
|
||||||
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
|
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
|
||||||
github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA=
|
github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA=
|
||||||
@@ -612,6 +620,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
|||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.uber.org/mock v0.5.1 h1:ASgazW/qBmR+A32MYFDB6E2POoTgOwT509VP0CT/fjs=
|
go.uber.org/mock v0.5.1 h1:ASgazW/qBmR+A32MYFDB6E2POoTgOwT509VP0CT/fjs=
|
||||||
go.uber.org/mock v0.5.1/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
go.uber.org/mock v0.5.1/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc=
|
go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc=
|
||||||
go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU=
|
go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU=
|
||||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package controller
|
|||||||
import (
|
import (
|
||||||
"github.com/GopeedLab/gopeed/internal/tempfiles"
|
"github.com/GopeedLab/gopeed/internal/tempfiles"
|
||||||
"github.com/GopeedLab/gopeed/pkg/base"
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -15,6 +16,9 @@ type Controller struct {
|
|||||||
TempDir string
|
TempDir string
|
||||||
GetConfig func(v any)
|
GetConfig func(v any)
|
||||||
GetProxy func(requestProxy *base.RequestProxy) func(*http.Request) (*url.URL, error)
|
GetProxy func(requestProxy *base.RequestProxy) func(*http.Request) (*url.URL, error)
|
||||||
|
// Logger is the downloader's core logger. It may be nil, in which case
|
||||||
|
// fetchers log nothing.
|
||||||
|
Logger *zerolog.Logger
|
||||||
FileController
|
FileController
|
||||||
//ContextDialer() (proxy.Dialer, error)
|
//ContextDialer() (proxy.Dialer, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
+241
-114
File diff suppressed because it is too large.
Load diff
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/GopeedLab/gopeed/internal/controller"
|
"github.com/GopeedLab/gopeed/internal/controller"
|
||||||
@@ -18,11 +19,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 +32,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 +54,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 +62,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",
|
||||||
@@ -71,6 +72,82 @@ func TestFetcher_ResolveWithProxy(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFetcher_CloseAfterInit(t *testing.T) {
|
||||||
|
if err := closeClient(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
f := buildFetcher(t).(*Fetcher)
|
||||||
|
f.meta.Req = &base.Request{}
|
||||||
|
lock.Lock()
|
||||||
|
err := f.initClient()
|
||||||
|
lock.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := closeClient(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetcher_CloseWithoutClient(t *testing.T) {
|
||||||
|
if err := closeClient(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("restored task", func(t *testing.T) {
|
||||||
|
_, build := new(FetcherManager).Restore()
|
||||||
|
f := build(&fetcher.FetcherMeta{
|
||||||
|
Req: &base.Request{URL: "./testdata/test.torrent"},
|
||||||
|
Opts: &base.Options{},
|
||||||
|
}, &fetcherData{})
|
||||||
|
f.Setup(controller.NewController())
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
t.Errorf("Close() got = %v, want nil", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("failed resolve", func(t *testing.T) {
|
||||||
|
f := buildFetcher(t)
|
||||||
|
if err := f.Resolve(&base.Request{URL: "./testdata/not-exist.torrent"}); err == nil {
|
||||||
|
t.Fatal("Resolve() got = nil, want error")
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
t.Errorf("Close() got = %v, want nil", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetcher_ResolveWhileClientCloses(t *testing.T) {
|
||||||
|
if err := closeClient(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { closeClient() })
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, u := range []string{"./testdata/test.torrent", "./testdata/ubuntu-22.04-live-server-amd64.iso.torrent"} {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
f := buildFetcher(t)
|
||||||
|
// The other goroutine may close the client after the torrent was added.
|
||||||
|
if err := f.Resolve(&base.Request{URL: u}); err != nil && err.Error() != "torrent closed during resolution" {
|
||||||
|
t.Errorf("Resolve(%s) got = %v", u, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
if err := closeClient(); err != nil {
|
||||||
|
t.Errorf("closeClient() got = %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
|
func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
|
||||||
t.Run("Resolve Single File", func(t *testing.T) {
|
t.Run("Resolve Single File", func(t *testing.T) {
|
||||||
err := fetcher.Resolve(&base.Request{
|
err := fetcher.Resolve(&base.Request{
|
||||||
@@ -81,7 +158,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 +188,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 +222,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 +233,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 +288,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 +296,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 +315,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 +445,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
package ftp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/textproto"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jlaffaye/ftp"
|
||||||
|
"golang.org/x/net/proxy"
|
||||||
|
|
||||||
|
pftp "github.com/GopeedLab/gopeed/pkg/protocol/ftp"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
connectTimeout = 15 * time.Second
|
||||||
|
readTimeout = 15 * time.Second
|
||||||
|
quitTimeout = 2 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// target is a parsed FTP URL. It holds the password, so it must never be
|
||||||
|
// formatted into an error or a log line.
|
||||||
|
type target struct {
|
||||||
|
host string // host name or IP, the TLS server name
|
||||||
|
addr string // host:port
|
||||||
|
user string
|
||||||
|
pass string
|
||||||
|
path string // path relative to the login directory, "" for the directory itself
|
||||||
|
tlsMode string
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTarget reads an ftp, ftps or ftpes URL. The path follows RFC 1738: it
|
||||||
|
// is relative to the login directory, and an encoded leading slash
|
||||||
|
// (ftp://host/%2Fpub) makes it absolute.
|
||||||
|
func parseTarget(rawURL, optTLS string) (*target, error) {
|
||||||
|
u, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
// url.Error repeats the whole URL, password included.
|
||||||
|
return nil, errors.New("ftp: invalid URL")
|
||||||
|
}
|
||||||
|
t := &target{}
|
||||||
|
switch strings.ToLower(u.Scheme) {
|
||||||
|
case "ftps":
|
||||||
|
t.tlsMode = pftp.TLSImplicit
|
||||||
|
case "ftpes":
|
||||||
|
t.tlsMode = pftp.TLSExplicit
|
||||||
|
case "ftp":
|
||||||
|
switch strings.ToLower(optTLS) {
|
||||||
|
case "", pftp.TLSNone:
|
||||||
|
t.tlsMode = pftp.TLSNone
|
||||||
|
case pftp.TLSExplicit:
|
||||||
|
t.tlsMode = pftp.TLSExplicit
|
||||||
|
case pftp.TLSImplicit:
|
||||||
|
t.tlsMode = pftp.TLSImplicit
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("ftp: unknown tls mode %q", optTLS)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("ftp: unsupported scheme %q", u.Scheme)
|
||||||
|
}
|
||||||
|
t.host = u.Hostname()
|
||||||
|
if t.host == "" {
|
||||||
|
return nil, errors.New("ftp: URL has no host")
|
||||||
|
}
|
||||||
|
port := u.Port()
|
||||||
|
if port == "" {
|
||||||
|
port = "21"
|
||||||
|
if t.tlsMode == pftp.TLSImplicit {
|
||||||
|
port = "990"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.addr = net.JoinHostPort(t.host, port)
|
||||||
|
if u.User != nil {
|
||||||
|
// Username and Password are already percent-decoded.
|
||||||
|
t.user = u.User.Username()
|
||||||
|
t.pass, _ = u.User.Password()
|
||||||
|
}
|
||||||
|
if t.user == "" {
|
||||||
|
t.user, t.pass = "anonymous", "anonymous@"
|
||||||
|
}
|
||||||
|
t.path = strings.TrimPrefix(u.Path, "/")
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type dialContextFunc func(ctx context.Context, network, addr string) (net.Conn, error)
|
||||||
|
|
||||||
|
// session is one logged-in control connection. It owns the network
|
||||||
|
// connections it opens, so abort can unblock any read or write in progress.
|
||||||
|
type session struct {
|
||||||
|
*ftp.ServerConn
|
||||||
|
|
||||||
|
ctrl net.Conn // control connection as dialed, under any TLS layer
|
||||||
|
dataHost string // where data connections go
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
data net.Conn // current data connection as dialed
|
||||||
|
closed bool
|
||||||
|
stop func() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// deadline bounds the next control-connection exchange. The control
|
||||||
|
// connection is idle during a transfer, so every exchange sets a new one.
|
||||||
|
func (s *session) deadline() {
|
||||||
|
_ = s.ctrl.SetDeadline(time.Now().Add(readTimeout))
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearDeadline lets the control connection stay idle through a transfer.
|
||||||
|
func (s *session) clearDeadline() {
|
||||||
|
_ = s.ctrl.SetDeadline(time.Time{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// abort closes every connection at once. Pending calls fail immediately.
|
||||||
|
func (s *session) abort() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.closed = true
|
||||||
|
_ = s.ctrl.Close()
|
||||||
|
if s.data != nil {
|
||||||
|
_ = s.data.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// quit logs out and waits, briefly, for the server to hang up. Servers that
|
||||||
|
// cap logins count a session until they have seen it end, so the next login
|
||||||
|
// would otherwise be refused.
|
||||||
|
func (s *session) quit() {
|
||||||
|
if s.stop != nil {
|
||||||
|
s.stop()
|
||||||
|
}
|
||||||
|
_ = s.ctrl.SetDeadline(time.Now().Add(quitTimeout))
|
||||||
|
if s.ServerConn != nil {
|
||||||
|
_ = s.ServerConn.Quit()
|
||||||
|
}
|
||||||
|
s.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *session) setData(c net.Conn) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.closed {
|
||||||
|
_ = c.Close()
|
||||||
|
return net.ErrClosed
|
||||||
|
}
|
||||||
|
s.data = c
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ctrlConn is the control connection handed to the ftp library.
|
||||||
|
type ctrlConn struct {
|
||||||
|
net.Conn
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoteAddr must be a *net.TCPAddr: the library asserts it. A connection
|
||||||
|
// through a proxy may report something else, and its address is not used
|
||||||
|
// for data connections anyway (see dialData).
|
||||||
|
func (c ctrlConn) RemoteAddr() net.Addr {
|
||||||
|
if a, ok := c.Conn.RemoteAddr().(*net.TCPAddr); ok {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return &net.TCPAddr{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close is called by the library's Quit right after it sends QUIT. Reading
|
||||||
|
// until the server hangs up (bounded by the deadline quit set) tells us the
|
||||||
|
// server has ended the session.
|
||||||
|
func (c ctrlConn) Close() error {
|
||||||
|
_, _ = io.Copy(io.Discard, c.Conn)
|
||||||
|
return c.Conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// dial opens a control connection, upgrades it to TLS when asked, and logs in.
|
||||||
|
//
|
||||||
|
// The library is given a dial function for every connection. That is how
|
||||||
|
// data connections go through the same proxy as the control connection, but
|
||||||
|
// it also means the library does no TLS on them: with a dial function it
|
||||||
|
// neither wraps an implicit-TLS control connection nor any data connection.
|
||||||
|
// We do both here. Explicit TLS (AUTH TLS) is still done by the library, and
|
||||||
|
// so are PBSZ and PROT P after login. For the same reason the library's
|
||||||
|
// DialWithTimeout and DialWithContext would have no effect: connectTimeout
|
||||||
|
// and ctx are applied to our own dials instead.
|
||||||
|
func (f *Fetcher) dial(ctx context.Context, t *target) (*session, error) {
|
||||||
|
netDial, proxied, err := f.netDialer(t)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var tlsConf *tls.Config
|
||||||
|
if t.tlsMode != pftp.TLSNone {
|
||||||
|
tlsConf = f.tlsConfig(t.host)
|
||||||
|
}
|
||||||
|
|
||||||
|
dialCtx, cancel := context.WithTimeout(ctx, connectTimeout)
|
||||||
|
defer cancel()
|
||||||
|
raw, err := netDial(dialCtx, "tcp", t.addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ctrl := raw
|
||||||
|
if t.tlsMode == pftp.TLSImplicit {
|
||||||
|
tc := tls.Client(raw, tlsConf)
|
||||||
|
if err := tc.HandshakeContext(dialCtx); err != nil {
|
||||||
|
_ = raw.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ctrl = tc
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &session{ctrl: raw, dataHost: t.host}
|
||||||
|
if a, ok := raw.RemoteAddr().(*net.TCPAddr); ok && !proxied {
|
||||||
|
// Same IP as the control connection, as the library itself does,
|
||||||
|
// so a name with several addresses cannot send us elsewhere.
|
||||||
|
s.dataHost = a.IP.String()
|
||||||
|
}
|
||||||
|
s.stop = context.AfterFunc(ctx, s.abort)
|
||||||
|
s.deadline()
|
||||||
|
|
||||||
|
handedOut := false
|
||||||
|
opts := []ftp.DialOption{
|
||||||
|
ftp.DialWithShutTimeout(readTimeout),
|
||||||
|
ftp.DialWithDialFunc(func(network, addr string) (net.Conn, error) {
|
||||||
|
if !handedOut {
|
||||||
|
// Dial asks for the control connection first, once.
|
||||||
|
handedOut = true
|
||||||
|
return ctrlConn{ctrl}, nil
|
||||||
|
}
|
||||||
|
return s.dialData(ctx, netDial, tlsConf, addr)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
switch t.tlsMode {
|
||||||
|
case pftp.TLSImplicit:
|
||||||
|
opts = append(opts, ftp.DialWithTLS(tlsConf))
|
||||||
|
case pftp.TLSExplicit:
|
||||||
|
opts = append(opts, ftp.DialWithExplicitTLS(tlsConf))
|
||||||
|
}
|
||||||
|
c, err := ftp.Dial(t.addr, opts...)
|
||||||
|
if err != nil {
|
||||||
|
s.stop()
|
||||||
|
s.abort()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.ServerConn = c
|
||||||
|
s.deadline()
|
||||||
|
if err := c.Login(t.user, t.pass); err != nil {
|
||||||
|
s.stop()
|
||||||
|
s.abort()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialData opens a passive data connection. The library takes the host from
|
||||||
|
// the control connection's peer, which through a proxy is the proxy, so only
|
||||||
|
// the port is used here.
|
||||||
|
func (s *session) dialData(ctx context.Context, netDial dialContextFunc, tlsConf *tls.Config, addr string) (net.Conn, error) {
|
||||||
|
_, port, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dialCtx, cancel := context.WithTimeout(ctx, connectTimeout)
|
||||||
|
defer cancel()
|
||||||
|
raw, err := netDial(dialCtx, "tcp", net.JoinHostPort(s.dataHost, port))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.setData(raw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if tlsConf != nil {
|
||||||
|
// The handshake runs on the first read, as in the library, because
|
||||||
|
// some servers only start it once the transfer command is answered.
|
||||||
|
return tls.Client(raw, tlsConf), nil
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tlsConfig returns the TLS settings for one control connection and its data
|
||||||
|
// connections. Certificates are verified unless the request sets
|
||||||
|
// skipVerifyCert, as for HTTP.
|
||||||
|
//
|
||||||
|
// Each call gets its own one-entry session cache, so a data connection
|
||||||
|
// resumes the TLS session of its own control connection. vsftpd
|
||||||
|
// (require_ssl_reuse) only checks that some session was resumed, but ProFTPD's
|
||||||
|
// mod_tls also checks that it is the control connection's; a cache shared by
|
||||||
|
// several workers would hand a data connection another worker's session.
|
||||||
|
// A cache in the manager's base config is kept as given.
|
||||||
|
func (f *Fetcher) tlsConfig(host string) *tls.Config {
|
||||||
|
cfg := &tls.Config{}
|
||||||
|
if f.manager != nil && f.manager.baseTLS != nil {
|
||||||
|
cfg = f.manager.baseTLS.Clone()
|
||||||
|
}
|
||||||
|
cfg.ServerName = host
|
||||||
|
if f.meta.Req != nil && f.meta.Req.SkipVerifyCert {
|
||||||
|
cfg.InsecureSkipVerify = true
|
||||||
|
}
|
||||||
|
if cfg.ClientSessionCache == nil {
|
||||||
|
cfg.ClientSessionCache = tls.NewLRUClientSessionCache(1)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// netDialer picks how to reach the server, from the same proxy settings the
|
||||||
|
// HTTP protocol reads (the task's proxy, otherwise the global one).
|
||||||
|
func (f *Fetcher) netDialer(t *target) (dialContextFunc, bool, error) {
|
||||||
|
direct := (&net.Dialer{Timeout: connectTimeout}).DialContext
|
||||||
|
pu, err := f.proxyURL(t)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if pu == nil {
|
||||||
|
return direct, false, nil
|
||||||
|
}
|
||||||
|
switch strings.ToLower(pu.Scheme) {
|
||||||
|
case "socks5", "socks5h":
|
||||||
|
d, err := proxy.FromURL(pu, &net.Dialer{Timeout: connectTimeout})
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("ftp: socks5 proxy: %w", err)
|
||||||
|
}
|
||||||
|
if cd, ok := d.(proxy.ContextDialer); ok {
|
||||||
|
return cd.DialContext, true, nil
|
||||||
|
}
|
||||||
|
return func(_ context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return d.Dial(network, addr)
|
||||||
|
}, true, nil
|
||||||
|
default:
|
||||||
|
// An HTTP proxy is not used. Every passive data connection goes to
|
||||||
|
// a port the server picks, and HTTP proxies usually allow CONNECT to
|
||||||
|
// port 443 only, so the transfer would fail after the login worked.
|
||||||
|
f.warnOnce.Do(func() {
|
||||||
|
if l := f.logger(); l != nil {
|
||||||
|
l.Warn().Msgf("ftp: the %s proxy at %s cannot carry FTP data connections (HTTP proxy), connecting directly; use a SOCKS5 proxy for FTP", strings.ToLower(pu.Scheme), pu.Host)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return direct, false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) proxyURL(t *target) (*url.URL, error) {
|
||||||
|
if f.ctl == nil || f.ctl.GetProxy == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
handler := f.ctl.GetProxy(f.meta.Req.Proxy)
|
||||||
|
if handler == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
// The proxy chooser reads only the URL; the credentials stay out.
|
||||||
|
req := &http.Request{URL: &url.URL{Scheme: "ftp", Host: t.addr, Path: "/" + t.path}, Header: http.Header{}}
|
||||||
|
pu, err := handler(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("ftp: cannot read the proxy settings")
|
||||||
|
}
|
||||||
|
return pu, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLoginRefusal tells a server that is full apart from bad credentials.
|
||||||
|
// 421 always means "not now". 530 means a refused login; it is only a
|
||||||
|
// capacity limit when these credentials have worked before. The library
|
||||||
|
// drops the code when USER itself is refused, so a "too many" message counts
|
||||||
|
// as well.
|
||||||
|
func isLoginRefusal(err error, credentialsWorked bool) bool {
|
||||||
|
var te *textproto.Error
|
||||||
|
if errors.As(err, &te) {
|
||||||
|
switch te.Code {
|
||||||
|
case ftp.StatusNotAvailable:
|
||||||
|
return true
|
||||||
|
case ftp.StatusNotLoggedIn:
|
||||||
|
return credentialsWorked || strings.Contains(strings.ToLower(te.Msg), "too many")
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(strings.ToLower(err.Error()), "too many")
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPermanent reports errors a retry cannot fix: a 5xx reply and a
|
||||||
|
// certificate that does not verify.
|
||||||
|
func isPermanent(err error) bool {
|
||||||
|
var te *textproto.Error
|
||||||
|
if errors.As(err, &te) {
|
||||||
|
return te.Code >= 500
|
||||||
|
}
|
||||||
|
var verifyErr *tls.CertificateVerificationError
|
||||||
|
if errors.As(err, &verifyErr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var recordErr tls.RecordHeaderError
|
||||||
|
if errors.As(err, &recordErr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return errors.Is(err, errSizeChanged)
|
||||||
|
}
|
||||||
|
|
||||||
|
func replyCode(err error) int {
|
||||||
|
var te *textproto.Error
|
||||||
|
if errors.As(err, &te) {
|
||||||
|
return te.Code
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package ftp_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
|
||||||
|
"github.com/GopeedLab/gopeed/internal/logger"
|
||||||
|
iftp "github.com/GopeedLab/gopeed/internal/protocol/ftp"
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/download"
|
||||||
|
pftp "github.com/GopeedLab/gopeed/pkg/protocol/ftp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// syncBuffer is a log sink that the downloader's goroutines can share.
|
||||||
|
type syncBuffer struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
buf bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *syncBuffer) Write(p []byte) (int, error) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return b.buf.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *syncBuffer) String() string {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return b.buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// taskEvents records the first done or error event of every task, so the
|
||||||
|
// test never reads a task's status while the downloader writes it.
|
||||||
|
type taskEvents struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
final map[string]download.EventKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *taskEvents) listen(ev *download.Event) {
|
||||||
|
if ev.Key != download.EventKeyDone && ev.Key != download.EventKeyError {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
if _, seen := e.final[ev.Task.ID]; !seen {
|
||||||
|
e.final[ev.Task.ID] = ev.Key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *taskEvents) wait(t *testing.T, id string) download.EventKey {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(60 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
e.mu.Lock()
|
||||||
|
key, ok := e.final[id]
|
||||||
|
e.mu.Unlock()
|
||||||
|
if ok {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("task %s did not finish", id)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPasswordNeverLogged runs FTP tasks through the downloader with its core
|
||||||
|
// logger captured: a success, a missing file and a wrong password, all behind
|
||||||
|
// an HTTP proxy that FTP must skip with a warning.
|
||||||
|
func TestPasswordNeverLogged(t *testing.T) {
|
||||||
|
srv := iftp.StartFTPServer(t, iftp.ServerOptions{User: "u", Pass: "p@ss"})
|
||||||
|
want := srv.WriteFile(t, "secret.bin", iftp.RandomBytes(300000, 21))
|
||||||
|
|
||||||
|
d := download.NewDownloader(&download.DownloaderConfig{Storage: download.NewMemStorage(), StorageDir: t.TempDir()})
|
||||||
|
logs := &syncBuffer{}
|
||||||
|
d.Logger = &logger.Logger{Logger: zerolog.New(logs)}
|
||||||
|
events := &taskEvents{final: map[string]download.EventKey{}}
|
||||||
|
d.Listener(events.listen)
|
||||||
|
if err := d.Setup(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Close, not Clear: Clear resets the extension list without a lock while
|
||||||
|
// the last task's done hooks may still read it.
|
||||||
|
defer d.Close()
|
||||||
|
cfg, err := d.GetConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg.Proxy = &base.DownloaderProxyConfig{Enable: true, Scheme: "http", Host: "127.0.0.1:1", Usr: "proxyuser", Pwd: "proxysecret"}
|
||||||
|
if err := d.PutConfig(cfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
create := func(rawURL string) string {
|
||||||
|
id, err := d.CreateDirect(&base.Request{URL: rawURL}, &base.Options{Path: dir, Extra: &pftp.OptsExtra{Connections: 2}})
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "p@ss") || strings.Contains(err.Error(), "p%40ss") {
|
||||||
|
t.Fatalf("the password leaked into an error: %v", err)
|
||||||
|
}
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
root := "ftp://u:p%40ss@" + srv.Addr + "/"
|
||||||
|
|
||||||
|
okID := create(root + "secret.bin")
|
||||||
|
if key := events.wait(t, okID); key != download.EventKeyDone {
|
||||||
|
t.Fatalf("download ended with %q, logs:\n%s", key, logs.String())
|
||||||
|
}
|
||||||
|
ok := d.GetTask(okID)
|
||||||
|
if ok.Protocol != "ftp" {
|
||||||
|
t.Fatalf("protocol %q, want ftp", ok.Protocol)
|
||||||
|
}
|
||||||
|
if got := iftp.FileSHA256(t, ok.Meta.SingleFilepath()); got != want {
|
||||||
|
t.Fatalf("sha256 %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
missingID := create(root + "missing.bin")
|
||||||
|
if key := events.wait(t, missingID); key != download.EventKeyError {
|
||||||
|
t.Fatalf("a missing file must fail, got %q", key)
|
||||||
|
}
|
||||||
|
wrongID := create("ftp://u:wr0ng%40pw@" + srv.Addr + "/secret.bin")
|
||||||
|
if key := events.wait(t, wrongID); key != download.EventKeyError {
|
||||||
|
t.Fatalf("a wrong password must fail, got %q", key)
|
||||||
|
}
|
||||||
|
missing, wrong := d.GetTask(missingID), d.GetTask(wrongID)
|
||||||
|
for _, task := range []*download.Task{ok, missing, wrong} {
|
||||||
|
if name := task.Name(); strings.Contains(name, "p@ss") || strings.Contains(name, "p%40ss") || strings.Contains(name, "wr0ng") {
|
||||||
|
t.Fatalf("task name %q shows the password", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := logs.String()
|
||||||
|
if !strings.Contains(out, "task id") || !strings.Contains(out, "HTTP proxy") {
|
||||||
|
t.Fatalf("the captured log misses the expected lines, so it proves nothing:\n%s", out)
|
||||||
|
}
|
||||||
|
for _, secret := range []string{"p@ss", "p%40ss", "wr0ng", "proxysecret"} {
|
||||||
|
if strings.Contains(out, secret) {
|
||||||
|
t.Fatalf("%q appears in the log:\n%s", secret, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,109 @@
|
|||||||
|
package ftp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
|
pftp "github.com/GopeedLab/gopeed/pkg/protocol/ftp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fetcherData is what a task saves to resume later. Segments has the same
|
||||||
|
// Begin, End and Downloaded fields every version writes; the other fields are
|
||||||
|
// optional, so a save without them still restores.
|
||||||
|
type fetcherData struct {
|
||||||
|
Segments []*segment
|
||||||
|
// MaxConns is the login ceiling the server enforced, 0 when none was hit.
|
||||||
|
MaxConns int `json:",omitempty"`
|
||||||
|
// NoRest records that the server refused REST, so the task downloads each
|
||||||
|
// file in one sequential stream.
|
||||||
|
NoRest bool `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetcherManager handles ftp://, ftps:// (implicit TLS) and ftpes:// (explicit
|
||||||
|
// TLS) URLs.
|
||||||
|
type FetcherManager struct {
|
||||||
|
// baseTLS is the base for every TLS connection. Nil means the system
|
||||||
|
// roots; the package's tests set it to trust their own certificate. The
|
||||||
|
// task's skipVerifyCert still turns verification off.
|
||||||
|
baseTLS *tls.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *FetcherManager) Name() string {
|
||||||
|
return "ftp"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *FetcherManager) Filters() []*fetcher.SchemeFilter {
|
||||||
|
return []*fetcher.SchemeFilter{
|
||||||
|
{Type: fetcher.FilterTypeUrl, Pattern: "FTP"},
|
||||||
|
{Type: fetcher.FilterTypeUrl, Pattern: "FTPS"},
|
||||||
|
{Type: fetcher.FilterTypeUrl, Pattern: "FTPES"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *FetcherManager) Build() fetcher.Fetcher {
|
||||||
|
return &Fetcher{manager: fm}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseName names a task before it is resolved. It never includes the
|
||||||
|
// userinfo of the URL.
|
||||||
|
func (fm *FetcherManager) ParseName(u string) string {
|
||||||
|
parsed, err := url.Parse(u)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
name := path.Base(strings.TrimSuffix(parsed.Path, "/"))
|
||||||
|
if name == "" || name == "/" || name == "." {
|
||||||
|
name = parsed.Hostname()
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *FetcherManager) AutoRename() bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *FetcherManager) DefaultConfig() any {
|
||||||
|
return &config{Connections: defaultConnections}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *FetcherManager) Store(f fetcher.Fetcher) (any, error) {
|
||||||
|
ff := f.(*Fetcher)
|
||||||
|
ff.mu.Lock()
|
||||||
|
defer ff.mu.Unlock()
|
||||||
|
segments := make([]*segment, len(ff.segments))
|
||||||
|
for i, s := range ff.segments {
|
||||||
|
c := *s
|
||||||
|
segments[i] = &c
|
||||||
|
}
|
||||||
|
return &fetcherData{
|
||||||
|
Segments: segments,
|
||||||
|
MaxConns: ff.maxConns,
|
||||||
|
NoRest: ff.noRest,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *FetcherManager) Restore() (v any, f func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher) {
|
||||||
|
return &fetcherData{}, func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher {
|
||||||
|
fd := v.(*fetcherData)
|
||||||
|
ff := fm.Build().(*Fetcher)
|
||||||
|
ff.meta = meta
|
||||||
|
if meta != nil && meta.Req != nil {
|
||||||
|
_ = base.ParseReqExtra[pftp.ReqExtra](meta.Req)
|
||||||
|
}
|
||||||
|
if meta != nil && meta.Opts != nil {
|
||||||
|
_ = base.ParseOptExtra[pftp.OptsExtra](meta.Opts)
|
||||||
|
}
|
||||||
|
ff.segments = fd.Segments
|
||||||
|
ff.maxConns = fd.MaxConns
|
||||||
|
ff.noRest = fd.NoRest
|
||||||
|
return ff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *FetcherManager) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,187 @@
|
|||||||
|
package ftp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"fmt"
|
||||||
|
"path"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jlaffaye/ftp"
|
||||||
|
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Limits of a directory download. A tree past either one is refused with an
|
||||||
|
// error that names the limit; it is never cut short.
|
||||||
|
const maxWalkDepth = 8
|
||||||
|
|
||||||
|
// maxWalkFiles is a variable so tests can lower it.
|
||||||
|
var maxWalkFiles = 10000
|
||||||
|
|
||||||
|
// resolveTarget describes the URL's path: one file, or a directory walked
|
||||||
|
// into a multi-file resource whose paths are relative to it.
|
||||||
|
func (f *Fetcher) resolveTarget(s *session, t *target) (*base.Resource, error) {
|
||||||
|
isDir, size, mtime, err := f.stat(s, t.path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !isDir {
|
||||||
|
return &base.Resource{
|
||||||
|
Size: size,
|
||||||
|
Range: true,
|
||||||
|
Files: []*base.FileInfo{{
|
||||||
|
Name: path.Base(t.path),
|
||||||
|
Size: size,
|
||||||
|
Ctime: mtime,
|
||||||
|
}},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := f.walk(s, t.path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
name := path.Base(strings.TrimSuffix(t.path, "/"))
|
||||||
|
if name == "" || name == "." || name == "/" {
|
||||||
|
name = t.host
|
||||||
|
}
|
||||||
|
res := &base.Resource{Name: name, Range: true, Files: files}
|
||||||
|
res.CalcSize(nil)
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stat tells a file from a directory. MLST answers both at once. Without
|
||||||
|
// it, SIZE answers for a file, and a directory is one we can change into.
|
||||||
|
func (f *Fetcher) stat(s *session, p string) (isDir bool, size int64, mtime *time.Time, err error) {
|
||||||
|
if p == "" || strings.HasSuffix(p, "/") {
|
||||||
|
return true, 0, nil, nil
|
||||||
|
}
|
||||||
|
s.deadline()
|
||||||
|
if entry, mlstErr := s.GetEntry(p); mlstErr == nil {
|
||||||
|
switch entry.Type {
|
||||||
|
case ftp.EntryTypeFolder:
|
||||||
|
return true, 0, nil, nil
|
||||||
|
case ftp.EntryTypeFile:
|
||||||
|
if !entry.Time.IsZero() {
|
||||||
|
t := entry.Time
|
||||||
|
mtime = &t
|
||||||
|
}
|
||||||
|
return false, int64(entry.Size), mtime, nil
|
||||||
|
}
|
||||||
|
// A link: SIZE below follows it on the server.
|
||||||
|
}
|
||||||
|
// MLST is missing or refused the path: the older commands decide. A
|
||||||
|
// broken connection fails them too, with its own error.
|
||||||
|
|
||||||
|
s.deadline()
|
||||||
|
size, sizeErr := s.FileSize(p)
|
||||||
|
if sizeErr == nil {
|
||||||
|
if s.IsGetTimeSupported() {
|
||||||
|
s.deadline()
|
||||||
|
if t, err := s.GetTime(p); err == nil {
|
||||||
|
mtime = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, size, mtime, nil
|
||||||
|
}
|
||||||
|
if replyCode(sizeErr) == 0 {
|
||||||
|
return false, 0, nil, sizeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// SIZE failed: a directory, or a missing path.
|
||||||
|
s.deadline()
|
||||||
|
cwd, err := s.CurrentDir()
|
||||||
|
if err != nil {
|
||||||
|
return false, 0, nil, err
|
||||||
|
}
|
||||||
|
s.deadline()
|
||||||
|
if err := s.ChangeDir(p); err != nil {
|
||||||
|
return false, 0, nil, sizeErr
|
||||||
|
}
|
||||||
|
s.deadline()
|
||||||
|
if err := s.ChangeDir(cwd); err != nil {
|
||||||
|
return false, 0, nil, err
|
||||||
|
}
|
||||||
|
return true, 0, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeEntryName reports whether a name from a listing can become a local
|
||||||
|
// path element. A hostile server could otherwise list "../x" and write
|
||||||
|
// outside the task directory.
|
||||||
|
func safeEntryName(name string) bool {
|
||||||
|
if name == "" || name == "." || name == ".." {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !strings.ContainsAny(name, "/\\\x00")
|
||||||
|
}
|
||||||
|
|
||||||
|
// walk lists a directory tree, at most maxWalkDepth levels below root and
|
||||||
|
// maxWalkFiles files. Files come back sorted by path, then name.
|
||||||
|
func (f *Fetcher) walk(s *session, root string) ([]*base.FileInfo, error) {
|
||||||
|
type dir struct {
|
||||||
|
remote string // path on the server
|
||||||
|
rel string // path below root, "" for root itself
|
||||||
|
depth int
|
||||||
|
}
|
||||||
|
var files []*base.FileInfo
|
||||||
|
stack := []dir{{remote: strings.TrimSuffix(root, "/"), rel: "", depth: 0}}
|
||||||
|
for len(stack) > 0 {
|
||||||
|
d := stack[len(stack)-1]
|
||||||
|
stack = stack[:len(stack)-1]
|
||||||
|
|
||||||
|
s.deadline()
|
||||||
|
entries, err := s.List(d.remote)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Name == "." || e.Name == ".." {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !safeEntryName(e.Name) {
|
||||||
|
f.warn("ftp: skipping a listed name that is not a safe file name: %q", e.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
remote := path.Join(d.remote, e.Name)
|
||||||
|
rel := path.Join(d.rel, e.Name)
|
||||||
|
if d.remote == "" {
|
||||||
|
remote = e.Name
|
||||||
|
}
|
||||||
|
entryType, size := e.Type, int64(e.Size)
|
||||||
|
if entryType == ftp.EntryTypeLink {
|
||||||
|
// A link to a file has a size; a link to a directory is
|
||||||
|
// not followed, so a loop cannot make the walk endless.
|
||||||
|
s.deadline()
|
||||||
|
linkSize, err := s.FileSize(remote)
|
||||||
|
if err != nil {
|
||||||
|
f.warn("ftp: skipping link %q: %v", rel, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entryType, size = ftp.EntryTypeFile, linkSize
|
||||||
|
}
|
||||||
|
switch entryType {
|
||||||
|
case ftp.EntryTypeFolder:
|
||||||
|
if d.depth+1 > maxWalkDepth {
|
||||||
|
return nil, fmt.Errorf("ftp: the folder is deeper than %d levels, the limit for a folder download", maxWalkDepth)
|
||||||
|
}
|
||||||
|
stack = append(stack, dir{remote: remote, rel: rel, depth: d.depth + 1})
|
||||||
|
case ftp.EntryTypeFile:
|
||||||
|
if len(files) >= maxWalkFiles {
|
||||||
|
return nil, fmt.Errorf("ftp: the folder has more than %d files, the limit for a folder download", maxWalkFiles)
|
||||||
|
}
|
||||||
|
fi := &base.FileInfo{Name: e.Name, Path: d.rel, Size: size}
|
||||||
|
if !e.Time.IsZero() {
|
||||||
|
t := e.Time
|
||||||
|
fi.Ctime = &t
|
||||||
|
}
|
||||||
|
files = append(files, fi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.SortFunc(files, func(a, b *base.FileInfo) int {
|
||||||
|
return cmp.Or(strings.Compare(a.Path, b.Path), strings.Compare(a.Name, b.Name))
|
||||||
|
})
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
Loaded 50 of 295 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user