mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-10 10:30:28 +00:00
Compare commits
71
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88701ad8e8 | ||
|
|
07cbfbdade | ||
|
|
392ee36137 | ||
|
|
a34383a7e2 | ||
|
|
98879569f0 | ||
|
|
8ef3b63e8d | ||
|
|
d873cd411c | ||
|
|
9e9bcd4b01 | ||
|
|
5bd57608d2 | ||
|
|
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 | ||
|
|
ba84a04a43 | ||
|
|
224b488087 | ||
|
|
7ce4fb7f0e | ||
|
|
8d1ae93b03 | ||
|
|
363153fa33 | ||
|
|
f0dab90e09 | ||
|
|
6e60889ce8 | ||
|
|
88d150b933 | ||
|
|
694b43e3f9 | ||
|
|
fe25ccab1d | ||
|
|
8301b1f9ce | ||
|
|
49aab4ea7c | ||
|
|
5d8267fa56 | ||
|
|
1ab452ced8 | ||
|
|
743f356567 | ||
|
|
b1dd46cc7c | ||
|
|
38750d8505 | ||
|
|
7c1447f659 | ||
|
|
21702d5d46 | ||
|
|
e99ef6c7f4 | ||
|
|
c667f097dc | ||
|
|
46c34f2e92 | ||
|
|
e47a20a1bd | ||
|
|
cee5f3336e | ||
|
|
a760290213 | ||
|
|
a29f4bb42a | ||
|
|
62e8ab3cef | ||
|
|
8fa9a1e902 | ||
|
|
46675f26af | ||
|
|
0d52b840a2 | ||
|
|
091e7b8936 | ||
|
|
ff8f61423e |
No files matched your search
@@ -0,0 +1,2 @@
|
||||
# Keep upstream plugin sources recognizable and collapsed in GitHub reviews.
|
||||
ui/flutter/third_party/** linguist-vendored whitespace=-blank-at-eol,-blank-at-eof
|
||||
@@ -106,17 +106,32 @@ jobs:
|
||||
if ("${{ matrix.arch }}" -eq "amd64") {
|
||||
# for amd64
|
||||
$release = "build\windows\x64\runner\Release\"
|
||||
$mingw = "C:\Program Files\Git\mingw64\bin"
|
||||
cp $mingw\libstdc++-6.dll $release
|
||||
cp $mingw\libgcc_s_seh-1.dll $release
|
||||
# Use the same toolchain as cgo instead of Git's bundled DLLs.
|
||||
$cc = (Get-Command -Name (go env CC) -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source
|
||||
foreach ($dll in @("libstdc++-6.dll", "libgcc_s_seh-1.dll", "libwinpthread-1.dll")) {
|
||||
$runtimePath = [string](& $cc "-print-file-name=$dll")
|
||||
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($runtimePath)) {
|
||||
throw "Failed to locate $dll using $cc"
|
||||
}
|
||||
$runtimePath = $runtimePath.Trim()
|
||||
# Some MinGW distributions keep runtime DLLs beside gcc.exe.
|
||||
if ($runtimePath -eq $dll) {
|
||||
$runtimePath = Join-Path (Split-Path -Parent $cc) $dll
|
||||
}
|
||||
if (!(Test-Path -LiteralPath $runtimePath -PathType Leaf)) {
|
||||
throw "MinGW runtime $dll was not found by $cc (reported path: $runtimePath)"
|
||||
}
|
||||
Write-Host "Copying MinGW runtime: $runtimePath"
|
||||
Copy-Item -LiteralPath $runtimePath -Destination $release
|
||||
}
|
||||
} else {
|
||||
# for ARM64
|
||||
$release = "build\windows\arm64\runner\Release\"
|
||||
$mingw = "$env:CLANGARM64_ROOT\aarch64-w64-mingw32\bin"
|
||||
cp $mingw\libc++.dll $release
|
||||
cp $mingw\libunwind.dll $release
|
||||
cp $mingw\libwinpthread-1.dll $release
|
||||
}
|
||||
cp $mingw\libwinpthread-1.dll $release
|
||||
cp $system\msvcp140.dll $release
|
||||
cp $system\vcruntime140.dll $release
|
||||
cp $system\vcruntime140_1.dll $release
|
||||
@@ -783,5 +798,5 @@ jobs:
|
||||
linux/arm64
|
||||
linux/arm/v7
|
||||
tags: |
|
||||
liwei2633/gopeed:latest
|
||||
${{ !contains(env.VERSION, 'beta') && 'liwei2633/gopeed:latest' || '' }}
|
||||
liwei2633/gopeed:v${{ env.VERSION }}
|
||||
@@ -69,6 +69,49 @@ jobs:
|
||||
with:
|
||||
files: ./coverage.txt
|
||||
token: ${{ secrets.CODECOV_UPLOAD_TOKEN }}
|
||||
test-flutter-category:
|
||||
# The settings editor and the backend have to read an extension list the
|
||||
# same way, so both sides run the shared rule table. The Go side is covered
|
||||
# by the test job above, this runs the ui side of it.
|
||||
name: Test shared category rules
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
- name: Run the shared category rule table on the ui side
|
||||
working-directory: ui/flutter
|
||||
run: |
|
||||
flutter pub get
|
||||
flutter gen-l10n
|
||||
flutter test test/api/downloader_config_test.dart
|
||||
test-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:
|
||||
name: Test webview integration (${{ matrix.name }})
|
||||
strategy:
|
||||
@@ -77,6 +120,8 @@ jobs:
|
||||
include:
|
||||
- name: Windows
|
||||
os: windows-latest
|
||||
- name: Linux
|
||||
os: ubuntu-latest
|
||||
- name: macOS
|
||||
os: macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -89,9 +134,25 @@ jobs:
|
||||
if: runner.os == 'macOS'
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
- name: Install Linux WebView dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev xvfb
|
||||
- name: Test webview
|
||||
if: runner.os == 'Linux'
|
||||
# Match the full suite's coverage instrumentation and repeat on the same
|
||||
# GTK event loop to catch queued callbacks after engine destruction.
|
||||
run: |
|
||||
PACKAGES=$(go list ./... | awk '!/\/bind\// && (!/\/cmd\// || /\/cmd\/web$/)')
|
||||
go test -c -tags webview -coverpkg=$(echo "$PACKAGES" | paste -sd "," -) -covermode=count -o "$RUNNER_TEMP/goprovider.test" ./internal/webview/goprovider
|
||||
xvfb-run -a "$RUNNER_TEMP/goprovider.test" -test.v -test.count=5 -test.timeout=300s -test.coverprofile="$RUNNER_TEMP/webview-coverage.out"
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
- name: Test webview
|
||||
if: runner.os == 'Windows'
|
||||
run: go test -tags webview -v -count=1 -timeout 300s ./internal/webview/goprovider/
|
||||
# Repeat close/reopen coverage to catch browser shutdown races.
|
||||
run: go test -tags webview -v -count=3 -timeout 300s ./internal/webview/goprovider/
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
- name: Test webview
|
||||
|
||||
@@ -20,7 +20,7 @@ Beyond core download management, Gopeed offers browser integration, JavaScript e
|
||||
|
||||
Visit ✈ [Official Website](https://gopeed.com)
|
||||
|
||||

|
||||

|
||||
|
||||
## ✨ Features
|
||||
|
||||
@@ -63,7 +63,7 @@ Connect Gopeed to an AI agent and manage downloads with natural language. For ex
|
||||
|
||||
Gopeed 2.0.0 is currently in public beta, introducing a redesigned interface, a native communication architecture that connects desktop and mobile clients directly to the Go core through FFI, a more consistent cross-platform experience, improved task management, more flexible API support, and MCP-based AI agent integration. Some features may still be incomplete or unstable, so please try it and report any issues you encounter.
|
||||
|
||||
- [Download Gopeed 2.0.0 Beta 2](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2)
|
||||
- [Download Gopeed 2.0.0 Beta 3](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3)
|
||||
|
||||
Once the features and stability meet our release standards, we will publish the official Gopeed 2.0.0 release. Beta users will be able to upgrade directly to the final release, while existing stable users will not be automatically moved onto the beta channel.
|
||||
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@ Gopeed(**Go Speed** の略)は、Go と Flutter で開発された高速で
|
||||
|
||||
✈ [公式サイト](https://gopeed.com)
|
||||
|
||||

|
||||

|
||||
|
||||
## ✨ 主な機能
|
||||
|
||||
@@ -63,7 +63,7 @@ Gopeed を AI Agent と連携すると、自然言語でダウンロードを管
|
||||
|
||||
Gopeed 2.0.0 は現在パブリックベータ版です。刷新されたインターフェース、デスクトップおよびモバイルから FFI で Go コアへ直接接続するネイティブ通信アーキテクチャ、より統一されたクロスプラットフォーム体験、強化されたタスク管理、より柔軟な API、MCP による AI Agent 連携機能を導入しています。一部の機能はまだ不完全または不安定な場合がありますので、ぜひお試しいただき、問題があればフィードバックをお寄せください。
|
||||
|
||||
- [Gopeed 2.0.0 Beta 2 をダウンロード](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2)
|
||||
- [Gopeed 2.0.0 Beta 3 をダウンロード](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3)
|
||||
|
||||
機能と安定性が正式リリースの基準に達した時点で、Gopeed 2.0.0 正式版を公開します。ベータ版のユーザーは正式版へ直接アップデートできます。既存の安定版ユーザーが自動的にベータチャンネルへ移行することはありません。
|
||||
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||

|
||||

|
||||
|
||||
## ✨ Tính năng chính
|
||||
|
||||
@@ -63,7 +63,7 @@ Sau khi kết nối Gopeed với AI Agent, bạn có thể quản lý tải xu
|
||||
|
||||
Gopeed 2.0.0 hiện đang trong giai đoạn beta công khai, với giao diện được thiết kế lại, kiến trúc giao tiếp native kết nối trực tiếp ứng dụng desktop và di động với lõi Go qua FFI, trải nghiệm đa nền tảng nhất quán hơn, khả năng quản lý tác vụ được cải thiện, API linh hoạt hơn và khả năng tích hợp AI Agent qua MCP. Một số tính năng có thể vẫn chưa hoàn thiện hoặc chưa ổn định, vì vậy hãy dùng thử và gửi phản hồi nếu bạn gặp vấn đề.
|
||||
|
||||
- [Tải Gopeed 2.0.0 Beta 2](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2)
|
||||
- [Tải Gopeed 2.0.0 Beta 3](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3)
|
||||
|
||||
Khi các tính năng và độ ổn định đáp ứng tiêu chuẩn phát hành, chúng tôi sẽ phát hành Gopeed 2.0.0 chính thức. Người dùng bản beta có thể nâng cấp trực tiếp lên bản chính thức, trong khi người dùng bản ổn định hiện tại sẽ không tự động được chuyển sang kênh beta.
|
||||
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@ Gopeed(全称 **Go Speed**,也叫“够快下载器”)是一款使用 Go
|
||||
|
||||
访问 ✈ [官方网站](https://gopeed.com)
|
||||
|
||||

|
||||

|
||||
|
||||
## ✨ 主要特性
|
||||
|
||||
@@ -63,7 +63,7 @@ Gopeed(全称 **Go Speed**,也叫“够快下载器”)是一款使用 Go
|
||||
|
||||
Gopeed 2.0.0 目前处于公开 Beta 测试阶段,引入了全新界面、通过 FFI 直连 Go 核心的原生通信架构、更一致的跨平台体验、更完善的任务管理、更灵活的 API,以及用于 AI Agent 接入的 MCP 能力。部分功能仍可能不够完善或稳定,欢迎提前体验并向我们反馈问题。
|
||||
|
||||
- [下载 Gopeed 2.0.0 Beta 2](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2)
|
||||
- [下载 Gopeed 2.0.0 Beta 3](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3)
|
||||
|
||||
当功能完整度和稳定性达到正式发布标准后,我们会发布 Gopeed 2.0.0 正式版。已安装 Beta 版本的用户可以直接升级到最终正式版,现有稳定版用户则不会被自动切换到 Beta 渠道。
|
||||
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@ Gopeed(**Go Speed** 的縮寫)是一款以 Go 與 Flutter 開發的高速、
|
||||
|
||||
瀏覽 ✈ [官方網站](https://gopeed.com)
|
||||
|
||||

|
||||

|
||||
|
||||
## ✨ 主要功能
|
||||
|
||||
@@ -63,7 +63,7 @@ Gopeed(**Go Speed** 的縮寫)是一款以 Go 與 Flutter 開發的高速、
|
||||
|
||||
Gopeed 2.0.0 目前處於公開 Beta 測試階段,引入了全新介面、透過 FFI 直連 Go 核心的原生通訊架構、更一致的跨平台體驗、更完善的任務管理、更靈活的 API,以及透過 MCP 串接 AI Agent 的能力。部分功能仍可能不夠完善或穩定,歡迎搶先體驗並向我們回報問題。
|
||||
|
||||
- [下載 Gopeed 2.0.0 Beta 2](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.2)
|
||||
- [下載 Gopeed 2.0.0 Beta 3](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.3)
|
||||
|
||||
當功能完整度與穩定性達到正式發布標準後,我們會發布 Gopeed 2.0.0 正式版。已安裝 Beta 版本的使用者可以直接升級至最終正式版,現有穩定版使用者則不會被自動切換至 Beta 頻道。
|
||||
|
||||
|
||||
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: 391 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 792 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 357 KiB |
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,6 +7,7 @@ import (
|
||||
|
||||
nativebridge "github.com/GopeedLab/gopeed/bind/native"
|
||||
"github.com/GopeedLab/gopeed/internal/webview/rpcprovider"
|
||||
goapi "github.com/GopeedLab/gopeed/pkg/api"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
)
|
||||
@@ -90,3 +91,14 @@ func applyWebViewProvider(config *model.StartConfig) {
|
||||
}
|
||||
config.WebViewProvider = rpcprovider.New(*config.WebViewRPCConfig)
|
||||
}
|
||||
|
||||
func LiveActivityTaskEventMask() int64 {
|
||||
return int64(
|
||||
goapi.TaskEventDone |
|
||||
goapi.TaskEventError |
|
||||
goapi.TaskEventStart |
|
||||
goapi.TaskEventProgress |
|
||||
goapi.TaskEventPause |
|
||||
goapi.TaskEventDelete,
|
||||
)
|
||||
}
|
||||
+7
-1
@@ -1,6 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/GopeedLab/gopeed/cmd"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
)
|
||||
@@ -13,5 +16,8 @@ func main() {
|
||||
Storage: model.StorageBolt,
|
||||
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{
|
||||
URL: url,
|
||||
Labels: map[string]string(labels),
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -250,7 +250,7 @@ func runDownload(args []string) error {
|
||||
result, err := d.Resolve(&base.Request{
|
||||
URL: url,
|
||||
Labels: map[string]string(labels),
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+7
-3
@@ -7,9 +7,11 @@ import (
|
||||
)
|
||||
|
||||
type args struct {
|
||||
url string
|
||||
connections *int
|
||||
dir *string
|
||||
url string
|
||||
connections *int
|
||||
dir *string
|
||||
checksum *string
|
||||
checksumAlgo *string
|
||||
}
|
||||
|
||||
func parse() *args {
|
||||
@@ -20,6 +22,8 @@ func parse() *args {
|
||||
var args args
|
||||
args.connections = flag.Int("C", 16, "Concurrent connections.")
|
||||
args.dir = flag.String("D", dir, "Store directory.")
|
||||
args.checksum = flag.String("checksum", "", "Expected checksum hash (hex encoded).")
|
||||
args.checksumAlgo = flag.String("checksum-algo", "md5", "Checksum algorithm: md5, sha1, or sha256.")
|
||||
flag.Parse()
|
||||
t := flag.Args()
|
||||
if len(t) > 0 {
|
||||
|
||||
+17
-2
@@ -19,6 +19,18 @@ func main() {
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
var checksumOpt *http.ChecksumOption
|
||||
if args.checksum != nil && *args.checksum != "" {
|
||||
algo := "md5"
|
||||
if args.checksumAlgo != nil && *args.checksumAlgo != "" {
|
||||
algo = *args.checksumAlgo
|
||||
}
|
||||
checksumOpt = &http.ChecksumOption{
|
||||
Algorithm: algo,
|
||||
Expected: *args.checksum,
|
||||
}
|
||||
}
|
||||
|
||||
_, err := download.Boot().
|
||||
URL(args.url).
|
||||
Listener(func(event *download.Event) {
|
||||
@@ -43,8 +55,11 @@ func main() {
|
||||
}
|
||||
}).
|
||||
Create(&base.Options{
|
||||
Path: *args.dir,
|
||||
Extra: http.OptsExtra{Connections: *args.connections},
|
||||
Path: *args.dir,
|
||||
Extra: http.OptsExtra{
|
||||
Connections: *args.connections,
|
||||
Checksum: checksumOpt,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
||||
+14
-6
@@ -16,17 +16,18 @@ import (
|
||||
//go:embed banner.txt
|
||||
var banner string
|
||||
|
||||
func Start(cfg *model.StartConfig) {
|
||||
fmt.Println(banner)
|
||||
func Start(cfg *model.StartConfig) error {
|
||||
srv, listener, err := rest.BuildServer(cfg)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
return err
|
||||
}
|
||||
fmt.Println(banner)
|
||||
downloadCfg, err := rest.Downloader.GetConfig()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
listener.Close()
|
||||
return err
|
||||
}
|
||||
if downloadCfg.FirstLoad {
|
||||
if rest.Downloader.IsFirstLoad() {
|
||||
// Set default download config
|
||||
if cfg.DownloadConfig != nil {
|
||||
cfg.DownloadConfig.Merge(downloadCfg)
|
||||
@@ -51,13 +52,20 @@ func Start(cfg *model.StartConfig) {
|
||||
rest.Downloader.PutConfig(downloadCfg)
|
||||
}
|
||||
}
|
||||
|
||||
// The default download dir is resolved only after the core starts, so
|
||||
// seed the built-in categories now that the dir is known. Init is
|
||||
// idempotent and leaves an existing category list untouched.
|
||||
downloadCfg.Init()
|
||||
rest.Downloader.PutConfig(downloadCfg)
|
||||
}
|
||||
watchExit()
|
||||
|
||||
fmt.Printf("Server start success on http://%s\n", listener.Addr().String())
|
||||
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
panic(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -37,15 +37,17 @@ func installByInstaller(killSignalChan chan<- any, packagePath, destDir string)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
// Find the installer file
|
||||
var installerPath string
|
||||
for _, file := range reader.File {
|
||||
cleanName := filepath.Clean(file.Name)
|
||||
if strings.HasPrefix(cleanName, "..") || filepath.IsAbs(cleanName) {
|
||||
continue
|
||||
}
|
||||
if file.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract file
|
||||
path := filepath.Join(tempDir, file.Name)
|
||||
path := filepath.Join(tempDir, cleanName)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
|
||||
return err
|
||||
@@ -101,7 +103,11 @@ func installByPortable(killSignalChan chan<- any, packagePath, destDir string) e
|
||||
defer reader.Close()
|
||||
|
||||
for _, file := range reader.File {
|
||||
path := filepath.Join(destDir, file.Name)
|
||||
cleanName := filepath.Clean(file.Name)
|
||||
if strings.HasPrefix(cleanName, "..") || filepath.IsAbs(cleanName) {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(destDir, cleanName)
|
||||
|
||||
if file.FileInfo().IsDir() {
|
||||
os.MkdirAll(path, file.Mode())
|
||||
|
||||
+9
-2
@@ -13,6 +13,7 @@ import (
|
||||
)
|
||||
|
||||
type args struct {
|
||||
TempDir *string `json:"tempDir"`
|
||||
Address *string `json:"address"`
|
||||
Port *int `json:"port"`
|
||||
Username *string `json:"username"`
|
||||
@@ -51,9 +52,10 @@ func loadCliArgs() *args {
|
||||
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")
|
||||
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")
|
||||
flag.StringVar(cfg.ApiToken, "api-token", "", "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 (requires web authentication with username and password)")
|
||||
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.StorageDir = flag.String("d", "", "Storage directory")
|
||||
flag.StringVar(cfg.StorageDir, "storage-dir", "", "Storage directory")
|
||||
whiteDownloadDirs := flag.String("white-download-dirs", "", "White download directories, comma-separated")
|
||||
@@ -89,6 +91,8 @@ func overrideWithCliArgs(cfg *args, cliConfig *args) {
|
||||
cfg.ApiToken = cliConfig.ApiToken
|
||||
case "mcp-enable":
|
||||
cfg.MCPEnable = cliConfig.MCPEnable
|
||||
case "temp-dir":
|
||||
cfg.TempDir = cliConfig.TempDir
|
||||
case "d", "storage-dir":
|
||||
cfg.StorageDir = cliConfig.StorageDir
|
||||
case "white-download-dirs":
|
||||
@@ -101,6 +105,9 @@ func overrideWithCliArgs(cfg *args, cliConfig *args) {
|
||||
|
||||
// setDefaults sets default values for any unset configuration fields
|
||||
func setDefaults(cfg *args, cliConfig *args) {
|
||||
if cfg.TempDir == nil {
|
||||
cfg.TempDir = cliConfig.TempDir
|
||||
}
|
||||
if cfg.Address == nil {
|
||||
cfg.Address = cliConfig.Address
|
||||
}
|
||||
|
||||
+9
-1
@@ -43,11 +43,16 @@ func main() {
|
||||
storageDir = filepath.Join(filepath.Dir(exe), "storage")
|
||||
}
|
||||
|
||||
tempDir := ""
|
||||
if args.TempDir != nil {
|
||||
tempDir = *args.TempDir
|
||||
}
|
||||
cfg := &model.StartConfig{
|
||||
Network: "tcp",
|
||||
Address: fmt.Sprintf("%s:%d", *args.Address, *args.Port),
|
||||
Storage: model.StorageBolt,
|
||||
StorageDir: storageDir,
|
||||
TempDir: tempDir,
|
||||
WhiteDownloadDirs: args.WhiteDownloadDirs,
|
||||
ApiToken: *args.ApiToken,
|
||||
MCPEnable: args.MCPEnable,
|
||||
@@ -57,7 +62,10 @@ func main() {
|
||||
WebFS: sub,
|
||||
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 {
|
||||
|
||||
@@ -3,16 +3,20 @@ module github.com/GopeedLab/gopeed
|
||||
go 1.25.4
|
||||
|
||||
require (
|
||||
github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5
|
||||
codeberg.org/gruf/go-ffmpreg v0.6.23-0.20260827095416-df1836cb5be0
|
||||
github.com/GopeedLab/webview_go v0.0.0-20260918010912-0d4c27bb7914
|
||||
github.com/anacrolix/torrent v1.60.1-0.20251217073903-486bcbe758e0
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
||||
github.com/bodgit/sevenzip v1.6.1
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
||||
github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc
|
||||
github.com/go-git/go-git/v5 v5.8.1
|
||||
github.com/fclairamb/ftpserverlib v0.31.0
|
||||
github.com/go-git/go-git/v5 v5.19.2
|
||||
github.com/google/jsonschema-go v0.4.3
|
||||
github.com/gorilla/handlers v1.5.1
|
||||
github.com/gorilla/mux v1.8.0
|
||||
github.com/imroc/req/v3 v3.52.2
|
||||
github.com/imroc/req/v3 v3.58.0
|
||||
github.com/jlaffaye/ftp v0.2.4
|
||||
github.com/matoous/go-nanoid/v2 v2.0.0
|
||||
github.com/mattn/go-ieproxy v0.0.12
|
||||
github.com/mholt/archives v0.1.5
|
||||
@@ -21,9 +25,17 @@ require (
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529
|
||||
github.com/rs/zerolog v1.31.0
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/tetratelabs/wazero v1.12.0
|
||||
github.com/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad
|
||||
go.etcd.io/bbolt v1.4.3
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/onsi/gomega v1.36.3 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -36,49 +48,40 @@ require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
|
||||
github.com/felixge/fgprof v0.9.5 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/icholy/digest v1.1.0 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||
github.com/mikelolasagasti/xz v1.0.1 // indirect
|
||||
github.com/minio/minlz v1.0.1 // indirect
|
||||
github.com/nwaples/rardecode/v2 v2.2.0 // indirect
|
||||
github.com/onsi/ginkgo/v2 v2.23.4 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.22 // indirect
|
||||
github.com/pion/transport/v4 v4.0.1 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.51.0 // indirect
|
||||
github.com/refraction-networking/utls v1.6.7 // indirect
|
||||
github.com/pion/transport/v4 v4.0.2 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/refraction-networking/utls v1.8.2 // indirect
|
||||
github.com/segmentio/asm v1.1.3 // indirect
|
||||
github.com/segmentio/encoding v0.5.4 // 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/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
go.uber.org/mock v0.5.1 // indirect
|
||||
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
|
||||
golang.org/x/oauth2 v0.35.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.1
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2
|
||||
github.com/ProtonMail/go-crypto v1.1.6 // indirect
|
||||
github.com/RoaringBitmap/roaring v1.9.4
|
||||
github.com/acomagu/bufpipe v1.0.4 // indirect
|
||||
github.com/alecthomas/atomic v0.1.0-alpha2 // indirect
|
||||
github.com/anacrolix/chansync v0.7.0 // indirect
|
||||
github.com/anacrolix/dht/v2 v2.23.0 // indirect
|
||||
github.com/anacrolix/envpprof v1.5.0 // indirect
|
||||
github.com/anacrolix/generics v0.2.0 // indirect
|
||||
github.com/anacrolix/generics v0.2.0
|
||||
github.com/anacrolix/go-libutp v1.3.2 // indirect
|
||||
github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb // indirect
|
||||
github.com/anacrolix/missinggo v1.3.0 // indirect
|
||||
@@ -94,7 +97,7 @@ require (
|
||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
|
||||
github.com/cespare/xxhash v1.1.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.1 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
@@ -102,13 +105,13 @@ require (
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.1 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.4.1 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.9.2 // indirect
|
||||
github.com/go-llsqlite/adapter v0.2.0 // indirect
|
||||
github.com/go-llsqlite/crawshaw v0.6.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/google/btree v1.1.3 // indirect
|
||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
@@ -126,7 +129,7 @@ require (
|
||||
github.com/multiformats/go-varint v0.1.0 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pion/datachannel v1.6.0 // indirect
|
||||
github.com/pion/dtls/v3 v3.0.10 // indirect
|
||||
github.com/pion/dtls/v3 v3.1.4 // indirect
|
||||
github.com/pion/ice/v4 v4.2.0 // indirect
|
||||
github.com/pion/interceptor v0.1.42 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
@@ -137,15 +140,15 @@ require (
|
||||
github.com/pion/sctp v1.9.1 // indirect
|
||||
github.com/pion/sdp/v3 v3.0.17 // indirect
|
||||
github.com/pion/srtp/v3 v3.0.10 // indirect
|
||||
github.com/pion/stun/v3 v3.1.1 // indirect
|
||||
github.com/pion/stun/v3 v3.1.5 // indirect
|
||||
github.com/pion/turn/v4 v4.1.4 // indirect
|
||||
github.com/pion/webrtc/v4 v4.2.2-0.20260109001657-a5962f314db7 // indirect
|
||||
github.com/pjbgf/sha1cd v0.3.0 // indirect
|
||||
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
|
||||
github.com/protolambda/ctxlock v0.1.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/sergi/go-diff v1.1.0 // indirect
|
||||
github.com/skeema/knownhosts v1.2.0 // indirect
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
|
||||
github.com/skeema/knownhosts v1.3.1 // indirect
|
||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||
github.com/tidwall/btree v1.8.1 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
@@ -153,13 +156,11 @@ require (
|
||||
go.opentelemetry.io/otel v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/net v0.50.0
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
golang.org/x/crypto v0.55.0 // indirect
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0
|
||||
golang.org/x/time v0.15.0
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
lukechampine.com/blake3 v1.4.1 // indirect
|
||||
modernc.org/libc v1.67.4 // indirect
|
||||
|
||||
@@ -14,6 +14,8 @@ cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2k
|
||||
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
||||
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
||||
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
|
||||
codeberg.org/gruf/go-ffmpreg v0.6.23-0.20260827095416-df1836cb5be0 h1:AqNFl60o4xhTTOxmzAvSf7pazXHKBGuV5ATw7sETIRA=
|
||||
codeberg.org/gruf/go-ffmpreg v0.6.23-0.20260827095416-df1836cb5be0/go.mod h1:XM6eW8CSgAuZRKRynm5nDiSNedm0rav30IychqygOHE=
|
||||
crawshaw.io/iox v0.0.0-20181124134642-c51c3df30797/go.mod h1:sXBiorCo8c46JlQV3oXPKINnZ8mcqnye1EkVkqsectk=
|
||||
crawshaw.io/sqlite v0.3.2/go.mod h1:igAO5JulrQ1DbdZdtVq48mnZUBAPOeFzer7VhDWNtW4=
|
||||
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
||||
@@ -23,17 +25,17 @@ filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmG
|
||||
filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5 h1:86LVTaNDM/j/uTAD2HedkXNO9LIQlt642VAdbU/0D/M=
|
||||
github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5/go.mod h1:FvV19lu9rQ9NcbczN5y8pbWecURRRbWa8tbfFARBDa0=
|
||||
github.com/GopeedLab/webview_go v0.0.0-20260918010912-0d4c27bb7914 h1:9VJQZIZ1R6T4m+4vlNQYNXSPNUv2gf2PiTfIXiQ3MTw=
|
||||
github.com/GopeedLab/webview_go v0.0.0-20260918010912-0d4c27bb7914/go.mod h1:oNO07SCX8xgoHLds2OBwML8TU4xils88vMQ4gadKUHQ=
|
||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
|
||||
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
|
||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 h1:KLq8BE0KwCL+mmXnjLWEAOYO+2l2AE4YMmqG1ZpZHBs=
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
|
||||
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
|
||||
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
|
||||
github.com/RoaringBitmap/roaring v0.4.7/go.mod h1:8khRDP4HmeXns4xIj9oGrKSz7XTQiJx2zgh7AcNke4w=
|
||||
github.com/RoaringBitmap/roaring v0.4.17/go.mod h1:D3qVegWTmfCaX4Bl5CrBE9hfrSrrXIr8KVNvRsDi1NI=
|
||||
github.com/RoaringBitmap/roaring v0.4.23/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06Mq5mKs52e1TwOo=
|
||||
@@ -43,8 +45,6 @@ github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=
|
||||
github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
|
||||
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
|
||||
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
|
||||
github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ=
|
||||
github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
|
||||
github.com/alecthomas/assert/v2 v2.0.0-alpha3 h1:pcHeMvQ3OMstAWgaeaXIAL8uzB9xMm2zlxt+/4ml8lk=
|
||||
github.com/alecthomas/assert/v2 v2.0.0-alpha3/go.mod h1:+zD0lmDXTeQj7TgDgCt0ePWxb0hMC1G+PGTsTCv1B9o=
|
||||
github.com/alecthomas/atomic v0.1.0-alpha2 h1:dqwXmax66gXvHhsOS4pGPZKqYOlTkapELkLb3MNdlH8=
|
||||
@@ -141,7 +141,6 @@ github.com/bradfitz/iter v0.0.0-20140124041915-454541ec3da2/go.mod h1:PyRFw1Lt2w
|
||||
github.com/bradfitz/iter v0.0.0-20190303215204-33e6a9893b0c/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo=
|
||||
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 h1:GKTyiRCL6zVf5wWaqKnf+7Qs6GbEPfd4iMOitWzXJx8=
|
||||
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8/go.mod h1:spo1JLcs67NmW1aVLEgtA8Yy1elc+X8y5SRW1sFW4Og=
|
||||
github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
|
||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||
@@ -158,11 +157,12 @@ github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObk
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
|
||||
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
||||
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -185,12 +185,14 @@ github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1
|
||||
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
|
||||
github.com/edsrzf/mmap-go v1.2.0 h1:hXLYlkbaPzt1SaQk+anYwKSRNhufIDCchSPkUD6dD84=
|
||||
github.com/edsrzf/mmap-go v1.2.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
|
||||
github.com/elazarl/goproxy v0.0.0-20221015165544-a0805db90819 h1:RIB4cRk+lBqKK3Oy0r2gRX4ui7tuhiZq2SuTtTCi0/0=
|
||||
github.com/elazarl/goproxy v0.0.0-20221015165544-a0805db90819/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
|
||||
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
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/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/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZPpayhMM=
|
||||
github.com/felixge/httpsnoop v1.0.1 h1:lvB5Jl89CsZtGIWuTcDM1E/vkVs49/Ml7JJe07l8SPQ=
|
||||
@@ -200,8 +202,8 @@ github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=
|
||||
github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4=
|
||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
|
||||
github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
|
||||
github.com/glycerine/go-unsnap-stream v0.0.0-20190901134440-81cf024a9e0a/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
|
||||
@@ -210,12 +212,12 @@ github.com/glycerine/goconvey v0.0.0-20190315024820-982ee783a72e/go.mod h1:Ogl1T
|
||||
github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||
github.com/go-git/go-billy/v5 v5.4.1 h1:Uwp5tDRkPr+l/TnbHOQzp+tmJfLceOlbVucgpTz8ix4=
|
||||
github.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20230305113008-0c11038e723f h1:Pz0DHeFij3XFhoBRGUDPzSJ+w2UcK5/0JvF8DRI58r8=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20230305113008-0c11038e723f/go.mod h1:8LHG1a3SRW71ettAD/jW13h8c6AqjVSeL11RAdgaqpo=
|
||||
github.com/go-git/go-git/v5 v5.8.1 h1:Zo79E4p7TRk0xoRgMq0RShiTHGKcKI4+DI6BfJc/Q+A=
|
||||
github.com/go-git/go-git/v5 v5.8.1/go.mod h1:FHFuoD6yGz5OSKEBK+aWN9Oah0q54Jxl0abmj6GnqAo=
|
||||
github.com/go-git/go-billy/v5 v5.9.2 h1:OXFSRyz4g20upsGDJgQG9Bak1l/ZEv8GHVYB52O71sE=
|
||||
github.com/go-git/go-billy/v5 v5.9.2/go.mod h1:ExsU+jcGwXTBOnyilvAnEM1wug1IxHr4yP2ZXsNRtV0=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||
github.com/go-git/go-git/v5 v5.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyGqY=
|
||||
github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuwuB/jWEHjk=
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
@@ -236,8 +238,6 @@ github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZs
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
|
||||
@@ -250,8 +250,8 @@ github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfU
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
|
||||
@@ -276,10 +276,13 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
@@ -307,11 +310,6 @@ github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
@@ -329,10 +327,12 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
|
||||
github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4=
|
||||
github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y=
|
||||
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.58.0 h1:rBqJhv2gZjMymGot4vKmNSUs/mAITMpGASqElhuMvS4=
|
||||
github.com/imroc/req/v3 v3.58.0/go.mod h1:rC7NTGgfMSkVWR4GO46rMeJsUfaeOpa6pLsgpq9q3zI=
|
||||
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/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/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
@@ -345,8 +345,8 @@ github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4
|
||||
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
@@ -356,7 +356,6 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxv
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
@@ -369,8 +368,6 @@ github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJ
|
||||
github.com/matoous/go-nanoid v1.5.0/go.mod h1:zyD2a71IubI24efhpvkJz+ZwfwagzgSO6UNiFsZKN7U=
|
||||
github.com/matoous/go-nanoid/v2 v2.0.0 h1:d19kur2QuLeHmJBkvYkFdhFBzLoo1XVm2GgTpL+9Tj0=
|
||||
github.com/matoous/go-nanoid/v2 v2.0.0/go.mod h1:FtS4aGPVfEkxKxhdWPAspZpZSh1cOjtM7Ej/So3hR0g=
|
||||
github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A=
|
||||
github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-ieproxy v0.0.12 h1:OZkUFJC3ESNZPQ+6LzC3VJIFSnreeFLQyqvBWtvfL2M=
|
||||
@@ -408,13 +405,10 @@ github.com/multiformats/go-varint v0.1.0/go.mod h1:5KVAVXegtfmNQQm/lCY+ATvDzvJJh
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/nwaples/rardecode/v2 v2.2.0 h1:4ufPGHiNe1rYJxYfehALLjup4Ls3ck42CWwjKiOqu0A=
|
||||
github.com/nwaples/rardecode/v2 v2.2.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
|
||||
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
|
||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU=
|
||||
github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
|
||||
@@ -426,8 +420,8 @@ github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU
|
||||
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0=
|
||||
github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk=
|
||||
github.com/pion/dtls/v3 v3.0.10 h1:k9ekkq1kaZoxnNEbyLKI8DI37j/Nbk1HWmMuywpQJgg=
|
||||
github.com/pion/dtls/v3 v3.0.10/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8=
|
||||
github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY=
|
||||
github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc=
|
||||
github.com/pion/ice/v4 v4.2.0 h1:jJC8S+CvXCCvIQUgx+oNZnoUpt6zwc34FhjWwCU4nlw=
|
||||
github.com/pion/ice/v4 v4.2.0/go.mod h1:EgjBGxDgmd8xB0OkYEVFlzQuEI7kWSCFu+mULqaisy4=
|
||||
github.com/pion/interceptor v0.1.42 h1:0/4tvNtruXflBxLfApMVoMubUMik57VZ+94U0J7cmkQ=
|
||||
@@ -448,18 +442,18 @@ github.com/pion/sdp/v3 v3.0.17 h1:9SfLAW/fF1XC8yRqQ3iWGzxkySxup4k4V7yN8Fs8nuo=
|
||||
github.com/pion/sdp/v3 v3.0.17/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo=
|
||||
github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ=
|
||||
github.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M=
|
||||
github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw=
|
||||
github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM=
|
||||
github.com/pion/stun/v3 v3.1.5 h1:Y1FHlhaI6+4UoC5i/zQf4F7JvdZtB24/05oyy/GF1x8=
|
||||
github.com/pion/stun/v3 v3.1.5/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs=
|
||||
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
|
||||
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
|
||||
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
|
||||
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
|
||||
github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
|
||||
github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
|
||||
github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ=
|
||||
github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ=
|
||||
github.com/pion/webrtc/v4 v4.2.2-0.20260109001657-a5962f314db7 h1:gnkvpxCSzeTKovDsxeFFl+W/i6WydzSHhu5lH/RpFfM=
|
||||
github.com/pion/webrtc/v4 v4.2.2-0.20260109001657-a5962f314db7/go.mod h1:/td+qaV3q20qTbRjYV9ABWjmCkvt+Pog/S+NqLMoUKk=
|
||||
github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
|
||||
github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
|
||||
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
|
||||
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
@@ -467,10 +461,7 @@ github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
||||
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
|
||||
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
|
||||
@@ -490,13 +481,13 @@ github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+Gx
|
||||
github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
|
||||
github.com/protolambda/ctxlock v0.1.0 h1:rCUY3+vRdcdZXqT07iXgyr744J2DU2LCBIXowYAjBCE=
|
||||
github.com/protolambda/ctxlock v0.1.0/go.mod h1:vefhX6rIZH8rsg5ZpOJfEDYQOppZi19SfPiGOFrNnwM=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.51.0 h1:K8exxe9zXxeRKxaXxi/GpUqYiTrtdiWP8bo1KFya6Wc=
|
||||
github.com/quic-go/quic-go v0.51.0/go.mod h1:MFlGGpcpJqRAfmYi6NC2cptDPSxRWTOGNuP4wqrWmzQ=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
|
||||
github.com/refraction-networking/utls v1.6.7 h1:zVJ7sP1dJx/WtVuITug3qYUq034cDq9B2MR1K67ULZM=
|
||||
github.com/refraction-networking/utls v1.6.7/go.mod h1:BC3O4vQzye5hqpmDTWUqi4P5DDhzJfkV1tdqtawQIH0=
|
||||
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
@@ -513,17 +504,19 @@ 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/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/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/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/skeema/knownhosts v1.2.0 h1:h9r9cf0+u7wSE+M183ZtMGgOJKiL96brpaz5ekfJCpM=
|
||||
github.com/skeema/knownhosts v1.2.0/go.mod h1:g4fPeYpque7P0xefxtGzV81ihjC8sX2IqpAoNkjxbMo=
|
||||
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
|
||||
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/assertions v0.0.0-20190215210624-980c5ac6f3ac/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
|
||||
@@ -550,8 +543,10 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
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/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/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/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A=
|
||||
github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
|
||||
@@ -590,12 +585,12 @@ go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||
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/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.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
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/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
@@ -605,11 +600,9 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
|
||||
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@@ -619,8 +612,8 @@ golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u0
|
||||
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
||||
golang.org/x/exp v0.0.0-20220428152302-39d4317da171/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
@@ -642,8 +635,8 @@ golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
|
||||
golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -665,14 +658,12 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@@ -689,8 +680,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -723,24 +714,20 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -748,13 +735,11 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
@@ -787,8 +772,8 @@ golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapK
|
||||
golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -833,8 +818,6 @@ google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/GopeedLab/gopeed/internal/production"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -61,6 +62,7 @@ type Metadata struct {
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
producer production.Reader
|
||||
ID string
|
||||
URL string
|
||||
ContentType string
|
||||
@@ -359,11 +361,35 @@ func (r *Registry) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
if producer, ok := reader.(production.Reader); ok {
|
||||
src.mu.Lock()
|
||||
src.producer = producer
|
||||
src.mu.Unlock()
|
||||
if err := producer.WaitProduction(req.Context()); err != nil {
|
||||
return
|
||||
}
|
||||
if producer.Production() != nil {
|
||||
meta.Size = 0
|
||||
meta.Range = false
|
||||
start = 0
|
||||
end = -1
|
||||
ranged = false
|
||||
src.mu.Lock()
|
||||
src.size = 0
|
||||
src.rangeEnabled = false
|
||||
src.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
writeHeaders(w, meta, start, end, ranged)
|
||||
if ranged {
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
}
|
||||
if producer, ok := reader.(production.Reader); ok && producer.Production() != nil {
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
limit := int64(-1)
|
||||
if end >= start {
|
||||
limit = end - start + 1
|
||||
@@ -675,3 +701,21 @@ func randomID(size int) (string, error) {
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// Production returns the active opener's input counter, if it generates output.
|
||||
func (r *Registry) Production(raw string) production.Source {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
src, err := r.get(raw)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
src.mu.Lock()
|
||||
reader := src.producer
|
||||
src.mu.Unlock()
|
||||
if reader == nil {
|
||||
return nil
|
||||
}
|
||||
return reader.Production()
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/GopeedLab/gopeed/internal/tempfiles"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/rs/zerolog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -9,8 +11,14 @@ import (
|
||||
)
|
||||
|
||||
type Controller struct {
|
||||
GetConfig func(v any)
|
||||
GetProxy func(requestProxy *base.RequestProxy) func(*http.Request) (*url.URL, error)
|
||||
TempFiles *tempfiles.Scope
|
||||
ManagedProduction func(string) bool
|
||||
TempDir string
|
||||
GetConfig func(v any)
|
||||
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
|
||||
//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
|
||||
type Fetcher interface {
|
||||
Setup(ctl *controller.Controller)
|
||||
Resolve(req *base.Request, opts *base.Options) error
|
||||
Resolve(req *base.Request) error
|
||||
Start() error
|
||||
// Patch modifies task-specific data based on the protocol.
|
||||
// For HTTP: can modify Request info (URL, headers, etc.)
|
||||
@@ -106,7 +106,13 @@ func (s *SchemeFilter) Match(uri string) bool {
|
||||
case FilterTypeUrl:
|
||||
return strings.HasPrefix(uriUpper, patternUpper+":")
|
||||
case FilterTypeFile:
|
||||
return strings.HasSuffix(uriUpper, "."+patternUpper)
|
||||
// Strip query and fragment before matching so URLs like
|
||||
// https://example.com/video.m3u8?sign=abc still route by extension.
|
||||
clean := uri
|
||||
if idx := strings.IndexAny(clean, "?#"); idx >= 0 {
|
||||
clean = clean[:idx]
|
||||
}
|
||||
return strings.HasSuffix(strings.ToUpper(clean), "."+patternUpper)
|
||||
case FilterTypeBase64:
|
||||
return strings.HasPrefix(uriUpper, "DATA:"+patternUpper+";BASE64,")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Package production associates generated resources with their input progress.
|
||||
package production
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Source counts unique input bytes and total received bytes (including refetches).
|
||||
// Implementations must support concurrent snapshots.
|
||||
type Source interface {
|
||||
Progress() (downloaded, received int64)
|
||||
}
|
||||
|
||||
// Reader resolves its optional producer before response headers are sent.
|
||||
// A nil producer preserves ordinary Blob behavior.
|
||||
type Reader interface {
|
||||
Production() Source
|
||||
WaitProduction(context.Context) error
|
||||
}
|
||||
type Registry struct {
|
||||
mu sync.Mutex
|
||||
sources map[string]Source
|
||||
}
|
||||
|
||||
func (r *Registry) Set(id string, source Source) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.sources == nil {
|
||||
r.sources = make(map[string]Source)
|
||||
}
|
||||
r.sources[id] = source
|
||||
}
|
||||
func (r *Registry) Get(id string) Source { r.mu.Lock(); defer r.mu.Unlock(); return r.sources[id] }
|
||||
func (r *Registry) Remove(id string) { r.mu.Lock(); defer r.mu.Unlock(); delete(r.sources, id) }
|
||||
@@ -1,12 +1,37 @@
|
||||
package bt
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
)
|
||||
|
||||
type trackerSubscriptionStatus struct {
|
||||
Success bool `json:"success"`
|
||||
TrackerCount int `json:"trackerCount"`
|
||||
LastFetchTime time.Time `json:"lastFetchTime"`
|
||||
}
|
||||
|
||||
type state struct {
|
||||
TrackerSubscriptionStatuses map[string]trackerSubscriptionStatus `json:"trackerSubscriptionStatuses"`
|
||||
TrackerSources map[string][]string `json:"trackerSources"`
|
||||
LastTrackerUpdateTime time.Time `json:"lastTrackerUpdateTime"`
|
||||
}
|
||||
|
||||
type config struct {
|
||||
ListenPort int `json:"listenPort"`
|
||||
Trackers []string `json:"trackers"`
|
||||
fetcher.ConfigState[state]
|
||||
TrackerSubscribeURLs []string `json:"trackerSubscribeUrls"`
|
||||
AutoUpdateTrackers bool `json:"autoUpdateTrackers"`
|
||||
CustomTrackers []string `json:"customTrackers"`
|
||||
ListenPort int `json:"listenPort"`
|
||||
// SeedKeep is always keep seeding after downloading is complete, unless manually stopped.
|
||||
SeedKeep bool `json:"seedKeep"`
|
||||
// SeedRatio is the ratio of uploaded data to downloaded data to seed.
|
||||
SeedRatio float64 `json:"seedRatio"`
|
||||
// SeedTime is the time in seconds to seed after downloading is complete.
|
||||
SeedTime int64 `json:"seedTime"`
|
||||
// DisableDHT and DisablePEX turn off DHT and PEX peer discovery. Like ListenPort,
|
||||
// they are read when the shared client is built.
|
||||
DisableDHT bool `json:"disableDht"`
|
||||
DisablePEX bool `json:"disablePex"`
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
package bt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
btprotocol "github.com/GopeedLab/gopeed/pkg/protocol/bt"
|
||||
"github.com/anacrolix/torrent"
|
||||
pp "github.com/anacrolix/torrent/peer_protocol"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
type btDownloadHarness struct {
|
||||
*Fetcher
|
||||
pendingRequests atomic.Int64
|
||||
}
|
||||
|
||||
// These tests exercise the BT fetcher, native storage and real peer protocol.
|
||||
// All content is generated locally and served by a loopback seeder.
|
||||
func btDownloadFetcher(t *testing.T, f fetcher.Fetcher) *btDownloadHarness {
|
||||
t.Helper()
|
||||
if f == nil {
|
||||
f = new(FetcherManager).Build()
|
||||
}
|
||||
ctl := controller.NewController()
|
||||
ctl.GetConfig = func(v any) {
|
||||
encoded, err := json.Marshal(new(FetcherManager).DefaultConfig())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(encoded, v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
f.Setup(ctl)
|
||||
t.Cleanup(func() {
|
||||
if err := f.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := closeClient(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
h := &btDownloadHarness{Fetcher: f.(*Fetcher)}
|
||||
// Keep the test client on loopback and observe real cancellation replies.
|
||||
// Waiting for them avoids the pinned library's immediate cancel/resume
|
||||
// request scheduling bug, without relying on arbitrary sleep durations.
|
||||
cfg = torrent.TestingConfig(t)
|
||||
cfg.DisableTrackers = false
|
||||
cfg.Seed = true
|
||||
cfg.DisableUTP = true
|
||||
cfg.DisableIPv6 = true
|
||||
cfg.DisableWebtorrent = true
|
||||
cfg.KeepAliveTimeout = time.Minute
|
||||
cfg.MaxAllocPeerRequestDataPerConn = 1 << 20
|
||||
cfg.Callbacks.SentRequest = []func(torrent.PeerRequestEvent){func(torrent.PeerRequestEvent) {
|
||||
h.pendingRequests.Add(1)
|
||||
}}
|
||||
cfg.Callbacks.ReadMessage = func(_ *torrent.PeerConn, msg *pp.Message) {
|
||||
if !msg.Keepalive && (msg.Type == pp.Piece || msg.Type == pp.Reject) {
|
||||
h.pendingRequests.Add(-1)
|
||||
}
|
||||
}
|
||||
var err error
|
||||
client, err = torrent.NewClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func btResolve(t *testing.T, f *btDownloadHarness, source string) {
|
||||
t.Helper()
|
||||
if err := f.Resolve(&base.Request{URL: source}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func btStart(t *testing.T, f *btDownloadHarness) {
|
||||
t.Helper()
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func btAwaitDone(t *testing.T, f *btDownloadHarness) {
|
||||
t.Helper()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- f.Wait() }()
|
||||
timeout := time.NewTimer(20 * time.Second)
|
||||
defer timeout.Stop()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("BT download failed: %v", err)
|
||||
}
|
||||
if !f.isDone() {
|
||||
t.Fatal("Wait returned before selected files were verified")
|
||||
}
|
||||
case <-timeout.C:
|
||||
var status strings.Builder
|
||||
client.WriteStatus(&status)
|
||||
t.Log(status.String())
|
||||
t.Fatalf("BT download did not finish: progress=%v stats=%+v pieces=%+v peers=%+v", f.Progress(), f.torrent.Stats(), f.torrent.PieceStateRuns(), f.Stats().Runtime)
|
||||
}
|
||||
}
|
||||
|
||||
func btAwaitPartial(t *testing.T, f *btDownloadHarness) {
|
||||
t.Helper()
|
||||
timeout := time.NewTimer(15 * time.Second)
|
||||
defer timeout.Stop()
|
||||
ticker := time.NewTicker(20 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
progress := f.Progress().TotalDownloaded()
|
||||
if progress >= 32*1024 && progress < f.Meta().Res.Size {
|
||||
return
|
||||
}
|
||||
case <-timeout.C:
|
||||
t.Fatal("BT transfer did not reach partial progress")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func btPause(t *testing.T, f *btDownloadHarness) {
|
||||
t.Helper()
|
||||
if err := f.Pause(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
timeout := time.NewTimer(5 * time.Second)
|
||||
defer timeout.Stop()
|
||||
ticker := time.NewTicker(10 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if f.pendingRequests.Load() == 0 {
|
||||
// The message callback runs under the client lock. Acquiring that
|
||||
// lock ensures the final cancellation reply has finished processing.
|
||||
client.Stats()
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ticker.C:
|
||||
case <-timeout.C:
|
||||
t.Fatal("peer did not acknowledge cancelled requests")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTDownloadSources(t *testing.T) {
|
||||
for _, kind := range []string{"magnet", "torrent", "file URI", "data URI"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
fixture := newBTFixture(t, map[string][]byte{"": btPayload(96*1024, kind)}, 0)
|
||||
source := fixture.Magnet
|
||||
switch kind {
|
||||
case "torrent":
|
||||
source = fixture.TorrentFile
|
||||
case "file URI":
|
||||
source = fixture.FileURI()
|
||||
case "data URI":
|
||||
source = fixture.DataURI
|
||||
}
|
||||
f := btDownloadFetcher(t, nil)
|
||||
btResolve(t, f, source)
|
||||
if f.Meta().Opts != nil {
|
||||
t.Fatal("Resolve assigned download options")
|
||||
}
|
||||
f.storage.mu.RLock()
|
||||
bound := f.storage.owner != nil
|
||||
f.storage.mu.RUnlock()
|
||||
if bound {
|
||||
t.Fatal("Resolve bound download storage")
|
||||
}
|
||||
resolvedTorrent := f.torrent
|
||||
dir := filepath.Join(t.TempDir(), "chosen-after-resolve")
|
||||
f.Meta().Opts = &base.Options{Path: dir}
|
||||
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
||||
t.Fatalf("output directory exists before Start: %v", err)
|
||||
}
|
||||
btStart(t, f)
|
||||
if f.torrent != resolvedTorrent {
|
||||
t.Fatal("Start replaced the resolved torrent")
|
||||
}
|
||||
btAwaitDone(t, f)
|
||||
fixture.AssertFiles(t, dir)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTDirectDownloadWithExtraTrackers(t *testing.T) {
|
||||
for _, source := range []string{"request", "config", "subscription"} {
|
||||
t.Run(source, func(t *testing.T) {
|
||||
fixture := makeBTFixture(t, map[string][]byte{
|
||||
"a.bin": btPayload(20*1024, "direct-a"),
|
||||
"nested/b.bin": btPayload(48*1024, "direct-b"),
|
||||
}, 0, false, false)
|
||||
magnet, err := url.Parse(fixture.Magnet)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
query := magnet.Query()
|
||||
query.Del("tr")
|
||||
query.Del("x.pe")
|
||||
magnet.RawQuery = query.Encode()
|
||||
req := &base.Request{URL: magnet.String()}
|
||||
f := btDownloadFetcher(t, nil)
|
||||
if source == "request" {
|
||||
req.Extra = &btprotocol.ReqExtra{Trackers: []string{fixture.TrackerURL}}
|
||||
} else {
|
||||
backendConfig := new(FetcherManager).DefaultConfig().(*config)
|
||||
if source == "config" {
|
||||
backendConfig.CustomTrackers = []string{fixture.TrackerURL}
|
||||
} else {
|
||||
backendConfig.TrackerSubscribeURLs = []string{"https://subscription.example/list"}
|
||||
backendConfig.State.TrackerSources[backendConfig.TrackerSubscribeURLs[0]] = []string{fixture.TrackerURL}
|
||||
}
|
||||
f.ctl.GetConfig = func(dst any) {
|
||||
raw, err := json.Marshal(backendConfig)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal(raw, dst); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// CreateDirect supplies options before Downloader resolves the task.
|
||||
dir := filepath.Join(t.TempDir(), "direct-download")
|
||||
opts := &base.Options{Path: dir, SelectFiles: []int{}}
|
||||
f.Meta().Opts = opts
|
||||
resolved := make(chan error, 1)
|
||||
go func() { resolved <- f.Resolve(req) }()
|
||||
select {
|
||||
case err := <-resolved:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
f.torrentDropFunc()
|
||||
<-resolved
|
||||
t.Fatal("direct magnet download did not obtain metadata from its extra tracker")
|
||||
}
|
||||
if f.Meta().Opts != opts || len(opts.SelectFiles) != len(fixture.Files) {
|
||||
t.Fatal("resolution lost direct download options or did not select all files")
|
||||
}
|
||||
btStart(t, f)
|
||||
btAwaitDone(t, f)
|
||||
fixture.AssertFiles(t, dir)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTDownloadSelectedFiles(t *testing.T) {
|
||||
fixture := newBTFixture(t, map[string][]byte{
|
||||
"a.bin": btPayload(20*1024, "a"), "nested/b.bin": btPayload(48*1024, "b"),
|
||||
}, 0)
|
||||
f := btDownloadFetcher(t, nil)
|
||||
dir := t.TempDir()
|
||||
btResolve(t, f, fixture.Magnet)
|
||||
selected := -1
|
||||
for i, file := range f.Meta().Res.Files {
|
||||
if file.Name == "b.bin" {
|
||||
selected = i
|
||||
}
|
||||
}
|
||||
if selected < 0 {
|
||||
t.Fatal("selected file absent from metadata")
|
||||
}
|
||||
f.Meta().Opts = &base.Options{Path: dir}
|
||||
if err := f.Patch(nil, &base.Options{SelectFiles: []int{selected}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
btStart(t, f)
|
||||
btAwaitDone(t, f)
|
||||
fixture.AssertFiles(t, dir, filepath.Join("bundle", "nested", "b.bin"))
|
||||
if _, err := os.Stat(filepath.Join(dir, "bundle", "a.bin")); !os.IsNotExist(err) {
|
||||
t.Fatalf("unselected output remains: %v", err)
|
||||
}
|
||||
if f.Meta().Res.Size != 48*1024 {
|
||||
t.Fatal("selected resource size is incorrect")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTPauseAndResume(t *testing.T) {
|
||||
fixture := newBTFixture(t, map[string][]byte{"": btPayload(512*1024, "pause")}, 64*1024)
|
||||
f := btDownloadFetcher(t, nil)
|
||||
dir := t.TempDir()
|
||||
btResolve(t, f, fixture.Magnet)
|
||||
f.Meta().Opts = &base.Options{Path: dir}
|
||||
btStart(t, f)
|
||||
btAwaitPartial(t, f)
|
||||
btPause(t, f)
|
||||
resolvedTorrent := f.torrent
|
||||
fixture.Limiter.SetLimit(rate.Inf)
|
||||
// Once cancelled requests have settled, no more data should arrive.
|
||||
stats := f.torrent.Stats()
|
||||
readWhilePaused := stats.BytesReadData.Int64()
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
stats = f.torrent.Stats()
|
||||
if stats.BytesReadData.Int64() != readWhilePaused || f.isDone() {
|
||||
t.Fatal("data download continued while paused")
|
||||
}
|
||||
btStart(t, f)
|
||||
if f.torrent != resolvedTorrent {
|
||||
t.Fatal("resume replaced the resolved torrent")
|
||||
}
|
||||
btAwaitDone(t, f)
|
||||
fixture.AssertFiles(t, dir)
|
||||
}
|
||||
|
||||
type btFetcherCheckpoint struct {
|
||||
Meta *fetcher.FetcherMeta `json:"meta"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
func btStore(t *testing.T, f *btDownloadHarness) []byte {
|
||||
t.Helper()
|
||||
data, err := new(FetcherManager).Store(f.Fetcher)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encodedData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encoded, err := json.Marshal(btFetcherCheckpoint{Meta: f.Meta(), Data: encodedData})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
func btRestore(t *testing.T, encoded []byte) *btDownloadHarness {
|
||||
t.Helper()
|
||||
var checkpoint btFetcherCheckpoint
|
||||
if err := json.Unmarshal(encoded, &checkpoint); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, restore := new(FetcherManager).Restore()
|
||||
if err := json.Unmarshal(checkpoint.Data, data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return btDownloadFetcher(t, restore(checkpoint.Meta, data))
|
||||
}
|
||||
|
||||
func btCloseForRestart(t *testing.T, f *btDownloadHarness) {
|
||||
t.Helper()
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := new(FetcherManager).Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTColdStartResume(t *testing.T) {
|
||||
for _, kind := range []string{"v1", "hybrid"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
var fixture *btFixture
|
||||
if kind == "hybrid" {
|
||||
fixture = newBTHybridFixture(t, btPayload(512*1024, "restart-hybrid"))
|
||||
fixture.Limiter.SetLimit(64 * 1024)
|
||||
} else {
|
||||
fixture = newBTFixture(t, map[string][]byte{"": btPayload(512*1024, "restart")}, 64*1024)
|
||||
}
|
||||
f := btDownloadFetcher(t, nil)
|
||||
dir := t.TempDir()
|
||||
btResolve(t, f, fixture.Magnet)
|
||||
f.Meta().Opts = &base.Options{Path: dir}
|
||||
btStart(t, f)
|
||||
btAwaitPartial(t, f)
|
||||
btPause(t, f)
|
||||
checkpoint := btStore(t, f)
|
||||
hash := f.Meta().Res.Hash
|
||||
btCloseForRestart(t, f)
|
||||
seedStats := fixture.Torrent.Stats()
|
||||
sentBeforeRestart := seedStats.BytesWrittenData.Int64()
|
||||
restored := btRestore(t, checkpoint)
|
||||
if restored.Meta().Opts.Path != dir || restored.Meta().Res.Hash != hash {
|
||||
t.Fatal("download directory or hash was lost")
|
||||
}
|
||||
fixture.Limiter.SetLimit(rate.Inf)
|
||||
btStart(t, restored)
|
||||
btAwaitDone(t, restored)
|
||||
fixture.AssertFiles(t, dir)
|
||||
seedStats = fixture.Torrent.Stats()
|
||||
if sent := seedStats.BytesWrittenData.Int64() - sentBeforeRestart; sent >= 512*1024 {
|
||||
t.Fatalf("restart redownloaded the entire file: %d bytes", sent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTDownloadHybrid(t *testing.T) {
|
||||
fixture := newBTHybridFixture(t, btPayload(80*1024, "hybrid-v2"))
|
||||
f := btDownloadFetcher(t, nil)
|
||||
dir := t.TempDir()
|
||||
btResolve(t, f, fixture.Magnet)
|
||||
f.Meta().Opts = &base.Options{Path: dir}
|
||||
btStart(t, f)
|
||||
btAwaitDone(t, f)
|
||||
fixture.AssertFiles(t, dir)
|
||||
}
|
||||
|
||||
func TestBTRepairCorruptedExistingFile(t *testing.T) {
|
||||
fixture := newBTFixture(t, map[string][]byte{"": btPayload(128*1024, "repair")}, 0)
|
||||
dir := t.TempDir()
|
||||
damaged := append([]byte(nil), fixture.Files["payload.bin"]...)
|
||||
damaged[20000] ^= 0xff
|
||||
if err := os.WriteFile(filepath.Join(dir, "payload.bin"), damaged, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := btDownloadFetcher(t, nil)
|
||||
btResolve(t, f, fixture.Magnet)
|
||||
f.Meta().Opts = &base.Options{Path: dir}
|
||||
btStart(t, f)
|
||||
btAwaitDone(t, f)
|
||||
fixture.AssertFiles(t, dir)
|
||||
}
|
||||
|
||||
func TestBTPatchSelectionWhilePaused(t *testing.T) {
|
||||
fixture := newBTFixture(t, map[string][]byte{"a.bin": btPayload(256*1024, "patch-a"), "b.bin": btPayload(256*1024, "patch-b")}, 64*1024)
|
||||
f := btDownloadFetcher(t, nil)
|
||||
dir := t.TempDir()
|
||||
btResolve(t, f, fixture.Magnet)
|
||||
f.Meta().Opts = &base.Options{Path: dir, SelectFiles: []int{0}}
|
||||
btStart(t, f)
|
||||
btAwaitPartial(t, f)
|
||||
btPause(t, f)
|
||||
if err := f.Patch(nil, &base.Options{SelectFiles: []int{1}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixture.Limiter.SetLimit(rate.Inf)
|
||||
btStart(t, f)
|
||||
btAwaitDone(t, f)
|
||||
fixture.AssertFiles(t, dir, filepath.Join("bundle", "b.bin"))
|
||||
for _, suffix := range []string{"", ".part"} {
|
||||
if _, err := os.Stat(filepath.Join(dir, "bundle", "a.bin") + suffix); !os.IsNotExist(err) {
|
||||
t.Fatalf("unselected output remains: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTDownloadMultiFileWithEmptyFile(t *testing.T) {
|
||||
fixture := newBTFixture(t, map[string][]byte{"empty.txt": {}, "nested/data.bin": btPayload(48*1024, "empty-file")}, 0)
|
||||
f := btDownloadFetcher(t, nil)
|
||||
dir := t.TempDir()
|
||||
btResolve(t, f, fixture.Magnet)
|
||||
f.Meta().Opts = &base.Options{Path: dir}
|
||||
btStart(t, f)
|
||||
btAwaitDone(t, f)
|
||||
fixture.AssertFiles(t, dir)
|
||||
}
|
||||
+305
-115
@@ -3,11 +3,13 @@ package bt
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -33,6 +35,10 @@ var (
|
||||
)
|
||||
|
||||
type Fetcher struct {
|
||||
mu sync.Mutex
|
||||
closeOnce sync.Once
|
||||
storage *deferredFileStorage
|
||||
|
||||
ctl *controller.Controller
|
||||
config *config
|
||||
|
||||
@@ -61,22 +67,19 @@ func (f *Fetcher) Setup(ctl *controller.Controller) {
|
||||
return
|
||||
}
|
||||
|
||||
// initClient must be called with lock held.
|
||||
func (f *Fetcher) initClient() (err error) {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
if client != nil {
|
||||
return
|
||||
}
|
||||
if closeCtx == nil {
|
||||
closeCtx, closeFunc = context.WithCancel(context.Background())
|
||||
}
|
||||
|
||||
cfg = torrent.NewDefaultClientConfig()
|
||||
cfg.Seed = true
|
||||
cfg.Bep20 = fmt.Sprintf("-GP%s-", parseBep20())
|
||||
cfg.ExtendedHandshakeClientVersion = fmt.Sprintf("Gopeed %s", base.Version)
|
||||
cfg.ListenPort = f.config.ListenPort
|
||||
cfg.NoDHT = f.config.DisableDHT
|
||||
cfg.DisablePEX = f.config.DisablePEX
|
||||
cfg.HTTPProxy = f.ctl.GetProxy(f.meta.Req.Proxy)
|
||||
dnsResolver := &DnsCacheResolver{RefreshTimeout: 5 * time.Minute}
|
||||
cfg.TrackerDialContext = dnsResolver.DialContext
|
||||
@@ -86,19 +89,34 @@ func (f *Fetcher) initClient() (err error) {
|
||||
}
|
||||
|
||||
closeCtx, closeFunc = context.WithCancel(context.Background())
|
||||
go func() {
|
||||
dnsResolver.Run(closeCtx)
|
||||
}()
|
||||
// closeClient may reset closeCtx before the goroutine runs, so pass it as an argument.
|
||||
go dnsResolver.Run(closeCtx)
|
||||
return
|
||||
}
|
||||
|
||||
func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
|
||||
f.meta.Req = req
|
||||
f.meta.Opts = opts
|
||||
if f.meta.Opts == nil {
|
||||
f.meta.Opts = &base.Options{}
|
||||
// addTorrentSpec holds lock from client initialization until the torrent is added,
|
||||
// so closeClient cannot close or reset the client in between.
|
||||
func (f *Fetcher) addTorrentSpec(spec *torrent.TorrentSpec) (*torrent.Torrent, bool, error) {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
if err := f.initClient(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if err := f.addTorrent(req, false); err != nil {
|
||||
return client.AddTorrentSpec(spec)
|
||||
}
|
||||
|
||||
func (f *Fetcher) Resolve(req *base.Request) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.torrent != nil {
|
||||
f.safeDrop()
|
||||
f.torrent = nil
|
||||
f.storage = nil
|
||||
f.torrentReady.Store(false)
|
||||
}
|
||||
f.meta.Req = req
|
||||
if err := f.addTorrent(req); err != nil {
|
||||
return err
|
||||
}
|
||||
f.updateRes()
|
||||
@@ -106,12 +124,72 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
|
||||
}
|
||||
|
||||
func (f *Fetcher) Start() (err error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.meta.Opts == nil || f.meta.Opts.Path == "" {
|
||||
return errors.New("download directory is required when starting a torrent")
|
||||
}
|
||||
if err := f.torrentDropCtx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if !f.torrentReady.Load() {
|
||||
if err = f.addTorrent(f.meta.Req, false); err != nil {
|
||||
if err = f.addTorrent(f.meta.Req); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
path := f.meta.Opts.Path
|
||||
if err := os.MkdirAll(path, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
hashes, err := f.storage.bind(storage.NewFileClientOpts{
|
||||
ClientBaseDir: path,
|
||||
FilePathMaker: func(opts storage.FilePathMakerOpts) string {
|
||||
_, name := torrentFileLayout(opts.Info, *opts.File)
|
||||
return name
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hashes != nil {
|
||||
// File storage infers completion from final file names and sizes. Verify
|
||||
// before publishing that state, or peers can see an unverified file as
|
||||
// complete and disconnect before a damaged piece is discovered.
|
||||
// Late v2 hashes schedule their own initial checks through the library.
|
||||
changes := f.torrent.SubscribePieceStateChanges()
|
||||
defer changes.Close()
|
||||
for i, known := range hashes {
|
||||
if !known {
|
||||
continue
|
||||
}
|
||||
if err := f.torrentDropCtx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.torrent.Piece(i).VerifyDataContext(f.torrentDropCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
// VerifyDataContext can return while the result is still being
|
||||
// written to storage. Wait for it before refreshing completion.
|
||||
for {
|
||||
state := f.torrent.Piece(i).State()
|
||||
if !state.Hashing && !state.QueuedForHash && !state.Marking {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-changes.Values:
|
||||
case <-f.torrentDropCtx.Done():
|
||||
return f.torrentDropCtx.Err()
|
||||
case <-f.torrent.Closed():
|
||||
return errors.New("torrent closed during verification")
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range hashes {
|
||||
f.torrent.Piece(i).UpdateCompletion()
|
||||
}
|
||||
}
|
||||
|
||||
files := f.torrent.Files()
|
||||
// If the user does not specify the file to download, all files will be downloaded by default
|
||||
if f.data.Progress == nil {
|
||||
@@ -132,21 +210,29 @@ func (f *Fetcher) Start() (err error) {
|
||||
}
|
||||
}
|
||||
f.torrent.AllowDataDownload()
|
||||
go f.doUpload(false)
|
||||
return
|
||||
}
|
||||
|
||||
func (f *Fetcher) Pause() (err error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.torrent == nil {
|
||||
return nil
|
||||
}
|
||||
f.torrent.DisallowDataDownload()
|
||||
return
|
||||
}
|
||||
|
||||
func (f *Fetcher) Close() (err error) {
|
||||
f.safeDrop()
|
||||
f.torrentDropFunc()
|
||||
f.uploadDoneCh <- nil
|
||||
if len(client.Torrents()) == 0 {
|
||||
err = closeClient()
|
||||
}
|
||||
f.closeOnce.Do(func() {
|
||||
f.torrentDropFunc()
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.safeDrop()
|
||||
f.torrentReady.Store(false)
|
||||
close(f.uploadDoneCh)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -164,6 +250,8 @@ func (f *Fetcher) Meta() *fetcher.FetcherMeta {
|
||||
}
|
||||
|
||||
func (f *Fetcher) Stats() *fetcher.Stats {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var stats torrent.TorrentStats
|
||||
var pieceMap *base.PieceMap
|
||||
pieceMapReady := false
|
||||
@@ -370,53 +458,138 @@ func normalizeBTTransport(network string) string {
|
||||
}
|
||||
|
||||
func (f *Fetcher) Progress() fetcher.Progress {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if !f.torrentReady.Load() {
|
||||
return f.data.Progress
|
||||
return append(fetcher.Progress(nil), f.data.Progress...)
|
||||
}
|
||||
for i := range f.data.Progress {
|
||||
selectIndex := f.meta.Opts.SelectFiles[i]
|
||||
file := f.torrent.Files()[selectIndex]
|
||||
f.data.Progress[i] = file.BytesCompleted()
|
||||
}
|
||||
return f.data.Progress
|
||||
return append(fetcher.Progress(nil), f.data.Progress...)
|
||||
}
|
||||
|
||||
func (f *Fetcher) Wait() (err error) {
|
||||
func (f *Fetcher) Wait() error {
|
||||
f.mu.Lock()
|
||||
var closed <-chan struct{}
|
||||
if f.torrent != nil {
|
||||
closed = f.torrent.Closed()
|
||||
}
|
||||
f.mu.Unlock()
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-f.torrentDropCtx.Done():
|
||||
return
|
||||
case <-time.After(time.Second):
|
||||
if f.torrentReady.Load() && len(f.meta.Opts.SelectFiles) > 0 {
|
||||
if f.isDone() {
|
||||
// remove unselected files
|
||||
for i, file := range f.torrent.Files() {
|
||||
selected := false
|
||||
for _, selectIndex := range f.meta.Opts.SelectFiles {
|
||||
if i == selectIndex {
|
||||
selected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !selected {
|
||||
util.SafeRemove(filepath.Join(f.meta.Opts.Path, f.meta.Res.Name, file.Path()))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
return nil
|
||||
case <-closed:
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
done, err := f.finishDownload()
|
||||
if done || err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Fetcher) finishDownload() (bool, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if !f.torrentReady.Load() || f.meta.Opts == nil || len(f.meta.Opts.SelectFiles) == 0 || !f.isDoneLocked() {
|
||||
return false, nil
|
||||
}
|
||||
selected := make(map[int]bool, len(f.meta.Opts.SelectFiles))
|
||||
for _, i := range f.meta.Opts.SelectFiles {
|
||||
selected[i] = true
|
||||
}
|
||||
for i, file := range f.torrent.Files() {
|
||||
if selected[i] {
|
||||
continue
|
||||
}
|
||||
root, name := torrentFileLayout(f.torrent.Info(), file.FileInfo())
|
||||
if err := removeUnselectedFile(f.meta.Opts.Path, root, name); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Match the file storage layout, including its NoName convention for rootless
|
||||
// torrents. File.Path() always prepends BestName, even when storage omits it.
|
||||
func torrentFileLayout(info *metainfo.Info, file metainfo.FileInfo) (root, name string) {
|
||||
parts := file.BestPath()
|
||||
if info.BestName() != metainfo.NoName {
|
||||
if len(parts) > 0 {
|
||||
root = info.BestName()
|
||||
}
|
||||
parts = append([]string{info.BestName()}, parts...)
|
||||
}
|
||||
// A v2 file tree can itself have a named root without an extra name wrapper.
|
||||
if root == "" && info.HasV2() && len(info.FileTree.Dir) == 1 {
|
||||
for dir, tree := range info.FileTree.Dir {
|
||||
if tree.IsDir() {
|
||||
root = dir
|
||||
}
|
||||
}
|
||||
}
|
||||
return root, filepath.Join(parts...)
|
||||
}
|
||||
|
||||
// Preserve the actual torrent root, or the download directory for rootless
|
||||
// torrents. Only prune empty directories strictly below that boundary.
|
||||
func removeUnselectedFile(downloadDir, torrentRoot, torrentPath string) error {
|
||||
if !filepath.IsLocal(torrentPath) || (torrentRoot != "" && !filepath.IsLocal(torrentRoot)) {
|
||||
return fmt.Errorf("invalid torrent file path %q", torrentPath)
|
||||
}
|
||||
root := filepath.Join(downloadDir, torrentRoot)
|
||||
name := filepath.Join(downloadDir, torrentPath)
|
||||
rel, err := filepath.Rel(root, name)
|
||||
if err != nil || rel == "." || !filepath.IsLocal(rel) {
|
||||
return fmt.Errorf("torrent file %q is outside cleanup root %q", name, root)
|
||||
}
|
||||
for _, candidate := range []string{name, name + ".part"} {
|
||||
if err := util.SafeRemove(candidate); err != nil {
|
||||
return fmt.Errorf("remove unselected torrent file %q: %w", candidate, err)
|
||||
}
|
||||
}
|
||||
for dir := filepath.Dir(name); dir != root && dir != filepath.Dir(dir); dir = filepath.Dir(dir) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read torrent directory %q: %w", dir, err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
break
|
||||
}
|
||||
if err := util.SafeRemove(dir); err != nil {
|
||||
return fmt.Errorf("remove empty torrent directory %q: %w", dir, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) isDone() bool {
|
||||
if f.meta.Opts == nil {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.isDoneLocked()
|
||||
}
|
||||
|
||||
func (f *Fetcher) isDoneLocked() bool {
|
||||
if f.meta.Opts == nil || f.torrent == nil {
|
||||
return false
|
||||
}
|
||||
for _, selectIndex := range f.meta.Opts.SelectFiles {
|
||||
file := f.torrent.Files()[selectIndex]
|
||||
if file.BytesCompleted() < file.Length() {
|
||||
return false
|
||||
for _, index := range f.meta.Opts.SelectFiles {
|
||||
file := f.torrent.Files()[index]
|
||||
for i := file.BeginPieceIndex(); i < file.EndPieceIndex(); i++ {
|
||||
state := f.torrent.Piece(i).State()
|
||||
if !state.Ok || !state.Complete || state.Hashing || state.QueuedForHash {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
@@ -425,6 +598,11 @@ func (f *Fetcher) isDone() bool {
|
||||
// Patch modifies the BT task settings.
|
||||
// Invalid file indices are silently ignored.
|
||||
func (f *Fetcher) Patch(req *base.Request, opts *base.Options) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.meta.Opts == nil {
|
||||
f.meta.Opts = &base.Options{}
|
||||
}
|
||||
if opts == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -481,9 +659,9 @@ func (f *Fetcher) updateRes() {
|
||||
Files: make([]*base.FileInfo, len(f.torrent.Files())),
|
||||
Hash: f.torrent.InfoHash().String(),
|
||||
}
|
||||
// Directory torrent
|
||||
if f.torrent.Info().Length == 0 {
|
||||
res.Name = f.torrent.Name()
|
||||
// Use the same root classification as storage (v2 single files have Length == 0).
|
||||
if files := f.torrent.Files(); len(files) > 0 {
|
||||
res.Name, _ = torrentFileLayout(f.torrent.Info(), files[0].FileInfo())
|
||||
}
|
||||
for i, file := range f.torrent.Files() {
|
||||
res.Files[i] = &base.FileInfo{
|
||||
@@ -500,61 +678,50 @@ func (f *Fetcher) updateRes() {
|
||||
}
|
||||
|
||||
func (f *Fetcher) Upload() (err error) {
|
||||
return f.addTorrent(f.meta.Req, true)
|
||||
if err = f.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) doUpload(fromUpload bool) {
|
||||
if !f.torrentUpload.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check and update seed data
|
||||
lastData := &fetcherData{
|
||||
SeedBytes: f.data.SeedBytes,
|
||||
SeedTime: f.data.SeedTime,
|
||||
}
|
||||
var doneTime int64 = 0
|
||||
f.mu.Lock()
|
||||
initialBytes, initialTime := f.data.SeedBytes, f.data.SeedTime
|
||||
torrentClosed := f.torrent.Closed()
|
||||
f.mu.Unlock()
|
||||
var doneTime int64
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-f.torrentDropCtx.Done():
|
||||
return
|
||||
case <-time.After(time.Second):
|
||||
case <-torrentClosed:
|
||||
return
|
||||
case <-ticker.C:
|
||||
f.mu.Lock()
|
||||
if !f.torrentReady.Load() {
|
||||
f.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
stats := f.torrentStats()
|
||||
f.data.SeedBytes = lastData.SeedBytes + stats.BytesWrittenData.Int64()
|
||||
|
||||
// Check is download complete, if not don't check and stop seeding
|
||||
if !fromUpload && !f.isDone() {
|
||||
f.data.SeedBytes = initialBytes + stats.BytesWrittenData.Int64()
|
||||
if !fromUpload && !f.isDoneLocked() {
|
||||
f.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
if doneTime == 0 {
|
||||
doneTime = time.Now().Unix()
|
||||
}
|
||||
f.data.SeedTime = lastData.SeedTime + time.Now().Unix() - doneTime
|
||||
|
||||
// If the seed forever is true, keep seeding
|
||||
if f.config.SeedKeep {
|
||||
continue
|
||||
}
|
||||
|
||||
// If the seed ratio is reached, stop seeding
|
||||
if f.config.SeedRatio > 0 {
|
||||
seedRadio := f.seedRadio()
|
||||
if seedRadio >= f.config.SeedRatio {
|
||||
f.Close()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If the seed time is reached, stop seeding
|
||||
if f.config.SeedTime > 0 {
|
||||
if f.data.SeedTime >= f.config.SeedTime {
|
||||
f.Close()
|
||||
break
|
||||
}
|
||||
f.data.SeedTime = initialTime + time.Now().Unix() - doneTime
|
||||
stop := !f.config.SeedKeep && ((f.config.SeedRatio > 0 && f.seedRadio() >= f.config.SeedRatio) || (f.config.SeedTime > 0 && f.data.SeedTime >= f.config.SeedTime))
|
||||
f.mu.Unlock()
|
||||
if stop {
|
||||
f.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -572,6 +739,8 @@ func (f *Fetcher) torrentStats() torrent.TorrentStats {
|
||||
}
|
||||
|
||||
func (f *Fetcher) UploadedBytes() int64 {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.data.SeedBytes
|
||||
}
|
||||
|
||||
@@ -580,13 +749,11 @@ func (f *Fetcher) WaitUpload() (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) addTorrent(req *base.Request, fromUpload bool) (err error) {
|
||||
func (f *Fetcher) addTorrent(req *base.Request) (err error) {
|
||||
f.ctl.GetConfig(&f.config)
|
||||
if err = base.ParseReqExtra[bt.ReqExtra](req); err != nil {
|
||||
return
|
||||
}
|
||||
if err = f.initClient(); err != nil {
|
||||
return
|
||||
}
|
||||
schema := util.ParseSchema(req.URL)
|
||||
privateTorrent := false
|
||||
var spec *torrent.TorrentSpec
|
||||
@@ -599,11 +766,16 @@ func (f *Fetcher) addTorrent(req *base.Request, fromUpload bool) (err error) {
|
||||
var reader io.Reader
|
||||
if schema == "FILE" {
|
||||
fileUrl, _ := url.Parse(req.URL)
|
||||
filePath := fileUrl.Path[1:]
|
||||
filePath := fileUrl.Path
|
||||
if runtime.GOOS == "windows" && len(filePath) > 2 && filePath[0] == '/' && filePath[2] == ':' {
|
||||
filePath = filePath[1:]
|
||||
}
|
||||
filePath = filepath.FromSlash(filePath)
|
||||
reader, err = os.Open(filePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer reader.(io.Closer).Close()
|
||||
} else if schema == "DATA" {
|
||||
_, data := util.ParseDataUri(req.URL)
|
||||
reader = bytes.NewBuffer(data)
|
||||
@@ -636,17 +808,6 @@ func (f *Fetcher) addTorrent(req *base.Request, fromUpload bool) (err error) {
|
||||
return
|
||||
}
|
||||
}
|
||||
spec.Storage = storage.NewFileOpts(storage.NewFileClientOpts{
|
||||
ClientBaseDir: cfg.DataDir,
|
||||
TorrentDirMaker: func(baseDir string, info *metainfo.Info, infoHash metainfo.Hash) string {
|
||||
return f.meta.Opts.Path
|
||||
},
|
||||
})
|
||||
f.torrent, _, err = client.AddTorrentSpec(spec)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Do not add external tracker to a private torrent.
|
||||
if !privateTorrent {
|
||||
// use map to deduplicate
|
||||
@@ -659,23 +820,40 @@ func (f *Fetcher) addTorrent(req *base.Request, fromUpload bool) (err error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(f.config.Trackers) > 0 {
|
||||
for _, tracker := range f.config.Trackers {
|
||||
trackers[tracker] = true
|
||||
}
|
||||
for _, tracker := range f.config.effectiveTrackers() {
|
||||
trackers[tracker] = true
|
||||
}
|
||||
if len(trackers) > 0 {
|
||||
announceList := make([][]string, 0)
|
||||
for tracker := range trackers {
|
||||
announceList = append(announceList, []string{tracker})
|
||||
spec.Trackers = append(spec.Trackers, []string{tracker})
|
||||
}
|
||||
f.torrent.AddTrackers(announceList)
|
||||
}
|
||||
}
|
||||
<-f.torrent.GotInfo()
|
||||
s := &deferredFileStorage{}
|
||||
spec.Storage = s
|
||||
var added bool
|
||||
f.torrent, added, err = f.addTorrentSpec(spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !added {
|
||||
f.torrent = nil
|
||||
return errors.New("torrent is already active")
|
||||
}
|
||||
f.storage = s
|
||||
f.torrent.DisallowDataDownload()
|
||||
// Extra trackers must be available while magnet metadata is still missing;
|
||||
// they may be the only way to discover a peer that can provide it.
|
||||
select {
|
||||
case <-f.torrent.GotInfo():
|
||||
case <-f.torrentDropCtx.Done():
|
||||
f.safeDrop()
|
||||
return f.torrentDropCtx.Err()
|
||||
case <-f.torrent.Closed():
|
||||
return errors.New("torrent closed during resolution")
|
||||
}
|
||||
f.torrentReady.Store(true)
|
||||
|
||||
go f.doUpload(fromUpload)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -771,17 +949,29 @@ func (fm *FetcherManager) AutoRename() bool {
|
||||
|
||||
func (fm *FetcherManager) DefaultConfig() any {
|
||||
return &config{
|
||||
TrackerSubscribeURLs: append([]string{}, defaultTrackerSubscribeURLs...),
|
||||
AutoUpdateTrackers: true,
|
||||
CustomTrackers: []string{},
|
||||
ConfigState: fetcher.ConfigState[state]{State: state{
|
||||
TrackerSources: map[string][]string{},
|
||||
TrackerSubscriptionStatuses: map[string]trackerSubscriptionStatus{},
|
||||
}},
|
||||
ListenPort: 0,
|
||||
Trackers: []string{},
|
||||
SeedKeep: false,
|
||||
SeedRatio: 1.0,
|
||||
SeedTime: 120 * 60,
|
||||
DisableDHT: false,
|
||||
DisablePEX: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Store(f fetcher.Fetcher) (data any, err error) {
|
||||
_f := f.(*Fetcher)
|
||||
return _f.data, nil
|
||||
_f.mu.Lock()
|
||||
defer _f.mu.Unlock()
|
||||
snapshot := *_f.data
|
||||
snapshot.Progress = append(fetcher.Progress(nil), snapshot.Progress...)
|
||||
return &snapshot, nil
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Restore() (v any, f func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
@@ -18,11 +19,11 @@ import (
|
||||
)
|
||||
|
||||
func TestFetcher_Resolve_Torrent(t *testing.T) {
|
||||
doResolve(t, buildFetcher())
|
||||
doResolve(t, buildFetcher(t))
|
||||
}
|
||||
|
||||
func TestFetcher_Resolve_DataUri_Torrent(t *testing.T) {
|
||||
fetcher := buildFetcher()
|
||||
fetcher := buildFetcher(t)
|
||||
buf, err := os.ReadFile("./testdata/ubuntu-22.04-live-server-amd64.iso.torrent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -31,7 +32,7 @@ func TestFetcher_Resolve_DataUri_Torrent(t *testing.T) {
|
||||
dataUri := "data:application/x-bittorrent;base64," + base64.StdEncoding.EncodeToString(buf)
|
||||
err = fetcher.Resolve(&base.Request{
|
||||
URL: dataUri,
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -53,7 +54,7 @@ func TestFetcher_Resolve_DataUri_Torrent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFetcher_Config(t *testing.T) {
|
||||
doResolve(t, buildConfigFetcher(nil))
|
||||
doResolve(t, buildConfigFetcher(t, nil))
|
||||
}
|
||||
|
||||
func TestFetcher_ResolveWithProxy(t *testing.T) {
|
||||
@@ -61,7 +62,7 @@ func TestFetcher_ResolveWithProxy(t *testing.T) {
|
||||
proxyListener := test.StartSocks5Server(usr, pwd)
|
||||
defer proxyListener.Close()
|
||||
|
||||
doResolve(t, buildConfigFetcher(&base.DownloaderProxyConfig{
|
||||
doResolve(t, buildConfigFetcher(t, &base.DownloaderProxyConfig{
|
||||
Enable: true,
|
||||
System: false,
|
||||
Scheme: "socks5",
|
||||
@@ -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) {
|
||||
t.Run("Resolve Single File", func(t *testing.T) {
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
@@ -81,7 +158,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
|
||||
"udp://tracker.bitsearch.to:1337/announce",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -111,7 +188,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
|
||||
"udp://tracker.bitsearch.to:1337/announce",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -145,7 +222,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
|
||||
t.Run("Resolve Unclean Torrent", func(t *testing.T) {
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "./testdata/test.unclean.torrent",
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("Resolve Unclean Torrent Resolve() got = %v, want nil", err)
|
||||
}
|
||||
@@ -156,7 +233,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
|
||||
uri := "file:///" + file
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: uri,
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("Resolve file scheme Torrent Resolve() got = %v, want nil", err)
|
||||
}
|
||||
@@ -211,7 +288,7 @@ func TestFetcherManager_ParseName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func buildFetcher() fetcher.Fetcher {
|
||||
func buildFetcher(t testing.TB) fetcher.Fetcher {
|
||||
fb := new(FetcherManager)
|
||||
fetcher := fb.Build()
|
||||
newController := controller.NewController()
|
||||
@@ -219,14 +296,15 @@ func buildFetcher() fetcher.Fetcher {
|
||||
json.Unmarshal([]byte(test.ToJson(fb.DefaultConfig())), v)
|
||||
}
|
||||
fetcher.Setup(newController)
|
||||
t.Cleanup(func() { fetcher.Close(); closeClient() })
|
||||
return fetcher
|
||||
}
|
||||
|
||||
func buildConfigFetcher(proxyConfig *base.DownloaderProxyConfig) fetcher.Fetcher {
|
||||
func buildConfigFetcher(t testing.TB, proxyConfig *base.DownloaderProxyConfig) fetcher.Fetcher {
|
||||
fetcher := new(FetcherManager).Build()
|
||||
newController := controller.NewController()
|
||||
mockCfg := config{
|
||||
Trackers: []string{
|
||||
CustomTrackers: []string{
|
||||
"udp://tracker.birkenwald.de:6969/announce",
|
||||
"udp://tracker.bitsearch.to:1337/announce",
|
||||
}}
|
||||
@@ -237,29 +315,30 @@ func buildConfigFetcher(proxyConfig *base.DownloaderProxyConfig) fetcher.Fetcher
|
||||
return proxyConfig.ToHandler()
|
||||
}
|
||||
fetcher.Setup(newController)
|
||||
t.Cleanup(func() { fetcher.Close(); closeClient() })
|
||||
return fetcher
|
||||
}
|
||||
|
||||
// TestFetcher_Patch tests the Patch functionality for BT fetcher.
|
||||
// It tests modifying selected files after Resolve (without downloading).
|
||||
func TestFetcher_Patch(t *testing.T) {
|
||||
f := buildFetcher()
|
||||
f := buildFetcher(t)
|
||||
|
||||
// Resolve a multi-file torrent
|
||||
err := f.Resolve(&base.Request{
|
||||
URL: "./testdata/test.torrent",
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify initial state: 3 files, all selected by default
|
||||
// Resolution describes files without choosing download options.
|
||||
meta := f.Meta()
|
||||
if len(meta.Res.Files) != 3 {
|
||||
t.Fatalf("Expected 3 files, got %d", len(meta.Res.Files))
|
||||
}
|
||||
if len(meta.Opts.SelectFiles) != 3 {
|
||||
t.Fatalf("Expected 3 selected files, got %d", len(meta.Opts.SelectFiles))
|
||||
if meta.Opts != nil {
|
||||
t.Fatal("Resolve should not assign download options")
|
||||
}
|
||||
|
||||
// Total size of all files: 107484864
|
||||
@@ -366,3 +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()
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
package bt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/anacrolix/torrent"
|
||||
"github.com/anacrolix/torrent/bencode"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
"github.com/anacrolix/torrent/storage"
|
||||
)
|
||||
|
||||
func TestStartDoesNotPublishUnverifiedCompletion(t *testing.T) {
|
||||
cfg := torrent.TestingConfig(t)
|
||||
cfg.DisableTCP = true
|
||||
cfg.DisableUTP = true
|
||||
c, err := torrent.NewClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
payload := btPayload(32*1024, "unverified")
|
||||
info := metainfo.Info{Name: "payload.bin", Length: int64(len(payload)), PieceLength: 16 * 1024}
|
||||
for offset := 0; offset < len(payload); offset += int(info.PieceLength) {
|
||||
hash := sha1.Sum(payload[offset : offset+int(info.PieceLength)])
|
||||
info.Pieces = append(info.Pieces, hash[:]...)
|
||||
}
|
||||
raw, err := bencode.Marshal(info)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := &deferredFileStorage{}
|
||||
spec := torrent.TorrentSpecFromMetaInfo(&metainfo.MetaInfo{InfoBytes: raw})
|
||||
spec.Storage = store
|
||||
tor, _, err := c.AddTorrentSpec(spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tor.DisallowDataDownload()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
f := &Fetcher{torrent: tor, storage: store, meta: &fetcher.FetcherMeta{}, data: &fetcherData{}, torrentDropCtx: ctx, torrentDropFunc: cancel, config: &config{SeedKeep: true}}
|
||||
f.torrentReady.Store(true)
|
||||
f.updateRes()
|
||||
dir := t.TempDir()
|
||||
payload[20000] ^= 0xff
|
||||
if err := os.WriteFile(filepath.Join(dir, info.Name), payload, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.meta.Opts = &base.Options{Path: dir}
|
||||
// Retain the original event channel to detect even a transient completion.
|
||||
// With no network transports, the damaged piece cannot have been repaired.
|
||||
completed := tor.Complete().On()
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-completed:
|
||||
t.Fatal("Start published completion before verifying the existing file")
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartUsesSelectionSubmittedAfterResolve(t *testing.T) {
|
||||
cfg := torrent.TestingConfig(t)
|
||||
cfg.DataDir = t.TempDir()
|
||||
cfg.DisableTCP = true
|
||||
cfg.DisableUTP = true
|
||||
c, err := torrent.NewClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
store := &deferredFileStorage{}
|
||||
mi, err := metainfo.LoadFromFile("testdata/test.torrent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spec := torrent.TorrentSpecFromMetaInfo(mi)
|
||||
spec.Storage = store
|
||||
tor, _, err := c.AddTorrentSpec(spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tor.DisallowDataDownload()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
f := &Fetcher{torrent: tor, storage: store, meta: &fetcher.FetcherMeta{}, data: &fetcherData{}, torrentDropCtx: ctx, torrentDropFunc: cancel, config: &config{SeedKeep: true}}
|
||||
f.torrentReady.Store(true)
|
||||
f.updateRes()
|
||||
f.meta.Opts = &base.Options{Path: t.TempDir(), SelectFiles: []int{2}}
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, file := range tor.Files() {
|
||||
want := torrent.PiecePriorityNone
|
||||
if i == 2 {
|
||||
want = torrent.PiecePriorityNormal
|
||||
}
|
||||
if file.Priority() != want {
|
||||
t.Errorf("file %d priority = %v, want %v", i, file.Priority(), want)
|
||||
}
|
||||
}
|
||||
if len(f.data.Progress) != 1 {
|
||||
t.Fatalf("progress tracks %d files, want 1", len(f.data.Progress))
|
||||
}
|
||||
if err := f.Pause(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tor.Files()[0].Priority() != torrent.PiecePriorityNone {
|
||||
t.Fatal("resume selected an unselected file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitCleansUnselectedFilesFromSharedPiece(t *testing.T) {
|
||||
for _, mode := range []string{"partial", "part only", "both paths", "all", "already removed", "remove error"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
keepPath := filepath.Join(dir, "bundle", "keep.txt")
|
||||
skipPath := filepath.Join(dir, "bundle", "nested", "skip.txt")
|
||||
if err := os.MkdirAll(filepath.Dir(skipPath), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(keepPath, []byte("keep"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(skipPath, []byte("skip"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Both files occupy the same piece, so downloading keep.txt also writes skip.txt.
|
||||
hash := sha1.Sum([]byte("keepskip"))
|
||||
info := metainfo.Info{Name: "bundle", PieceLength: 16, Pieces: hash[:], Files: []metainfo.FileInfo{
|
||||
{Length: 4, Path: []string{"keep.txt"}},
|
||||
{Length: 4, Path: []string{"nested", "skip.txt"}},
|
||||
}}
|
||||
storageOpts := storage.NewFileClientOpts{
|
||||
ClientBaseDir: dir,
|
||||
TorrentDirMaker: func(baseDir string, _ *metainfo.Info, _ metainfo.Hash) string { return baseDir },
|
||||
}
|
||||
// VerifyDataContext can return before storage finishes marking the piece
|
||||
// complete. Disable automatic part-file promotion so it cannot rename
|
||||
// the .part fixtures that this test creates to exercise cleanup.
|
||||
storageOpts.UsePartFiles.Set(false)
|
||||
store := storage.NewFileOpts(storageOpts)
|
||||
defer store.Close()
|
||||
config := torrent.TestingConfig(t)
|
||||
config.DisableTCP = true
|
||||
config.DisableUTP = true
|
||||
c, err := torrent.NewClient(config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
spec, err := torrent.TorrentSpecFromMetaInfoErr(&metainfo.MetaInfo{InfoBytes: bencode.MustMarshal(&info)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spec.Storage = store
|
||||
tor, _, err := c.AddTorrentSpec(spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := tor.VerifyDataContext(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &Fetcher{torrent: tor, meta: &fetcher.FetcherMeta{Opts: &base.Options{Path: dir, SelectFiles: []int{0}}}, data: &fetcherData{}, torrentDropCtx: ctx}
|
||||
f.torrentReady.Store(true)
|
||||
f.updateRes()
|
||||
if mode == "all" {
|
||||
f.meta.Opts.SelectFiles = []int{0, 1}
|
||||
}
|
||||
if !f.isDone() {
|
||||
t.Fatal("selected file is not complete")
|
||||
}
|
||||
if mode == "part only" || mode == "both paths" || mode == "all" {
|
||||
if err := os.WriteFile(skipPath+".part", []byte("skip"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if mode == "part only" || mode == "already removed" || mode == "remove error" {
|
||||
if err := os.Remove(skipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if mode == "remove error" {
|
||||
if err := os.Mkdir(skipPath, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(skipPath, "child"), []byte("preserve"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
err = f.Wait()
|
||||
if ctx.Err() != nil {
|
||||
t.Fatal(ctx.Err())
|
||||
}
|
||||
if mode == "remove error" {
|
||||
if err == nil {
|
||||
t.Fatal("cleanup failure was silently ignored")
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(keepPath)
|
||||
if err != nil || string(data) != "keep" {
|
||||
t.Fatalf("selected file changed: %q, %v", data, err)
|
||||
}
|
||||
if mode == "all" {
|
||||
if _, err := os.Stat(skipPath + ".part"); err != nil {
|
||||
t.Fatalf("selected part file was removed: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(skipPath)
|
||||
if err != nil || string(data) != "skip" {
|
||||
t.Fatalf("selected file changed: %q, %v", data, err)
|
||||
}
|
||||
} else if mode != "remove error" {
|
||||
for _, removed := range []string{skipPath, skipPath + ".part", filepath.Dir(skipPath)} {
|
||||
if _, err := os.Stat(removed); !os.IsNotExist(err) {
|
||||
t.Fatalf("unselected file or empty directory remains: %s: %v", removed, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveUnselectedFilePrunesOnlyEmptyParents(t *testing.T) {
|
||||
for _, preserveSibling := range []bool{false, true} {
|
||||
t.Run(fmt.Sprint(preserveSibling), func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
relative := filepath.Join("bundle", "nested", "deep", "skip.txt")
|
||||
name := filepath.Join(root, relative)
|
||||
if err := os.MkdirAll(filepath.Dir(name), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(name+".part", []byte("partial"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sibling := filepath.Join(root, "bundle", "keep.txt")
|
||||
if preserveSibling {
|
||||
if err := os.WriteFile(sibling, []byte("keep"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := removeUnselectedFile(root, "bundle", relative); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "bundle", "nested")); !os.IsNotExist(err) {
|
||||
t.Fatalf("empty ancestors remain: %v", err)
|
||||
}
|
||||
if preserveSibling {
|
||||
data, err := os.ReadFile(sibling)
|
||||
if err != nil || string(data) != "keep" {
|
||||
t.Fatalf("sibling changed: %q, %v", data, err)
|
||||
}
|
||||
}
|
||||
if info, err := os.Stat(filepath.Join(root, "bundle")); err != nil || !info.IsDir() {
|
||||
t.Fatalf("torrent root directory was removed: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
t.Fatalf("download directory was removed: %v", err)
|
||||
}
|
||||
// A file directly inside the torrent root must not cause that root to be pruned.
|
||||
rootFile := filepath.Join("bundle", "root.txt")
|
||||
if err := os.WriteFile(filepath.Join(root, rootFile)+".part", []byte("partial"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := removeUnselectedFile(root, "bundle", rootFile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "bundle")); err != nil {
|
||||
t.Fatalf("torrent root directory was removed after root-level cleanup: %v", err)
|
||||
}
|
||||
// Repeating cleanup after the file and its subdirectories are gone is harmless.
|
||||
if err := removeUnselectedFile(root, "bundle", relative); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupMatchesNamedAndRootlessStorage(t *testing.T) {
|
||||
for _, version := range []int64{1, 2} {
|
||||
for _, layout := range []string{"rootless", "wrapped", "file-tree-root"} {
|
||||
if version == 1 && layout == "file-tree-root" {
|
||||
continue
|
||||
}
|
||||
t.Run(fmt.Sprintf("v%d/%s", version, layout), func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
info := metainfo.Info{Name: metainfo.NoName, PieceLength: 16384}
|
||||
root := dir
|
||||
if layout == "wrapped" {
|
||||
info.Name = "bundle"
|
||||
root = filepath.Join(dir, "bundle")
|
||||
}
|
||||
if version == 1 {
|
||||
info.Files = []metainfo.FileInfo{{Path: []string{"keep.txt"}}, {Path: []string{"nested", "deep", "skip.txt"}}}
|
||||
} else {
|
||||
info.MetaVersion = 2
|
||||
info.FileTree = metainfo.FileTree{Dir: map[string]metainfo.FileTree{
|
||||
"keep.txt": {},
|
||||
"nested": {Dir: map[string]metainfo.FileTree{"deep": {Dir: map[string]metainfo.FileTree{"skip.txt": {}}}}},
|
||||
}}
|
||||
}
|
||||
if layout == "file-tree-root" {
|
||||
info.FileTree = metainfo.FileTree{Dir: map[string]metainfo.FileTree{"tree-root": info.FileTree}}
|
||||
root = filepath.Join(dir, "tree-root")
|
||||
}
|
||||
// Keep the library's default FilePathMaker here to independently verify
|
||||
// that cleanup agrees with its real disk layout, including NoName.
|
||||
store := storage.NewFileOpts(storage.NewFileClientOpts{
|
||||
ClientBaseDir: dir,
|
||||
TorrentDirMaker: func(baseDir string, _ *metainfo.Info, _ metainfo.Hash) string { return baseDir },
|
||||
})
|
||||
defer store.Close()
|
||||
torrentStorage, err := store.OpenTorrent(context.Background(), &info, metainfo.Hash{1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer torrentStorage.Close()
|
||||
skip := filepath.Join(root, "nested", "deep", "skip.txt")
|
||||
if _, err := os.Stat(skip); err != nil {
|
||||
t.Fatalf("unexpected storage layout: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(skip+".part", []byte("partial"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files := info.UpvertedFiles()
|
||||
boundary, name := torrentFileLayout(&info, files[1])
|
||||
if err := removeUnselectedFile(dir, boundary, name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "nested")); !os.IsNotExist(err) {
|
||||
t.Fatalf("empty subtree remains: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "keep.txt")); err != nil {
|
||||
t.Fatalf("selected file removed: %v", err)
|
||||
}
|
||||
// Also verify the boundary when the protected directory becomes empty.
|
||||
if err := os.Remove(filepath.Join(root, "keep.txt")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, file := range files {
|
||||
boundary, name := torrentFileLayout(&info, file)
|
||||
if err := removeUnselectedFile(dir, boundary, name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if stat, err := os.Stat(root); err != nil || !stat.IsDir() {
|
||||
t.Fatalf("cleanup boundary was removed: %v", 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)
|
||||
}
|
||||
|
||||
func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
|
||||
func (f *Fetcher) Resolve(req *base.Request) error {
|
||||
link, err := parseLink(req.URL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f.meta.Req = req
|
||||
f.meta.Opts = opts
|
||||
if f.meta.Opts == nil {
|
||||
f.meta.Opts = &base.Options{}
|
||||
}
|
||||
|
||||
f.meta.Res = buildResource(link)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ func TestFetcher_Resolve(t *testing.T) {
|
||||
f := (&FetcherManager{}).Build()
|
||||
f.Setup(controller.NewController())
|
||||
|
||||
err := f.Resolve(&base.Request{URL: testLink}, &base.Options{Path: t.TempDir()})
|
||||
f.Meta().Opts = &base.Options{Path: t.TempDir()}
|
||||
err := f.Resolve(&base.Request{URL: testLink})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,810 @@
|
||||
// Package ftp downloads files and directory trees over FTP, FTPS (implicit
|
||||
// TLS) and FTPES (explicit TLS, AUTH TLS).
|
||||
//
|
||||
// A file is split into segments of at least 1 MiB. Each segment is fetched on
|
||||
// its own login with REST and RETR, and its data connection is closed once
|
||||
// the segment's last byte has arrived. A server that refuses a login lowers
|
||||
// the task's connection ceiling to the logins that worked, and a server that
|
||||
// refuses REST gets one sequential stream per file.
|
||||
package ftp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/jlaffaye/ftp"
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
pftp "github.com/GopeedLab/gopeed/pkg/protocol/ftp"
|
||||
)
|
||||
|
||||
const (
|
||||
// Many servers cap logins per user, so FTP starts lower than HTTP.
|
||||
defaultConnections = 4
|
||||
maxConnections = 256
|
||||
minSegmentSize = 1 << 20
|
||||
maxRetries = 5
|
||||
copyBufferSize = 256 << 10
|
||||
)
|
||||
|
||||
// retryDelay is the first wait before a retry. Later retries wait longer.
|
||||
var retryDelay = 500 * time.Millisecond
|
||||
|
||||
var (
|
||||
errSizeChanged = errors.New("ftp: the remote file changed size since the task was resolved")
|
||||
errShortTransfer = errors.New("ftp: the transfer ended before the segment was complete")
|
||||
errRestUnsupported = errors.New("ftp: the server does not support REST")
|
||||
errDiskWrite = errors.New("ftp: cannot write the downloaded data")
|
||||
)
|
||||
|
||||
type config struct {
|
||||
Connections int `json:"connections"`
|
||||
}
|
||||
|
||||
// segment is a byte range of one file. End is inclusive, so an empty file
|
||||
// has End == Begin-1 and nothing to fetch.
|
||||
type segment struct {
|
||||
File int `json:",omitempty"` // index in Res.Files
|
||||
Begin int64
|
||||
End int64
|
||||
Downloaded int64
|
||||
}
|
||||
|
||||
func (s *segment) remain() int64 {
|
||||
return s.End - s.Begin + 1 - s.Downloaded
|
||||
}
|
||||
|
||||
// lane is one worker that logged in, shown as one connection in the stats.
|
||||
type lane struct {
|
||||
downloaded int64
|
||||
retryTimes int
|
||||
failed bool
|
||||
completed bool
|
||||
}
|
||||
|
||||
// loginError marks a failure to connect or log in, as opposed to a failure
|
||||
// during a transfer.
|
||||
type loginError struct{ err error }
|
||||
|
||||
func (e *loginError) Error() string { return e.err.Error() }
|
||||
func (e *loginError) Unwrap() error { return e.err }
|
||||
|
||||
type Fetcher struct {
|
||||
manager *FetcherManager
|
||||
ctl *controller.Controller
|
||||
config *config
|
||||
meta *fetcher.FetcherMeta
|
||||
doneCh chan error
|
||||
|
||||
warnOnce sync.Once
|
||||
// loginOK is set once these credentials have logged in, after which a
|
||||
// 530 on another login means the server is full.
|
||||
loginOK atomic.Bool
|
||||
|
||||
// mu guards the download state below.
|
||||
mu sync.Mutex
|
||||
segments []*segment
|
||||
maxConns int
|
||||
noRest bool
|
||||
lanes []*lane
|
||||
done bool
|
||||
|
||||
// runMu serializes Start and Pause.
|
||||
runMu sync.Mutex
|
||||
cancel context.CancelFunc
|
||||
runDone chan struct{}
|
||||
}
|
||||
|
||||
func (f *Fetcher) Setup(ctl *controller.Controller) {
|
||||
f.ctl = ctl
|
||||
f.doneCh = make(chan error, 1)
|
||||
if f.meta == nil {
|
||||
f.meta = &fetcher.FetcherMeta{}
|
||||
}
|
||||
if ctl != nil && ctl.GetConfig != nil {
|
||||
ctl.GetConfig(&f.config)
|
||||
}
|
||||
if f.config == nil {
|
||||
f.config = &config{Connections: defaultConnections}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Fetcher) logger() *zerolog.Logger {
|
||||
if f.ctl == nil {
|
||||
return nil
|
||||
}
|
||||
return f.ctl.Logger
|
||||
}
|
||||
|
||||
func (f *Fetcher) warn(format string, args ...any) {
|
||||
if l := f.logger(); l != nil {
|
||||
l.Warn().Msgf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Fetcher) initOptions() error {
|
||||
if f.meta.Opts == nil {
|
||||
f.meta.Opts = &base.Options{}
|
||||
}
|
||||
opts := f.meta.Opts
|
||||
if err := base.ParseOptExtra[pftp.OptsExtra](opts); err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Extra == nil {
|
||||
opts.Extra = &pftp.OptsExtra{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) extra() *pftp.OptsExtra {
|
||||
if f.meta.Opts != nil {
|
||||
if e, ok := f.meta.Opts.Extra.(*pftp.OptsExtra); ok && e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return &pftp.OptsExtra{}
|
||||
}
|
||||
|
||||
// tlsMode is the request's "tls" field, empty when the request has none.
|
||||
func (f *Fetcher) tlsMode() string {
|
||||
if f.meta.Req != nil {
|
||||
if e, ok := f.meta.Req.Extra.(*pftp.ReqExtra); ok && e != nil {
|
||||
return e.TLS
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// connections is the number of parallel logins to use. The caller holds mu.
|
||||
func (f *Fetcher) connections() int {
|
||||
if f.noRest {
|
||||
return 1
|
||||
}
|
||||
n := f.extra().Connections
|
||||
if n <= 0 && f.config != nil {
|
||||
n = f.config.Connections
|
||||
}
|
||||
if n <= 0 {
|
||||
n = defaultConnections
|
||||
}
|
||||
n = min(n, maxConnections)
|
||||
if f.maxConns > 0 {
|
||||
n = min(n, f.maxConns)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (f *Fetcher) Resolve(req *base.Request) error {
|
||||
if err := base.ParseReqExtra[pftp.ReqExtra](req); err != nil {
|
||||
return err
|
||||
}
|
||||
f.meta.Req = req
|
||||
t, err := parseTarget(req.URL, f.tlsMode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s, err := f.dial(context.Background(), t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// quit, not abort: a server that caps logins must see this session end
|
||||
// before the download's own logins arrive.
|
||||
defer s.quit()
|
||||
f.loginOK.Store(true)
|
||||
res, err := f.resolveTarget(s, t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.meta.Res = res
|
||||
f.mu.Lock()
|
||||
f.segments = nil
|
||||
f.done = false
|
||||
f.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// selected returns the indexes of the files to download, in SelectFiles
|
||||
// order. The caller holds mu or owns the fetcher.
|
||||
func (f *Fetcher) selected() []int {
|
||||
if f.meta == nil || f.meta.Res == nil {
|
||||
return nil
|
||||
}
|
||||
if f.meta.Opts != nil && len(f.meta.Opts.SelectFiles) > 0 {
|
||||
return f.meta.Opts.SelectFiles
|
||||
}
|
||||
all := make([]int, len(f.meta.Res.Files))
|
||||
for i := range all {
|
||||
all[i] = i
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// localPath is where a file lands: under the task path, with the file's
|
||||
// directories below the URL kept.
|
||||
func (f *Fetcher) localPath(index int) string {
|
||||
if f.meta.Res.Name == "" {
|
||||
return f.meta.SingleFilepath()
|
||||
}
|
||||
file := f.meta.Res.Files[index]
|
||||
return path.Join(f.meta.FolderPath(), file.Path, file.Name)
|
||||
}
|
||||
|
||||
// remotePath is the server path of a file. A single file uses the URL's own
|
||||
// path, which a local rename never touches.
|
||||
func (f *Fetcher) remotePath(t *target, index int) string {
|
||||
if f.meta.Res.Name == "" {
|
||||
return t.path
|
||||
}
|
||||
file := f.meta.Res.Files[index]
|
||||
return path.Join(t.path, file.Path, file.Name)
|
||||
}
|
||||
|
||||
// plan splits every selected file into segments of at least minSegmentSize,
|
||||
// one per connection at most. The caller holds mu.
|
||||
func (f *Fetcher) plan() {
|
||||
conns := f.connections()
|
||||
f.segments = nil
|
||||
for _, index := range f.selected() {
|
||||
size := f.meta.Res.Files[index].Size
|
||||
n := int64(1)
|
||||
if !f.noRest {
|
||||
n = max(1, min(int64(conns), size/minSegmentSize))
|
||||
}
|
||||
for i := int64(0); i < n; i++ {
|
||||
f.segments = append(f.segments, &segment{
|
||||
File: index,
|
||||
Begin: i * size / n,
|
||||
End: (i+1)*size/n - 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// planSequential gives every unfinished file one segment from byte 0,
|
||||
// because without REST a transfer cannot start in the middle. The caller
|
||||
// holds mu.
|
||||
func (f *Fetcher) planSequential() {
|
||||
complete := map[int]bool{}
|
||||
for _, s := range f.segments {
|
||||
if _, seen := complete[s.File]; !seen {
|
||||
complete[s.File] = true
|
||||
}
|
||||
if s.remain() > 0 {
|
||||
complete[s.File] = false
|
||||
}
|
||||
}
|
||||
var out []*segment
|
||||
for _, index := range f.selected() {
|
||||
size := f.meta.Res.Files[index].Size
|
||||
if complete[index] {
|
||||
out = append(out, &segment{File: index, Begin: 0, End: size - 1, Downloaded: size})
|
||||
continue
|
||||
}
|
||||
out = append(out, &segment{File: index, Begin: 0, End: size - 1})
|
||||
}
|
||||
f.segments = out
|
||||
// Bytes counted by the old segments are fetched again.
|
||||
f.lanes = nil
|
||||
}
|
||||
|
||||
// openFiles opens every selected file for writing. A fresh plan creates and
|
||||
// sizes them; a resumed one keeps their content, and a file that has gone
|
||||
// missing starts over.
|
||||
func (f *Fetcher) openFiles(fresh bool) (map[int]*os.File, error) {
|
||||
files := map[int]*os.File{}
|
||||
closeAll := func() {
|
||||
for _, file := range files {
|
||||
_ = file.Close()
|
||||
}
|
||||
}
|
||||
for _, index := range f.selected() {
|
||||
name := f.localPath(index)
|
||||
var (
|
||||
file *os.File
|
||||
err error
|
||||
)
|
||||
_, statErr := os.Stat(name)
|
||||
switch {
|
||||
case fresh || os.IsNotExist(statErr):
|
||||
file, err = f.ctl.Touch(name, f.meta.Res.Files[index].Size)
|
||||
if err == nil && !fresh {
|
||||
f.mu.Lock()
|
||||
for _, s := range f.segments {
|
||||
if s.File == index {
|
||||
s.Downloaded = 0
|
||||
}
|
||||
}
|
||||
f.mu.Unlock()
|
||||
}
|
||||
case statErr != nil:
|
||||
err = statErr
|
||||
default:
|
||||
file, err = os.OpenFile(name, os.O_RDWR, 0)
|
||||
}
|
||||
if err != nil {
|
||||
closeAll()
|
||||
return nil, err
|
||||
}
|
||||
files[index] = file
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) Start() error {
|
||||
f.runMu.Lock()
|
||||
defer f.runMu.Unlock()
|
||||
if f.runDone != nil {
|
||||
select {
|
||||
case <-f.runDone:
|
||||
default:
|
||||
return nil // already running
|
||||
}
|
||||
}
|
||||
if err := f.initOptions(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := base.ParseReqExtra[pftp.ReqExtra](f.meta.Req); err != nil {
|
||||
return err
|
||||
}
|
||||
t, err := parseTarget(f.meta.Req.URL, f.tlsMode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// A paused or failed run may have left a result nobody read.
|
||||
select {
|
||||
case <-f.doneCh:
|
||||
default:
|
||||
}
|
||||
|
||||
f.mu.Lock()
|
||||
if f.done {
|
||||
f.mu.Unlock()
|
||||
f.doneCh <- nil
|
||||
return nil
|
||||
}
|
||||
fresh := len(f.segments) == 0
|
||||
if fresh {
|
||||
f.plan()
|
||||
} else if f.noRest {
|
||||
f.planSequential()
|
||||
}
|
||||
f.mu.Unlock()
|
||||
|
||||
files, err := f.openFiles(fresh)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
f.cancel = cancel
|
||||
f.runDone = make(chan struct{})
|
||||
go f.run(ctx, t, files, f.runDone)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) run(ctx context.Context, t *target, files map[int]*os.File, done chan struct{}) {
|
||||
defer close(done)
|
||||
err := f.download(ctx, t, files)
|
||||
for _, file := range files {
|
||||
if cerr := file.Close(); cerr != nil && err == nil {
|
||||
err = cerr
|
||||
}
|
||||
}
|
||||
f.mu.Lock()
|
||||
if err == nil {
|
||||
f.done = true
|
||||
for _, l := range f.lanes {
|
||||
l.completed = true
|
||||
}
|
||||
}
|
||||
f.mu.Unlock()
|
||||
if err != nil && ctx.Err() != nil {
|
||||
return // paused
|
||||
}
|
||||
select {
|
||||
case f.doneCh <- err:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Fetcher) download(ctx context.Context, t *target, files map[int]*os.File) error {
|
||||
for {
|
||||
err := f.runWorkers(ctx, t, files)
|
||||
if !errors.Is(err, errRestUnsupported) {
|
||||
return err
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.noRest = true
|
||||
f.planSequential()
|
||||
f.mu.Unlock()
|
||||
f.warn("ftp: the server refused REST, so each file is downloaded in one stream")
|
||||
}
|
||||
}
|
||||
|
||||
// pool hands out the segments of one run to its workers. Every decision to
|
||||
// leave the run is taken under mu together with the queue it depends on, so
|
||||
// a segment put back can never be left without a worker.
|
||||
type pool struct {
|
||||
mu sync.Mutex
|
||||
queue []*segment
|
||||
running int // workers still in the run
|
||||
}
|
||||
|
||||
// take returns the next segment. A worker that finds the queue empty leaves
|
||||
// the run.
|
||||
func (p *pool) take() *segment {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if len(p.queue) == 0 {
|
||||
p.running--
|
||||
return nil
|
||||
}
|
||||
s := p.queue[0]
|
||||
p.queue = p.queue[1:]
|
||||
return s
|
||||
}
|
||||
|
||||
func (p *pool) putBack(s *segment) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.queue = append([]*segment{s}, p.queue...)
|
||||
}
|
||||
|
||||
// refused puts back the segment of a worker whose login the server turned
|
||||
// away. The worker leaves when another worker is still in the run to take
|
||||
// the segment, and left is the number of workers that remain; the last
|
||||
// worker stays and retries.
|
||||
func (p *pool) refused(s *segment) (leave bool, left int) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.queue = append([]*segment{s}, p.queue...)
|
||||
if p.running > 1 {
|
||||
p.running--
|
||||
return true, p.running
|
||||
}
|
||||
return false, p.running
|
||||
}
|
||||
|
||||
// leave takes a worker out of the run when it stops for any other reason.
|
||||
func (p *pool) leave() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.running--
|
||||
}
|
||||
|
||||
func (f *Fetcher) runWorkers(ctx context.Context, t *target, files map[int]*os.File) error {
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
f.mu.Lock()
|
||||
p := &pool{}
|
||||
for _, s := range f.segments {
|
||||
if s.remain() > 0 {
|
||||
p.queue = append(p.queue, s)
|
||||
}
|
||||
}
|
||||
workers := min(f.connections(), len(p.queue))
|
||||
f.mu.Unlock()
|
||||
if workers == 0 {
|
||||
return nil
|
||||
}
|
||||
p.running = workers
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
errMu sync.Mutex
|
||||
firstErr error
|
||||
)
|
||||
for id := 0; id < workers; id++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
if err := f.worker(runCtx, t, files, p, id); err != nil {
|
||||
errMu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
errMu.Unlock()
|
||||
cancel()
|
||||
}
|
||||
}(id)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if firstErr != nil {
|
||||
return firstErr
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, s := range f.segments {
|
||||
if s.remain() > 0 {
|
||||
return errors.New("ftp: the download stopped with data missing")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// worker fetches segments from the pool until it is empty. A worker whose
|
||||
// login the server refuses (421, or 530 once these credentials have worked)
|
||||
// hands its segment back and leaves while other workers remain, lowering the
|
||||
// task's ceiling to the workers left. This holds whether or not the worker
|
||||
// had logged in before: a slot can also go to another client mid-download.
|
||||
func (f *Fetcher) worker(ctx context.Context, t *target, files map[int]*os.File, p *pool, id int) error {
|
||||
var ln *lane
|
||||
f.mu.Lock()
|
||||
if id < len(f.lanes) {
|
||||
ln = f.lanes[id]
|
||||
}
|
||||
f.mu.Unlock()
|
||||
onLogin := func() {
|
||||
f.mu.Lock()
|
||||
if ln == nil {
|
||||
ln = &lane{}
|
||||
f.lanes = append(f.lanes, ln)
|
||||
}
|
||||
f.mu.Unlock()
|
||||
}
|
||||
|
||||
failures := 0
|
||||
pendingRetries := 0
|
||||
for {
|
||||
seg := p.take()
|
||||
if seg == nil {
|
||||
return nil
|
||||
}
|
||||
err := f.fetchSegment(ctx, t, files, seg, &ln, onLogin)
|
||||
if err == nil {
|
||||
failures = 0
|
||||
continue
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
p.putBack(seg)
|
||||
p.leave()
|
||||
return nil
|
||||
}
|
||||
|
||||
var le *loginError
|
||||
if errors.As(err, &le) && isLoginRefusal(le.err, f.loginOK.Load()) {
|
||||
if leave, left := p.refused(seg); leave {
|
||||
f.lowerCeiling(left)
|
||||
return nil
|
||||
}
|
||||
// The last worker: wait for a slot.
|
||||
} else {
|
||||
p.putBack(seg)
|
||||
if errors.Is(err, errRestUnsupported) {
|
||||
p.leave()
|
||||
return err
|
||||
}
|
||||
if isPermanent(err) || errors.Is(err, errDiskWrite) {
|
||||
f.markFailed(ln)
|
||||
p.leave()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
failures++
|
||||
f.mu.Lock()
|
||||
if ln != nil {
|
||||
ln.retryTimes += pendingRetries + 1
|
||||
pendingRetries = 0
|
||||
} else {
|
||||
pendingRetries++
|
||||
}
|
||||
f.mu.Unlock()
|
||||
if failures > maxRetries {
|
||||
f.markFailed(ln)
|
||||
p.leave()
|
||||
return fmt.Errorf("ftp: giving up after %d retries: %w", maxRetries, err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
p.leave()
|
||||
return nil
|
||||
case <-time.After(retryDelay * time.Duration(failures)):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Fetcher) lowerCeiling(n int) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.maxConns == 0 || n < f.maxConns {
|
||||
f.maxConns = n
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Fetcher) markFailed(ln *lane) {
|
||||
if ln == nil {
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
ln.failed = true
|
||||
f.mu.Unlock()
|
||||
}
|
||||
|
||||
// isRestRefused tells a REST the server does not implement. RETR itself
|
||||
// fails with 550 and the like, never with these codes.
|
||||
func isRestRefused(err error) bool {
|
||||
switch replyCode(err) {
|
||||
case ftp.StatusBadCommand, ftp.StatusBadArguments, ftp.StatusNotImplemented, ftp.StatusNotImplementedParameter:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// fetchSegment logs in, retrieves the segment from its first missing byte
|
||||
// and writes it in place. It stops reading at the segment's end and closes
|
||||
// the data connection; the server then reports the transfer aborted, which
|
||||
// is expected.
|
||||
func (f *Fetcher) fetchSegment(ctx context.Context, t *target, files map[int]*os.File, seg *segment, ln **lane, onLogin func()) error {
|
||||
s, err := f.dial(ctx, t)
|
||||
if err != nil {
|
||||
return &loginError{err}
|
||||
}
|
||||
defer s.quit()
|
||||
f.loginOK.Store(true)
|
||||
onLogin()
|
||||
|
||||
remote := f.remotePath(t, seg.File)
|
||||
f.mu.Lock()
|
||||
noRest := f.noRest
|
||||
wantSize := f.meta.Res.Files[seg.File].Size
|
||||
if noRest {
|
||||
seg.Downloaded = 0
|
||||
}
|
||||
pos := seg.Begin + seg.Downloaded
|
||||
end := seg.End
|
||||
f.mu.Unlock()
|
||||
|
||||
// A file that changed on the server would mix old and new bytes.
|
||||
s.deadline()
|
||||
if size, err := s.FileSize(remote); err == nil && size != wantSize {
|
||||
return errSizeChanged
|
||||
} else if err != nil && replyCode(err) == 0 {
|
||||
return err
|
||||
}
|
||||
|
||||
s.deadline()
|
||||
var resp *ftp.Response
|
||||
if noRest {
|
||||
resp, err = s.Retr(remote)
|
||||
} else {
|
||||
resp, err = s.RetrFrom(remote, uint64(pos))
|
||||
}
|
||||
if err != nil {
|
||||
if !noRest && pos > 0 && isRestRefused(err) {
|
||||
return errRestUnsupported
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer resp.Close()
|
||||
s.clearDeadline()
|
||||
|
||||
file := files[seg.File]
|
||||
buf := make([]byte, copyBufferSize)
|
||||
for pos <= end {
|
||||
n := int(min(int64(len(buf)), end-pos+1))
|
||||
_ = resp.SetDeadline(time.Now().Add(readTimeout))
|
||||
nr, rerr := resp.Read(buf[:n])
|
||||
if nr > 0 {
|
||||
if _, werr := file.WriteAt(buf[:nr], pos); werr != nil {
|
||||
return fmt.Errorf("%w: %w", errDiskWrite, werr)
|
||||
}
|
||||
pos += int64(nr)
|
||||
f.mu.Lock()
|
||||
seg.Downloaded += int64(nr)
|
||||
if *ln != nil {
|
||||
(*ln).downloaded += int64(nr)
|
||||
}
|
||||
f.mu.Unlock()
|
||||
}
|
||||
if rerr != nil {
|
||||
if pos > end {
|
||||
break
|
||||
}
|
||||
if errors.Is(rerr, io.EOF) {
|
||||
return errShortTransfer
|
||||
}
|
||||
return rerr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) Patch(req *base.Request, opts *base.Options) error {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
if req.URL != "" {
|
||||
f.meta.Req.URL = req.URL
|
||||
}
|
||||
if req.Labels != nil {
|
||||
if f.meta.Req.Labels == nil {
|
||||
f.meta.Req.Labels = make(map[string]string)
|
||||
}
|
||||
for k, v := range req.Labels {
|
||||
f.meta.Req.Labels[k] = v
|
||||
}
|
||||
}
|
||||
if req.Proxy != nil {
|
||||
f.meta.Req.Proxy = req.Proxy
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) Pause() error {
|
||||
f.runMu.Lock()
|
||||
defer f.runMu.Unlock()
|
||||
if f.cancel != nil {
|
||||
f.cancel()
|
||||
}
|
||||
if f.runDone != nil {
|
||||
<-f.runDone
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) Close() error {
|
||||
return f.Pause()
|
||||
}
|
||||
|
||||
func (f *Fetcher) Meta() *fetcher.FetcherMeta {
|
||||
return f.meta
|
||||
}
|
||||
|
||||
// Stats reports one connection per worker that logged in, in the HTTP
|
||||
// protocol's shape.
|
||||
func (f *Fetcher) Stats() *fetcher.Stats {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var total int64
|
||||
if n := int64(len(f.lanes)); n > 0 && f.meta != nil && f.meta.Res != nil && f.meta.Res.Size > 0 {
|
||||
total = (f.meta.Res.Size + n - 1) / n
|
||||
}
|
||||
conns := make([]*pftp.StatsConnection, 0, len(f.lanes))
|
||||
for _, l := range f.lanes {
|
||||
conns = append(conns, &pftp.StatsConnection{
|
||||
Downloaded: l.downloaded,
|
||||
Total: total,
|
||||
Completed: l.completed || (f.done && !l.failed),
|
||||
Failed: l.failed,
|
||||
RetryTimes: l.retryTimes,
|
||||
})
|
||||
}
|
||||
return &fetcher.Stats{Snapshot: &pftp.Stats{Connections: conns}}
|
||||
}
|
||||
|
||||
// Progress returns the bytes downloaded of each selected file, in
|
||||
// SelectFiles order.
|
||||
func (f *Fetcher) Progress() fetcher.Progress {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
perFile := map[int]int64{}
|
||||
for _, s := range f.segments {
|
||||
perFile[s.File] += s.Downloaded
|
||||
}
|
||||
sel := f.selected()
|
||||
p := make(fetcher.Progress, len(sel))
|
||||
for i, index := range sel {
|
||||
p[i] = perFile[index]
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (f *Fetcher) Wait() error {
|
||||
return <-f.doneCh
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,818 @@
|
||||
package ftp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/armon/go-socks5"
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
pftp "github.com/GopeedLab/gopeed/pkg/protocol/ftp"
|
||||
)
|
||||
|
||||
const mib = 1 << 20
|
||||
|
||||
// testManager trusts the test server's certificate, the way an embedding
|
||||
// application could add private roots.
|
||||
func testManager(srv *FTPServer) *FetcherManager {
|
||||
fm := &FetcherManager{}
|
||||
if srv != nil {
|
||||
fm.baseTLS = &tls.Config{RootCAs: srv.CA}
|
||||
}
|
||||
return fm
|
||||
}
|
||||
|
||||
func testController(fm *FetcherManager) *controller.Controller {
|
||||
ctl := controller.NewController()
|
||||
ctl.GetConfig = func(v any) {
|
||||
data, _ := json.Marshal(fm.DefaultConfig())
|
||||
_ = json.Unmarshal(data, v)
|
||||
}
|
||||
return ctl
|
||||
}
|
||||
|
||||
func buildFetcher(fm *FetcherManager) *Fetcher {
|
||||
f := fm.Build().(*Fetcher)
|
||||
f.Setup(testController(fm))
|
||||
return f
|
||||
}
|
||||
|
||||
func ftpURL(srv *FTPServer, scheme, userinfo, p string) string {
|
||||
if userinfo != "" {
|
||||
userinfo += "@"
|
||||
}
|
||||
return scheme + "://" + userinfo + srv.Addr + "/" + strings.TrimPrefix(p, "/")
|
||||
}
|
||||
|
||||
// resolveOpts resolves req and then gives the fetcher its options, in the
|
||||
// order the downloader uses.
|
||||
func resolveOpts(f *Fetcher, req *base.Request, opts *base.Options) error {
|
||||
if err := f.Resolve(req); err != nil {
|
||||
return err
|
||||
}
|
||||
f.Meta().Opts = opts
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveTask(t *testing.T, f *Fetcher, rawURL string, extra *pftp.OptsExtra) {
|
||||
t.Helper()
|
||||
resolveRequest(t, f, &base.Request{URL: rawURL}, extra)
|
||||
}
|
||||
|
||||
func resolveRequest(t *testing.T, f *Fetcher, req *base.Request, extra *pftp.OptsExtra) {
|
||||
t.Helper()
|
||||
opts := &base.Options{Path: t.TempDir()}
|
||||
if extra != nil {
|
||||
opts.Extra = extra
|
||||
}
|
||||
if err := resolveOpts(f, req, opts); err != nil {
|
||||
t.Fatalf("resolve %s: %v", req.URL, err)
|
||||
}
|
||||
}
|
||||
|
||||
func waitFetcher(t *testing.T, f fetcher.Fetcher) error {
|
||||
t.Helper()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- f.Wait() }()
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-time.After(60 * time.Second):
|
||||
t.Fatal("download did not finish in 60s")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func startAndWait(t *testing.T, f *Fetcher) {
|
||||
t.Helper()
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
if err := waitFetcher(t, f); err != nil {
|
||||
t.Fatalf("download: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func checkFile(t *testing.T, name, want string) {
|
||||
t.Helper()
|
||||
if got := FileSHA256(t, name); got != want {
|
||||
t.Fatalf("%s: sha256 %s, want %s", name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func ftpStats(t *testing.T, f *Fetcher) *pftp.Stats {
|
||||
t.Helper()
|
||||
stats, ok := f.Stats().Snapshot.(*pftp.Stats)
|
||||
if !ok {
|
||||
t.Fatalf("stats snapshot is %T, want *ftp.Stats", f.Stats().Snapshot)
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
func storeData(t *testing.T, fm *FetcherManager, f *Fetcher) []byte {
|
||||
t.Helper()
|
||||
data, err := fm.Store(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blob, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return blob
|
||||
}
|
||||
|
||||
// restoreFetcher rebuilds a fetcher the way the downloader does after a
|
||||
// restart: the meta and the save blob both come back from JSON.
|
||||
func restoreFetcher(t *testing.T, fm *FetcherManager, meta *fetcher.FetcherMeta, blob []byte) *Fetcher {
|
||||
t.Helper()
|
||||
metaJSON, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var restoredMeta fetcher.FetcherMeta
|
||||
if err := json.Unmarshal(metaJSON, &restoredMeta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, build := fm.Restore()
|
||||
if err := json.Unmarshal(blob, v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := build(&restoredMeta, v).(*Fetcher)
|
||||
f.Setup(testController(fm))
|
||||
return f
|
||||
}
|
||||
|
||||
func TestResolveFile(t *testing.T) {
|
||||
for _, noMLST := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("noMLST=%v", noMLST), func(t *testing.T) {
|
||||
srv := StartFTPServer(t, ServerOptions{NoMLST: noMLST})
|
||||
data := RandomBytes(100*1024, 1)
|
||||
srv.WriteFile(t, "pub/file name.bin", data)
|
||||
|
||||
f := buildFetcher(testManager(srv))
|
||||
resolveTask(t, f, ftpURL(srv, "ftp", "", "pub/file%20name.bin"), nil)
|
||||
res := f.Meta().Res
|
||||
if res.Name != "" {
|
||||
t.Fatalf("a file must not be a folder resource, got name %q", res.Name)
|
||||
}
|
||||
if len(res.Files) != 1 {
|
||||
t.Fatalf("got %d files, want 1", len(res.Files))
|
||||
}
|
||||
file := res.Files[0]
|
||||
if file.Name != "file name.bin" || file.Path != "" || file.Size != int64(len(data)) {
|
||||
t.Fatalf("got file %+v, want name %q, empty path, size %d", file, "file name.bin", len(data))
|
||||
}
|
||||
if res.Size != int64(len(data)) {
|
||||
t.Fatalf("resource size %d, want %d", res.Size, len(data))
|
||||
}
|
||||
if file.Ctime == nil || time.Since(*file.Ctime) > time.Hour {
|
||||
t.Fatalf("modification time %v is missing or wrong", file.Ctime)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func writeTree(t *testing.T, srv *FTPServer) map[string]string {
|
||||
t.Helper()
|
||||
return map[string]string{
|
||||
"a.txt": srv.WriteFile(t, "pub/tree/a.txt", []byte("hello")),
|
||||
"sub/b.bin": srv.WriteFile(t, "pub/tree/sub/b.bin", RandomBytes(3*mib+17, 2)),
|
||||
"sub/deeper/c.dat": srv.WriteFile(t, "pub/tree/sub/deeper/c.dat", RandomBytes(2000, 3)),
|
||||
"sub/empty.txt": srv.WriteFile(t, "pub/tree/sub/empty.txt", nil),
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDir(t *testing.T) {
|
||||
for _, noMLST := range []bool{false, true} {
|
||||
for _, p := range []string{"pub/tree", "pub/tree/"} {
|
||||
t.Run(fmt.Sprintf("noMLST=%v/%s", noMLST, p), func(t *testing.T) {
|
||||
srv := StartFTPServer(t, ServerOptions{NoMLST: noMLST})
|
||||
writeTree(t, srv)
|
||||
if err := os.MkdirAll(filepath.Join(srv.Root, "pub/tree/void"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f := buildFetcher(testManager(srv))
|
||||
resolveTask(t, f, ftpURL(srv, "ftp", "", p), nil)
|
||||
res := f.Meta().Res
|
||||
if res.Name != "tree" {
|
||||
t.Fatalf("folder name %q, want tree", res.Name)
|
||||
}
|
||||
var got []string
|
||||
for _, file := range res.Files {
|
||||
got = append(got, fmt.Sprintf("%s|%s|%d", file.Path, file.Name, file.Size))
|
||||
}
|
||||
want := []string{
|
||||
"|a.txt|5",
|
||||
"sub|b.bin|3145745",
|
||||
"sub|empty.txt|0",
|
||||
"sub/deeper|c.dat|2000",
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("files\n got %q\nwant %q", got, want)
|
||||
}
|
||||
if res.Size != 5+3145745+2000 {
|
||||
t.Fatalf("resource size %d", res.Size)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDirLimits(t *testing.T) {
|
||||
srv := StartFTPServer(t, ServerOptions{})
|
||||
srv.WriteFile(t, "deep/1/2/3/4/5/6/7/8/f.txt", []byte("ok"))
|
||||
srv.WriteFile(t, "deep9/1/2/3/4/5/6/7/8/9/f.txt", []byte("too deep"))
|
||||
for i := 0; i < 4; i++ {
|
||||
srv.WriteFile(t, fmt.Sprintf("many/f%d", i), []byte("x"))
|
||||
}
|
||||
|
||||
f := buildFetcher(testManager(srv))
|
||||
resolveTask(t, f, ftpURL(srv, "ftp", "", "deep"), nil)
|
||||
if got := f.Meta().Res.Files[0].Path; got != "1/2/3/4/5/6/7/8" {
|
||||
t.Fatalf("depth 8 path %q", got)
|
||||
}
|
||||
|
||||
f = buildFetcher(testManager(srv))
|
||||
err := resolveOpts(f, &base.Request{URL: ftpURL(srv, "ftp", "", "deep9")}, &base.Options{Path: t.TempDir()})
|
||||
if err == nil || !strings.Contains(err.Error(), "8 levels") {
|
||||
t.Fatalf("a tree deeper than 8 levels must fail and name the limit, got %v", err)
|
||||
}
|
||||
|
||||
saved := maxWalkFiles
|
||||
maxWalkFiles = 3
|
||||
defer func() { maxWalkFiles = saved }()
|
||||
f = buildFetcher(testManager(srv))
|
||||
err = resolveOpts(f, &base.Request{URL: ftpURL(srv, "ftp", "", "many")}, &base.Options{Path: t.TempDir()})
|
||||
if err == nil || !strings.Contains(err.Error(), "3 files") {
|
||||
t.Fatalf("a tree over the file cap must fail and name the limit, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadPlain(t *testing.T) {
|
||||
srv := StartFTPServer(t, ServerOptions{})
|
||||
data := RandomBytes(3*mib+12345, 4)
|
||||
want := srv.WriteFile(t, "pub/plain.bin", data)
|
||||
|
||||
f := buildFetcher(testManager(srv))
|
||||
resolveTask(t, f, ftpURL(srv, "ftp", "", "pub/plain.bin"), &pftp.OptsExtra{Connections: 4})
|
||||
startAndWait(t, f)
|
||||
checkFile(t, f.Meta().SingleFilepath(), want)
|
||||
if got := f.Progress().TotalDownloaded(); got != int64(len(data)) {
|
||||
t.Fatalf("progress %d, want %d", got, len(data))
|
||||
}
|
||||
stats := ftpStats(t, f)
|
||||
if len(stats.Connections) == 0 || len(stats.Connections) > 4 {
|
||||
t.Fatalf("got %d connections in stats", len(stats.Connections))
|
||||
}
|
||||
var sum int64
|
||||
for _, c := range stats.Connections {
|
||||
sum += c.Downloaded
|
||||
if !c.Completed || c.Failed {
|
||||
t.Fatalf("connection stats %+v after success", c)
|
||||
}
|
||||
}
|
||||
if sum != int64(len(data)) {
|
||||
t.Fatalf("connections downloaded %d bytes, want %d", sum, len(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadDir(t *testing.T) {
|
||||
srv := StartFTPServer(t, ServerOptions{})
|
||||
want := writeTree(t, srv)
|
||||
|
||||
f := buildFetcher(testManager(srv))
|
||||
resolveTask(t, f, ftpURL(srv, "ftp", "", "pub/tree"), &pftp.OptsExtra{Connections: 2})
|
||||
startAndWait(t, f)
|
||||
root := f.Meta().FolderPath()
|
||||
if root != filepath.Join(f.Meta().Opts.Path, "tree") {
|
||||
t.Fatalf("folder path %q", root)
|
||||
}
|
||||
for rel, sum := range want {
|
||||
checkFile(t, filepath.Join(root, filepath.FromSlash(rel)), sum)
|
||||
}
|
||||
}
|
||||
|
||||
func testDownloadTLS(t *testing.T, mode string, schemes map[string]string) {
|
||||
srv := StartFTPServer(t, ServerOptions{TLS: mode})
|
||||
data := RandomBytes(2*mib+999, 5)
|
||||
want := srv.WriteFile(t, "secure.bin", data)
|
||||
for scheme, reqTLS := range schemes {
|
||||
t.Run(scheme+"+"+reqTLS, func(t *testing.T) {
|
||||
f := buildFetcher(testManager(srv))
|
||||
req := &base.Request{URL: ftpURL(srv, scheme, "", "secure.bin"), Extra: &pftp.ReqExtra{TLS: reqTLS}}
|
||||
resolveRequest(t, f, req, &pftp.OptsExtra{Connections: 2})
|
||||
startAndWait(t, f)
|
||||
checkFile(t, f.Meta().SingleFilepath(), want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadExplicitTLS(t *testing.T) {
|
||||
testDownloadTLS(t, pftp.TLSExplicit, map[string]string{
|
||||
"ftpes": "",
|
||||
"ftp": pftp.TLSExplicit,
|
||||
})
|
||||
}
|
||||
|
||||
func TestDownloadImplicitTLS(t *testing.T) {
|
||||
testDownloadTLS(t, pftp.TLSImplicit, map[string]string{
|
||||
"ftps": "",
|
||||
"ftp": pftp.TLSImplicit,
|
||||
})
|
||||
}
|
||||
|
||||
// The TLS mode of a plain ftp:// link lives on the request, so a task that is
|
||||
// saved and restored from JSON still logs in over TLS.
|
||||
func TestRestoredRequestKeepsTLSMode(t *testing.T) {
|
||||
srv := StartFTPServer(t, ServerOptions{TLS: pftp.TLSExplicit})
|
||||
want := srv.WriteFile(t, "keep.bin", RandomBytes(2*mib+5, 12))
|
||||
fm := testManager(srv)
|
||||
f := buildFetcher(fm)
|
||||
req := &base.Request{URL: ftpURL(srv, "ftp", "", "keep.bin"), Extra: &pftp.ReqExtra{TLS: pftp.TLSExplicit}}
|
||||
resolveRequest(t, f, req, &pftp.OptsExtra{Connections: 2})
|
||||
|
||||
restored := restoreFetcher(t, fm, f.Meta(), storeData(t, fm, f))
|
||||
if _, ok := restored.Meta().Req.Extra.(*pftp.ReqExtra); !ok {
|
||||
t.Fatalf("restored request extra is %T, want *ftp.ReqExtra", restored.Meta().Req.Extra)
|
||||
}
|
||||
// Explicit mode refuses plain logins, so the download only finishes over TLS.
|
||||
startAndWait(t, restored)
|
||||
checkFile(t, restored.Meta().SingleFilepath(), want)
|
||||
}
|
||||
|
||||
func TestBadCertRejectedByDefault(t *testing.T) {
|
||||
for mode, scheme := range map[string]string{pftp.TLSExplicit: "ftpes", pftp.TLSImplicit: "ftps"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
srv := StartFTPServer(t, ServerOptions{TLS: mode})
|
||||
want := srv.WriteFile(t, "f.bin", RandomBytes(4096, 6))
|
||||
rawURL := ftpURL(srv, scheme, "", "f.bin")
|
||||
|
||||
// No injected roots: the self-signed certificate is unknown.
|
||||
f := buildFetcher(testManager(nil))
|
||||
err := resolveOpts(f, &base.Request{URL: rawURL}, &base.Options{Path: t.TempDir()})
|
||||
var verifyErr *tls.CertificateVerificationError
|
||||
if !errors.As(err, &verifyErr) {
|
||||
t.Fatalf("want a certificate verification error, got %v", err)
|
||||
}
|
||||
|
||||
// The request's existing skipVerifyCert switch accepts it.
|
||||
f = buildFetcher(testManager(nil))
|
||||
if err := resolveOpts(f, &base.Request{URL: rawURL, SkipVerifyCert: true}, &base.Options{Path: t.TempDir()}); err != nil {
|
||||
t.Fatalf("skipVerifyCert resolve: %v", err)
|
||||
}
|
||||
startAndWait(t, f)
|
||||
checkFile(t, f.Meta().SingleFilepath(), want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSegmentsUseRest(t *testing.T) {
|
||||
srv := StartFTPServer(t, ServerOptions{})
|
||||
srv.ReadDelay.Store(int64(time.Millisecond))
|
||||
size := 8 * mib
|
||||
want := srv.WriteFile(t, "big.bin", RandomBytes(size, 7))
|
||||
|
||||
f := buildFetcher(testManager(srv))
|
||||
resolveTask(t, f, ftpURL(srv, "ftp", "", "big.bin"), &pftp.OptsExtra{Connections: 4})
|
||||
startAndWait(t, f)
|
||||
checkFile(t, f.Meta().SingleFilepath(), want)
|
||||
|
||||
rests := srv.RestOffsets()
|
||||
for _, off := range []int64{2 * mib, 4 * mib, 6 * mib} {
|
||||
if !slices.Contains(rests, off) {
|
||||
t.Fatalf("REST offsets %v miss the segment start %d", rests, off)
|
||||
}
|
||||
}
|
||||
for _, off := range rests {
|
||||
if off <= 0 || off >= int64(size) {
|
||||
t.Fatalf("REST offset %d out of range", off)
|
||||
}
|
||||
}
|
||||
if got := len(ftpStats(t, f).Connections); got != 4 {
|
||||
t.Fatalf("stats report %d connections, want 4", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginCapFallsBack(t *testing.T) {
|
||||
srv := StartFTPServer(t, ServerOptions{MaxLogins: 1})
|
||||
want := srv.WriteFile(t, "capped.bin", RandomBytes(4*mib+5, 8))
|
||||
|
||||
fm := testManager(srv)
|
||||
f := buildFetcher(fm)
|
||||
resolveTask(t, f, ftpURL(srv, "ftp", "", "capped.bin"), &pftp.OptsExtra{Connections: 4})
|
||||
startAndWait(t, f)
|
||||
checkFile(t, f.Meta().SingleFilepath(), want)
|
||||
|
||||
if srv.Refused() == 0 {
|
||||
t.Fatal("the server never refused a login, so the cap was not exercised")
|
||||
}
|
||||
if got := len(ftpStats(t, f).Connections); got != 1 {
|
||||
t.Fatalf("stats report %d connections, want 1", got)
|
||||
}
|
||||
var saved fetcherData
|
||||
if err := json.Unmarshal(storeData(t, fm, f), &saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.MaxConns != 1 {
|
||||
t.Fatalf("saved login ceiling %d, want 1", saved.MaxConns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoRestSequential(t *testing.T) {
|
||||
srv := StartFTPServer(t, ServerOptions{NoRest: true})
|
||||
srv.ReadDelay.Store(int64(time.Millisecond))
|
||||
want := srv.WriteFile(t, "norest.bin", RandomBytes(4*mib+3, 9))
|
||||
|
||||
fm := testManager(srv)
|
||||
f := buildFetcher(fm)
|
||||
resolveTask(t, f, ftpURL(srv, "ftp", "", "norest.bin"), &pftp.OptsExtra{Connections: 4})
|
||||
startAndWait(t, f)
|
||||
checkFile(t, f.Meta().SingleFilepath(), want)
|
||||
|
||||
if rests := srv.RestOffsets(); len(rests) != 0 {
|
||||
t.Fatalf("the server cannot have seen a REST, got %v", rests)
|
||||
}
|
||||
var saved fetcherData
|
||||
if err := json.Unmarshal(storeData(t, fm, f), &saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !saved.NoRest {
|
||||
t.Fatal("the missing REST support was not recorded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResumeAfterKill stops the server's transfers at a fixed byte count,
|
||||
// waits until the client has written exactly those bytes, then pauses, saves,
|
||||
// restores and finishes. No timing decides where the pause lands.
|
||||
func TestResumeAfterKill(t *testing.T) {
|
||||
srv := StartFTPServer(t, ServerOptions{})
|
||||
size := int64(4*mib + 777)
|
||||
want := srv.WriteFile(t, "resume.bin", RandomBytes(int(size), 10))
|
||||
stallAt := size / 2
|
||||
srv.StallAfter(stallAt)
|
||||
|
||||
fm := testManager(srv)
|
||||
f := buildFetcher(fm)
|
||||
resolveTask(t, f, ftpURL(srv, "ftp", "", "resume.bin"), &pftp.OptsExtra{Connections: 2})
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-srv.Stalled():
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("the server never reached the stall point")
|
||||
}
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for f.Progress().TotalDownloaded() != stallAt {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("progress %d, want the %d bytes the server sent", f.Progress().TotalDownloaded(), stallAt)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if err := f.Pause(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv.Release()
|
||||
blob := storeData(t, fm, f)
|
||||
|
||||
var saved fetcherData
|
||||
if err := json.Unmarshal(blob, &saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var savedTotal int64
|
||||
resumeAt := map[int64]bool{}
|
||||
for _, seg := range saved.Segments {
|
||||
savedTotal += seg.Downloaded
|
||||
if seg.Begin+seg.Downloaded <= seg.End {
|
||||
resumeAt[seg.Begin+seg.Downloaded] = true
|
||||
}
|
||||
}
|
||||
if savedTotal != stallAt {
|
||||
t.Fatalf("saved progress %d, want %d", savedTotal, stallAt)
|
||||
}
|
||||
|
||||
before := len(srv.RetrOffsets())
|
||||
restored := restoreFetcher(t, fm, f.Meta(), blob)
|
||||
if got := restored.Progress().TotalDownloaded(); got != savedTotal {
|
||||
t.Fatalf("restored progress %d, want %d", got, savedTotal)
|
||||
}
|
||||
startAndWait(t, restored)
|
||||
checkFile(t, restored.Meta().SingleFilepath(), want)
|
||||
|
||||
resumed := srv.RetrOffsets()[before:]
|
||||
if len(resumed) == 0 {
|
||||
t.Fatal("no transfer after resume")
|
||||
}
|
||||
for _, off := range resumed {
|
||||
if !resumeAt[off] {
|
||||
t.Fatalf("resumed RETR at %d, want one of the saved offsets %v", off, resumeAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLateRefusalAfterQueueDrained: worker B holds the second file's segment
|
||||
// while the server takes its time to refuse B's login. Worker A finishes the
|
||||
// first file meanwhile and finds the queue empty. B must not leave too, or
|
||||
// nobody fetches the second file.
|
||||
func TestLateRefusalAfterQueueDrained(t *testing.T) {
|
||||
srv := StartFTPServer(t, ServerOptions{MaxLogins: 1, RefuseDelay: 500 * time.Millisecond})
|
||||
want := map[string]string{
|
||||
"a.bin": srv.WriteFile(t, "dir/a.bin", RandomBytes(1000, 16)),
|
||||
"b.bin": srv.WriteFile(t, "dir/b.bin", RandomBytes(1000, 17)),
|
||||
}
|
||||
|
||||
f := buildFetcher(testManager(srv))
|
||||
resolveTask(t, f, ftpURL(srv, "ftp", "", "dir"), &pftp.OptsExtra{Connections: 2})
|
||||
startAndWait(t, f)
|
||||
for name, sum := range want {
|
||||
checkFile(t, filepath.Join(f.Meta().FolderPath(), name), sum)
|
||||
}
|
||||
if srv.Refused() == 0 {
|
||||
t.Fatal("the server never refused a login, so the late refusal was not exercised")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginCapLoweredMidway: both workers have logged in when the server's
|
||||
// cap drops to one, as when another client takes a slot. The refused worker
|
||||
// leaves, the ceiling drops to one, and the task finishes.
|
||||
func TestLoginCapLoweredMidway(t *testing.T) {
|
||||
srv := StartFTPServer(t, ServerOptions{})
|
||||
srv.ReadDelay.Store(int64(time.Millisecond))
|
||||
want := map[string]string{}
|
||||
for i := 0; i < 8; i++ {
|
||||
name := fmt.Sprintf("f%d.bin", i)
|
||||
want[name] = srv.WriteFile(t, "dir/"+name, RandomBytes(512<<10, byte(30+i)))
|
||||
}
|
||||
|
||||
fm := testManager(srv)
|
||||
f := buildFetcher(fm)
|
||||
resolveTask(t, f, ftpURL(srv, "ftp", "", "dir"), &pftp.OptsExtra{Connections: 2})
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for len(ftpStats(t, f).Connections) < 2 || f.Progress().TotalDownloaded() < 512<<10 {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("the two workers never got going")
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
srv.SetMaxLogins(1)
|
||||
if err := waitFetcher(t, f); err != nil {
|
||||
t.Fatalf("download: %v", err)
|
||||
}
|
||||
for name, sum := range want {
|
||||
checkFile(t, filepath.Join(f.Meta().FolderPath(), name), sum)
|
||||
}
|
||||
if srv.Refused() == 0 {
|
||||
t.Fatal("the server never refused a login, so the lowered cap was not exercised")
|
||||
}
|
||||
for _, c := range ftpStats(t, f).Connections {
|
||||
if c.Failed {
|
||||
t.Fatalf("a refused worker failed: %+v", c)
|
||||
}
|
||||
}
|
||||
var saved fetcherData
|
||||
if err := json.Unmarshal(storeData(t, fm, f), &saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.MaxConns != 1 {
|
||||
t.Fatalf("saved login ceiling %d, want 1", saved.MaxConns)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataConnectionsResumeOwnSession runs four FTPS workers against a server
|
||||
// that, like ProFTPD, accepts a data connection only when it resumes the TLS
|
||||
// session of its own control connection.
|
||||
func TestDataConnectionsResumeOwnSession(t *testing.T) {
|
||||
srv := StartFTPServer(t, ServerOptions{TLS: pftp.TLSImplicit, RequireSessionReuse: true})
|
||||
srv.ReadDelay.Store(int64(200 * time.Microsecond))
|
||||
want := srv.WriteFile(t, "reuse.bin", RandomBytes(8*mib, 18))
|
||||
|
||||
f := buildFetcher(testManager(srv))
|
||||
resolveTask(t, f, ftpURL(srv, "ftps", "", "reuse.bin"), &pftp.OptsExtra{Connections: 4})
|
||||
startAndWait(t, f)
|
||||
checkFile(t, f.Meta().SingleFilepath(), want)
|
||||
ok, bad := srv.SessionReuse()
|
||||
if ok < 4 || bad != 0 {
|
||||
t.Fatalf("data connections: %d resumed their own session, %d did not", ok, bad)
|
||||
}
|
||||
for _, c := range ftpStats(t, f).Connections {
|
||||
if c.RetryTimes != 0 {
|
||||
t.Fatalf("a worker needed retries: %+v", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryAfterDroppedConnection(t *testing.T) {
|
||||
srv := StartFTPServer(t, ServerOptions{})
|
||||
want := srv.WriteFile(t, "drop.bin", RandomBytes(3*mib, 11))
|
||||
srv.DropAfter.Store(mib + 100)
|
||||
|
||||
f := buildFetcher(testManager(srv))
|
||||
resolveTask(t, f, ftpURL(srv, "ftp", "", "drop.bin"), &pftp.OptsExtra{Connections: 1})
|
||||
startAndWait(t, f)
|
||||
checkFile(t, f.Meta().SingleFilepath(), want)
|
||||
|
||||
offsets := srv.RetrOffsets()
|
||||
if len(offsets) != 2 || offsets[0] != 0 || offsets[1] <= 0 || offsets[1] > mib+100 {
|
||||
t.Fatalf("RETR offsets %v, want a restart at the dropped offset", offsets)
|
||||
}
|
||||
if got := ftpStats(t, f).Connections[0].RetryTimes; got != 1 {
|
||||
t.Fatalf("retry count %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPercentEncodedPassword(t *testing.T) {
|
||||
srv := StartFTPServer(t, ServerOptions{User: "u", Pass: "p@ss"})
|
||||
want := srv.WriteFile(t, "secret.bin", RandomBytes(5000, 12))
|
||||
|
||||
f := buildFetcher(testManager(srv))
|
||||
resolveTask(t, f, ftpURL(srv, "ftp", "u:p%40ss", "secret.bin"), nil)
|
||||
startAndWait(t, f)
|
||||
checkFile(t, f.Meta().SingleFilepath(), want)
|
||||
|
||||
f = buildFetcher(testManager(srv))
|
||||
err := resolveOpts(f, &base.Request{URL: ftpURL(srv, "ftp", "u:wrong", "secret.bin")}, &base.Options{Path: t.TempDir()})
|
||||
if err == nil || !strings.Contains(err.Error(), "530") {
|
||||
t.Fatalf("a wrong password must fail with 530, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOldSaveFormatResumes(t *testing.T) {
|
||||
srv := StartFTPServer(t, ServerOptions{})
|
||||
data := RandomBytes(2*mib+1, 13)
|
||||
want := srv.WriteFile(t, "old.bin", data)
|
||||
|
||||
fm := testManager(srv)
|
||||
f := buildFetcher(fm)
|
||||
resolveTask(t, f, ftpURL(srv, "ftp", "", "old.bin"), &pftp.OptsExtra{Connections: 1})
|
||||
|
||||
// A save blob that has only the segment fields every version writes.
|
||||
const done = 700000
|
||||
blob := []byte(fmt.Sprintf(`{"Segments":[{"Begin":0,"End":%d,"Downloaded":%d}]}`, len(data)-1, done))
|
||||
name := f.Meta().SingleFilepath()
|
||||
if err := os.WriteFile(name, append(data[:done:done], make([]byte, len(data)-done)...), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
restored := restoreFetcher(t, fm, f.Meta(), blob)
|
||||
if got := restored.Progress().TotalDownloaded(); got != done {
|
||||
t.Fatalf("restored progress %d, want %d", got, done)
|
||||
}
|
||||
startAndWait(t, restored)
|
||||
checkFile(t, name, want)
|
||||
if got := srv.RetrOffsets(); !slices.Equal(got, []int64{done}) {
|
||||
t.Fatalf("RETR offsets %v, want [%d]", got, done)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadThroughSOCKS5(t *testing.T) {
|
||||
srv := StartFTPServer(t, ServerOptions{TLS: pftp.TLSExplicit})
|
||||
want := srv.WriteFile(t, "proxied.bin", RandomBytes(2*mib+1, 14))
|
||||
|
||||
var dials atomic.Int32
|
||||
proxySrv, err := socks5.New(&socks5.Config{
|
||||
Dial: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
dials.Add(1)
|
||||
var d net.Dialer
|
||||
return d.DialContext(ctx, network, addr)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
go proxySrv.Serve(ln)
|
||||
|
||||
f := buildFetcher(testManager(srv))
|
||||
req := &base.Request{
|
||||
URL: ftpURL(srv, "ftpes", "", "proxied.bin"),
|
||||
Proxy: &base.RequestProxy{
|
||||
Mode: base.RequestProxyModeCustom,
|
||||
Scheme: "socks5",
|
||||
Host: ln.Addr().String(),
|
||||
},
|
||||
}
|
||||
if err := resolveOpts(f, req, &base.Options{Path: t.TempDir(), Extra: &pftp.OptsExtra{Connections: 2}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
startAndWait(t, f)
|
||||
checkFile(t, f.Meta().SingleFilepath(), want)
|
||||
// Resolve's control and listing, then per worker one control and one
|
||||
// data connection at least: every one of them went through the proxy.
|
||||
if got := dials.Load(); got < 4 {
|
||||
t.Fatalf("the proxy saw %d connections, want every control and data connection", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPProxyFallsBackToDirect(t *testing.T) {
|
||||
srv := StartFTPServer(t, ServerOptions{})
|
||||
want := srv.WriteFile(t, "direct.bin", RandomBytes(100000, 15))
|
||||
|
||||
var logs bytes.Buffer
|
||||
logger := zerolog.New(&logs)
|
||||
fm := testManager(srv)
|
||||
f := fm.Build().(*Fetcher)
|
||||
ctl := testController(fm)
|
||||
ctl.Logger = &logger
|
||||
f.Setup(ctl)
|
||||
req := &base.Request{
|
||||
URL: ftpURL(srv, "ftp", "", "direct.bin"),
|
||||
Proxy: &base.RequestProxy{
|
||||
Mode: base.RequestProxyModeCustom,
|
||||
Scheme: "http",
|
||||
Host: "127.0.0.1:1",
|
||||
Usr: "proxyuser",
|
||||
Pwd: "proxysecret",
|
||||
},
|
||||
}
|
||||
if err := resolveOpts(f, req, &base.Options{Path: t.TempDir(), Extra: &pftp.OptsExtra{Connections: 2}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
startAndWait(t, f)
|
||||
checkFile(t, f.Meta().SingleFilepath(), want)
|
||||
|
||||
out := logs.String()
|
||||
if n := strings.Count(out, "HTTP proxy"); n != 1 {
|
||||
t.Fatalf("want exactly one HTTP proxy warning, got %d in %q", n, out)
|
||||
}
|
||||
if strings.Contains(out, "proxysecret") {
|
||||
t.Fatalf("the proxy password leaked into the log: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTorrentNameIsFile(t *testing.T) {
|
||||
fm := testManager(nil)
|
||||
for _, u := range []string{"ftp://h/x.torrent", "ftps://h/x.torrent", "ftpes://h/x.torrent", "FTP://h/v.m3u8"} {
|
||||
matched := false
|
||||
for _, filter := range fm.Filters() {
|
||||
if filter.Match(u) {
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
t.Fatalf("%s is not matched by the FTP filters", u)
|
||||
}
|
||||
}
|
||||
if fm.Name() != "ftp" {
|
||||
t.Fatalf("manager name %q", fm.Name())
|
||||
}
|
||||
|
||||
srv := StartFTPServer(t, ServerOptions{})
|
||||
want := srv.WriteFile(t, "x.torrent", []byte("d8:announce0:e"))
|
||||
f := buildFetcher(testManager(srv))
|
||||
resolveTask(t, f, ftpURL(srv, "ftp", "", "x.torrent"), nil)
|
||||
res := f.Meta().Res
|
||||
if res.Name != "" || len(res.Files) != 1 || res.Files[0].Name != "x.torrent" {
|
||||
t.Fatalf("x.torrent must resolve to one FTP file, got name %q and %d files", res.Name, len(res.Files))
|
||||
}
|
||||
startAndWait(t, f)
|
||||
checkFile(t, f.Meta().SingleFilepath(), want)
|
||||
}
|
||||
|
||||
func TestParseNameHidesCredentials(t *testing.T) {
|
||||
fm := testManager(nil)
|
||||
for u, want := range map[string]string{
|
||||
"ftp://u:p%[email protected]/pub/a.iso": "a.iso",
|
||||
"ftp://u:p%[email protected]/": "example.com",
|
||||
"ftps://u:p%[email protected]": "example.com",
|
||||
"ftp://u:p%[email protected]/pub/dir/": "dir",
|
||||
} {
|
||||
if got := fm.ParseName(u); got != want {
|
||||
t.Fatalf("ParseName(%q) = %q, want %q", u, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsafeEntryNames(t *testing.T) {
|
||||
for name, ok := range map[string]bool{
|
||||
"a.txt": true, "..a": true, "a b": true,
|
||||
"": false, ".": false, "..": false, "a/b": false, `a\b`: false, "../x": false, "x\x00y": false,
|
||||
} {
|
||||
if got := safeEntryName(name); got != ok {
|
||||
t.Fatalf("safeEntryName(%q) = %v, want %v", name, got, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
package ftp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
ftpserver "github.com/fclairamb/ftpserverlib"
|
||||
"github.com/spf13/afero"
|
||||
|
||||
pftp "github.com/GopeedLab/gopeed/pkg/protocol/ftp"
|
||||
)
|
||||
|
||||
// ServerOptions are the knobs of the in-process FTP test server.
|
||||
type ServerOptions struct {
|
||||
// TLS is pftp.TLSNone (or empty), pftp.TLSExplicit or pftp.TLSImplicit.
|
||||
// Explicit mode refuses plain logins, so a passing test proves TLS was used.
|
||||
TLS string
|
||||
// MaxLogins caps concurrent control connections. Beyond it the server
|
||||
// greets with "421 Too many connections" and hangs up. Zero means no cap.
|
||||
MaxLogins int
|
||||
// NoRest answers "502" to REST. It filters the plain control stream, so it
|
||||
// works with TLSNone and TLSImplicit only.
|
||||
NoRest bool
|
||||
// NoMLST disables MLST and MLSD, so clients fall back to SIZE and LIST.
|
||||
NoMLST bool
|
||||
// User and Pass are the only accepted credentials. An empty User accepts
|
||||
// any login, anonymous included.
|
||||
User string
|
||||
Pass string
|
||||
// RefuseDelay holds every 421 refusal back this long, like a server
|
||||
// that answers slowly when it is full.
|
||||
RefuseDelay time.Duration
|
||||
// RequireSessionReuse makes every data connection resume the TLS
|
||||
// session of the control connection that opened it, as ProFTPD's
|
||||
// mod_tls does by default. A data connection that does not is refused.
|
||||
// It needs TLSImplicit, where this test's wrapper sees the plain
|
||||
// control stream and so learns which passive port belongs to whom.
|
||||
RequireSessionReuse bool
|
||||
}
|
||||
|
||||
// FTPServer is an ftpserverlib server over a temp directory.
|
||||
type FTPServer struct {
|
||||
Addr string // host:port
|
||||
Root string // directory served as "/"
|
||||
// CA trusts the server's self-signed certificate.
|
||||
CA *x509.CertPool
|
||||
|
||||
opts ServerOptions
|
||||
tlsConf *tls.Config
|
||||
server *ftpserver.FtpServer
|
||||
|
||||
// ReadDelay is slept before every read of a transferred file, in
|
||||
// nanoseconds, to slow downloads down. Tests may change it at any time.
|
||||
ReadDelay atomic.Int64
|
||||
|
||||
// DropAfter, when positive, makes the next transfer fail once after it
|
||||
// has sent that many bytes, as if the data connection were killed.
|
||||
DropAfter atomic.Int64
|
||||
|
||||
mu sync.Mutex
|
||||
active int
|
||||
retrs []int64 // offset of every RETR, 0 when no REST came first
|
||||
refused int
|
||||
logins int
|
||||
|
||||
// Passive port to the control connection that announced it, and the
|
||||
// outcome of every data connection's session check.
|
||||
pasvOwner map[int]string
|
||||
reuseOK int
|
||||
reuseBad int
|
||||
|
||||
// Stall state, see StallAfter. stallMu is held across file reads.
|
||||
stallMu sync.Mutex
|
||||
stallAt int64
|
||||
sent int64
|
||||
stallCh chan struct{}
|
||||
stalled chan struct{}
|
||||
stalledOnce *sync.Once
|
||||
}
|
||||
|
||||
// StartFTPServer starts a server and stops it when the test ends.
|
||||
func StartFTPServer(t *testing.T, opts ServerOptions) *FTPServer {
|
||||
t.Helper()
|
||||
s := &FTPServer{Root: t.TempDir(), opts: opts}
|
||||
s.tlsConf, s.CA = selfSignedTLS(t)
|
||||
|
||||
base, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ln net.Listener = base
|
||||
if opts.RequireSessionReuse {
|
||||
if opts.TLS != pftp.TLSImplicit {
|
||||
t.Fatal("RequireSessionReuse needs TLSImplicit")
|
||||
}
|
||||
s.requireSessionReuse(t, base.Addr().(*net.TCPAddr).Port)
|
||||
}
|
||||
if opts.TLS == pftp.TLSImplicit {
|
||||
ln = tls.NewListener(ln, s.tlsConf)
|
||||
}
|
||||
ln = &limitListener{Listener: ln, srv: s}
|
||||
|
||||
s.Addr = base.Addr().String()
|
||||
s.server = ftpserver.NewFtpServer(&mainDriver{srv: s, listener: ln})
|
||||
if err := s.server.Listen(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go s.server.Serve()
|
||||
t.Cleanup(func() { _ = s.server.Stop() })
|
||||
return s
|
||||
}
|
||||
|
||||
// WriteFile creates a file under the served root and returns its SHA-256.
|
||||
func (s *FTPServer) WriteFile(t *testing.T, rel string, data []byte) string {
|
||||
t.Helper()
|
||||
name := filepath.Join(s.Root, filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(name, data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return SHA256Hex(data)
|
||||
}
|
||||
|
||||
// RetrOffsets returns the start offset of every RETR the server received.
|
||||
func (s *FTPServer) RetrOffsets() []int64 {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]int64(nil), s.retrs...)
|
||||
}
|
||||
|
||||
// RestOffsets returns the non-zero RETR offsets, which only a REST sets.
|
||||
func (s *FTPServer) RestOffsets() []int64 {
|
||||
var out []int64
|
||||
for _, off := range s.RetrOffsets() {
|
||||
if off != 0 {
|
||||
out = append(out, off)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Refused returns how many connections were turned away with 421.
|
||||
func (s *FTPServer) Refused() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.refused
|
||||
}
|
||||
|
||||
// Logins returns the number of successful logins.
|
||||
func (s *FTPServer) Logins() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.logins
|
||||
}
|
||||
|
||||
// SetMaxLogins changes the login cap while the server runs, as when other
|
||||
// clients take slots.
|
||||
func (s *FTPServer) SetMaxLogins(n int) {
|
||||
s.mu.Lock()
|
||||
s.opts.MaxLogins = n
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// SessionReuse returns how many data connections resumed their own control
|
||||
// connection's TLS session, and how many did not.
|
||||
func (s *FTPServer) SessionReuse() (ok, bad int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.reuseOK, s.reuseBad
|
||||
}
|
||||
|
||||
// StallAfter makes every transfer stop sending once the server has sent n
|
||||
// bytes in total, until Release. Stalled is closed when that happens.
|
||||
func (s *FTPServer) StallAfter(n int64) {
|
||||
s.stallMu.Lock()
|
||||
defer s.stallMu.Unlock()
|
||||
s.stallAt, s.sent = n, 0
|
||||
s.stallCh = make(chan struct{})
|
||||
s.stalled = make(chan struct{})
|
||||
s.stalledOnce = &sync.Once{}
|
||||
}
|
||||
|
||||
// Stalled is closed once a transfer has stopped at the StallAfter limit.
|
||||
func (s *FTPServer) Stalled() <-chan struct{} {
|
||||
s.stallMu.Lock()
|
||||
defer s.stallMu.Unlock()
|
||||
return s.stalled
|
||||
}
|
||||
|
||||
// Sent returns the bytes sent since StallAfter.
|
||||
func (s *FTPServer) Sent() int64 {
|
||||
s.stallMu.Lock()
|
||||
defer s.stallMu.Unlock()
|
||||
return s.sent
|
||||
}
|
||||
|
||||
// Release ends the stall. The stalled transfers fail.
|
||||
func (s *FTPServer) Release() {
|
||||
s.stallMu.Lock()
|
||||
defer s.stallMu.Unlock()
|
||||
if s.stallAt > 0 {
|
||||
s.stallAt = 0
|
||||
close(s.stallCh)
|
||||
}
|
||||
}
|
||||
|
||||
var errStalled = errors.New("test server released a stalled transfer")
|
||||
|
||||
// stall limits a read to the StallAfter budget, and blocks once the budget
|
||||
// is spent.
|
||||
func (s *FTPServer) stall(read func([]byte) (int, error), p []byte) (int, error) {
|
||||
s.stallMu.Lock()
|
||||
if s.stallAt <= 0 {
|
||||
s.stallMu.Unlock()
|
||||
return read(p)
|
||||
}
|
||||
allowed := s.stallAt - s.sent
|
||||
if allowed <= 0 {
|
||||
ch := s.stallCh
|
||||
s.stalledOnce.Do(func() { close(s.stalled) })
|
||||
s.stallMu.Unlock()
|
||||
<-ch
|
||||
return 0, errStalled
|
||||
}
|
||||
if int64(len(p)) > allowed {
|
||||
p = p[:allowed]
|
||||
}
|
||||
n, err := read(p)
|
||||
s.sent += int64(n)
|
||||
s.stallMu.Unlock()
|
||||
return n, err
|
||||
}
|
||||
|
||||
var pasvReply = regexp.MustCompile(`^(?:229 [^(]*\(\|\|\|(\d+)\|\)|227 [^(]*\(\d+,\d+,\d+,\d+,(\d+),(\d+)\))`)
|
||||
|
||||
// notePassive records the passive port a control connection announced.
|
||||
func (s *FTPServer) notePassive(owner string, reply []byte) {
|
||||
m := pasvReply.FindSubmatch(reply)
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
var port int
|
||||
if len(m[1]) > 0 {
|
||||
port, _ = strconv.Atoi(string(m[1]))
|
||||
} else {
|
||||
hi, _ := strconv.Atoi(string(m[2]))
|
||||
lo, _ := strconv.Atoi(string(m[3]))
|
||||
port = hi<<8 | lo
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.pasvOwner[port] = owner
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// requireSessionReuse stamps each control connection's session tickets with
|
||||
// its address, and checks that each data connection resumed a ticket of the
|
||||
// control connection that announced its passive port.
|
||||
func (s *FTPServer) requireSessionReuse(t *testing.T, controlPort int) {
|
||||
base := s.tlsConf
|
||||
var key [32]byte
|
||||
if _, err := rand.Read(key[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base.SetSessionTicketKeys([][32]byte{key})
|
||||
s.pasvOwner = map[int]string{}
|
||||
stamp := func(owner string) func(tls.ConnectionState, *tls.SessionState) ([]byte, error) {
|
||||
return func(cs tls.ConnectionState, ss *tls.SessionState) ([]byte, error) {
|
||||
ss.Extra = [][]byte{[]byte(owner)}
|
||||
return base.EncryptTicket(cs, ss)
|
||||
}
|
||||
}
|
||||
base.GetConfigForClient = func(hello *tls.ClientHelloInfo) (*tls.Config, error) {
|
||||
cfg := base.Clone()
|
||||
cfg.GetConfigForClient = nil
|
||||
local, _ := hello.Conn.LocalAddr().(*net.TCPAddr)
|
||||
if local == nil || local.Port == controlPort {
|
||||
cfg.WrapSession = stamp(hello.Conn.RemoteAddr().String())
|
||||
return cfg, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
owner := s.pasvOwner[local.Port]
|
||||
s.mu.Unlock()
|
||||
var resumedFrom string
|
||||
cfg.UnwrapSession = func(identity []byte, cs tls.ConnectionState) (*tls.SessionState, error) {
|
||||
ss, err := base.DecryptTicket(identity, cs)
|
||||
if err != nil || ss == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if len(ss.Extra) > 0 {
|
||||
resumedFrom = string(ss.Extra[0])
|
||||
}
|
||||
return ss, nil
|
||||
}
|
||||
cfg.WrapSession = stamp(owner)
|
||||
cfg.VerifyConnection = func(cs tls.ConnectionState) error {
|
||||
ok := owner != "" && cs.DidResume && resumedFrom == owner
|
||||
s.mu.Lock()
|
||||
if ok {
|
||||
s.reuseOK++
|
||||
} else {
|
||||
s.reuseBad++
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
return errors.New("data connection did not resume its control connection's TLS session")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *FTPServer) acquire() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.opts.MaxLogins > 0 && s.active >= s.opts.MaxLogins {
|
||||
s.refused++
|
||||
return false
|
||||
}
|
||||
s.active++
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *FTPServer) release() {
|
||||
s.mu.Lock()
|
||||
s.active--
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// limitListener counts control connections and refuses those beyond
|
||||
// MaxLogins with a 421 greeting, like vsftpd's max_clients and max_per_ip.
|
||||
type limitListener struct {
|
||||
net.Listener
|
||||
srv *FTPServer
|
||||
}
|
||||
|
||||
func (l *limitListener) Accept() (net.Conn, error) {
|
||||
for {
|
||||
c, err := l.Listener.Accept()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !l.srv.acquire() {
|
||||
go func() {
|
||||
time.Sleep(l.srv.opts.RefuseDelay)
|
||||
_ = c.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
_, _ = io.WriteString(c, "421 Too many connections\r\n")
|
||||
_ = c.Close()
|
||||
}()
|
||||
continue
|
||||
}
|
||||
cc := &countedConn{Conn: c, release: l.srv.release}
|
||||
if l.srv.opts.RequireSessionReuse {
|
||||
owner := c.RemoteAddr().String()
|
||||
cc.sniff = func(b []byte) { l.srv.notePassive(owner, b) }
|
||||
}
|
||||
var conn net.Conn = cc
|
||||
if l.srv.opts.NoRest {
|
||||
conn = &noRestConn{countedConn: conn.(*countedConn), in: bufio.NewReader(c)}
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
}
|
||||
|
||||
type countedConn struct {
|
||||
net.Conn
|
||||
once sync.Once
|
||||
release func()
|
||||
sniff func([]byte) // sees every reply the server writes
|
||||
}
|
||||
|
||||
func (c *countedConn) Write(b []byte) (int, error) {
|
||||
if c.sniff != nil {
|
||||
c.sniff(b)
|
||||
}
|
||||
return c.Conn.Write(b)
|
||||
}
|
||||
|
||||
func (c *countedConn) Close() error {
|
||||
c.once.Do(c.release)
|
||||
return c.Conn.Close()
|
||||
}
|
||||
|
||||
// noRestConn answers REST itself with 502 and hides it from the server.
|
||||
type noRestConn struct {
|
||||
*countedConn
|
||||
in *bufio.Reader
|
||||
pending []byte
|
||||
}
|
||||
|
||||
func (c *noRestConn) Read(p []byte) (int, error) {
|
||||
for len(c.pending) == 0 {
|
||||
line, err := c.in.ReadBytes('\n')
|
||||
if len(line) > 0 {
|
||||
if strings.HasPrefix(strings.ToUpper(string(line)), "REST ") {
|
||||
if _, werr := io.WriteString(c.Conn, "502 REST not implemented\r\n"); werr != nil {
|
||||
return 0, werr
|
||||
}
|
||||
} else {
|
||||
c.pending = line
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if len(c.pending) > 0 {
|
||||
break
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
n := copy(p, c.pending)
|
||||
c.pending = c.pending[n:]
|
||||
return n, nil
|
||||
}
|
||||
|
||||
type mainDriver struct {
|
||||
srv *FTPServer
|
||||
listener net.Listener
|
||||
}
|
||||
|
||||
func (d *mainDriver) GetSettings() (*ftpserver.Settings, error) {
|
||||
settings := &ftpserver.Settings{
|
||||
Listener: d.listener,
|
||||
DisableMLSD: d.srv.opts.NoMLST,
|
||||
DisableMLST: d.srv.opts.NoMLST,
|
||||
}
|
||||
switch d.srv.opts.TLS {
|
||||
case pftp.TLSExplicit:
|
||||
settings.TLSRequired = ftpserver.MandatoryEncryption
|
||||
case pftp.TLSImplicit:
|
||||
settings.TLSRequired = ftpserver.ImplicitEncryption
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func (d *mainDriver) ClientConnected(ftpserver.ClientContext) (string, error) {
|
||||
return "gopeed test server", nil
|
||||
}
|
||||
|
||||
func (d *mainDriver) ClientDisconnected(ftpserver.ClientContext) {}
|
||||
|
||||
func (d *mainDriver) AuthUser(_ ftpserver.ClientContext, user, pass string) (ftpserver.ClientDriver, error) {
|
||||
if d.srv.opts.User != "" && (user != d.srv.opts.User || pass != d.srv.opts.Pass) {
|
||||
return nil, errors.New("bad credentials")
|
||||
}
|
||||
d.srv.mu.Lock()
|
||||
d.srv.logins++
|
||||
d.srv.mu.Unlock()
|
||||
return &clientDriver{Fs: afero.NewBasePathFs(afero.NewOsFs(), d.srv.Root), srv: d.srv}, nil
|
||||
}
|
||||
|
||||
func (d *mainDriver) GetTLSConfig() (*tls.Config, error) {
|
||||
return d.srv.tlsConf, nil
|
||||
}
|
||||
|
||||
type clientDriver struct {
|
||||
afero.Fs
|
||||
srv *FTPServer
|
||||
}
|
||||
|
||||
// GetHandle sees the REST offset of every transfer. The server seeks the
|
||||
// returned file to that offset itself.
|
||||
func (d *clientDriver) GetHandle(name string, flags int, offset int64) (ftpserver.FileTransfer, error) {
|
||||
f, err := d.Fs.OpenFile(name, flags, 0o644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if flags == os.O_RDONLY {
|
||||
d.srv.mu.Lock()
|
||||
d.srv.retrs = append(d.srv.retrs, offset)
|
||||
d.srv.mu.Unlock()
|
||||
}
|
||||
return &slowFile{File: f, srv: d.srv, dropAfter: d.srv.DropAfter.Swap(0)}, nil
|
||||
}
|
||||
|
||||
var errDropped = errors.New("test server dropped the transfer")
|
||||
|
||||
type slowFile struct {
|
||||
afero.File
|
||||
srv *FTPServer
|
||||
sent int64
|
||||
dropAfter int64
|
||||
}
|
||||
|
||||
func (f *slowFile) Read(p []byte) (int, error) {
|
||||
if delay := time.Duration(f.srv.ReadDelay.Load()); delay > 0 {
|
||||
time.Sleep(delay)
|
||||
if len(p) > 16<<10 {
|
||||
p = p[:16<<10]
|
||||
}
|
||||
}
|
||||
if f.dropAfter > 0 {
|
||||
if f.sent >= f.dropAfter {
|
||||
return 0, errDropped
|
||||
}
|
||||
if rest := f.dropAfter - f.sent; int64(len(p)) > rest {
|
||||
p = p[:rest]
|
||||
}
|
||||
}
|
||||
n, err := f.srv.stall(f.File.Read, p)
|
||||
f.sent += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// selfSignedTLS returns a server config with a fresh certificate for
|
||||
// 127.0.0.1 and localhost, and a pool that trusts it.
|
||||
func selfSignedTLS(t *testing.T) (*tls.Config, *x509.CertPool) {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(time.Now().UnixNano()),
|
||||
Subject: pkix.Name{CommonName: "gopeed ftp test"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
DNSNames: []string{"localhost"},
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
IsCA: true,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(cert)
|
||||
return &tls.Config{
|
||||
Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key, Leaf: cert}},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}, pool
|
||||
}
|
||||
|
||||
func SHA256Hex(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func FileSHA256(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return SHA256Hex(data)
|
||||
}
|
||||
|
||||
// RandomBytes returns n reproducible pseudo-random bytes.
|
||||
func RandomBytes(n int, seed byte) []byte {
|
||||
out := make([]byte, n)
|
||||
x := uint32(seed) + 1
|
||||
for i := range out {
|
||||
x ^= x << 13
|
||||
x ^= x >> 17
|
||||
x ^= x << 5
|
||||
out[i] = byte(x)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package hls
|
||||
|
||||
const (
|
||||
defaultSegmentConnections = 8
|
||||
defaultMaxRetries = 5
|
||||
defaultTimeoutSeconds = 30
|
||||
|
||||
// maxPrefetchSizeSegments caps the HEAD probing done at resolve time.
|
||||
// Playlists larger than this skip size prefetch entirely.
|
||||
maxPrefetchSizeSegments = 500
|
||||
)
|
||||
|
||||
type config struct {
|
||||
SegmentConnections int `json:"segmentConnections"`
|
||||
MaxRetries int `json:"maxRetries"`
|
||||
TimeoutSeconds int `json:"timeoutSeconds"`
|
||||
PrefetchContentLength bool `json:"prefetchContentLength"`
|
||||
}
|
||||
|
||||
func (c *config) normalize() {
|
||||
if c.SegmentConnections <= 0 {
|
||||
c.SegmentConnections = defaultSegmentConnections
|
||||
}
|
||||
if c.MaxRetries < 0 {
|
||||
c.MaxRetries = defaultMaxRetries
|
||||
}
|
||||
if c.TimeoutSeconds <= 0 {
|
||||
c.TimeoutSeconds = defaultTimeoutSeconds
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package hls
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// decryptAES128 decrypts an AES-128-CBC encrypted segment and strips PKCS7
|
||||
// padding. data length must be a multiple of the AES block size.
|
||||
func decryptAES128(data, key, iv []byte) ([]byte, error) {
|
||||
if len(key) != 16 {
|
||||
return nil, fmt.Errorf("invalid AES-128 key size %d", len(key))
|
||||
}
|
||||
if len(iv) != 16 {
|
||||
return nil, fmt.Errorf("invalid AES-128 IV size %d", len(iv))
|
||||
}
|
||||
if len(data) == 0 || len(data)%aes.BlockSize != 0 {
|
||||
return nil, fmt.Errorf("invalid AES-128 data size %d", len(data))
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plain := make([]byte, len(data))
|
||||
cipher.NewCBCDecrypter(block, iv).CryptBlocks(plain, data)
|
||||
return pkcs7Unpad(plain, aes.BlockSize)
|
||||
}
|
||||
|
||||
func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, fmt.Errorf("empty plaintext")
|
||||
}
|
||||
pad := int(data[len(data)-1])
|
||||
if pad == 0 || pad > blockSize || pad > len(data) {
|
||||
return nil, fmt.Errorf("invalid PKCS7 padding")
|
||||
}
|
||||
for _, b := range data[len(data)-pad:] {
|
||||
if int(b) != pad {
|
||||
return nil, fmt.Errorf("invalid PKCS7 padding")
|
||||
}
|
||||
}
|
||||
return data[:len(data)-pad], nil
|
||||
}
|
||||
|
||||
// sequenceIV derives the default per-segment IV mandated by RFC 8216: the
|
||||
// segment media sequence number as a 16-byte big-endian integer.
|
||||
func sequenceIV(sequence int64) []byte {
|
||||
iv := make([]byte, 16)
|
||||
iv[8] = byte(sequence >> 56)
|
||||
iv[9] = byte(sequence >> 48)
|
||||
iv[10] = byte(sequence >> 40)
|
||||
iv[11] = byte(sequence >> 32)
|
||||
iv[12] = byte(sequence >> 24)
|
||||
iv[13] = byte(sequence >> 16)
|
||||
iv[14] = byte(sequence >> 8)
|
||||
iv[15] = byte(sequence)
|
||||
return iv
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package hls
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func pkcs7Pad(data []byte, blockSize int) []byte {
|
||||
pad := blockSize - len(data)%blockSize
|
||||
return append(data, bytes.Repeat([]byte{byte(pad)}, pad)...)
|
||||
}
|
||||
|
||||
func TestDecryptAES128Roundtrip(t *testing.T) {
|
||||
key := make([]byte, 16)
|
||||
iv := make([]byte, 16)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := rand.Read(iv); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain := []byte("hello hls segment payload, padded to block size!")
|
||||
padded := pkcs7Pad(plain, aes.BlockSize)
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encrypted := make([]byte, len(padded))
|
||||
cipher.NewCBCEncrypter(block, iv).CryptBlocks(encrypted, padded)
|
||||
|
||||
got, err := decryptAES128(encrypted, key, iv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, plain) {
|
||||
t.Errorf("roundtrip mismatch: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptAES128InvalidInput(t *testing.T) {
|
||||
if _, err := decryptAES128([]byte{1, 2, 3}, make([]byte, 16), make([]byte, 16)); err == nil {
|
||||
t.Error("non-block-aligned data should fail")
|
||||
}
|
||||
if _, err := decryptAES128(make([]byte, 16), make([]byte, 15), make([]byte, 16)); err == nil {
|
||||
t.Error("short key should fail")
|
||||
}
|
||||
if _, err := decryptAES128(make([]byte, 16), make([]byte, 16), make([]byte, 16)); err == nil {
|
||||
t.Error("all-zero block has invalid padding and should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSequenceIV(t *testing.T) {
|
||||
iv := sequenceIV(1)
|
||||
if len(iv) != 16 || iv[15] != 1 {
|
||||
t.Errorf("sequence 1 IV wrong: %v", iv)
|
||||
}
|
||||
iv = sequenceIV(0x0102030405060708)
|
||||
for i, want := range []byte{1, 2, 3, 4, 5, 6, 7, 8} {
|
||||
if iv[8+i] != want {
|
||||
t.Errorf("sequence IV byte %d: want %d got %d", i, want, iv[8+i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Package hls implements native HTTP Live Streaming downloads for m3u8
|
||||
// playlists. A task produces exactly one merged, playable output file:
|
||||
// MPEG-2 TS segments are concatenated as-is, fMP4 streams are merged as
|
||||
// init section followed by the media fragments, and AES-128 encrypted
|
||||
// segments are decrypted before they are stored.
|
||||
//
|
||||
// Supported subset:
|
||||
// - VOD media playlists (EXT-X-ENDLIST present) and master playlists whose
|
||||
// selected variant carries its own audio
|
||||
// - AES-128 with the identity KEYFORMAT, explicit IV or media-sequence
|
||||
// derived IV, including key rotation mid-playlist
|
||||
// - EXT-X-BYTERANGE (including offset inheritance) and fMP4 init sections
|
||||
// via a single, constant EXT-X-MAP
|
||||
//
|
||||
// Everything the merge pipeline cannot reproduce faithfully is rejected with
|
||||
// an explicit error instead of silently producing a broken output file:
|
||||
// - live streams (no EXT-X-ENDLIST) and SAMPLE-AES / DRM encryption
|
||||
// - EXT-X-GAP (would leave a hole) and EXT-X-DISCONTINUITY (broken timeline)
|
||||
// - changing init sections (a repeated EXT-X-MAP is compared by all its
|
||||
// attributes, not just the URI) and encrypted EXT-X-MAP without an
|
||||
// explicit IV
|
||||
// - separate audio renditions referenced by the selected variant (the
|
||||
// merged output would have no audio) and non-identity KEYFORMATs
|
||||
package hls
|
||||
@@ -0,0 +1,997 @@
|
||||
package hls
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/internal/httpclient"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
// ReqExtra mirrors the HTTP protocol extra: the create dialog attaches HTTP
|
||||
// style options to m3u8 URLs (same JSON keys), so the same shape is decoded.
|
||||
type ReqExtra struct {
|
||||
Method string `json:"method,omitempty"`
|
||||
Header map[string]string `json:"header,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
func copyReqExtra(extra *ReqExtra) *ReqExtra {
|
||||
copied := &ReqExtra{}
|
||||
if extra == nil {
|
||||
return copied
|
||||
}
|
||||
copied.Method = extra.Method
|
||||
copied.Body = extra.Body
|
||||
if extra.Header != nil {
|
||||
copied.Header = make(map[string]string, len(extra.Header))
|
||||
for key, value := range extra.Header {
|
||||
copied.Header[key] = value
|
||||
}
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
// fetcherState is the resolved download plan persisted for resume. StagingID
|
||||
// gives the task its own staging folder (two tasks downloading the same URL
|
||||
// into the same directory never share segment files) and survives restarts
|
||||
// through the persisted state. StagingDir pins the absolute folder so user
|
||||
// renames or moves of the task output do not orphan the staged segments.
|
||||
type fetcherState struct {
|
||||
MediaURL string `json:"mediaURL"`
|
||||
OutputName string `json:"outputName"`
|
||||
Segments []*Segment `json:"segments"`
|
||||
StagingID string `json:"stagingID,omitempty"`
|
||||
StagingDir string `json:"stagingDir,omitempty"`
|
||||
}
|
||||
|
||||
// Stats is the protocol task statistics snapshot.
|
||||
type Stats struct {
|
||||
SegmentsTotal int `json:"segmentsTotal"`
|
||||
SegmentsDone int `json:"segmentsDone"`
|
||||
SegmentsFailed int `json:"segmentsFailed"`
|
||||
}
|
||||
|
||||
// tempDirPrefix separates HLS staging folders from regular task files.
|
||||
const tempDirPrefix = ".gopeed-hls-"
|
||||
|
||||
// hlsRun holds every piece of state that belongs to one Start..Pause/complete
|
||||
// cycle. Runs never share mutable state: a Pause that is immediately followed
|
||||
// by a Start replaces the whole run, so workers of the previous run cannot
|
||||
// race with the next one. The run also snapshots the plan, request headers,
|
||||
// client and merge target so concurrent Patch calls cannot change them under
|
||||
// a running download.
|
||||
type hlsRun struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
// wg covers the queue producer AND the segment workers: Pause must not
|
||||
// return while the producer can still hand out segment indexes.
|
||||
wg sync.WaitGroup
|
||||
done chan struct{} // closed when the supervisor has fully exited
|
||||
|
||||
segs []*Segment // plan snapshot, read-only for the duration of the run
|
||||
extra *ReqExtra // header snapshot (deep copy)
|
||||
client *http.Client
|
||||
outputPath string // merge target snapshot
|
||||
tempDir string
|
||||
|
||||
keyCache map[string][]byte
|
||||
keyCacheMu sync.Mutex
|
||||
|
||||
stagingID string // identity of the staging folder, bound into the journal
|
||||
planHash string // fingerprint of the download plan, validated on resume
|
||||
journal map[int64]int64
|
||||
journalPath string
|
||||
journalMu sync.Mutex
|
||||
|
||||
// downloaded counts bytes of the final output that are safely on disk
|
||||
// (completed segment files), not raw network traffic: discarded retry
|
||||
// bytes must never show up as progress.
|
||||
downloaded atomic.Int64
|
||||
doneCount atomic.Int64
|
||||
failCount atomic.Int64
|
||||
baseBytes int64 // stored bytes restored from the journal at startup
|
||||
baseDone int // completed segments restored from the journal at startup
|
||||
pending []int64 // segment indexes to download, set by prepareRun
|
||||
}
|
||||
|
||||
func (r *hlsRun) statsSnapshot() Stats {
|
||||
return Stats{
|
||||
SegmentsTotal: len(r.segs),
|
||||
SegmentsDone: r.baseDone + int(r.doneCount.Load()),
|
||||
SegmentsFailed: int(r.failCount.Load()),
|
||||
}
|
||||
}
|
||||
|
||||
type Fetcher struct {
|
||||
fetcher.DefaultFetcher
|
||||
meta *fetcher.FetcherMeta
|
||||
config *config
|
||||
|
||||
// mu guards the fields below and every lifecycle transition. Progress and
|
||||
// Stats are read from the engine's checkpoint goroutine while a run is
|
||||
// active; they only take mu to snapshot the active run pointer, then read
|
||||
// run-owned atomics lock-free.
|
||||
mu sync.Mutex
|
||||
extra *ReqExtra
|
||||
client *http.Client
|
||||
state *fetcherState
|
||||
activeRun *hlsRun
|
||||
closed bool
|
||||
lastStats Stats
|
||||
lastDownloaded int64
|
||||
|
||||
impSession *httpclient.ImpersonationSession
|
||||
}
|
||||
|
||||
func (f *Fetcher) Setup(ctl *controller.Controller) {
|
||||
// Initializes Ctl and DoneCh, which Wait() depends on.
|
||||
f.DefaultFetcher.Setup(ctl)
|
||||
if f.meta == nil {
|
||||
f.meta = &fetcher.FetcherMeta{}
|
||||
}
|
||||
f.impSession = httpclient.NewImpersonationSession()
|
||||
f.config = &config{}
|
||||
f.Ctl.GetConfig(f.config)
|
||||
f.config.normalize()
|
||||
// Restored fetchers receive their request through Setup (the engine calls
|
||||
// it after the Restore builder): parse the extra headers so resumed
|
||||
// segment/key requests keep Referer/Cookie and other anti-hotlink headers.
|
||||
f.mu.Lock()
|
||||
f.reloadExtraLocked()
|
||||
f.mu.Unlock()
|
||||
}
|
||||
|
||||
// reloadExtraLocked refreshes f.extra from the typed extra of meta.Req.
|
||||
func (f *Fetcher) reloadExtraLocked() {
|
||||
if f.meta == nil || f.meta.Req == nil {
|
||||
return
|
||||
}
|
||||
if err := base.ParseReqExtra[ReqExtra](f.meta.Req); err != nil {
|
||||
return
|
||||
}
|
||||
if e, ok := f.meta.Req.Extra.(*ReqExtra); ok {
|
||||
f.extra = e
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Fetcher) Meta() *fetcher.FetcherMeta {
|
||||
return f.meta
|
||||
}
|
||||
|
||||
func (f *Fetcher) Resolve(req *base.Request) (err error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.meta.Req = req
|
||||
|
||||
f.extra = &ReqExtra{}
|
||||
if err = base.ParseReqExtra[ReqExtra](req); err != nil {
|
||||
return
|
||||
}
|
||||
if e, ok := req.Extra.(*ReqExtra); ok {
|
||||
f.extra = e
|
||||
}
|
||||
if f.client == nil {
|
||||
f.client = f.buildClient()
|
||||
}
|
||||
|
||||
state, name, err := f.resolvePlanLocked(context.Background(), "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state.StagingID = randomStagingID()
|
||||
f.state = state
|
||||
size := planTotalSize(state.Segments)
|
||||
f.meta.Res = &base.Resource{
|
||||
Range: false,
|
||||
Files: []*base.FileInfo{
|
||||
{
|
||||
Name: name,
|
||||
Size: size,
|
||||
},
|
||||
},
|
||||
Size: size,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolvePlanLocked fetches and parses the playlist into a fresh state. It
|
||||
// must be called with f.mu held while the fetcher is quiescent. When keepName
|
||||
// is non-empty the output keeps that file name (used when re-resolving an
|
||||
// existing task so a refreshed URL does not rename the file).
|
||||
func (f *Fetcher) resolvePlanLocked(ctx context.Context, keepName string) (*fetcherState, string, error) {
|
||||
content, finalURL, err := f.fetchText(ctx, f.playlistMethod(), f.meta.Req.URL)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("fetch playlist failed: %w", err)
|
||||
}
|
||||
baseURL, err := url.Parse(finalURL)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
mediaURL := finalURL
|
||||
if IsMasterPlaylist(content) {
|
||||
master, err := ParseMasterPlaylist(content, baseURL)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
best := PickBestVariant(master.Variants)
|
||||
if err := checkAudioRendition(master, best); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
variantContent, variantURL, err := f.fetchText(ctx, http.MethodGet, best.URI)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("fetch variant playlist failed: %w", err)
|
||||
}
|
||||
variantBase, err := url.Parse(variantURL)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
content = variantContent
|
||||
baseURL = variantBase
|
||||
mediaURL = variantURL
|
||||
}
|
||||
media, err := ParseMedia(content, baseURL)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := validateSupported(media); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
outputName := keepName
|
||||
if outputName == "" {
|
||||
outputName = DeriveOutputName(mediaURL, media)
|
||||
}
|
||||
state := &fetcherState{
|
||||
MediaURL: mediaURL,
|
||||
OutputName: outputName,
|
||||
Segments: media.Segments,
|
||||
}
|
||||
if f.config.PrefetchContentLength {
|
||||
f.prefetchSize(media.Segments)
|
||||
}
|
||||
return state, outputName, nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) Patch(req *base.Request, opts *base.Options) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.activeRun != nil {
|
||||
return errors.New("task is running; pause it before changing its settings")
|
||||
}
|
||||
if req != nil {
|
||||
cur := f.meta.Req
|
||||
if cur == nil {
|
||||
f.meta.Req = req
|
||||
cur = req
|
||||
}
|
||||
if req.URL != "" && req.URL != cur.URL {
|
||||
cur.URL = req.URL
|
||||
// The playlist URL changed (e.g. refreshed signed link): drop the
|
||||
// plan so the engine re-resolves on next start (it does when Res
|
||||
// is nil). Only explicitly supplied fields are applied, so a
|
||||
// header-only or rename-only Patch keeps the resolved plan and
|
||||
// the output path.
|
||||
f.dropPlanLocked()
|
||||
}
|
||||
if err := mergeReqExtra(cur, req); err != nil {
|
||||
return err
|
||||
}
|
||||
if req.Proxy != nil && cur.Proxy != req.Proxy {
|
||||
cur.Proxy = req.Proxy
|
||||
// Transport settings changed: rebuild the client before the
|
||||
// next run.
|
||||
f.client = nil
|
||||
}
|
||||
if len(req.Labels) > 0 {
|
||||
if cur.Labels == nil {
|
||||
cur.Labels = make(map[string]string, len(req.Labels))
|
||||
}
|
||||
for key, value := range req.Labels {
|
||||
cur.Labels[key] = value
|
||||
}
|
||||
}
|
||||
// SkipVerifyCert is a plain bool, so a partial update cannot tell an
|
||||
// absent field from an explicit false: it is intentionally not merged
|
||||
// (the HTTP protocol Patch ignores it as well).
|
||||
}
|
||||
if opts != nil {
|
||||
if f.meta.Opts == nil {
|
||||
f.meta.Opts = opts
|
||||
} else {
|
||||
if opts.Name != "" {
|
||||
f.meta.Opts.Name = opts.Name
|
||||
}
|
||||
if opts.Path != "" {
|
||||
f.meta.Opts.Path = opts.Path
|
||||
}
|
||||
}
|
||||
}
|
||||
f.reloadExtraLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeReqExtra merges the typed extra of incoming into cur following the
|
||||
// HTTP protocol semantics: Method/Body are overridden when non-empty and
|
||||
// headers are merged per key.
|
||||
func mergeReqExtra(cur *base.Request, incoming *base.Request) error {
|
||||
if incoming.Extra == nil {
|
||||
return nil
|
||||
}
|
||||
if err := base.ParseReqExtra[ReqExtra](incoming); err != nil {
|
||||
return err
|
||||
}
|
||||
inc, ok := incoming.Extra.(*ReqExtra)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if err := base.ParseReqExtra[ReqExtra](cur); err != nil {
|
||||
return err
|
||||
}
|
||||
target, ok := cur.Extra.(*ReqExtra)
|
||||
if !ok {
|
||||
target = &ReqExtra{}
|
||||
cur.Extra = target
|
||||
}
|
||||
if inc.Method != "" {
|
||||
target.Method = inc.Method
|
||||
}
|
||||
if inc.Body != "" {
|
||||
target.Body = inc.Body
|
||||
}
|
||||
if len(inc.Header) > 0 {
|
||||
if target.Header == nil {
|
||||
target.Header = make(map[string]string, len(inc.Header))
|
||||
}
|
||||
for key, value := range inc.Header {
|
||||
target.Header[key] = value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dropPlanLocked discards the resolved plan after a URL change. The staging
|
||||
// folder of the dropped plan is removed too: this only runs while the
|
||||
// fetcher is quiescent, so no run can still be writing into it.
|
||||
func (f *Fetcher) dropPlanLocked() {
|
||||
if f.state != nil && f.state.StagingDir != "" {
|
||||
os.RemoveAll(f.state.StagingDir)
|
||||
}
|
||||
f.state = nil
|
||||
f.meta.Res = nil
|
||||
}
|
||||
|
||||
// existingOutputNameLocked returns the output file name the task already
|
||||
// uses, so a re-resolve does not rename an in-progress download.
|
||||
func (f *Fetcher) existingOutputNameLocked() string {
|
||||
if f.meta.Opts != nil && f.meta.Opts.Name != "" {
|
||||
return f.meta.Opts.Name
|
||||
}
|
||||
if res := f.meta.Res; res != nil && len(res.Files) > 0 && res.Files[0] != nil {
|
||||
return res.Files[0].Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (f *Fetcher) Start() error {
|
||||
f.mu.Lock()
|
||||
if f.closed {
|
||||
f.mu.Unlock()
|
||||
return errors.New("hls fetcher is closed")
|
||||
}
|
||||
if f.activeRun != nil {
|
||||
f.mu.Unlock()
|
||||
return errors.New("hls fetcher already running")
|
||||
}
|
||||
if f.state == nil {
|
||||
if f.meta.Req == nil || f.meta.Res == nil {
|
||||
f.mu.Unlock()
|
||||
return errors.New("hls fetcher is not resolved")
|
||||
}
|
||||
// Crash window: the engine persists the task (with its resource)
|
||||
// before the first HLS state checkpoint lands, so a restored task
|
||||
// can have a resource but no plan. Rebuild the plan instead of
|
||||
// failing, keeping the existing output name.
|
||||
if f.client == nil {
|
||||
f.client = f.buildClient()
|
||||
}
|
||||
state, _, err := f.resolvePlanLocked(context.Background(), f.existingOutputNameLocked())
|
||||
if err != nil {
|
||||
f.mu.Unlock()
|
||||
return fmt.Errorf("re-resolve failed: %w", err)
|
||||
}
|
||||
f.state = state
|
||||
size := planTotalSize(state.Segments)
|
||||
if res := f.meta.Res; res != nil {
|
||||
res.Size = size
|
||||
if len(res.Files) > 0 && res.Files[0] != nil {
|
||||
res.Files[0].Size = size
|
||||
}
|
||||
}
|
||||
}
|
||||
// The client is built here, before any worker exists, so segment
|
||||
// goroutines never race on lazy initialization.
|
||||
if f.client == nil {
|
||||
f.client = f.buildClient()
|
||||
}
|
||||
if f.meta.Res == nil {
|
||||
// A restored plan without a resource cannot determine its output
|
||||
// path; the engine re-resolves when Res is nil.
|
||||
f.mu.Unlock()
|
||||
return errors.New("hls fetcher is not resolved")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
run := &hlsRun{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
done: make(chan struct{}),
|
||||
segs: f.state.Segments,
|
||||
extra: copyReqExtra(f.extra),
|
||||
client: f.client,
|
||||
outputPath: f.meta.SingleFilepath(),
|
||||
stagingID: f.state.StagingID,
|
||||
planHash: planFingerprint(f.state),
|
||||
tempDir: f.resolveStagingDirLocked(),
|
||||
keyCache: make(map[string][]byte),
|
||||
journal: make(map[int64]int64),
|
||||
}
|
||||
// Prepare staging and the completed-segment counters BEFORE publishing
|
||||
// the run: Stats/Progress may observe it immediately afterwards and must
|
||||
// never read half-initialized plain fields.
|
||||
if err := f.prepareRun(run); err != nil {
|
||||
f.mu.Unlock()
|
||||
run.cancel()
|
||||
close(run.done)
|
||||
return err
|
||||
}
|
||||
f.activeRun = run
|
||||
f.mu.Unlock()
|
||||
|
||||
f.launchWorkers(run)
|
||||
go f.supervise(run)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Pause stops all segment downloads synchronously. It waits for the queue
|
||||
// producer, every worker AND the supervisor (including a merge in flight)
|
||||
// before returning, so a Store right after Pause sees a consistent state.
|
||||
func (f *Fetcher) Pause() error {
|
||||
f.mu.Lock()
|
||||
run := f.activeRun
|
||||
f.mu.Unlock()
|
||||
if run == nil {
|
||||
return nil
|
||||
}
|
||||
run.cancel()
|
||||
<-run.done
|
||||
f.clearActiveRun(run)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close pauses any active run and only removes the staging folder once every
|
||||
// run goroutine has stopped writing to it.
|
||||
func (f *Fetcher) Close() error {
|
||||
f.mu.Lock()
|
||||
f.closed = true
|
||||
run := f.activeRun
|
||||
tempDir := ""
|
||||
if f.state != nil {
|
||||
tempDir = f.resolveStagingDirLocked()
|
||||
}
|
||||
f.mu.Unlock()
|
||||
|
||||
if run != nil {
|
||||
run.cancel()
|
||||
<-run.done
|
||||
f.clearActiveRun(run)
|
||||
}
|
||||
if f.impSession != nil {
|
||||
f.impSession.Clear()
|
||||
}
|
||||
if tempDir != "" {
|
||||
os.RemoveAll(tempDir)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearActiveRun detaches a finished run and keeps its counters as the last
|
||||
// known values for idle Stats/Progress reporting.
|
||||
func (f *Fetcher) clearActiveRun(run *hlsRun) {
|
||||
f.mu.Lock()
|
||||
if f.activeRun == run {
|
||||
f.activeRun = nil
|
||||
f.lastStats = run.statsSnapshot()
|
||||
f.lastDownloaded = run.downloaded.Load()
|
||||
}
|
||||
f.mu.Unlock()
|
||||
}
|
||||
|
||||
// Progress reports final-output bytes stored on disk for the single file.
|
||||
func (f *Fetcher) Progress() fetcher.Progress {
|
||||
f.mu.Lock()
|
||||
run := f.activeRun
|
||||
if run != nil {
|
||||
f.mu.Unlock()
|
||||
return fetcher.Progress{run.downloaded.Load()}
|
||||
}
|
||||
last := f.lastDownloaded
|
||||
f.mu.Unlock()
|
||||
return fetcher.Progress{last}
|
||||
}
|
||||
|
||||
func (f *Fetcher) Stats() *fetcher.Stats {
|
||||
f.mu.Lock()
|
||||
run := f.activeRun
|
||||
state := f.state
|
||||
if run != nil {
|
||||
f.mu.Unlock()
|
||||
snap := run.statsSnapshot()
|
||||
return &fetcher.Stats{Snapshot: &snap}
|
||||
}
|
||||
snap := f.lastStats
|
||||
f.mu.Unlock()
|
||||
if snap.SegmentsTotal == 0 && state != nil {
|
||||
snap.SegmentsTotal = len(state.Segments)
|
||||
}
|
||||
return &fetcher.Stats{Snapshot: &snap}
|
||||
}
|
||||
|
||||
// supervise waits for the segment workers, then merges. A canceled context
|
||||
// exits silently (the engine is pausing), everything else is reported through
|
||||
// DoneCh exactly once per run.
|
||||
func (f *Fetcher) supervise(run *hlsRun) {
|
||||
run.wg.Wait()
|
||||
defer close(run.done)
|
||||
|
||||
if run.ctx.Err() != nil {
|
||||
f.clearActiveRun(run)
|
||||
return
|
||||
}
|
||||
var err error
|
||||
if failed := run.failCount.Load(); failed > 0 {
|
||||
err = fmt.Errorf("%d segment(s) failed to download", failed)
|
||||
} else {
|
||||
err = f.merge(run)
|
||||
if err == nil {
|
||||
if run.ctx.Err() != nil {
|
||||
// Paused while the merge was in flight: keep the staged
|
||||
// segments and exit silently; the next run re-merges.
|
||||
f.clearActiveRun(run)
|
||||
return
|
||||
}
|
||||
os.RemoveAll(run.tempDir)
|
||||
}
|
||||
}
|
||||
f.clearActiveRun(run)
|
||||
select {
|
||||
case f.DoneCh <- err:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// prepareRun prepares the staging folder and loads the completed-segment
|
||||
// journal into run.baseBytes/baseDone/pending. It performs no goroutine
|
||||
// launches and no context-bound work, so it runs safely under f.mu before
|
||||
// the run is published.
|
||||
func (f *Fetcher) prepareRun(run *hlsRun) error {
|
||||
if err := os.MkdirAll(run.tempDir, 0777); err != nil {
|
||||
return err
|
||||
}
|
||||
run.journalPath = filepath.Join(run.tempDir, "journal.json")
|
||||
if err := loadJournal(run); err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range run.segs {
|
||||
if size, ok := run.journal[int64(i)]; ok {
|
||||
if info, err := os.Stat(segmentPath(run.tempDir, int64(i))); err == nil && info.Size() == size {
|
||||
run.baseBytes += size
|
||||
run.baseDone++
|
||||
continue
|
||||
}
|
||||
}
|
||||
run.pending = append(run.pending, int64(i))
|
||||
}
|
||||
run.downloaded.Store(run.baseBytes)
|
||||
return nil
|
||||
}
|
||||
|
||||
// launchWorkers starts the queue producer and the segment download workers
|
||||
// for a published run. Every goroutine registers itself in run.wg so Pause
|
||||
// waits for all of them.
|
||||
func (f *Fetcher) launchWorkers(run *hlsRun) {
|
||||
if len(run.pending) == 0 {
|
||||
return
|
||||
}
|
||||
queue := make(chan int64)
|
||||
run.wg.Add(1)
|
||||
go func() {
|
||||
defer run.wg.Done()
|
||||
defer close(queue)
|
||||
for _, idx := range run.pending {
|
||||
select {
|
||||
case <-run.ctx.Done():
|
||||
return
|
||||
case queue <- idx:
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
workers := min(f.config.SegmentConnections, len(run.pending))
|
||||
for i := 0; i < workers; i++ {
|
||||
run.wg.Add(1)
|
||||
go func() {
|
||||
defer run.wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-run.ctx.Done():
|
||||
return
|
||||
case idx, ok := <-queue:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := f.downloadSegment(run, idx); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
run.failCount.Add(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Fetcher) downloadSegment(run *hlsRun, idx int64) error {
|
||||
seg := run.segs[idx]
|
||||
data, err := f.fetchSegmentData(run, seg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if seg.Key != nil {
|
||||
key, err := f.fetchKey(run, seg.Key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
iv := seg.Key.IV
|
||||
if iv == nil {
|
||||
iv = sequenceIV(seg.Sequence)
|
||||
}
|
||||
data, err = decryptAES128(data, key, iv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := storeSegment(run, idx, data); err != nil {
|
||||
return err
|
||||
}
|
||||
run.doneCount.Add(1)
|
||||
run.downloaded.Add(int64(len(data)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchSegmentData downloads one segment with retries, returning its (still
|
||||
// encrypted) content.
|
||||
func (f *Fetcher) fetchSegmentData(run *hlsRun, seg *Segment) ([]byte, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= f.config.MaxRetries; attempt++ {
|
||||
if err := run.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if attempt > 0 {
|
||||
select {
|
||||
case <-run.ctx.Done():
|
||||
return nil, run.ctx.Err()
|
||||
case <-time.After(time.Duration(attempt) * time.Second):
|
||||
}
|
||||
}
|
||||
data, err := f.tryGetSegment(run, seg)
|
||||
if err == nil {
|
||||
return data, nil
|
||||
}
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return nil, err
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (f *Fetcher) tryGetSegment(run *hlsRun, seg *Segment) ([]byte, error) {
|
||||
resp, err := f.do(run.ctx, run.client, run.extra, http.MethodGet, seg.URI, seg.Byterange)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("segment %s: unexpected status %d", seg.URI, resp.StatusCode)
|
||||
}
|
||||
reader := &stallReader{rc: resp.Body, timeout: f.timeout()}
|
||||
var buf bytes.Buffer
|
||||
chunk := make([]byte, 64<<10)
|
||||
for {
|
||||
n, err := reader.Read(chunk)
|
||||
if n > 0 {
|
||||
buf.Write(chunk[:n])
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if seg.Byterange != nil && int64(buf.Len()) != seg.Byterange.Length {
|
||||
return nil, fmt.Errorf("segment %s: size mismatch, got %d want %d", seg.URI, buf.Len(), seg.Byterange.Length)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) fetchKey(run *hlsRun, key *Key) ([]byte, error) {
|
||||
run.keyCacheMu.Lock()
|
||||
if cached, ok := run.keyCache[key.URI]; ok {
|
||||
run.keyCacheMu.Unlock()
|
||||
return cached, nil
|
||||
}
|
||||
run.keyCacheMu.Unlock()
|
||||
|
||||
var data []byte
|
||||
var err error
|
||||
for attempt := 0; attempt <= f.config.MaxRetries; attempt++ {
|
||||
if err = run.ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if attempt > 0 {
|
||||
select {
|
||||
case <-run.ctx.Done():
|
||||
return nil, run.ctx.Err()
|
||||
case <-time.After(time.Duration(attempt) * time.Second):
|
||||
}
|
||||
}
|
||||
resp, reqErr := f.do(run.ctx, run.client, run.extra, http.MethodGet, key.URI, nil)
|
||||
if reqErr != nil {
|
||||
err = reqErr
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
resp.Body.Close()
|
||||
err = fmt.Errorf("key %s: unexpected status %d", key.URI, resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
data, err = io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
resp.Body.Close()
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) != 16 {
|
||||
return nil, fmt.Errorf("key %s: invalid key size %d", key.URI, len(data))
|
||||
}
|
||||
run.keyCacheMu.Lock()
|
||||
run.keyCache[key.URI] = data
|
||||
run.keyCacheMu.Unlock()
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// merge concatenates the downloaded segments in plan order into the final
|
||||
// output file. Segments are stored decrypted, so this is pure I/O. The merge
|
||||
// writes to a unique temporary file next to the target, checks write and
|
||||
// close errors, and only then replaces the target: a failed merge leaves the
|
||||
// staged segments intact so it can be retried, and the unique part name keeps
|
||||
// concurrent tasks that share an output path from clobbering each other. The
|
||||
// final size is published through Progress: the engine backfills Res.Size
|
||||
// from Progress().TotalDownloaded() on completion.
|
||||
func (f *Fetcher) merge(run *hlsRun) error {
|
||||
target := run.outputPath
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0777); err != nil {
|
||||
return err
|
||||
}
|
||||
partPath := target + tempDirPrefix + run.stagingID + ".part"
|
||||
out, err := os.Create(partPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var total int64
|
||||
for i := range run.segs {
|
||||
data, err := os.ReadFile(segmentPath(run.tempDir, int64(i)))
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(partPath)
|
||||
return fmt.Errorf("read segment %d failed: %w", i, err)
|
||||
}
|
||||
if _, err := out.Write(data); err != nil {
|
||||
out.Close()
|
||||
os.Remove(partPath)
|
||||
return err
|
||||
}
|
||||
total += int64(len(data))
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
os.Remove(partPath)
|
||||
return err
|
||||
}
|
||||
// Remove-then-rename keeps this idempotent: if a previous merge already
|
||||
// renamed its output before a crash lost the bookkeeping, re-merging
|
||||
// must still succeed.
|
||||
if err := os.Remove(target); err != nil && !os.IsNotExist(err) {
|
||||
os.Remove(partPath)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(partPath, target); err != nil {
|
||||
os.Remove(partPath)
|
||||
return err
|
||||
}
|
||||
run.downloaded.Store(total)
|
||||
return nil
|
||||
}
|
||||
|
||||
func storeSegment(run *hlsRun, idx int64, data []byte) error {
|
||||
partPath := segmentPath(run.tempDir, idx) + ".part"
|
||||
if err := os.WriteFile(partPath, data, 0666); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(partPath, segmentPath(run.tempDir, idx)); err != nil {
|
||||
return err
|
||||
}
|
||||
run.journalMu.Lock()
|
||||
defer run.journalMu.Unlock()
|
||||
run.journal[idx] = int64(len(data))
|
||||
return saveJournal(run)
|
||||
}
|
||||
|
||||
func segmentPath(tempDir string, idx int64) string {
|
||||
return filepath.Join(tempDir, "seg-"+strconv.FormatInt(idx, 10))
|
||||
}
|
||||
|
||||
// resolveStagingDirLocked returns the staging folder of the current plan,
|
||||
// deriving and pinning it on first use so later output renames or moves do
|
||||
// not orphan the staged segments. It lives next to the task output, which is
|
||||
// exactly opts.Path for a single-file task. Must be called with f.mu held.
|
||||
func (f *Fetcher) resolveStagingDirLocked() string {
|
||||
if f.state.StagingDir == "" {
|
||||
if f.state.StagingID == "" {
|
||||
// Legacy state (or a hand-crafted plan): migrate to a fresh task
|
||||
// owned identity. The old URL-hash folder is deliberately not
|
||||
// reused: its contents cannot be proven to belong to this plan,
|
||||
// and sharing it with another same-URL task is exactly the bug
|
||||
// being fixed. It is left behind as orphaned temp data.
|
||||
f.state.StagingID = randomStagingID()
|
||||
}
|
||||
dir := ""
|
||||
if f.meta.Opts != nil {
|
||||
dir = f.meta.Opts.Path
|
||||
}
|
||||
f.state.StagingDir = filepath.Join(dir, tempDirPrefix+f.state.StagingID)
|
||||
}
|
||||
return f.state.StagingDir
|
||||
}
|
||||
|
||||
// prefetchSize probes segment Content-Length via HEAD. Any failure resets all
|
||||
// sizes to zero: a partial size estimate would corrupt progress reporting.
|
||||
func (f *Fetcher) prefetchSize(segments []*Segment) {
|
||||
var need []*Segment
|
||||
for _, seg := range segments {
|
||||
if seg.Size <= 0 {
|
||||
need = append(need, seg)
|
||||
}
|
||||
}
|
||||
if len(need) == 0 || len(need) > maxPrefetchSizeSegments {
|
||||
return
|
||||
}
|
||||
|
||||
workerCount := min(f.config.SegmentConnections, 8)
|
||||
var failed atomic.Bool
|
||||
queue := make(chan *Segment)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < workerCount; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for seg := range queue {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), f.timeout())
|
||||
seg.Size = f.headContentLength(ctx, seg.URI)
|
||||
cancel()
|
||||
if seg.Size <= 0 {
|
||||
failed.Store(true)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
for _, seg := range need {
|
||||
queue <- seg
|
||||
}
|
||||
close(queue)
|
||||
wg.Wait()
|
||||
if failed.Load() {
|
||||
for _, seg := range segments {
|
||||
seg.Size = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Fetcher) headContentLength(ctx context.Context, u string) int64 {
|
||||
req, err := buildRequest(ctx, f.extra, http.MethodHead, u, nil, nil)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
resp, err := f.client.Do(req)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return 0
|
||||
}
|
||||
return resp.ContentLength
|
||||
}
|
||||
|
||||
func (f *Fetcher) fetchText(ctx context.Context, method, u string) (string, string, error) {
|
||||
resp, err := f.do(ctx, f.client, f.extra, method, u, nil)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", "", fmt.Errorf("unexpected status %d for %s", resp.StatusCode, u)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(&stallReader{rc: resp.Body, timeout: f.timeout()}, maxPlaylistSize))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return string(data), resp.Request.URL.String(), nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) do(ctx context.Context, client *http.Client, extra *ReqExtra, method, u string, byterange *ByteRange) (*http.Response, error) {
|
||||
if method == "" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
req, err := buildRequest(ctx, extra, method, u, nil, byterange)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
func (f *Fetcher) playlistMethod() string {
|
||||
if f.extra != nil && f.extra.Method != "" {
|
||||
return f.extra.Method
|
||||
}
|
||||
return http.MethodGet
|
||||
}
|
||||
|
||||
// planTotalSize sums the known segment sizes. It returns 0 unless every
|
||||
// segment is unencrypted with a known size: ciphertext lengths overstate the
|
||||
// final output (CBC padding is removed on decrypt), and partial estimates
|
||||
// would make progress stall below 100%. For indeterminate plans the engine
|
||||
// backfills Res.Size from Progress().TotalDownloaded() when the task
|
||||
// completes (downloader.go watch).
|
||||
func planTotalSize(segments []*Segment) int64 {
|
||||
var total int64
|
||||
for _, seg := range segments {
|
||||
if seg.Key != nil {
|
||||
return 0
|
||||
}
|
||||
if seg.Size <= 0 {
|
||||
return 0
|
||||
}
|
||||
total += seg.Size
|
||||
}
|
||||
return total
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package hls
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
)
|
||||
|
||||
type FetcherManager struct {
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Name() string {
|
||||
return "hls"
|
||||
}
|
||||
|
||||
// Filters registers the .m3u8 extension. The manager must be registered BEFORE
|
||||
// the http manager in the downloader config: protocol routing picks the first
|
||||
// manager whose filter matches, and the http filter claims every http(s) URL.
|
||||
func (fm *FetcherManager) Filters() []*fetcher.SchemeFilter {
|
||||
return []*fetcher.SchemeFilter{
|
||||
{
|
||||
Type: fetcher.FilterTypeFile,
|
||||
Pattern: "M3U8",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Build() fetcher.Fetcher {
|
||||
return &Fetcher{}
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) ParseName(u string) string {
|
||||
parsed, err := url.Parse(u)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
name := path.Base(parsed.Path)
|
||||
name = strings.TrimSuffix(name, path.Ext(name))
|
||||
if name == "" || name == "/" || name == "." {
|
||||
name = parsed.Hostname()
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) AutoRename() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) DefaultConfig() any {
|
||||
return &config{
|
||||
SegmentConnections: defaultSegmentConnections,
|
||||
MaxRetries: defaultMaxRetries,
|
||||
TimeoutSeconds: defaultTimeoutSeconds,
|
||||
PrefetchContentLength: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Store(f fetcher.Fetcher) (data any, err error) {
|
||||
_f := f.(*Fetcher)
|
||||
_f.mu.Lock()
|
||||
defer _f.mu.Unlock()
|
||||
if _f.state == nil {
|
||||
return nil, nil
|
||||
}
|
||||
// The engine's periodic checkpoint calls Store without holding the task
|
||||
// lock and serializes the returned value afterwards, while Resolve/Patch/
|
||||
// Start mutate the live state under the fetcher mutex: return a snapshot
|
||||
// copy taken under that mutex, mirroring the HTTP protocol's Store.
|
||||
return cloneState(_f.state), nil
|
||||
}
|
||||
|
||||
func cloneState(state *fetcherState) *fetcherState {
|
||||
cloned := *state
|
||||
cloned.Segments = make([]*Segment, len(state.Segments))
|
||||
for i, seg := range state.Segments {
|
||||
s := *seg
|
||||
cloned.Segments[i] = &s
|
||||
}
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Restore() (v any, f func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher) {
|
||||
return &fetcherState{}, func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher {
|
||||
fb := &FetcherManager{}
|
||||
restored := fb.Build().(*Fetcher)
|
||||
restored.meta = meta
|
||||
if state, ok := v.(*fetcherState); ok && state.MediaURL != "" {
|
||||
restored.state = state
|
||||
}
|
||||
// Setup is invoked by the engine (restoreFetcher) after this builder
|
||||
// returns; ReqExtra parsing happens there as well.
|
||||
return restored
|
||||
}
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Close() error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,865 @@
|
||||
package hls
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
func pkcs7PadForTest(data []byte, blockSize int) []byte {
|
||||
pad := blockSize - len(data)%blockSize
|
||||
return append(data, bytes.Repeat([]byte{byte(pad)}, pad)...)
|
||||
}
|
||||
|
||||
func encryptSegment(plain, key, iv []byte) []byte {
|
||||
padded := pkcs7PadForTest(plain, aes.BlockSize)
|
||||
block, _ := aes.NewCipher(key)
|
||||
encrypted := make([]byte, len(padded))
|
||||
cipher.NewCBCEncrypter(block, iv).CryptBlocks(encrypted, padded)
|
||||
return encrypted
|
||||
}
|
||||
|
||||
// serveContentWithReferer is a helper handler that enforces an optional
|
||||
// Referer requirement and reports request counts.
|
||||
type cdnHandler struct {
|
||||
mux *http.ServeMux
|
||||
requireReferer string
|
||||
requests atomic.Int64
|
||||
}
|
||||
|
||||
func (h *cdnHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.requests.Add(1)
|
||||
if h.requireReferer != "" && r.Header.Get("Referer") != h.requireReferer {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
h.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func newTestFetcher(t *testing.T, cfg config) *Fetcher {
|
||||
t.Helper()
|
||||
fm := &FetcherManager{}
|
||||
f := fm.Build().(*Fetcher)
|
||||
ctl := controller.NewController()
|
||||
ctl.GetConfig = func(v any) {
|
||||
if c, ok := v.(*config); ok {
|
||||
*c = cfg
|
||||
}
|
||||
}
|
||||
f.Setup(ctl)
|
||||
return f
|
||||
}
|
||||
|
||||
func testConfig() config {
|
||||
return config{
|
||||
SegmentConnections: 4,
|
||||
MaxRetries: 2,
|
||||
TimeoutSeconds: 5,
|
||||
PrefetchContentLength: true,
|
||||
}
|
||||
}
|
||||
|
||||
func mustResolve(t *testing.T, f *Fetcher, playlistURL, referer string) {
|
||||
t.Helper()
|
||||
req := &base.Request{URL: playlistURL}
|
||||
if referer != "" {
|
||||
req.Extra = &ReqExtra{Header: map[string]string{"Referer": referer}}
|
||||
}
|
||||
f.Meta().Opts = &base.Options{Path: t.TempDir()}
|
||||
if err := f.Resolve(req); err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustRun(t *testing.T, f *Fetcher) {
|
||||
t.Helper()
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatalf("wait: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func statsOf(t *testing.T, f *Fetcher) Stats {
|
||||
t.Helper()
|
||||
s, ok := f.Stats().Snapshot.(*Stats)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected stats snapshot type %T", f.Stats().Snapshot)
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
func readOutput(t *testing.T, f *Fetcher) []byte {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(f.meta.SingleFilepath())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestFetcherE2E_MasterTS(t *testing.T) {
|
||||
seg := func(i int) []byte {
|
||||
return []byte(fmt.Sprintf("SEGMENT-%02d-PAYLOAD", i))
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/master.m3u8", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("#EXTM3U\n" +
|
||||
"#EXT-X-STREAM-INF:BANDWIDTH=800000\nv1/index.m3u8\n" +
|
||||
"#EXT-X-STREAM-INF:BANDWIDTH=2000000\nv2/index.m3u8\n"))
|
||||
})
|
||||
mux.HandleFunc("/v1/index.m3u8", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("#EXTM3U\n#EXTINF:4,\nlow-0.ts\n#EXT-X-ENDLIST\n"))
|
||||
})
|
||||
mux.HandleFunc("/v2/index.m3u8", func(w http.ResponseWriter, r *http.Request) {
|
||||
var b strings.Builder
|
||||
b.WriteString("#EXTM3U\n")
|
||||
for i := 0; i < 5; i++ {
|
||||
b.WriteString(fmt.Sprintf("#EXTINF:4,\nseg-%d.ts\n", i))
|
||||
}
|
||||
b.WriteString("#EXT-X-ENDLIST\n")
|
||||
w.Write([]byte(b.String()))
|
||||
})
|
||||
for i := 0; i < 5; i++ {
|
||||
content := seg(i)
|
||||
path := "/v2/seg-" + strconv.Itoa(i) + ".ts"
|
||||
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(content)))
|
||||
w.Write(content)
|
||||
})
|
||||
}
|
||||
server := httptest.NewServer(&cdnHandler{mux: mux})
|
||||
defer server.Close()
|
||||
|
||||
f := newTestFetcher(t, testConfig())
|
||||
mustResolve(t, f, server.URL+"/master.m3u8", "")
|
||||
// Master resolution picked the 2000000 variant.
|
||||
if f.state.MediaURL != server.URL+"/v2/index.m3u8" {
|
||||
t.Fatalf("wrong variant picked: %s", f.state.MediaURL)
|
||||
}
|
||||
// HEAD prefetch should have computed the exact total size.
|
||||
if f.meta.Res.Size != int64(5*len(seg(0))) {
|
||||
t.Fatalf("prefetched size wrong: %d", f.meta.Res.Size)
|
||||
}
|
||||
mustRun(t, f)
|
||||
|
||||
var want []byte
|
||||
for i := 0; i < 5; i++ {
|
||||
want = append(want, seg(i)...)
|
||||
}
|
||||
got := readOutput(t, f)
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Errorf("merged output mismatch: %q", got)
|
||||
}
|
||||
// The staging folder must be cleaned up after a successful merge; only
|
||||
// the output file remains in the task directory.
|
||||
if entries, _ := os.ReadDir(filepath.Dir(f.meta.SingleFilepath())); len(entries) != 1 {
|
||||
for _, e := range entries {
|
||||
t.Logf("leftover entry: %s", e.Name())
|
||||
}
|
||||
t.Errorf("staging dir should be cleaned up after merge")
|
||||
}
|
||||
if f.meta.Res.Size != int64(len(want)) {
|
||||
t.Errorf("final size wrong: %d", f.meta.Res.Size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcherE2E_RefererEnforced(t *testing.T) {
|
||||
page := "https://player.example.com/watch/123"
|
||||
var playlist strings.Builder
|
||||
playlist.WriteString("#EXTM3U\n")
|
||||
segments := make(map[string][]byte)
|
||||
for i := 0; i < 3; i++ {
|
||||
name := fmt.Sprintf("seg-%d.ts", i)
|
||||
segments["/"+name] = []byte(fmt.Sprintf("DATA-%d", i))
|
||||
playlist.WriteString(fmt.Sprintf("#EXTINF:4,\n%s\n", name))
|
||||
}
|
||||
playlist.WriteString("#EXT-X-ENDLIST\n")
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(playlist.String()))
|
||||
})
|
||||
for path, content := range segments {
|
||||
c := content
|
||||
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write(c)
|
||||
})
|
||||
}
|
||||
server := httptest.NewServer(&cdnHandler{mux: mux, requireReferer: page})
|
||||
defer server.Close()
|
||||
|
||||
// Without Referer the resolve itself must fail (403 on the playlist).
|
||||
blocked := newTestFetcher(t, testConfig())
|
||||
req := &base.Request{URL: server.URL + "/video.m3u8"}
|
||||
blocked.Meta().Opts = &base.Options{Path: t.TempDir()}
|
||||
if err := blocked.Resolve(req); err == nil {
|
||||
t.Fatal("resolve without Referer should fail")
|
||||
}
|
||||
|
||||
// With the Referer set through the task extra headers it must succeed.
|
||||
allowed := newTestFetcher(t, testConfig())
|
||||
allowedReq := &base.Request{
|
||||
URL: server.URL + "/video.m3u8",
|
||||
Extra: &ReqExtra{Header: map[string]string{"Referer": page}},
|
||||
}
|
||||
allowed.Meta().Opts = &base.Options{Path: t.TempDir()}
|
||||
if err := allowed.Resolve(allowedReq); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustRun(t, allowed)
|
||||
want := []byte("DATA-0DATA-1DATA-2")
|
||||
if !bytes.Equal(readOutput(t, allowed), want) {
|
||||
t.Error("output mismatch with Referer download")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcherE2E_AES128KeyRotation(t *testing.T) {
|
||||
key1 := []byte("0123456789abcdef")
|
||||
key2 := []byte("fedcba9876543210")
|
||||
iv1 := []byte("aaaaaaaaaaaaaaaa")
|
||||
plain1 := []byte("ENCRYPTED-SEGMENT-ONE!!") // padded on encrypt
|
||||
plain2 := []byte("ENCRYPTED-SEGMENT-TWO!!")
|
||||
// seg-2 has no explicit IV: derived from media sequence 1.
|
||||
enc1 := encryptSegment(plain1, key1, iv1)
|
||||
enc2 := encryptSegment(plain2, key2, sequenceIV(1))
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("#EXTM3U\n" +
|
||||
"#EXT-X-MEDIA-SEQUENCE:0\n" +
|
||||
"#EXT-X-KEY:METHOD=AES-128,URI=\"key-1.bin\",IV=0x" + hexEncode(iv1) + "\n" +
|
||||
"#EXTINF:5,\nseg-1.ts\n" +
|
||||
"#EXT-X-KEY:METHOD=AES-128,URI=\"key-2.bin\"\n" +
|
||||
"#EXTINF:5,\nseg-2.ts\n" +
|
||||
"#EXT-X-ENDLIST\n"))
|
||||
})
|
||||
mux.HandleFunc("/key-1.bin", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write(key1)
|
||||
})
|
||||
mux.HandleFunc("/key-2.bin", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write(key2)
|
||||
})
|
||||
mux.HandleFunc("/seg-1.ts", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write(enc1)
|
||||
})
|
||||
mux.HandleFunc("/seg-2.ts", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write(enc2)
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
f := newTestFetcher(t, testConfig())
|
||||
mustResolve(t, f, server.URL+"/video.m3u8", "")
|
||||
// Ciphertext sizes overstate the final output after decryption, so an
|
||||
// encrypted plan must report an indeterminate size.
|
||||
if f.meta.Res.Size != 0 {
|
||||
t.Errorf("encrypted plan should report size 0, got %d", f.meta.Res.Size)
|
||||
}
|
||||
mustRun(t, f)
|
||||
|
||||
want := append(append([]byte{}, plain1...), plain2...)
|
||||
if !bytes.Equal(readOutput(t, f), want) {
|
||||
t.Errorf("decrypted output mismatch: %q", readOutput(t, f))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcherE2E_ByteRange(t *testing.T) {
|
||||
data := []byte("0123456789ABCDEFGHIJ") // 20 bytes
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("#EXTM3U\n" +
|
||||
"#EXT-X-BYTERANGE:10@0\nblob.bin\n" +
|
||||
"#EXT-X-BYTERANGE:10\nblob.bin\n" +
|
||||
"#EXT-X-ENDLIST\n"))
|
||||
})
|
||||
mux.HandleFunc("/blob.bin", func(w http.ResponseWriter, r *http.Request) {
|
||||
rng := r.Header.Get("Range")
|
||||
if rng == "" {
|
||||
w.Write(data)
|
||||
return
|
||||
}
|
||||
// bytes=start-end
|
||||
var start, end int
|
||||
fmt.Sscanf(rng, "bytes=%d-%d", &start, &end)
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(data)))
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
w.Write(data[start : end+1])
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
f := newTestFetcher(t, testConfig())
|
||||
mustResolve(t, f, server.URL+"/video.m3u8", "")
|
||||
if f.meta.Res.Size != 20 {
|
||||
t.Fatalf("byterange sizes should be known from the playlist, got %d", f.meta.Res.Size)
|
||||
}
|
||||
mustRun(t, f)
|
||||
if !bytes.Equal(readOutput(t, f), data) {
|
||||
t.Errorf("byterange merge mismatch: %q", readOutput(t, f))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcherE2E_PauseResume(t *testing.T) {
|
||||
const total = 8
|
||||
var served atomic.Int64
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) {
|
||||
var b strings.Builder
|
||||
b.WriteString("#EXTM3U\n")
|
||||
for i := 0; i < total; i++ {
|
||||
b.WriteString(fmt.Sprintf("#EXTINF:4,\nseg-%d.ts\n", i))
|
||||
}
|
||||
b.WriteString("#EXT-X-ENDLIST\n")
|
||||
w.Write([]byte(b.String()))
|
||||
})
|
||||
segments := make(map[int][]byte)
|
||||
for i := 0; i < total; i++ {
|
||||
segments[i] = []byte(fmt.Sprintf("SLOW-SEGMENT-%02d", i))
|
||||
}
|
||||
for i := 0; i < total; i++ {
|
||||
idx := i
|
||||
mux.HandleFunc(fmt.Sprintf("/seg-%d.ts", i), func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
served.Add(1)
|
||||
w.Write(segments[idx])
|
||||
})
|
||||
}
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
cfg := testConfig()
|
||||
cfg.SegmentConnections = 2
|
||||
cfg.PrefetchContentLength = false
|
||||
f := newTestFetcher(t, cfg)
|
||||
mustResolve(t, f, server.URL+"/video.m3u8", "")
|
||||
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Let a couple of segments finish, then pause.
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
if err := f.Pause(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doneBefore := statsOf(t, f).SegmentsDone
|
||||
if doneBefore == 0 {
|
||||
t.Fatal("expected some segments to complete before pause")
|
||||
}
|
||||
if doneBefore == total {
|
||||
t.Fatal("all segments finished before pause, test is not exercising resume")
|
||||
}
|
||||
|
||||
// Resume must finish the rest and produce the full output.
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var want []byte
|
||||
for i := 0; i < total; i++ {
|
||||
want = append(want, segments[i]...)
|
||||
}
|
||||
if !bytes.Equal(readOutput(t, f), want) {
|
||||
t.Errorf("resumed output mismatch: %q", readOutput(t, f))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcherE2E_StoreRestoreResume(t *testing.T) {
|
||||
const total = 6
|
||||
var served atomic.Int64
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) {
|
||||
var b strings.Builder
|
||||
b.WriteString("#EXTM3U\n")
|
||||
for i := 0; i < total; i++ {
|
||||
b.WriteString(fmt.Sprintf("#EXTINF:4,\nseg-%d.ts\n", i))
|
||||
}
|
||||
b.WriteString("#EXT-X-ENDLIST\n")
|
||||
w.Write([]byte(b.String()))
|
||||
})
|
||||
segments := make(map[int][]byte)
|
||||
for i := 0; i < total; i++ {
|
||||
segments[i] = []byte(fmt.Sprintf("RESTORE-SEGMENT-%02d", i))
|
||||
}
|
||||
for i := 0; i < total; i++ {
|
||||
idx := i
|
||||
mux.HandleFunc(fmt.Sprintf("/seg-%d.ts", i), func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
served.Add(1)
|
||||
w.Write(segments[idx])
|
||||
})
|
||||
}
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
cfg := testConfig()
|
||||
cfg.SegmentConnections = 2
|
||||
cfg.PrefetchContentLength = false
|
||||
|
||||
f := newTestFetcher(t, cfg)
|
||||
mustResolve(t, f, server.URL+"/video.m3u8", "")
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(350 * time.Millisecond)
|
||||
if err := f.Pause(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doneBefore := statsOf(t, f).SegmentsDone
|
||||
if doneBefore == 0 {
|
||||
t.Fatal("expected some segments to finish before simulated restart")
|
||||
}
|
||||
|
||||
// Simulate an app restart: persist, then restore into a fresh fetcher.
|
||||
fm := &FetcherManager{}
|
||||
state, err := fm.Store(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state == nil {
|
||||
t.Fatal("state should be persisted")
|
||||
}
|
||||
ctl := controller.NewController()
|
||||
ctl.GetConfig = func(v any) {
|
||||
if c, ok := v.(*config); ok {
|
||||
*c = cfg
|
||||
}
|
||||
}
|
||||
holder := fm.Build().(*Fetcher)
|
||||
holder.Setup(ctl)
|
||||
holder.meta = &fetcher.FetcherMeta{
|
||||
Req: f.meta.Req,
|
||||
Opts: f.meta.Opts,
|
||||
Res: f.meta.Res,
|
||||
}
|
||||
_, builder := fm.Restore()
|
||||
rf := builder(holder.meta, state).(*Fetcher)
|
||||
// The engine calls Setup on the builder result (restoreFetcher).
|
||||
rf.Setup(ctl)
|
||||
if rf.state == nil || len(rf.state.Segments) != total {
|
||||
t.Fatal("restored state missing segment plan")
|
||||
}
|
||||
|
||||
if err := rf.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := rf.Wait(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if done := statsOf(t, rf).SegmentsDone; done != total {
|
||||
t.Errorf("want %d done, got %d", total, done)
|
||||
}
|
||||
var want []byte
|
||||
for i := 0; i < total; i++ {
|
||||
want = append(want, segments[i]...)
|
||||
}
|
||||
if !bytes.Equal(readOutput(t, rf), want) {
|
||||
t.Errorf("restored output mismatch: %q", readOutput(t, rf))
|
||||
}
|
||||
}
|
||||
|
||||
func hexEncode(b []byte) string {
|
||||
const digits = "0123456789abcdef"
|
||||
out := make([]byte, 0, len(b)*2)
|
||||
for _, v := range b {
|
||||
out = append(out, digits[v>>4], digits[v&0x0f])
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// restoreLikeEngine round-trips the persisted state and the task meta through
|
||||
// JSON the way the engine storage does, then builds a fresh fetcher. This is
|
||||
// the path that historically lost the request extra headers.
|
||||
func restoreLikeEngine(t *testing.T, f *Fetcher, cfg config) *Fetcher {
|
||||
t.Helper()
|
||||
fm := &FetcherManager{}
|
||||
stateAny, err := fm.Store(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rawState, err := json.Marshal(stateAny)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
storedState := &fetcherState{}
|
||||
if err := json.Unmarshal(rawState, storedState); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rawMeta, err := json.Marshal(f.meta)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restoredMeta := &fetcher.FetcherMeta{}
|
||||
if err := json.Unmarshal(rawMeta, restoredMeta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := restoredMeta.Req.Extra.(*ReqExtra); ok {
|
||||
t.Fatal("storage round trip should yield a map-shaped extra, like the engine")
|
||||
}
|
||||
ctl := controller.NewController()
|
||||
ctl.GetConfig = func(v any) {
|
||||
if c, ok := v.(*config); ok {
|
||||
*c = cfg
|
||||
}
|
||||
}
|
||||
_, builder := fm.Restore()
|
||||
rf := builder(restoredMeta, storedState).(*Fetcher)
|
||||
// The engine calls Setup on the builder result (restoreFetcher).
|
||||
rf.Setup(ctl)
|
||||
return rf
|
||||
}
|
||||
|
||||
func TestFetcherE2E_StoreRestoreAuthenticated(t *testing.T) {
|
||||
const total = 4
|
||||
page := "https://player.example.com/watch/42"
|
||||
var served atomic.Int64
|
||||
mux := http.NewServeMux()
|
||||
var playlist strings.Builder
|
||||
playlist.WriteString("#EXTM3U\n")
|
||||
for i := 0; i < total; i++ {
|
||||
playlist.WriteString(fmt.Sprintf("#EXTINF:4,\nseg-%d.ts\n", i))
|
||||
}
|
||||
playlist.WriteString("#EXT-X-ENDLIST\n")
|
||||
mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(playlist.String()))
|
||||
})
|
||||
segments := make(map[int][]byte)
|
||||
for i := 0; i < total; i++ {
|
||||
segments[i] = []byte(fmt.Sprintf("AUTH-SEGMENT-%02d", i))
|
||||
idx := i
|
||||
mux.HandleFunc(fmt.Sprintf("/seg-%d.ts", idx), func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
served.Add(1)
|
||||
w.Write(segments[idx])
|
||||
})
|
||||
}
|
||||
server := httptest.NewServer(&cdnHandler{mux: mux, requireReferer: page})
|
||||
defer server.Close()
|
||||
|
||||
cfg := testConfig()
|
||||
cfg.SegmentConnections = 2
|
||||
cfg.PrefetchContentLength = false
|
||||
|
||||
f := newTestFetcher(t, cfg)
|
||||
req := &base.Request{
|
||||
URL: server.URL + "/video.m3u8",
|
||||
Extra: &ReqExtra{Header: map[string]string{"Referer": page}},
|
||||
}
|
||||
f.Meta().Opts = &base.Options{Path: t.TempDir()}
|
||||
if err := f.Resolve(req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
if err := f.Pause(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rf := restoreLikeEngine(t, f, cfg)
|
||||
if rf.extra == nil || rf.extra.Header["Referer"] != page {
|
||||
t.Fatalf("restored fetcher lost the request extra headers: %+v", rf.extra)
|
||||
}
|
||||
if err := rf.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := rf.Wait(); err != nil {
|
||||
t.Fatalf("resumed download failed (request headers lost?): %v", err)
|
||||
}
|
||||
var want []byte
|
||||
for i := 0; i < total; i++ {
|
||||
want = append(want, segments[i]...)
|
||||
}
|
||||
if !bytes.Equal(readOutput(t, rf), want) {
|
||||
t.Errorf("restored output mismatch: %q", readOutput(t, rf))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcherE2E_PatchPartialUpdate(t *testing.T) {
|
||||
page := "https://player.example.com/watch/7"
|
||||
var requiredReferer atomic.Value // string
|
||||
requiredReferer.Store(page)
|
||||
var playlist strings.Builder
|
||||
playlist.WriteString("#EXTM3U\n")
|
||||
segments := make(map[string][]byte)
|
||||
for i := 0; i < 3; i++ {
|
||||
segments[fmt.Sprintf("/s%d.ts", i)] = []byte(fmt.Sprintf("PATCH-%d", i))
|
||||
playlist.WriteString(fmt.Sprintf("#EXTINF:4,\ns%d.ts\n", i))
|
||||
}
|
||||
playlist.WriteString("#EXT-X-ENDLIST\n")
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(playlist.String()))
|
||||
})
|
||||
for path, content := range segments {
|
||||
c := content
|
||||
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write(c)
|
||||
})
|
||||
}
|
||||
// The Referer requirement can be flipped mid-test to prove that a patched
|
||||
// header is actually sent by the next run.
|
||||
dyn := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if want, _ := requiredReferer.Load().(string); want != "" && r.Header.Get("Referer") != want {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
mux.ServeHTTP(w, r)
|
||||
})
|
||||
server := httptest.NewServer(dyn)
|
||||
defer server.Close()
|
||||
|
||||
f := newTestFetcher(t, testConfig())
|
||||
req := &base.Request{
|
||||
URL: server.URL + "/video.m3u8",
|
||||
Extra: &ReqExtra{Header: map[string]string{"Referer": page}},
|
||||
}
|
||||
opts := &base.Options{Path: t.TempDir()}
|
||||
f.Meta().Opts = opts
|
||||
if err := f.Resolve(req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A nil/nil patch must be a no-op, not a panic.
|
||||
if err := f.Patch(nil, nil); err != nil {
|
||||
t.Fatalf("nil patch should be accepted: %v", err)
|
||||
}
|
||||
|
||||
// Header-only patch: the resolved plan and output options are retained
|
||||
// while the new header takes effect on the next run.
|
||||
newReferer := "https://player.example.com/watch/9"
|
||||
if err := f.Patch(&base.Request{
|
||||
URL: server.URL + "/video.m3u8",
|
||||
Extra: &ReqExtra{Header: map[string]string{"Referer": newReferer}},
|
||||
}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.state == nil || f.meta.Res == nil {
|
||||
t.Fatal("header-only patch must keep the resolved plan")
|
||||
}
|
||||
if f.meta.Opts.Path != opts.Path {
|
||||
t.Fatalf("header-only patch must keep the output path: %s", f.meta.Opts.Path)
|
||||
}
|
||||
if f.extra.Header["Referer"] != newReferer {
|
||||
t.Fatalf("patched header not applied: %+v", f.extra.Header)
|
||||
}
|
||||
requiredReferer.Store(newReferer)
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatalf("run with patched header failed: %v", err)
|
||||
}
|
||||
if got := readOutput(t, f); string(got) != "PATCH-0PATCH-1PATCH-2" {
|
||||
t.Errorf("output mismatch after header patch: %q", got)
|
||||
}
|
||||
|
||||
// Options-only patch: renaming the task keeps the resolved plan.
|
||||
if err := f.Patch(nil, &base.Options{Name: "renamed.ts"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.state == nil {
|
||||
t.Fatal("options-only patch must keep the resolved plan")
|
||||
}
|
||||
if f.meta.Opts.Name != "renamed.ts" || f.meta.Opts.Path != opts.Path {
|
||||
t.Fatalf("options patch misapplied: %+v", f.meta.Opts)
|
||||
}
|
||||
|
||||
// URL-only patch: the plan is dropped for a re-resolve but the output
|
||||
// path and renamed file survive.
|
||||
if err := f.Patch(&base.Request{URL: server.URL + "/refreshed.m3u8"}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.state != nil || f.meta.Res != nil {
|
||||
t.Fatal("URL patch must drop the resolved plan")
|
||||
}
|
||||
if f.meta.Opts.Path != opts.Path || f.meta.Opts.Name != "renamed.ts" {
|
||||
t.Fatal("URL patch must keep the output options")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcherE2E_PatchWhileRunningRejected(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
var playlist strings.Builder
|
||||
playlist.WriteString("#EXTM3U\n")
|
||||
for i := 0; i < 6; i++ {
|
||||
playlist.WriteString(fmt.Sprintf("#EXTINF:4,\nseg-%d.ts\n", i))
|
||||
}
|
||||
playlist.WriteString("#EXT-X-ENDLIST\n")
|
||||
mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(playlist.String()))
|
||||
})
|
||||
for i := 0; i < 6; i++ {
|
||||
mux.HandleFunc(fmt.Sprintf("/seg-%d.ts", i), func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
w.Write([]byte("X"))
|
||||
})
|
||||
}
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
f := newTestFetcher(t, testConfig())
|
||||
f.Meta().Opts = &base.Options{Path: t.TempDir()}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Patch(&base.Request{URL: server.URL + "/other.m3u8"}, nil); err == nil {
|
||||
t.Fatal("patching a running task must be rejected")
|
||||
}
|
||||
if err := f.Pause(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// After the pause the patch goes through and only then drops the plan.
|
||||
if err := f.Patch(&base.Request{URL: server.URL + "/other.m3u8"}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.state != nil {
|
||||
t.Fatal("URL patch after pause must drop the plan")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcherE2E_RetryAfterFailure(t *testing.T) {
|
||||
const total = 4
|
||||
var healthy atomic.Bool
|
||||
mux := http.NewServeMux()
|
||||
var playlist strings.Builder
|
||||
playlist.WriteString("#EXTM3U\n")
|
||||
for i := 0; i < total; i++ {
|
||||
playlist.WriteString(fmt.Sprintf("#EXTINF:4,\nseg-%d.ts\n", i))
|
||||
}
|
||||
playlist.WriteString("#EXT-X-ENDLIST\n")
|
||||
mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(playlist.String()))
|
||||
})
|
||||
segments := make(map[int][]byte)
|
||||
for i := 0; i < total; i++ {
|
||||
segments[i] = []byte(fmt.Sprintf("RETRY-SEGMENT-%02d", i))
|
||||
idx := i
|
||||
mux.HandleFunc(fmt.Sprintf("/seg-%d.ts", idx), func(w http.ResponseWriter, r *http.Request) {
|
||||
if idx == 1 && !healthy.Load() {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
w.Write(segments[idx])
|
||||
})
|
||||
}
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
cfg := testConfig()
|
||||
cfg.MaxRetries = 0 // fail the run on the first bad segment
|
||||
cfg.PrefetchContentLength = false
|
||||
f := newTestFetcher(t, cfg)
|
||||
f.Meta().Opts = &base.Options{Path: t.TempDir()}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Wait(); err == nil {
|
||||
t.Fatal("first run should fail while the segment endpoint is down")
|
||||
}
|
||||
if got := statsOf(t, f).SegmentsFailed; got == 0 {
|
||||
t.Fatal("failed segments should be reported in stats")
|
||||
}
|
||||
|
||||
// Server recovered: a fresh run must succeed and report zero failures.
|
||||
healthy.Store(true)
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatalf("retry run failed: %v", err)
|
||||
}
|
||||
stats := statsOf(t, f)
|
||||
if stats.SegmentsFailed != 0 {
|
||||
t.Errorf("failures must reset per run, got %d", stats.SegmentsFailed)
|
||||
}
|
||||
if stats.SegmentsDone != total {
|
||||
t.Errorf("want %d done, got %d", total, stats.SegmentsDone)
|
||||
}
|
||||
var want []byte
|
||||
for i := 0; i < total; i++ {
|
||||
want = append(want, segments[i]...)
|
||||
}
|
||||
if !bytes.Equal(readOutput(t, f), want) {
|
||||
t.Errorf("retry output mismatch: %q", readOutput(t, f))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcherE2E_StateMissingSelfHeal(t *testing.T) {
|
||||
const total = 3
|
||||
mux := http.NewServeMux()
|
||||
var playlist strings.Builder
|
||||
playlist.WriteString("#EXTM3U\n")
|
||||
for i := 0; i < total; i++ {
|
||||
playlist.WriteString(fmt.Sprintf("#EXTINF:4,\nseg-%d.ts\n", i))
|
||||
}
|
||||
playlist.WriteString("#EXT-X-ENDLIST\n")
|
||||
mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(playlist.String()))
|
||||
})
|
||||
segments := make(map[int][]byte)
|
||||
for i := 0; i < total; i++ {
|
||||
segments[i] = []byte(fmt.Sprintf("SELFHEAL-%02d", i))
|
||||
idx := i
|
||||
mux.HandleFunc(fmt.Sprintf("/seg-%d.ts", idx), func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write(segments[idx])
|
||||
})
|
||||
}
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
f := newTestFetcher(t, testConfig())
|
||||
f.Meta().Opts = &base.Options{Path: t.TempDir()}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Simulate the crash window where the engine persisted the task with its
|
||||
// resource but the HLS plan was never checkpointed.
|
||||
f.state = nil
|
||||
outputName := f.meta.Res.Files[0].Name
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("start should self-heal a missing plan: %v", err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatalf("self-healed run failed: %v", err)
|
||||
}
|
||||
if f.state == nil || len(f.state.Segments) != total {
|
||||
t.Fatal("self-heal should rebuild the segment plan")
|
||||
}
|
||||
if f.meta.Res.Files[0].Name != outputName {
|
||||
t.Fatalf("self-heal renamed the output: %s", f.meta.Res.Files[0].Name)
|
||||
}
|
||||
var want []byte
|
||||
for i := 0; i < total; i++ {
|
||||
want = append(want, segments[i]...)
|
||||
}
|
||||
if !bytes.Equal(readOutput(t, f), want) {
|
||||
t.Errorf("self-healed output mismatch: %q", readOutput(t, f))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package hls
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/httpclient"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
const defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36"
|
||||
|
||||
const maxPlaylistSize = 10 << 20 // 10MB, playlists are small text files
|
||||
|
||||
func (f *Fetcher) timeout() time.Duration {
|
||||
return time.Duration(f.config.TimeoutSeconds) * time.Second
|
||||
}
|
||||
|
||||
// buildClient builds an HTTP client honoring the task proxy and TLS settings.
|
||||
// The client has no total timeout on purpose: large segments over slow links
|
||||
// must not be aborted; body stalls are handled by stallReader instead. It is
|
||||
// only called while the fetcher is quiescent (Resolve/Start), never from
|
||||
// inside running worker goroutines.
|
||||
func (f *Fetcher) buildClient() *http.Client {
|
||||
var proxy *base.RequestProxy
|
||||
skipVerify := false
|
||||
if req := f.meta.Req; req != nil {
|
||||
proxy = req.Proxy
|
||||
skipVerify = req.SkipVerifyCert
|
||||
}
|
||||
jar, _ := cookiejar.New(nil)
|
||||
client, err := httpclient.NewClient(httpclient.Options{
|
||||
Client: httpclient.ClientOptions{
|
||||
Jar: jar,
|
||||
},
|
||||
Transport: httpclient.TransportOptions{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: f.timeout(),
|
||||
}).DialContext,
|
||||
Proxy: f.Ctl.GetProxy(proxy),
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: skipVerify,
|
||||
},
|
||||
TLSHandshakeTimeout: f.timeout(),
|
||||
},
|
||||
Impersonation: httpclient.ImpersonationOptions{
|
||||
Mode: httpclient.ImpersonationAuto,
|
||||
Session: f.impSession,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("build HTTP client: %v", err))
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
// buildRequest builds a request with task extra headers, the default user
|
||||
// agent and an optional byte range. extra may be nil.
|
||||
func buildRequest(ctx context.Context, extra *ReqExtra, method, u string, body io.Reader, byterange *ByteRange) (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, u, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if extra != nil {
|
||||
for key, value := range extra.Header {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
}
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", defaultUserAgent)
|
||||
}
|
||||
if byterange != nil {
|
||||
req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", byterange.Offset, byterange.Offset+byterange.Length-1))
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// stallReader aborts reads that see no data for the configured timeout.
|
||||
type stallReader struct {
|
||||
rc io.ReadCloser
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func (r *stallReader) Read(p []byte) (int, error) {
|
||||
timer := time.AfterFunc(r.timeout, func() {
|
||||
r.rc.Close()
|
||||
})
|
||||
defer timer.Stop()
|
||||
return r.rc.Read(p)
|
||||
}
|
||||
|
||||
func (r *stallReader) Close() error {
|
||||
return r.rc.Close()
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package hls
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// journalFile is the persisted completed-segment ledger. It is bound to the
|
||||
// exact download plan: a journal whose fingerprint does not match the current
|
||||
// plan is discarded instead of being applied to the wrong segments.
|
||||
type journalFile struct {
|
||||
StagingID string `json:"stagingID"`
|
||||
PlanHash string `json:"planHash"`
|
||||
SegmentCount int `json:"segmentCount"`
|
||||
Sizes map[string]int64 `json:"sizes"`
|
||||
}
|
||||
|
||||
// randomStagingID returns a random identifier used to give every task its own
|
||||
// staging folder, stable across app restarts through the persisted state.
|
||||
func randomStagingID() string {
|
||||
buf := make([]byte, 6)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
// crypto/rand failing is catastrophic anyway; fall back to address
|
||||
// entropy rather than crashing the fetch.
|
||||
return fmt.Sprintf("%x", sha1.Sum([]byte(fmt.Sprintf("%p", buf))))[:12]
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
// planFingerprint hashes everything that defines the download plan: the media
|
||||
// URL, the ordered segments with their sequence numbers, byte ranges, keys
|
||||
// and init markers. Two tasks resolve to different fingerprints even when
|
||||
// they point at the same playlist URL.
|
||||
func planFingerprint(state *fetcherState) string {
|
||||
h := sha1.New()
|
||||
io.WriteString(h, state.MediaURL+"\n")
|
||||
io.WriteString(h, strconv.Itoa(len(state.Segments))+"\n")
|
||||
for _, seg := range state.Segments {
|
||||
fmt.Fprintf(h, "%d|%s|", seg.Sequence, seg.URI)
|
||||
if seg.Byterange != nil {
|
||||
fmt.Fprintf(h, "%d@%d|", seg.Byterange.Offset, seg.Byterange.Length)
|
||||
}
|
||||
if seg.Key != nil {
|
||||
// The IV is part of the plan: same URI with a rotated IV would
|
||||
// silently decrypt to garbage if the journal were reused.
|
||||
fmt.Fprintf(h, "key=%s iv=%x|", seg.Key.URI, seg.Key.IV)
|
||||
}
|
||||
if seg.IsInit {
|
||||
io.WriteString(h, "init|")
|
||||
}
|
||||
io.WriteString(h, "\n")
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))[:16]
|
||||
}
|
||||
|
||||
// loadJournal reads the journal of a run, discarding anything that cannot be
|
||||
// proven to belong to the current plan: foreign or legacy journals, corrupted
|
||||
// files, and entries whose recorded size no longer matches the plan.
|
||||
func loadJournal(run *hlsRun) error {
|
||||
data, err := os.ReadFile(run.journalPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
var file journalFile
|
||||
if err := json.Unmarshal(data, &file); err != nil {
|
||||
// A broken or legacy journal must not kill a restartable download.
|
||||
return nil
|
||||
}
|
||||
if file.StagingID != run.stagingID || file.PlanHash != run.planHash || file.SegmentCount != len(run.segs) {
|
||||
return nil
|
||||
}
|
||||
for key, size := range file.Sizes {
|
||||
idx, err := strconv.ParseInt(key, 10, 64)
|
||||
if err != nil || idx < 0 || idx >= int64(len(run.segs)) || size <= 0 {
|
||||
continue
|
||||
}
|
||||
run.journal[idx] = size
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveJournal atomically rewrites the journal through a temp file + rename so
|
||||
// a crash never leaves a half-written ledger behind.
|
||||
func saveJournal(run *hlsRun) error {
|
||||
file := journalFile{
|
||||
StagingID: run.stagingID,
|
||||
PlanHash: run.planHash,
|
||||
SegmentCount: len(run.segs),
|
||||
Sizes: make(map[string]int64, len(run.journal)),
|
||||
}
|
||||
for idx, size := range run.journal {
|
||||
file.Sizes[strconv.FormatInt(idx, 10)] = size
|
||||
}
|
||||
data, err := json.Marshal(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := run.journalPath + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0666); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, run.journalPath)
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package hls
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
// The validators below check real media structure, not just byte
|
||||
// concatenation: TS packets are verified on the 188 byte grid with
|
||||
// continuity-counter rules, PAT/PMT presence and PES start codes; fMP4
|
||||
// output is verified by walking the ISO-BMFF box hierarchy.
|
||||
|
||||
func be32(b []byte) int {
|
||||
return int(b[0])<<24 | int(b[1])<<16 | int(b[2])<<8 | int(b[3])
|
||||
}
|
||||
|
||||
// validateTS asserts structural validity of a merged MPEG-2 TS stream.
|
||||
func validateTS(t *testing.T, data []byte) {
|
||||
t.Helper()
|
||||
if len(data) == 0 || len(data)%188 != 0 {
|
||||
t.Fatalf("TS size %d is not a positive multiple of 188", len(data))
|
||||
}
|
||||
type pidState struct{ cc int }
|
||||
ccByPID := map[int]int{}
|
||||
patSeen, pmtSeen, pesSeen := false, false, false
|
||||
for off := 0; off < len(data); off += 188 {
|
||||
pkt := data[off : off+188]
|
||||
if pkt[0] != 0x47 {
|
||||
t.Fatalf("bad TS sync byte at offset %d", off)
|
||||
}
|
||||
pid := (int(pkt[1]&0x1F) << 8) | int(pkt[2])
|
||||
pusi := pkt[1]&0x40 != 0
|
||||
afc := (pkt[3] >> 4) & 0x3
|
||||
if afc == 0 {
|
||||
continue // reserved
|
||||
}
|
||||
idx := 4
|
||||
if afc&0x2 != 0 { // adaptation field present
|
||||
afLen := int(pkt[4])
|
||||
if afLen > 0 && pkt[5]&0x80 != 0 {
|
||||
// discontinuity_indicator: restart the CC expectation
|
||||
delete(ccByPID, pid)
|
||||
}
|
||||
idx += 1 + afLen
|
||||
if afc == 2 {
|
||||
continue // adaptation only, no payload: no CC rule
|
||||
}
|
||||
}
|
||||
if idx > 188 {
|
||||
t.Fatalf("adaptation field overruns packet at offset %d", off)
|
||||
}
|
||||
if pid == 0x1FFF {
|
||||
continue // null packet
|
||||
}
|
||||
payload := pkt[idx:]
|
||||
if pusi && len(payload) >= 3 && pid != 0 && payload[0] == 0 && payload[1] == 0 && payload[2] == 1 {
|
||||
pesSeen = true
|
||||
}
|
||||
if pid == 0 {
|
||||
if ptr := int(payload[0]); 1+ptr < len(payload) && payload[1+ptr] == 0x00 {
|
||||
patSeen = true // program_association_section table_id
|
||||
}
|
||||
} else if pusi && len(payload) >= 2 && payload[0] == 0 && payload[1] == 0x02 {
|
||||
pmtSeen = true // program_map_section table_id
|
||||
}
|
||||
cc := int(pkt[3] & 0x0F)
|
||||
prev, ok := ccByPID[pid]
|
||||
if !ok {
|
||||
ccByPID[pid] = cc
|
||||
continue
|
||||
}
|
||||
// CC increments by one per payload-bearing packet; a repeated value
|
||||
// models the single legal duplicate packet.
|
||||
if cc != (prev+1)%16 && cc != prev {
|
||||
t.Fatalf("continuity error on PID %d: %d after %d at offset %d", pid, cc, prev, off)
|
||||
}
|
||||
ccByPID[pid] = cc
|
||||
}
|
||||
if !patSeen {
|
||||
t.Error("no PAT found in merged TS")
|
||||
}
|
||||
if !pmtSeen {
|
||||
t.Error("no PMT found in merged TS")
|
||||
}
|
||||
if !pesSeen {
|
||||
t.Error("no PES start code found in merged TS")
|
||||
}
|
||||
}
|
||||
|
||||
// walkBoxes iterates the top-level ISO-BMFF boxes of data[off:end].
|
||||
func walkBoxes(t *testing.T, data []byte, off, end int) []string {
|
||||
t.Helper()
|
||||
var types []string
|
||||
for off+8 <= end {
|
||||
size := be32(data[off:])
|
||||
typ := string(data[off+4 : off+8])
|
||||
if size < 8 || off+size > end {
|
||||
t.Fatalf("invalid box %q at offset %d (size %d)", typ, off, size)
|
||||
}
|
||||
types = append(types, typ)
|
||||
off += size
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
// validateFMP4 asserts the merged fMP4 output is init section followed by
|
||||
// well-formed moof+mdat fragments.
|
||||
func validateFMP4(t *testing.T, merged []byte, wantFragments int) {
|
||||
t.Helper()
|
||||
types := walkBoxes(t, merged, 0, len(merged))
|
||||
hasFtyp, hasMoov := false, false
|
||||
fragments := 0
|
||||
expectFragment := false
|
||||
for i, typ := range types {
|
||||
switch typ {
|
||||
case "ftyp":
|
||||
if i != 0 {
|
||||
t.Errorf("ftyp not at stream start")
|
||||
}
|
||||
hasFtyp = true
|
||||
case "moov":
|
||||
if i != 1 {
|
||||
t.Errorf("moov must follow ftyp, found at index %d", i)
|
||||
}
|
||||
hasMoov = true
|
||||
case "styp":
|
||||
expectFragment = true
|
||||
case "moof":
|
||||
if !hasMoov {
|
||||
t.Errorf("fragment before moov")
|
||||
}
|
||||
expectFragment = true
|
||||
case "mdat":
|
||||
if !expectFragment {
|
||||
t.Errorf("mdat outside of a fragment at index %d", i)
|
||||
}
|
||||
expectFragment = false
|
||||
fragments++
|
||||
case "mfra":
|
||||
// optional trailer
|
||||
default:
|
||||
t.Errorf("unexpected top-level box %q", typ)
|
||||
}
|
||||
}
|
||||
if !hasFtyp || !hasMoov {
|
||||
t.Error("merged fMP4 missing ftyp/moov init section")
|
||||
}
|
||||
if fragments != wantFragments {
|
||||
t.Errorf("want %d fragments in merged fMP4, got %d", wantFragments, fragments)
|
||||
}
|
||||
}
|
||||
|
||||
func mediaFixture(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile("testdata/media/" + name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func serveFixtureServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
fs := http.FileServer(http.Dir("testdata/media"))
|
||||
mux.Handle("/", fs)
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
func TestHLSRealMedia_TS(t *testing.T) {
|
||||
seg0 := mediaFixture(t, "sample-00.ts")
|
||||
seg1 := mediaFixture(t, "sample-01.ts")
|
||||
playlist := "#EXTM3U\n" +
|
||||
"#EXTINF:0.5,\nsample-00.ts\n" +
|
||||
"#EXTINF:0.5,\nsample-01.ts\n" +
|
||||
"#EXT-X-ENDLIST\n"
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(playlist))
|
||||
})
|
||||
mux.Handle("/", http.FileServer(http.Dir("testdata/media")))
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
f := newTestFetcher(t, testConfig())
|
||||
f.Meta().Opts = &base.Options{Path: t.TempDir()}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := readOutput(t, f)
|
||||
if !bytes.Equal(got, append(append([]byte{}, seg0...), seg1...)) {
|
||||
t.Fatal("merged TS is not the exact concatenation of the segments")
|
||||
}
|
||||
validateTS(t, got)
|
||||
}
|
||||
|
||||
func TestHLSRealMedia_FMP4(t *testing.T) {
|
||||
init := mediaFixture(t, "fmp4-init.mp4")
|
||||
frag1 := mediaFixture(t, "fmp4-seg1.m4s")
|
||||
frag2 := mediaFixture(t, "fmp4-seg2.m4s")
|
||||
frag3 := mediaFixture(t, "fmp4-seg3.m4s")
|
||||
playlist := "#EXTM3U\n" +
|
||||
"#EXT-X-MAP:URI=\"fmp4-init.mp4\"\n" +
|
||||
"#EXTINF:0.5,\nfmp4-seg1.m4s\n" +
|
||||
"#EXTINF:0.5,\nfmp4-seg2.m4s\n" +
|
||||
"#EXTINF:0.5,\nfmp4-seg3.m4s\n" +
|
||||
"#EXT-X-ENDLIST\n"
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(playlist))
|
||||
})
|
||||
mux.Handle("/", http.FileServer(http.Dir("testdata/media")))
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
f := newTestFetcher(t, testConfig())
|
||||
f.Meta().Opts = &base.Options{Path: t.TempDir()}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := readOutput(t, f)
|
||||
want := append(append(append(append([]byte{}, init...), frag1...), frag2...), frag3...)
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatal("merged fMP4 is not the exact concatenation of init + fragments")
|
||||
}
|
||||
validateFMP4(t, got, 3)
|
||||
}
|
||||
|
||||
func TestHLSRealMedia_AES128(t *testing.T) {
|
||||
keys := [][]byte{[]byte("key-A-0123456789"), []byte("key-B-9876543210")}
|
||||
iv := []byte("iv-1337-01234567")
|
||||
plain := [][]byte{mediaFixture(t, "sample-00.ts"), mediaFixture(t, "sample-01.ts")}
|
||||
|
||||
playlist := "#EXTM3U\n" +
|
||||
"#EXT-X-MEDIA-SEQUENCE:0\n" +
|
||||
"#EXT-X-KEY:METHOD=AES-128,URI=\"key-0.bin\",IV=0x" + hexEncode(iv) + "\n" +
|
||||
"#EXTINF:0.5,\nenc-0.ts\n" +
|
||||
"#EXT-X-KEY:METHOD=AES-128,URI=\"key-1.bin\"\n" +
|
||||
"#EXTINF:0.5,\nenc-1.ts\n" +
|
||||
"#EXT-X-ENDLIST\n"
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(playlist))
|
||||
})
|
||||
for i, key := range keys {
|
||||
k := key
|
||||
mux.HandleFunc(fmt.Sprintf("/key-%d.bin", i), func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write(k)
|
||||
})
|
||||
// Segment 0 has an explicit IV, segment 1 derives it from its media
|
||||
// sequence number (1).
|
||||
enc := encryptSegment(plain[i], k, map[bool][]byte{true: iv, false: sequenceIV(int64(i))}[i == 0])
|
||||
mux.HandleFunc(fmt.Sprintf("/enc-%d.ts", i), func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write(enc)
|
||||
})
|
||||
}
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
f := newTestFetcher(t, testConfig())
|
||||
f.Meta().Opts = &base.Options{Path: t.TempDir()}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.meta.Res.Size != 0 {
|
||||
t.Errorf("encrypted plan must report indeterminate size, got %d", f.meta.Res.Size)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := readOutput(t, f)
|
||||
if !bytes.Equal(got, append(append([]byte{}, plain[0]...), plain[1]...)) {
|
||||
t.Fatal("decrypted output mismatch")
|
||||
}
|
||||
validateTS(t, got)
|
||||
if p := f.Progress(); p.TotalDownloaded() != int64(len(got)) {
|
||||
t.Errorf("final progress %d != merged size %d", p.TotalDownloaded(), len(got))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
package hls
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Variant is one rendition entry of a master playlist.
|
||||
type Variant struct {
|
||||
URI string
|
||||
Bandwidth int64
|
||||
AvgBandwidth int64
|
||||
Resolution string
|
||||
Codecs string
|
||||
Name string
|
||||
AudioGroup string // AUDIO attribute: group id of the audio renditions
|
||||
}
|
||||
|
||||
// MediaRendition is one EXT-X-MEDIA entry of a master playlist. Only the
|
||||
// fields needed to detect unsupported track layouts are kept.
|
||||
type MediaRendition struct {
|
||||
Type string
|
||||
GroupID string
|
||||
URI string
|
||||
}
|
||||
|
||||
// MasterPlaylist is a parsed master playlist with its rendition groups.
|
||||
type MasterPlaylist struct {
|
||||
Variants []*Variant
|
||||
Rendition map[string][]*MediaRendition // group id -> all EXT-X-MEDIA members
|
||||
}
|
||||
|
||||
// Key is the decryption key applied to the segments that follow its tag, until
|
||||
// the next EXT-X-KEY tag or the end of the playlist.
|
||||
type Key struct {
|
||||
Method string
|
||||
URI string // absolute URI
|
||||
IV []byte // explicit IV; nil means derive from the media sequence number
|
||||
}
|
||||
|
||||
// ByteRange is an EXT-X-BYTERANGE sub range of the segment resource.
|
||||
type ByteRange struct {
|
||||
Length int64
|
||||
Offset int64
|
||||
}
|
||||
|
||||
// Segment is one downloadable piece of a media playlist. The init section of
|
||||
// an fMP4 stream is represented as a segment with IsInit set.
|
||||
type Segment struct {
|
||||
URI string
|
||||
Sequence int64 // media sequence number, used to derive the default AES IV
|
||||
Byterange *ByteRange
|
||||
Key *Key // nil means unencrypted
|
||||
IsInit bool
|
||||
Gap bool
|
||||
Duration float64
|
||||
Size int64 // exact size in bytes when known (byterange / HEAD probe), 0 when unknown
|
||||
}
|
||||
|
||||
// Media is a parsed media playlist.
|
||||
type Media struct {
|
||||
Segments []*Segment
|
||||
Version int
|
||||
Live bool // no EXT-X-ENDLIST tag
|
||||
IsFMP4 bool // playlist declares an init section via EXT-X-MAP
|
||||
Discontinuity bool // playlist contains EXT-X-DISCONTINUITY
|
||||
}
|
||||
|
||||
const (
|
||||
keyMethodNone = "NONE"
|
||||
keyMethodAES128 = "AES-128"
|
||||
keyMethodSampleAES = "SAMPLE-AES"
|
||||
)
|
||||
|
||||
// IsMasterPlaylist reports whether the content looks like a master playlist.
|
||||
func IsMasterPlaylist(content string) bool {
|
||||
return strings.Contains(content, "#EXT-X-STREAM-INF")
|
||||
}
|
||||
|
||||
func validateM3U8Header(content string) error {
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if line != "#EXTM3U" {
|
||||
return errors.New("content is not a valid m3u8 playlist")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return errors.New("empty m3u8 playlist")
|
||||
}
|
||||
|
||||
// ParseMaster parses a master playlist and returns all variants.
|
||||
func ParseMaster(content string, baseURL *url.URL) ([]*Variant, error) {
|
||||
master, err := ParseMasterPlaylist(content, baseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return master.Variants, nil
|
||||
}
|
||||
|
||||
// ParseMasterPlaylist parses a master playlist including its EXT-X-MEDIA
|
||||
// rendition groups.
|
||||
func ParseMasterPlaylist(content string, baseURL *url.URL) (*MasterPlaylist, error) {
|
||||
if err := validateM3U8Header(content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
master := &MasterPlaylist{Rendition: make(map[string][]*MediaRendition)}
|
||||
var pending *Variant
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "#EXT-X-STREAM-INF:") {
|
||||
attrs := parseAttributes(line[len("#EXT-X-STREAM-INF:"):])
|
||||
pending = &Variant{
|
||||
Bandwidth: parseInt64(attrs["BANDWIDTH"]),
|
||||
AvgBandwidth: parseInt64(attrs["AVERAGE-BANDWIDTH"]),
|
||||
Resolution: attrs["RESOLUTION"],
|
||||
Codecs: unquote(attrs["CODECS"]),
|
||||
Name: unquote(attrs["NAME"]),
|
||||
AudioGroup: unquote(attrs["AUDIO"]),
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "#EXT-X-MEDIA:") {
|
||||
attrs := parseAttributes(line[len("#EXT-X-MEDIA:"):])
|
||||
rendition := &MediaRendition{
|
||||
Type: strings.ToUpper(attrs["TYPE"]),
|
||||
GroupID: unquote(attrs["GROUP-ID"]),
|
||||
}
|
||||
// Only an explicit URI means a separate track; resolving an empty
|
||||
// reference would fabricate one out of the playlist URL.
|
||||
if uri := unquote(attrs["URI"]); uri != "" {
|
||||
rendition.URI = resolveURL(baseURL, uri)
|
||||
}
|
||||
if rendition.GroupID != "" {
|
||||
// Keep every member: a group is unsupported as a whole when
|
||||
// ANY of its members carries its own URI.
|
||||
master.Rendition[rendition.GroupID] = append(master.Rendition[rendition.GroupID], rendition)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if pending != nil {
|
||||
if line[0] != '#' {
|
||||
pending.URI = resolveURL(baseURL, line)
|
||||
master.Variants = append(master.Variants, pending)
|
||||
}
|
||||
pending = nil
|
||||
}
|
||||
}
|
||||
if len(master.Variants) == 0 {
|
||||
return nil, errors.New("master playlist has no variants")
|
||||
}
|
||||
return master, nil
|
||||
}
|
||||
|
||||
// checkAudioRendition rejects the selected variant when its AUDIO group
|
||||
// carries a separate audio track: downloading only the video variant would
|
||||
// silently produce a silent file. Groups the variant does not reference are
|
||||
// ignored (variants without an AUDIO attribute are assumed self-contained).
|
||||
func checkAudioRendition(master *MasterPlaylist, best *Variant) error {
|
||||
if best == nil || best.AudioGroup == "" {
|
||||
return nil
|
||||
}
|
||||
for _, rendition := range master.Rendition[best.AudioGroup] {
|
||||
if rendition == nil || rendition.Type != "AUDIO" || rendition.URI == "" {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"selected variant uses a separate audio rendition (group %q), which is not supported: the merged output would have no audio",
|
||||
best.AudioGroup)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PickBestVariant selects the highest-bandwidth variant.
|
||||
func PickBestVariant(variants []*Variant) *Variant {
|
||||
var best *Variant
|
||||
for _, v := range variants {
|
||||
if best == nil || v.Bandwidth > best.Bandwidth {
|
||||
best = v
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// ParseMedia parses a media playlist into an ordered segment plan.
|
||||
func ParseMedia(content string, baseURL *url.URL) (*Media, error) {
|
||||
if err := validateM3U8Header(content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
media := &Media{Live: true}
|
||||
var currentKey *Key
|
||||
var pendingByterange *ByteRange
|
||||
var pendingGap bool
|
||||
var pendingDuration float64
|
||||
var lastRangeEnd int64
|
||||
var sequence int64
|
||||
var mapIdentity string // normalized identity of the current EXT-X-MAP
|
||||
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if line[0] != '#' {
|
||||
// Every non-comment line in a media playlist is a segment URI.
|
||||
seg := &Segment{
|
||||
URI: resolveURL(baseURL, line),
|
||||
Sequence: sequence,
|
||||
Byterange: pendingByterange,
|
||||
Key: currentKey,
|
||||
Gap: pendingGap,
|
||||
Duration: pendingDuration,
|
||||
}
|
||||
if pendingByterange != nil {
|
||||
// Byterange segments have exact sizes straight from the playlist.
|
||||
seg.Size = pendingByterange.Length
|
||||
}
|
||||
sequence++
|
||||
pendingByterange = nil
|
||||
pendingGap = false
|
||||
pendingDuration = 0
|
||||
media.Segments = append(media.Segments, seg)
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(line, "#EXTINF:"):
|
||||
value := line[len("#EXTINF:"):]
|
||||
if idx := strings.Index(value, ","); idx >= 0 {
|
||||
value = value[:idx]
|
||||
}
|
||||
pendingDuration = parseFloat(value)
|
||||
case strings.HasPrefix(line, "#EXT-X-KEY:"):
|
||||
key, err := parseKey(line[len("#EXT-X-KEY:"):], baseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
currentKey = key
|
||||
case strings.HasPrefix(line, "#EXT-X-MAP:"):
|
||||
attrs := parseAttributes(line[len("#EXT-X-MAP:"):])
|
||||
uriAttr := unquote(attrs["URI"])
|
||||
if uriAttr == "" {
|
||||
return nil, errors.New("EXT-X-MAP without URI is not supported")
|
||||
}
|
||||
resolved := resolveURL(baseURL, uriAttr)
|
||||
// The init section identity covers every attribute, not just the
|
||||
// URI: a repeated tag with a different BYTERANGE addresses
|
||||
// different bytes and changes the init section. NUL keeps the
|
||||
// two parts unambiguously separated.
|
||||
identity := resolved + "\x00" + attrs["BYTERANGE"]
|
||||
if mapIdentity != "" {
|
||||
if mapIdentity != identity {
|
||||
return nil, errors.New("changing init sections are not supported")
|
||||
}
|
||||
media.IsFMP4 = true
|
||||
continue
|
||||
}
|
||||
mapIdentity = identity
|
||||
init := &Segment{
|
||||
URI: resolved,
|
||||
Sequence: sequence,
|
||||
IsInit: true,
|
||||
Key: currentKey,
|
||||
}
|
||||
if attrs["BYTERANGE"] != "" {
|
||||
init.Byterange = parseByteRange(attrs["BYTERANGE"], 0)
|
||||
if init.Byterange == nil {
|
||||
return nil, fmt.Errorf("invalid EXT-X-MAP BYTERANGE: %s", attrs["BYTERANGE"])
|
||||
}
|
||||
}
|
||||
if init.Key != nil && init.Key.IV == nil {
|
||||
// RFC 8216 only defines sequence-derived IVs for media
|
||||
// segments; guessing one for the init section produces
|
||||
// corrupt output, so require the playlist to be explicit.
|
||||
return nil, errors.New("encrypted EXT-X-MAP without an explicit IV is not supported")
|
||||
}
|
||||
// The init section is prepended to the segment plan, it takes
|
||||
// the media sequence number of the playlist for IV derivation.
|
||||
media.Segments = append(media.Segments, init)
|
||||
media.IsFMP4 = true
|
||||
case strings.HasPrefix(line, "#EXT-X-BYTERANGE:"):
|
||||
pendingByterange = parseByteRange(line[len("#EXT-X-BYTERANGE:"):], lastRangeEnd)
|
||||
if pendingByterange == nil {
|
||||
return nil, fmt.Errorf("invalid EXT-X-BYTERANGE: %s", line)
|
||||
}
|
||||
case line == "#EXT-X-GAP":
|
||||
pendingGap = true
|
||||
case line == "#EXT-X-DISCONTINUITY":
|
||||
media.Discontinuity = true
|
||||
case line == "#EXT-X-ENDLIST":
|
||||
media.Live = false
|
||||
case strings.HasPrefix(line, "#EXT-X-MEDIA-SEQUENCE:"):
|
||||
sequence = parseInt64(line[len("#EXT-X-MEDIA-SEQUENCE:"):])
|
||||
case strings.HasPrefix(line, "#EXT-X-VERSION:"):
|
||||
media.Version = int(parseInt64(line[len("#EXT-X-VERSION:"):]))
|
||||
}
|
||||
|
||||
if pendingByterange != nil {
|
||||
// Non-segment tags between BYTERANGE and its URI must not reset the
|
||||
// running offset, so it is tracked here after each parse.
|
||||
lastRangeEnd = pendingByterange.Offset + pendingByterange.Length
|
||||
}
|
||||
}
|
||||
|
||||
if len(media.Segments) == 0 {
|
||||
return nil, errors.New("media playlist has no segments")
|
||||
}
|
||||
return media, nil
|
||||
}
|
||||
|
||||
// validateSupported rejects playlist features the byte-concatenation merge
|
||||
// cannot reproduce faithfully. Skipping such features silently would report
|
||||
// success while producing a broken file.
|
||||
func validateSupported(media *Media) error {
|
||||
if media.Live {
|
||||
return errors.New("live streams are not supported yet")
|
||||
}
|
||||
if media.Discontinuity {
|
||||
return errors.New("EXT-X-DISCONTINUITY is not supported: the merged output would have a broken timeline")
|
||||
}
|
||||
for _, seg := range media.Segments {
|
||||
if seg.Gap {
|
||||
return errors.New("EXT-X-GAP is not supported: the missing segment would leave a hole in the merged output")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseKey(attrs string, baseURL *url.URL) (*Key, error) {
|
||||
parsed := parseAttributes(attrs)
|
||||
switch method := parsed["METHOD"]; method {
|
||||
case keyMethodNone:
|
||||
return nil, nil
|
||||
case keyMethodAES128:
|
||||
uri := unquote(parsed["URI"])
|
||||
if uri == "" {
|
||||
return nil, errors.New("AES-128 EXT-X-KEY without URI")
|
||||
}
|
||||
if format := unquote(parsed["KEYFORMAT"]); format != "" && format != "identity" {
|
||||
return nil, fmt.Errorf("unsupported EXT-X-KEY KEYFORMAT: %s", format)
|
||||
}
|
||||
key := &Key{Method: keyMethodAES128, URI: resolveURL(baseURL, uri)}
|
||||
if iv, ok := parsed["IV"]; ok {
|
||||
value := strings.TrimPrefix(strings.ToLower(iv), "0x")
|
||||
decoded, err := hex.DecodeString(value)
|
||||
if err != nil || len(decoded) != 16 {
|
||||
return nil, fmt.Errorf("invalid EXT-X-KEY IV: %s", iv)
|
||||
}
|
||||
key.IV = decoded
|
||||
}
|
||||
return key, nil
|
||||
case keyMethodSampleAES:
|
||||
return nil, errors.New("SAMPLE-AES encryption is not supported")
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported encryption method: %s", method)
|
||||
}
|
||||
}
|
||||
|
||||
func parseByteRange(value string, defaultOffset int64) *ByteRange {
|
||||
lengthPart := value
|
||||
offsetPart := ""
|
||||
if idx := strings.Index(value, "@"); idx >= 0 {
|
||||
lengthPart = value[:idx]
|
||||
offsetPart = value[idx+1:]
|
||||
}
|
||||
length := parseInt64(lengthPart)
|
||||
if length <= 0 {
|
||||
return nil
|
||||
}
|
||||
offset := defaultOffset
|
||||
if offsetPart != "" {
|
||||
offset = parseInt64(offsetPart)
|
||||
if offset < 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return &ByteRange{Length: length, Offset: offset}
|
||||
}
|
||||
|
||||
// resolveURL resolves a playlist URI reference against the playlist URL
|
||||
// following RFC 3986.
|
||||
func resolveURL(baseURL *url.URL, ref string) string {
|
||||
refURL, err := url.Parse(ref)
|
||||
if err != nil {
|
||||
return ref
|
||||
}
|
||||
return baseURL.ResolveReference(refURL).String()
|
||||
}
|
||||
|
||||
// parseAttributes parses an HLS attribute list like FOO="bar",NUM=42.
|
||||
func parseAttributes(s string) map[string]string {
|
||||
attrs := make(map[string]string)
|
||||
var key, val strings.Builder
|
||||
inKey := true
|
||||
inQuote := false
|
||||
flush := func() {
|
||||
name := strings.ToUpper(strings.TrimSpace(key.String()))
|
||||
if name != "" {
|
||||
attrs[name] = strings.TrimSpace(val.String())
|
||||
}
|
||||
key.Reset()
|
||||
val.Reset()
|
||||
inKey = true
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if inKey {
|
||||
if c == '=' {
|
||||
inKey = false
|
||||
} else {
|
||||
key.WriteByte(c)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if c == '"' {
|
||||
inQuote = !inQuote
|
||||
continue
|
||||
}
|
||||
if c == ',' && !inQuote {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
val.WriteByte(c)
|
||||
}
|
||||
flush()
|
||||
return attrs
|
||||
}
|
||||
|
||||
func unquote(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) >= 2 && strings.HasPrefix(s, "\"") && strings.HasSuffix(s, "\"") {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func parseInt64(s string) int64 {
|
||||
value, _ := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
|
||||
return value
|
||||
}
|
||||
|
||||
func parseFloat(s string) float64 {
|
||||
value, _ := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||||
return value
|
||||
}
|
||||
|
||||
// OutputExtension returns the container file extension for the playlist.
|
||||
func (m *Media) OutputExtension() string {
|
||||
if m.IsFMP4 {
|
||||
return ".mp4"
|
||||
}
|
||||
return ".ts"
|
||||
}
|
||||
|
||||
// DeriveOutputName builds the merged output file name from a playlist URL.
|
||||
func DeriveOutputName(playlistURL string, media *Media) string {
|
||||
name := ""
|
||||
if u, err := url.Parse(playlistURL); err == nil {
|
||||
name = path.Base(u.Path)
|
||||
name = strings.TrimSuffix(name, path.Ext(name))
|
||||
}
|
||||
if name == "" || name == "/" || name == "." {
|
||||
if u, err := url.Parse(playlistURL); err == nil {
|
||||
name = u.Hostname()
|
||||
}
|
||||
}
|
||||
return name + media.OutputExtension()
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package hls
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
//go:embed testdata/olemovienews.m3u8
|
||||
var realWorldPlaylist string
|
||||
|
||||
// TestParseMediaRealWorld guards parsing against a real site playlist
|
||||
// (olemovienews.com VOD, 138 TS segments).
|
||||
func TestParseMediaRealWorld(t *testing.T) {
|
||||
base, err := url.Parse("https://europe.olemovienews.com/ts2/20230125/afAxfjit/mp4/afAxfjit.mp4/index-v1-a1.m3u8")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
media, err := ParseMedia(realWorldPlaylist, base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if media.Live {
|
||||
t.Error("real VOD playlist should not be live")
|
||||
}
|
||||
if media.IsFMP4 {
|
||||
t.Error("playlist should be detected as TS")
|
||||
}
|
||||
if len(media.Segments) != 138 {
|
||||
t.Errorf("want 138 segments, got %d", len(media.Segments))
|
||||
}
|
||||
first := media.Segments[0]
|
||||
if first.URI != "https://europe.olemovienews.com/ts2/20230125/afAxfjit/mp4/afAxfjit.mp4/seg-1-v1-a1.ts" {
|
||||
t.Errorf("segment URI resolution wrong: %s", first.URI)
|
||||
}
|
||||
if media.Segments[0].Sequence != 1 {
|
||||
t.Errorf("first sequence = %d, want 1", media.Segments[0].Sequence)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package hls
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func mustURL(t *testing.T, raw string) *url.URL {
|
||||
t.Helper()
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func TestParseMediaTS(t *testing.T) {
|
||||
content := "#EXTM3U\n" +
|
||||
"#EXT-X-TARGETDURATION:20\n" +
|
||||
"#EXT-X-VERSION:3\n" +
|
||||
"#EXT-X-MEDIA-SEQUENCE:1\n" +
|
||||
"#EXTINF:20.000,\n" +
|
||||
"seg-1.ts\n" +
|
||||
"#EXTINF:20.000,\n" +
|
||||
"seg-2.ts\n" +
|
||||
"#EXT-X-ENDLIST\n"
|
||||
media, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/vod/index.m3u8"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if media.Live {
|
||||
t.Error("VOD playlist should not be live")
|
||||
}
|
||||
if media.IsFMP4 {
|
||||
t.Error("TS playlist should not be fMP4")
|
||||
}
|
||||
if len(media.Segments) != 2 {
|
||||
t.Fatalf("want 2 segments, got %d", len(media.Segments))
|
||||
}
|
||||
want := "https://cdn.example.com/vod/seg-1.ts"
|
||||
if media.Segments[0].URI != want {
|
||||
t.Errorf("want uri %s, got %s", want, media.Segments[0].URI)
|
||||
}
|
||||
if media.Segments[0].Sequence != 1 {
|
||||
t.Errorf("want sequence 1, got %d", media.Segments[0].Sequence)
|
||||
}
|
||||
if media.Segments[0].Key != nil {
|
||||
t.Error("segment should be unencrypted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMediaWithoutExtinf(t *testing.T) {
|
||||
// Some playlists list segment URIs without EXTINF lines.
|
||||
content := "#EXTM3U\nseg-1.ts\nseg-2.ts\n#EXT-X-ENDLIST\n"
|
||||
media, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/a/index.m3u8"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(media.Segments) != 2 {
|
||||
t.Fatalf("want 2 segments, got %d", len(media.Segments))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMediaLive(t *testing.T) {
|
||||
content := "#EXTM3U\n#EXT-X-TARGETDURATION:6\n#EXTINF:6.0,\nseg-1.ts\n"
|
||||
media, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/live/index.m3u8"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !media.Live {
|
||||
t.Error("playlist without ENDLIST should be live")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMediaFMP4(t *testing.T) {
|
||||
content := "#EXTM3U\n" +
|
||||
"#EXT-X-VERSION:6\n" +
|
||||
"#EXT-X-MAP:URI=\"init.mp4\"\n" +
|
||||
"#EXTINF:5.0,\n" +
|
||||
"seg-1.m4s\n" +
|
||||
"#EXT-X-ENDLIST\n"
|
||||
media, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/vod/index.m3u8"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !media.IsFMP4 {
|
||||
t.Error("playlist with MAP should be fMP4")
|
||||
}
|
||||
if len(media.Segments) != 2 {
|
||||
t.Fatalf("want 2 segments (init + 1), got %d", len(media.Segments))
|
||||
}
|
||||
if !media.Segments[0].IsInit {
|
||||
t.Error("first segment should be the init section")
|
||||
}
|
||||
if media.Segments[0].URI != "https://cdn.example.com/vod/init.mp4" {
|
||||
t.Errorf("init uri wrong: %s", media.Segments[0].URI)
|
||||
}
|
||||
if media.OutputExtension() != ".mp4" {
|
||||
t.Errorf("want .mp4, got %s", media.OutputExtension())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMediaMultiMapError(t *testing.T) {
|
||||
content := "#EXTM3U\n" +
|
||||
"#EXT-X-MAP:URI=\"init1.mp4\"\n" +
|
||||
"#EXTINF:5.0,\nseg-1.m4s\n" +
|
||||
"#EXT-X-DISCONTINUITY\n" +
|
||||
"#EXT-X-MAP:URI=\"init2.mp4\"\n" +
|
||||
"#EXTINF:5.0,\nseg-2.m4s\n" +
|
||||
"#EXT-X-ENDLIST\n"
|
||||
if _, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/vod/index.m3u8")); err == nil {
|
||||
t.Error("multi-period fMP4 should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMediaKeyRotation(t *testing.T) {
|
||||
content := "#EXTM3U\n" +
|
||||
"#EXT-X-KEY:METHOD=AES-128,URI=\"key1.bin\",IV=0x00000000000000000000000000000001\n" +
|
||||
"#EXTINF:5.0,\nseg-1.ts\n" +
|
||||
"#EXT-X-KEY:METHOD=AES-128,URI=\"key2.bin\"\n" +
|
||||
"#EXTINF:5.0,\nseg-2.ts\n" +
|
||||
"#EXT-X-KEY:METHOD=NONE\n" +
|
||||
"#EXTINF:5.0,\nseg-3.ts\n" +
|
||||
"#EXT-X-ENDLIST\n"
|
||||
media, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/vod/index.m3u8"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if media.Segments[0].Key == nil || media.Segments[0].Key.URI != "https://cdn.example.com/vod/key1.bin" {
|
||||
t.Fatalf("segment 1 key wrong: %+v", media.Segments[0].Key)
|
||||
}
|
||||
if len(media.Segments[0].Key.IV) != 16 || media.Segments[0].Key.IV[15] != 1 {
|
||||
t.Error("explicit IV not parsed")
|
||||
}
|
||||
if media.Segments[1].Key == nil || media.Segments[1].Key.URI != "https://cdn.example.com/vod/key2.bin" {
|
||||
t.Fatalf("segment 2 key wrong: %+v", media.Segments[1].Key)
|
||||
}
|
||||
if media.Segments[1].Key.IV != nil {
|
||||
t.Error("key without IV should leave IV nil (sequence-derived)")
|
||||
}
|
||||
if media.Segments[2].Key != nil {
|
||||
t.Error("segment 3 should be unencrypted after METHOD=NONE")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMediaSampleAESError(t *testing.T) {
|
||||
content := "#EXTM3U\n#EXT-X-KEY:METHOD=SAMPLE-AES,URI=\"skd://key\"\n#EXTINF:5.0,\nseg-1.ts\n"
|
||||
if _, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/vod/index.m3u8")); err == nil {
|
||||
t.Error("SAMPLE-AES should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMediaByterange(t *testing.T) {
|
||||
content := "#EXTM3U\n" +
|
||||
"#EXT-X-BYTERANGE:1000@0\nseg.ts\n" +
|
||||
"#EXT-X-BYTERANGE:2000\nseg.ts\n" +
|
||||
"#EXT-X-BYTERANGE:3000\nseg.ts\n" +
|
||||
"#EXT-X-ENDLIST\n"
|
||||
media, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/vod/index.m3u8"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ranges := [][2]int64{{0, 1000}, {1000, 2000}, {3000, 3000}}
|
||||
for i, want := range ranges {
|
||||
br := media.Segments[i].Byterange
|
||||
if br == nil {
|
||||
t.Fatalf("segment %d byterange missing", i)
|
||||
}
|
||||
if br.Offset != want[0] || br.Length != want[1] {
|
||||
t.Errorf("segment %d: want offset %d length %d, got %d/%d", i, want[0], want[1], br.Offset, br.Length)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMediaGap(t *testing.T) {
|
||||
content := "#EXTM3U\n#EXTINF:5.0,\nseg-1.ts\n#EXT-X-GAP\n#EXTINF:5.0,\nseg-2.ts\n#EXT-X-ENDLIST\n"
|
||||
media, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/vod/index.m3u8"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if media.Segments[0].Gap || !media.Segments[1].Gap {
|
||||
t.Error("gap flags wrong")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMediaInvalidHeader(t *testing.T) {
|
||||
if _, err := ParseMedia("not a playlist", mustURL(t, "https://cdn.example.com/a.m3u8")); err == nil {
|
||||
t.Error("invalid content should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMasterPickBest(t *testing.T) {
|
||||
content := "#EXTM3U\n" +
|
||||
"#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud\",NAME=\"en\",DEFAULT=YES,URI=\"audio/index.m3u8\"\n" +
|
||||
"#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360\n" +
|
||||
"360p/index.m3u8\n" +
|
||||
"#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=1920x1080,CODECS=\"avc1.640028,mp4a.40.2\"\n" +
|
||||
"1080p/index.m3u8?sign=abc\n" +
|
||||
"#EXT-X-ENDLIST\n"
|
||||
variants, err := ParseMaster(content, mustURL(t, "https://cdn.example.com/vod/master.m3u8"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(variants) != 2 {
|
||||
t.Fatalf("want 2 variants, got %d", len(variants))
|
||||
}
|
||||
best := PickBestVariant(variants)
|
||||
if best.Bandwidth != 2500000 {
|
||||
t.Errorf("want highest bandwidth variant, got %d", best.Bandwidth)
|
||||
}
|
||||
if best.URI != "https://cdn.example.com/vod/1080p/index.m3u8?sign=abc" {
|
||||
t.Errorf("variant uri wrong: %s", best.URI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveURLQueryNotInherited(t *testing.T) {
|
||||
base := mustURL(t, "https://cdn.example.com/vod/index.m3u8?sign=abc")
|
||||
got := resolveURL(base, "seg-1.ts")
|
||||
if got != "https://cdn.example.com/vod/seg-1.ts" {
|
||||
t.Errorf("relative resolution should follow RFC3986 (no query inheritance), got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveOutputName(t *testing.T) {
|
||||
media := &Media{}
|
||||
if got := DeriveOutputName("https://cdn.example.com/ts2/a/index-v1-a1.m3u8", media); got != "index-v1-a1.ts" {
|
||||
t.Errorf("want index-v1-a1.ts, got %s", got)
|
||||
}
|
||||
fmp4 := &Media{IsFMP4: true}
|
||||
if got := DeriveOutputName("https://cdn.example.com/ts2/a/index.m3u8", fmp4); got != "index.mp4" {
|
||||
t.Errorf("want index.mp4, got %s", got)
|
||||
}
|
||||
if got := DeriveOutputName("https://cdn.example.com/play?token=x", media); got != "play.ts" {
|
||||
t.Errorf("want play.ts, got %s", got)
|
||||
}
|
||||
if got := DeriveOutputName("https://cdn.example.com/", media); got != "cdn.example.com.ts" {
|
||||
t.Errorf("want host fallback, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAttributesQuotedComma(t *testing.T) {
|
||||
attrs := parseAttributes(`URI="a,b.ts",IV=0xABCDEF00000000000000000000000001,NAME="x"`)
|
||||
if attrs["URI"] != "a,b.ts" {
|
||||
t.Errorf("quoted comma value wrong: %q", attrs["URI"])
|
||||
}
|
||||
if attrs["IV"] != "0xABCDEF00000000000000000000000001" {
|
||||
t.Errorf("hex value wrong: %q", attrs["IV"])
|
||||
}
|
||||
if attrs["NAME"] != "x" {
|
||||
t.Errorf("quoted value wrong: %q", attrs["NAME"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package hls
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
func TestFetcherE2E_SameURLTwoTasks(t *testing.T) {
|
||||
const total = 6
|
||||
mux := http.NewServeMux()
|
||||
var playlist strings.Builder
|
||||
playlist.WriteString("#EXTM3U\n")
|
||||
for i := 0; i < total; i++ {
|
||||
playlist.WriteString(fmt.Sprintf("#EXTINF:4,\nseg-%d.ts\n", i))
|
||||
}
|
||||
playlist.WriteString("#EXT-X-ENDLIST\n")
|
||||
mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(playlist.String()))
|
||||
})
|
||||
segments := make(map[int][]byte)
|
||||
for i := 0; i < total; i++ {
|
||||
segments[i] = []byte(fmt.Sprintf("SHARED-SEGMENT-%02d", i))
|
||||
idx := i
|
||||
mux.HandleFunc(fmt.Sprintf("/seg-%d.ts", idx), func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
w.Write(segments[idx])
|
||||
})
|
||||
}
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
cfg := testConfig()
|
||||
cfg.SegmentConnections = 2
|
||||
cfg.PrefetchContentLength = false
|
||||
dir := t.TempDir()
|
||||
|
||||
// Two tasks for the same playlist URL into the same directory, like a
|
||||
// user duplicating a task. The engine would rename the second output
|
||||
// (AutoRename); mirror that here.
|
||||
f1 := newTestFetcher(t, cfg)
|
||||
f1.Meta().Opts = &base.Options{Path: dir}
|
||||
if err := f1.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f2 := newTestFetcher(t, cfg)
|
||||
f2.Meta().Opts = &base.Options{Path: dir, Name: "video-2.ts"}
|
||||
if err := f2.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f1.state.StagingID == "" || f1.state.StagingID == f2.state.StagingID {
|
||||
t.Fatal("staging identities must be distinct per task")
|
||||
}
|
||||
if f1.state.StagingDir != "" || f2.state.StagingDir != "" {
|
||||
t.Fatal("Resolve pinned an output directory")
|
||||
}
|
||||
|
||||
// Start task 1, let it stage some segments, then pause it.
|
||||
if err := f1.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
if err := f1.Pause(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
staged1, err := os.ReadDir(f1.state.StagingDir)
|
||||
if err != nil || len(staged1) == 0 {
|
||||
t.Fatalf("task 1 should have staged segments, dir: %s", f1.state.StagingDir)
|
||||
}
|
||||
|
||||
// Task 2 must complete without touching task 1's staging data...
|
||||
if err := f2.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f2.Wait(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stillThere, err := os.ReadDir(f1.state.StagingDir)
|
||||
if err != nil || len(stillThere) != len(staged1) {
|
||||
t.Fatalf("task 2 disturbed task 1 staging: had %d entries, now %d", len(staged1), len(stillThere))
|
||||
}
|
||||
|
||||
// ...and task 1 resumes to a complete, correct output of its own.
|
||||
if err := f1.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f1.Wait(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var want []byte
|
||||
for i := 0; i < total; i++ {
|
||||
want = append(want, segments[i]...)
|
||||
}
|
||||
if !bytes.Equal(readOutput(t, f1), want) || !bytes.Equal(readOutput(t, f2), want) {
|
||||
t.Error("same-URL task outputs mismatch")
|
||||
}
|
||||
entries, _ := os.ReadDir(dir)
|
||||
if len(entries) != 2 {
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
t.Errorf("expected only the two outputs to remain, got %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcherE2E_ForeignJournalDiscarded(t *testing.T) {
|
||||
const total = 3
|
||||
var served atomic.Int64
|
||||
mux := http.NewServeMux()
|
||||
var playlist strings.Builder
|
||||
playlist.WriteString("#EXTM3U\n")
|
||||
for i := 0; i < total; i++ {
|
||||
playlist.WriteString(fmt.Sprintf("#EXTINF:4,\nseg-%d.ts\n", i))
|
||||
}
|
||||
playlist.WriteString("#EXT-X-ENDLIST\n")
|
||||
mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(playlist.String()))
|
||||
})
|
||||
for i := 0; i < total; i++ {
|
||||
mux.HandleFunc(fmt.Sprintf("/seg-%d.ts", i), func(w http.ResponseWriter, r *http.Request) {
|
||||
served.Add(1)
|
||||
w.Write([]byte("DATA"))
|
||||
})
|
||||
}
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
cfg := testConfig()
|
||||
cfg.PrefetchContentLength = false
|
||||
f := newTestFetcher(t, cfg)
|
||||
f.Meta().Opts = &base.Options{Path: t.TempDir()}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Plant a journal that does not belong to this plan (wrong fingerprint).
|
||||
tempDir := f.resolveStagingDirLocked()
|
||||
if err := os.MkdirAll(tempDir, 0777); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
foreign := journalFile{
|
||||
StagingID: "some-other-task",
|
||||
PlanHash: "deadbeef",
|
||||
SegmentCount: total,
|
||||
Sizes: map[string]int64{"0": 4, "1": 4, "2": 4},
|
||||
}
|
||||
data, _ := json.Marshal(foreign)
|
||||
if err := os.WriteFile(filepath.Join(tempDir, "journal.json"), data, 0666); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := served.Load(); got != total {
|
||||
t.Errorf("foreign journal must be discarded, want %d downloads, got %d", total, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcherE2E_MergeFailurePreservesStaging(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("#EXTM3U\n#EXTINF:4,\na.ts\n#EXTINF:4,\nb.ts\n#EXT-X-ENDLIST\n"))
|
||||
})
|
||||
mux.HandleFunc("/a.ts", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("AAAA")) })
|
||||
mux.HandleFunc("/b.ts", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("BBBB")) })
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
f := newTestFetcher(t, testConfig())
|
||||
f.Meta().Opts = &base.Options{Path: t.TempDir()}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run := &hlsRun{
|
||||
ctx: context.Background(),
|
||||
cancel: func() {},
|
||||
done: make(chan struct{}),
|
||||
segs: f.state.Segments,
|
||||
outputPath: f.meta.SingleFilepath(),
|
||||
stagingID: f.state.StagingID,
|
||||
planHash: planFingerprint(f.state),
|
||||
tempDir: f.resolveStagingDirLocked(),
|
||||
keyCache: make(map[string][]byte),
|
||||
journal: make(map[int64]int64),
|
||||
}
|
||||
run.journalPath = filepath.Join(run.tempDir, "journal.json")
|
||||
if err := os.MkdirAll(run.tempDir, 0777); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(segmentPath(run.tempDir, 0), []byte("AAAA"), 0666); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Segment 1 is missing on disk: the merge must fail without deleting the
|
||||
// staged data or leaving a partial output behind.
|
||||
if err := f.merge(run); err == nil {
|
||||
t.Fatal("merge with a missing segment should fail")
|
||||
}
|
||||
if _, err := os.Stat(segmentPath(run.tempDir, 0)); err != nil {
|
||||
t.Fatal("merge failure must preserve staged segments")
|
||||
}
|
||||
entries, _ := os.ReadDir(filepath.Dir(run.outputPath))
|
||||
for _, e := range entries {
|
||||
if strings.HasSuffix(e.Name(), ".part") {
|
||||
t.Errorf("failed merge left a partial output behind: %s", e.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// Recover the segment: the retried merge succeeds.
|
||||
if err := os.WriteFile(segmentPath(run.tempDir, 1), []byte("BBBB"), 0666); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.merge(run); err != nil {
|
||||
t.Fatalf("retried merge failed: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(run.outputPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "AAAABBBB" {
|
||||
t.Errorf("retried merge output mismatch: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcherE2E_ReMergeOverExistingOutput(t *testing.T) {
|
||||
const total = 2
|
||||
mux := http.NewServeMux()
|
||||
var playlist strings.Builder
|
||||
playlist.WriteString("#EXTM3U\n")
|
||||
for i := 0; i < total; i++ {
|
||||
playlist.WriteString(fmt.Sprintf("#EXTINF:4,\nseg-%d.ts\n", i))
|
||||
}
|
||||
playlist.WriteString("#EXT-X-ENDLIST\n")
|
||||
mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(playlist.String()))
|
||||
})
|
||||
for i := 0; i < total; i++ {
|
||||
mux.HandleFunc(fmt.Sprintf("/seg-%d.ts", i), func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("DATA"))
|
||||
})
|
||||
}
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
|
||||
f := newTestFetcher(t, testConfig())
|
||||
f.Meta().Opts = &base.Options{Path: t.TempDir()}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tempDir := f.resolveStagingDirLocked()
|
||||
if err := os.MkdirAll(tempDir, 0777); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Simulate the crash window after a completed rename but before the task
|
||||
// was marked done: a stale output file, a complete journal and all
|
||||
// segments staged.
|
||||
stale := filepath.Join(filepath.Dir(f.meta.SingleFilepath()), f.state.OutputName)
|
||||
if err := os.WriteFile(stale, []byte("STALE-OUTPUT"), 0666); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jr := &hlsRun{
|
||||
segs: f.state.Segments,
|
||||
stagingID: f.state.StagingID,
|
||||
planHash: planFingerprint(f.state),
|
||||
journalPath: filepath.Join(tempDir, "journal.json"),
|
||||
journal: map[int64]int64{0: 4, 1: 4},
|
||||
}
|
||||
if err := saveJournal(jr); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < total; i++ {
|
||||
if err := os.WriteFile(segmentPath(tempDir, int64(i)), []byte("DATA"), 0666); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatalf("re-merge over an existing output failed: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(stale)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "DATADATA" {
|
||||
t.Errorf("stale output was not replaced: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package hls
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func mustParseURL(t *testing.T, raw string) *url.URL {
|
||||
t.Helper()
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// subsetCase is a media playlist plus the error substring the pipeline must
|
||||
// reject it with; wantOK playlists must parse AND validate cleanly.
|
||||
type subsetCase struct {
|
||||
name string
|
||||
playlist string
|
||||
wantErr string
|
||||
}
|
||||
|
||||
func TestSubsetRejections(t *testing.T) {
|
||||
cases := []subsetCase{
|
||||
{
|
||||
name: "gap",
|
||||
playlist: "#EXTM3U\n#EXTINF:4,\na.ts\n#EXT-X-GAP\n#EXTINF:4,\nb.ts\n#EXT-X-ENDLIST\n",
|
||||
wantErr: "EXT-X-GAP",
|
||||
},
|
||||
{
|
||||
name: "gap-before-first-segment",
|
||||
playlist: "#EXTM3U\n#EXT-X-GAP\n#EXTINF:4,\na.ts\n#EXT-X-ENDLIST\n",
|
||||
wantErr: "EXT-X-GAP",
|
||||
},
|
||||
{
|
||||
name: "discontinuity",
|
||||
playlist: "#EXTM3U\n#EXTINF:4,\na.ts\n#EXT-X-DISCONTINUITY\n#EXTINF:4,\nb.ts\n#EXT-X-ENDLIST\n",
|
||||
wantErr: "EXT-X-DISCONTINUITY",
|
||||
},
|
||||
{
|
||||
name: "live",
|
||||
playlist: "#EXTM3U\n#EXTINF:4,\na.ts\n",
|
||||
wantErr: "live",
|
||||
},
|
||||
{
|
||||
name: "keyformat",
|
||||
playlist: "#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI=\"k\",KEYFORMAT=\"com.apple.streamingkeydelivery\"\n#EXTINF:4,\na.ts\n#EXT-X-ENDLIST\n",
|
||||
wantErr: "KEYFORMAT",
|
||||
},
|
||||
{
|
||||
name: "encrypted-map-without-iv",
|
||||
playlist: "#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI=\"k\"\n#EXT-X-MAP:URI=\"init.mp4\"\n#EXTINF:4,\na.m4s\n#EXT-X-ENDLIST\n",
|
||||
wantErr: "explicit IV",
|
||||
},
|
||||
{
|
||||
name: "map-change-byterange",
|
||||
playlist: "#EXTM3U\n#EXT-X-MAP:URI=\"init.mp4\",BYTERANGE=\"100@0\"\n#EXTINF:4,\na.m4s\n#EXT-X-MAP:URI=\"init.mp4\",BYTERANGE=\"100@100\"\n#EXTINF:4,\nb.m4s\n#EXT-X-ENDLIST\n",
|
||||
wantErr: "changing init sections",
|
||||
},
|
||||
{
|
||||
name: "map-change-uri",
|
||||
playlist: "#EXTM3U\n#EXT-X-MAP:URI=\"init1.mp4\"\n#EXTINF:4,\na.m4s\n#EXT-X-MAP:URI=\"init2.mp4\"\n#EXTINF:4,\nb.m4s\n#EXT-X-ENDLIST\n",
|
||||
wantErr: "changing init sections",
|
||||
},
|
||||
}
|
||||
baseURL := mustParseURL(t, "https://cdn.example.com/vod/index.m3u8")
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
media, parseErr := ParseMedia(tc.playlist, baseURL)
|
||||
if parseErr != nil {
|
||||
if !strings.Contains(parseErr.Error(), tc.wantErr) {
|
||||
t.Fatalf("parse error %q does not mention %q", parseErr, tc.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
err := validateSupported(media)
|
||||
if err == nil {
|
||||
t.Fatalf("playlist should be rejected with %q", tc.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("error %q does not mention %q", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubsetRepeatedSameMapAllowed(t *testing.T) {
|
||||
playlist := "#EXTM3U\n" +
|
||||
"#EXT-X-MAP:URI=\"init.mp4\",BYTERANGE=\"100@0\"\n" +
|
||||
"#EXTINF:4,\na.m4s\n" +
|
||||
"#EXT-X-MAP:URI=\"init.mp4\",BYTERANGE=\"100@0\"\n" +
|
||||
"#EXTINF:4,\nb.m4s\n" +
|
||||
"#EXT-X-ENDLIST\n"
|
||||
media, err := ParseMedia(playlist, mustParseURL(t, "https://cdn.example.com/vod/index.m3u8"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateSupported(media); err != nil {
|
||||
t.Fatalf("identical repeated MAP must be allowed: %v", err)
|
||||
}
|
||||
if len(media.Segments) != 3 { // init + 2 segments, no duplicate init
|
||||
t.Fatalf("want 3 segments (init + 2), got %d", len(media.Segments))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMasterAudioRenditionDetection(t *testing.T) {
|
||||
masterURL := mustParseURL(t, "https://cdn.example.com/master.m3u8")
|
||||
|
||||
separateAudio := "#EXTM3U\n" +
|
||||
"#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud\",NAME=\"en\",DEFAULT=YES,URI=\"audio/index.m3u8\"\n" +
|
||||
"#EXT-X-STREAM-INF:BANDWIDTH=1000000,AUDIO=\"aud\"\nvideo/index.m3u8\n"
|
||||
master, err := ParseMasterPlaylist(separateAudio, masterURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := checkAudioRendition(master, PickBestVariant(master.Variants)); err == nil {
|
||||
t.Fatal("selected variant with a separate audio rendition must be rejected")
|
||||
}
|
||||
|
||||
// A group the variant does not reference must not cause a false positive.
|
||||
unrelatedGroup := "#EXTM3U\n" +
|
||||
"#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud\",NAME=\"en\",DEFAULT=YES,URI=\"audio/index.m3u8\"\n" +
|
||||
"#EXT-X-STREAM-INF:BANDWIDTH=1000000\nvideo/index.m3u8\n"
|
||||
master, err = ParseMasterPlaylist(unrelatedGroup, masterURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := checkAudioRendition(master, PickBestVariant(master.Variants)); err != nil {
|
||||
t.Fatalf("unreferenced audio group must not be rejected: %v", err)
|
||||
}
|
||||
|
||||
// A rendition group without URIs (muxed tracks) is fine.
|
||||
groupWithoutURI := "#EXTM3U\n" +
|
||||
"#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud\",NAME=\"en\",DEFAULT=YES\n" +
|
||||
"#EXT-X-STREAM-INF:BANDWIDTH=1000000,AUDIO=\"aud\"\nvideo/index.m3u8\n"
|
||||
master, err = ParseMasterPlaylist(groupWithoutURI, masterURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := checkAudioRendition(master, PickBestVariant(master.Variants)); err != nil {
|
||||
t.Fatalf("audio group without URI must not be rejected: %v", err)
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
Media fixtures generated locally with FFmpeg 7.0.2 (testsrc + sine sources,
|
||||
no third-party content):
|
||||
|
||||
- sample-00.ts / sample-01.ts: one continuous MPEG-2 TS encode split at a
|
||||
packet boundary, so continuity counters and PCR are consistent across the
|
||||
segment join exactly like segments cut from a single real-time stream.
|
||||
- fmp4-init.mp4: ftyp+moov init section of a fragmented MP4.
|
||||
- fmp4-seg1.m4s .. fmp4-seg3.m4s: moof+mdat media fragments (styp omitted by
|
||||
the muxer, which is legal) split at moof boundaries from one fragmented MP4
|
||||
(-movflags +frag_keyframe+empty_moov+default_base_moof -frag_duration 500000).
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+283
@@ -0,0 +1,283 @@
|
||||
#EXTM3U
|
||||
#EXT-X-TARGETDURATION:20
|
||||
#EXT-X-ALLOW-CACHE:YES
|
||||
#EXT-X-PLAYLIST-TYPE:VOD
|
||||
#EXT-X-VERSION:3
|
||||
#EXT-X-MEDIA-SEQUENCE:1
|
||||
#EXTINF:20.000,
|
||||
seg-1-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-2-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-3-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-4-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-5-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-6-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-7-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-8-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-9-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-10-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-11-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-12-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-13-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-14-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-15-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-16-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-17-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-18-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-19-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-20-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-21-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-22-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-23-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-24-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-25-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-26-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-27-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-28-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-29-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-30-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-31-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-32-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-33-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-34-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-35-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-36-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-37-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-38-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-39-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-40-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-41-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-42-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-43-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-44-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-45-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-46-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-47-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-48-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-49-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-50-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-51-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-52-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-53-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-54-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-55-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-56-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-57-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-58-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-59-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-60-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-61-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-62-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-63-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-64-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-65-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-66-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-67-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-68-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-69-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-70-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-71-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-72-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-73-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-74-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-75-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-76-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-77-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-78-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-79-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-80-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-81-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-82-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-83-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-84-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-85-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-86-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-87-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-88-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-89-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-90-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-91-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-92-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-93-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-94-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-95-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-96-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-97-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-98-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-99-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-100-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-101-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-102-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-103-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-104-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-105-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-106-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-107-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-108-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-109-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-110-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-111-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-112-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-113-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-114-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-115-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-116-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-117-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-118-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-119-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-120-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-121-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-122-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-123-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-124-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-125-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-126-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-127-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-128-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-129-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-130-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-131-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-132-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-133-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-134-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-135-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-136-v1-a1.ts
|
||||
#EXTINF:20.000,
|
||||
seg-137-v1-a1.ts
|
||||
#EXTINF:4.111,
|
||||
seg-138-v1-a1.ts
|
||||
#EXT-X-ENDLIST
|
||||
@@ -1,5 +1,8 @@
|
||||
package http
|
||||
|
||||
// DefaultUserAgent is shared by HTTP downloads and extension HTTP requests.
|
||||
const DefaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36"
|
||||
|
||||
type config struct {
|
||||
UserAgent string `json:"userAgent"`
|
||||
Connections int `json:"connections"`
|
||||
|
||||
+422
-231
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,391 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
gohttp "net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||
)
|
||||
|
||||
// serveRangeContent serves byte range requests or full responses from data.
|
||||
func serveRangeContent(w gohttp.ResponseWriter, r *gohttp.Request, data []byte) {
|
||||
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
|
||||
rangeHdr := r.Header.Get(base.HttpHeaderRange)
|
||||
total := int64(len(data))
|
||||
if rangeHdr == "" {
|
||||
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", total))
|
||||
w.WriteHeader(gohttp.StatusOK)
|
||||
_, _ = w.Write(data)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse bytes=start-end or bytes=start-
|
||||
if !strings.HasPrefix(rangeHdr, "bytes=") {
|
||||
w.WriteHeader(gohttp.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
spec := strings.TrimPrefix(rangeHdr, "bytes=")
|
||||
parts := strings.Split(spec, "-")
|
||||
start, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || start < 0 || start >= total {
|
||||
w.WriteHeader(gohttp.StatusRequestedRangeNotSatisfiable)
|
||||
return
|
||||
}
|
||||
end := total - 1
|
||||
if len(parts) > 1 && parts[1] != "" {
|
||||
if parsedEnd, err := strconv.ParseInt(parts[1], 10, 64); err == nil && parsedEnd >= start && parsedEnd < total {
|
||||
end = parsedEnd
|
||||
}
|
||||
}
|
||||
|
||||
contentLength := end - start + 1
|
||||
w.Header().Set(base.HttpHeaderContentRange, fmt.Sprintf("bytes %d-%d/%d", start, end, total))
|
||||
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", contentLength))
|
||||
w.WriteHeader(gohttp.StatusPartialContent)
|
||||
_, _ = w.Write(data[start : end+1])
|
||||
}
|
||||
|
||||
func TestFetcher_ChecksumMismatch_Retry(t *testing.T) {
|
||||
corruptContent := []byte("Corrupt Initial Download Data 33B")
|
||||
correctContent := []byte("Correct Retried Download Data 33B")
|
||||
|
||||
correctSha256Hasher := sha256.New()
|
||||
correctSha256Hasher.Write(correctContent)
|
||||
correctSha256 := hex.EncodeToString(correctSha256Hasher.Sum(nil))
|
||||
|
||||
var phase atomic.Int32 // 0: serve corruptContent, 1: serve correctContent
|
||||
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
|
||||
var served []byte
|
||||
if phase.Load() == 0 {
|
||||
served = corruptContent
|
||||
} else {
|
||||
served = correctContent
|
||||
}
|
||||
serveRangeContent(w, r, served)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
downloadDir := t.TempDir()
|
||||
fileName := "test_checksum_retry.txt"
|
||||
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
|
||||
opts := &base.Options{
|
||||
Path: downloadDir,
|
||||
Name: fileName,
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
Checksum: &http.ChecksumOption{
|
||||
Algorithm: "sha256",
|
||||
Expected: correctSha256,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// 1. Initial attempt -> will download corruptContent, failing checksum verification
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/retry_test.txt"}); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
f.Meta().Opts = opts
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error: %v", err)
|
||||
}
|
||||
err := f.Wait()
|
||||
if err == nil {
|
||||
t.Fatal("expected checksum mismatch error on initial attempt, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "checksum mismatch") {
|
||||
t.Fatalf("expected 'checksum mismatch' in error, got %v", err)
|
||||
}
|
||||
if f.getState() != stateError {
|
||||
t.Fatalf("expected stateError after checksum mismatch, got %v", f.getState())
|
||||
}
|
||||
|
||||
// Flip phase to 1 after observing checksum mismatch
|
||||
phase.Store(1)
|
||||
|
||||
// 2. Retry download -> should trigger forced full reset, truncate file, and re-download correctContent
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error on retry: %v", err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatalf("Wait error on retry (expected success): %v", err)
|
||||
}
|
||||
if f.getState() != stateDone {
|
||||
t.Fatalf("expected stateDone after successful retry, got %v", f.getState())
|
||||
}
|
||||
|
||||
// 3. Verify on-disk file content matches the re-downloaded bytes
|
||||
filePath := f.meta.SingleFilepath()
|
||||
fileBytes, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read completed file: %v", err)
|
||||
}
|
||||
if string(fileBytes) != string(correctContent) {
|
||||
t.Fatalf("on-disk file content mismatch:\ngot: %q\nwant: %q", string(fileBytes), string(correctContent))
|
||||
}
|
||||
|
||||
// 4. Compute hash independently and verify
|
||||
actualHasher := sha256.New()
|
||||
actualHasher.Write(fileBytes)
|
||||
actualHash := hex.EncodeToString(actualHasher.Sum(nil))
|
||||
if actualHash != correctSha256 {
|
||||
t.Fatalf("re-computed on-disk hash mismatch: got %s, want %s", actualHash, correctSha256)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcher_ChecksumMismatch_Retry_PartialPrefetch(t *testing.T) {
|
||||
corruptContent := []byte("Corrupt Initial Download Data 33B")
|
||||
correctContent := []byte("Correct Retried Download Data 33B")
|
||||
|
||||
correctSha256Hasher := sha256.New()
|
||||
correctSha256Hasher.Write(correctContent)
|
||||
correctSha256 := hex.EncodeToString(correctSha256Hasher.Sum(nil))
|
||||
|
||||
var phase atomic.Int32 // 0: corrupt, 1: correct
|
||||
var isFirstRequest atomic.Bool
|
||||
isFirstRequest.Store(true)
|
||||
|
||||
releasePrefetch := make(chan struct{})
|
||||
var closeReleaseOnce sync.Once
|
||||
|
||||
var mu sync.Mutex
|
||||
var phase1Ranges []string
|
||||
|
||||
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
|
||||
curPhase := phase.Load()
|
||||
if curPhase == 1 {
|
||||
mu.Lock()
|
||||
phase1Ranges = append(phase1Ranges, r.Header.Get(base.HttpHeaderRange))
|
||||
mu.Unlock()
|
||||
serveRangeContent(w, r, correctContent)
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 0: First request is Resolve GET without Range header.
|
||||
// Write exactly 7 bytes and flush, then block until release channel closes or context is done.
|
||||
if isFirstRequest.CompareAndSwap(true, false) {
|
||||
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
|
||||
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(corruptContent)))
|
||||
w.WriteHeader(gohttp.StatusOK)
|
||||
_, _ = w.Write(corruptContent[:7])
|
||||
if flusher, ok := w.(gohttp.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
select {
|
||||
case <-releasePrefetch:
|
||||
case <-r.Context().Done():
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Subsequent requests in Phase 0 (such as Range request from Start)
|
||||
serveRangeContent(w, r, corruptContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
defer closeReleaseOnce.Do(func() { close(releasePrefetch) })
|
||||
|
||||
downloadDir := t.TempDir()
|
||||
fileName := "test_checksum_prefetch_retry.txt"
|
||||
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
|
||||
opts := &base.Options{
|
||||
Path: downloadDir,
|
||||
Name: fileName,
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
Checksum: &http.ChecksumOption{
|
||||
Algorithm: "sha256",
|
||||
Expected: correctSha256,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// 1. Resolve phase
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/prefetch_retry.txt"}); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
f.Meta().Opts = opts
|
||||
|
||||
// Wait until prefetch has received exactly 7 bytes
|
||||
for i := 0; i < 200; i++ {
|
||||
if f.prefetchSize.Load() >= 7 {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if f.prefetchSize.Load() < 7 {
|
||||
t.Fatalf("expected at least 7 prefetched bytes, got %d", f.prefetchSize.Load())
|
||||
}
|
||||
|
||||
// Release prefetch handler right before Start
|
||||
closeReleaseOnce.Do(func() { close(releasePrefetch) })
|
||||
|
||||
// 2. Start initial download attempt (will complete with corruptContent and fail checksum)
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error: %v", err)
|
||||
}
|
||||
err := f.Wait()
|
||||
if err == nil {
|
||||
t.Fatal("expected checksum mismatch error on initial attempt, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "checksum mismatch") {
|
||||
t.Fatalf("expected 'checksum mismatch' in error, got %v", err)
|
||||
}
|
||||
if f.getState() != stateError {
|
||||
t.Fatalf("expected stateError after checksum mismatch, got %v", f.getState())
|
||||
}
|
||||
|
||||
// 3. Flip to phase 1 (correctContent) and retry
|
||||
phase.Store(1)
|
||||
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error on retry: %v", err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatalf("Wait error on retry (expected success): %v", err)
|
||||
}
|
||||
if f.getState() != stateDone {
|
||||
t.Fatalf("expected stateDone after successful retry, got %v", f.getState())
|
||||
}
|
||||
|
||||
// Verify that in Phase 1 at least one Range request started at byte 0
|
||||
mu.Lock()
|
||||
rangesSeen := append([]string(nil), phase1Ranges...)
|
||||
mu.Unlock()
|
||||
|
||||
hasByteZero := false
|
||||
for _, r := range rangesSeen {
|
||||
if strings.HasPrefix(r, "bytes=0-") {
|
||||
hasByteZero = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasByteZero {
|
||||
t.Fatalf("expected at least one Phase 1 Range request starting at byte 0, saw: %v", rangesSeen)
|
||||
}
|
||||
|
||||
// Verify on-disk content matches correctContent exactly
|
||||
filePath := f.meta.SingleFilepath()
|
||||
fileBytes, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read completed file: %v", err)
|
||||
}
|
||||
if string(fileBytes) != string(correctContent) {
|
||||
t.Fatalf("on-disk file content mismatch:\ngot: %q\nwant: %q", string(fileBytes), string(correctContent))
|
||||
}
|
||||
|
||||
// Compute hash independently and verify
|
||||
actualHasher := sha256.New()
|
||||
actualHasher.Write(fileBytes)
|
||||
actualHash := hex.EncodeToString(actualHasher.Sum(nil))
|
||||
if actualHash != correctSha256 {
|
||||
t.Fatalf("re-computed on-disk hash mismatch: got %s, want %s", actualHash, correctSha256)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcher_ChecksumMismatch_Retry_NonRange(t *testing.T) {
|
||||
corruptContent := []byte("Corrupt Non-Range Initial Data 33B")
|
||||
correctContent := []byte("Correct Non-Range Retried Data 33B")
|
||||
|
||||
correctSha256Hasher := sha256.New()
|
||||
correctSha256Hasher.Write(correctContent)
|
||||
correctSha256 := hex.EncodeToString(correctSha256Hasher.Sum(nil))
|
||||
|
||||
var phase atomic.Int32 // 0: corrupt, 1: correct
|
||||
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
|
||||
// Server does not support Range (always returns full content 200 OK)
|
||||
var served []byte
|
||||
if phase.Load() == 0 {
|
||||
served = corruptContent
|
||||
} else {
|
||||
served = correctContent
|
||||
}
|
||||
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(served)))
|
||||
w.WriteHeader(gohttp.StatusOK)
|
||||
_, _ = w.Write(served)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
downloadDir := t.TempDir()
|
||||
fileName := "test_checksum_nonrange_retry.txt"
|
||||
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
|
||||
opts := &base.Options{
|
||||
Path: downloadDir,
|
||||
Name: fileName,
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
Checksum: &http.ChecksumOption{
|
||||
Algorithm: "sha256",
|
||||
Expected: correctSha256,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// 1. Resolve and start initial attempt
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/nonrange_retry.txt"}); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
f.Meta().Opts = opts
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error: %v", err)
|
||||
}
|
||||
err := f.Wait()
|
||||
if err == nil {
|
||||
t.Fatal("expected checksum mismatch error on initial non-range attempt, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "checksum mismatch") {
|
||||
t.Fatalf("expected 'checksum mismatch' in error, got %v", err)
|
||||
}
|
||||
if f.getState() != stateError {
|
||||
t.Fatalf("expected stateError after checksum mismatch, got %v", f.getState())
|
||||
}
|
||||
|
||||
// 2. Switch phase and retry
|
||||
phase.Store(1)
|
||||
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error on non-range retry: %v", err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatalf("Wait error on non-range retry (expected success): %v", err)
|
||||
}
|
||||
if f.getState() != stateDone {
|
||||
t.Fatalf("expected stateDone after successful non-range retry, got %v", f.getState())
|
||||
}
|
||||
|
||||
// 3. Verify on-disk file content
|
||||
filePath := f.meta.SingleFilepath()
|
||||
fileBytes, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read completed file: %v", err)
|
||||
}
|
||||
if string(fileBytes) != string(correctContent) {
|
||||
t.Fatalf("on-disk file content mismatch:\ngot: %q\nwant: %q", string(fileBytes), string(correctContent))
|
||||
}
|
||||
|
||||
// 4. Compute hash independently and verify
|
||||
actualHasher := sha256.New()
|
||||
actualHasher.Write(fileBytes)
|
||||
actualHash := hex.EncodeToString(actualHasher.Sum(nil))
|
||||
if actualHash != correctSha256 {
|
||||
t.Fatalf("re-computed on-disk hash mismatch: got %s, want %s", actualHash, correctSha256)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
gohttp "net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/test"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||
)
|
||||
|
||||
func TestFetcher_ChecksumVerification(t *testing.T) {
|
||||
content := []byte("Hello, Gopeed Checksum Verification Test Data!")
|
||||
md5Hasher := md5.New()
|
||||
md5Hasher.Write(content)
|
||||
correctMd5 := hex.EncodeToString(md5Hasher.Sum(nil))
|
||||
|
||||
sha256Hasher := sha256.New()
|
||||
sha256Hasher.Write(content)
|
||||
correctSha256 := hex.EncodeToString(sha256Hasher.Sum(nil))
|
||||
|
||||
wrongHash := "0123456789abcdef0123456789abcdef"
|
||||
|
||||
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
|
||||
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(content)))
|
||||
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
|
||||
w.WriteHeader(gohttp.StatusOK)
|
||||
_, _ = w.Write(content)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
t.Run("Valid MD5", func(t *testing.T) {
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
|
||||
opts := &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "test_valid_md5.txt",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
Checksum: &http.ChecksumOption{
|
||||
Algorithm: "md5",
|
||||
Expected: correctMd5,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
|
||||
f.Meta().Opts = opts
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error: %v", err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatalf("Wait error (expected success): %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Valid SHA256", func(t *testing.T) {
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
|
||||
opts := &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "test_valid_sha256.txt",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
Checksum: &http.ChecksumOption{
|
||||
Algorithm: "sha256",
|
||||
Expected: correctSha256,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
|
||||
f.Meta().Opts = opts
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error: %v", err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatalf("Wait error (expected success): %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Wrong MD5 mismatch", func(t *testing.T) {
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
|
||||
opts := &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "test_wrong_md5.txt",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
Checksum: &http.ChecksumOption{
|
||||
Algorithm: "md5",
|
||||
Expected: wrongHash,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
|
||||
f.Meta().Opts = opts
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error: %v", err)
|
||||
}
|
||||
err := f.Wait()
|
||||
if err == nil {
|
||||
t.Fatal("Wait expected checksum mismatch error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "checksum mismatch") {
|
||||
t.Fatalf("expected 'checksum mismatch' in error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Unsupported Algorithm", func(t *testing.T) {
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
|
||||
opts := &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "test_unsupported.txt",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
Checksum: &http.ChecksumOption{
|
||||
Algorithm: "invalid-algo",
|
||||
Expected: wrongHash,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
f.Meta().Opts = opts
|
||||
err := f.Start()
|
||||
if err == nil {
|
||||
t.Fatal("Start expected error for unsupported algorithm, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unsupported checksum algorithm") {
|
||||
t.Fatalf("expected 'unsupported checksum algorithm' in error, got %v", err)
|
||||
}
|
||||
if f.checksumFailed.Load() {
|
||||
t.Fatal("checksumFailed flag should NOT be set on unsupported algorithm error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Case-Insensitive Hash Comparison", func(t *testing.T) {
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
|
||||
opts := &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "test_case_insensitive.txt",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
Checksum: &http.ChecksumOption{
|
||||
Algorithm: "sha256",
|
||||
Expected: strings.ToUpper(correctSha256),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
|
||||
f.Meta().Opts = opts
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error: %v", err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatalf("Wait error (expected success with upper case hash): %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Empty Expected Checksum (Skipped)", func(t *testing.T) {
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
|
||||
opts := &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "test_empty_expected.txt",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
Checksum: &http.ChecksumOption{
|
||||
Algorithm: "",
|
||||
Expected: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
|
||||
f.Meta().Opts = opts
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error: %v", err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatalf("Wait error (expected skip and success): %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Large File Multi-Chunk Streaming (>64KB)", func(t *testing.T) {
|
||||
largeData := make([]byte, 128*1024)
|
||||
for i := range largeData {
|
||||
largeData[i] = byte(i % 256)
|
||||
}
|
||||
largeSha256Hasher := sha256.New()
|
||||
largeSha256Hasher.Write(largeData)
|
||||
largeSha256 := hex.EncodeToString(largeSha256Hasher.Sum(nil))
|
||||
|
||||
largeServer := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
|
||||
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(largeData)))
|
||||
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
|
||||
w.WriteHeader(gohttp.StatusOK)
|
||||
_, _ = w.Write(largeData)
|
||||
}))
|
||||
defer largeServer.Close()
|
||||
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
|
||||
opts := &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "test_large_file.bin",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
Checksum: &http.ChecksumOption{
|
||||
Algorithm: "sha256",
|
||||
Expected: largeSha256,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := f.Resolve(&base.Request{URL: largeServer.URL + "/large.bin"}); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
|
||||
f.Meta().Opts = opts
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error: %v", err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatalf("Wait error (expected large file checksum success): %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("No Checksum (Opt-in backward compatibility)", func(t *testing.T) {
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
|
||||
opts := &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: test.DownloadName,
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
},
|
||||
}
|
||||
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
|
||||
f.Meta().Opts = opts
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error: %v", err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatalf("Wait error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Invalid Checksum In Start Makes No Additional Requests", func(t *testing.T) {
|
||||
var reqReceived atomic.Int32
|
||||
customServer := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
|
||||
reqReceived.Add(1)
|
||||
w.WriteHeader(gohttp.StatusOK)
|
||||
_, _ = w.Write([]byte("some data"))
|
||||
}))
|
||||
defer customServer.Close()
|
||||
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
|
||||
opts := &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "test_invalid_checksum.txt",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
Checksum: &http.ChecksumOption{
|
||||
Algorithm: "sha256",
|
||||
Expected: "invalid-hex-chars-not-valid-hex-00000000000000000000000000000000",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := f.Resolve(&base.Request{URL: customServer.URL + "/test.txt"}); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
requestsAfterResolve := reqReceived.Load()
|
||||
f.Meta().Opts = opts
|
||||
err := f.Start()
|
||||
if err == nil {
|
||||
t.Fatal("Start expected error for invalid checksum hash, got nil")
|
||||
}
|
||||
if got := reqReceived.Load(); got != requestsAfterResolve {
|
||||
t.Fatalf("invalid checksum in Start issued additional requests: got %d, had %d after Resolve", got, requestsAfterResolve)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -19,10 +19,11 @@ type fetcherData struct {
|
||||
IfRange string // Strong ETag or Last-Modified validator for safe resume
|
||||
RangeReprobeEligible bool // Origin advertised Range before falling back to sequential mode
|
||||
RangeValidatorPinned bool // Recovered Range mode must keep the same If-Range validator
|
||||
SequentialSizeUnknown bool // Interrupted chunked restart must retry from byte zero
|
||||
SequentialSizeUnknown bool // Interrupted chunked sequential streams restart from byte zero
|
||||
SizeKnown *bool // Nil for legacy snapshots; false means Size is only a display estimate
|
||||
Range *bool // Authoritative Range mode; nil for records saved by older versions
|
||||
ResourceSize *int64 // Authoritative resource size from the same snapshot as Connections
|
||||
FileSize *int64 // Authoritative single-file size; nil for older records
|
||||
ResourceSize *int64 // Display size from the same snapshot; authority is recorded by SizeKnown
|
||||
FileSize *int64 // Single-file size from the same snapshot; nil for older records
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -72,7 +73,7 @@ func (fm *FetcherManager) AutoRename() bool {
|
||||
|
||||
func (fm *FetcherManager) DefaultConfig() any {
|
||||
return &config{
|
||||
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36",
|
||||
UserAgent: DefaultUserAgent,
|
||||
Connections: 16,
|
||||
}
|
||||
}
|
||||
@@ -93,6 +94,7 @@ func (fm *FetcherManager) Store(f fetcher.Fetcher) (data any, err error) {
|
||||
rangeReprobeEligible := _f.rangeReprobeEligible
|
||||
rangeValidatorPinned := _f.rangeValidatorPinned
|
||||
sequentialSizeUnknown := _f.sequentialSizeUnknown
|
||||
sizeKnown := _f.hasKnownSize()
|
||||
var rangeMode *bool
|
||||
var resourceSize *int64
|
||||
var fileSize *int64
|
||||
@@ -115,6 +117,7 @@ func (fm *FetcherManager) Store(f fetcher.Fetcher) (data any, err error) {
|
||||
RangeReprobeEligible: rangeReprobeEligible,
|
||||
RangeValidatorPinned: rangeValidatorPinned,
|
||||
SequentialSizeUnknown: sequentialSizeUnknown,
|
||||
SizeKnown: &sizeKnown,
|
||||
Range: rangeMode,
|
||||
ResourceSize: resourceSize,
|
||||
FileSize: fileSize,
|
||||
@@ -168,6 +171,10 @@ func (fm *FetcherManager) Restore() (v any, f func(meta *fetcher.FetcherMeta, v
|
||||
fetcher.rangeReprobeEligible = fd.RangeReprobeEligible
|
||||
fetcher.rangeValidatorPinned = fd.RangeValidatorPinned
|
||||
fetcher.sequentialSizeUnknown = fd.SequentialSizeUnknown
|
||||
if fd.SizeKnown != nil {
|
||||
known := *fd.SizeKnown
|
||||
fetcher.sizeKnown = &known
|
||||
}
|
||||
if fd.Range != nil && fetcher.meta.Res != nil {
|
||||
// Recovery-critical resource fields live in the same persisted snapshot
|
||||
// as Connections, making them authoritative over task metadata that may
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -232,10 +233,11 @@ func TestFetcher_ResolveFilenameFromContentType(t *testing.T) {
|
||||
defer fetcher.Pause()
|
||||
|
||||
const opaqueName = "OIP-C.u5nEXTXND3PJF0gNTgw5YgHaFP"
|
||||
err := fetcher.Resolve(&base.Request{URL: server.URL + "/" + opaqueName}, &base.Options{
|
||||
fetcher.Meta().Opts = &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
})
|
||||
}
|
||||
err := fetcher.Resolve(&base.Request{URL: server.URL + "/" + opaqueName})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -250,6 +252,10 @@ func TestFetcher_ResolveWithHostHeader(t *testing.T) {
|
||||
defer listener.Close()
|
||||
|
||||
fetcher := buildFetcher()
|
||||
fetcher.Meta().Opts = &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
}
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/",
|
||||
Extra: &http.ReqExtra{
|
||||
@@ -257,9 +263,6 @@ func TestFetcher_ResolveWithHostHeader(t *testing.T) {
|
||||
"Host": "test",
|
||||
},
|
||||
},
|
||||
}, &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
})
|
||||
// The server should return 400 for invalid Host header
|
||||
if err == nil || !strings.Contains(err.Error(), "400") {
|
||||
@@ -273,6 +276,10 @@ func TestFetcher_ResolveWithInvalidHeader(t *testing.T) {
|
||||
|
||||
fetcher := buildFetcher()
|
||||
defer fetcher.Pause() // Close the resolve response to allow server shutdown
|
||||
fetcher.Meta().Opts = &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
}
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/",
|
||||
Extra: &http.ReqExtra{
|
||||
@@ -280,9 +287,6 @@ func TestFetcher_ResolveWithInvalidHeader(t *testing.T) {
|
||||
"Referer": "\rtest",
|
||||
},
|
||||
},
|
||||
}, &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
})
|
||||
// Invalid header with \r should be sanitized by Go's http client, allowing the request to succeed
|
||||
if err != nil {
|
||||
@@ -302,12 +306,13 @@ func TestFetcher_ResolveAutomaticallyFallsBackToBrowserFingerprint(t *testing.T)
|
||||
|
||||
fetcher := buildFetcher()
|
||||
defer fetcher.Pause()
|
||||
fetcher.Meta().Opts = &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
}
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: server.URL + "/file.bin",
|
||||
SkipVerifyCert: true,
|
||||
}, &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
@@ -368,11 +373,12 @@ func testResolve(startTestServer func() net.Listener, path string, t *testing.T,
|
||||
defer listener.Close()
|
||||
fetcher := buildFetcher()
|
||||
defer fetcher.Pause() // Close the resolve response to allow server shutdown
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + path,
|
||||
}, &base.Options{
|
||||
fetcher.Meta().Opts = &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
}
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + path,
|
||||
})
|
||||
want, err := wantFn(err)
|
||||
if err != nil {
|
||||
@@ -424,13 +430,14 @@ func TestFetcher_PauseBeforeStartDiscardsPrefetchProgress(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
fetcher := buildFetcher()
|
||||
if err := fetcher.Resolve(&base.Request{URL: server.URL + "/" + test.BuildName}, &base.Options{
|
||||
fetcher.Meta().Opts = &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: test.DownloadName,
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 2,
|
||||
},
|
||||
}); err != nil {
|
||||
}
|
||||
if err := fetcher.Resolve(&base.Request{URL: server.URL + "/" + test.BuildName}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
@@ -771,13 +778,14 @@ func TestFetcher_SequentialRetryReprobesRange(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
f := buildFetcher()
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/dynamic-range.data"}, &base.Options{
|
||||
f.Meta().Opts = &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "dynamic-range.data",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 4,
|
||||
},
|
||||
}); err != nil {
|
||||
}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/dynamic-range.data"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
@@ -853,31 +861,44 @@ func TestFetcher_InitialIgnoredRangeUsesResponseSize(t *testing.T) {
|
||||
resolvePayload := bytes.Repeat([]byte("resolve"), 64*1024)
|
||||
replacement := bytes.Repeat([]byte("replacement"), 24*1024)
|
||||
var rangeRequests atomic.Int32
|
||||
rangeStarted := make(chan struct{})
|
||||
releaseResolve := sync.OnceFunc(func() { close(rangeStarted) })
|
||||
|
||||
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
|
||||
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
|
||||
if r.Header.Get(base.HttpHeaderRange) == "" {
|
||||
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(resolvePayload)))
|
||||
w.WriteHeader(gohttp.StatusOK)
|
||||
w.(gohttp.Flusher).Flush()
|
||||
// Keep Resolve prefetch incomplete until the Range request starts.
|
||||
// Otherwise Start may reuse the completed body and skip this scenario.
|
||||
select {
|
||||
case <-rangeStarted:
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(resolvePayload)
|
||||
return
|
||||
}
|
||||
|
||||
rangeRequests.Add(1)
|
||||
releaseResolve()
|
||||
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(replacement)))
|
||||
w.WriteHeader(gohttp.StatusOK)
|
||||
_, _ = w.Write(replacement)
|
||||
}))
|
||||
defer server.Close()
|
||||
defer releaseResolve()
|
||||
|
||||
f := buildFetcher()
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/ignored-range.data"}, &base.Options{
|
||||
f.Meta().Opts = &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "ignored-range.data",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 4,
|
||||
},
|
||||
}); err != nil {
|
||||
}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/ignored-range.data"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
@@ -968,13 +989,14 @@ func TestFetcher_InterruptedChunkedReplacementRestartsFromZero(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
f := buildFetcher()
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/chunked-replacement.data"}, &base.Options{
|
||||
f.Meta().Opts = &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "chunked-replacement.data",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 4,
|
||||
},
|
||||
}); err != nil {
|
||||
}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/chunked-replacement.data"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
@@ -1154,13 +1176,14 @@ func TestFetcher_UsesResolveResponseWhenRangeTakeoverFails(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
f := buildFetcher()
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/resolve-fallback.data"}, &base.Options{
|
||||
f.Meta().Opts = &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "resolve-fallback.data",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
},
|
||||
}); err != nil {
|
||||
}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/resolve-fallback.data"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
@@ -1233,13 +1256,14 @@ func TestFetcher_ValidRangeTakesOverResolveResponse(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
f := buildFetcher()
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/resolve-takeover.data"}, &base.Options{
|
||||
f.Meta().Opts = &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "resolve-takeover.data",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 4,
|
||||
},
|
||||
}); err != nil {
|
||||
}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/resolve-takeover.data"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
@@ -1263,6 +1287,12 @@ func TestFetcher_ValidRangeTakesOverResolveResponse(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFetcher_UnknownSizeRangeUsesOpenEndedWorker(t *testing.T) {
|
||||
for _, hint := range []int64{0, 3, 8 * 1024 * 1024} {
|
||||
t.Run(fmt.Sprint(hint), func(t *testing.T) { testFetcherUnknownSizeRange(t, hint) })
|
||||
}
|
||||
}
|
||||
|
||||
func testFetcherUnknownSizeRange(t *testing.T, hint int64) {
|
||||
const resolveETag = `"unknown-size-v1"`
|
||||
payload := make([]byte, 4*1024*1024)
|
||||
for i := range payload {
|
||||
@@ -1327,18 +1357,20 @@ func TestFetcher_UnknownSizeRangeUsesOpenEndedWorker(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
f := buildFetcher()
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/unknown-size-range.data"}, &base.Options{
|
||||
f.Meta().Opts = &base.Options{
|
||||
Size: hint,
|
||||
Path: t.TempDir(),
|
||||
Name: "unknown-size-range.data",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 4,
|
||||
},
|
||||
}); err != nil {
|
||||
}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/unknown-size-range.data"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if f.meta.Res.Size != 0 || !f.meta.Res.Range {
|
||||
if f.meta.Res.Size != hint || f.hasKnownSize() || !f.meta.Res.Range {
|
||||
t.Fatalf("resolved resource = %+v, want unknown size with Range support", f.meta.Res)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
@@ -1421,13 +1453,14 @@ func TestFetcher_UnknownSizeRangeRetriesShortPartialBody(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
f := buildFetcher()
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/short-range.data"}, &base.Options{
|
||||
f.Meta().Opts = &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "short-range.data",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
},
|
||||
}); err != nil {
|
||||
}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/short-range.data"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
@@ -1480,13 +1513,14 @@ func TestFetcher_UnknownSizeRangeFallsBackToResolve(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
f := buildFetcher()
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/unknown-size-fallback.data"}, &base.Options{
|
||||
f.Meta().Opts = &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "unknown-size-fallback.data",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
},
|
||||
}); err != nil {
|
||||
}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/unknown-size-fallback.data"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
@@ -1567,13 +1601,15 @@ func TestFetcher_UnknownSizeRangeRemainsOpenEndedAfterPause(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
f := buildFetcher()
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/pause-open-range.data"}, &base.Options{
|
||||
f.Meta().Opts = &base.Options{
|
||||
Size: 3,
|
||||
Path: t.TempDir(),
|
||||
Name: "pause-open-range.data",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
},
|
||||
}); err != nil {
|
||||
}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/pause-open-range.data"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
@@ -1622,13 +1658,14 @@ func TestFetcher_UnknownSizeResolveReadErrorFailsTask(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
f := buildFetcher()
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/broken-chunked.data"}, &base.Options{
|
||||
f.Meta().Opts = &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "broken-chunked.data",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
},
|
||||
}); err != nil {
|
||||
}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/broken-chunked.data"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
@@ -1654,13 +1691,14 @@ func TestFetcher_UnknownSizeResolveCleanEOFUpdatesSize(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
f := buildFetcher()
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/unknown-size.data"}, &base.Options{
|
||||
f.Meta().Opts = &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "unknown-size.data",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
},
|
||||
}); err != nil {
|
||||
}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/unknown-size.data"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
@@ -2256,14 +2294,15 @@ func TestFetcher_DownloadOneTimeURL(t *testing.T) {
|
||||
defer listener.Close()
|
||||
|
||||
fetcher := buildFetcher()
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}, &base.Options{
|
||||
fetcher.Meta().Opts = &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 4, // Try to use multiple connections, but only first should work
|
||||
},
|
||||
}
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -2322,14 +2361,15 @@ func TestFetcher_SlowStartExpansion(t *testing.T) {
|
||||
Connections: tc.maxConns,
|
||||
})
|
||||
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}, &base.Options{
|
||||
fetcher.Meta().Opts = &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: tc.maxConns,
|
||||
},
|
||||
}
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
})
|
||||
if err != nil {
|
||||
cleanup()
|
||||
@@ -2385,14 +2425,15 @@ func TestFetcher_AsyncPrefetch(t *testing.T) {
|
||||
defer listener.Close()
|
||||
|
||||
fetcher := buildFetcher()
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}, &base.Options{
|
||||
fetcher.Meta().Opts = &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 4,
|
||||
},
|
||||
}
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -2456,14 +2497,15 @@ func TestFetcher_AsyncPrefetch(t *testing.T) {
|
||||
defer listener.Close()
|
||||
|
||||
fetcher := buildFetcher()
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}, &base.Options{
|
||||
fetcher.Meta().Opts = &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 4,
|
||||
},
|
||||
}
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -2538,14 +2580,15 @@ func TestFetcher_DownloadExpiringRedirectURL(t *testing.T) {
|
||||
defer listener.Close()
|
||||
|
||||
fetcher := buildFetcher()
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}, &base.Options{
|
||||
fetcher.Meta().Opts = &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 4, // Use multiple connections to trigger redirect expiration
|
||||
},
|
||||
}
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -2579,14 +2622,15 @@ func TestFetcher_DownloadExpiringRedirectURL(t *testing.T) {
|
||||
defer listener.Close()
|
||||
|
||||
fetcher := buildFetcher()
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}, &base.Options{
|
||||
fetcher.Meta().Opts = &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 8,
|
||||
},
|
||||
}
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -2626,14 +2670,15 @@ func TestFetcher_RetryAfterError(t *testing.T) {
|
||||
defer listener.Close()
|
||||
|
||||
fetcher := buildFetcher()
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}, &base.Options{
|
||||
fetcher.Meta().Opts = &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1, // Use single connection to simplify test
|
||||
},
|
||||
}
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -3054,9 +3099,10 @@ func doDownloadReady(f fetcher.Fetcher, listener net.Listener, connections int,
|
||||
Path: test.Dir,
|
||||
Extra: extra,
|
||||
}
|
||||
f.Meta().Opts = opts
|
||||
err := f.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}, opts)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -3105,7 +3151,8 @@ func downloadPost(listener net.Listener, connections int, t *testing.T) {
|
||||
Body: fmt.Sprintf(`{"name":"%s"}`, test.BuildName),
|
||||
},
|
||||
}
|
||||
err := f.Resolve(req, opts)
|
||||
f.Meta().Opts = opts
|
||||
err := f.Resolve(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -3151,11 +3198,12 @@ func downloadContinue(listener net.Listener, connections int, t *testing.T) {
|
||||
|
||||
func downloadError(listener net.Listener, connections int, t *testing.T) {
|
||||
fetcher := buildFetcher()
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}, &base.Options{
|
||||
fetcher.Meta().Opts = &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
}
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
})
|
||||
// With the new async design, Resolve may succeed (HTTP response received)
|
||||
// but errors occur during async download or Start/Wait
|
||||
@@ -3213,16 +3261,17 @@ func downloadResume(listener net.Listener, connections int, t *testing.T) {
|
||||
}
|
||||
|
||||
func downloadWithProxy(httpListener net.Listener, proxyListener net.Listener, t *testing.T) {
|
||||
fetcher := downloadReady(httpListener, 4, t)
|
||||
ctl := controller.NewController()
|
||||
ctl.GetProxy = func(requestProxy *base.RequestProxy) func(*gohttp.Request) (*url.URL, error) {
|
||||
// Setup must run before Resolve, as in the downloader, so the Resolve
|
||||
// request also goes through the proxy.
|
||||
f := buildFetcher()
|
||||
f.ctl.GetProxy = func(requestProxy *base.RequestProxy) func(*gohttp.Request) (*url.URL, error) {
|
||||
return (&base.DownloaderProxyConfig{
|
||||
Enable: true,
|
||||
Scheme: "socks5",
|
||||
Host: proxyListener.Addr().String(),
|
||||
}).ToHandler()
|
||||
}
|
||||
fetcher.Setup(ctl)
|
||||
fetcher := doDownloadReady(f, httpListener, 4, t)
|
||||
err := fetcher.Start()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -3279,7 +3328,8 @@ func TestFetcher_Patch_URLChange(t *testing.T) {
|
||||
}
|
||||
|
||||
// Step 1: Try to resolve with bad URL - should fail with error
|
||||
err := f.Resolve(&base.Request{URL: badURL}, opts)
|
||||
f.Meta().Opts = opts
|
||||
err := f.Resolve(&base.Request{URL: badURL})
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for bad URL, got nil")
|
||||
}
|
||||
@@ -3289,7 +3339,8 @@ func TestFetcher_Patch_URLChange(t *testing.T) {
|
||||
f2 := buildFetcher()
|
||||
|
||||
// First resolve with good URL to create a valid fetcher state
|
||||
err = f2.Resolve(&base.Request{URL: goodURL}, opts)
|
||||
f2.Meta().Opts = opts
|
||||
err = f2.Resolve(&base.Request{URL: goodURL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -3341,13 +3392,14 @@ func TestFetcher_Patch_Labels(t *testing.T) {
|
||||
Extra: &http.OptsExtra{Connections: 1},
|
||||
}
|
||||
|
||||
f.Meta().Opts = opts
|
||||
err := f.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
Labels: map[string]string{
|
||||
"key1": "value1",
|
||||
"key3": "value3",
|
||||
},
|
||||
}, opts)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -3398,6 +3450,7 @@ func TestFetcher_Patch_Extra(t *testing.T) {
|
||||
}
|
||||
|
||||
// Resolve with initial Extra
|
||||
f.Meta().Opts = opts
|
||||
err := f.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
Extra: &http.ReqExtra{
|
||||
@@ -3408,7 +3461,7 @@ func TestFetcher_Patch_Extra(t *testing.T) {
|
||||
"X-Custom": "original",
|
||||
},
|
||||
},
|
||||
}, opts)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -3486,9 +3539,10 @@ func TestFetcher_Patch_NilData(t *testing.T) {
|
||||
Extra: &http.OptsExtra{Connections: 1},
|
||||
}
|
||||
|
||||
f.Meta().Opts = opts
|
||||
err := f.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}, opts)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -3538,6 +3592,7 @@ func TestFetcher_Patch_CookieExpired(t *testing.T) {
|
||||
|
||||
// Step 1: Resolve with old_token - should succeed (first request accepts old_token)
|
||||
f := buildFetcher()
|
||||
f.Meta().Opts = opts
|
||||
err := f.Resolve(&base.Request{
|
||||
URL: downloadURL,
|
||||
Extra: &http.ReqExtra{
|
||||
@@ -3545,7 +3600,7 @@ func TestFetcher_Patch_CookieExpired(t *testing.T) {
|
||||
"Cookie": "session=old_token",
|
||||
},
|
||||
},
|
||||
}, opts)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve should succeed with old_token: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
"github.com/GopeedLab/gopeed/internal/tempfiles"
|
||||
)
|
||||
|
||||
type prefetchSetupBody struct {
|
||||
io.Reader
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (b *prefetchSetupBody) Close() error {
|
||||
b.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestPrefetchSetupFailureClosesResponse(t *testing.T) {
|
||||
for _, closedScope := range []bool{false, true} {
|
||||
name := "invalid_temp_directory"
|
||||
if closedScope {
|
||||
name = "downloader_already_closed"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
scope := &tempfiles.Scope{}
|
||||
dir := root
|
||||
if closedScope {
|
||||
if err := scope.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
dir = filepath.Join(root, "file")
|
||||
if err := os.WriteFile(dir, nil, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
body := &prefetchSetupBody{Reader: strings.NewReader("unused response")}
|
||||
f := &Fetcher{
|
||||
ctl: &controller.Controller{TempDir: dir, TempFiles: scope},
|
||||
resolveResp: &http.Response{Body: body},
|
||||
}
|
||||
f.asyncPrefetch()
|
||||
if f.prefetchErr == nil {
|
||||
t.Fatal("expected temporary-file initialization to fail")
|
||||
}
|
||||
if !body.closed {
|
||||
t.Error("prefetch setup failure left the response body open")
|
||||
}
|
||||
if f.resolveResp != nil {
|
||||
t.Error("prefetch retained its failed resolve response")
|
||||
}
|
||||
if !f.prefetchDone.Load() {
|
||||
t.Error("prefetch did not report completion")
|
||||
}
|
||||
if closedScope {
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("late prefetch left temporary files: %v", entries)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
gohttp "net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||
)
|
||||
|
||||
// Start can run after the Resolve prefetch wrote every byte but before it set
|
||||
// prefetchDone. The prefetched body must still be copied to the target.
|
||||
func TestFetcher_StartBeforePrefetchDoneCopiesFullBody(t *testing.T) {
|
||||
payload := []byte("prefetched before Start")
|
||||
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
|
||||
gohttp.ServeContent(w, r, "f.txt", time.Time{}, bytes.NewReader(payload))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
f := buildFetcher()
|
||||
f.Meta().Opts = &base.Options{Path: t.TempDir(), Name: "f.txt", Extra: &http.OptsExtra{Connections: 1}}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/f.txt"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for !f.prefetchDone.Load() {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
// Reopen the window between the last write and prefetchDone.
|
||||
f.prefetchDone.Store(false)
|
||||
go func() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
f.prefetchDone.Store(true)
|
||||
}()
|
||||
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(f.meta.SingleFilepath())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Fatalf("content = %q, want %q", got, payload)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
fhttp "github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||
)
|
||||
|
||||
func TestResolveResponseSize(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
status int
|
||||
length, contentRange string
|
||||
size int64
|
||||
invalid bool
|
||||
}{
|
||||
{"full", 200, "801571015", "", 801571015, false},
|
||||
{"partial_prefix", 206, "472321339", "bytes 0-472321338/801571015", 801571015, false},
|
||||
{"partial_nonzero", 206, "100000", "bytes 100000-199999/801571015", 801571015, false},
|
||||
{"second_report", 206, "335134409", "bytes 0-335134408/552140234", 552140234, false},
|
||||
{"length_mismatch", 206, "10", "bytes 0-19/100", 0, true},
|
||||
{"unknown_total", 206, "472321339", "bytes 0-472321338/*", 0, true},
|
||||
{"missing_range", 206, "10", "", 0, true},
|
||||
{"malformed_range", 206, "10", "bytes invalid", 0, true},
|
||||
{"invalid_bounds", 206, "10", "bytes 20-10/100", 0, true},
|
||||
{"invalid_total", 206, "10", "bytes 0-9/9", 0, true},
|
||||
{"overflow", 206, "10", "bytes 0-9/9223372036854775808", 0, true},
|
||||
{"chunked_partial", 206, "", "bytes 0-9/100", 100, false},
|
||||
{"unknown_full", 200, "", "", 0, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if tc.length != "" {
|
||||
w.Header().Set("Content-Length", tc.length)
|
||||
}
|
||||
if tc.contentRange != "" {
|
||||
w.Header().Set("Content-Range", tc.contentRange)
|
||||
}
|
||||
w.WriteHeader(tc.status)
|
||||
w.(http.Flusher).Flush()
|
||||
}))
|
||||
defer server.Close()
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
f.Meta().Opts = &base.Options{Path: t.TempDir()}
|
||||
err := f.Resolve(&base.Request{URL: server.URL + "/file"})
|
||||
if tc.invalid {
|
||||
if !errors.Is(err, errInvalidRangeResponse) {
|
||||
t.Fatalf("Resolve error = %v, want invalid range", err)
|
||||
}
|
||||
if f.meta.Res != nil || f.resolveResp != nil || f.getState() != stateError {
|
||||
t.Fatal("invalid response was retained or published")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.meta.Res.Size != tc.size || f.meta.Res.Files[0].Size != tc.size {
|
||||
t.Fatalf("resource/file size = %d/%d, want %d", f.meta.Res.Size, f.meta.Res.Files[0].Size, tc.size)
|
||||
}
|
||||
if tc.status == 206 && !f.meta.Res.Range {
|
||||
t.Fatal("partial response must enable Range downloads")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadPartialResolve(t *testing.T) {
|
||||
payload := make([]byte, 256*1024)
|
||||
for i := range payload {
|
||||
payload[i] = byte(i*31 + i/251)
|
||||
}
|
||||
for _, start := range []int{0, 100000} {
|
||||
for _, connections := range []int{1, 4} {
|
||||
t.Run(fmt.Sprintf("start_%d_connections_%d", start, connections), func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
begin, end := start, start+9999
|
||||
if h := r.Header.Get("Range"); h != "" {
|
||||
if _, err := fmt.Sscanf(h, "bytes=%d-%d", &begin, &end); err != nil {
|
||||
t.Errorf("invalid request range %q: %v", h, err)
|
||||
w.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Length", fmt.Sprint(end-begin+1))
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", begin, end, len(payload)))
|
||||
w.WriteHeader(206)
|
||||
w.Write(payload[begin : end+1])
|
||||
}))
|
||||
defer server.Close()
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
f.Meta().Opts = &base.Options{Path: t.TempDir(), Name: "file", Extra: &fhttp.OptsExtra{Connections: connections}}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/file"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(f.meta.SingleFilepath())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Fatalf("download content mismatch: got %d bytes, want %d", len(got), len(payload))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/internal/test"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
fhttp "github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||
)
|
||||
|
||||
func TestFetcher_RestoredStartSkipsAbsentPrefetch(t *testing.T) {
|
||||
data := bytes.Repeat([]byte("0123456789abcdef"), 64*1024)
|
||||
var firstRequest atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
firstRequest.CompareAndSwap(0, time.Now().UnixNano())
|
||||
http.ServeContent(w, r, "restored.bin", time.Time{}, bytes.NewReader(data))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
size := int64(len(data))
|
||||
half := size / 2
|
||||
file := filepath.Join(dir, "restored.bin")
|
||||
if err := os.WriteFile(file, data[:half], 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn := &connection{ID: 0, Role: rolePrimary, State: connNotStarted, Chunk: newChunk(0, size-1), Downloaded: half}
|
||||
conn.Chunk.Downloaded = half
|
||||
rangeMode := true
|
||||
saved := &fetcherData{Connections: []*connection{conn}, Range: &rangeMode, ResourceSize: &size, FileSize: &size}
|
||||
meta := &fetcher.FetcherMeta{
|
||||
Req: &base.Request{URL: server.URL + "/restored.bin"},
|
||||
Res: &base.Resource{Size: size, Range: true, Files: []*base.FileInfo{{Name: "restored.bin", Size: size}}},
|
||||
Opts: &base.Options{Path: dir, Name: "restored.bin", Extra: &fhttp.OptsExtra{Connections: 1}},
|
||||
}
|
||||
fm := new(FetcherManager)
|
||||
_, restore := fm.Restore()
|
||||
f := restore(meta, saved).(*Fetcher)
|
||||
ctl := controller.NewController()
|
||||
ctl.GetConfig = func(v any) {
|
||||
if err := json.Unmarshal([]byte(test.ToJson(fm.DefaultConfig())), v); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
f.Setup(ctl)
|
||||
defer f.Close()
|
||||
started := time.Now()
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actual, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(actual, data) {
|
||||
t.Fatal("restored download differs from the complete server content")
|
||||
}
|
||||
if firstRequest.Load() == 0 {
|
||||
t.Fatal("restored download did not request the remaining content")
|
||||
}
|
||||
delay := time.Duration(firstRequest.Load() - started.UnixNano())
|
||||
t.Logf("first restored request after %s", delay)
|
||||
if delay > 2*time.Second {
|
||||
t.Errorf("restored download waited %s before its first request", delay)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
func TestFetcher_OptionsSizeHintAndPatch(t *testing.T) {
|
||||
for _, known := range []bool{false, true} {
|
||||
for _, payload := range []string{"", "0123456789"} {
|
||||
t.Run(fmt.Sprintf("known=%v/bytes=%d", known, len(payload)), func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
if known {
|
||||
w.Header().Set("Content-Length", fmt.Sprint(len(payload)))
|
||||
}
|
||||
w.(http.Flusher).Flush()
|
||||
fmt.Fprint(w, payload)
|
||||
}))
|
||||
defer server.Close()
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
f.Meta().Opts = &base.Options{Size: 7, Path: t.TempDir(), Name: "output.bin"}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := int64(7)
|
||||
if known {
|
||||
want = int64(len(payload))
|
||||
}
|
||||
if f.meta.Res.Size != want || f.hasKnownSize() != known {
|
||||
t.Fatalf("resolved size=%d known=%v", f.meta.Res.Size, f.hasKnownSize())
|
||||
}
|
||||
if err := f.Patch(nil, &base.Options{Size: 9}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !known {
|
||||
want = 9
|
||||
}
|
||||
if f.meta.Res.Size != want {
|
||||
t.Fatal("hint update overrode a real size or failed to update an estimate")
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.meta.Res.Size != int64(len(payload)) || !f.hasKnownSize() {
|
||||
t.Fatalf("final size=%d known=%v", f.meta.Res.Size, f.hasKnownSize())
|
||||
}
|
||||
snapshot, err := new(FetcherManager).Store(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fd := snapshot.(*fetcherData)
|
||||
if fd.SizeKnown == nil || !*fd.SizeKnown {
|
||||
t.Fatal("snapshot lost confirmed size authority")
|
||||
}
|
||||
_, restore := new(FetcherManager).Restore()
|
||||
restored := restore(f.meta, fd).(*Fetcher)
|
||||
if !restored.hasKnownSize() || restored.knownSize() != int64(len(payload)) {
|
||||
t.Fatal("restored size lost authority, including known zero")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcher_SizeHintCannotCompleteForbiddenUnknownRange(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Range") != "" {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
w.(http.Flusher).Flush()
|
||||
fmt.Fprint(w, "0123456789")
|
||||
w.(http.Flusher).Flush()
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer server.Close()
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
f.Meta().Opts = &base.Options{Size: 3, Path: t.TempDir(), Name: "output.bin"}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Wait(); err == nil {
|
||||
t.Fatal("a failed unknown-length Range request was completed from an estimate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcher_SizeHintPreservesHTTPIntegrity(t *testing.T) {
|
||||
for _, chunked := range []bool{false, true} {
|
||||
for _, broken := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("chunked=%v/broken=%v", chunked, broken), func(t *testing.T) {
|
||||
payload := []byte("0123456789")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
if chunked && broken {
|
||||
conn, rw, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
fmt.Fprint(rw, "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\na\r\n0123456789\r\n")
|
||||
rw.Flush() // Omit the final chunk to simulate a connection interruption.
|
||||
return
|
||||
}
|
||||
if !chunked {
|
||||
length := len(payload)
|
||||
if broken {
|
||||
length++
|
||||
}
|
||||
w.Header().Set("Content-Length", fmt.Sprint(length))
|
||||
}
|
||||
w.(http.Flusher).Flush()
|
||||
w.Write(payload)
|
||||
}))
|
||||
defer server.Close()
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
f.Meta().Opts = &base.Options{Size: 3, Path: t.TempDir(), Name: "output.bin"}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !chunked {
|
||||
length := int64(len(payload))
|
||||
if broken {
|
||||
length++
|
||||
}
|
||||
if f.meta.Res.Size != length || f.sequentialSizeUnknown {
|
||||
t.Fatal("HTTP length did not override size hint")
|
||||
}
|
||||
} else if f.meta.Res.Size != 3 || !f.sequentialSizeUnknown {
|
||||
t.Fatal("unknown HTTP length did not retain the display hint")
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := f.Wait()
|
||||
if broken {
|
||||
if err == nil {
|
||||
t.Fatal("truncated HTTP response succeeded after exceeding the size hint")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.meta.Res.Size != int64(len(payload)) || f.sequentialSizeUnknown {
|
||||
t.Fatalf("completed size=%d unknown=%v", f.meta.Res.Size, f.sequentialSizeUnknown)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcher_SizeHintSurvivesRestore(t *testing.T) {
|
||||
for _, partial := range []bool{false, true} {
|
||||
for _, fixedLength := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("partial=%v/fixedLength=%v", partial, fixedLength), func(t *testing.T) {
|
||||
payload := []byte("0123456789")
|
||||
var requests atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
request := requests.Add(1)
|
||||
if got := r.Header.Get("Range"); got != "" {
|
||||
t.Errorf("estimated sizes must not be used for resume ranges: %s", got)
|
||||
}
|
||||
if fixedLength && request > 1 {
|
||||
w.Header().Set("Content-Length", fmt.Sprint(len(payload)))
|
||||
}
|
||||
w.(http.Flusher).Flush()
|
||||
if fixedLength && request == 2 {
|
||||
w.Write(payload[:4]) // Retry must discard this interrupted attempt.
|
||||
return
|
||||
}
|
||||
w.Write(payload)
|
||||
}))
|
||||
defer server.Close()
|
||||
f := buildFetcher()
|
||||
opts := &base.Options{Size: 3, Path: t.TempDir(), Name: "output.bin"}
|
||||
f.Meta().Opts = opts
|
||||
if err := f.Resolve(&base.Request{URL: server.URL}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fm := new(FetcherManager)
|
||||
snapshot, err := fm.Store(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var saved fetcherData
|
||||
if err := json.Unmarshal(data, &saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !saved.SequentialSizeUnknown || saved.SizeKnown == nil || *saved.SizeKnown || *saved.ResourceSize != 3 {
|
||||
t.Fatal("snapshot lost the size hint or its advisory state")
|
||||
}
|
||||
f.Close()
|
||||
// An interrupted earlier attempt left a tail longer than the new output.
|
||||
if err := os.WriteFile(filepath.Join(opts.Path, opts.Name), bytes.Repeat([]byte("old"), 100), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if partial {
|
||||
chunk := newChunk(0, 0)
|
||||
chunk.Downloaded = 7 // Already exceeds the display estimate.
|
||||
saved.Connections = []*connection{{ID: 0, Role: rolePrimary, State: connNotStarted, Chunk: chunk, Downloaded: 7}}
|
||||
}
|
||||
_, restore := fm.Restore()
|
||||
restored := restore(f.meta, &saved).(*Fetcher)
|
||||
restored.Setup(f.ctl)
|
||||
defer restored.Close()
|
||||
if err := restored.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := restored.Wait(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := os.ReadFile(restored.meta.SingleFilepath())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(body, payload) || restored.meta.Res.Size != int64(len(payload)) || restored.Progress().TotalDownloaded() != int64(len(payload)) {
|
||||
t.Fatalf("restored stream retained an estimate or stale bytes: body=%q size=%d progress=%d", body, restored.meta.Res.Size, restored.Progress().TotalDownloaded())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
gohttp "net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||
)
|
||||
|
||||
// A stalled Resolve body must not keep the prefetch goroutine alive after
|
||||
// stopPrefetchAndCopyData returns, or it writes to a file we already removed.
|
||||
func TestFetcher_StopPrefetchUnblocksStalledRead(t *testing.T) {
|
||||
prefix := bytes.Repeat([]byte("p"), 64*1024)
|
||||
stall := make(chan struct{})
|
||||
defer close(stall)
|
||||
|
||||
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
|
||||
w.Header().Set(base.HttpHeaderAcceptRanges, base.HttpHeaderBytes)
|
||||
w.Header().Set(base.HttpHeaderContentLength, fmt.Sprintf("%d", len(prefix)*4))
|
||||
w.WriteHeader(gohttp.StatusOK)
|
||||
_, _ = w.Write(prefix)
|
||||
w.(gohttp.Flusher).Flush()
|
||||
select {
|
||||
case <-stall:
|
||||
case <-r.Context().Done():
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
f := buildFetcher()
|
||||
f.Meta().Opts = &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "stalled.data",
|
||||
Extra: &http.OptsExtra{Connections: 1},
|
||||
}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/stalled.data"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for f.prefetchSize.Load() < int64(len(prefix)) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
f.stopPrefetch()
|
||||
if elapsed := time.Since(start); elapsed > 2*time.Second {
|
||||
t.Fatalf("stopPrefetch took %v, want it to interrupt the stalled read", elapsed)
|
||||
}
|
||||
if !f.prefetchDone.Load() {
|
||||
t.Fatal("prefetch goroutine still running after stopPrefetch")
|
||||
}
|
||||
}
|
||||
@@ -6,35 +6,56 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// TimeoutReader fails a Read that makes no progress within timeout.
|
||||
//
|
||||
// One timer per reader is armed when Read starts and stopped before it
|
||||
// returns, so time the caller spends between reads is not counted. If the
|
||||
// timer fires, it calls cancel, which should cancel the HTTP request the
|
||||
// reader belongs to so the transport unblocks the pending Read. Read still
|
||||
// waits for the underlying Read to return; that Read and every later one
|
||||
// return context.DeadlineExceeded. Errors from any other cancellation are
|
||||
// returned unchanged, and closing the response body is left to the caller.
|
||||
//
|
||||
// With a nil cancel the pending Read cannot be interrupted; it still reports
|
||||
// context.DeadlineExceeded, but only once it returns.
|
||||
//
|
||||
// TimeoutReader is not safe for concurrent use.
|
||||
type TimeoutReader struct {
|
||||
reader io.Reader
|
||||
timeout time.Duration
|
||||
reader io.Reader
|
||||
timeout time.Duration
|
||||
cancel context.CancelFunc
|
||||
timer *time.Timer
|
||||
timedOut bool
|
||||
}
|
||||
|
||||
func NewTimeoutReader(r io.Reader, timeout time.Duration) *TimeoutReader {
|
||||
func NewTimeoutReader(r io.Reader, timeout time.Duration, cancel context.CancelFunc) *TimeoutReader {
|
||||
if cancel == nil {
|
||||
cancel = func() {}
|
||||
}
|
||||
return &TimeoutReader{
|
||||
reader: r,
|
||||
timeout: timeout,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
func (tr *TimeoutReader) Read(p []byte) (n int, err error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), tr.timeout)
|
||||
defer cancel()
|
||||
|
||||
done := make(chan struct{})
|
||||
var readErr error
|
||||
var bytesRead int
|
||||
|
||||
go func() {
|
||||
bytesRead, readErr = tr.reader.Read(p)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
return bytesRead, readErr
|
||||
case <-ctx.Done():
|
||||
return 0, ctx.Err()
|
||||
if tr.timedOut {
|
||||
return 0, context.DeadlineExceeded
|
||||
}
|
||||
if tr.timer == nil {
|
||||
tr.timer = time.AfterFunc(tr.timeout, tr.cancel)
|
||||
} else {
|
||||
tr.timer.Reset(tr.timeout)
|
||||
}
|
||||
|
||||
n, err = tr.reader.Read(p)
|
||||
|
||||
if !tr.timer.Stop() {
|
||||
// The timer fired during this Read and cancelled the request, so err is
|
||||
// whatever the interrupted read produced. Report the timeout instead.
|
||||
tr.timedOut = true
|
||||
return n, context.DeadlineExceeded
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
@@ -2,17 +2,26 @@ package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"io"
|
||||
gohttp "net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/httpclient"
|
||||
)
|
||||
|
||||
func TestTimeoutReader_Read(t *testing.T) {
|
||||
data := []byte("Hello, World!")
|
||||
reader := bytes.NewReader(data)
|
||||
timeoutReader := NewTimeoutReader(reader, 1*time.Second)
|
||||
timeoutReader := NewTimeoutReader(reader, 1*time.Second, nil)
|
||||
|
||||
buf := make([]byte, len(data))
|
||||
n, err := timeoutReader.Read(buf)
|
||||
@@ -29,7 +38,7 @@ func TestTimeoutReader_Read(t *testing.T) {
|
||||
|
||||
func TestTimeoutReader_ReadTimeout(t *testing.T) {
|
||||
reader := &slowReader{delay: 2 * time.Second}
|
||||
timeoutReader := NewTimeoutReader(reader, 1*time.Second)
|
||||
timeoutReader := NewTimeoutReader(reader, 1*time.Second, nil)
|
||||
|
||||
buf := make([]byte, 8192)
|
||||
_, err := timeoutReader.Read(buf)
|
||||
@@ -49,3 +58,340 @@ func (sr *slowReader) Read(p []byte) (n int, err error) {
|
||||
time.Sleep(sr.delay)
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
// The timer must only cover time spent inside Read, not caller work between reads.
|
||||
func TestTimeoutReader_IdleBetweenReads(t *testing.T) {
|
||||
var cancelled atomic.Bool
|
||||
timeoutReader := NewTimeoutReader(bytes.NewReader([]byte("abcdef")), 50*time.Millisecond, func() {
|
||||
cancelled.Store(true)
|
||||
})
|
||||
|
||||
buf := make([]byte, 3)
|
||||
if _, err := timeoutReader.Read(buf); err != nil {
|
||||
t.Fatalf("first read: %v", err)
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
if _, err := timeoutReader.Read(buf); err != nil {
|
||||
t.Fatalf("second read after idle: %v", err)
|
||||
}
|
||||
if cancelled.Load() {
|
||||
t.Fatal("request was cancelled while idle between reads")
|
||||
}
|
||||
}
|
||||
|
||||
// A timed-out Read must not return while the underlying Read can still write
|
||||
// into the caller's buffer, and must leave closing the reader to the caller.
|
||||
func TestTimeoutReader_TimedOutReadDoesNotOutliveCaller(t *testing.T) {
|
||||
reader := &lateWriteReader{cancelled: make(chan struct{})}
|
||||
timeoutReader := NewTimeoutReader(reader, 50*time.Millisecond, reader.cancel)
|
||||
|
||||
buf := make([]byte, 8)
|
||||
_, err := timeoutReader.Read(buf)
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("expected %v, got %v", context.DeadlineExceeded, err)
|
||||
}
|
||||
if !reader.returned.Load() {
|
||||
t.Fatal("Read returned before the underlying Read finished")
|
||||
}
|
||||
if _, err := timeoutReader.Read(buf); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("read after timeout: expected %v, got %v", context.DeadlineExceeded, err)
|
||||
}
|
||||
if reader.closed.Load() {
|
||||
t.Fatal("reader was closed by the timeout")
|
||||
}
|
||||
}
|
||||
|
||||
// lateWriteReader blocks until cancelled, then writes into p before returning.
|
||||
type lateWriteReader struct {
|
||||
cancelOnce sync.Once
|
||||
cancelled chan struct{}
|
||||
returned atomic.Bool
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
func (r *lateWriteReader) Read(p []byte) (int, error) {
|
||||
<-r.cancelled
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
p[0] = 'x'
|
||||
r.returned.Store(true)
|
||||
return 0, context.Canceled
|
||||
}
|
||||
|
||||
func (r *lateWriteReader) cancel() {
|
||||
r.cancelOnce.Do(func() { close(r.cancelled) })
|
||||
}
|
||||
|
||||
func (r *lateWriteReader) Close() error {
|
||||
r.closed.Store(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
type timeoutReaderClientCase struct {
|
||||
name string
|
||||
http2 bool
|
||||
client func(t *testing.T, server *httptest.Server) *gohttp.Client
|
||||
}
|
||||
|
||||
func timeoutReaderClientCases() []timeoutReaderClientCase {
|
||||
newClient := func(mode httpclient.ImpersonationMode) func(t *testing.T, server *httptest.Server) *gohttp.Client {
|
||||
return func(t *testing.T, server *httptest.Server) *gohttp.Client {
|
||||
var tlsConfig *tls.Config
|
||||
if server.TLS != nil {
|
||||
roots := x509.NewCertPool()
|
||||
roots.AddCert(server.Certificate())
|
||||
tlsConfig = &tls.Config{RootCAs: roots}
|
||||
}
|
||||
client, err := httpclient.NewClient(httpclient.Options{
|
||||
Transport: httpclient.TransportOptions{
|
||||
TLSClientConfig: tlsConfig,
|
||||
},
|
||||
Impersonation: httpclient.ImpersonationOptions{
|
||||
Mode: mode,
|
||||
Browser: httpclient.BrowserChrome,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return client
|
||||
}
|
||||
}
|
||||
var cases []timeoutReaderClientCase
|
||||
for _, mode := range []httpclient.ImpersonationMode{httpclient.ImpersonationNative, httpclient.ImpersonationFixed} {
|
||||
cases = append(cases,
|
||||
timeoutReaderClientCase{name: string(mode) + "/http1", client: newClient(mode)},
|
||||
timeoutReaderClientCase{name: string(mode) + "/http2", http2: true, client: newClient(mode)},
|
||||
)
|
||||
}
|
||||
return cases
|
||||
}
|
||||
|
||||
func startTimeoutReaderServer(t *testing.T, useHTTP2 bool, handler gohttp.HandlerFunc) *httptest.Server {
|
||||
server := httptest.NewUnstartedServer(handler)
|
||||
if useHTTP2 {
|
||||
server.EnableHTTP2 = true
|
||||
server.StartTLS()
|
||||
} else {
|
||||
server.Start()
|
||||
}
|
||||
t.Cleanup(server.Close)
|
||||
return server
|
||||
}
|
||||
|
||||
// readBodyWithTimeout sends the request under its own context derived from
|
||||
// ctx, as the fetcher does, and reads the body through a TimeoutReader.
|
||||
func readBodyWithTimeout(t *testing.T, ctx context.Context, client *gohttp.Client, url string, timeout time.Duration, wantProto int) ([]byte, error) {
|
||||
t.Helper()
|
||||
reqCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
req, err := gohttp.NewRequestWithContext(reqCtx, gohttp.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.ProtoMajor != wantProto {
|
||||
t.Fatalf("expected HTTP/%d, got %s", wantProto, resp.Proto)
|
||||
}
|
||||
|
||||
type result struct {
|
||||
data []byte
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
data, err := io.ReadAll(NewTimeoutReader(resp.Body, timeout, cancel))
|
||||
done <- result{data, err}
|
||||
}()
|
||||
select {
|
||||
case r := <-done:
|
||||
return r.data, r.err
|
||||
case <-time.After(timeout + 10*time.Second):
|
||||
t.Fatal("Read did not return after the read timeout")
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeoutReader_StalledHTTPBodyTimesOut(t *testing.T) {
|
||||
for _, tc := range timeoutReaderClientCases() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := startTimeoutReaderServer(t, tc.http2, func(w gohttp.ResponseWriter, r *gohttp.Request) {
|
||||
w.Header().Set("Content-Length", "1048576")
|
||||
w.Write(bytes.Repeat([]byte("a"), 1024))
|
||||
w.(gohttp.Flusher).Flush()
|
||||
<-r.Context().Done()
|
||||
})
|
||||
|
||||
wantProto := 1
|
||||
if tc.http2 {
|
||||
wantProto = 2
|
||||
}
|
||||
const timeout = 200 * time.Millisecond
|
||||
start := time.Now()
|
||||
data, err := readBodyWithTimeout(t, context.Background(), tc.client(t, server), server.URL, timeout, wantProto)
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("expected %v, got %v", context.DeadlineExceeded, err)
|
||||
}
|
||||
if len(data) != 1024 {
|
||||
t.Fatalf("expected the 1024 bytes sent before the stall, got %d", len(data))
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed < timeout {
|
||||
t.Fatalf("timed out after %v, before the %v read timeout", elapsed, timeout)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeoutReader_SlowSteadyHTTPBodyDoesNotTimeOut(t *testing.T) {
|
||||
const (
|
||||
timeout = 300 * time.Millisecond
|
||||
interval = 30 * time.Millisecond
|
||||
chunks = 40 // 1.2s in total, four times the read timeout
|
||||
)
|
||||
for _, tc := range timeoutReaderClientCases() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := startTimeoutReaderServer(t, tc.http2, func(w gohttp.ResponseWriter, r *gohttp.Request) {
|
||||
for i := 0; i < chunks; i++ {
|
||||
w.Write([]byte("0123456789"))
|
||||
w.(gohttp.Flusher).Flush()
|
||||
time.Sleep(interval)
|
||||
}
|
||||
})
|
||||
|
||||
wantProto := 1
|
||||
if tc.http2 {
|
||||
wantProto = 2
|
||||
}
|
||||
data, err := readBodyWithTimeout(t, context.Background(), tc.client(t, server), server.URL, timeout, wantProto)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if len(data) != chunks*10 {
|
||||
t.Fatalf("expected %d bytes, got %d", chunks*10, len(data))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A gzip-encoded body that stalls must time out without racing the caller's
|
||||
// Body.Close against the decompressing reader.
|
||||
func TestTimeoutReader_StalledGzipHTTPBodyTimesOut(t *testing.T) {
|
||||
payload := bytes.Repeat([]byte("gopeed"), 1024)
|
||||
stages := []struct {
|
||||
name string
|
||||
write func(w io.Writer)
|
||||
want int
|
||||
}{
|
||||
{
|
||||
// The first bytes of a gzip header, so the client blocks inside
|
||||
// gzip.NewReader.
|
||||
name: "incomplete-header",
|
||||
write: func(w io.Writer) { w.Write([]byte{0x1f, 0x8b, 0x08, 0x00}) },
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
// A complete header and some compressed data, flushed without
|
||||
// finishing the gzip stream.
|
||||
name: "stalled-body",
|
||||
write: func(w io.Writer) {
|
||||
gz := gzip.NewWriter(w)
|
||||
gz.Write(payload)
|
||||
gz.Flush()
|
||||
},
|
||||
want: len(payload),
|
||||
},
|
||||
}
|
||||
for _, stage := range stages {
|
||||
for _, tc := range timeoutReaderClientCases() {
|
||||
t.Run(stage.name+"/"+tc.name, func(t *testing.T) {
|
||||
server := startTimeoutReaderServer(t, tc.http2, func(w gohttp.ResponseWriter, r *gohttp.Request) {
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
stage.write(w)
|
||||
w.(gohttp.Flusher).Flush()
|
||||
<-r.Context().Done()
|
||||
})
|
||||
|
||||
wantProto := 1
|
||||
if tc.http2 {
|
||||
wantProto = 2
|
||||
}
|
||||
data, err := readBodyWithTimeout(t, context.Background(), tc.client(t, server), server.URL, 200*time.Millisecond, wantProto)
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("expected %v, got %v", context.DeadlineExceeded, err)
|
||||
}
|
||||
if len(data) != stage.want {
|
||||
t.Fatalf("expected the %d bytes sent before the stall, got %d", stage.want, len(data))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cancelling the caller's context must not be reported as a read timeout.
|
||||
func TestTimeoutReader_CallerCancellationIsNotTimeout(t *testing.T) {
|
||||
for _, tc := range timeoutReaderClientCases() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := startTimeoutReaderServer(t, tc.http2, func(w gohttp.ResponseWriter, r *gohttp.Request) {
|
||||
w.Header().Set("Content-Length", "1048576")
|
||||
w.Write(bytes.Repeat([]byte("a"), 1024))
|
||||
w.(gohttp.Flusher).Flush()
|
||||
<-r.Context().Done()
|
||||
})
|
||||
|
||||
wantProto := 1
|
||||
if tc.http2 {
|
||||
wantProto = 2
|
||||
}
|
||||
// Cancel once the client has read the first bytes, so the next
|
||||
// Read is the one blocked when the caller cancels.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
client := tc.client(t, server)
|
||||
next := client.Transport
|
||||
client.Transport = roundTripFunc(func(r *gohttp.Request) (*gohttp.Response, error) {
|
||||
resp, err := next.RoundTrip(r)
|
||||
if err == nil {
|
||||
resp.Body = &cancelAfterFirstRead{ReadCloser: resp.Body, cancel: cancel}
|
||||
}
|
||||
return resp, err
|
||||
})
|
||||
|
||||
const timeout = 5 * time.Second
|
||||
start := time.Now()
|
||||
data, err := readBodyWithTimeout(t, ctx, client, server.URL, timeout, wantProto)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("expected %v, got %v", context.Canceled, err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Fatal("cancelled before any data was read")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed >= timeout {
|
||||
t.Fatalf("read returned after %v, not on cancellation", elapsed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*gohttp.Request) (*gohttp.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(r *gohttp.Request) (*gohttp.Response, error) {
|
||||
return f(r)
|
||||
}
|
||||
|
||||
// cancelAfterFirstRead calls cancel once a Read has returned data.
|
||||
type cancelAfterFirstRead struct {
|
||||
io.ReadCloser
|
||||
once sync.Once
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (b *cancelAfterFirstRead) Read(p []byte) (int, error) {
|
||||
n, err := b.ReadCloser.Read(p)
|
||||
if n > 0 {
|
||||
b.once.Do(b.cancel)
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Package tempfiles tracks temporary paths owned by one downloader, without
|
||||
// requiring a private parent directory or scanning the shared system temp root.
|
||||
package tempfiles
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Scope struct {
|
||||
mu sync.Mutex
|
||||
paths map[string]struct{}
|
||||
closed bool
|
||||
}
|
||||
|
||||
// Track adopts a newly created unique path. Late creations are removed when
|
||||
// shutdown has already begun. A nil scope leaves cleanup to the resource owner.
|
||||
func (s *Scope) Track(path string) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return errors.Join(io.ErrClosedPipe, os.RemoveAll(path))
|
||||
}
|
||||
if s.paths == nil {
|
||||
s.paths = make(map[string]struct{})
|
||||
}
|
||||
s.paths[path] = struct{}{}
|
||||
return nil
|
||||
}
|
||||
func (s *Scope) Remove(path string) error {
|
||||
if s == nil {
|
||||
return os.RemoveAll(path)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := os.RemoveAll(path); err != nil {
|
||||
return err
|
||||
}
|
||||
delete(s.paths, path)
|
||||
return nil
|
||||
}
|
||||
func (s *Scope) Close() error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.closed = true
|
||||
var result error
|
||||
for path := range s.paths {
|
||||
if err := os.RemoveAll(path); err != nil {
|
||||
result = errors.Join(result, err)
|
||||
} else {
|
||||
delete(s.paths, path)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package tempfiles
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSharedDirectoryOwnersAndLateCreation(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
a, b := &Scope{}, &Scope{}
|
||||
own := func(scope *Scope) string {
|
||||
t.Helper()
|
||||
dir, err := os.MkdirTemp(root, "gopeed-media-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := scope.Track(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
first, second := own(a), own(b)
|
||||
if err := a.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(first); !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(second); err != nil {
|
||||
t.Fatal("removed other owner's path", err)
|
||||
}
|
||||
late, err := os.MkdirTemp(root, "gopeed-media-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.Track(late); !errors.Is(err, io.ErrClosedPipe) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(late); !os.IsNotExist(err) {
|
||||
t.Fatal("late creation leaked", err)
|
||||
}
|
||||
if err := b.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentCreationAndShutdown(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
scope := &Scope{}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
dir, err := os.MkdirTemp(root, "gopeed-media-")
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
if err := scope.Track(dir); err != nil && !errors.Is(err, io.ErrClosedPipe) {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
if err := scope.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wg.Wait()
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil || len(entries) != 0 {
|
||||
t.Fatal("shutdown leaked paths", entries, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
# Extension browser host configuration
|
||||
|
||||
The downloader wraps each provider with the extension's stable identity before
|
||||
constructing the runtime. `OpenOptions.ProfileID`, `DataPath`, and `ProxyURL` are
|
||||
host-only fields. `parseOpenOptions` never reads them from extension JavaScript.
|
||||
The extension API is unchanged. The Flutter host uses the fork's public
|
||||
`WebViewProfile`, `InAppWebViewSettings.profileId`, and profile-scoped
|
||||
`CookieManager` APIs; the library has no Gopeed-specific method-channel bridge.
|
||||
Only the Gopeed extension JavaScript boundary keeps these options host-only.
|
||||
|
||||
Profiles are persistent and shared across invocations/pages of the same extension.
|
||||
Different identities use different native stores. Go providers use a SHA-256-based
|
||||
path under `<StorageDir>/webview`; Apple RPC hosts use a stable UUID data store;
|
||||
Android uses a named WebView profile. Cookie operations select that same store,
|
||||
including `clearCookies`. Closing an execution closes its pages, not its profile.
|
||||
Uninstall closes the extension's pages, prevents its old runtimes from opening
|
||||
new pages, and removes its native profile before deleting the extension record.
|
||||
Cleanup failures abort uninstall and can be retried. Reinstallation starts with
|
||||
empty browser data; an ordinary extension update keeps the existing profile.
|
||||
Windows/Linux delete the profile directory; Apple removes the named WebKit store.
|
||||
Android cannot delete a profile already loaded by the current process: when
|
||||
DELETE_BROWSING_DATA is supported it clears all site data immediately, and records
|
||||
an empty-profile deletion in SharedPreferences for the next process start. A
|
||||
reinstallation may reuse that cleared profile and cancels the pending deletion.
|
||||
If the full-data clearing API is unavailable, uninstall fails with a restart
|
||||
instruction; the pending profile is deleted at startup before it can be loaded.
|
||||
No removal operation is exposed through extension JavaScript.
|
||||
|
||||
Existing shared browser data is not copied into every extension's new profile.
|
||||
|
||||
The downloader lazily owns one loopback forward proxy with HTTP and SOCKS5
|
||||
listeners. Windows/Linux/Android use HTTP; Apple uses SOCKS5 because its HTTP
|
||||
CONNECT configuration may bypass plain HTTP. The forwarder reads the current
|
||||
Gopeed proxy config for each new request/tunnel and supports direct, system,
|
||||
HTTP(S) upstream proxies with Basic authentication, and authenticated SOCKS5.
|
||||
Credentials remain in Go and are never sent over the WebView RPC.
|
||||
|
||||
Changes affect new requests/connections. Existing CONNECT/SOCKS tunnels keep their
|
||||
route until closed; opening another page in the same profile does not reset it.
|
||||
SOCKS carries host/port rather than a full URL: system proxy/PAC selection for
|
||||
Apple is host-based, with port 80 treated as HTTP and other ports as HTTPS.
|
||||
Path-dependent PAC rules cannot be reproduced from a SOCKS connection.
|
||||
|
||||
Apple requires macOS 14 / iOS 17. Android checks MULTI_PROFILE and PROXY_OVERRIDE.
|
||||
Unsupported hosts fail explicitly instead of silently sharing browser storage or
|
||||
ignoring the proxy.
|
||||
|
||||
## Validation
|
||||
|
||||
- `go test -race ./internal/webview/proxy ./pkg/download/engine/webview`
|
||||
- `go test ./pkg/download -run 'TestWebViewProfileBound|TestDownloader_ExtensionRuntimeWebView'`
|
||||
- Windows/Linux: `go test -tags webview -v ./internal/webview/goprovider` (Linux: run under `xvfb-run -a`).
|
||||
- macOS: start the normal Flutter app, then run `go test -tags webview -v ./internal/webview/rpcprovider` with its socket address. See `rpcprovider/README.md`.
|
||||
- In webview_go: `go test -tags webview_integration -v -count=1 .` (Linux: run under `xvfb-run -a`).
|
||||
|
||||
The shared provider contract covers HTTP/HTTPS proxy routing, concurrent pages,
|
||||
HttpOnly cookies, LocalStorage, IndexedDB, profile-scoped clearing, reopening,
|
||||
and removal without affecting another profile. HTTPS routing is checked with a
|
||||
rejecting upstream tunnel; no test-only certificate bypass is added to the app.
|
||||
Go proxy unit tests separately cover successful TLS forwarding and authentication.
|
||||
These cases run in the existing `test.yml` WebView jobs; existing mobile build jobs
|
||||
compile the Android/iOS bridge. No separate Flutter test application is required.
|
||||
|
||||
## Extension events and host environment
|
||||
|
||||
`gopeed.host.env.version` is the host's build version (`dev` for development
|
||||
builds). `gopeed.info.version` continues to identify the extension's own version.
|
||||
`gopeed.host.env.os` and `gopeed.host.env.arch` match the REST info endpoint's `os` and
|
||||
`arch`: the host's Go `runtime.GOOS` and `runtime.GOARCH` values (for example,
|
||||
`windows` / `amd64` or `darwin` / `arm64`).
|
||||
See [extension-api.d.ts](extension-api.d.ts) for the event payloads.
|
||||
|
||||
```js
|
||||
const page = await gopeed.runtime.webview.open();
|
||||
const unsubscribe = await page.on("url-changed", ({ url, sameDocument }) => {
|
||||
gopeed.logger.info(url, sameDocument);
|
||||
});
|
||||
await page.on("closed", ({ reason }) => gopeed.logger.info(reason));
|
||||
await page.addInitScript("globalThis.extensionReady = true");
|
||||
await page.goto("https://example.com");
|
||||
unsubscribe();
|
||||
await page.close();
|
||||
```
|
||||
|
||||
Registration resolves only after the listener is installed; it does not replay
|
||||
current state. `url-changed` reports actual address changes, including hash,
|
||||
pushState, replaceState and history navigation. `load` follows the main document's
|
||||
`window.load`, including reloads, and does not wait for business-level async work.
|
||||
Iframe loads and subresource failures do not become main-page notifications.
|
||||
`closed` distinguishes the user's close action from API closure and is terminal.
|
||||
Unsubscribe is idempotent. Notifications cannot block navigation; the engine does
|
||||
not await promises returned by handlers. Catch async errors inside the handler.
|
||||
|
||||
`addInitScript` applies to future document contexts only. Use `execute` to run
|
||||
code on the current page. Executable functions are serialized and cannot capture
|
||||
extension-side lexical variables; pass values explicitly as arguments.
|
||||
|
||||
The RPC host uses an NDJSON `page.events` response whose first record is
|
||||
`{"ready":true}`. Subsequent records are `{event,data}`. Closing a page sends the
|
||||
terminal record before ending the stream. The native backend uses document-start
|
||||
notifications for main-document URL/load events and native callbacks for
|
||||
navigation failures and user closure.
|
||||
|
||||
Additional validation:
|
||||
|
||||
- `go test -race ./pkg/download/engine/webview ./internal/webview/rpcprovider`
|
||||
- `go test ./pkg/download -run 'TestExtension(WebViewEvents|InfoHostVersion)'`
|
||||
- macOS native: `go test -tags webview_native ./internal/webview/goprovider -run TestProviderEvents`
|
||||
- Running Flutter host: `go test -tags webview ./internal/webview/rpcprovider -run TestProviderEvents`
|
||||
Vendored
+60
@@ -0,0 +1,60 @@
|
||||
/** Public extension API additions. info.version remains the extension version. */
|
||||
export interface ExtensionInfo {
|
||||
identity: string;
|
||||
name: string;
|
||||
author: string;
|
||||
title: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
/** Available as gopeed.host. */
|
||||
export interface Host {
|
||||
env: HostEnv;
|
||||
}
|
||||
|
||||
export interface HostEnv {
|
||||
/** Host build version; "dev" for development builds. */
|
||||
version: string;
|
||||
/** Host OS and architecture, matching the REST info endpoint (Go GOOS/GOARCH). */
|
||||
os: string;
|
||||
arch: string;
|
||||
}
|
||||
|
||||
/** Functions are serialized; they cannot capture extension-side variables. */
|
||||
export type WebviewExecutable<T = unknown> =
|
||||
| string
|
||||
| ((...args: unknown[]) => T | Promise<T>);
|
||||
|
||||
export type WebviewUnsubscribe = () => void;
|
||||
|
||||
/** Navigation and load notifications are for the main document only. */
|
||||
export interface WebviewEventMap {
|
||||
"url-changed": { url: string; sameDocument: boolean };
|
||||
/** window.load, including reloads, excluding hash/History API navigation. */
|
||||
"load": { url: string };
|
||||
"load-error": { url: string; message: string };
|
||||
"closed": { reason: "user" | "api" };
|
||||
}
|
||||
|
||||
export interface WebviewGotoOptions {
|
||||
timeoutMs?: number;
|
||||
waitUntil?: "load" | "domcontentloaded";
|
||||
}
|
||||
|
||||
/** These members extend the existing WebviewPage API. */
|
||||
export interface WebviewPage {
|
||||
/**
|
||||
* Resolves when listening is ready. Does not replay current state.
|
||||
* Register before goto() to observe navigation. Unsubscribe is idempotent.
|
||||
* Closed is delivered once, then listeners are released automatically.
|
||||
* Callbacks cannot cancel navigation; returned promises are not awaited.
|
||||
* Callers handle errors in asynchronous callbacks.
|
||||
*/
|
||||
on<K extends keyof WebviewEventMap>(
|
||||
event: K,
|
||||
handler: (data: WebviewEventMap[K]) => void,
|
||||
): Promise<WebviewUnsubscribe>;
|
||||
/** Runs at document creation on future navigations, never immediately. */
|
||||
addInitScript(script: string): Promise<void>;
|
||||
goto(url: string, opts?: WebviewGotoOptions): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build cgo && darwin && webview_native
|
||||
|
||||
package goprovider
|
||||
|
||||
import (
|
||||
"github.com/GopeedLab/gopeed/internal/webview/integrationtest"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) { os.Exit(RunMainThreadLoop(m.Run)) }
|
||||
func TestProviderEvents(t *testing.T) { integrationtest.RunEventContract(t, New()) }
|
||||
@@ -0,0 +1,49 @@
|
||||
//go:build cgo
|
||||
|
||||
package goprovider
|
||||
|
||||
import (
|
||||
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||
webview "github.com/GopeedLab/webview_go"
|
||||
"github.com/dop251/goja"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInternalErrorDocumentDoesNotReportPageEvents(t *testing.T) {
|
||||
vm := goja.New()
|
||||
if _, err := vm.RunString(`
|
||||
var window = globalThis;
|
||||
window.top = window;
|
||||
var location = {protocol: "chrome-error:", href: "chrome-error://chromewebdata/"};
|
||||
var notifications = [];
|
||||
var listeners = [];
|
||||
function notify(payload) { notifications.push(payload); }
|
||||
window.addEventListener = (name) => listeners.push(name);
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := vm.RunString(buildLoadNotifyScript("notify")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"notifications", "listeners"} {
|
||||
value, err := vm.RunString(name + ".length")
|
||||
if err != nil || value.ToInteger() != 0 {
|
||||
t.Fatalf("internal error page registered %s: %v (%v)", name, value, err)
|
||||
}
|
||||
}
|
||||
// The provider must wait for the native failure, preserving the failed URL.
|
||||
page := newPageWrapper(enginewebview.OpenOptions{})
|
||||
got := make(chan string, 1)
|
||||
_, err := page.On("load-error", func(e enginewebview.Event) { got <- e.Data["url"].(string) })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page.handleNativeEvent(webview.Event{Name: "load-error", URL: "https://example.test/failure", Message: "TLS handshake failed"})
|
||||
if err := page.waitForNavigation(time.Second, "load"); err == nil {
|
||||
t.Fatal("native failure completed navigation successfully")
|
||||
}
|
||||
if url := <-got; url != "https://example.test/failure" {
|
||||
t.Fatal(url)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//go:build linux && cgo
|
||||
|
||||
package goprovider
|
||||
|
||||
/*
|
||||
#cgo pkg-config: gtk+-3.0
|
||||
#include <gtk/gtk.h>
|
||||
static void pumpGTK(void) {
|
||||
// Bound each batch so Go creation/removal requests cannot be starved.
|
||||
for (int i = 0; i < 64 && g_main_context_pending(NULL); ++i)
|
||||
g_main_context_iteration(NULL, FALSE);
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var gtkThreadOnce sync.Once
|
||||
var gtkThreadTasks = make(chan func())
|
||||
|
||||
func RunMainThreadLoop(run func() int) int { return run() }
|
||||
|
||||
func postMainThreadTask(task func()) bool {
|
||||
gtkThreadOnce.Do(func() {
|
||||
go func() {
|
||||
runtime.LockOSThread()
|
||||
// WebKit must always use its original UI thread, including after
|
||||
// the last page closes. A page cannot stop this shared event loop.
|
||||
ticker := time.NewTicker(10 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case task := <-gtkThreadTasks:
|
||||
task()
|
||||
case <-ticker.C:
|
||||
}
|
||||
C.pumpGTK()
|
||||
}
|
||||
}()
|
||||
})
|
||||
gtkThreadTasks <- task
|
||||
return true
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !darwin
|
||||
//go:build !darwin && (!linux || !cgo)
|
||||
|
||||
package goprovider
|
||||
|
||||
|
||||
Loaded 100 of 452 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user