chore: initial sanitized public snapshot
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
[term]
|
||||||
|
color = "auto"
|
||||||
|
|
||||||
|
[unstable]
|
||||||
|
build-std = ["std", "panic_abort"]
|
||||||
|
build-std-features = ["optimize_for_size"]
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
.git
|
||||||
|
.git/
|
||||||
|
.cargo-home
|
||||||
|
.cargo-home/
|
||||||
|
.codegraph
|
||||||
|
.codegraph/
|
||||||
|
.local
|
||||||
|
.local/
|
||||||
|
target
|
||||||
|
target/
|
||||||
|
target-*
|
||||||
|
dist
|
||||||
|
dist/
|
||||||
|
artifacts
|
||||||
|
artifacts/
|
||||||
|
coverage
|
||||||
|
coverage/
|
||||||
|
coverage-*
|
||||||
|
ci-diagnostics
|
||||||
|
ci-diagnostics/
|
||||||
|
ci-diagnostics-*
|
||||||
|
tmp
|
||||||
|
tmp/
|
||||||
|
temp
|
||||||
|
temp/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
docker/.env
|
||||||
|
docker/.env.*
|
||||||
|
*.key
|
||||||
|
*.pem
|
||||||
|
*.p12
|
||||||
|
*.pfx
|
||||||
|
auth.json
|
||||||
|
credentials
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
*.sh text eol=lf
|
||||||
|
*.bash text eol=lf
|
||||||
|
*.yml text eol=lf
|
||||||
|
*.yaml text eol=lf
|
||||||
|
*.toml text eol=lf
|
||||||
|
*.rs text eol=lf
|
||||||
|
*.md text eol=lf
|
||||||
|
Dockerfile text eol=lf
|
||||||
|
docker/Dockerfile text eol=lf
|
||||||
|
.gitea/workflows/*.yml text eol=lf
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
fast-gates:
|
||||||
|
name: Fast Gates
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 75
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
CARGO_BUILD_JOBS: "2"
|
||||||
|
CARGO_TARGET_DIR: target/ci
|
||||||
|
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
|
||||||
|
CARGO_HTTP_TIMEOUT: "60"
|
||||||
|
CARGO_HTTP_MULTIPLEXING: "false"
|
||||||
|
CARGO_NET_RETRY: "2"
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
timeout-minutes: 5
|
||||||
|
env:
|
||||||
|
GITEA_ACTIONS_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
GITEA_INTERNAL_URL: http://gitea:3000
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repository="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
|
||||||
|
sha="${GITHUB_SHA:?GITHUB_SHA is required}"
|
||||||
|
workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}"
|
||||||
|
token="${GITEA_ACTIONS_TOKEN:-}"
|
||||||
|
server_url="${GITEA_INTERNAL_URL%/}"
|
||||||
|
remote_url="${server_url}/${repository}"
|
||||||
|
git_fetch() {
|
||||||
|
if [ -n "${token}" ]; then
|
||||||
|
auth="$(printf 'x-access-token:%s' "${token}" | base64 | tr -d '\n')"
|
||||||
|
extra_header="AUTHORIZATION: basic ${auth}"
|
||||||
|
git -c "http.${server_url}/.extraheader=${extra_header}" fetch "$@"
|
||||||
|
else
|
||||||
|
git fetch "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "${workspace}"
|
||||||
|
cd "${workspace}"
|
||||||
|
git init .
|
||||||
|
if git remote get-url origin >/dev/null 2>&1; then
|
||||||
|
git remote set-url origin "${remote_url}"
|
||||||
|
else
|
||||||
|
git remote add origin "${remote_url}"
|
||||||
|
fi
|
||||||
|
git_fetch --prune --no-recurse-submodules origin \
|
||||||
|
+refs/heads/*:refs/remotes/origin/* \
|
||||||
|
+refs/tags/*:refs/tags/*
|
||||||
|
if ! git cat-file -e "${sha}^{commit}" 2>/dev/null; then
|
||||||
|
git_fetch --no-recurse-submodules origin "${sha}"
|
||||||
|
fi
|
||||||
|
git clean -ffdx
|
||||||
|
git checkout --force "${sha}"
|
||||||
|
git clean -ffdx
|
||||||
|
git log -1 --format=%H
|
||||||
|
|
||||||
|
- name: Validate repository conventions
|
||||||
|
timeout-minutes: 5
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
./scripts/ci/check-conventional-commits.sh v1.0.0
|
||||||
|
|
||||||
|
- name: Install Linux dependencies
|
||||||
|
timeout-minutes: 10
|
||||||
|
run: ./scripts/ci/install-linux-deps.sh
|
||||||
|
|
||||||
|
- name: Bootstrap Rust toolchains
|
||||||
|
timeout-minutes: 25
|
||||||
|
run: ./scripts/ci/bootstrap-rust.sh 1.88.0 none
|
||||||
|
|
||||||
|
- name: Install Cargo tools
|
||||||
|
timeout-minutes: 10
|
||||||
|
run: ./scripts/ci/install-cargo-tools.sh fast
|
||||||
|
|
||||||
|
- name: Validate git-cliff configuration
|
||||||
|
timeout-minutes: 5
|
||||||
|
run: git-cliff --config cliff.toml --unreleased > /dev/null
|
||||||
|
|
||||||
|
- name: Run fast quality gates
|
||||||
|
timeout-minutes: 45
|
||||||
|
run: ./scripts/ci/run-fast-gates.sh
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
release_tag:
|
||||||
|
description: Existing Git tag to publish
|
||||||
|
required: true
|
||||||
|
default: v1.0.0
|
||||||
|
release_name:
|
||||||
|
description: Human-readable release name
|
||||||
|
required: true
|
||||||
|
default: aria2-rust-pro 1.0.0
|
||||||
|
draft:
|
||||||
|
description: Publish as draft
|
||||||
|
required: true
|
||||||
|
default: "false"
|
||||||
|
validation_only:
|
||||||
|
description: Run release gates without publishing
|
||||||
|
required: true
|
||||||
|
default: "true"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
releases: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: Build and Publish Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 180
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
CARGO_BUILD_JOBS: "2"
|
||||||
|
CARGO_TARGET_DIR: target/release-ci
|
||||||
|
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
|
||||||
|
CARGO_HTTP_TIMEOUT: "60"
|
||||||
|
CARGO_HTTP_MULTIPLEXING: "false"
|
||||||
|
CARGO_NET_RETRY: "2"
|
||||||
|
GITEA_SERVER_URL: ${{ github.server_url }}
|
||||||
|
GITEA_REPOSITORY: ${{ github.repository }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
GITEA_CURL_INSECURE: "true"
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
timeout-minutes: 5
|
||||||
|
env:
|
||||||
|
GITEA_ACTIONS_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
GITEA_INTERNAL_URL: http://gitea:3000
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repository="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
|
||||||
|
sha="${GITHUB_SHA:?GITHUB_SHA is required}"
|
||||||
|
workspace="${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}"
|
||||||
|
token="${GITEA_ACTIONS_TOKEN:?GITEA_ACTIONS_TOKEN is required}"
|
||||||
|
server_url="${GITEA_INTERNAL_URL%/}"
|
||||||
|
remote_url="${server_url}/${repository}"
|
||||||
|
auth="$(printf 'x-access-token:%s' "${token}" | base64 | tr -d '\n')"
|
||||||
|
extra_header="AUTHORIZATION: basic ${auth}"
|
||||||
|
|
||||||
|
mkdir -p "${workspace}"
|
||||||
|
cd "${workspace}"
|
||||||
|
git init .
|
||||||
|
if git remote get-url origin >/dev/null 2>&1; then
|
||||||
|
git remote set-url origin "${remote_url}"
|
||||||
|
else
|
||||||
|
git remote add origin "${remote_url}"
|
||||||
|
fi
|
||||||
|
git -c "http.${server_url}/.extraheader=${extra_header}" \
|
||||||
|
fetch --prune --no-recurse-submodules origin \
|
||||||
|
+refs/heads/*:refs/remotes/origin/* \
|
||||||
|
+refs/tags/*:refs/tags/*
|
||||||
|
if ! git cat-file -e "${sha}^{commit}" 2>/dev/null; then
|
||||||
|
git -c "http.${server_url}/.extraheader=${extra_header}" \
|
||||||
|
fetch --no-recurse-submodules origin "${sha}"
|
||||||
|
fi
|
||||||
|
git clean -ffdx
|
||||||
|
git checkout --force "${sha}"
|
||||||
|
git clean -ffdx
|
||||||
|
git log -1 --format=%H
|
||||||
|
|
||||||
|
- name: Select release ref
|
||||||
|
env:
|
||||||
|
RELEASE_EVENT_NAME: ${{ github.event_name }}
|
||||||
|
RELEASE_INPUT_TAG: ${{ inputs.release_tag }}
|
||||||
|
RELEASE_INPUT_NAME: ${{ inputs.release_name }}
|
||||||
|
RELEASE_INPUT_DRAFT: ${{ inputs.draft }}
|
||||||
|
RELEASE_INPUT_VALIDATION_ONLY: ${{ inputs.validation_only }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ "${RELEASE_EVENT_NAME}" = "workflow_dispatch" ]; then
|
||||||
|
validation_only="${RELEASE_INPUT_VALIDATION_ONLY:-true}"
|
||||||
|
tag="${RELEASE_INPUT_TAG}"
|
||||||
|
name="${RELEASE_INPUT_NAME}"
|
||||||
|
draft="${RELEASE_INPUT_DRAFT}"
|
||||||
|
case "${validation_only}" in
|
||||||
|
true|false) ;;
|
||||||
|
*)
|
||||||
|
echo "release validation_only input must be true or false: ${validation_only}" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if [ "${validation_only}" = "true" ]; then
|
||||||
|
publish="false"
|
||||||
|
else
|
||||||
|
publish="true"
|
||||||
|
git checkout "refs/tags/${tag}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
tag="${GITHUB_REF_NAME}"
|
||||||
|
name="aria2-rust-pro ${tag#v}"
|
||||||
|
draft="false"
|
||||||
|
publish="true"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! printf '%s\n' "${tag}" | grep -Eq '^v[0-9]+[.][0-9]+[.][0-9]+$'; then
|
||||||
|
echo "release tag must look like vMAJOR.MINOR.PATCH: ${tag}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
case "${draft}" in
|
||||||
|
true|false) ;;
|
||||||
|
*)
|
||||||
|
echo "release draft input must be true or false: ${draft}" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
version="${tag#v}"
|
||||||
|
manifest_version="$(
|
||||||
|
awk '
|
||||||
|
$0 == "[workspace.package]" { in_workspace_package = 1; next }
|
||||||
|
/^\[/ { in_workspace_package = 0 }
|
||||||
|
in_workspace_package && $1 == "version" {
|
||||||
|
gsub(/"/, "", $3)
|
||||||
|
print $3
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
' Cargo.toml
|
||||||
|
)"
|
||||||
|
if [ -z "${manifest_version}" ]; then
|
||||||
|
echo "could not read workspace package version from Cargo.toml" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "${version}" != "${manifest_version}" ]; then
|
||||||
|
echo "release tag ${tag} does not match Cargo.toml version ${manifest_version}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
target="$(git rev-parse HEAD)"
|
||||||
|
notes_file="docs/release/${tag}.md"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "RELEASE_TAG=${tag}"
|
||||||
|
echo "RELEASE_NAME=${name}"
|
||||||
|
echo "RELEASE_DRAFT=${draft}"
|
||||||
|
echo "RELEASE_VERSION=${version}"
|
||||||
|
echo "RELEASE_TARGET=${target}"
|
||||||
|
echo "RELEASE_BODY_FILE=${notes_file}"
|
||||||
|
echo "RELEASE_PUBLISH=${publish}"
|
||||||
|
} >> "${GITHUB_ENV}"
|
||||||
|
|
||||||
|
- name: Install Linux dependencies
|
||||||
|
timeout-minutes: 10
|
||||||
|
run: ./scripts/ci/install-linux-deps.sh
|
||||||
|
|
||||||
|
- name: Bootstrap Rust toolchains
|
||||||
|
timeout-minutes: 25
|
||||||
|
run: ./scripts/ci/bootstrap-rust.sh
|
||||||
|
|
||||||
|
- name: Install Cargo tools
|
||||||
|
timeout-minutes: 30
|
||||||
|
run: ./scripts/ci/install-cargo-tools.sh strict
|
||||||
|
|
||||||
|
- name: Generate release notes when absent
|
||||||
|
timeout-minutes: 5
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -f "${RELEASE_BODY_FILE}" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
mkdir -p "$(dirname "${RELEASE_BODY_FILE}")"
|
||||||
|
git-cliff --config cliff.toml --tag "${RELEASE_TAG}" > "${RELEASE_BODY_FILE}"
|
||||||
|
test -s "${RELEASE_BODY_FILE}"
|
||||||
|
|
||||||
|
- name: Run fast quality gates
|
||||||
|
timeout-minutes: 45
|
||||||
|
run: ./scripts/ci/run-fast-gates.sh
|
||||||
|
|
||||||
|
- name: Run strict quality gates
|
||||||
|
timeout-minutes: 45
|
||||||
|
run: ./scripts/ci/run-strict-gates.sh
|
||||||
|
|
||||||
|
- name: Check SemVer API compatibility
|
||||||
|
timeout-minutes: 45
|
||||||
|
run: ./scripts/ci/run-semver-checks.sh "${RELEASE_TAG}"
|
||||||
|
|
||||||
|
- name: Build local release artifact
|
||||||
|
timeout-minutes: 30
|
||||||
|
run: cargo run --manifest-path ./xtask/Cargo.toml -- release package-local --build
|
||||||
|
|
||||||
|
- name: Export Docker image tar
|
||||||
|
timeout-minutes: 20
|
||||||
|
run: cargo run --manifest-path ./xtask/Cargo.toml -- docker export-local --build --tag aria2-rust-pro:release
|
||||||
|
|
||||||
|
- name: Publish Gitea release
|
||||||
|
timeout-minutes: 10
|
||||||
|
run: |
|
||||||
|
if [ "${RELEASE_PUBLISH:-true}" != "true" ]; then
|
||||||
|
echo "Release validation only; skipping Gitea publish"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
./scripts/ci/publish-gitea-release.sh
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
/target/
|
||||||
|
/target-alt/
|
||||||
|
/target-*/
|
||||||
|
/dist/
|
||||||
|
/artifacts/
|
||||||
|
/tmp/
|
||||||
|
/.codegraph/
|
||||||
|
/.cargo-home/
|
||||||
|
/.local/
|
||||||
|
/.serena/
|
||||||
|
/.idea/
|
||||||
|
/.vscode/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
docker/.env
|
||||||
|
docker/.env.*
|
||||||
|
!docker/.env.example
|
||||||
|
*.key
|
||||||
|
*.pem
|
||||||
|
*.p12
|
||||||
|
*.pfx
|
||||||
|
auth.json
|
||||||
|
credentials
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
crates/aria2-rust-pro-cli/*.bin
|
||||||
|
crates/aria2-rust-pro-cli/*.iso
|
||||||
|
crates/aria2-rust-pro-cli/live
|
||||||
|
crates/aria2-rust-pro-cli/live-*
|
||||||
|
crates/aria2-rust-pro-core/engine-session.txt
|
||||||
|
crates/aria2-rust-pro-tests/payload*.bin
|
||||||
|
crates/aria2-rust-pro-tests/shared-runtime-*.bin
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable user-facing changes to `aria2-rust-pro` should be recorded here.
|
||||||
|
|
||||||
|
The format is intentionally simple and release-oriented. Keep internal refactor
|
||||||
|
noise out unless it changes behavior, packaging, compatibility, migration, or
|
||||||
|
operator experience.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Placeholder for the next release.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Placeholder for the next release.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Placeholder for the next release.
|
||||||
|
|
||||||
|
### Compatibility
|
||||||
|
|
||||||
|
- Placeholder for compatibility notes and migration impact.
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-05-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Rust 2024 workspace rewrite of the current Aria2-Pro-Core line
|
||||||
|
- Cargo-native `xtask` automation for release packaging and Docker workflows
|
||||||
|
- local release archive staging with manifest and SHA-256 generation
|
||||||
|
- local Docker image tar export with manifest and checksum generation
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- release builds now use a size-oriented profile with `opt-level = "s"`,
|
||||||
|
fat LTO, single codegen unit, stripped symbols, and std rebuilt with
|
||||||
|
`optimize_for_size`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Docker release builds now include the `xtask/` workspace member required by
|
||||||
|
the workspace manifest during image compilation
|
||||||
|
|
||||||
|
### Compatibility
|
||||||
|
|
||||||
|
- version smoke preserves the upstream-compatible first line
|
||||||
|
`aria2 version 1.37.0`
|
||||||
|
- container images expose `aria2c` as a symlink to `aria2-rust-pro`
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
# Contributing to aria2-rust-pro
|
||||||
|
|
||||||
|
Thanks for contributing to `aria2-rust-pro`.
|
||||||
|
|
||||||
|
This repository is maintained as a practical, compatibility-driven rewrite of
|
||||||
|
Aria2-Pro-Core on top of a Rust 2024 workspace. The most helpful contributions
|
||||||
|
are the ones that improve behavior, compatibility evidence, packaging, and
|
||||||
|
maintainability without drifting the external aria2-facing contract.
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
Recommended local prerequisites:
|
||||||
|
|
||||||
|
- Rust toolchain from `rust-toolchain.toml`
|
||||||
|
- PowerShell 7 on Windows
|
||||||
|
- Docker Desktop or a compatible Docker daemon for image validation
|
||||||
|
- `rtk` for compact command output and the repository's preferred terminal flow
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
rtk cargo metadata --manifest-path .\Cargo.toml --no-deps --format-version 1
|
||||||
|
rtk cargo check --workspace --all-targets --all-features --locked
|
||||||
|
rtk cargo nextest run --workspace --all-targets --all-features --locked
|
||||||
|
```
|
||||||
|
|
||||||
|
## Preferred Workflow
|
||||||
|
|
||||||
|
1. branch from `main`
|
||||||
|
2. keep the edit scope tight and purpose-driven
|
||||||
|
3. follow existing crate boundaries instead of inventing new cross-cutting
|
||||||
|
abstractions too early
|
||||||
|
4. add or update tests when behavior changes
|
||||||
|
5. run the relevant gates before asking for review
|
||||||
|
|
||||||
|
If your change affects release packaging or container behavior, also run the
|
||||||
|
owned smoke path:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
rtk cargo run --manifest-path .\xtask\Cargo.toml -- release smoke-version
|
||||||
|
rtk cargo run --manifest-path .\xtask\Cargo.toml -- docker smoke-local
|
||||||
|
```
|
||||||
|
|
||||||
|
## Required Quality Gates
|
||||||
|
|
||||||
|
The expected final gates are documented in
|
||||||
|
[docs/testing/quality-gates.md](docs/testing/quality-gates.md). The usual
|
||||||
|
closeout set is:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
rtk cargo fmt --all --check
|
||||||
|
rtk cargo check --workspace --all-targets --all-features --locked
|
||||||
|
rtk cargo nextest run --workspace --all-targets --all-features --locked
|
||||||
|
rtk cargo clippy --workspace --all-targets --all-features --locked --no-deps -- -D warnings -D clippy::pedantic -D clippy::nursery -D clippy::cargo
|
||||||
|
rtk cargo +nightly udeps --workspace --all-targets --all-features --locked
|
||||||
|
rtk cargo deny --locked check
|
||||||
|
```
|
||||||
|
|
||||||
|
For a one-shot local sweep:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pwsh ./scripts/testing/strict-sweep.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Repository Conventions
|
||||||
|
|
||||||
|
- Prefer Cargo-native `xtask` entrypoints over ad hoc scripts when both exist.
|
||||||
|
- Keep public-facing behavior compatible unless the change is an intentional,
|
||||||
|
documented divergence.
|
||||||
|
- Preserve release evidence: manifests, checksums, version-smoke validation,
|
||||||
|
and migration notes matter here.
|
||||||
|
- Prefer `rtk` commands for repo work.
|
||||||
|
- Keep documentation in sync when changing deployment, Docker, migration, or
|
||||||
|
packaging behavior.
|
||||||
|
|
||||||
|
### Commit Messages
|
||||||
|
|
||||||
|
Use Conventional Commits for every authored commit:
|
||||||
|
|
||||||
|
```text
|
||||||
|
type(scope)!: concise summary
|
||||||
|
```
|
||||||
|
|
||||||
|
Allowed types are `feat`, `fix`, `perf`, `refactor`, `test`, `docs`, `build`,
|
||||||
|
`ci`, `chore`, and `revert`. The scope and `!` are optional. Use `!` or a
|
||||||
|
`BREAKING CHANGE:` footer for intentional compatibility breaks. CI validates
|
||||||
|
non-merge commits after the `v1.0.0` baseline.
|
||||||
|
|
||||||
|
### Versioning and Changelog
|
||||||
|
|
||||||
|
The workspace package version in the root `Cargo.toml` is the release version.
|
||||||
|
Internal crate constraints are centralized in `[workspace.dependencies]`; do
|
||||||
|
not add crate-local copies. Release tags remain `vMAJOR.MINOR.PATCH` and must
|
||||||
|
match the workspace version.
|
||||||
|
|
||||||
|
- `feat` is a minor-release candidate.
|
||||||
|
- `fix` and `perf` are patch-release candidates.
|
||||||
|
- `!` or `BREAKING CHANGE:` requires a major-release decision.
|
||||||
|
- `docs`, `refactor`, `test`, `build`, `ci`, and `chore` do not independently
|
||||||
|
require a version bump.
|
||||||
|
|
||||||
|
`git-cliff` generates user-facing entries from Conventional Commits. Run the
|
||||||
|
following preview before a release-facing change:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
rtk git-cliff --config .\cliff.toml --unreleased
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dependency Updates
|
||||||
|
|
||||||
|
Renovate creates dependency pull requests against `main`. Patch updates may
|
||||||
|
automerge only after CI succeeds; minor and major updates always remain review
|
||||||
|
pull requests. Lockfile maintenance is also review-only.
|
||||||
|
|
||||||
|
## Pull Requests and Reviews
|
||||||
|
|
||||||
|
When opening a review:
|
||||||
|
|
||||||
|
- summarize the behavior change in plain language
|
||||||
|
- list the exact verification commands you ran
|
||||||
|
- call out compatibility-sensitive areas explicitly
|
||||||
|
- mention any remaining limits or follow-up work instead of hiding them
|
||||||
|
|
||||||
|
## Release-Facing Changes
|
||||||
|
|
||||||
|
If your change affects users directly, also update at least one of:
|
||||||
|
|
||||||
|
- [CHANGELOG.md](CHANGELOG.md)
|
||||||
|
- [docs/release/RELEASE-NOTES-TEMPLATE.md](docs/release/RELEASE-NOTES-TEMPLATE.md)
|
||||||
|
- [docs/migration/README.md](docs/migration/README.md)
|
||||||
|
|
||||||
|
That keeps the repository ready for an actual external release instead of only
|
||||||
|
an internal development snapshot.
|
||||||
Generated
+2365
File diff suppressed because it is too large
Load Diff
+91
@@ -0,0 +1,91 @@
|
|||||||
|
[workspace]
|
||||||
|
members = [
|
||||||
|
"crates/aria2-rust-pro-cli",
|
||||||
|
"crates/aria2-rust-pro-compat",
|
||||||
|
"crates/aria2-rust-pro-core",
|
||||||
|
"crates/aria2-rust-pro-protocol",
|
||||||
|
"crates/aria2-rust-pro-rpc",
|
||||||
|
"crates/aria2-rust-pro-storage",
|
||||||
|
"crates/aria2-rust-pro-tests",
|
||||||
|
"xtask",
|
||||||
|
]
|
||||||
|
resolver = "3"
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
categories = ["command-line-utilities", "network-programming"]
|
||||||
|
description = "Rust 2024 rewrite of Aria2-Pro-Core with aria2 compatibility"
|
||||||
|
edition = "2024"
|
||||||
|
keywords = ["aria2", "download", "rpc", "bittorrent", "rust"]
|
||||||
|
license = "GPL-2.0-or-later"
|
||||||
|
readme = "README.md"
|
||||||
|
rust-version = "1.88"
|
||||||
|
version = "1.0.0"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
aria2-rust-pro-cli = { version = "1.0.0", path = "crates/aria2-rust-pro-cli" }
|
||||||
|
aria2-rust-pro-compat = { version = "1.0.0", path = "crates/aria2-rust-pro-compat" }
|
||||||
|
aria2-rust-pro-core = { version = "1.0.0", path = "crates/aria2-rust-pro-core" }
|
||||||
|
aria2-rust-pro-protocol = { version = "1.0.0", path = "crates/aria2-rust-pro-protocol" }
|
||||||
|
aria2-rust-pro-rpc = { version = "1.0.0", path = "crates/aria2-rust-pro-rpc" }
|
||||||
|
aria2-rust-pro-storage = { version = "1.0.0", path = "crates/aria2-rust-pro-storage" }
|
||||||
|
|
||||||
|
[workspace.lints.rust]
|
||||||
|
elided_lifetimes_in_paths = "deny"
|
||||||
|
future_incompatible = { level = "deny", priority = -1 }
|
||||||
|
missing_copy_implementations = "deny"
|
||||||
|
missing_debug_implementations = "deny"
|
||||||
|
missing_docs = "deny"
|
||||||
|
rust_2018_idioms = { level = "deny", priority = -1 }
|
||||||
|
rust_2021_compatibility = { level = "deny", priority = -1 }
|
||||||
|
rust_2024_compatibility = { level = "deny", priority = -1 }
|
||||||
|
trivial_casts = "deny"
|
||||||
|
trivial_numeric_casts = "deny"
|
||||||
|
unreachable_pub = "deny"
|
||||||
|
unsafe_op_in_unsafe_fn = "deny"
|
||||||
|
unsafe_code = "forbid"
|
||||||
|
unused = { level = "deny", priority = -1 }
|
||||||
|
unused_crate_dependencies = "deny"
|
||||||
|
unused_import_braces = "deny"
|
||||||
|
unused_lifetimes = "deny"
|
||||||
|
unused_qualifications = "deny"
|
||||||
|
warnings = "deny"
|
||||||
|
|
||||||
|
[workspace.lints.rustdoc]
|
||||||
|
bare_urls = "deny"
|
||||||
|
broken_intra_doc_links = "deny"
|
||||||
|
invalid_codeblock_attributes = "deny"
|
||||||
|
invalid_html_tags = "deny"
|
||||||
|
|
||||||
|
[workspace.lints.clippy]
|
||||||
|
all = { level = "deny", priority = -1 }
|
||||||
|
nursery = { level = "deny", priority = -1 }
|
||||||
|
pedantic = { level = "deny", priority = -1 }
|
||||||
|
allow_attributes = "deny"
|
||||||
|
allow_attributes_without_reason = "deny"
|
||||||
|
as_conversions = "deny"
|
||||||
|
arithmetic_side_effects = "deny"
|
||||||
|
cast_possible_truncation = "deny"
|
||||||
|
cast_possible_wrap = "deny"
|
||||||
|
cast_precision_loss = "deny"
|
||||||
|
cast_sign_loss = "deny"
|
||||||
|
dbg_macro = "deny"
|
||||||
|
float_arithmetic = "deny"
|
||||||
|
indexing_slicing = "deny"
|
||||||
|
integer_division = "deny"
|
||||||
|
missing_docs_in_private_items = "deny"
|
||||||
|
missing_errors_doc = "deny"
|
||||||
|
missing_panics_doc = "deny"
|
||||||
|
missing_safety_doc = "deny"
|
||||||
|
multiple_unsafe_ops_per_block = "deny"
|
||||||
|
todo = "deny"
|
||||||
|
undocumented_unsafe_blocks = "deny"
|
||||||
|
unimplemented = "deny"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
codegen-units = 1
|
||||||
|
debug = 0
|
||||||
|
debug-assertions = false
|
||||||
|
lto = "fat"
|
||||||
|
opt-level = "s"
|
||||||
|
panic = "abort"
|
||||||
|
strip = "symbols"
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 2, June 1991
|
||||||
|
|
||||||
|
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
|
||||||
|
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The licenses for most software are designed to take away your
|
||||||
|
freedom to share and change it. By contrast, the GNU General Public
|
||||||
|
License is intended to guarantee your freedom to share and change free
|
||||||
|
software--to make sure the software is free for all its users. This
|
||||||
|
General Public License applies to most of the Free Software
|
||||||
|
Foundation's software and to any other program whose authors commit to
|
||||||
|
using it. (Some other Free Software Foundation software is covered by
|
||||||
|
the GNU Lesser General Public License instead.) You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
this service if you wish), that you receive source code or can get it
|
||||||
|
if you want it, that you can change the software or use pieces of it
|
||||||
|
in new free programs; and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to make restrictions that forbid
|
||||||
|
anyone to deny you these rights or to ask you to surrender the rights.
|
||||||
|
These restrictions translate to certain responsibilities for you if you
|
||||||
|
distribute copies of the software, or if you modify it.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must give the recipients all the rights that
|
||||||
|
you have. You must make sure that they, too, receive or can get the
|
||||||
|
source code. And you must show them these terms so they know their
|
||||||
|
rights.
|
||||||
|
|
||||||
|
We protect your rights with two steps: (1) copyright the software, and
|
||||||
|
(2) offer you this license which gives you legal permission to copy,
|
||||||
|
distribute and/or modify the software.
|
||||||
|
|
||||||
|
Also, for each author's protection and ours, we want to make certain
|
||||||
|
that everyone understands that there is no warranty for this free
|
||||||
|
software. If the software is modified by someone else and passed on, we
|
||||||
|
want its recipients to know that what they have is not the original, so
|
||||||
|
that any problems introduced by others will not reflect on the original
|
||||||
|
authors' reputations.
|
||||||
|
|
||||||
|
Finally, any free program is threatened constantly by software
|
||||||
|
patents. We wish to avoid the danger that redistributors of a free
|
||||||
|
program will individually obtain patent licenses, in effect making the
|
||||||
|
program proprietary. To prevent this, we have made it clear that any
|
||||||
|
patent must be licensed for everyone's free use or not licensed at all.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||||
|
|
||||||
|
0. This License applies to any program or other work which contains
|
||||||
|
a notice placed by the copyright holder saying it may be distributed
|
||||||
|
under the terms of this General Public License. The "Program", below,
|
||||||
|
refers to any such program or work, and a "work based on the Program"
|
||||||
|
means either the Program or any derivative work under copyright law:
|
||||||
|
that is to say, a work containing the Program or a portion of it,
|
||||||
|
either verbatim or with modifications and/or translated into another
|
||||||
|
language. (Hereinafter, translation is included without limitation in
|
||||||
|
the term "modification".) Each licensee is addressed as "you".
|
||||||
|
|
||||||
|
Activities other than copying, distribution and modification are not
|
||||||
|
covered by this License; they are outside its scope. The act of
|
||||||
|
running the Program is not restricted, and the output from the Program
|
||||||
|
is covered only if its contents constitute a work based on the
|
||||||
|
Program (independent of having been made by running the Program).
|
||||||
|
Whether that is true depends on what the Program does.
|
||||||
|
|
||||||
|
1. You may copy and distribute verbatim copies of the Program's
|
||||||
|
source code as you receive it, in any medium, provided that you
|
||||||
|
conspicuously and appropriately publish on each copy an appropriate
|
||||||
|
copyright notice and disclaimer of warranty; keep intact all the
|
||||||
|
notices that refer to this License and to the absence of any warranty;
|
||||||
|
and give any other recipients of the Program a copy of this License
|
||||||
|
along with the Program.
|
||||||
|
|
||||||
|
You may charge a fee for the physical act of transferring a copy, and
|
||||||
|
you may at your option offer warranty protection in exchange for a fee.
|
||||||
|
|
||||||
|
2. You may modify your copy or copies of the Program or any portion
|
||||||
|
of it, thus forming a work based on the Program, and copy and
|
||||||
|
distribute such modifications or work under the terms of Section 1
|
||||||
|
above, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) You must cause the modified files to carry prominent notices
|
||||||
|
stating that you changed the files and the date of any change.
|
||||||
|
|
||||||
|
b) You must cause any work that you distribute or publish, that in
|
||||||
|
whole or in part contains or is derived from the Program or any
|
||||||
|
part thereof, to be licensed as a whole at no charge to all third
|
||||||
|
parties under the terms of this License.
|
||||||
|
|
||||||
|
c) If the modified program normally reads commands interactively
|
||||||
|
when run, you must cause it, when started running for such
|
||||||
|
interactive use in the most ordinary way, to print or display an
|
||||||
|
announcement including an appropriate copyright notice and a
|
||||||
|
notice that there is no warranty (or else, saying that you provide
|
||||||
|
a warranty) and that users may redistribute the program under
|
||||||
|
these conditions, and telling the user how to view a copy of this
|
||||||
|
License. (Exception: if the Program itself is interactive but
|
||||||
|
does not normally print such an announcement, your work based on
|
||||||
|
the Program is not required to print an announcement.)
|
||||||
|
|
||||||
|
These requirements apply to the modified work as a whole. If
|
||||||
|
identifiable sections of that work are not derived from the Program,
|
||||||
|
and can be reasonably considered independent and separate works in
|
||||||
|
themselves, then this License, and its terms, do not apply to those
|
||||||
|
sections when you distribute them as separate works. But when you
|
||||||
|
distribute the same sections as part of a whole which is a work based
|
||||||
|
on the Program, the distribution of the whole must be on the terms of
|
||||||
|
this License, whose permissions for other licensees extend to the
|
||||||
|
entire whole, and thus to each and every part regardless of who wrote it.
|
||||||
|
|
||||||
|
Thus, it is not the intent of this section to claim rights or contest
|
||||||
|
your rights to work written entirely by you; rather, the intent is to
|
||||||
|
exercise the right to control the distribution of derivative or
|
||||||
|
collective works based on the Program.
|
||||||
|
|
||||||
|
In addition, mere aggregation of another work not based on the Program
|
||||||
|
with the Program (or with a work based on the Program) on a volume of
|
||||||
|
a storage or distribution medium does not bring the other work under
|
||||||
|
the scope of this License.
|
||||||
|
|
||||||
|
3. You may copy and distribute the Program (or a work based on it,
|
||||||
|
under Section 2) in object code or executable form under the terms of
|
||||||
|
Sections 1 and 2 above provided that you also do one of the following:
|
||||||
|
|
||||||
|
a) Accompany it with the complete corresponding machine-readable
|
||||||
|
source code, which must be distributed under the terms of Sections
|
||||||
|
1 and 2 above on a medium customarily used for software interchange; or,
|
||||||
|
|
||||||
|
b) Accompany it with a written offer, valid for at least three
|
||||||
|
years, to give any third party, for a charge no more than your
|
||||||
|
cost of physically performing source distribution, a complete
|
||||||
|
machine-readable copy of the corresponding source code, to be
|
||||||
|
distributed under the terms of Sections 1 and 2 above on a medium
|
||||||
|
customarily used for software interchange; or,
|
||||||
|
|
||||||
|
c) Accompany it with the information you received as to the offer
|
||||||
|
to distribute corresponding source code. (This alternative is
|
||||||
|
allowed only for noncommercial distribution and only if you
|
||||||
|
received the program in object code or executable form with such
|
||||||
|
an offer, in accord with Subsection b above.)
|
||||||
|
|
||||||
|
The source code for a work means the preferred form of the work for
|
||||||
|
making modifications to it. For an executable work, complete source
|
||||||
|
code means all the source code for all modules it contains, plus any
|
||||||
|
associated interface definition files, plus the scripts used to
|
||||||
|
control compilation and installation of the executable. However, as a
|
||||||
|
special exception, the source code distributed need not include
|
||||||
|
anything that is normally distributed (in either source or binary
|
||||||
|
form) with the major components (compiler, kernel, and so on) of the
|
||||||
|
operating system on which the executable runs, unless that component
|
||||||
|
itself accompanies the executable.
|
||||||
|
|
||||||
|
If distribution of executable or object code is made by offering
|
||||||
|
access to copy from a designated place, then offering equivalent
|
||||||
|
access to copy the source code from the same place counts as
|
||||||
|
distribution of the source code, even though third parties are not
|
||||||
|
compelled to copy the source along with the object code.
|
||||||
|
|
||||||
|
4. You may not copy, modify, sublicense, or distribute the Program
|
||||||
|
except as expressly provided under this License. Any attempt
|
||||||
|
otherwise to copy, modify, sublicense or distribute the Program is
|
||||||
|
void, and will automatically terminate your rights under this License.
|
||||||
|
However, parties who have received copies, or rights, from you under
|
||||||
|
this License will not have their licenses terminated so long as such
|
||||||
|
parties remain in full compliance.
|
||||||
|
|
||||||
|
5. You are not required to accept this License, since you have not
|
||||||
|
signed it. However, nothing else grants you permission to modify or
|
||||||
|
distribute the Program or its derivative works. These actions are
|
||||||
|
prohibited by law if you do not accept this License. Therefore, by
|
||||||
|
modifying or distributing the Program (or any work based on the
|
||||||
|
Program), you indicate your acceptance of this License to do so, and
|
||||||
|
all its terms and conditions for copying, distributing or modifying
|
||||||
|
the Program or works based on it.
|
||||||
|
|
||||||
|
6. Each time you redistribute the Program (or any work based on the
|
||||||
|
Program), the recipient automatically receives a license from the
|
||||||
|
original licensor to copy, distribute or modify the Program subject to
|
||||||
|
these terms and conditions. You may not impose any further
|
||||||
|
restrictions on the recipients' exercise of the rights granted herein.
|
||||||
|
You are not responsible for enforcing compliance by third parties to
|
||||||
|
this License.
|
||||||
|
|
||||||
|
7. If, as a consequence of a court judgment or allegation of patent
|
||||||
|
infringement or for any other reason (not limited to patent issues),
|
||||||
|
conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot
|
||||||
|
distribute so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you
|
||||||
|
may not distribute the Program at all. For example, if a patent
|
||||||
|
license would not permit royalty-free redistribution of the Program by
|
||||||
|
all those who receive copies directly or indirectly through you, then
|
||||||
|
the only way you could satisfy both it and this License would be to
|
||||||
|
refrain entirely from distribution of the Program.
|
||||||
|
|
||||||
|
If any portion of this section is held invalid or unenforceable under
|
||||||
|
any particular circumstance, the balance of the section is intended to
|
||||||
|
apply and the section as a whole is intended to apply in other
|
||||||
|
circumstances.
|
||||||
|
|
||||||
|
It is not the purpose of this section to induce you to infringe any
|
||||||
|
patents or other property right claims or to contest validity of any
|
||||||
|
such claims; this section has the sole purpose of protecting the
|
||||||
|
integrity of the free software distribution system, which is
|
||||||
|
implemented by public license practices. Many people have made
|
||||||
|
generous contributions to the wide range of software distributed
|
||||||
|
through that system in reliance on consistent application of that
|
||||||
|
system; it is up to the author/donor to decide if he or she is willing
|
||||||
|
to distribute software through any other system and a licensee cannot
|
||||||
|
impose that choice.
|
||||||
|
|
||||||
|
This section is intended to make thoroughly clear what is believed to
|
||||||
|
be a consequence of the rest of this License.
|
||||||
|
|
||||||
|
8. If the distribution and/or use of the Program is restricted in
|
||||||
|
certain countries either by patents or by copyrighted interfaces, the
|
||||||
|
original copyright holder who places the Program under this License
|
||||||
|
may add an explicit geographical distribution limitation excluding
|
||||||
|
those countries, so that distribution is permitted only in or among
|
||||||
|
countries not thus excluded. In such case, this License incorporates
|
||||||
|
the limitation as if written in the body of this License.
|
||||||
|
|
||||||
|
9. The Free Software Foundation may publish revised and/or new versions
|
||||||
|
of the General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the Program
|
||||||
|
specifies a version number of this License which applies to it and "any
|
||||||
|
later version", you have the option of following the terms and conditions
|
||||||
|
either of that version or of any later version published by the Free
|
||||||
|
Software Foundation. If the Program does not specify a version number of
|
||||||
|
this License, you may choose any version ever published by the Free Software
|
||||||
|
Foundation.
|
||||||
|
|
||||||
|
10. If you wish to incorporate parts of the Program into other free
|
||||||
|
programs whose distribution conditions are different, write to the author
|
||||||
|
to ask for permission. For software which is copyrighted by the Free
|
||||||
|
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||||
|
make exceptions for this. Our decision will be guided by the two goals
|
||||||
|
of preserving the free status of all derivatives of our free software and
|
||||||
|
of promoting the sharing and reuse of software generally.
|
||||||
|
|
||||||
|
NO WARRANTY
|
||||||
|
|
||||||
|
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||||
|
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||||
|
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||||
|
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||||
|
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||||
|
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||||
|
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||||
|
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||||
|
REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||||
|
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||||
|
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||||
|
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||||
|
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||||
|
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||||
|
POSSIBILITY OF SUCH DAMAGES.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
convey the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software; you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation; either version 2 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License along
|
||||||
|
with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program is interactive, make it output a short notice like this
|
||||||
|
when it starts in an interactive mode:
|
||||||
|
|
||||||
|
Gnomovision version 69, Copyright (C) year name of author
|
||||||
|
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, the commands you use may
|
||||||
|
be called something other than `show w' and `show c'; they could even be
|
||||||
|
mouse-clicks or menu items--whatever suits your program.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or your
|
||||||
|
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||||
|
necessary. Here is a sample; alter the names:
|
||||||
|
|
||||||
|
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||||
|
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||||
|
|
||||||
|
<signature of Ty Coon>, 1 April 1989
|
||||||
|
Ty Coon, President of Vice
|
||||||
|
|
||||||
|
This General Public License does not permit incorporating your program into
|
||||||
|
proprietary programs. If your program is a subroutine library, you may
|
||||||
|
consider it more useful to permit linking proprietary applications with the
|
||||||
|
library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License.
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# aria2-rust-pro
|
||||||
|
|
||||||
|
`aria2-rust-pro` is a Rust 2024 rewrite of the current Aria2-Pro-Core
|
||||||
|
behavior, with upstream aria2 1.37.0 compatibility as the primary external
|
||||||
|
contract.
|
||||||
|
|
||||||
|
This repository is the maintained Rust line for the project. It aims to keep
|
||||||
|
the familiar aria2 configuration and RPC surface, while moving the
|
||||||
|
implementation onto a modern Cargo-based toolchain, stricter quality gates, and
|
||||||
|
source-first packaging automation.
|
||||||
|
|
||||||
|
## What This Repository Provides
|
||||||
|
|
||||||
|
- a Rust-native `aria2-rust-pro` CLI with aria2-style config and RPC behavior
|
||||||
|
- a Docker image build that preserves the familiar Pro Docker environment model
|
||||||
|
- a Cargo-native `xtask` workflow for release packaging, Docker smoke tests,
|
||||||
|
and local export
|
||||||
|
- strict workspace-wide linting, testing, and dependency policy enforcement
|
||||||
|
- local release staging for Windows artifacts and portable Docker image tar
|
||||||
|
exports
|
||||||
|
|
||||||
|
## Current Snapshot
|
||||||
|
|
||||||
|
The repository already includes:
|
||||||
|
|
||||||
|
- a local Windows release packaging flow
|
||||||
|
- a local Docker image export flow
|
||||||
|
- JSON-RPC and XML-RPC coverage for representative aria2 client paths
|
||||||
|
- BitTorrent, Metalink, HTTP, HTTPS, XML-RPC, JSON-RPC, and SFTP feature
|
||||||
|
surfaces in the current binary banner
|
||||||
|
|
||||||
|
The current public repository state should be read as:
|
||||||
|
|
||||||
|
- source of truth for ongoing Rust development
|
||||||
|
- suitable for local builds, local packaging, and local Docker deployment
|
||||||
|
- backed by a self-hosted Gitea `v1.0.0` release with Windows archive, Docker
|
||||||
|
tar, manifests, and checksum assets
|
||||||
|
- not yet presented as a public container registry or broad multi-platform
|
||||||
|
binary distribution service
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Build the binary
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
rtk cargo build --release -p aria2-rust-pro-cli --bin aria2-rust-pro
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check the version banner
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\target\release\aria2-rust-pro.exe --version
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run the main validation lanes
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
rtk cargo check --workspace --all-targets --all-features --locked
|
||||||
|
rtk cargo nextest run --workspace --all-targets --all-features --locked
|
||||||
|
```
|
||||||
|
|
||||||
|
### Package a local release artifact
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
rtk cargo run --manifest-path .\xtask\Cargo.toml -- release package-local --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build and export a local Docker image tar
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
rtk cargo run --manifest-path .\xtask\Cargo.toml -- docker export-local --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Repository Guide
|
||||||
|
|
||||||
|
- [docs/deployment/README.md](docs/deployment/README.md): native and Docker
|
||||||
|
deployment guide
|
||||||
|
- [docs/migration/README.md](docs/migration/README.md): migration notes from
|
||||||
|
existing Aria2-Pro-Core and Aria2-Pro-Docker installs
|
||||||
|
- [docs/release/README.md](docs/release/README.md): local release packaging,
|
||||||
|
Docker export, and artifact manifest flow
|
||||||
|
- [docs/release/RELEASE-NOTES-TEMPLATE.md](docs/release/RELEASE-NOTES-TEMPLATE.md):
|
||||||
|
release announcement template
|
||||||
|
- [docs/compatibility/aria2-compat-ledger.md](docs/compatibility/aria2-compat-ledger.md):
|
||||||
|
compatibility tracking ledger
|
||||||
|
- [docs/testing/quality-gates.md](docs/testing/quality-gates.md): required test
|
||||||
|
and lint gates
|
||||||
|
- [CONTRIBUTING.md](CONTRIBUTING.md): contribution workflow and repository
|
||||||
|
expectations
|
||||||
|
- [CHANGELOG.md](CHANGELOG.md): release-facing change history
|
||||||
|
|
||||||
|
## Workspace Layout
|
||||||
|
|
||||||
|
- `crates/aria2-rust-pro-cli`: CLI surface, config projection, runtime launch,
|
||||||
|
and rendering
|
||||||
|
- `crates/aria2-rust-pro-compat`: aria2-facing option and compatibility layer
|
||||||
|
- `crates/aria2-rust-pro-core`: scheduler, engine, runtime coordination
|
||||||
|
- `crates/aria2-rust-pro-protocol`: HTTP, BitTorrent, Metalink, and transport
|
||||||
|
domain logic
|
||||||
|
- `crates/aria2-rust-pro-rpc`: JSON-RPC, XML-RPC, routing, and handlers
|
||||||
|
- `crates/aria2-rust-pro-storage`: session and persistence layers
|
||||||
|
- `crates/aria2-rust-pro-tests`: integration, compatibility, and pressure tests
|
||||||
|
- `xtask/`: Cargo-native project automation
|
||||||
|
- `docker/`: image build, entrypoint, compose example, and bundled defaults
|
||||||
|
|
||||||
|
## Packaging and Local Artifacts
|
||||||
|
|
||||||
|
Local staged artifacts live under:
|
||||||
|
|
||||||
|
- `dist/release/v<version>/` for packaged release archives
|
||||||
|
- `dist/docker/v<version>/` for portable Docker image tar exports
|
||||||
|
|
||||||
|
The package and export commands emit checksum files and JSON manifests beside
|
||||||
|
the artifacts so a staged build can be moved to another machine and verified
|
||||||
|
without guessing which binary or image tag it came from.
|
||||||
|
|
||||||
|
## Compatibility Direction
|
||||||
|
|
||||||
|
The compatibility target is practical aria2 interoperability rather than a
|
||||||
|
novel CLI surface. That means the project prioritizes:
|
||||||
|
|
||||||
|
- aria2-style config semantics
|
||||||
|
- existing RPC client compatibility
|
||||||
|
- migration paths from Aria2-Pro-Core and Aria2-Pro-Docker
|
||||||
|
- release packaging that keeps runtime evidence with the artifact
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
`aria2-rust-pro` is licensed under [GPL-2.0-or-later](LICENSE). OpenSSL
|
||||||
|
linking-exception handling remains a release and documentation concern carried
|
||||||
|
forward from the aria2 ecosystem.
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
[changelog]
|
||||||
|
header = """
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable user-facing changes to `aria2-rust-pro` are recorded here.
|
||||||
|
"""
|
||||||
|
body = """
|
||||||
|
{% if version %}
|
||||||
|
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
||||||
|
{% else %}
|
||||||
|
## [Unreleased]
|
||||||
|
{% endif %}
|
||||||
|
{% for group, commits in commits | group_by(attribute="group") %}
|
||||||
|
### {{ group }}
|
||||||
|
{% for commit in commits %}
|
||||||
|
- {% if commit.scope %}**{{ commit.scope }}:** {% endif %}{% if commit.breaking %}**BREAKING:** {% endif %}{{ commit.message | upper_first }}
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
"""
|
||||||
|
trim = true
|
||||||
|
render_always = true
|
||||||
|
|
||||||
|
[git]
|
||||||
|
conventional_commits = true
|
||||||
|
filter_unconventional = true
|
||||||
|
require_conventional = true
|
||||||
|
protect_breaking_commits = true
|
||||||
|
filter_commits = false
|
||||||
|
topo_order_commits = true
|
||||||
|
sort_commits = "oldest"
|
||||||
|
commit_parsers = [
|
||||||
|
{ message = "^feat", group = "Added" },
|
||||||
|
{ message = "^fix", group = "Fixed" },
|
||||||
|
{ message = "^perf", group = "Performance" },
|
||||||
|
{ message = "^revert", group = "Reverted" },
|
||||||
|
{ message = ".*", skip = true },
|
||||||
|
]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
[package]
|
||||||
|
name = "aria2-rust-pro-cli"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
description.workspace = true
|
||||||
|
readme.workspace = true
|
||||||
|
keywords.workspace = true
|
||||||
|
categories.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "aria2-rust-pro"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
aria2-rust-pro-compat.workspace = true
|
||||||
|
aria2-rust-pro-core.workspace = true
|
||||||
|
aria2-rust-pro-protocol.workspace = true
|
||||||
|
aria2-rust-pro-rpc.workspace = true
|
||||||
|
aria2-rust-pro-storage.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
ssh2 = "0.9.5"
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,511 @@
|
|||||||
|
#![doc(hidden)]
|
||||||
|
#![expect(
|
||||||
|
clippy::redundant_pub_crate,
|
||||||
|
reason = "this private CLI parsing module exposes parent-only helpers while keeping documentation focused on the public command surface"
|
||||||
|
)]
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Parses aria2-compatible command-line arguments into a high-level invocation.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error when an option is unknown or when an option requiring a
|
||||||
|
/// value is missing that value.
|
||||||
|
pub fn parse_args(args: impl IntoIterator<Item = OsString>) -> Result<Invocation, CliError> {
|
||||||
|
Ok(parse_cli(args)?.invocation)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether a token can be consumed as aria2-compatible boolean text.
|
||||||
|
fn looks_like_bool_value(value: &str) -> bool {
|
||||||
|
parse_bool_text(value).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a transient compat profile from CLI-originated directives.
|
||||||
|
fn build_cli_profile(directives: Vec<ConfigDirective>) -> Option<ConfigProfile> {
|
||||||
|
(!directives.is_empty()).then(|| ConfigProfile {
|
||||||
|
name: "cli".to_owned(),
|
||||||
|
source: ConfigSource::RuntimeOverride,
|
||||||
|
document: ConfigDocument {
|
||||||
|
directives,
|
||||||
|
location: ConfigLocationKind::Cli,
|
||||||
|
scope: ConfigScope::Mixed,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merges a file-backed config profile with CLI overrides, keeping CLI values last.
|
||||||
|
pub(crate) fn merged_profile(
|
||||||
|
file_profile: Option<&ConfigProfile>,
|
||||||
|
cli_profile: Option<&ConfigProfile>,
|
||||||
|
) -> Option<ConfigProfile> {
|
||||||
|
match (file_profile, cli_profile) {
|
||||||
|
(None, None) => None,
|
||||||
|
(Some(profile), None) | (None, Some(profile)) => Some(profile.clone()),
|
||||||
|
(Some(file_profile), Some(cli_profile)) => {
|
||||||
|
let mut directives = file_profile.document.directives.clone();
|
||||||
|
directives.extend(cli_profile.document.directives.clone());
|
||||||
|
Some(ConfigProfile {
|
||||||
|
name: format!("{}+cli", file_profile.name),
|
||||||
|
source: ConfigSource::RuntimeOverride,
|
||||||
|
document: ConfigDocument {
|
||||||
|
directives,
|
||||||
|
location: ConfigLocationKind::Cli,
|
||||||
|
scope: ConfigScope::Mixed,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads text for an aria2 input file from disk or stdin.
|
||||||
|
fn read_input_file_text(path: &str) -> Result<String, CliError> {
|
||||||
|
if path == "-" {
|
||||||
|
let text = {
|
||||||
|
let stdin_handle = io::stdin();
|
||||||
|
let mut stdin = stdin_handle.lock();
|
||||||
|
let mut text = String::new();
|
||||||
|
stdin.read_to_string(&mut text).map_err(|error| {
|
||||||
|
CliError::Io(format!("failed to read input-file from stdin: {error}"))
|
||||||
|
})?;
|
||||||
|
text
|
||||||
|
};
|
||||||
|
Ok(text)
|
||||||
|
} else {
|
||||||
|
fs::read_to_string(path)
|
||||||
|
.map_err(|error| CliError::Io(format!("failed to read input-file {path}: {error}")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a profile for one logical input-file entity.
|
||||||
|
fn build_input_entry_profile(
|
||||||
|
name: &str,
|
||||||
|
directives: Vec<ConfigDirective>,
|
||||||
|
) -> Option<ConfigProfile> {
|
||||||
|
(!directives.is_empty()).then(|| ConfigProfile {
|
||||||
|
name: name.to_owned(),
|
||||||
|
source: ConfigSource::InputFile,
|
||||||
|
document: ConfigDocument {
|
||||||
|
directives,
|
||||||
|
location: ConfigLocationKind::Inline,
|
||||||
|
scope: ConfigScope::Mixed,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses aria2 input-file text into logical transfer entities.
|
||||||
|
fn parse_input_file_entries(name: &str, text: &str) -> Result<Vec<TransferInputEntry>, CliError> {
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
let mut current_uris: Option<Vec<String>> = None;
|
||||||
|
let mut current_directives = Vec::new();
|
||||||
|
|
||||||
|
let finalize_current =
|
||||||
|
|entries: &mut Vec<TransferInputEntry>,
|
||||||
|
current_uris: &mut Option<Vec<String>>,
|
||||||
|
current_directives: &mut Vec<ConfigDirective>| {
|
||||||
|
if let Some(uris) = current_uris.take()
|
||||||
|
&& !uris.is_empty()
|
||||||
|
{
|
||||||
|
let profile = build_input_entry_profile(name, std::mem::take(current_directives));
|
||||||
|
entries.push(TransferInputEntry {
|
||||||
|
uris,
|
||||||
|
implied_profile: None,
|
||||||
|
profile,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for raw_line in text.lines() {
|
||||||
|
let trimmed = raw_line.trim();
|
||||||
|
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw_line.chars().next().is_some_and(char::is_whitespace) {
|
||||||
|
let Some(_) = current_uris else {
|
||||||
|
return Err(CliError::Config(ConfigParseError::InvalidDirective(
|
||||||
|
raw_line.to_owned(),
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
if let Some(directive) = parse_config_line_strict(trimmed).map_err(CliError::Config)? {
|
||||||
|
current_directives.push(directive);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
finalize_current(&mut entries, &mut current_uris, &mut current_directives);
|
||||||
|
let uris = raw_line
|
||||||
|
.split('\t')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|uri| !uri.is_empty())
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if uris.is_empty() {
|
||||||
|
return Err(CliError::Config(ConfigParseError::InvalidDirective(
|
||||||
|
raw_line.to_owned(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
current_uris = Some(uris);
|
||||||
|
}
|
||||||
|
|
||||||
|
finalize_current(&mut entries, &mut current_uris, &mut current_directives);
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expands CLI positional URIs plus any configured input-file into logical transfer entries.
|
||||||
|
pub(crate) fn expand_transfer_entries(
|
||||||
|
uris: &[String],
|
||||||
|
profile: Option<&ConfigProfile>,
|
||||||
|
cli_sources: Option<&[CliTransferSource]>,
|
||||||
|
) -> Result<Vec<TransferInputEntry>, CliError> {
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
|
||||||
|
if let Some(cli_sources) = cli_sources
|
||||||
|
&& !cli_sources.is_empty()
|
||||||
|
{
|
||||||
|
for source in cli_sources {
|
||||||
|
match source {
|
||||||
|
CliTransferSource::Uri(uri) => entries.push(TransferInputEntry {
|
||||||
|
uris: vec![uri.clone()],
|
||||||
|
implied_profile: None,
|
||||||
|
profile: None,
|
||||||
|
}),
|
||||||
|
CliTransferSource::InputFile(path) => {
|
||||||
|
let text = read_input_file_text(path)?;
|
||||||
|
entries.extend(parse_input_file_entries(path, &text)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
entries.extend(uris.iter().map(|uri| TransferInputEntry {
|
||||||
|
uris: vec![uri.clone()],
|
||||||
|
implied_profile: None,
|
||||||
|
profile: None,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(path) = profile
|
||||||
|
.and_then(|profile| profile_option_value(profile, "input-file"))
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
&& !cli_sources.unwrap_or(&[]).iter().any(
|
||||||
|
|source| matches!(source, CliTransferSource::InputFile(existing) if existing == &path),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
let text = read_input_file_text(&path)?;
|
||||||
|
entries.extend(parse_input_file_entries(&path, &text)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts compat directives into an RPC option object.
|
||||||
|
pub(crate) fn rpc_option_object(profile: Option<&ConfigProfile>) -> Option<RpcValue> {
|
||||||
|
let profile = profile?;
|
||||||
|
let mut options = std::collections::BTreeMap::new();
|
||||||
|
for directive in &profile.document.directives {
|
||||||
|
let Some(value) = directive.value.as_ref() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
options.insert(directive.name.clone(), RpcValue::String(value.clone()));
|
||||||
|
}
|
||||||
|
if options.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(RpcValue::Object(options))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds an inline compat profile from a small directive set.
|
||||||
|
pub(crate) fn config_profile_from_directives(
|
||||||
|
name: &str,
|
||||||
|
source: ConfigSource,
|
||||||
|
directives: Vec<ConfigDirective>,
|
||||||
|
) -> Option<ConfigProfile> {
|
||||||
|
(!directives.is_empty()).then(|| ConfigProfile {
|
||||||
|
name: name.to_owned(),
|
||||||
|
source,
|
||||||
|
document: ConfigDocument {
|
||||||
|
directives,
|
||||||
|
location: ConfigLocationKind::Inline,
|
||||||
|
scope: ConfigScope::PerDownload,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses aria2 checksum option text into an enabled checksum hook.
|
||||||
|
pub(crate) fn parse_checksum_hook_text(value: &str) -> Option<ChecksumHookModel> {
|
||||||
|
let (algorithm, expected_hex) = value.split_once('=')?;
|
||||||
|
let algorithm = algorithm.trim().to_ascii_lowercase();
|
||||||
|
let expected_hex = expected_hex.trim().to_ascii_lowercase();
|
||||||
|
if algorithm.is_empty() || expected_hex.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(ChecksumHookModel {
|
||||||
|
spec: ChecksumSpec {
|
||||||
|
algorithm,
|
||||||
|
expected_hex,
|
||||||
|
actual_hex: None,
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synthesizes per-download defaults implied by a Metalink file entry.
|
||||||
|
pub(crate) fn metalink_entry_implied_profile(
|
||||||
|
file_name: &str,
|
||||||
|
checksum: Option<&ChecksumSpec>,
|
||||||
|
) -> Option<ConfigProfile> {
|
||||||
|
let mut directives = Vec::new();
|
||||||
|
if !file_name.trim().is_empty() {
|
||||||
|
directives.push(ConfigDirective {
|
||||||
|
name: "out".to_owned(),
|
||||||
|
value: Some(file_name.trim().to_owned()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(checksum) = checksum {
|
||||||
|
directives.push(ConfigDirective {
|
||||||
|
name: "checksum".to_owned(),
|
||||||
|
value: Some(format!("{}={}", checksum.algorithm, checksum.expected_hex)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
config_profile_from_directives(
|
||||||
|
"metalink-implied",
|
||||||
|
ConfigSource::RuntimeOverride,
|
||||||
|
directives,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses an aria2-style CLI option into a compat directive.
|
||||||
|
///
|
||||||
|
/// Returns the parsed directive plus whether the next argv item was consumed.
|
||||||
|
fn parse_cli_override_argument(
|
||||||
|
arg: &str,
|
||||||
|
next: Option<&str>,
|
||||||
|
) -> Result<(ConfigDirective, bool), CliError> {
|
||||||
|
let option_name = arg.trim_start_matches('-');
|
||||||
|
if option_name.is_empty() {
|
||||||
|
return Err(CliError::UnknownOption(arg.to_owned()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let parse_directive = |text: &str| -> Result<ConfigDirective, CliError> {
|
||||||
|
parse_config_line_strict(text)
|
||||||
|
.map_err(CliError::Config)?
|
||||||
|
.ok_or_else(|| CliError::UnknownOption(arg.to_owned()))
|
||||||
|
};
|
||||||
|
|
||||||
|
if arg.contains('=') {
|
||||||
|
return Ok((parse_directive(arg)?, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
let spec = option_spec(option_name).ok_or_else(|| CliError::UnknownOption(arg.to_owned()))?;
|
||||||
|
|
||||||
|
if spec.kind == OptionKind::Bool {
|
||||||
|
if let Some(value) = next.filter(|candidate| looks_like_bool_value(candidate)) {
|
||||||
|
Ok((parse_directive(&format!("{arg}={value}"))?, true))
|
||||||
|
} else {
|
||||||
|
Ok((parse_directive(&format!("{arg}=true"))?, false))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let value = next.ok_or_else(|| CliError::MissingValue(arg.to_owned()))?;
|
||||||
|
Ok((parse_directive(&format!("{arg}={value}"))?, true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consumes the next CLI token as a required value-bearing argument.
|
||||||
|
fn next_cli_value<I>(args: &mut std::iter::Peekable<I>, option: &str) -> Result<String, CliError>
|
||||||
|
where
|
||||||
|
I: Iterator<Item = String>,
|
||||||
|
{
|
||||||
|
args.next()
|
||||||
|
.ok_or_else(|| CliError::MissingValue(option.to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses aria2-compatible command-line arguments into a richer startup model.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error when an option is unknown or when an option requiring a
|
||||||
|
/// value is missing that value.
|
||||||
|
#[expect(
|
||||||
|
clippy::too_many_lines,
|
||||||
|
reason = "CLI flag parsing stays linear to preserve aria2-compatible option precedence"
|
||||||
|
)]
|
||||||
|
pub fn parse_cli(args: impl IntoIterator<Item = OsString>) -> Result<ParsedArguments, CliError> {
|
||||||
|
let mut args = args
|
||||||
|
.into_iter()
|
||||||
|
.map(|arg| arg.into_string().map_err(|_| CliError::InvalidUtf8Argument))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?
|
||||||
|
.into_iter();
|
||||||
|
let _program = args.next();
|
||||||
|
let mut args = args.peekable();
|
||||||
|
let mut config_path = None;
|
||||||
|
let mut uris = Vec::new();
|
||||||
|
let mut profile = StartupProfile::default();
|
||||||
|
let mut cli_directives = Vec::new();
|
||||||
|
let mut cli_transfer_sources = Vec::new();
|
||||||
|
|
||||||
|
while let Some(arg) = args.next() {
|
||||||
|
match arg.as_str() {
|
||||||
|
"--version" | "-v" => {
|
||||||
|
return Ok(ParsedArguments {
|
||||||
|
invocation: Invocation::Version,
|
||||||
|
profile,
|
||||||
|
cli_profile: None,
|
||||||
|
cli_transfer_sources: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"--help" | "-h" | "--help=#all" => {
|
||||||
|
return Ok(ParsedArguments {
|
||||||
|
invocation: Invocation::Help { query: None },
|
||||||
|
profile,
|
||||||
|
cli_profile: None,
|
||||||
|
cli_transfer_sources: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ if arg.starts_with("--help=") || arg.starts_with("-h=") => {
|
||||||
|
let query = arg
|
||||||
|
.strip_prefix("--help=")
|
||||||
|
.or_else(|| arg.strip_prefix("-h="))
|
||||||
|
.map(str::to_owned);
|
||||||
|
return Ok(ParsedArguments {
|
||||||
|
invocation: Invocation::Help { query },
|
||||||
|
profile,
|
||||||
|
cli_profile: None,
|
||||||
|
cli_transfer_sources: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"--conf-path" => {
|
||||||
|
config_path = Some(PathBuf::from(next_cli_value(&mut args, &arg)?));
|
||||||
|
}
|
||||||
|
"--enable-rpc" => {
|
||||||
|
let (directive, consumed_next) =
|
||||||
|
parse_cli_override_argument(&arg, args.peek().map(String::as_str))?;
|
||||||
|
if directive
|
||||||
|
.value
|
||||||
|
.as_deref()
|
||||||
|
.and_then(parse_bool_text)
|
||||||
|
.unwrap_or(true)
|
||||||
|
{
|
||||||
|
profile.rpc.enabled = true;
|
||||||
|
}
|
||||||
|
cli_directives.push(directive);
|
||||||
|
if consumed_next {
|
||||||
|
let _ = args.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--rpc-listen-all" => {
|
||||||
|
let (directive, consumed_next) =
|
||||||
|
parse_cli_override_argument(&arg, args.peek().map(String::as_str))?;
|
||||||
|
if directive
|
||||||
|
.value
|
||||||
|
.as_deref()
|
||||||
|
.and_then(parse_bool_text)
|
||||||
|
.unwrap_or(true)
|
||||||
|
{
|
||||||
|
"0.0.0.0".clone_into(&mut profile.rpc.listen_host);
|
||||||
|
}
|
||||||
|
cli_directives.push(directive);
|
||||||
|
if consumed_next {
|
||||||
|
let _ = args.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--daemon" | "-D" => {
|
||||||
|
profile.mode = RuntimeMode::Daemon;
|
||||||
|
profile.daemonize = true;
|
||||||
|
}
|
||||||
|
"--rpc-only" => profile.mode = RuntimeMode::RpcOnly,
|
||||||
|
"--dry-run" => profile.dry_run = true,
|
||||||
|
_ if arg.starts_with("--conf-path=") => {
|
||||||
|
config_path = Some(PathBuf::from(arg.trim_start_matches("--conf-path=")));
|
||||||
|
}
|
||||||
|
_ if arg.starts_with("--rpc-listen-port=") => {
|
||||||
|
let value = arg.trim_start_matches("--rpc-listen-port=");
|
||||||
|
profile.rpc.listen_port = value
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| CliError::UnknownOption(arg.clone()))?;
|
||||||
|
cli_directives.push(parse_cli_override_argument(&arg, None)?.0);
|
||||||
|
}
|
||||||
|
"--rpc-listen-port" => {
|
||||||
|
let value = next_cli_value(&mut args, &arg)?;
|
||||||
|
profile.rpc.listen_port = value
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| CliError::UnknownOption(arg.clone()))?;
|
||||||
|
cli_directives.push(parse_cli_override_argument(&arg, Some(value.as_str()))?.0);
|
||||||
|
}
|
||||||
|
_ if arg.starts_with("--rpc-secret=") => {
|
||||||
|
profile.rpc.secret = Some(arg.trim_start_matches("--rpc-secret=").to_owned());
|
||||||
|
}
|
||||||
|
"--rpc-secret" => {
|
||||||
|
let value = next_cli_value(&mut args, &arg)?;
|
||||||
|
profile.rpc.secret = Some(value.clone());
|
||||||
|
}
|
||||||
|
_ if arg.starts_with("--rpc-path=") => {
|
||||||
|
arg.trim_start_matches("--rpc-path=")
|
||||||
|
.clone_into(&mut profile.rpc.path);
|
||||||
|
}
|
||||||
|
"--rpc-path" => {
|
||||||
|
next_cli_value(&mut args, &arg)?.clone_into(&mut profile.rpc.path);
|
||||||
|
}
|
||||||
|
"--input-file" | "-i" => {
|
||||||
|
let value = next_cli_value(&mut args, &arg)?;
|
||||||
|
cli_directives.push(parse_cli_override_argument(&arg, Some(value.as_str()))?.0);
|
||||||
|
cli_transfer_sources.push(CliTransferSource::InputFile(value));
|
||||||
|
}
|
||||||
|
_ if arg.starts_with("--input-file=") || arg.starts_with("-i=") => {
|
||||||
|
let value = arg
|
||||||
|
.split_once('=')
|
||||||
|
.map(|(_, value)| value.to_owned())
|
||||||
|
.ok_or_else(|| CliError::MissingValue(arg.clone()))?;
|
||||||
|
cli_directives.push(parse_cli_override_argument(&arg, None)?.0);
|
||||||
|
cli_transfer_sources.push(CliTransferSource::InputFile(value));
|
||||||
|
}
|
||||||
|
_ if arg.starts_with('-') => {
|
||||||
|
let (directive, consumed_next) =
|
||||||
|
parse_cli_override_argument(&arg, args.peek().map(String::as_str))?;
|
||||||
|
cli_directives.push(directive);
|
||||||
|
if consumed_next {
|
||||||
|
let _ = args.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
uris.push(arg.clone());
|
||||||
|
cli_transfer_sources.push(CliTransferSource::Uri(arg.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ParsedArguments {
|
||||||
|
invocation: Invocation::Run { config_path, uris },
|
||||||
|
profile,
|
||||||
|
cli_profile: build_cli_profile(cli_directives),
|
||||||
|
cli_transfer_sources,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
/// Renders the CLI help surface, optionally filtered by a help query.
|
||||||
|
pub fn render_help(query: Option<&str>) -> String {
|
||||||
|
query.map_or_else(help_text, |query| help_text_for_query(Some(query)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
/// Renders the compatibility-oriented help surface.
|
||||||
|
pub fn render_compatibility_help() -> String {
|
||||||
|
compatibility_help_text()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
/// Renders the CLI version banner.
|
||||||
|
pub fn render_version() -> String {
|
||||||
|
cli_version_text()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
/// Captures a small compatibility snapshot for smoke tests and docs.
|
||||||
|
pub fn compatibility_snapshot() -> CompatibilitySnapshot {
|
||||||
|
CompatibilitySnapshot {
|
||||||
|
version_banner: render_version(),
|
||||||
|
help_sections: help_sections().len(),
|
||||||
|
tracked_protocol_count: compat_ledger().entries.len(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#![doc(hidden)]
|
||||||
|
#![expect(
|
||||||
|
clippy::redundant_pub_crate,
|
||||||
|
reason = "this private HTTP runtime helper facade keeps parent-only transfer helpers available without forcing public-facing docs onto every internal step"
|
||||||
|
)]
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
borrow::Cow,
|
||||||
|
collections::VecDeque,
|
||||||
|
fs,
|
||||||
|
io::{self, Write as _},
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
|
use aria2_rust_pro_compat::ConfigProfile;
|
||||||
|
use aria2_rust_pro_core::{RequestGroup, RuntimeConfig};
|
||||||
|
use aria2_rust_pro_protocol::http::HttpResponseSinkTarget;
|
||||||
|
use aria2_rust_pro_protocol::{
|
||||||
|
Downloader, HeaderKind, HttpBody, HttpCompletionState, HttpHeader, HttpMethod,
|
||||||
|
HttpRequestHeaders, HttpRequestModel, HttpResponseHeaders, HttpResponseModel, HttpSessionModel,
|
||||||
|
HttpTransferTaskModel, HttpVersion, RangeSpec, RangeUnit, ResponseBody, RetryPolicy,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
CliError, lossless_u64_from_usize, parse_checksum_hook_text, profile_option_value,
|
||||||
|
saturating_u16_from_usize, saturating_u32_from_usize, saturating_usize_from_u64,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(crate) use self::{build::*, execution::*, persist::*, planning::*};
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
mod build;
|
||||||
|
#[doc(hidden)]
|
||||||
|
mod execution;
|
||||||
|
#[doc(hidden)]
|
||||||
|
mod persist;
|
||||||
|
#[doc(hidden)]
|
||||||
|
mod planning;
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
#![doc(hidden)]
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
ConfigProfile, HeaderKind, HttpBody, HttpHeader, HttpMethod, HttpRequestHeaders,
|
||||||
|
HttpRequestModel, HttpResponseHeaders, HttpResponseSinkTarget, HttpSessionModel,
|
||||||
|
HttpTransferTaskModel, HttpVersion, PathBuf, RangeSpec, RangeUnit, RequestGroup, ResponseBody,
|
||||||
|
RuntimeConfig, parse_checksum_hook_text, profile_option_value, saturating_u16_from_usize,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(crate) fn build_http_transfer_task(
|
||||||
|
task_id: String,
|
||||||
|
uri: String,
|
||||||
|
session: &HttpSessionModel,
|
||||||
|
runtime: &RuntimeConfig,
|
||||||
|
profile: Option<&ConfigProfile>,
|
||||||
|
) -> HttpTransferTaskModel {
|
||||||
|
build_http_transfer_task_with_target(task_id, uri, session, runtime, profile, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_http_transfer_task_with_target(
|
||||||
|
task_id: String,
|
||||||
|
uri: String,
|
||||||
|
session: &HttpSessionModel,
|
||||||
|
runtime: &RuntimeConfig,
|
||||||
|
profile: Option<&ConfigProfile>,
|
||||||
|
target_path: Option<PathBuf>,
|
||||||
|
) -> HttpTransferTaskModel {
|
||||||
|
let mut headers = session.default_headers.clone();
|
||||||
|
if let Some(user_agent) = &session.user_agent {
|
||||||
|
headers.push(HttpHeader {
|
||||||
|
name: "user-agent".to_owned(),
|
||||||
|
value: user_agent.clone(),
|
||||||
|
kind: HeaderKind::Request,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let connection_budget =
|
||||||
|
saturating_u16_from_usize(runtime.max_connections_per_server.min(runtime.split.max(1)));
|
||||||
|
let checksum_hook = profile
|
||||||
|
.and_then(|profile| profile_option_value(profile, "checksum"))
|
||||||
|
.and_then(parse_checksum_hook_text);
|
||||||
|
let response_sink = target_path
|
||||||
|
.filter(|_| checksum_hook.is_none())
|
||||||
|
.map(|target_path| HttpResponseSinkTarget { target_path });
|
||||||
|
let request = HttpRequestModel {
|
||||||
|
method: HttpMethod::Get,
|
||||||
|
url: uri,
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpRequestHeaders { headers },
|
||||||
|
query: std::collections::HashMap::new(),
|
||||||
|
range: None,
|
||||||
|
body: HttpBody::Empty,
|
||||||
|
retry: session.retry,
|
||||||
|
auth: session.auth.clone(),
|
||||||
|
proxy: session.proxy.clone(),
|
||||||
|
response_sink,
|
||||||
|
};
|
||||||
|
HttpTransferTaskModel {
|
||||||
|
task_id,
|
||||||
|
request,
|
||||||
|
response_headers: HttpResponseHeaders {
|
||||||
|
headers: Vec::new(),
|
||||||
|
},
|
||||||
|
body: ResponseBody::Empty,
|
||||||
|
resume_state: None,
|
||||||
|
retry_attempts: Vec::new(),
|
||||||
|
checksum_hook,
|
||||||
|
max_connections: connection_budget.max(1),
|
||||||
|
retry: session.retry,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_segment_transfer_tasks(
|
||||||
|
base_task: &HttpTransferTaskModel,
|
||||||
|
group: &RequestGroup,
|
||||||
|
) -> Vec<HttpTransferTaskModel> {
|
||||||
|
if group.segment_assignments().is_empty() {
|
||||||
|
return vec![base_task.clone()];
|
||||||
|
}
|
||||||
|
|
||||||
|
group
|
||||||
|
.segment_assignments()
|
||||||
|
.iter()
|
||||||
|
.map(|assignment| {
|
||||||
|
let mut task = base_task.clone();
|
||||||
|
task.request.range = Some(RangeSpec {
|
||||||
|
start: assignment.range.start,
|
||||||
|
end_inclusive: Some(assignment.range.end.saturating_sub(1)),
|
||||||
|
unit: RangeUnit::Bytes,
|
||||||
|
});
|
||||||
|
task.resume_state = Some(aria2_rust_pro_protocol::ResumeState {
|
||||||
|
requested_offset: assignment.range.start,
|
||||||
|
accepted_offset: None,
|
||||||
|
resumed: assignment.range.start > 0,
|
||||||
|
});
|
||||||
|
task
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
#![doc(hidden)]
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
Arc, Cow, Downloader, HttpResponseModel, HttpTransferTaskModel, Mutex, RangeSpec, RangeUnit,
|
||||||
|
RetryPolicy, RuntimeConfig, VecDeque, planned_segment_span, saturating_u32_from_usize,
|
||||||
|
saturating_usize_from_u64,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fn should_retry_response(response: &HttpResponseModel, policy: &RetryPolicy) -> bool {
|
||||||
|
match response.status {
|
||||||
|
300..=399 => policy.retry_on_3xx,
|
||||||
|
400..=499 => policy.retry_on_4xx,
|
||||||
|
500..=599 => policy.retry_on_5xx,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn response_total_length(response: &HttpResponseModel) -> Option<u64> {
|
||||||
|
response.total_length()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn response_completed_length(response: &HttpResponseModel) -> u64 {
|
||||||
|
response.completed_length()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct HttpTransferExecution {
|
||||||
|
pub(crate) response: Option<HttpResponseModel>,
|
||||||
|
pub(crate) retry_count: u32,
|
||||||
|
pub(crate) retry_attempts: Vec<aria2_rust_pro_protocol::RetryAttempt>,
|
||||||
|
pub(crate) planned_ranges: Vec<Option<RangeSpec>>,
|
||||||
|
pub(crate) checksum_observed: bool,
|
||||||
|
pub(crate) checksum_complete: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::single_match_else,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
reason = "retry, resume, checksum, and terminal-status branches are kept together to preserve transfer semantics"
|
||||||
|
)]
|
||||||
|
pub(crate) fn execute_http_transfer_with_retry<D: Downloader>(
|
||||||
|
downloader: &D,
|
||||||
|
base_task: &HttpTransferTaskModel,
|
||||||
|
runtime: &RuntimeConfig,
|
||||||
|
) -> HttpTransferExecution {
|
||||||
|
let max_attempts = base_task.retry.policy.max_attempts.max(1);
|
||||||
|
let requested_start = base_task
|
||||||
|
.request
|
||||||
|
.range
|
||||||
|
.as_ref()
|
||||||
|
.map_or(0, |range| range.start);
|
||||||
|
let requested_end_exclusive = base_task
|
||||||
|
.request
|
||||||
|
.range
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|range| range.end_inclusive.map(|end| end.saturating_add(1)));
|
||||||
|
let mut completed_length = requested_start;
|
||||||
|
let mut retry_attempts = Vec::new();
|
||||||
|
let mut current_total_length = 0_u64;
|
||||||
|
let mut planned_ranges = Vec::new();
|
||||||
|
let mut checksum_observed = false;
|
||||||
|
let mut checksum_complete = false;
|
||||||
|
|
||||||
|
for attempt in 0..max_attempts {
|
||||||
|
let next_attempt = attempt.saturating_add(1);
|
||||||
|
let task = if completed_length > requested_start || !retry_attempts.is_empty() {
|
||||||
|
let mut owned_task = base_task.clone();
|
||||||
|
if completed_length > requested_start {
|
||||||
|
let end_inclusive = requested_end_exclusive
|
||||||
|
.map(|end| end.saturating_sub(1))
|
||||||
|
.or_else(|| {
|
||||||
|
planned_segment_span(current_total_length, runtime).and_then(|span| {
|
||||||
|
let next_end = completed_length.saturating_add(span).saturating_sub(1);
|
||||||
|
(current_total_length > 0)
|
||||||
|
.then_some(next_end.min(current_total_length.saturating_sub(1)))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
owned_task.request.range = Some(RangeSpec {
|
||||||
|
start: completed_length,
|
||||||
|
end_inclusive,
|
||||||
|
unit: RangeUnit::Bytes,
|
||||||
|
});
|
||||||
|
owned_task.resume_state = Some(aria2_rust_pro_protocol::ResumeState {
|
||||||
|
requested_offset: completed_length,
|
||||||
|
accepted_offset: None,
|
||||||
|
resumed: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !retry_attempts.is_empty() {
|
||||||
|
owned_task.retry_attempts.clone_from(&retry_attempts);
|
||||||
|
}
|
||||||
|
Cow::Owned(owned_task)
|
||||||
|
} else {
|
||||||
|
Cow::Borrowed(base_task)
|
||||||
|
};
|
||||||
|
planned_ranges.push(task.request.range);
|
||||||
|
|
||||||
|
match downloader.start_http_transfer(task.as_ref()) {
|
||||||
|
Ok(response) => {
|
||||||
|
let success = (200..=299).contains(&response.status);
|
||||||
|
let total_length = response_total_length(&response).unwrap_or(0);
|
||||||
|
if response.checksum.is_some() {
|
||||||
|
checksum_observed = true;
|
||||||
|
}
|
||||||
|
if total_length > 0 {
|
||||||
|
current_total_length = total_length;
|
||||||
|
}
|
||||||
|
let completed_after = response_completed_length(&response);
|
||||||
|
if success {
|
||||||
|
completed_length = completed_length.max(completed_after);
|
||||||
|
if response.checksum.is_some()
|
||||||
|
&& total_length > 0
|
||||||
|
&& completed_length >= total_length
|
||||||
|
&& response.completion_model().checksum_verified
|
||||||
|
{
|
||||||
|
checksum_complete = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let terminal_success = if let Some(segment_end) = requested_end_exclusive {
|
||||||
|
success && completed_length >= segment_end
|
||||||
|
} else {
|
||||||
|
success
|
||||||
|
&& (!response.partial_content
|
||||||
|
|| (total_length > 0 && completed_length >= total_length))
|
||||||
|
};
|
||||||
|
if terminal_success || next_attempt >= max_attempts {
|
||||||
|
return HttpTransferExecution {
|
||||||
|
response: Some(response),
|
||||||
|
retry_count: saturating_u32_from_usize(retry_attempts.len()),
|
||||||
|
retry_attempts,
|
||||||
|
planned_ranges,
|
||||||
|
checksum_observed,
|
||||||
|
checksum_complete,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if success
|
||||||
|
&& response.partial_content
|
||||||
|
&& requested_end_exclusive
|
||||||
|
.is_some_and(|segment_end| completed_length < segment_end)
|
||||||
|
{
|
||||||
|
retry_attempts.push(aria2_rust_pro_protocol::RetryAttempt {
|
||||||
|
attempt: next_attempt,
|
||||||
|
reason: aria2_rust_pro_protocol::RetryReason::Other,
|
||||||
|
status: Some(response.status),
|
||||||
|
backoff_ms: Some(0),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !should_retry_response(&response, &task.retry.policy) {
|
||||||
|
return HttpTransferExecution {
|
||||||
|
response: Some(response),
|
||||||
|
retry_count: saturating_u32_from_usize(retry_attempts.len()),
|
||||||
|
retry_attempts,
|
||||||
|
planned_ranges,
|
||||||
|
checksum_observed,
|
||||||
|
checksum_complete,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let retry_reason = match response.status {
|
||||||
|
300..=399 => aria2_rust_pro_protocol::RetryReason::Http3xx,
|
||||||
|
400..=499 => aria2_rust_pro_protocol::RetryReason::Http4xx,
|
||||||
|
500..=599 => aria2_rust_pro_protocol::RetryReason::Http5xx,
|
||||||
|
_ => aria2_rust_pro_protocol::RetryReason::Other,
|
||||||
|
};
|
||||||
|
retry_attempts.push(aria2_rust_pro_protocol::RetryAttempt {
|
||||||
|
attempt: next_attempt,
|
||||||
|
reason: retry_reason,
|
||||||
|
status: Some(response.status),
|
||||||
|
backoff_ms: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
if next_attempt >= max_attempts || !task.retry.policy.retry_on_network_error {
|
||||||
|
return HttpTransferExecution {
|
||||||
|
response: None,
|
||||||
|
retry_count: saturating_u32_from_usize(retry_attempts.len()),
|
||||||
|
retry_attempts,
|
||||||
|
planned_ranges,
|
||||||
|
checksum_observed,
|
||||||
|
checksum_complete,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
retry_attempts.push(aria2_rust_pro_protocol::RetryAttempt {
|
||||||
|
attempt: next_attempt,
|
||||||
|
reason: aria2_rust_pro_protocol::RetryReason::NetworkError,
|
||||||
|
status: None,
|
||||||
|
backoff_ms: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpTransferExecution {
|
||||||
|
response: None,
|
||||||
|
retry_count: saturating_u32_from_usize(retry_attempts.len()),
|
||||||
|
retry_attempts,
|
||||||
|
planned_ranges,
|
||||||
|
checksum_observed,
|
||||||
|
checksum_complete,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn execute_tagged_segment_transfers<D: Downloader + Sync, T: Send>(
|
||||||
|
downloader: &D,
|
||||||
|
planned_tasks: Vec<(T, HttpTransferTaskModel)>,
|
||||||
|
runtime: &RuntimeConfig,
|
||||||
|
) -> Vec<(T, HttpTransferTaskModel, HttpTransferExecution)> {
|
||||||
|
let parallelism = effective_segment_transfer_parallelism(runtime, planned_tasks.len());
|
||||||
|
execute_tagged_segment_transfers_with_parallelism(
|
||||||
|
downloader,
|
||||||
|
planned_tasks,
|
||||||
|
runtime,
|
||||||
|
parallelism,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn execute_tagged_segment_transfers_with_parallelism<D: Downloader + Sync, T: Send>(
|
||||||
|
downloader: &D,
|
||||||
|
planned_tasks: Vec<(T, HttpTransferTaskModel)>,
|
||||||
|
runtime: &RuntimeConfig,
|
||||||
|
parallelism: usize,
|
||||||
|
) -> Vec<(T, HttpTransferTaskModel, HttpTransferExecution)> {
|
||||||
|
if planned_tasks.len() <= 1 || parallelism <= 1 {
|
||||||
|
return planned_tasks
|
||||||
|
.into_iter()
|
||||||
|
.map(|(tag, task)| {
|
||||||
|
let execution = execute_http_transfer_with_retry(downloader, &task, runtime);
|
||||||
|
(tag, task, execution)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
if planned_tasks.len() <= parallelism.saturating_mul(4) {
|
||||||
|
return execute_tagged_segment_transfers_static_partitioned(
|
||||||
|
downloader,
|
||||||
|
planned_tasks,
|
||||||
|
runtime,
|
||||||
|
parallelism,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let task_count = planned_tasks.len();
|
||||||
|
let chunk_size = task_count
|
||||||
|
.div_ceil(parallelism.saturating_mul(4).max(1))
|
||||||
|
.max(1);
|
||||||
|
let mut chunk_queue = VecDeque::new();
|
||||||
|
let mut current_chunk = Vec::with_capacity(chunk_size);
|
||||||
|
for item in planned_tasks.into_iter().enumerate() {
|
||||||
|
current_chunk.push(item);
|
||||||
|
if current_chunk.len() >= chunk_size {
|
||||||
|
chunk_queue.push_back(std::mem::take(&mut current_chunk));
|
||||||
|
current_chunk = Vec::with_capacity(chunk_size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !current_chunk.is_empty() {
|
||||||
|
chunk_queue.push_back(current_chunk);
|
||||||
|
}
|
||||||
|
let work_chunks = Arc::new(Mutex::new(chunk_queue));
|
||||||
|
|
||||||
|
let mut indexed_results = std::thread::scope(|scope| {
|
||||||
|
let mut handles = Vec::with_capacity(parallelism);
|
||||||
|
for _ in 0..parallelism {
|
||||||
|
let work_chunks = Arc::clone(&work_chunks);
|
||||||
|
handles.push(scope.spawn(move || {
|
||||||
|
let mut local_results = Vec::new();
|
||||||
|
loop {
|
||||||
|
let Some(chunk) = work_chunks
|
||||||
|
.lock()
|
||||||
|
.expect("segment work chunk mutex should not be poisoned")
|
||||||
|
.pop_front()
|
||||||
|
else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
local_results.reserve(chunk.len());
|
||||||
|
for (index, (tag, task)) in chunk {
|
||||||
|
let execution =
|
||||||
|
execute_http_transfer_with_retry(downloader, &task, runtime);
|
||||||
|
local_results.push((index, (tag, task, execution)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
local_results
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
handles
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|handle| {
|
||||||
|
handle
|
||||||
|
.join()
|
||||||
|
.expect("dynamic segment transfer worker should not panic")
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
});
|
||||||
|
debug_assert_eq!(indexed_results.len(), task_count);
|
||||||
|
indexed_results.sort_by_key(|(index, _)| *index);
|
||||||
|
indexed_results
|
||||||
|
.into_iter()
|
||||||
|
.map(|(_, result)| result)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_tagged_segment_transfers_static_partitioned<D: Downloader + Sync, T: Send>(
|
||||||
|
downloader: &D,
|
||||||
|
planned_tasks: Vec<(T, HttpTransferTaskModel)>,
|
||||||
|
runtime: &RuntimeConfig,
|
||||||
|
parallelism: usize,
|
||||||
|
) -> Vec<(T, HttpTransferTaskModel, HttpTransferExecution)> {
|
||||||
|
let task_count = planned_tasks.len();
|
||||||
|
let indexed_chunks = partition_indexed_work_evenly(
|
||||||
|
planned_tasks.into_iter().enumerate(),
|
||||||
|
task_count,
|
||||||
|
parallelism,
|
||||||
|
);
|
||||||
|
|
||||||
|
std::thread::scope(|scope| {
|
||||||
|
let mut handles = Vec::with_capacity(indexed_chunks.len());
|
||||||
|
for chunk in indexed_chunks {
|
||||||
|
handles.push(scope.spawn(move || {
|
||||||
|
chunk
|
||||||
|
.into_iter()
|
||||||
|
.map(|(index, (tag, task))| {
|
||||||
|
let execution =
|
||||||
|
execute_http_transfer_with_retry(downloader, &task, runtime);
|
||||||
|
(index, (tag, task, execution))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ordered_results = std::iter::repeat_with(|| None)
|
||||||
|
.take(task_count)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
for handle in handles {
|
||||||
|
for (index, result) in handle
|
||||||
|
.join()
|
||||||
|
.expect("static segment transfer worker should not panic")
|
||||||
|
{
|
||||||
|
*ordered_results
|
||||||
|
.get_mut(index)
|
||||||
|
.expect("static segment transfer worker should return an in-bounds index") =
|
||||||
|
Some(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ordered_results
|
||||||
|
.into_iter()
|
||||||
|
.map(|result| result.expect("static segment transfer worker should fill every slot"))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn execute_segment_transfers<D: Downloader + Sync>(
|
||||||
|
downloader: &D,
|
||||||
|
planned_tasks: Vec<HttpTransferTaskModel>,
|
||||||
|
runtime: &RuntimeConfig,
|
||||||
|
) -> Vec<(HttpTransferTaskModel, HttpTransferExecution)> {
|
||||||
|
execute_tagged_segment_transfers(
|
||||||
|
downloader,
|
||||||
|
planned_tasks.into_iter().map(|task| ((), task)).collect(),
|
||||||
|
runtime,
|
||||||
|
)
|
||||||
|
.into_iter()
|
||||||
|
.map(|((), task, execution)| (task, execution))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn partition_indexed_work_evenly<T>(
|
||||||
|
indexed_work: impl IntoIterator<Item = (usize, T)>,
|
||||||
|
task_count: usize,
|
||||||
|
parallelism: usize,
|
||||||
|
) -> Vec<Vec<(usize, T)>> {
|
||||||
|
if task_count == 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let worker_count = parallelism.max(1).min(task_count);
|
||||||
|
let mut chunks = std::iter::repeat_with(Vec::new)
|
||||||
|
.take(worker_count)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
for (ordinal, item) in indexed_work.into_iter().enumerate() {
|
||||||
|
let chunk_index = ordinal
|
||||||
|
.checked_rem(worker_count)
|
||||||
|
.expect("worker count is nonzero when partitioning indexed work");
|
||||||
|
chunks
|
||||||
|
.get_mut(chunk_index)
|
||||||
|
.expect("round-robin chunk index should be in bounds")
|
||||||
|
.push(item);
|
||||||
|
}
|
||||||
|
chunks.retain(|chunk| !chunk.is_empty());
|
||||||
|
chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn effective_segment_transfer_parallelism(
|
||||||
|
runtime: &RuntimeConfig,
|
||||||
|
planned_tasks: usize,
|
||||||
|
) -> usize {
|
||||||
|
if planned_tasks <= 1 {
|
||||||
|
return planned_tasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
let segment_unit = runtime.min_split_size.max(runtime.piece_length).max(1);
|
||||||
|
let mut parallelism = planned_tasks;
|
||||||
|
if let Some(limit) = runtime
|
||||||
|
.max_download_limit
|
||||||
|
.map(|bytes_per_second| saturating_usize_from_u64(bytes_per_second.div_ceil(segment_unit)))
|
||||||
|
{
|
||||||
|
parallelism = parallelism.min(limit.max(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
parallelism.max(1).min(planned_tasks)
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
#![doc(hidden)]
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn derive_http_target_path(profile: Option<&ConfigProfile>, uri: &str) -> PathBuf {
|
||||||
|
let dir = profile
|
||||||
|
.and_then(|profile| profile_option_value(profile, "dir"))
|
||||||
|
.map(PathBuf::from);
|
||||||
|
let file_name = profile
|
||||||
|
.and_then(|profile| profile_option_value(profile, "out"))
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.or_else(|| {
|
||||||
|
uri.rsplit('/')
|
||||||
|
.next()
|
||||||
|
.map(|segment| segment.split(['?', '#']).next().unwrap_or(segment))
|
||||||
|
.filter(|segment| !segment.is_empty())
|
||||||
|
.map(str::to_owned)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "download.bin".to_owned());
|
||||||
|
|
||||||
|
match dir {
|
||||||
|
Some(dir) => dir.join(file_name),
|
||||||
|
None => PathBuf::from(file_name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prepare_http_target_path(
|
||||||
|
profile: Option<&ConfigProfile>,
|
||||||
|
uri: &str,
|
||||||
|
) -> Result<PathBuf, CliError> {
|
||||||
|
let target_path = derive_http_target_path(profile, uri);
|
||||||
|
if let Some(parent) = target_path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|error| {
|
||||||
|
CliError::Io(format!("failed to create download directory: {error}"))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(target_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_temp_file_contents_to_target(
|
||||||
|
temp_path: &Path,
|
||||||
|
target_path: &Path,
|
||||||
|
offset: u64,
|
||||||
|
truncate: bool,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
let mut source = fs::File::open(temp_path)?;
|
||||||
|
let mut target = fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.truncate(truncate)
|
||||||
|
.write(true)
|
||||||
|
.open(target_path)?;
|
||||||
|
io::Seek::seek(&mut target, io::SeekFrom::Start(offset))?;
|
||||||
|
io::copy(&mut source, &mut target)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_temp_file_to_target(temp_path: &Path, target_path: &Path, offset: u64) -> io::Result<()> {
|
||||||
|
if offset == 0 {
|
||||||
|
if fs::rename(temp_path, target_path).is_ok() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
return copy_temp_file_contents_to_target(temp_path, target_path, 0, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
copy_temp_file_contents_to_target(temp_path, target_path, offset, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_bytes_to_target(target_path: &Path, offset: u64, payload: &[u8]) -> io::Result<()> {
|
||||||
|
let mut target = fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.truncate(offset == 0)
|
||||||
|
.write(true)
|
||||||
|
.open(target_path)?;
|
||||||
|
io::Seek::seek(&mut target, io::SeekFrom::Start(offset))?;
|
||||||
|
target.write_all(payload)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn persist_http_response_body_to_target(
|
||||||
|
target_path: &Path,
|
||||||
|
task: &HttpTransferTaskModel,
|
||||||
|
response: &HttpResponseModel,
|
||||||
|
) -> Result<(), CliError> {
|
||||||
|
let offset = response
|
||||||
|
.content_range
|
||||||
|
.as_ref()
|
||||||
|
.map(|range| range.start)
|
||||||
|
.or_else(|| task.request.range.as_ref().map(|range| range.start))
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
match &response.body {
|
||||||
|
ResponseBody::Empty => {
|
||||||
|
if offset == 0 && !response.partial_content {
|
||||||
|
let _ = fs::File::create(target_path).map_err(|error| {
|
||||||
|
CliError::Io(format!(
|
||||||
|
"failed to create download target {}: {error}",
|
||||||
|
target_path.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ResponseBody::Inline(bytes) => {
|
||||||
|
write_bytes_to_target(target_path, offset, bytes).map_err(|error| {
|
||||||
|
CliError::Io(format!(
|
||||||
|
"failed to persist inline response body to {}: {error}",
|
||||||
|
target_path.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
ResponseBody::Streamed { temp_path, .. } => {
|
||||||
|
if let Some(temp_path) = temp_path {
|
||||||
|
let write_result = write_temp_file_to_target(temp_path, target_path, offset)
|
||||||
|
.map_err(|error| {
|
||||||
|
CliError::Io(format!(
|
||||||
|
"failed to persist streamed response body to {}: {error}",
|
||||||
|
target_path.display()
|
||||||
|
))
|
||||||
|
});
|
||||||
|
let _ = fs::remove_file(temp_path);
|
||||||
|
write_result?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persisted_http_target_satisfies_completion(
|
||||||
|
profile: Option<&ConfigProfile>,
|
||||||
|
uri: &str,
|
||||||
|
task: &HttpTransferTaskModel,
|
||||||
|
response: &HttpResponseModel,
|
||||||
|
) -> bool {
|
||||||
|
let Some(total_length) = response.total_length() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let target_path = derive_http_target_path(profile, uri);
|
||||||
|
let Ok(metadata) = fs::metadata(&target_path) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if metadata.len() < total_length {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(checksum_hook) = task.checksum_hook.as_ref().filter(|hook| hook.enabled) else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
fs::read(target_path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|bytes| checksum_hook.spec.verify_payload(&bytes))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn http_execution_completed_via_checksum(
|
||||||
|
execution: &HttpTransferExecution,
|
||||||
|
response: &HttpResponseModel,
|
||||||
|
profile: Option<&ConfigProfile>,
|
||||||
|
uri: &str,
|
||||||
|
task: &HttpTransferTaskModel,
|
||||||
|
) -> bool {
|
||||||
|
if !execution.checksum_observed {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let completion = response.completion_model();
|
||||||
|
let response_checksum_complete = matches!(
|
||||||
|
completion.state,
|
||||||
|
HttpCompletionState::Complete | HttpCompletionState::Verified
|
||||||
|
) && execution.checksum_complete;
|
||||||
|
response_checksum_complete
|
||||||
|
|| persisted_http_target_satisfies_completion(profile, uri, task, response)
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
#![doc(hidden)]
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
Downloader, HttpTransferExecution, HttpTransferTaskModel, RangeSpec, RangeUnit, RuntimeConfig,
|
||||||
|
execute_http_transfer_with_retry, lossless_u64_from_usize,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SMALL_SEGMENT_PROBE_ALIGNMENT_LIMIT: u64 = 64 * 1_024;
|
||||||
|
|
||||||
|
pub(crate) struct InitialHttpExecutionPlan {
|
||||||
|
pub(crate) task: HttpTransferTaskModel,
|
||||||
|
pub(crate) execution: HttpTransferExecution,
|
||||||
|
pub(crate) planned_segments: Vec<HttpTransferTaskModel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn planned_segment_span(total_length: u64, runtime: &RuntimeConfig) -> Option<u64> {
|
||||||
|
if runtime.split <= 1 || total_length == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let split_budget = lossless_u64_from_usize(runtime.split.max(1));
|
||||||
|
let planned = total_length.div_ceil(split_budget);
|
||||||
|
Some(
|
||||||
|
planned
|
||||||
|
.max(runtime.min_split_size)
|
||||||
|
.max(runtime.piece_length)
|
||||||
|
.min(total_length),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_initial_http_execution_plan<D: Downloader>(
|
||||||
|
downloader: &D,
|
||||||
|
base_task: &HttpTransferTaskModel,
|
||||||
|
runtime: &RuntimeConfig,
|
||||||
|
) -> InitialHttpExecutionPlan {
|
||||||
|
if !should_attempt_initial_segment_probe(base_task) {
|
||||||
|
return InitialHttpExecutionPlan {
|
||||||
|
task: base_task.clone(),
|
||||||
|
execution: execute_http_transfer_with_retry(downloader, base_task, runtime),
|
||||||
|
planned_segments: Vec::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let probe_task = build_initial_segment_probe_task(base_task, runtime);
|
||||||
|
let probe_execution = execute_http_transfer_with_retry(downloader, &probe_task, runtime);
|
||||||
|
let planned_segments = probe_execution
|
||||||
|
.response
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|response| {
|
||||||
|
response
|
||||||
|
.partial_content
|
||||||
|
.then_some((response.total_length(), response.completed_length()))
|
||||||
|
})
|
||||||
|
.and_then(|(total_length, completed_length)| {
|
||||||
|
total_length.map(|total_length| {
|
||||||
|
build_balanced_segment_transfer_tasks(
|
||||||
|
base_task,
|
||||||
|
runtime,
|
||||||
|
completed_length.min(total_length),
|
||||||
|
total_length,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
InitialHttpExecutionPlan {
|
||||||
|
task: probe_task,
|
||||||
|
execution: probe_execution,
|
||||||
|
planned_segments,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn should_attempt_initial_segment_probe(task: &HttpTransferTaskModel) -> bool {
|
||||||
|
task.request.range.is_none() && task.max_connections > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_initial_segment_probe_task(
|
||||||
|
base_task: &HttpTransferTaskModel,
|
||||||
|
runtime: &RuntimeConfig,
|
||||||
|
) -> HttpTransferTaskModel {
|
||||||
|
let mut probe_task = base_task.clone();
|
||||||
|
let alignment = runtime.min_split_size.max(runtime.piece_length).max(1);
|
||||||
|
let probe_span =
|
||||||
|
if base_task.max_connections >= 4 && alignment <= SMALL_SEGMENT_PROBE_ALIGNMENT_LIMIT {
|
||||||
|
alignment
|
||||||
|
.saturating_mul(u64::from(base_task.max_connections))
|
||||||
|
.saturating_mul(2)
|
||||||
|
} else if base_task.max_connections >= 4 {
|
||||||
|
alignment.saturating_mul(2)
|
||||||
|
} else {
|
||||||
|
alignment
|
||||||
|
};
|
||||||
|
probe_task.request.range = Some(RangeSpec {
|
||||||
|
start: 0,
|
||||||
|
end_inclusive: Some(probe_span.saturating_sub(1)),
|
||||||
|
unit: RangeUnit::Bytes,
|
||||||
|
});
|
||||||
|
probe_task.resume_state = Some(aria2_rust_pro_protocol::ResumeState {
|
||||||
|
requested_offset: 0,
|
||||||
|
accepted_offset: None,
|
||||||
|
resumed: false,
|
||||||
|
});
|
||||||
|
probe_task
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_balanced_segment_transfer_tasks(
|
||||||
|
base_task: &HttpTransferTaskModel,
|
||||||
|
runtime: &RuntimeConfig,
|
||||||
|
start_offset: u64,
|
||||||
|
total_length: u64,
|
||||||
|
) -> Vec<HttpTransferTaskModel> {
|
||||||
|
if total_length <= start_offset {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let desired_segments = usize::from(base_task.max_connections.max(1));
|
||||||
|
let alignment = runtime.min_split_size.max(runtime.piece_length).max(1);
|
||||||
|
let remaining = total_length.saturating_sub(start_offset);
|
||||||
|
let mut segment_count = desired_segments
|
||||||
|
.min(
|
||||||
|
usize::try_from(remaining.div_ceil(runtime.min_split_size.max(1)))
|
||||||
|
.unwrap_or(usize::MAX),
|
||||||
|
)
|
||||||
|
.max(1);
|
||||||
|
if desired_segments >= 4 && segment_count > 1 && remaining <= alignment.saturating_mul(2) {
|
||||||
|
segment_count = 1;
|
||||||
|
} else if segment_count > 2 && remaining <= alignment.saturating_mul(3) {
|
||||||
|
segment_count = 2;
|
||||||
|
}
|
||||||
|
let mut cursor = start_offset;
|
||||||
|
let mut planned_tasks = Vec::with_capacity(segment_count);
|
||||||
|
|
||||||
|
for segment_index in 0..segment_count {
|
||||||
|
if cursor >= total_length {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let remaining_segments = segment_count.saturating_sub(segment_index);
|
||||||
|
let remaining_bytes = total_length.saturating_sub(cursor);
|
||||||
|
let span = if remaining_segments <= 1 {
|
||||||
|
remaining_bytes
|
||||||
|
} else {
|
||||||
|
let target = remaining_bytes.div_ceil(lossless_u64_from_usize(remaining_segments));
|
||||||
|
let aligned_target = target.div_ceil(alignment).saturating_mul(alignment);
|
||||||
|
let min_tail = lossless_u64_from_usize(remaining_segments.saturating_sub(1));
|
||||||
|
aligned_target
|
||||||
|
.min(remaining_bytes.saturating_sub(min_tail).max(1))
|
||||||
|
.max(1)
|
||||||
|
};
|
||||||
|
let end_exclusive = cursor.saturating_add(span).min(total_length);
|
||||||
|
let mut task = base_task.clone();
|
||||||
|
task.request.range = Some(RangeSpec {
|
||||||
|
start: cursor,
|
||||||
|
end_inclusive: Some(end_exclusive.saturating_sub(1)),
|
||||||
|
unit: RangeUnit::Bytes,
|
||||||
|
});
|
||||||
|
task.resume_state = Some(aria2_rust_pro_protocol::ResumeState {
|
||||||
|
requested_offset: cursor,
|
||||||
|
accepted_offset: None,
|
||||||
|
resumed: cursor > 0,
|
||||||
|
});
|
||||||
|
planned_tasks.push(task);
|
||||||
|
cursor = end_exclusive;
|
||||||
|
}
|
||||||
|
|
||||||
|
planned_tasks
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
//! CLI entrypoints and runtime glue for the `aria2-rust-pro` binary.
|
||||||
|
//!
|
||||||
|
//! This crate keeps the user-facing command surface, config projection, and
|
||||||
|
//! downloader-backed execution bridge in one place so the integration suite can
|
||||||
|
//! verify the observable behavior without reaching into lower layers directly.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![expect(
|
||||||
|
clippy::multiple_crate_versions,
|
||||||
|
reason = "workspace dependency resolution is shared across crates and not owned by cli alone"
|
||||||
|
)]
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
env,
|
||||||
|
ffi::OsString,
|
||||||
|
fs,
|
||||||
|
io::{self, Read as _},
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
use aria2_rust_pro_compat::{
|
||||||
|
ConfigDirective, ConfigDocument, ConfigLocationKind, ConfigParseError, ConfigProfile,
|
||||||
|
ConfigScope, ConfigSource, OptionKind, cli_version_text, compat::compat_ledger,
|
||||||
|
compatibility_help_text, help::help_sections, help_text, help_text_for_query, option_spec,
|
||||||
|
parse_config_line_strict,
|
||||||
|
};
|
||||||
|
use aria2_rust_pro_core::RuntimeConfig;
|
||||||
|
use aria2_rust_pro_protocol::{
|
||||||
|
ChecksumHookModel, ChecksumSpec, Downloader, HeaderKind, HttpHeader, HttpSessionModel,
|
||||||
|
Protocol, ReqwestHttpConnector, downloader::ConnectorBackedDownloader,
|
||||||
|
};
|
||||||
|
use aria2_rust_pro_rpc::{InProcessRpcDispatcher, RpcValue};
|
||||||
|
/// CLI argument parsing and compatibility rendering helpers.
|
||||||
|
mod args;
|
||||||
|
/// HTTP transfer task planning, retry, and persistence helpers.
|
||||||
|
mod http_runtime;
|
||||||
|
/// Parallel HTTP bootstrap and segment follow-up execution helpers.
|
||||||
|
mod parallel_http_runtime;
|
||||||
|
/// CLI/config projection and typed option conversion helpers.
|
||||||
|
mod projection;
|
||||||
|
/// RPC-daemon launch assembly and listener bootstrap helpers.
|
||||||
|
mod rpc_daemon;
|
||||||
|
/// Registered transfer execution and runtime dispatch helpers.
|
||||||
|
mod runtime_execution;
|
||||||
|
/// Foreground runtime orchestration façade for parsed invocations.
|
||||||
|
mod runtime_host;
|
||||||
|
/// Runtime input expansion, resolution, and dispatcher registration helpers.
|
||||||
|
mod runtime_planning;
|
||||||
|
/// Terminal runtime summary collection helpers.
|
||||||
|
mod runtime_summary;
|
||||||
|
/// Transfer registration, Metalink expansion, and bootstrap payload helpers.
|
||||||
|
mod transfer_resolution;
|
||||||
|
/// Per-transfer execution, BT runtime driving, and live completion helpers.
|
||||||
|
mod transfer_runtime;
|
||||||
|
/// CLI-facing domain types used across the private split modules.
|
||||||
|
mod types;
|
||||||
|
|
||||||
|
pub use self::{
|
||||||
|
args::{
|
||||||
|
compatibility_snapshot, parse_args, parse_cli, render_compatibility_help, render_help,
|
||||||
|
render_version,
|
||||||
|
},
|
||||||
|
projection::{
|
||||||
|
classify_transfer, command_surface, derive_http_session, derive_runtime_config,
|
||||||
|
load_config_report, parse_protocol, profile_option_map,
|
||||||
|
},
|
||||||
|
types::{
|
||||||
|
BtStatusReport, CliError, CommandSurface, CompatibilitySnapshot, ConfigLoadReport,
|
||||||
|
Invocation, ParsedArguments, RpcLaunchConfig, RuntimeMode, RuntimeReport, StartupProfile,
|
||||||
|
TransferSelection,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use self::{
|
||||||
|
args::{
|
||||||
|
expand_transfer_entries, merged_profile, metalink_entry_implied_profile,
|
||||||
|
parse_checksum_hook_text, rpc_option_object,
|
||||||
|
},
|
||||||
|
http_runtime::{
|
||||||
|
build_http_transfer_task, build_http_transfer_task_with_target,
|
||||||
|
build_initial_http_execution_plan, build_segment_transfer_tasks,
|
||||||
|
execute_http_transfer_with_retry, execute_segment_transfers,
|
||||||
|
execute_tagged_segment_transfers, http_execution_completed_via_checksum,
|
||||||
|
persist_http_response_body_to_target, prepare_http_target_path,
|
||||||
|
},
|
||||||
|
projection::{
|
||||||
|
apply_proxy_auth_overrides, derive_rpc_listen_host, derive_rpc_secret,
|
||||||
|
lossless_u64_from_usize, parse_bool_text, parse_bt_status_report, parse_csv_text,
|
||||||
|
parse_proxy_text, profile_option_value, rpc_bool, rpc_u64, saturating_u16_from_usize,
|
||||||
|
saturating_u32_from_usize, saturating_usize_from_u64,
|
||||||
|
},
|
||||||
|
transfer_resolution::{
|
||||||
|
DispatcherRegistrationKind, build_ftp_transfer_parts, build_sftp_transfer_parts,
|
||||||
|
register_resolved_entry_with_dispatcher, resolve_transfer_entry_with_downloader,
|
||||||
|
},
|
||||||
|
transfer_runtime::{
|
||||||
|
dispatcher_status_for_gid, dispatcher_status_summary_for_gid, execute_bt_runtime_for_gid,
|
||||||
|
execute_transfer_for_uri, http_response_is_terminal_success, rpc_status_text,
|
||||||
|
},
|
||||||
|
types::{CliTransferSource, TransferInputEntry},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
use self::http_runtime::planned_segment_span;
|
||||||
|
|
||||||
|
/// Executes a parsed invocation and returns a structured runtime report.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns config, I/O, or in-process RPC errors encountered while wiring the
|
||||||
|
/// runtime execution surface.
|
||||||
|
pub fn execute_runtime(invocation: Invocation) -> Result<RuntimeReport, CliError> {
|
||||||
|
let connector = ReqwestHttpConnector::new().map_err(|error| {
|
||||||
|
CliError::Io(format!("failed to initialize reqwest connector: {error}"))
|
||||||
|
})?;
|
||||||
|
let downloader = ConnectorBackedDownloader::new(connector.clone(), connector);
|
||||||
|
execute_runtime_with_context(
|
||||||
|
invocation,
|
||||||
|
&StartupProfile::default(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes a runtime invocation with explicit startup and CLI override context.
|
||||||
|
fn execute_runtime_with_context<D: Downloader + Sync>(
|
||||||
|
invocation: Invocation,
|
||||||
|
startup: &StartupProfile,
|
||||||
|
cli_profile: Option<&ConfigProfile>,
|
||||||
|
cli_transfer_sources: Option<&[CliTransferSource]>,
|
||||||
|
downloader: &D,
|
||||||
|
) -> Result<RuntimeReport, CliError> {
|
||||||
|
runtime_host::execute_runtime_with_downloader_impl(
|
||||||
|
invocation,
|
||||||
|
startup,
|
||||||
|
cli_profile,
|
||||||
|
cli_transfer_sources,
|
||||||
|
downloader,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes a parsed invocation against the supplied downloader.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns config, I/O, protocol, or in-process RPC errors encountered while
|
||||||
|
/// wiring the runtime execution surface.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if a scoped HTTP worker thread panics while the runtime is executing
|
||||||
|
/// parallel transfer work.
|
||||||
|
pub fn execute_runtime_with_downloader<D: Downloader + Sync>(
|
||||||
|
invocation: Invocation,
|
||||||
|
downloader: &D,
|
||||||
|
) -> Result<RuntimeReport, CliError> {
|
||||||
|
execute_runtime_with_context(
|
||||||
|
invocation,
|
||||||
|
&StartupProfile::default(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
downloader,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared runtime execution entrypoint used by public and parsed-command flows.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns config, I/O, protocol, or in-process RPC errors encountered while
|
||||||
|
/// executing the supplied invocation with explicit startup and CLI override
|
||||||
|
/// context.
|
||||||
|
/// Executes a parsed invocation.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error when a referenced config file cannot be read or when the
|
||||||
|
/// config parser rejects the file contents.
|
||||||
|
pub fn execute(invocation: Invocation) -> Result<(), CliError> {
|
||||||
|
match invocation.clone() {
|
||||||
|
Invocation::Version => {
|
||||||
|
println!("{}", render_version());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Invocation::Help { query } => {
|
||||||
|
println!("{}", render_help(query.as_deref()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Invocation::Run { .. } => {
|
||||||
|
let report = execute_runtime(invocation)?;
|
||||||
|
println!(
|
||||||
|
"accepted {} uri(s); tracked {}; control-file v{}",
|
||||||
|
report.accepted_uri_count,
|
||||||
|
report.tracked_download_count,
|
||||||
|
report.control_file_version_major
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the RPC daemon surface implied by a parsed CLI invocation.
|
||||||
|
/// Parses process arguments and executes the resulting invocation.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns any argument parsing, config reading, or config parsing error
|
||||||
|
/// produced by [`parse_cli`] or [`execute`].
|
||||||
|
pub fn run_from_env() -> Result<(), CliError> {
|
||||||
|
let parsed = parse_cli(env::args_os())?;
|
||||||
|
match command_surface(&parsed) {
|
||||||
|
CommandSurface::PrintVersion => execute(Invocation::Version),
|
||||||
|
CommandSurface::PrintHelp { query } => execute(Invocation::Help { query }),
|
||||||
|
CommandSurface::ValidateConfig {
|
||||||
|
config_path,
|
||||||
|
strict,
|
||||||
|
} => {
|
||||||
|
let _ = load_config_report(&config_path, strict)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
CommandSurface::Foreground(invocation) => {
|
||||||
|
let connector = ReqwestHttpConnector::new().map_err(|error| {
|
||||||
|
CliError::Io(format!("failed to initialize reqwest connector: {error}"))
|
||||||
|
})?;
|
||||||
|
let downloader = ConnectorBackedDownloader::new(connector.clone(), connector);
|
||||||
|
let report = execute_runtime_with_context(
|
||||||
|
invocation,
|
||||||
|
&parsed.profile,
|
||||||
|
parsed.cli_profile.as_ref(),
|
||||||
|
Some(&parsed.cli_transfer_sources),
|
||||||
|
&downloader,
|
||||||
|
)?;
|
||||||
|
println!(
|
||||||
|
"accepted {} uri(s); tracked {}; control-file v{}",
|
||||||
|
report.accepted_uri_count,
|
||||||
|
report.tracked_download_count,
|
||||||
|
report.control_file_version_major
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
CommandSurface::RpcDaemon { .. } => rpc_daemon::run_rpc_daemon(parsed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
/// Regression coverage for CLI parsing, config merging, runtime wiring, and live transport smokes.
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
//! Binary entrypoint for the `aria2-rust-pro` CLI.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![doc = "Binary entrypoint for the aria2-rust-pro CLI."]
|
||||||
|
|
||||||
|
use aria2_rust_pro_cli::run_from_env;
|
||||||
|
use aria2_rust_pro_compat as _;
|
||||||
|
use aria2_rust_pro_core as _;
|
||||||
|
use aria2_rust_pro_protocol as _;
|
||||||
|
use aria2_rust_pro_rpc as _;
|
||||||
|
use aria2_rust_pro_storage as _;
|
||||||
|
#[cfg(test)]
|
||||||
|
use ssh2 as _;
|
||||||
|
|
||||||
|
/// Runs the CLI process and exits with a non-zero status on failure.
|
||||||
|
fn main() {
|
||||||
|
if let Err(error) = run_from_env() {
|
||||||
|
eprintln!("{error}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,627 @@
|
|||||||
|
#![doc(hidden)]
|
||||||
|
#![expect(
|
||||||
|
clippy::redundant_pub_crate,
|
||||||
|
clippy::indexing_slicing,
|
||||||
|
reason = "this private parallel HTTP runtime module keeps batch execution and follow-up coordination together, and the index usage is constrained by precomputed entry partitions"
|
||||||
|
)]
|
||||||
|
|
||||||
|
use aria2_rust_pro_compat::ConfigProfile;
|
||||||
|
use aria2_rust_pro_core::RuntimeConfig;
|
||||||
|
use aria2_rust_pro_protocol::{Downloader, HttpTransferTaskModel};
|
||||||
|
use std::{
|
||||||
|
collections::VecDeque,
|
||||||
|
env,
|
||||||
|
path::PathBuf,
|
||||||
|
sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicUsize, Ordering},
|
||||||
|
},
|
||||||
|
time::Instant,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
CliError, InProcessRpcDispatcher, build_http_transfer_task_with_target,
|
||||||
|
build_initial_http_execution_plan, build_segment_transfer_tasks,
|
||||||
|
execute_tagged_segment_transfers, http_execution_completed_via_checksum,
|
||||||
|
http_response_is_terminal_success, persist_http_response_body_to_target,
|
||||||
|
prepare_http_target_path,
|
||||||
|
};
|
||||||
|
use crate::http_runtime::{
|
||||||
|
HttpTransferExecution, effective_segment_transfer_parallelism,
|
||||||
|
execute_tagged_segment_transfers_with_parallelism, partition_indexed_work_evenly,
|
||||||
|
};
|
||||||
|
use crate::runtime_planning::RegisteredRuntimeEntry;
|
||||||
|
|
||||||
|
struct PreparedParallelHttpEntry {
|
||||||
|
index: usize,
|
||||||
|
target_path: PathBuf,
|
||||||
|
task: HttpTransferTaskModel,
|
||||||
|
bootstrap_execution: HttpTransferExecution,
|
||||||
|
planned_segments: Vec<HttpTransferTaskModel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlannedSegmentBatchTag = usize;
|
||||||
|
|
||||||
|
fn execute_shared_runtime_segment_batches<D: Downloader + Sync>(
|
||||||
|
downloader: &D,
|
||||||
|
planned_segment_batches: Vec<(PlannedSegmentBatchTag, HttpTransferTaskModel)>,
|
||||||
|
runtime: &RuntimeConfig,
|
||||||
|
active_download_count: usize,
|
||||||
|
) -> Vec<(
|
||||||
|
PlannedSegmentBatchTag,
|
||||||
|
HttpTransferTaskModel,
|
||||||
|
HttpTransferExecution,
|
||||||
|
)> {
|
||||||
|
if planned_segment_batches.len() <= 1 {
|
||||||
|
return execute_tagged_segment_transfers(downloader, planned_segment_batches, runtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
let queue_slot_count = planned_segment_batches
|
||||||
|
.iter()
|
||||||
|
.map(|(tag, _)| *tag)
|
||||||
|
.max()
|
||||||
|
.map_or(0, |index| index.saturating_add(1));
|
||||||
|
let planned_segment_count = planned_segment_batches.len();
|
||||||
|
let mut per_download_queues = std::iter::repeat_with(VecDeque::new)
|
||||||
|
.take(queue_slot_count)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut active_queue_count = 0usize;
|
||||||
|
for (tag, task) in planned_segment_batches {
|
||||||
|
if let Some(queue) = per_download_queues.get_mut(tag) {
|
||||||
|
if queue.is_empty() {
|
||||||
|
active_queue_count = active_queue_count.saturating_add(1);
|
||||||
|
}
|
||||||
|
queue.push_back((tag, task));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut completed = Vec::with_capacity(planned_segment_count);
|
||||||
|
while active_queue_count > 0 {
|
||||||
|
let mut batch = Vec::with_capacity(active_queue_count);
|
||||||
|
|
||||||
|
for queue in per_download_queues
|
||||||
|
.iter_mut()
|
||||||
|
.filter(|queue| !queue.is_empty())
|
||||||
|
{
|
||||||
|
let per_download_parallelism =
|
||||||
|
effective_segment_transfer_parallelism(runtime, queue.len());
|
||||||
|
for _ in 0..per_download_parallelism {
|
||||||
|
if let Some(item) = queue.pop_front() {
|
||||||
|
batch.push(item);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if queue.is_empty() {
|
||||||
|
active_queue_count = active_queue_count.saturating_sub(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if batch.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parallelism = effective_shared_runtime_segment_parallelism(
|
||||||
|
runtime,
|
||||||
|
active_download_count,
|
||||||
|
batch.len(),
|
||||||
|
);
|
||||||
|
completed.extend(execute_tagged_segment_transfers_with_parallelism(
|
||||||
|
downloader,
|
||||||
|
batch,
|
||||||
|
runtime,
|
||||||
|
parallelism,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
completed
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_parallel_http_entry<D: Downloader + Sync>(
|
||||||
|
downloader: &D,
|
||||||
|
entry: &RegisteredRuntimeEntry,
|
||||||
|
base_profile: Option<&ConfigProfile>,
|
||||||
|
index: usize,
|
||||||
|
) -> Result<PreparedParallelHttpEntry, CliError> {
|
||||||
|
let target_path = prepare_http_target_path(
|
||||||
|
entry.resolved.entry_profile.as_ref().or(base_profile),
|
||||||
|
&entry.resolved.uri,
|
||||||
|
)?;
|
||||||
|
let task = build_http_transfer_task_with_target(
|
||||||
|
entry.gid.clone(),
|
||||||
|
entry.resolved.uri.clone(),
|
||||||
|
&entry.resolved.http_session,
|
||||||
|
&entry.resolved.runtime,
|
||||||
|
entry.resolved.entry_profile.as_ref().or(base_profile),
|
||||||
|
Some(target_path.clone()),
|
||||||
|
);
|
||||||
|
let plan = build_initial_http_execution_plan(downloader, &task, &entry.resolved.runtime);
|
||||||
|
|
||||||
|
Ok(PreparedParallelHttpEntry {
|
||||||
|
index,
|
||||||
|
target_path,
|
||||||
|
task: plan.task,
|
||||||
|
bootstrap_execution: plan.execution,
|
||||||
|
planned_segments: plan.planned_segments,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_parallel_http_entries_static_partitioned<D: Downloader + Sync>(
|
||||||
|
downloader: &D,
|
||||||
|
entries: &[RegisteredRuntimeEntry],
|
||||||
|
base_profile: Option<&ConfigProfile>,
|
||||||
|
parallel_http_indices: &[usize],
|
||||||
|
bootstrap_parallelism: usize,
|
||||||
|
) -> Result<Vec<PreparedParallelHttpEntry>, CliError> {
|
||||||
|
let task_count = parallel_http_indices.len();
|
||||||
|
let indexed_chunks = partition_indexed_work_evenly(
|
||||||
|
parallel_http_indices.iter().copied().enumerate(),
|
||||||
|
task_count,
|
||||||
|
bootstrap_parallelism,
|
||||||
|
);
|
||||||
|
|
||||||
|
std::thread::scope(|scope| {
|
||||||
|
let mut handles = Vec::with_capacity(indexed_chunks.len());
|
||||||
|
for chunk in indexed_chunks {
|
||||||
|
handles.push(scope.spawn(move || {
|
||||||
|
chunk
|
||||||
|
.into_iter()
|
||||||
|
.map(|(position, index)| {
|
||||||
|
(
|
||||||
|
position,
|
||||||
|
prepare_parallel_http_entry(
|
||||||
|
downloader,
|
||||||
|
&entries[index],
|
||||||
|
base_profile,
|
||||||
|
index,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ordered_results = std::iter::repeat_with(|| None)
|
||||||
|
.take(task_count)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
for handle in handles {
|
||||||
|
for (position, prepared) in handle
|
||||||
|
.join()
|
||||||
|
.expect("static parallel http prepare worker should not panic")
|
||||||
|
{
|
||||||
|
ordered_results[position] = Some(prepared);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ordered_results
|
||||||
|
.into_iter()
|
||||||
|
.map(|prepared| {
|
||||||
|
prepared.expect("static parallel http prepare worker should fill every slot")
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::too_many_arguments,
|
||||||
|
reason = "shared-runtime bookkeeping needs dispatcher state, timing, retry counters, and queued follow-up batches in one place"
|
||||||
|
)]
|
||||||
|
fn process_prepared_parallel_http_entry(
|
||||||
|
dispatcher: &mut InProcessRpcDispatcher,
|
||||||
|
entries: &[RegisteredRuntimeEntry],
|
||||||
|
base_profile: Option<&ConfigProfile>,
|
||||||
|
timing_probe: bool,
|
||||||
|
prepared: PreparedParallelHttpEntry,
|
||||||
|
planned_segment_batches: &mut Vec<(usize, HttpTransferTaskModel)>,
|
||||||
|
segment_target_paths: &mut [Option<PathBuf>],
|
||||||
|
cumulative_retry_counts: &mut [u32],
|
||||||
|
max_connections: &mut [u16],
|
||||||
|
) -> Result<(), CliError> {
|
||||||
|
let PreparedParallelHttpEntry {
|
||||||
|
index,
|
||||||
|
target_path,
|
||||||
|
task,
|
||||||
|
bootstrap_execution,
|
||||||
|
mut planned_segments,
|
||||||
|
} = prepared;
|
||||||
|
max_connections[index] = task.max_connections;
|
||||||
|
if let Some(ref response) = bootstrap_execution.response {
|
||||||
|
cumulative_retry_counts[index] =
|
||||||
|
cumulative_retry_counts[index].saturating_add(bootstrap_execution.retry_count);
|
||||||
|
let entry = &entries[index];
|
||||||
|
let persist_started = timing_probe.then(Instant::now);
|
||||||
|
persist_http_response_body_to_target(&target_path, &task, response)?;
|
||||||
|
let persist_elapsed_ms = persist_started
|
||||||
|
.as_ref()
|
||||||
|
.map(|started| started.elapsed().as_millis())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let completed_via_checksum = http_execution_completed_via_checksum(
|
||||||
|
&bootstrap_execution,
|
||||||
|
response,
|
||||||
|
entry.resolved.entry_profile.as_ref().or(base_profile),
|
||||||
|
&entry.resolved.uri,
|
||||||
|
&task,
|
||||||
|
);
|
||||||
|
let record_started = timing_probe.then(Instant::now);
|
||||||
|
dispatcher
|
||||||
|
.record_http_transfer_result(
|
||||||
|
&entry.gid,
|
||||||
|
response,
|
||||||
|
task.max_connections,
|
||||||
|
cumulative_retry_counts[index],
|
||||||
|
!bootstrap_execution.checksum_observed || completed_via_checksum,
|
||||||
|
)
|
||||||
|
.map_err(|error| CliError::Rpc(error.message))?;
|
||||||
|
let record_elapsed_ms = record_started
|
||||||
|
.as_ref()
|
||||||
|
.map(|started| started.elapsed().as_millis())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let _uri_marked_complete = http_response_is_terminal_success(response)
|
||||||
|
&& (!bootstrap_execution.checksum_observed || completed_via_checksum);
|
||||||
|
|
||||||
|
let needs_segment_followups = response.partial_content
|
||||||
|
&& response
|
||||||
|
.total_length()
|
||||||
|
.is_some_and(|total| response.completed_length() < total);
|
||||||
|
if needs_segment_followups {
|
||||||
|
if planned_segments.is_empty() {
|
||||||
|
let group = dispatcher
|
||||||
|
.prepare_http_download(&entry.gid)
|
||||||
|
.map_err(|error| CliError::Rpc(error.message))?;
|
||||||
|
planned_segments = build_segment_transfer_tasks(&task, &group);
|
||||||
|
}
|
||||||
|
if timing_probe {
|
||||||
|
eprintln!(
|
||||||
|
"parallel http timing uri={} bootstrap_persist_ms={} bootstrap_record_ms={} planned_segments={} status={} completed={} total_length={:?}",
|
||||||
|
entry.resolved.uri,
|
||||||
|
persist_elapsed_ms,
|
||||||
|
record_elapsed_ms,
|
||||||
|
planned_segments.len(),
|
||||||
|
response.status,
|
||||||
|
response.completed_length(),
|
||||||
|
response.total_length(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
planned_segment_batches.extend(
|
||||||
|
planned_segments
|
||||||
|
.into_iter()
|
||||||
|
.map(|planned_task| (index, planned_task)),
|
||||||
|
);
|
||||||
|
segment_target_paths[index] = Some(target_path);
|
||||||
|
} else if timing_probe {
|
||||||
|
eprintln!(
|
||||||
|
"parallel http timing uri={} bootstrap_persist_ms={} bootstrap_record_ms={} planned_segments=0 status={} completed={} total_length={:?}",
|
||||||
|
entry.resolved.uri,
|
||||||
|
persist_elapsed_ms,
|
||||||
|
record_elapsed_ms,
|
||||||
|
response.status,
|
||||||
|
response.completed_length(),
|
||||||
|
response.total_length(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::too_many_lines,
|
||||||
|
reason = "parallel HTTP execution intentionally keeps bootstrap, retry, and segment scheduling in one stateful control flow"
|
||||||
|
)]
|
||||||
|
pub(super) fn execute_parallel_http_entries<D: Downloader + Sync>(
|
||||||
|
dispatcher: &mut InProcessRpcDispatcher,
|
||||||
|
downloader: &D,
|
||||||
|
entries: &[RegisteredRuntimeEntry],
|
||||||
|
base_profile: Option<&ConfigProfile>,
|
||||||
|
derived_runtime: &RuntimeConfig,
|
||||||
|
parallel_http_indices: &[usize],
|
||||||
|
) -> Result<(), CliError> {
|
||||||
|
let timing_probe = env::var_os("ARIA2_RUST_PRO_HTTP_TIMING").is_some();
|
||||||
|
let bootstrap_parallelism =
|
||||||
|
effective_http_bootstrap_parallelism(derived_runtime, parallel_http_indices.len());
|
||||||
|
let mut planned_segment_batches = Vec::new();
|
||||||
|
let mut segment_target_paths = std::iter::repeat_with(|| None)
|
||||||
|
.take(entries.len())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut cumulative_retry_counts = vec![0_u32; entries.len()];
|
||||||
|
let mut max_connections = vec![0_u16; entries.len()];
|
||||||
|
if parallel_http_indices.len() <= 1 || bootstrap_parallelism <= 1 {
|
||||||
|
for index in parallel_http_indices {
|
||||||
|
let prepared =
|
||||||
|
prepare_parallel_http_entry(downloader, &entries[*index], base_profile, *index)?;
|
||||||
|
process_prepared_parallel_http_entry(
|
||||||
|
dispatcher,
|
||||||
|
entries,
|
||||||
|
base_profile,
|
||||||
|
timing_probe,
|
||||||
|
prepared,
|
||||||
|
&mut planned_segment_batches,
|
||||||
|
&mut segment_target_paths,
|
||||||
|
&mut cumulative_retry_counts,
|
||||||
|
&mut max_connections,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
} else if parallel_http_indices.len() <= bootstrap_parallelism.saturating_mul(4) {
|
||||||
|
for prepared in prepare_parallel_http_entries_static_partitioned(
|
||||||
|
downloader,
|
||||||
|
entries,
|
||||||
|
base_profile,
|
||||||
|
parallel_http_indices,
|
||||||
|
bootstrap_parallelism,
|
||||||
|
)? {
|
||||||
|
process_prepared_parallel_http_entry(
|
||||||
|
dispatcher,
|
||||||
|
entries,
|
||||||
|
base_profile,
|
||||||
|
timing_probe,
|
||||||
|
prepared,
|
||||||
|
&mut planned_segment_batches,
|
||||||
|
&mut segment_target_paths,
|
||||||
|
&mut cumulative_retry_counts,
|
||||||
|
&mut max_connections,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let work_indices = Arc::new(parallel_http_indices.to_vec());
|
||||||
|
let next_index = AtomicUsize::new(0);
|
||||||
|
|
||||||
|
let prepared_results = std::thread::scope(|scope| {
|
||||||
|
let mut handles = Vec::with_capacity(bootstrap_parallelism);
|
||||||
|
for _ in 0..bootstrap_parallelism {
|
||||||
|
let work_indices = Arc::clone(&work_indices);
|
||||||
|
let next_index = &next_index;
|
||||||
|
handles.push(scope.spawn(move || {
|
||||||
|
let mut local_results = Vec::new();
|
||||||
|
loop {
|
||||||
|
let position = next_index.fetch_add(1, Ordering::Relaxed);
|
||||||
|
if position >= work_indices.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = work_indices[position];
|
||||||
|
let prepared = prepare_parallel_http_entry(
|
||||||
|
downloader,
|
||||||
|
&entries[index],
|
||||||
|
base_profile,
|
||||||
|
index,
|
||||||
|
);
|
||||||
|
local_results.push((position, prepared));
|
||||||
|
}
|
||||||
|
local_results
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
handles
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|handle| {
|
||||||
|
handle
|
||||||
|
.join()
|
||||||
|
.expect("parallel http prepare worker should not panic")
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut prepared_results = prepared_results;
|
||||||
|
prepared_results.sort_by_key(|(position, _)| *position);
|
||||||
|
|
||||||
|
for (_, prepared) in prepared_results {
|
||||||
|
process_prepared_parallel_http_entry(
|
||||||
|
dispatcher,
|
||||||
|
entries,
|
||||||
|
base_profile,
|
||||||
|
timing_probe,
|
||||||
|
prepared?,
|
||||||
|
&mut planned_segment_batches,
|
||||||
|
&mut segment_target_paths,
|
||||||
|
&mut cumulative_retry_counts,
|
||||||
|
&mut max_connections,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut pending_segment_records = std::iter::repeat_with(|| None)
|
||||||
|
.take(entries.len())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
for (index, planned_task, execution) in execute_shared_runtime_segment_batches(
|
||||||
|
downloader,
|
||||||
|
planned_segment_batches,
|
||||||
|
derived_runtime,
|
||||||
|
parallel_http_indices.len(),
|
||||||
|
) {
|
||||||
|
if let Some(ref response) = execution.response {
|
||||||
|
cumulative_retry_counts[index] =
|
||||||
|
cumulative_retry_counts[index].saturating_add(execution.retry_count);
|
||||||
|
let target_path = segment_target_paths[index]
|
||||||
|
.as_ref()
|
||||||
|
.expect("segment follow-up target path should be recorded");
|
||||||
|
let persist_started = timing_probe.then(Instant::now);
|
||||||
|
persist_http_response_body_to_target(target_path, &planned_task, response)?;
|
||||||
|
let persist_elapsed_ms = persist_started
|
||||||
|
.as_ref()
|
||||||
|
.map(|started| started.elapsed().as_millis())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if timing_probe {
|
||||||
|
let entry = &entries[index];
|
||||||
|
eprintln!(
|
||||||
|
"parallel http segment timing uri={} persist_ms={} status={} completed={} total_length={:?}",
|
||||||
|
entry.resolved.uri,
|
||||||
|
persist_elapsed_ms,
|
||||||
|
response.status,
|
||||||
|
response.completed_length(),
|
||||||
|
response.total_length(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
pending_segment_records[index] = Some((planned_task, execution));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (index, pending_record) in pending_segment_records.into_iter().enumerate() {
|
||||||
|
let Some((planned_task, execution)) = pending_record else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(ref response) = execution.response else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let entry = &entries[index];
|
||||||
|
let completed_via_checksum = http_execution_completed_via_checksum(
|
||||||
|
&execution,
|
||||||
|
response,
|
||||||
|
entry.resolved.entry_profile.as_ref().or(base_profile),
|
||||||
|
&entry.resolved.uri,
|
||||||
|
&planned_task,
|
||||||
|
);
|
||||||
|
let record_started = timing_probe.then(Instant::now);
|
||||||
|
dispatcher
|
||||||
|
.record_http_transfer_result(
|
||||||
|
&entry.gid,
|
||||||
|
response,
|
||||||
|
max_connections[index],
|
||||||
|
cumulative_retry_counts[index],
|
||||||
|
!execution.checksum_observed || completed_via_checksum,
|
||||||
|
)
|
||||||
|
.map_err(|error| CliError::Rpc(error.message))?;
|
||||||
|
let record_elapsed_ms = record_started
|
||||||
|
.as_ref()
|
||||||
|
.map(|started| started.elapsed().as_millis())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if timing_probe {
|
||||||
|
eprintln!(
|
||||||
|
"parallel http segment record timing uri={} record_ms={} status={} completed={} total_length={:?}",
|
||||||
|
entry.resolved.uri,
|
||||||
|
record_elapsed_ms,
|
||||||
|
response.status,
|
||||||
|
response.completed_length(),
|
||||||
|
response.total_length(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn effective_http_bootstrap_parallelism(runtime: &RuntimeConfig, entry_count: usize) -> usize {
|
||||||
|
if entry_count <= 1 {
|
||||||
|
return entry_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry_count.min(runtime.max_downloads.max(1)).max(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn effective_shared_runtime_segment_parallelism(
|
||||||
|
runtime: &RuntimeConfig,
|
||||||
|
entry_count: usize,
|
||||||
|
batch_len: usize,
|
||||||
|
) -> usize {
|
||||||
|
if batch_len <= 1 {
|
||||||
|
return batch_len;
|
||||||
|
}
|
||||||
|
|
||||||
|
let active_downloads = entry_count.min(runtime.max_downloads.max(1)).max(1);
|
||||||
|
let connection_budget =
|
||||||
|
active_downloads.saturating_mul(runtime.effective_max_connections_per_server().max(1));
|
||||||
|
|
||||||
|
batch_len
|
||||||
|
.min(connection_budget.max(runtime.worker_threads.max(1)))
|
||||||
|
.max(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{
|
||||||
|
effective_shared_runtime_segment_parallelism, execute_shared_runtime_segment_batches,
|
||||||
|
};
|
||||||
|
use aria2_rust_pro_core::RuntimeConfig;
|
||||||
|
use aria2_rust_pro_protocol::{
|
||||||
|
FixtureHttpDownloader, HttpBody, HttpMethod, HttpRequestHeaders, HttpRequestModel,
|
||||||
|
HttpResponseHeaders, HttpTransferTaskModel, HttpVersion, ResponseBody, RetryPolicy,
|
||||||
|
RetryStrategy,
|
||||||
|
};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
fn retry_strategy() -> RetryStrategy {
|
||||||
|
RetryStrategy {
|
||||||
|
policy: RetryPolicy {
|
||||||
|
max_attempts: 1,
|
||||||
|
initial_backoff_ms: 0,
|
||||||
|
max_backoff_ms: 0,
|
||||||
|
retry_on_3xx: false,
|
||||||
|
retry_on_4xx: false,
|
||||||
|
retry_on_5xx: false,
|
||||||
|
retry_on_network_error: false,
|
||||||
|
retry_on_timeout: false,
|
||||||
|
},
|
||||||
|
jitter: None,
|
||||||
|
max_elapsed_ms: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn task(url: &str) -> HttpTransferTaskModel {
|
||||||
|
HttpTransferTaskModel {
|
||||||
|
task_id: url.to_owned(),
|
||||||
|
request: HttpRequestModel {
|
||||||
|
method: HttpMethod::Get,
|
||||||
|
url: url.to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpRequestHeaders {
|
||||||
|
headers: Vec::new(),
|
||||||
|
},
|
||||||
|
query: HashMap::new(),
|
||||||
|
range: None,
|
||||||
|
body: HttpBody::Empty,
|
||||||
|
retry: retry_strategy(),
|
||||||
|
auth: None,
|
||||||
|
proxy: None,
|
||||||
|
response_sink: None,
|
||||||
|
},
|
||||||
|
response_headers: HttpResponseHeaders {
|
||||||
|
headers: Vec::new(),
|
||||||
|
},
|
||||||
|
body: ResponseBody::Empty,
|
||||||
|
resume_state: None,
|
||||||
|
retry_attempts: Vec::new(),
|
||||||
|
checksum_hook: None,
|
||||||
|
max_connections: 1,
|
||||||
|
retry: retry_strategy(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shared_runtime_segment_batches_keep_sparse_entry_indices() {
|
||||||
|
let mut downloader = FixtureHttpDownloader::new();
|
||||||
|
downloader.register("http://127.0.0.1/sparse-two", b"two");
|
||||||
|
downloader.register("http://127.0.0.1/sparse-five", b"five");
|
||||||
|
|
||||||
|
let completed = execute_shared_runtime_segment_batches(
|
||||||
|
&downloader,
|
||||||
|
vec![
|
||||||
|
(2, task("http://127.0.0.1/sparse-two")),
|
||||||
|
(5, task("http://127.0.0.1/sparse-five")),
|
||||||
|
],
|
||||||
|
&RuntimeConfig::default(),
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(completed.len(), 2);
|
||||||
|
let mut indices = completed
|
||||||
|
.into_iter()
|
||||||
|
.map(|(index, _, _)| index)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
indices.sort_unstable();
|
||||||
|
assert_eq!(indices, vec![2, 5]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shared_runtime_segment_parallelism_uses_admitted_download_count() {
|
||||||
|
let runtime = RuntimeConfig {
|
||||||
|
worker_threads: 4,
|
||||||
|
max_active_downloads: 5,
|
||||||
|
max_downloads: 16,
|
||||||
|
max_connections_per_server: 4,
|
||||||
|
max_connection_per_server: 4,
|
||||||
|
..RuntimeConfig::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
effective_shared_runtime_segment_parallelism(&runtime, 6, 24),
|
||||||
|
24
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,708 @@
|
|||||||
|
#![doc(hidden)]
|
||||||
|
#![expect(
|
||||||
|
clippy::redundant_pub_crate,
|
||||||
|
reason = "this private projection module centralizes CLI/config coercion helpers and reuses the split CLI surface without repeating a long import list"
|
||||||
|
)]
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
BtStatusReport, CliError, CommandSurface, ConfigDocument, ConfigLoadReport, ConfigLocationKind,
|
||||||
|
ConfigProfile, ConfigScope, ConfigSource, HeaderKind, HttpHeader, HttpSessionModel, Invocation,
|
||||||
|
ParsedArguments, Path, PathBuf, Protocol, RpcValue, RuntimeConfig, RuntimeMode, StartupProfile,
|
||||||
|
TransferSelection, fs,
|
||||||
|
};
|
||||||
|
use aria2_rust_pro_compat::{parse_config, parse_config_lenient};
|
||||||
|
use aria2_rust_pro_protocol::{ProxyConfig, RetryPolicy, RetryStrategy, TlsConfig};
|
||||||
|
|
||||||
|
/// Extracts a string-like RPC field from a generic RPC value.
|
||||||
|
fn rpc_string(value: Option<&RpcValue>) -> Option<String> {
|
||||||
|
match value {
|
||||||
|
Some(RpcValue::String(value)) => Some(value.clone()),
|
||||||
|
Some(RpcValue::Number(value)) => Some(value.to_string()),
|
||||||
|
Some(RpcValue::Bool(value)) => Some(value.to_string()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts a boolean-like RPC field from a generic RPC value.
|
||||||
|
pub(crate) fn rpc_bool(value: Option<&RpcValue>) -> Option<bool> {
|
||||||
|
match value {
|
||||||
|
Some(RpcValue::Bool(value)) => Some(*value),
|
||||||
|
Some(RpcValue::String(value)) => parse_bool_text(value),
|
||||||
|
Some(RpcValue::Number(value)) => Some(*value != 0),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts an unsigned integer-like RPC field from a generic RPC value.
|
||||||
|
pub(crate) fn rpc_u64(value: Option<&RpcValue>) -> Option<u64> {
|
||||||
|
match value {
|
||||||
|
Some(RpcValue::Number(value)) => (*value).try_into().ok(),
|
||||||
|
Some(RpcValue::String(value)) => value.parse().ok(),
|
||||||
|
Some(RpcValue::Bool(value)) => Some(u64::from(*value)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the length of an RPC array field when the value is an array.
|
||||||
|
const fn rpc_array_len(value: Option<&RpcValue>) -> Option<usize> {
|
||||||
|
match value {
|
||||||
|
Some(RpcValue::Array(values)) => Some(values.len()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Projects `BitTorrent`-specific tellStatus fields into the CLI report model.
|
||||||
|
pub(crate) fn parse_bt_status_report(
|
||||||
|
status: &std::collections::BTreeMap<String, RpcValue>,
|
||||||
|
) -> Option<BtStatusReport> {
|
||||||
|
let is_bt = rpc_bool(status.get("isBt"));
|
||||||
|
let metadata_only = rpc_bool(status.get("metadataOnly"));
|
||||||
|
let magnet_uri = rpc_string(status.get("magnetUri"));
|
||||||
|
let announce_list_tier_count = rpc_array_len(status.get("announceList"));
|
||||||
|
let seeder = rpc_bool(status.get("seeder"));
|
||||||
|
let num_seeders = rpc_u64(status.get("numSeeders"));
|
||||||
|
let share_ratio = rpc_string(status.get("shareRatio"));
|
||||||
|
let share_ratio_progress = rpc_string(status.get("shareRatioProgress"));
|
||||||
|
let share_ratio_remaining = rpc_string(status.get("shareRatioRemaining"));
|
||||||
|
let share_time = rpc_u64(status.get("shareTime"));
|
||||||
|
|
||||||
|
if is_bt.is_none()
|
||||||
|
&& metadata_only.is_none()
|
||||||
|
&& magnet_uri.is_none()
|
||||||
|
&& announce_list_tier_count.is_none()
|
||||||
|
&& seeder.is_none()
|
||||||
|
&& num_seeders.is_none()
|
||||||
|
&& share_ratio.is_none()
|
||||||
|
&& share_ratio_progress.is_none()
|
||||||
|
&& share_ratio_remaining.is_none()
|
||||||
|
&& share_time.is_none()
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(BtStatusReport {
|
||||||
|
is_bt,
|
||||||
|
metadata_only,
|
||||||
|
magnet_uri,
|
||||||
|
announce_list_tier_count,
|
||||||
|
seeder,
|
||||||
|
num_seeders,
|
||||||
|
share_ratio,
|
||||||
|
share_ratio_progress,
|
||||||
|
share_ratio_remaining,
|
||||||
|
share_time,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether an input ends with an ASCII suffix, ignoring case.
|
||||||
|
fn has_ascii_case_insensitive_suffix(input: &str, suffix: &str) -> bool {
|
||||||
|
input
|
||||||
|
.get(input.len().saturating_sub(suffix.len())..)
|
||||||
|
.is_some_and(|tail| tail.eq_ignore_ascii_case(suffix))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
/// Classifies a transfer input by the user-visible download surface it implies.
|
||||||
|
pub fn classify_transfer(input: &str) -> TransferSelection {
|
||||||
|
if input
|
||||||
|
.get(.."magnet:?".len())
|
||||||
|
.is_some_and(|scheme| scheme.eq_ignore_ascii_case("magnet:?"))
|
||||||
|
{
|
||||||
|
TransferSelection::Magnet
|
||||||
|
} else if has_ascii_case_insensitive_suffix(input, ".torrent") {
|
||||||
|
TransferSelection::Torrent
|
||||||
|
} else if has_ascii_case_insensitive_suffix(input, ".meta4")
|
||||||
|
|| has_ascii_case_insensitive_suffix(input, ".metalink")
|
||||||
|
{
|
||||||
|
TransferSelection::Metalink
|
||||||
|
} else {
|
||||||
|
TransferSelection::Uri
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
/// Parses a supported transfer protocol from a URI-like input.
|
||||||
|
pub fn parse_protocol(input: &str) -> Option<Protocol> {
|
||||||
|
let (scheme, _) = input.split_once(':')?;
|
||||||
|
if scheme.eq_ignore_ascii_case("http") {
|
||||||
|
Some(Protocol::Http)
|
||||||
|
} else if scheme.eq_ignore_ascii_case("https") {
|
||||||
|
Some(Protocol::Https)
|
||||||
|
} else if scheme.eq_ignore_ascii_case("ftp") {
|
||||||
|
Some(Protocol::Ftp)
|
||||||
|
} else if scheme.eq_ignore_ascii_case("sftp") {
|
||||||
|
Some(Protocol::Sftp)
|
||||||
|
} else if scheme.eq_ignore_ascii_case("magnet") {
|
||||||
|
Some(Protocol::Magnet)
|
||||||
|
} else if scheme.eq_ignore_ascii_case("file") {
|
||||||
|
Some(Protocol::File)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
/// Chooses the execution surface implied by a parsed invocation.
|
||||||
|
pub fn command_surface(parsed: &ParsedArguments) -> CommandSurface {
|
||||||
|
match &parsed.invocation {
|
||||||
|
Invocation::Version => CommandSurface::PrintVersion,
|
||||||
|
Invocation::Help { query } => CommandSurface::PrintHelp {
|
||||||
|
query: query.clone(),
|
||||||
|
},
|
||||||
|
Invocation::Run { config_path, uris } => {
|
||||||
|
if parsed.profile.dry_run {
|
||||||
|
config_path.clone().map_or_else(
|
||||||
|
|| CommandSurface::Foreground(parsed.invocation.clone()),
|
||||||
|
|config_path| CommandSurface::ValidateConfig {
|
||||||
|
config_path,
|
||||||
|
strict: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else if parsed.profile.rpc.enabled || parsed.profile.mode != RuntimeMode::Foreground {
|
||||||
|
CommandSurface::RpcDaemon {
|
||||||
|
config_path: config_path.clone(),
|
||||||
|
inputs: uris.clone(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CommandSurface::Foreground(parsed.invocation.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads a config file and returns a summary report.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns I/O or parse errors while reading the config file.
|
||||||
|
pub fn load_config_report(path: &Path, strict: bool) -> Result<ConfigLoadReport, CliError> {
|
||||||
|
let config = fs::read_to_string(path).map_err(|error| CliError::Io(error.to_string()))?;
|
||||||
|
let directives = if strict {
|
||||||
|
parse_config(&config).map_err(CliError::Config)?
|
||||||
|
} else {
|
||||||
|
parse_config_lenient(&config).map_err(CliError::Config)?
|
||||||
|
};
|
||||||
|
let directive_count = directives.len();
|
||||||
|
Ok(ConfigLoadReport {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
directive_count,
|
||||||
|
strict,
|
||||||
|
profile: ConfigProfile {
|
||||||
|
name: path.to_string_lossy().into_owned(),
|
||||||
|
source: ConfigSource::UserConfig,
|
||||||
|
document: ConfigDocument {
|
||||||
|
directives,
|
||||||
|
location: ConfigLocationKind::File,
|
||||||
|
scope: ConfigScope::Mixed,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
/// Projects the effective config directives into a simple option map.
|
||||||
|
pub fn profile_option_map(profile: &ConfigProfile) -> std::collections::BTreeMap<String, String> {
|
||||||
|
profile
|
||||||
|
.document
|
||||||
|
.directives
|
||||||
|
.iter()
|
||||||
|
.filter_map(|directive| {
|
||||||
|
directive
|
||||||
|
.value
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| (directive.name.clone(), value.clone()))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
/// Returns the effective last-wins directive value for one option name.
|
||||||
|
pub(crate) fn profile_option_value<'a>(
|
||||||
|
profile: &'a ConfigProfile,
|
||||||
|
option_name: &str,
|
||||||
|
) -> Option<&'a str> {
|
||||||
|
for directive in profile.document.directives.iter().rev() {
|
||||||
|
if directive.name == option_name {
|
||||||
|
return directive.value.as_deref();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses aria2-style boolean text accepted by config and RPC surfaces.
|
||||||
|
pub(crate) fn parse_bool_text(value: &str) -> Option<bool> {
|
||||||
|
let value = value.trim();
|
||||||
|
if matches!(value, "1")
|
||||||
|
|| value.eq_ignore_ascii_case("true")
|
||||||
|
|| value.eq_ignore_ascii_case("yes")
|
||||||
|
|| value.eq_ignore_ascii_case("on")
|
||||||
|
{
|
||||||
|
Some(true)
|
||||||
|
} else if matches!(value, "0")
|
||||||
|
|| value.eq_ignore_ascii_case("false")
|
||||||
|
|| value.eq_ignore_ascii_case("no")
|
||||||
|
|| value.eq_ignore_ascii_case("off")
|
||||||
|
{
|
||||||
|
Some(false)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a trimmed unsigned integer from text.
|
||||||
|
pub(crate) fn parse_u64_text(value: &str) -> Option<u64> {
|
||||||
|
value.trim().parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a trimmed TCP/UDP port from text.
|
||||||
|
pub(crate) fn parse_u16_text(value: &str) -> Option<u16> {
|
||||||
|
value.trim().parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses an aria2-style size literal such as `4M`.
|
||||||
|
pub(crate) fn parse_size_text(value: &str) -> Option<u64> {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
let digits = trimmed.trim_end_matches(|c: char| c.is_ascii_alphabetic());
|
||||||
|
let suffix = &trimmed[digits.len()..];
|
||||||
|
let base: u64 = digits.parse().ok()?;
|
||||||
|
let multiplier = if suffix.is_empty() {
|
||||||
|
1
|
||||||
|
} else if suffix.eq_ignore_ascii_case("k") {
|
||||||
|
1024
|
||||||
|
} else if suffix.eq_ignore_ascii_case("m") {
|
||||||
|
1024 * 1024
|
||||||
|
} else if suffix.eq_ignore_ascii_case("g") {
|
||||||
|
1024 * 1024 * 1024
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
Some(base.saturating_mul(multiplier))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splits a comma-separated option value into trimmed entries.
|
||||||
|
pub(crate) fn parse_csv_text(value: &str) -> Vec<String> {
|
||||||
|
value
|
||||||
|
.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derives the effective RPC listen host from config and startup overrides.
|
||||||
|
pub(crate) fn derive_rpc_listen_host(
|
||||||
|
profile: Option<&ConfigProfile>,
|
||||||
|
startup: &StartupProfile,
|
||||||
|
) -> String {
|
||||||
|
let mut listen_host = startup.rpc.listen_host.clone();
|
||||||
|
if let Some(profile) = profile
|
||||||
|
&& parse_bool_text(profile_option_value(profile, "rpc-listen-all").unwrap_or_default())
|
||||||
|
== Some(true)
|
||||||
|
{
|
||||||
|
"0.0.0.0".clone_into(&mut listen_host);
|
||||||
|
}
|
||||||
|
listen_host
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derives the effective RPC secret from startup overrides or config.
|
||||||
|
pub(crate) fn derive_rpc_secret(
|
||||||
|
profile: Option<&ConfigProfile>,
|
||||||
|
startup: &StartupProfile,
|
||||||
|
) -> Option<String> {
|
||||||
|
if let Some(secret) = startup.rpc.secret.clone() {
|
||||||
|
return Some(secret);
|
||||||
|
}
|
||||||
|
profile
|
||||||
|
.and_then(|profile| profile_option_value(profile, "rpc-secret"))
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a proxy URL into the protocol-layer proxy model.
|
||||||
|
pub(crate) fn parse_proxy_text(value: &str, bypass_hosts: Vec<String>) -> Option<ProxyConfig> {
|
||||||
|
let (scheme, rest) = value.split_once("://").unwrap_or(("http", value));
|
||||||
|
let (auth_part, host_part) = match rest.rsplit_once('@') {
|
||||||
|
Some((auth, host)) => (Some(auth), host),
|
||||||
|
None => (None, rest),
|
||||||
|
};
|
||||||
|
let (host, port) = host_part.rsplit_once(':')?;
|
||||||
|
let (username, password) = match auth_part.and_then(|auth| auth.split_once(':')) {
|
||||||
|
Some((username, password)) => (Some(username.to_owned()), Some(password.to_owned())),
|
||||||
|
None => (auth_part.map(str::to_owned), None),
|
||||||
|
};
|
||||||
|
Some(ProxyConfig {
|
||||||
|
scheme: scheme.to_owned(),
|
||||||
|
host: host.to_owned(),
|
||||||
|
port: port.parse().ok()?,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
bypass_hosts,
|
||||||
|
no_proxy: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the layered proxy-auth override value for a selected proxy family.
|
||||||
|
pub(crate) fn layered_proxy_auth_override(
|
||||||
|
profile: &ConfigProfile,
|
||||||
|
primary_key: &str,
|
||||||
|
fallback_key: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
profile_option_value(profile, primary_key)
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.or_else(|| {
|
||||||
|
if primary_key == fallback_key {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
profile_option_value(profile, fallback_key).map(ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies aria2-style proxy auth overrides on top of a parsed proxy endpoint.
|
||||||
|
pub(crate) fn apply_proxy_auth_overrides(
|
||||||
|
proxy: &mut ProxyConfig,
|
||||||
|
profile: &ConfigProfile,
|
||||||
|
user_key: &str,
|
||||||
|
password_key: &str,
|
||||||
|
) {
|
||||||
|
if let Some(username) = layered_proxy_auth_override(profile, user_key, "all-proxy-user") {
|
||||||
|
proxy.username = Some(username);
|
||||||
|
}
|
||||||
|
if let Some(password) = layered_proxy_auth_override(profile, password_key, "all-proxy-passwd") {
|
||||||
|
proxy.password = Some(password);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saturating conversion from `u64` to `usize`.
|
||||||
|
pub(crate) fn saturating_usize_from_u64(value: u64) -> usize {
|
||||||
|
usize::try_from(value).unwrap_or(usize::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saturating conversion from `u64` to `u32`.
|
||||||
|
pub(crate) fn saturating_u32_from_u64(value: u64) -> u32 {
|
||||||
|
u32::try_from(value).unwrap_or(u32::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saturating conversion from `usize` to `u32`.
|
||||||
|
pub(crate) fn saturating_u32_from_usize(value: usize) -> u32 {
|
||||||
|
u32::try_from(value).unwrap_or(u32::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saturating conversion from `usize` to `u16`.
|
||||||
|
pub(crate) fn saturating_u16_from_usize(value: usize) -> u16 {
|
||||||
|
u16::try_from(value).unwrap_or(u16::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fallible-in-practice conversion from `usize` to `u64` with saturation fallback.
|
||||||
|
pub(crate) fn lossless_u64_from_usize(value: usize) -> u64 {
|
||||||
|
u64::try_from(value).unwrap_or(u64::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derives the core runtime configuration from CLI and config inputs.
|
||||||
|
#[must_use]
|
||||||
|
pub fn derive_runtime_config(
|
||||||
|
profile: Option<&ConfigProfile>,
|
||||||
|
startup: &StartupProfile,
|
||||||
|
) -> RuntimeConfig {
|
||||||
|
let mut runtime = RuntimeConfig::default();
|
||||||
|
runtime.allow_jsonrpc = startup.rpc.enabled || startup.mode == RuntimeMode::RpcOnly;
|
||||||
|
runtime.allow_xmlrpc = runtime.allow_jsonrpc;
|
||||||
|
runtime.rpc_port = startup.rpc.listen_port;
|
||||||
|
if let Some(profile) = profile {
|
||||||
|
let option = |name| profile_option_value(profile, name);
|
||||||
|
if let Some(value) = option("rpc-listen-port").and_then(parse_u16_text) {
|
||||||
|
runtime.rpc_port = value;
|
||||||
|
}
|
||||||
|
if let Some(value) = option("listen-port").and_then(parse_u16_text) {
|
||||||
|
runtime.listen_port = value;
|
||||||
|
}
|
||||||
|
if let Some(value) = option("dht-listen-port").and_then(parse_u16_text) {
|
||||||
|
runtime.listen_port = value;
|
||||||
|
}
|
||||||
|
if let Some(value) = option("max-concurrent-downloads").and_then(parse_u64_text) {
|
||||||
|
runtime.max_active_downloads = saturating_usize_from_u64(value.max(1));
|
||||||
|
}
|
||||||
|
if let Some(value) = option("max-connection-per-server").and_then(parse_u64_text) {
|
||||||
|
let connection_budget = saturating_usize_from_u64(value);
|
||||||
|
runtime.max_connections_per_server = connection_budget;
|
||||||
|
runtime.max_connection_per_server = connection_budget;
|
||||||
|
}
|
||||||
|
if let Some(value) = option("max-overall-download-limit").and_then(parse_size_text) {
|
||||||
|
runtime.max_overall_download_limit = (value > 0).then_some(value);
|
||||||
|
}
|
||||||
|
if let Some(value) = option("max-download-limit").and_then(parse_size_text) {
|
||||||
|
runtime.max_download_limit = (value > 0).then_some(value);
|
||||||
|
}
|
||||||
|
if let Some(value) = option("max-overall-upload-limit").and_then(parse_size_text) {
|
||||||
|
runtime.max_overall_upload_limit = (value > 0).then_some(value);
|
||||||
|
}
|
||||||
|
if let Some(value) = option("max-upload-limit").and_then(parse_size_text) {
|
||||||
|
runtime.max_upload_limit = (value > 0).then_some(value);
|
||||||
|
}
|
||||||
|
if let Some(value) = option("split").and_then(parse_u64_text) {
|
||||||
|
runtime.split = saturating_usize_from_u64(value.max(1));
|
||||||
|
}
|
||||||
|
if let Some(value) = option("disk-cache").and_then(parse_size_text) {
|
||||||
|
runtime.disk_cache_bytes = value;
|
||||||
|
}
|
||||||
|
if let Some(value) = option("min-split-size").and_then(parse_size_text) {
|
||||||
|
runtime.min_split_size = value;
|
||||||
|
}
|
||||||
|
if let Some(value) = option("piece-length").and_then(parse_size_text) {
|
||||||
|
runtime.piece_length = value;
|
||||||
|
}
|
||||||
|
if let Some(value) = option("save-session") {
|
||||||
|
runtime.session_path = Some(PathBuf::from(value));
|
||||||
|
}
|
||||||
|
if let Some(value) = option("save-session-interval").and_then(parse_u64_text) {
|
||||||
|
runtime.save_session_interval_secs = value;
|
||||||
|
}
|
||||||
|
if let Some(value) = option("enable-rpc").and_then(parse_bool_text) {
|
||||||
|
runtime.allow_jsonrpc = value;
|
||||||
|
runtime.allow_xmlrpc = value;
|
||||||
|
}
|
||||||
|
if let Some(value) = option("disable-ipv6").and_then(parse_bool_text) {
|
||||||
|
runtime.enable_ipv6 = !value;
|
||||||
|
}
|
||||||
|
if let Some(value) = option("retry-on-400").and_then(parse_bool_text) {
|
||||||
|
runtime.retry_on_400 = value;
|
||||||
|
}
|
||||||
|
if let Some(value) = option("retry-on-403").and_then(parse_bool_text) {
|
||||||
|
runtime.retry_on_403 = value;
|
||||||
|
}
|
||||||
|
if let Some(value) = option("retry-on-406").and_then(parse_bool_text) {
|
||||||
|
runtime.retry_on_406 = value;
|
||||||
|
}
|
||||||
|
if let Some(value) = option("retry-on-unknown").and_then(parse_bool_text) {
|
||||||
|
runtime.retry_on_unknown = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runtime
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derives the HTTP session model from CLI and config inputs.
|
||||||
|
#[must_use]
|
||||||
|
#[expect(
|
||||||
|
clippy::too_many_lines,
|
||||||
|
reason = "session derivation intentionally keeps option-to-field mapping in one audit-friendly routine"
|
||||||
|
)]
|
||||||
|
pub fn derive_http_session(
|
||||||
|
profile: Option<&ConfigProfile>,
|
||||||
|
startup: &StartupProfile,
|
||||||
|
) -> HttpSessionModel {
|
||||||
|
let mut session = HttpSessionModel {
|
||||||
|
session_id: "local-http-session".to_owned(),
|
||||||
|
user_agent: None,
|
||||||
|
default_headers: Vec::new(),
|
||||||
|
cookies: Vec::new(),
|
||||||
|
auth: None,
|
||||||
|
proxy: None,
|
||||||
|
tls: Some(TlsConfig {
|
||||||
|
verify_peer: true,
|
||||||
|
verify_host: true,
|
||||||
|
min_version: None,
|
||||||
|
max_version: None,
|
||||||
|
ca_file: None,
|
||||||
|
cert_file: None,
|
||||||
|
key_file: None,
|
||||||
|
}),
|
||||||
|
retry: default_retry_strategy(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = startup;
|
||||||
|
if let Some(profile) = profile {
|
||||||
|
let option = |name| profile_option_value(profile, name);
|
||||||
|
if let Some(value) = option("user-agent") {
|
||||||
|
session.user_agent = Some(value.to_owned());
|
||||||
|
}
|
||||||
|
if let Some(value) = option("header") {
|
||||||
|
session.default_headers = parse_csv_text(value)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|header| {
|
||||||
|
header.split_once(':').map(|(name, value)| HttpHeader {
|
||||||
|
name: name.trim().to_owned(),
|
||||||
|
value: value.trim().to_owned(),
|
||||||
|
kind: HeaderKind::Request,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
let bypass_hosts = option("no-proxy").map_or_else(Vec::new, parse_csv_text);
|
||||||
|
if let Some(proxy_text) = option("https-proxy") {
|
||||||
|
session.proxy = parse_proxy_text(proxy_text, bypass_hosts);
|
||||||
|
if let Some(proxy) = &mut session.proxy {
|
||||||
|
apply_proxy_auth_overrides(
|
||||||
|
proxy,
|
||||||
|
profile,
|
||||||
|
"https-proxy-user",
|
||||||
|
"https-proxy-passwd",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if let Some(proxy_text) = option("http-proxy") {
|
||||||
|
session.proxy = parse_proxy_text(proxy_text, bypass_hosts);
|
||||||
|
if let Some(proxy) = &mut session.proxy {
|
||||||
|
apply_proxy_auth_overrides(proxy, profile, "http-proxy-user", "http-proxy-passwd");
|
||||||
|
}
|
||||||
|
} else if let Some(proxy_text) = option("all-proxy") {
|
||||||
|
session.proxy = parse_proxy_text(proxy_text, bypass_hosts);
|
||||||
|
if let Some(proxy) = &mut session.proxy {
|
||||||
|
apply_proxy_auth_overrides(proxy, profile, "all-proxy-user", "all-proxy-passwd");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(check) = option("check-certificate").and_then(parse_bool_text)
|
||||||
|
&& let Some(tls) = &mut session.tls
|
||||||
|
{
|
||||||
|
tls.verify_peer = check;
|
||||||
|
tls.verify_host = check;
|
||||||
|
}
|
||||||
|
if let Some(value) = option("ca-certificate")
|
||||||
|
&& let Some(tls) = &mut session.tls
|
||||||
|
{
|
||||||
|
tls.ca_file = Some(value.to_owned());
|
||||||
|
}
|
||||||
|
if let Some(value) = option("certificate")
|
||||||
|
&& let Some(tls) = &mut session.tls
|
||||||
|
{
|
||||||
|
tls.cert_file = Some(value.to_owned());
|
||||||
|
}
|
||||||
|
if let Some(value) = option("private-key")
|
||||||
|
&& let Some(tls) = &mut session.tls
|
||||||
|
{
|
||||||
|
tls.key_file = Some(value.to_owned());
|
||||||
|
}
|
||||||
|
if let Some(value) = option("retry-wait").and_then(parse_u64_text) {
|
||||||
|
session.retry.policy.initial_backoff_ms = value.saturating_mul(1000);
|
||||||
|
session.retry.policy.max_backoff_ms = value.saturating_mul(1000);
|
||||||
|
if value > 0 {
|
||||||
|
session.retry.policy.retry_on_5xx = true;
|
||||||
|
session.retry.policy.retry_on_timeout = true;
|
||||||
|
session.retry.policy.retry_on_network_error = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(value) = option("max-tries").and_then(parse_u64_text) {
|
||||||
|
session.retry.policy.max_attempts = saturating_u32_from_u64(value);
|
||||||
|
if value > 1 {
|
||||||
|
session.retry.policy.retry_on_5xx = true;
|
||||||
|
session.retry.policy.retry_on_timeout = true;
|
||||||
|
session.retry.policy.retry_on_network_error = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(value) = option("retry-on-400").and_then(parse_bool_text) {
|
||||||
|
session.retry.policy.retry_on_4xx |= value;
|
||||||
|
}
|
||||||
|
if let Some(value) = option("retry-on-403").and_then(parse_bool_text) {
|
||||||
|
session.retry.policy.retry_on_4xx |= value;
|
||||||
|
}
|
||||||
|
if let Some(value) = option("retry-on-406").and_then(parse_bool_text) {
|
||||||
|
session.retry.policy.retry_on_4xx |= value;
|
||||||
|
}
|
||||||
|
if let Some(value) = option("retry-on-unknown").and_then(parse_bool_text) {
|
||||||
|
session.retry.policy.retry_on_network_error |= value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the default retry strategy used for one-shot synthetic transfer tasks.
|
||||||
|
pub(crate) const fn default_retry_strategy() -> RetryStrategy {
|
||||||
|
RetryStrategy {
|
||||||
|
policy: RetryPolicy {
|
||||||
|
max_attempts: 1,
|
||||||
|
initial_backoff_ms: 0,
|
||||||
|
max_backoff_ms: 0,
|
||||||
|
retry_on_3xx: false,
|
||||||
|
retry_on_4xx: false,
|
||||||
|
retry_on_5xx: false,
|
||||||
|
retry_on_network_error: false,
|
||||||
|
retry_on_timeout: false,
|
||||||
|
},
|
||||||
|
jitter: None,
|
||||||
|
max_elapsed_ms: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{load_config_report, profile_option_value};
|
||||||
|
use crate::CliError;
|
||||||
|
use aria2_rust_pro_compat::{
|
||||||
|
ConfigDirective, ConfigDocument, ConfigLocationKind, ConfigProfile, ConfigScope,
|
||||||
|
ConfigSource,
|
||||||
|
};
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_config_report_strict_rejects_unknown_options() {
|
||||||
|
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-projection-strict");
|
||||||
|
let _ = fs::create_dir_all(&temp_dir);
|
||||||
|
let config_path = temp_dir.join("aria2.conf");
|
||||||
|
fs::write(&config_path, "split=4\nunknown-option=yes\n")
|
||||||
|
.expect("config should be writable");
|
||||||
|
|
||||||
|
let error =
|
||||||
|
load_config_report(&config_path, true).expect_err("strict config load should fail");
|
||||||
|
assert!(matches!(error, CliError::Config(_)));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_config_report_lenient_preserves_known_directives() {
|
||||||
|
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-projection-lenient");
|
||||||
|
let _ = fs::create_dir_all(&temp_dir);
|
||||||
|
let config_path = temp_dir.join("aria2.conf");
|
||||||
|
fs::write(
|
||||||
|
&config_path,
|
||||||
|
"split=4\nunknown-option=yes\nmin-split-size=1M\n",
|
||||||
|
)
|
||||||
|
.expect("config should be writable");
|
||||||
|
|
||||||
|
let report =
|
||||||
|
load_config_report(&config_path, false).expect("lenient config load should work");
|
||||||
|
assert_eq!(report.directive_count, 3);
|
||||||
|
assert_eq!(report.profile.document.directives.len(), 3);
|
||||||
|
let directive_names = report
|
||||||
|
.profile
|
||||||
|
.document
|
||||||
|
.directives
|
||||||
|
.iter()
|
||||||
|
.map(|directive| directive.name.as_str())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
directive_names,
|
||||||
|
["split", "unknown-option", "min-split-size"]
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_option_value_uses_last_wins_directive_order() {
|
||||||
|
let profile = ConfigProfile {
|
||||||
|
name: "test".to_owned(),
|
||||||
|
source: ConfigSource::RuntimeOverride,
|
||||||
|
document: ConfigDocument {
|
||||||
|
directives: vec![
|
||||||
|
ConfigDirective {
|
||||||
|
name: "dir".to_owned(),
|
||||||
|
value: Some("downloads-a".to_owned()),
|
||||||
|
},
|
||||||
|
ConfigDirective {
|
||||||
|
name: "split".to_owned(),
|
||||||
|
value: Some("2".to_owned()),
|
||||||
|
},
|
||||||
|
ConfigDirective {
|
||||||
|
name: "dir".to_owned(),
|
||||||
|
value: Some("downloads-b".to_owned()),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
location: ConfigLocationKind::Inline,
|
||||||
|
scope: ConfigScope::Mixed,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(profile_option_value(&profile, "dir"), Some("downloads-b"));
|
||||||
|
assert_eq!(profile_option_value(&profile, "split"), Some("2"));
|
||||||
|
assert_eq!(profile_option_value(&profile, "missing"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
#![doc(hidden)]
|
||||||
|
#![expect(
|
||||||
|
clippy::needless_pass_by_value,
|
||||||
|
clippy::redundant_pub_crate,
|
||||||
|
reason = "this private daemon-launch module keeps ownership explicit across listener bootstrap helpers"
|
||||||
|
)]
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
net::{IpAddr, Ipv4Addr, TcpListener},
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
|
use aria2_rust_pro_core::RuntimeConfig;
|
||||||
|
use aria2_rust_pro_rpc::{
|
||||||
|
InProcessRpcDispatcher, JsonRpcRequest, RpcMeta, RpcMethod, RpcServerConfig, RpcValue,
|
||||||
|
serve_rpc_listener,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
CliError, ParsedArguments, TransferInputEntry, derive_rpc_listen_host, derive_rpc_secret,
|
||||||
|
derive_runtime_config, expand_transfer_entries, load_config_report, merged_profile,
|
||||||
|
rpc_option_object,
|
||||||
|
};
|
||||||
|
use crate::Invocation;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct RpcDaemonLaunch {
|
||||||
|
runtime: RuntimeConfig,
|
||||||
|
listen_ip: IpAddr,
|
||||||
|
secret: Option<String>,
|
||||||
|
input_entries: Vec<TransferInputEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the RPC daemon surface implied by a parsed CLI invocation.
|
||||||
|
pub(super) fn run_rpc_daemon(parsed: ParsedArguments) -> Result<(), CliError> {
|
||||||
|
let (config_path, inputs) = match &parsed.invocation {
|
||||||
|
Invocation::Run { config_path, uris } => (config_path.clone(), uris.clone()),
|
||||||
|
Invocation::Version | Invocation::Help { .. } => {
|
||||||
|
return Err(CliError::Io(
|
||||||
|
"rpc daemon launch requires a run invocation".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let launch = build_rpc_daemon_launch(&parsed, config_path, inputs)?;
|
||||||
|
let listener = bind_rpc_listener(launch.listen_ip, launch.runtime.rpc_port)?;
|
||||||
|
let mut dispatcher = InProcessRpcDispatcher::with_runtime(launch.runtime);
|
||||||
|
seed_dispatcher_with_inputs(&mut dispatcher, launch.input_entries);
|
||||||
|
let server_config = build_rpc_server_config(&listener, launch.secret)?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"rpc daemon listening on {}:{}",
|
||||||
|
launch.listen_ip,
|
||||||
|
server_config.listen_addr.port()
|
||||||
|
);
|
||||||
|
|
||||||
|
serve_rpc_listener(listener, server_config, Arc::new(Mutex::new(dispatcher)))
|
||||||
|
.map_err(|error| CliError::Io(format!("rpc daemon serve failed: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_rpc_daemon_launch(
|
||||||
|
parsed: &ParsedArguments,
|
||||||
|
config_path: Option<std::path::PathBuf>,
|
||||||
|
inputs: Vec<String>,
|
||||||
|
) -> Result<RpcDaemonLaunch, CliError> {
|
||||||
|
let config_report = config_path
|
||||||
|
.as_ref()
|
||||||
|
.map(|path| load_config_report(path, true))
|
||||||
|
.transpose()?;
|
||||||
|
let file_profile = config_report.as_ref().map(|report| &report.profile);
|
||||||
|
let effective_profile = merged_profile(file_profile, parsed.cli_profile.as_ref());
|
||||||
|
let profile = effective_profile.as_ref();
|
||||||
|
let runtime = derive_runtime_config(profile, &parsed.profile);
|
||||||
|
let listen_host = derive_rpc_listen_host(profile, &parsed.profile);
|
||||||
|
let secret = derive_rpc_secret(profile, &parsed.profile);
|
||||||
|
let listen_ip = listen_host
|
||||||
|
.parse::<IpAddr>()
|
||||||
|
.unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST));
|
||||||
|
let input_entries =
|
||||||
|
expand_transfer_entries(&inputs, profile, Some(&parsed.cli_transfer_sources))?;
|
||||||
|
|
||||||
|
Ok(RpcDaemonLaunch {
|
||||||
|
runtime,
|
||||||
|
listen_ip,
|
||||||
|
secret,
|
||||||
|
input_entries,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bind_rpc_listener(listen_ip: IpAddr, listen_port: u16) -> Result<TcpListener, CliError> {
|
||||||
|
TcpListener::bind((listen_ip, listen_port))
|
||||||
|
.map_err(|error| CliError::Io(format!("rpc daemon bind failed: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_rpc_server_config(
|
||||||
|
listener: &TcpListener,
|
||||||
|
secret: Option<String>,
|
||||||
|
) -> Result<RpcServerConfig, CliError> {
|
||||||
|
Ok(RpcServerConfig {
|
||||||
|
listen_addr: listener
|
||||||
|
.local_addr()
|
||||||
|
.map_err(|error| CliError::Io(format!("rpc daemon local addr failed: {error}")))?,
|
||||||
|
secret_token: secret,
|
||||||
|
..RpcServerConfig::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seed_dispatcher_with_inputs(
|
||||||
|
dispatcher: &mut InProcessRpcDispatcher,
|
||||||
|
input_entries: Vec<TransferInputEntry>,
|
||||||
|
) {
|
||||||
|
for entry in input_entries {
|
||||||
|
let mut params = vec![RpcValue::Array(
|
||||||
|
entry.uris.into_iter().map(RpcValue::String).collect(),
|
||||||
|
)];
|
||||||
|
if let Some(options) = rpc_option_object(entry.profile.as_ref()) {
|
||||||
|
params.push(options);
|
||||||
|
}
|
||||||
|
let _ = dispatcher.dispatch_json(JsonRpcRequest {
|
||||||
|
jsonrpc: Some("2.0".to_owned()),
|
||||||
|
id: None,
|
||||||
|
method: RpcMethod::Aria2AddUri.as_str().to_owned(),
|
||||||
|
params,
|
||||||
|
meta: RpcMeta::default(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
#![doc(hidden)]
|
||||||
|
#![expect(
|
||||||
|
clippy::redundant_pub_crate,
|
||||||
|
reason = "this private runtime-execution module exposes parent-only helpers across the split CLI runtime facade"
|
||||||
|
)]
|
||||||
|
|
||||||
|
use aria2_rust_pro_compat::ConfigProfile;
|
||||||
|
use aria2_rust_pro_core::RuntimeConfig;
|
||||||
|
use aria2_rust_pro_protocol::{Downloader, Protocol};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
CliError, DispatcherRegistrationKind, InProcessRpcDispatcher, TransferSelection,
|
||||||
|
classify_transfer, execute_bt_runtime_for_gid, execute_transfer_for_uri, parse_protocol,
|
||||||
|
};
|
||||||
|
use crate::{
|
||||||
|
parallel_http_runtime::execute_parallel_http_entries, runtime_planning::RegisteredRuntimeEntry,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(super) use crate::runtime_summary::collect_runtime_execution_summary;
|
||||||
|
|
||||||
|
pub(super) fn execute_registered_transfers<D: Downloader + Sync>(
|
||||||
|
dispatcher: &mut InProcessRpcDispatcher,
|
||||||
|
downloader: &D,
|
||||||
|
entries: &[RegisteredRuntimeEntry],
|
||||||
|
base_profile: Option<&ConfigProfile>,
|
||||||
|
derived_runtime: &RuntimeConfig,
|
||||||
|
) -> Result<(), CliError> {
|
||||||
|
let (mut serial_indices, parallel_http_indices) = partition_runtime_entries(entries);
|
||||||
|
if parallel_http_indices.len() > 1 {
|
||||||
|
execute_parallel_http_entries(
|
||||||
|
dispatcher,
|
||||||
|
downloader,
|
||||||
|
entries,
|
||||||
|
base_profile,
|
||||||
|
derived_runtime,
|
||||||
|
¶llel_http_indices,
|
||||||
|
)?;
|
||||||
|
} else {
|
||||||
|
serial_indices.extend(parallel_http_indices);
|
||||||
|
}
|
||||||
|
|
||||||
|
for index in serial_indices {
|
||||||
|
let entry = entries
|
||||||
|
.get(index)
|
||||||
|
.expect("serial runtime entry index must come from partition_runtime_entries");
|
||||||
|
match classify_transfer(&entry.resolved.uri) {
|
||||||
|
TransferSelection::Magnet | TransferSelection::Torrent => {
|
||||||
|
execute_bt_runtime_for_gid(dispatcher, &entry.gid)?;
|
||||||
|
}
|
||||||
|
_ => match entry.registration_kind {
|
||||||
|
DispatcherRegistrationKind::Uri => {
|
||||||
|
execute_transfer_for_uri(
|
||||||
|
dispatcher,
|
||||||
|
downloader,
|
||||||
|
&entry.resolved.uri,
|
||||||
|
&entry.gid,
|
||||||
|
entry.resolved.entry_profile.as_ref().or(base_profile),
|
||||||
|
&entry.resolved.http_session,
|
||||||
|
&entry.resolved.runtime,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
DispatcherRegistrationKind::Torrent => {
|
||||||
|
execute_bt_runtime_for_gid(dispatcher, &entry.gid)?;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn partition_runtime_entries(entries: &[RegisteredRuntimeEntry]) -> (Vec<usize>, Vec<usize>) {
|
||||||
|
let mut serial_indices = Vec::new();
|
||||||
|
let mut parallel_http_indices = Vec::new();
|
||||||
|
for (index, entry) in entries.iter().enumerate() {
|
||||||
|
if entry.registration_kind == DispatcherRegistrationKind::Uri
|
||||||
|
&& classify_transfer(&entry.resolved.uri) == TransferSelection::Uri
|
||||||
|
&& matches!(
|
||||||
|
parse_protocol(&entry.resolved.uri),
|
||||||
|
Some(Protocol::Http | Protocol::Https)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
parallel_http_indices.push(index);
|
||||||
|
} else {
|
||||||
|
serial_indices.push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(serial_indices, parallel_http_indices)
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
#![doc(hidden)]
|
||||||
|
#![expect(
|
||||||
|
clippy::redundant_pub_crate,
|
||||||
|
reason = "this private runtime host module is an internal orchestration façade for the CLI crate split"
|
||||||
|
)]
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use aria2_rust_pro_compat::ConfigProfile;
|
||||||
|
use aria2_rust_pro_core::RuntimeConfig;
|
||||||
|
use aria2_rust_pro_protocol::Downloader;
|
||||||
|
use aria2_rust_pro_storage::ControlFileVersion;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
CliError, CliTransferSource, Invocation, RuntimeReport, StartupProfile, derive_http_session,
|
||||||
|
};
|
||||||
|
use crate::{
|
||||||
|
runtime_execution::{collect_runtime_execution_summary, execute_registered_transfers},
|
||||||
|
runtime_planning::{build_runtime_input_context, register_runtime_entries},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(super) fn execute_runtime_with_downloader_impl<D: Downloader + Sync>(
|
||||||
|
invocation: Invocation,
|
||||||
|
startup: &StartupProfile,
|
||||||
|
cli_profile: Option<&ConfigProfile>,
|
||||||
|
cli_transfer_sources: Option<&[CliTransferSource]>,
|
||||||
|
downloader: &D,
|
||||||
|
) -> Result<RuntimeReport, CliError> {
|
||||||
|
match invocation {
|
||||||
|
Invocation::Version | Invocation::Help { .. } => Ok(empty_runtime_report()),
|
||||||
|
Invocation::Run { config_path, uris } => execute_run_invocation(
|
||||||
|
config_path,
|
||||||
|
uris,
|
||||||
|
startup,
|
||||||
|
cli_profile,
|
||||||
|
cli_transfer_sources,
|
||||||
|
downloader,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_runtime_report() -> RuntimeReport {
|
||||||
|
RuntimeReport {
|
||||||
|
accepted_uri_count: 0,
|
||||||
|
tracked_download_count: 0,
|
||||||
|
completed_download_count: 0,
|
||||||
|
first_gid: None,
|
||||||
|
first_status: None,
|
||||||
|
first_total_length: None,
|
||||||
|
first_completed_length: None,
|
||||||
|
first_connections: None,
|
||||||
|
recognized_schemes: Vec::new(),
|
||||||
|
transfer_kinds: Vec::new(),
|
||||||
|
config_report: None,
|
||||||
|
derived_runtime: RuntimeConfig::default(),
|
||||||
|
http_session: derive_http_session(None, &StartupProfile::default()),
|
||||||
|
control_file_version_major: ControlFileVersion::CURRENT.major(),
|
||||||
|
first_bt_status: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_run_invocation<D: Downloader + Sync>(
|
||||||
|
config_path: Option<PathBuf>,
|
||||||
|
uris: Vec<String>,
|
||||||
|
startup: &StartupProfile,
|
||||||
|
cli_profile: Option<&ConfigProfile>,
|
||||||
|
cli_transfer_sources: Option<&[CliTransferSource]>,
|
||||||
|
downloader: &D,
|
||||||
|
) -> Result<RuntimeReport, CliError> {
|
||||||
|
let context = build_runtime_input_context(
|
||||||
|
config_path,
|
||||||
|
uris,
|
||||||
|
startup,
|
||||||
|
cli_profile,
|
||||||
|
cli_transfer_sources,
|
||||||
|
downloader,
|
||||||
|
)?;
|
||||||
|
let base_profile = context.effective_profile.as_ref();
|
||||||
|
let mut dispatcher =
|
||||||
|
super::InProcessRpcDispatcher::with_runtime(context.derived_runtime.clone());
|
||||||
|
let registered_entries = register_runtime_entries(
|
||||||
|
&mut dispatcher,
|
||||||
|
downloader,
|
||||||
|
context.resolved_entries,
|
||||||
|
base_profile,
|
||||||
|
)?;
|
||||||
|
execute_registered_transfers(
|
||||||
|
&mut dispatcher,
|
||||||
|
downloader,
|
||||||
|
®istered_entries,
|
||||||
|
base_profile,
|
||||||
|
&context.derived_runtime,
|
||||||
|
)?;
|
||||||
|
let summary = collect_runtime_execution_summary(&mut dispatcher, ®istered_entries)?;
|
||||||
|
|
||||||
|
Ok(RuntimeReport {
|
||||||
|
accepted_uri_count: registered_entries.len(),
|
||||||
|
tracked_download_count: summary.tracked_download_count,
|
||||||
|
completed_download_count: summary.completed_download_count,
|
||||||
|
first_gid: summary.first_gid,
|
||||||
|
first_status: summary.first_status,
|
||||||
|
first_total_length: summary.first_total_length,
|
||||||
|
first_completed_length: summary.first_completed_length,
|
||||||
|
first_connections: summary.first_connections,
|
||||||
|
recognized_schemes: context.recognized_schemes,
|
||||||
|
transfer_kinds: context.transfer_kinds,
|
||||||
|
config_report: context.config_report,
|
||||||
|
derived_runtime: context.derived_runtime,
|
||||||
|
http_session: context.http_session,
|
||||||
|
control_file_version_major: ControlFileVersion::CURRENT.major(),
|
||||||
|
first_bt_status: summary.first_bt_status,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
#![doc(hidden)]
|
||||||
|
#![expect(
|
||||||
|
clippy::needless_pass_by_value,
|
||||||
|
clippy::redundant_pub_crate,
|
||||||
|
reason = "this private runtime-planning module shares parent-only planning structs and helpers across the split CLI runtime"
|
||||||
|
)]
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use aria2_rust_pro_compat::{ConfigParseError, ConfigProfile};
|
||||||
|
use aria2_rust_pro_core::RuntimeConfig;
|
||||||
|
use aria2_rust_pro_protocol::{Downloader, HttpSessionModel, Protocol};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
CliError, CliTransferSource, ConfigLoadReport, DispatcherRegistrationKind,
|
||||||
|
InProcessRpcDispatcher, StartupProfile, TransferInputEntry, TransferSelection,
|
||||||
|
classify_transfer, derive_http_session, derive_runtime_config, expand_transfer_entries,
|
||||||
|
load_config_report, merged_profile, parse_protocol, register_resolved_entry_with_dispatcher,
|
||||||
|
resolve_transfer_entry_with_downloader,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub(super) struct RuntimeInputContext {
|
||||||
|
pub(super) config_report: Option<ConfigLoadReport>,
|
||||||
|
pub(super) effective_profile: Option<ConfigProfile>,
|
||||||
|
pub(super) recognized_schemes: Vec<String>,
|
||||||
|
pub(super) transfer_kinds: Vec<TransferSelection>,
|
||||||
|
pub(super) derived_runtime: RuntimeConfig,
|
||||||
|
pub(super) http_session: HttpSessionModel,
|
||||||
|
pub(super) resolved_entries: Vec<ResolvedRuntimeEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub(super) struct ResolvedRuntimeEntry {
|
||||||
|
pub(super) entry: TransferInputEntry,
|
||||||
|
pub(super) entry_profile: Option<ConfigProfile>,
|
||||||
|
pub(super) runtime: RuntimeConfig,
|
||||||
|
pub(super) http_session: HttpSessionModel,
|
||||||
|
pub(super) uri: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub(super) struct RegisteredRuntimeEntry {
|
||||||
|
pub(super) resolved: ResolvedRuntimeEntry,
|
||||||
|
pub(super) gid: String,
|
||||||
|
pub(super) registration_kind: DispatcherRegistrationKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct DerivedEntryContext {
|
||||||
|
runtime: RuntimeConfig,
|
||||||
|
http_session: HttpSessionModel,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn build_runtime_input_context<D: Downloader + Sync>(
|
||||||
|
config_path: Option<PathBuf>,
|
||||||
|
uris: Vec<String>,
|
||||||
|
startup: &StartupProfile,
|
||||||
|
cli_profile: Option<&ConfigProfile>,
|
||||||
|
cli_transfer_sources: Option<&[CliTransferSource]>,
|
||||||
|
downloader: &D,
|
||||||
|
) -> Result<RuntimeInputContext, CliError> {
|
||||||
|
let config_report = config_path
|
||||||
|
.as_ref()
|
||||||
|
.map(|path| load_config_report(path, true))
|
||||||
|
.transpose()?;
|
||||||
|
let file_profile = config_report.as_ref().map(|report| &report.profile);
|
||||||
|
let effective_profile = merged_profile(file_profile, cli_profile);
|
||||||
|
let profile = effective_profile.as_ref();
|
||||||
|
let input_entries = expand_transfer_entries(&uris, profile, cli_transfer_sources)?;
|
||||||
|
let first_entry_profile = input_entries.first().and_then(|entry| {
|
||||||
|
let implied = merged_profile(entry.implied_profile.as_ref(), profile);
|
||||||
|
merged_profile(implied.as_ref(), entry.profile.as_ref())
|
||||||
|
});
|
||||||
|
let report_profile = first_entry_profile.as_ref().or(profile);
|
||||||
|
let derived_runtime = derive_runtime_config(report_profile, startup);
|
||||||
|
let http_session = derive_http_session(report_profile, startup);
|
||||||
|
|
||||||
|
let recognized_schemes = input_entries
|
||||||
|
.iter()
|
||||||
|
.filter_map(|entry| entry.uris.first())
|
||||||
|
.filter_map(|uri| parse_protocol(uri).map(Protocol::as_str))
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let transfer_kinds = input_entries
|
||||||
|
.iter()
|
||||||
|
.filter_map(|entry| entry.uris.first())
|
||||||
|
.map(String::as_str)
|
||||||
|
.map(classify_transfer)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let resolved_entries = resolve_runtime_entries(&input_entries, startup, profile, downloader)?;
|
||||||
|
|
||||||
|
Ok(RuntimeInputContext {
|
||||||
|
config_report,
|
||||||
|
effective_profile,
|
||||||
|
recognized_schemes,
|
||||||
|
transfer_kinds,
|
||||||
|
derived_runtime,
|
||||||
|
http_session,
|
||||||
|
resolved_entries,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn register_runtime_entries<D: Downloader + Sync>(
|
||||||
|
dispatcher: &mut InProcessRpcDispatcher,
|
||||||
|
downloader: &D,
|
||||||
|
entries: Vec<ResolvedRuntimeEntry>,
|
||||||
|
base_profile: Option<&ConfigProfile>,
|
||||||
|
) -> Result<Vec<RegisteredRuntimeEntry>, CliError> {
|
||||||
|
let mut registered_entries = Vec::with_capacity(entries.len());
|
||||||
|
for resolved in entries {
|
||||||
|
let (gid, registration_kind) = register_resolved_entry_with_dispatcher(
|
||||||
|
dispatcher,
|
||||||
|
downloader,
|
||||||
|
&resolved.entry,
|
||||||
|
&resolved.uri,
|
||||||
|
resolved.entry_profile.as_ref().or(base_profile),
|
||||||
|
&resolved.http_session,
|
||||||
|
&resolved.runtime,
|
||||||
|
)?;
|
||||||
|
registered_entries.push(RegisteredRuntimeEntry {
|
||||||
|
resolved,
|
||||||
|
gid,
|
||||||
|
registration_kind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(registered_entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_runtime_entries<D: Downloader + Sync>(
|
||||||
|
input_entries: &[TransferInputEntry],
|
||||||
|
startup: &StartupProfile,
|
||||||
|
profile: Option<&ConfigProfile>,
|
||||||
|
downloader: &D,
|
||||||
|
) -> Result<Vec<ResolvedRuntimeEntry>, CliError> {
|
||||||
|
let mut resolved_entries = Vec::new();
|
||||||
|
let mut derived_context_cache = Vec::new();
|
||||||
|
for entry in input_entries {
|
||||||
|
let direct_passthrough = entry
|
||||||
|
.uris
|
||||||
|
.first()
|
||||||
|
.is_some_and(|uri| classify_transfer(uri) != TransferSelection::Metalink);
|
||||||
|
let pre_resolve_profile = merged_profile(
|
||||||
|
merged_profile(entry.implied_profile.as_ref(), profile).as_ref(),
|
||||||
|
entry.profile.as_ref(),
|
||||||
|
);
|
||||||
|
let pre_resolve_context = derived_entry_context(
|
||||||
|
&mut derived_context_cache,
|
||||||
|
pre_resolve_profile.as_ref(),
|
||||||
|
startup,
|
||||||
|
);
|
||||||
|
let expanded_entries = resolve_transfer_entry_with_downloader(
|
||||||
|
entry,
|
||||||
|
downloader,
|
||||||
|
&pre_resolve_context.http_session,
|
||||||
|
&pre_resolve_context.runtime,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
if direct_passthrough && expanded_entries.len() == 1 {
|
||||||
|
let resolved_uri = entry.uris.first().cloned().ok_or_else(|| {
|
||||||
|
CliError::Config(ConfigParseError::InvalidDirective(
|
||||||
|
"input-file entry missing URI".to_owned(),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let expanded_entry = expanded_entries
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.expect("direct entry fast path should preserve one resolved entry");
|
||||||
|
resolved_entries.push(ResolvedRuntimeEntry {
|
||||||
|
entry: expanded_entry,
|
||||||
|
entry_profile: pre_resolve_profile,
|
||||||
|
runtime: pre_resolve_context.runtime,
|
||||||
|
http_session: pre_resolve_context.http_session,
|
||||||
|
uri: resolved_uri,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for expanded_entry in expanded_entries {
|
||||||
|
let entry_profile = merged_profile(
|
||||||
|
merged_profile(expanded_entry.implied_profile.as_ref(), profile).as_ref(),
|
||||||
|
expanded_entry.profile.as_ref(),
|
||||||
|
);
|
||||||
|
let entry_context =
|
||||||
|
derived_entry_context(&mut derived_context_cache, entry_profile.as_ref(), startup);
|
||||||
|
let resolved_uri = expanded_entry.uris.first().cloned().ok_or_else(|| {
|
||||||
|
CliError::Config(ConfigParseError::InvalidDirective(
|
||||||
|
"input-file entry missing URI".to_owned(),
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
resolved_entries.push(ResolvedRuntimeEntry {
|
||||||
|
entry: expanded_entry,
|
||||||
|
entry_profile,
|
||||||
|
runtime: entry_context.runtime,
|
||||||
|
http_session: entry_context.http_session,
|
||||||
|
uri: resolved_uri,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(resolved_entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derived_entry_context(
|
||||||
|
cache: &mut Vec<(Option<ConfigProfile>, DerivedEntryContext)>,
|
||||||
|
profile: Option<&ConfigProfile>,
|
||||||
|
startup: &StartupProfile,
|
||||||
|
) -> DerivedEntryContext {
|
||||||
|
if let Some((_, cached)) = cache
|
||||||
|
.iter()
|
||||||
|
.find(|(cached_profile, _)| cached_profile.as_ref() == profile)
|
||||||
|
{
|
||||||
|
return cached.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
let derived = DerivedEntryContext {
|
||||||
|
runtime: derive_runtime_config(profile, startup),
|
||||||
|
http_session: derive_http_session(profile, startup),
|
||||||
|
};
|
||||||
|
cache.push((profile.cloned(), derived.clone()));
|
||||||
|
derived
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#![doc(hidden)]
|
||||||
|
#![expect(
|
||||||
|
clippy::redundant_pub_crate,
|
||||||
|
reason = "this private runtime summary helper only feeds the CLI runtime host and does not define public-facing API contracts"
|
||||||
|
)]
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
BtStatusReport, CliError, InProcessRpcDispatcher, dispatcher_status_for_gid,
|
||||||
|
dispatcher_status_summary_for_gid, parse_bt_status_report, rpc_status_text,
|
||||||
|
};
|
||||||
|
use crate::runtime_planning::RegisteredRuntimeEntry;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub(super) struct RuntimeExecutionSummary {
|
||||||
|
pub(super) first_gid: Option<String>,
|
||||||
|
pub(super) tracked_download_count: usize,
|
||||||
|
pub(super) completed_download_count: usize,
|
||||||
|
pub(super) first_status: Option<String>,
|
||||||
|
pub(super) first_total_length: Option<u64>,
|
||||||
|
pub(super) first_completed_length: Option<u64>,
|
||||||
|
pub(super) first_connections: Option<u32>,
|
||||||
|
pub(super) first_bt_status: Option<BtStatusReport>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn collect_runtime_execution_summary(
|
||||||
|
dispatcher: &mut InProcessRpcDispatcher,
|
||||||
|
entries: &[RegisteredRuntimeEntry],
|
||||||
|
) -> Result<RuntimeExecutionSummary, CliError> {
|
||||||
|
let mut summary = RuntimeExecutionSummary {
|
||||||
|
first_gid: entries.first().map(|entry| entry.gid.clone()),
|
||||||
|
tracked_download_count: dispatcher.tracked_download_count(),
|
||||||
|
..RuntimeExecutionSummary::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
for (index, entry) in entries.iter().enumerate() {
|
||||||
|
let status = dispatcher_status_summary_for_gid(dispatcher, &entry.gid)?;
|
||||||
|
let status_value = rpc_status_text(status.status);
|
||||||
|
if status.status.as_rpc_status() == "complete" {
|
||||||
|
summary.completed_download_count = summary.completed_download_count.saturating_add(1);
|
||||||
|
}
|
||||||
|
if index == 0 {
|
||||||
|
summary.first_total_length = Some(status.total_length);
|
||||||
|
summary.first_completed_length = Some(status.completed_length);
|
||||||
|
summary.first_connections = Some(status.connections);
|
||||||
|
summary.first_status = Some(status_value);
|
||||||
|
if status.is_bt {
|
||||||
|
let full_status = dispatcher_status_for_gid(dispatcher, &entry.gid)?;
|
||||||
|
summary.first_bt_status = parse_bt_status_report(&full_status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(summary)
|
||||||
|
}
|
||||||
@@ -0,0 +1,930 @@
|
|||||||
|
use std::{
|
||||||
|
collections::VecDeque,
|
||||||
|
ffi::OsString,
|
||||||
|
fs,
|
||||||
|
io::{BufRead, BufReader, Read, Write},
|
||||||
|
net::{TcpListener, TcpStream},
|
||||||
|
process::{Child, Command, ExitStatus, Stdio},
|
||||||
|
sync::{
|
||||||
|
Arc, Mutex,
|
||||||
|
atomic::{AtomicUsize, Ordering},
|
||||||
|
},
|
||||||
|
thread,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
use aria2_rust_pro_core::RuntimeConfig;
|
||||||
|
use aria2_rust_pro_protocol::{
|
||||||
|
Downloader, FixtureHttpDownloader, FtpConfigModel, FtpRequestModel, FtpResponseModel,
|
||||||
|
HttpResponseHeaders, HttpResponseModel, HttpVersion, Protocol, ReqwestHttpConnector,
|
||||||
|
ResponseBody, SftpConfigModel, SftpRequestModel, SftpResponseModel,
|
||||||
|
transport::{TransportError, TransportErrorKind},
|
||||||
|
};
|
||||||
|
use aria2_rust_pro_rpc::{InProcessRpcDispatcher, JsonRpcRequest, RpcMethod, RpcValue};
|
||||||
|
|
||||||
|
use super::http_runtime::partition_indexed_work_evenly;
|
||||||
|
use super::{
|
||||||
|
CliError, CliTransferSource, CommandSurface, Invocation, RuntimeMode, StartupProfile,
|
||||||
|
TransferSelection, build_ftp_transfer_parts, build_http_transfer_task, classify_transfer,
|
||||||
|
command_surface, derive_http_session, derive_runtime_config, execute,
|
||||||
|
execute_http_transfer_with_retry, execute_runtime, execute_runtime_with_context,
|
||||||
|
execute_runtime_with_downloader, execute_segment_transfers, load_config_report, merged_profile,
|
||||||
|
parse_args, parse_cli, parse_protocol, planned_segment_span, profile_option_map, render_help,
|
||||||
|
render_version,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn percent_encode_uri_component(value: &str) -> String {
|
||||||
|
let mut encoded = String::with_capacity(value.len());
|
||||||
|
for byte in value.bytes() {
|
||||||
|
if matches!(byte, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~') {
|
||||||
|
encoded.push(char::from(byte));
|
||||||
|
} else {
|
||||||
|
encoded.push('%');
|
||||||
|
std::fmt::Write::write_fmt(&mut encoded, format_args!("{byte:02X}"))
|
||||||
|
.expect("writing percent-encoded byte into string must succeed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_live_bt_tracker_fixture() -> (String, thread::JoinHandle<()>) {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").expect("local listener should bind");
|
||||||
|
let addr = listener.local_addr().expect("local addr should exist");
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
for _ in 0..2 {
|
||||||
|
let (mut stream, _) = listener.accept().expect("tracker client should connect");
|
||||||
|
let mut request = [0_u8; 2048];
|
||||||
|
let read = stream.read(&mut request).expect("request should read");
|
||||||
|
let request_slice = request
|
||||||
|
.get(..read)
|
||||||
|
.expect("read length must stay within request buffer");
|
||||||
|
let request_text = String::from_utf8_lossy(request_slice);
|
||||||
|
let (payload, path) = if request_text.starts_with("GET /announce?") {
|
||||||
|
(
|
||||||
|
b"d8:intervali600e10:tracker id12:cli-live-0015:peers6:\x7f\x00\x00\x01\x1a\xe1e"
|
||||||
|
.to_vec(),
|
||||||
|
"/announce",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
b"d8:completei4e10:downloadedi9e10:incompletei2ee".to_vec(),
|
||||||
|
"/scrape",
|
||||||
|
)
|
||||||
|
};
|
||||||
|
assert!(request_text.contains(path));
|
||||||
|
let response = format!(
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: text/plain\r\n\r\n",
|
||||||
|
payload.len()
|
||||||
|
);
|
||||||
|
stream
|
||||||
|
.write_all(response.as_bytes())
|
||||||
|
.expect("headers should write");
|
||||||
|
stream.write_all(&payload).expect("payload should write");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
(format!("http://{addr}/announce"), handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_single_file_torrent_bytes(
|
||||||
|
announce_url: &str,
|
||||||
|
file_name: &str,
|
||||||
|
total_length: u64,
|
||||||
|
piece_length: u64,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
fn bencode_bytes(value: &[u8]) -> Vec<u8> {
|
||||||
|
let mut encoded = format!("{}:", value.len()).into_bytes();
|
||||||
|
encoded.extend_from_slice(value);
|
||||||
|
encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bencode_int(value: u64) -> Vec<u8> {
|
||||||
|
format!("i{value}e").into_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut torrent = Vec::new();
|
||||||
|
torrent.extend_from_slice(b"d8:announce");
|
||||||
|
torrent.extend_from_slice(&bencode_bytes(announce_url.as_bytes()));
|
||||||
|
torrent.extend_from_slice(b"4:infod6:length");
|
||||||
|
torrent.extend_from_slice(&bencode_int(total_length));
|
||||||
|
torrent.extend_from_slice(b"4:name");
|
||||||
|
torrent.extend_from_slice(&bencode_bytes(file_name.as_bytes()));
|
||||||
|
torrent.extend_from_slice(b"12:piece length");
|
||||||
|
torrent.extend_from_slice(&bencode_int(piece_length));
|
||||||
|
torrent.extend_from_slice(b"6:pieces20:");
|
||||||
|
torrent.extend_from_slice(&[0_u8; 20]);
|
||||||
|
torrent.extend_from_slice(b"ee");
|
||||||
|
torrent
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_with_local_metalink_fixture_expands_multiple_files_and_uses_implied_output_names()
|
||||||
|
{
|
||||||
|
let mut downloader = FixtureHttpDownloader::new();
|
||||||
|
downloader.register("http://example.com/alpha.bin", b"abc");
|
||||||
|
downloader.register("http://example.com/beta.bin", b"hello");
|
||||||
|
|
||||||
|
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-metalink-multifile-test");
|
||||||
|
let _ = fs::create_dir_all(&temp_dir);
|
||||||
|
let download_dir = temp_dir.join("downloads");
|
||||||
|
let metalink_path = temp_dir.join("fixture.meta4");
|
||||||
|
let config_path = temp_dir.join("aria2.conf");
|
||||||
|
fs::write(
|
||||||
|
&metalink_path,
|
||||||
|
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<metalink version="4.0">
|
||||||
|
<file name="alpha.bin">
|
||||||
|
<hash type="md5">900150983cd24fb0d6963f7d28e17f72</hash>
|
||||||
|
<url priority="1">http://example.com/alpha.bin</url>
|
||||||
|
</file>
|
||||||
|
<file name="beta.bin">
|
||||||
|
<url priority="1">http://example.com/beta.bin</url>
|
||||||
|
</file>
|
||||||
|
</metalink>"#,
|
||||||
|
)
|
||||||
|
.expect("metalink file should write");
|
||||||
|
fs::write(&config_path, format!("dir={}\n", download_dir.display()))
|
||||||
|
.expect("config should write");
|
||||||
|
|
||||||
|
let report = execute_runtime_with_downloader(
|
||||||
|
Invocation::Run {
|
||||||
|
config_path: Some(config_path),
|
||||||
|
uris: vec![metalink_path.to_string_lossy().into_owned()],
|
||||||
|
},
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should execute with expanded metalink fixture");
|
||||||
|
|
||||||
|
assert_eq!(report.accepted_uri_count, 2);
|
||||||
|
assert_eq!(report.first_status.as_deref(), Some("complete"));
|
||||||
|
assert_eq!(report.first_total_length, Some(3));
|
||||||
|
assert_eq!(report.first_completed_length, Some(3));
|
||||||
|
assert_eq!(report.transfer_kinds, vec![TransferSelection::Metalink]);
|
||||||
|
assert_eq!(report.completed_download_count, 2);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(download_dir.join("alpha.bin")).expect("alpha output should exist"),
|
||||||
|
b"abc"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(download_dir.join("beta.bin")).expect("beta output should exist"),
|
||||||
|
b"hello"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct SequencedHttpDownloader {
|
||||||
|
responses: Arc<Mutex<Vec<Result<HttpResponseModel, TransportError>>>>,
|
||||||
|
requests: Arc<Mutex<Vec<aria2_rust_pro_protocol::HttpTransferTaskModel>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LocalFtpTestServer {
|
||||||
|
control_port: u16,
|
||||||
|
join: Option<thread::JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalFtpTestServer {
|
||||||
|
fn spawn(payload: Vec<u8>) -> Self {
|
||||||
|
let control_listener =
|
||||||
|
TcpListener::bind("127.0.0.1:0").expect("control listener should bind");
|
||||||
|
let control_port = control_listener
|
||||||
|
.local_addr()
|
||||||
|
.expect("control addr should exist")
|
||||||
|
.port();
|
||||||
|
let data_listener = TcpListener::bind("127.0.0.1:0").expect("data listener should bind");
|
||||||
|
let data_addr = data_listener.local_addr().expect("data addr should exist");
|
||||||
|
let join = thread::spawn(move || {
|
||||||
|
let (mut control_stream, _) = control_listener
|
||||||
|
.accept()
|
||||||
|
.expect("control connection should arrive");
|
||||||
|
control_stream
|
||||||
|
.write_all(b"220 local ftp ready\r\n")
|
||||||
|
.expect("welcome should write");
|
||||||
|
let mut control_reader =
|
||||||
|
BufReader::new(control_stream.try_clone().expect("clone should work"));
|
||||||
|
loop {
|
||||||
|
let mut line = String::new();
|
||||||
|
let read = control_reader
|
||||||
|
.read_line(&mut line)
|
||||||
|
.expect("control line should read");
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if line.starts_with("USER ") {
|
||||||
|
control_stream
|
||||||
|
.write_all(b"331 password required\r\n")
|
||||||
|
.expect("USER response should write");
|
||||||
|
} else if line.starts_with("PASS ") {
|
||||||
|
control_stream
|
||||||
|
.write_all(b"230 login ok\r\n")
|
||||||
|
.expect("PASS response should write");
|
||||||
|
} else if line.starts_with("TYPE ") {
|
||||||
|
control_stream
|
||||||
|
.write_all(b"200 type ok\r\n")
|
||||||
|
.expect("TYPE response should write");
|
||||||
|
} else if line.starts_with("PASV") {
|
||||||
|
let port_hi = data_addr.port().div_euclid(256);
|
||||||
|
let port_lo = data_addr.port().rem_euclid(256);
|
||||||
|
let response =
|
||||||
|
format!("227 Entering Passive Mode (127,0,0,1,{port_hi},{port_lo})\r\n");
|
||||||
|
control_stream
|
||||||
|
.write_all(response.as_bytes())
|
||||||
|
.expect("PASV response should write");
|
||||||
|
} else if line.starts_with("RETR ") {
|
||||||
|
control_stream
|
||||||
|
.write_all(b"150 opening data\r\n")
|
||||||
|
.expect("RETR prelim response should write");
|
||||||
|
let (mut data_stream, _) = data_listener
|
||||||
|
.accept()
|
||||||
|
.expect("data connection should arrive");
|
||||||
|
data_stream
|
||||||
|
.write_all(&payload)
|
||||||
|
.expect("payload should write");
|
||||||
|
drop(data_stream);
|
||||||
|
control_stream
|
||||||
|
.write_all(b"226 transfer complete\r\n")
|
||||||
|
.expect("RETR completion should write");
|
||||||
|
} else if line.starts_with("QUIT") {
|
||||||
|
control_stream
|
||||||
|
.write_all(b"221 bye\r\n")
|
||||||
|
.expect("QUIT response should write");
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
control_stream
|
||||||
|
.write_all(b"500 unsupported\r\n")
|
||||||
|
.expect("fallback response should write");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Self {
|
||||||
|
control_port,
|
||||||
|
join: Some(join),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn control_port(&self) -> u16 {
|
||||||
|
self.control_port
|
||||||
|
}
|
||||||
|
|
||||||
|
fn join(mut self) {
|
||||||
|
if let Some(join) = self.join.take() {
|
||||||
|
join.join().expect("ftp server thread should join");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LiveFtpSmokeDownloader;
|
||||||
|
struct LiveSftpSmokeDownloader;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct LocalSftpDockerServer {
|
||||||
|
container_name: String,
|
||||||
|
port: u16,
|
||||||
|
payload_len: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalSftpDockerServer {
|
||||||
|
const DOCKER_EXEC_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
const DOCKER_RM_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
const DOCKER_RUN_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
fn spawn() -> Option<Self> {
|
||||||
|
let docker = Command::new("docker")
|
||||||
|
.arg("--version")
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status()
|
||||||
|
.ok()?;
|
||||||
|
if !docker.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let port = Self::pick_port();
|
||||||
|
let container_name = format!("aria2-rust-pro-sftp-smoke-{port}");
|
||||||
|
let payload = "hello-from-live-sftp\n";
|
||||||
|
let payload_len = payload.len();
|
||||||
|
let port_binding = format!("{port}:22");
|
||||||
|
let run = Self::docker_status_with_timeout(
|
||||||
|
&[
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"-d",
|
||||||
|
"--name",
|
||||||
|
&container_name,
|
||||||
|
"-p",
|
||||||
|
&port_binding,
|
||||||
|
"atmoz/sftp:debian",
|
||||||
|
"foo:pass:1001::upload",
|
||||||
|
],
|
||||||
|
Self::DOCKER_RUN_TIMEOUT,
|
||||||
|
)?;
|
||||||
|
if !run.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
for _ in 0..20 {
|
||||||
|
if Self::docker_status_with_timeout(
|
||||||
|
&[
|
||||||
|
"exec",
|
||||||
|
&container_name,
|
||||||
|
"sh",
|
||||||
|
"-lc",
|
||||||
|
"echo 'hello-from-live-sftp' > /home/foo/upload/hello.txt",
|
||||||
|
],
|
||||||
|
Self::DOCKER_EXEC_TIMEOUT,
|
||||||
|
)
|
||||||
|
.is_some_and(|status| status.success())
|
||||||
|
{
|
||||||
|
if Self::wait_until_ready(port) {
|
||||||
|
return Some(Self {
|
||||||
|
container_name,
|
||||||
|
port,
|
||||||
|
payload_len,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(250));
|
||||||
|
}
|
||||||
|
let _ = Self::docker_status_with_timeout(
|
||||||
|
&["rm", "-f", &container_name],
|
||||||
|
Self::DOCKER_RM_TIMEOUT,
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pick_port() -> u16 {
|
||||||
|
TcpListener::bind("127.0.0.1:0")
|
||||||
|
.expect("test should pick local port")
|
||||||
|
.local_addr()
|
||||||
|
.expect("local addr should exist")
|
||||||
|
.port()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn host_port(&self) -> u16 {
|
||||||
|
self.port
|
||||||
|
}
|
||||||
|
|
||||||
|
fn payload_len(&self) -> usize {
|
||||||
|
self.payload_len
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_until_ready(port: u16) -> bool {
|
||||||
|
for _ in 0..40 {
|
||||||
|
if let Ok(stream) = TcpStream::connect(("127.0.0.1", port)) {
|
||||||
|
let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
|
||||||
|
let _ = stream.set_write_timeout(Some(Duration::from_secs(2)));
|
||||||
|
if let Ok(mut session) = ssh2::Session::new() {
|
||||||
|
session.set_tcp_stream(stream);
|
||||||
|
if session.handshake().is_ok()
|
||||||
|
&& session.userauth_password("foo", "pass").is_ok()
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(250));
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn docker_status_with_timeout(args: &[&str], timeout: Duration) -> Option<ExitStatus> {
|
||||||
|
let mut child = Command::new("docker")
|
||||||
|
.args(args)
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
.ok()?;
|
||||||
|
Self::wait_child_with_timeout(&mut child, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_child_with_timeout(child: &mut Child, timeout: Duration) -> Option<ExitStatus> {
|
||||||
|
let started_at = Instant::now();
|
||||||
|
loop {
|
||||||
|
if let Some(status) = child.try_wait().ok()? {
|
||||||
|
return Some(status);
|
||||||
|
}
|
||||||
|
if started_at.elapsed() >= timeout {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for LocalSftpDockerServer {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = Self::docker_status_with_timeout(
|
||||||
|
&["rm", "-f", &self.container_name],
|
||||||
|
Self::DOCKER_RM_TIMEOUT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LiveFtpSmokeDownloader {
|
||||||
|
#[expect(
|
||||||
|
clippy::result_large_err,
|
||||||
|
reason = "test-only live FTP smoke helpers bubble full transport context for assertions"
|
||||||
|
)]
|
||||||
|
fn read_response_line(reader: &mut BufReader<TcpStream>) -> Result<String, TransportError> {
|
||||||
|
let mut line = String::new();
|
||||||
|
reader
|
||||||
|
.read_line(&mut line)
|
||||||
|
.map_err(|error| TransportError {
|
||||||
|
kind: TransportErrorKind::Io,
|
||||||
|
message: format!("failed to read ftp response: {error}"),
|
||||||
|
source: Some(error.to_string()),
|
||||||
|
context: None,
|
||||||
|
})?;
|
||||||
|
Ok(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::result_large_err,
|
||||||
|
reason = "test-only live FTP smoke helpers bubble full transport context for assertions"
|
||||||
|
)]
|
||||||
|
fn expect_code(
|
||||||
|
reader: &mut BufReader<TcpStream>,
|
||||||
|
expected: u16,
|
||||||
|
) -> Result<String, TransportError> {
|
||||||
|
let line = Self::read_response_line(reader)?;
|
||||||
|
let code = line
|
||||||
|
.get(0..3)
|
||||||
|
.and_then(|digits| digits.parse::<u16>().ok())
|
||||||
|
.ok_or_else(|| TransportError {
|
||||||
|
kind: TransportErrorKind::ProtocolViolation,
|
||||||
|
message: format!("invalid ftp response line: {line:?}"),
|
||||||
|
source: None,
|
||||||
|
context: None,
|
||||||
|
})?;
|
||||||
|
if code != expected {
|
||||||
|
return Err(TransportError {
|
||||||
|
kind: TransportErrorKind::ProtocolViolation,
|
||||||
|
message: format!("expected ftp code {expected}, got {code}: {line}"),
|
||||||
|
source: None,
|
||||||
|
context: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::result_large_err,
|
||||||
|
reason = "test-only live FTP smoke helpers bubble full transport context for assertions"
|
||||||
|
)]
|
||||||
|
fn write_command(stream: &mut TcpStream, command: &str) -> Result<(), TransportError> {
|
||||||
|
stream
|
||||||
|
.write_all(command.as_bytes())
|
||||||
|
.map_err(|error| TransportError {
|
||||||
|
kind: TransportErrorKind::Io,
|
||||||
|
message: format!("failed to write ftp command {command:?}: {error}"),
|
||||||
|
source: Some(error.to_string()),
|
||||||
|
context: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::result_large_err,
|
||||||
|
reason = "test-only live FTP smoke helpers bubble full transport context for assertions"
|
||||||
|
)]
|
||||||
|
fn parse_pasv_addr(line: &str) -> Result<(String, u16), TransportError> {
|
||||||
|
let (_, after_open) = line.split_once('(').ok_or_else(|| TransportError {
|
||||||
|
kind: TransportErrorKind::ProtocolViolation,
|
||||||
|
message: format!("missing PASV tuple in response: {line}"),
|
||||||
|
source: None,
|
||||||
|
context: None,
|
||||||
|
})?;
|
||||||
|
let (tuple_text, _) = after_open.split_once(')').ok_or_else(|| TransportError {
|
||||||
|
kind: TransportErrorKind::ProtocolViolation,
|
||||||
|
message: format!("unterminated PASV tuple in response: {line}"),
|
||||||
|
source: None,
|
||||||
|
context: None,
|
||||||
|
})?;
|
||||||
|
let parts = tuple_text
|
||||||
|
.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.map(str::parse::<u16>)
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|error| TransportError {
|
||||||
|
kind: TransportErrorKind::ProtocolViolation,
|
||||||
|
message: format!("invalid PASV tuple in response: {line}: {error}"),
|
||||||
|
source: Some(error.to_string()),
|
||||||
|
context: None,
|
||||||
|
})?;
|
||||||
|
let [a, b, c, d, hi, lo]: [u16; 6] =
|
||||||
|
parts.try_into().map_err(|parts: Vec<u16>| TransportError {
|
||||||
|
kind: TransportErrorKind::ProtocolViolation,
|
||||||
|
message: format!("expected 6 PASV tuple parts, got {}: {line}", parts.len()),
|
||||||
|
source: None,
|
||||||
|
context: None,
|
||||||
|
})?;
|
||||||
|
let host = format!("{a}.{b}.{c}.{d}");
|
||||||
|
let port = hi
|
||||||
|
.checked_mul(256)
|
||||||
|
.and_then(|value| value.checked_add(lo))
|
||||||
|
.ok_or_else(|| TransportError {
|
||||||
|
kind: TransportErrorKind::ProtocolViolation,
|
||||||
|
message: format!("invalid PASV port tuple in response: {line}"),
|
||||||
|
source: None,
|
||||||
|
context: None,
|
||||||
|
})?;
|
||||||
|
Ok((host, port))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Downloader for LiveFtpSmokeDownloader {
|
||||||
|
fn start_http_transfer(
|
||||||
|
&self,
|
||||||
|
_task: &aria2_rust_pro_protocol::HttpTransferTaskModel,
|
||||||
|
) -> Result<HttpResponseModel, TransportError> {
|
||||||
|
Err(TransportError {
|
||||||
|
kind: TransportErrorKind::UnsupportedScheme,
|
||||||
|
message: "http unused in live ftp smoke".to_owned(),
|
||||||
|
source: None,
|
||||||
|
context: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_ftp_transfer(
|
||||||
|
&self,
|
||||||
|
config: &FtpConfigModel,
|
||||||
|
request: &FtpRequestModel,
|
||||||
|
) -> Result<FtpResponseModel, TransportError> {
|
||||||
|
let mut control_stream =
|
||||||
|
TcpStream::connect((config.host.as_str(), config.port)).map_err(|error| {
|
||||||
|
TransportError {
|
||||||
|
kind: TransportErrorKind::NotConnected,
|
||||||
|
message: format!("failed to connect to live ftp smoke server: {error}"),
|
||||||
|
source: Some(error.to_string()),
|
||||||
|
context: None,
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
control_stream
|
||||||
|
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||||
|
.expect("read timeout should set");
|
||||||
|
control_stream
|
||||||
|
.set_write_timeout(Some(Duration::from_secs(5)))
|
||||||
|
.expect("write timeout should set");
|
||||||
|
let mut control_reader =
|
||||||
|
BufReader::new(control_stream.try_clone().expect("clone should work"));
|
||||||
|
|
||||||
|
let _ = Self::expect_code(&mut control_reader, 220)?;
|
||||||
|
Self::write_command(
|
||||||
|
&mut control_stream,
|
||||||
|
&format!(
|
||||||
|
"USER {}\r\n",
|
||||||
|
config.username.as_deref().unwrap_or("anonymous")
|
||||||
|
),
|
||||||
|
)?;
|
||||||
|
let _ = Self::expect_code(&mut control_reader, 331)?;
|
||||||
|
Self::write_command(
|
||||||
|
&mut control_stream,
|
||||||
|
&format!("PASS {}\r\n", config.password.as_deref().unwrap_or("")),
|
||||||
|
)?;
|
||||||
|
let _ = Self::expect_code(&mut control_reader, 230)?;
|
||||||
|
Self::write_command(&mut control_stream, "TYPE I\r\n")?;
|
||||||
|
let _ = Self::expect_code(&mut control_reader, 200)?;
|
||||||
|
Self::write_command(&mut control_stream, "PASV\r\n")?;
|
||||||
|
let pasv = Self::expect_code(&mut control_reader, 227)?;
|
||||||
|
let (data_host, data_port) = Self::parse_pasv_addr(&pasv)?;
|
||||||
|
let mut data_stream =
|
||||||
|
TcpStream::connect((data_host.as_str(), data_port)).map_err(|error| {
|
||||||
|
TransportError {
|
||||||
|
kind: TransportErrorKind::NotConnected,
|
||||||
|
message: format!("failed to connect ftp data socket: {error}"),
|
||||||
|
source: Some(error.to_string()),
|
||||||
|
context: None,
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let retr_path = request.path.as_deref().unwrap_or("/file.bin");
|
||||||
|
Self::write_command(&mut control_stream, &format!("RETR {retr_path}\r\n"))?;
|
||||||
|
let _ = Self::expect_code(&mut control_reader, 150)?;
|
||||||
|
let mut payload = Vec::new();
|
||||||
|
data_stream
|
||||||
|
.read_to_end(&mut payload)
|
||||||
|
.map_err(|error| TransportError {
|
||||||
|
kind: TransportErrorKind::Io,
|
||||||
|
message: format!("failed to read ftp data payload: {error}"),
|
||||||
|
source: Some(error.to_string()),
|
||||||
|
context: None,
|
||||||
|
})?;
|
||||||
|
let completion = Self::expect_code(&mut control_reader, 226)?;
|
||||||
|
let _ = Self::write_command(&mut control_stream, "QUIT\r\n");
|
||||||
|
|
||||||
|
Ok(FtpResponseModel {
|
||||||
|
code: 226,
|
||||||
|
message: completion.trim().to_owned(),
|
||||||
|
data: Some(payload),
|
||||||
|
path: request.path.clone(),
|
||||||
|
transferable: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_sftp_transfer(
|
||||||
|
&self,
|
||||||
|
_config: &SftpConfigModel,
|
||||||
|
_request: &SftpRequestModel,
|
||||||
|
) -> Result<SftpResponseModel, TransportError> {
|
||||||
|
Err(TransportError {
|
||||||
|
kind: TransportErrorKind::UnsupportedScheme,
|
||||||
|
message: "sftp unused in live ftp smoke".to_owned(),
|
||||||
|
source: None,
|
||||||
|
context: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Downloader for LiveSftpSmokeDownloader {
|
||||||
|
fn start_http_transfer(
|
||||||
|
&self,
|
||||||
|
_task: &aria2_rust_pro_protocol::HttpTransferTaskModel,
|
||||||
|
) -> Result<HttpResponseModel, TransportError> {
|
||||||
|
Err(TransportError {
|
||||||
|
kind: TransportErrorKind::UnsupportedScheme,
|
||||||
|
message: "http unused in live sftp smoke".to_owned(),
|
||||||
|
source: None,
|
||||||
|
context: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_ftp_transfer(
|
||||||
|
&self,
|
||||||
|
_config: &FtpConfigModel,
|
||||||
|
_request: &FtpRequestModel,
|
||||||
|
) -> Result<FtpResponseModel, TransportError> {
|
||||||
|
Err(TransportError {
|
||||||
|
kind: TransportErrorKind::UnsupportedScheme,
|
||||||
|
message: "ftp unused in live sftp smoke".to_owned(),
|
||||||
|
source: None,
|
||||||
|
context: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_sftp_transfer(
|
||||||
|
&self,
|
||||||
|
config: &SftpConfigModel,
|
||||||
|
request: &SftpRequestModel,
|
||||||
|
) -> Result<SftpResponseModel, TransportError> {
|
||||||
|
let tcp = TcpStream::connect((config.host.as_str(), config.port)).map_err(|error| {
|
||||||
|
TransportError {
|
||||||
|
kind: TransportErrorKind::NotConnected,
|
||||||
|
message: format!("failed to connect live sftp smoke server: {error}"),
|
||||||
|
source: Some(error.to_string()),
|
||||||
|
context: None,
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
tcp.set_read_timeout(Some(Duration::from_secs(8))).ok();
|
||||||
|
tcp.set_write_timeout(Some(Duration::from_secs(8))).ok();
|
||||||
|
|
||||||
|
let mut session = ssh2::Session::new().map_err(|error| TransportError {
|
||||||
|
kind: TransportErrorKind::ProtocolViolation,
|
||||||
|
message: format!("failed to build ssh2 session: {error}"),
|
||||||
|
source: Some(error.to_string()),
|
||||||
|
context: None,
|
||||||
|
})?;
|
||||||
|
session.set_tcp_stream(tcp);
|
||||||
|
session.handshake().map_err(|error| TransportError {
|
||||||
|
kind: TransportErrorKind::ProtocolViolation,
|
||||||
|
message: format!("ssh handshake failed: {error}"),
|
||||||
|
source: Some(error.to_string()),
|
||||||
|
context: None,
|
||||||
|
})?;
|
||||||
|
let user = config.username.as_deref().unwrap_or("foo");
|
||||||
|
let pass = config.password.as_deref().unwrap_or("pass");
|
||||||
|
session
|
||||||
|
.userauth_password(user, pass)
|
||||||
|
.map_err(|error| TransportError {
|
||||||
|
kind: TransportErrorKind::ProtocolViolation,
|
||||||
|
message: format!("ssh auth failed: {error}"),
|
||||||
|
source: Some(error.to_string()),
|
||||||
|
context: None,
|
||||||
|
})?;
|
||||||
|
let sftp = session.sftp().map_err(|error| TransportError {
|
||||||
|
kind: TransportErrorKind::ProtocolViolation,
|
||||||
|
message: format!("failed to start sftp subsystem: {error}"),
|
||||||
|
source: Some(error.to_string()),
|
||||||
|
context: None,
|
||||||
|
})?;
|
||||||
|
let path = request.path.as_deref().unwrap_or("/upload/hello.txt");
|
||||||
|
let mut remote_file = sftp.open(path).map_err(|error| TransportError {
|
||||||
|
kind: TransportErrorKind::Io,
|
||||||
|
message: format!("failed to open remote path {path}: {error}"),
|
||||||
|
source: Some(error.to_string()),
|
||||||
|
context: None,
|
||||||
|
})?;
|
||||||
|
let mut payload = Vec::new();
|
||||||
|
remote_file
|
||||||
|
.read_to_end(&mut payload)
|
||||||
|
.map_err(|error| TransportError {
|
||||||
|
kind: TransportErrorKind::Io,
|
||||||
|
message: format!("failed to read remote payload: {error}"),
|
||||||
|
source: Some(error.to_string()),
|
||||||
|
context: None,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(SftpResponseModel {
|
||||||
|
ok: true,
|
||||||
|
message: "sftp read ok".to_owned(),
|
||||||
|
payload: Some(payload),
|
||||||
|
path: request.path.clone(),
|
||||||
|
transferable: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SequencedHttpDownloader {
|
||||||
|
fn new(responses: Vec<Result<HttpResponseModel, TransportError>>) -> Self {
|
||||||
|
Self {
|
||||||
|
responses: Arc::new(Mutex::new(responses)),
|
||||||
|
requests: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recorded_requests(&self) -> Vec<aria2_rust_pro_protocol::HttpTransferTaskModel> {
|
||||||
|
self.requests.lock().expect("lock should work").clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Downloader for SequencedHttpDownloader {
|
||||||
|
fn start_http_transfer(
|
||||||
|
&self,
|
||||||
|
task: &aria2_rust_pro_protocol::HttpTransferTaskModel,
|
||||||
|
) -> Result<HttpResponseModel, TransportError> {
|
||||||
|
self.requests
|
||||||
|
.lock()
|
||||||
|
.expect("lock should work")
|
||||||
|
.push(task.clone());
|
||||||
|
let mut guard = self.responses.lock().expect("lock should work");
|
||||||
|
if guard.is_empty() {
|
||||||
|
return Err(TransportError {
|
||||||
|
kind: TransportErrorKind::Io,
|
||||||
|
message: "no scripted HTTP response remaining".to_owned(),
|
||||||
|
source: None,
|
||||||
|
context: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
guard.remove(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_ftp_transfer(
|
||||||
|
&self,
|
||||||
|
_config: &FtpConfigModel,
|
||||||
|
_request: &FtpRequestModel,
|
||||||
|
) -> Result<FtpResponseModel, TransportError> {
|
||||||
|
Err(TransportError {
|
||||||
|
kind: TransportErrorKind::UnsupportedScheme,
|
||||||
|
message: "unused in test".to_owned(),
|
||||||
|
source: None,
|
||||||
|
context: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_sftp_transfer(
|
||||||
|
&self,
|
||||||
|
_config: &SftpConfigModel,
|
||||||
|
_request: &SftpRequestModel,
|
||||||
|
) -> Result<SftpResponseModel, TransportError> {
|
||||||
|
Err(TransportError {
|
||||||
|
kind: TransportErrorKind::UnsupportedScheme,
|
||||||
|
message: "unused in test".to_owned(),
|
||||||
|
source: None,
|
||||||
|
context: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct ConcurrentProbeDownloader {
|
||||||
|
bootstrap: HttpResponseModel,
|
||||||
|
bootstrap_delay: Duration,
|
||||||
|
ranged_responses: Arc<Mutex<VecDeque<(u64, HttpResponseModel)>>>,
|
||||||
|
requests: Arc<Mutex<Vec<aria2_rust_pro_protocol::HttpTransferTaskModel>>>,
|
||||||
|
active_calls: Arc<AtomicUsize>,
|
||||||
|
max_concurrent_calls: Arc<AtomicUsize>,
|
||||||
|
ranged_delay: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConcurrentProbeDownloader {
|
||||||
|
fn new(
|
||||||
|
bootstrap: HttpResponseModel,
|
||||||
|
ranged_responses: Vec<(u64, HttpResponseModel)>,
|
||||||
|
ranged_delay: Duration,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
bootstrap,
|
||||||
|
bootstrap_delay: Duration::ZERO,
|
||||||
|
ranged_responses: Arc::new(Mutex::new(VecDeque::from(ranged_responses))),
|
||||||
|
requests: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
active_calls: Arc::new(AtomicUsize::new(0)),
|
||||||
|
max_concurrent_calls: Arc::new(AtomicUsize::new(0)),
|
||||||
|
ranged_delay,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_bootstrap_delay(
|
||||||
|
bootstrap: HttpResponseModel,
|
||||||
|
bootstrap_delay: Duration,
|
||||||
|
ranged_responses: Vec<(u64, HttpResponseModel)>,
|
||||||
|
ranged_delay: Duration,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
bootstrap,
|
||||||
|
bootstrap_delay,
|
||||||
|
ranged_responses: Arc::new(Mutex::new(VecDeque::from(ranged_responses))),
|
||||||
|
requests: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
active_calls: Arc::new(AtomicUsize::new(0)),
|
||||||
|
max_concurrent_calls: Arc::new(AtomicUsize::new(0)),
|
||||||
|
ranged_delay,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recorded_requests(&self) -> Vec<aria2_rust_pro_protocol::HttpTransferTaskModel> {
|
||||||
|
self.requests.lock().expect("lock should work").clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn max_concurrent_calls(&self) -> usize {
|
||||||
|
self.max_concurrent_calls.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn note_active_call(&self) {
|
||||||
|
let current = self
|
||||||
|
.active_calls
|
||||||
|
.fetch_add(1, Ordering::SeqCst)
|
||||||
|
.saturating_add(1);
|
||||||
|
let _ =
|
||||||
|
self.max_concurrent_calls
|
||||||
|
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |seen| {
|
||||||
|
(current > seen).then_some(current)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_active_call(&self) {
|
||||||
|
self.active_calls.fetch_sub(1, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Downloader for ConcurrentProbeDownloader {
|
||||||
|
fn start_http_transfer(
|
||||||
|
&self,
|
||||||
|
task: &aria2_rust_pro_protocol::HttpTransferTaskModel,
|
||||||
|
) -> Result<HttpResponseModel, TransportError> {
|
||||||
|
self.requests
|
||||||
|
.lock()
|
||||||
|
.expect("lock should work")
|
||||||
|
.push(task.clone());
|
||||||
|
self.note_active_call();
|
||||||
|
|
||||||
|
let response = task.request.range.as_ref().map_or_else(
|
||||||
|
|| {
|
||||||
|
if !self.bootstrap_delay.is_zero() {
|
||||||
|
thread::sleep(self.bootstrap_delay);
|
||||||
|
}
|
||||||
|
Ok(self.bootstrap.clone())
|
||||||
|
},
|
||||||
|
|range| {
|
||||||
|
thread::sleep(self.ranged_delay);
|
||||||
|
let mut queued = self.ranged_responses.lock().expect("lock should work");
|
||||||
|
let index = queued
|
||||||
|
.iter()
|
||||||
|
.position(|(start, _)| *start == range.start)
|
||||||
|
.expect("matching ranged response should exist");
|
||||||
|
Ok(queued
|
||||||
|
.remove(index)
|
||||||
|
.expect("queued ranged response should exist")
|
||||||
|
.1)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
self.finish_active_call();
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_ftp_transfer(
|
||||||
|
&self,
|
||||||
|
_config: &FtpConfigModel,
|
||||||
|
_request: &FtpRequestModel,
|
||||||
|
) -> Result<FtpResponseModel, TransportError> {
|
||||||
|
Err(TransportError {
|
||||||
|
kind: TransportErrorKind::UnsupportedScheme,
|
||||||
|
message: "probe downloader does not implement ftp".to_owned(),
|
||||||
|
source: None,
|
||||||
|
context: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_sftp_transfer(
|
||||||
|
&self,
|
||||||
|
_config: &SftpConfigModel,
|
||||||
|
_request: &SftpRequestModel,
|
||||||
|
) -> Result<SftpResponseModel, TransportError> {
|
||||||
|
Err(TransportError {
|
||||||
|
kind: TransportErrorKind::UnsupportedScheme,
|
||||||
|
message: "probe downloader does not implement sftp".to_owned(),
|
||||||
|
source: None,
|
||||||
|
context: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod command_surface;
|
||||||
|
mod integration_surface;
|
||||||
|
mod runtime_execution;
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_version_switch() {
|
||||||
|
let invocation = parse_args(vec![
|
||||||
|
OsString::from("aria2-rust-pro"),
|
||||||
|
OsString::from("--version"),
|
||||||
|
])
|
||||||
|
.expect("version should parse");
|
||||||
|
assert_eq!(invocation, Invocation::Version);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_help_switch() {
|
||||||
|
let invocation = parse_args(vec![
|
||||||
|
OsString::from("aria2-rust-pro"),
|
||||||
|
OsString::from("--help"),
|
||||||
|
])
|
||||||
|
.expect("help should parse");
|
||||||
|
assert_eq!(invocation, Invocation::Help { query: None });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_help_filter_switch() {
|
||||||
|
let invocation = parse_args(vec![
|
||||||
|
OsString::from("aria2-rust-pro"),
|
||||||
|
OsString::from("--help=#http"),
|
||||||
|
])
|
||||||
|
.expect("filtered help should parse");
|
||||||
|
assert_eq!(
|
||||||
|
invocation,
|
||||||
|
Invocation::Help {
|
||||||
|
query: Some("#http".to_owned())
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_config_path_and_uris() {
|
||||||
|
let invocation = parse_args(vec![
|
||||||
|
OsString::from("aria2-rust-pro"),
|
||||||
|
OsString::from("--conf-path"),
|
||||||
|
OsString::from("aria2.conf"),
|
||||||
|
OsString::from("https://example.com/file"),
|
||||||
|
])
|
||||||
|
.expect("config args should parse");
|
||||||
|
match invocation {
|
||||||
|
Invocation::Run { config_path, uris } => {
|
||||||
|
assert_eq!(
|
||||||
|
config_path.as_deref(),
|
||||||
|
Some(std::path::Path::new("aria2.conf"))
|
||||||
|
);
|
||||||
|
assert_eq!(uris, vec!["https://example.com/file"]);
|
||||||
|
}
|
||||||
|
other => panic!("unexpected invocation: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renderers_include_the_product_name() {
|
||||||
|
assert!(render_help(None).contains("Usage: aria2c [OPTIONS]"));
|
||||||
|
assert!(render_help(Some("#http")).contains("Printing options tagged with \"#http\"."));
|
||||||
|
assert!(render_version().contains("aria2-rust-pro"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_reads_configuration_files() {
|
||||||
|
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-test");
|
||||||
|
fs::create_dir_all(&temp_dir).expect("temp dir should be creatable");
|
||||||
|
let config_path = temp_dir.join("aria2.conf");
|
||||||
|
fs::write(&config_path, "max-connection-per-server=4\n").expect("config should be writable");
|
||||||
|
let invocation = Invocation::Run {
|
||||||
|
config_path: Some(config_path),
|
||||||
|
uris: Vec::new(),
|
||||||
|
};
|
||||||
|
execute(invocation).expect("config should execute");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unknown_options() {
|
||||||
|
let error = parse_args(vec![
|
||||||
|
OsString::from("aria2-rust-pro"),
|
||||||
|
OsString::from("--unknown-option"),
|
||||||
|
])
|
||||||
|
.expect_err("unknown options should fail");
|
||||||
|
assert!(matches!(error, CliError::UnknownOption(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_tracks_uris_through_rpc_and_core() {
|
||||||
|
let report = execute_runtime(Invocation::Run {
|
||||||
|
config_path: None,
|
||||||
|
uris: vec![
|
||||||
|
"https://example.org/file.iso".to_owned(),
|
||||||
|
"http://example.org/file-2.iso".to_owned(),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.expect("runtime should execute");
|
||||||
|
|
||||||
|
assert_eq!(report.accepted_uri_count, 2);
|
||||||
|
assert_eq!(report.tracked_download_count, 2);
|
||||||
|
assert_eq!(report.first_status.as_deref(), Some("error"));
|
||||||
|
assert_eq!(report.recognized_schemes, vec!["https", "http"]);
|
||||||
|
assert_eq!(
|
||||||
|
report.transfer_kinds,
|
||||||
|
vec![TransferSelection::Uri, TransferSelection::Uri]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_with_fixture_http_marks_download_complete() {
|
||||||
|
let mut downloader = FixtureHttpDownloader::new();
|
||||||
|
downloader.register("http://example.com/file.bin", b"fixture-body");
|
||||||
|
|
||||||
|
let report = execute_runtime_with_downloader(
|
||||||
|
Invocation::Run {
|
||||||
|
config_path: None,
|
||||||
|
uris: vec!["http://example.com/file.bin".to_owned()],
|
||||||
|
},
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should execute with fixture downloader");
|
||||||
|
|
||||||
|
assert_eq!(report.accepted_uri_count, 1);
|
||||||
|
assert_eq!(report.tracked_download_count, 1);
|
||||||
|
assert_eq!(report.first_status.as_deref(), Some("complete"));
|
||||||
|
assert_eq!(report.first_total_length, Some(12));
|
||||||
|
assert_eq!(report.first_completed_length, Some(12));
|
||||||
|
assert_eq!(report.first_connections, Some(1));
|
||||||
|
assert_eq!(report.recognized_schemes, vec!["http"]);
|
||||||
|
assert_eq!(report.completed_download_count, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_runs_multiple_http_uris_concurrently_in_one_runtime() {
|
||||||
|
let downloader = ConcurrentProbeDownloader::with_bootstrap_delay(
|
||||||
|
HttpResponseModel {
|
||||||
|
status: 200,
|
||||||
|
reason: "OK".to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: vec![aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-length".to_owned(),
|
||||||
|
value: "4".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
body: ResponseBody::Inline(b"test".to_vec()),
|
||||||
|
content_range: None,
|
||||||
|
partial_content: false,
|
||||||
|
checksum: None,
|
||||||
|
redirected_from: None,
|
||||||
|
},
|
||||||
|
Duration::from_millis(80),
|
||||||
|
Vec::new(),
|
||||||
|
Duration::ZERO,
|
||||||
|
);
|
||||||
|
|
||||||
|
let report = execute_runtime_with_downloader(
|
||||||
|
Invocation::Run {
|
||||||
|
config_path: None,
|
||||||
|
uris: vec![
|
||||||
|
"http://example.com/parallel-a.bin".to_owned(),
|
||||||
|
"http://example.com/parallel-b.bin".to_owned(),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should execute multiple http uris in one runtime");
|
||||||
|
|
||||||
|
assert_eq!(report.accepted_uri_count, 2);
|
||||||
|
assert_eq!(report.tracked_download_count, 2);
|
||||||
|
assert_eq!(report.completed_download_count, 2);
|
||||||
|
assert_eq!(report.first_status.as_deref(), Some("complete"));
|
||||||
|
assert!(
|
||||||
|
downloader.max_concurrent_calls() >= 2,
|
||||||
|
"multi-uri same-runtime execution should overlap bootstrap HTTP transfers"
|
||||||
|
);
|
||||||
|
assert_eq!(downloader.recorded_requests().len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_overlaps_registered_parallel_http_bootstrap_fanout() {
|
||||||
|
let downloader = ConcurrentProbeDownloader::with_bootstrap_delay(
|
||||||
|
HttpResponseModel {
|
||||||
|
status: 200,
|
||||||
|
reason: "OK".to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: vec![aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-length".to_owned(),
|
||||||
|
value: "4".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
body: ResponseBody::Inline(b"test".to_vec()),
|
||||||
|
content_range: None,
|
||||||
|
partial_content: false,
|
||||||
|
checksum: None,
|
||||||
|
redirected_from: None,
|
||||||
|
},
|
||||||
|
Duration::from_millis(80),
|
||||||
|
Vec::new(),
|
||||||
|
Duration::ZERO,
|
||||||
|
);
|
||||||
|
|
||||||
|
let report = execute_runtime_with_downloader(
|
||||||
|
Invocation::Run {
|
||||||
|
config_path: None,
|
||||||
|
uris: vec![
|
||||||
|
"http://example.com/capped-a.bin".to_owned(),
|
||||||
|
"http://example.com/capped-b.bin".to_owned(),
|
||||||
|
"http://example.com/capped-c.bin".to_owned(),
|
||||||
|
"http://example.com/capped-d.bin".to_owned(),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should execute capped parallel http bootstrap in one runtime");
|
||||||
|
|
||||||
|
assert_eq!(report.accepted_uri_count, 4);
|
||||||
|
assert_eq!(report.tracked_download_count, 4);
|
||||||
|
assert_eq!(report.completed_download_count, 4);
|
||||||
|
assert_eq!(report.first_status.as_deref(), Some("complete"));
|
||||||
|
assert_eq!(
|
||||||
|
downloader.max_concurrent_calls(),
|
||||||
|
4,
|
||||||
|
"bootstrap http fanout should use the registered same-runtime HTTP work set"
|
||||||
|
);
|
||||||
|
assert_eq!(downloader.recorded_requests().len(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn static_http_work_partition_uses_available_parallelism_evenly() {
|
||||||
|
let chunks = partition_indexed_work_evenly((0..6).map(|index| (index, index)), 6, 5);
|
||||||
|
assert_eq!(chunks.len(), 5);
|
||||||
|
assert_eq!(
|
||||||
|
chunks.iter().map(Vec::len).max(),
|
||||||
|
Some(2),
|
||||||
|
"six tasks over five workers should not collapse to three two-item workers"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_with_fixture_ftp_marks_download_complete() {
|
||||||
|
let mut downloader = FixtureHttpDownloader::new();
|
||||||
|
downloader.register_ftp(
|
||||||
|
"ftp://example.com:21/file.bin",
|
||||||
|
FtpResponseModel {
|
||||||
|
code: 226,
|
||||||
|
message: "transfer complete".to_owned(),
|
||||||
|
data: Some(b"ftp-payload".to_vec()),
|
||||||
|
path: Some("/file.bin".to_owned()),
|
||||||
|
transferable: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let report = execute_runtime_with_downloader(
|
||||||
|
Invocation::Run {
|
||||||
|
config_path: None,
|
||||||
|
uris: vec!["ftp://example.com/file.bin".to_owned()],
|
||||||
|
},
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should execute with ftp fixture downloader");
|
||||||
|
|
||||||
|
assert_eq!(report.accepted_uri_count, 1);
|
||||||
|
assert_eq!(report.first_status.as_deref(), Some("complete"));
|
||||||
|
assert_eq!(report.first_total_length, Some(11));
|
||||||
|
assert_eq!(report.first_completed_length, Some(11));
|
||||||
|
assert_eq!(report.recognized_schemes, vec!["ftp"]);
|
||||||
|
assert_eq!(report.completed_download_count, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_with_live_ftp_server_marks_download_complete() {
|
||||||
|
let payload = b"live-ftp-payload".to_vec();
|
||||||
|
let ftp_server = LocalFtpTestServer::spawn(payload.clone());
|
||||||
|
let downloader = LiveFtpSmokeDownloader;
|
||||||
|
|
||||||
|
let report = execute_runtime_with_downloader(
|
||||||
|
Invocation::Run {
|
||||||
|
config_path: None,
|
||||||
|
uris: vec![format!(
|
||||||
|
"ftp://user:pass@127.0.0.1:{}/file.bin",
|
||||||
|
ftp_server.control_port()
|
||||||
|
)],
|
||||||
|
},
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should execute with live ftp server");
|
||||||
|
|
||||||
|
let payload_len = u64::try_from(payload.len()).expect("payload length should fit in u64");
|
||||||
|
assert_eq!(report.accepted_uri_count, 1);
|
||||||
|
assert_eq!(report.first_status.as_deref(), Some("complete"));
|
||||||
|
assert_eq!(report.first_total_length, Some(payload_len));
|
||||||
|
assert_eq!(report.first_completed_length, Some(payload_len));
|
||||||
|
assert_eq!(report.recognized_schemes, vec!["ftp"]);
|
||||||
|
assert_eq!(report.completed_download_count, 1);
|
||||||
|
|
||||||
|
ftp_server.join();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_with_fixture_sftp_marks_download_complete() {
|
||||||
|
let mut downloader = FixtureHttpDownloader::new();
|
||||||
|
downloader.register_sftp(
|
||||||
|
"sftp://example.com:22/file.bin",
|
||||||
|
SftpResponseModel {
|
||||||
|
ok: true,
|
||||||
|
message: "read ok".to_owned(),
|
||||||
|
payload: Some(b"sftp-payload".to_vec()),
|
||||||
|
path: Some("/file.bin".to_owned()),
|
||||||
|
transferable: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let report = execute_runtime_with_downloader(
|
||||||
|
Invocation::Run {
|
||||||
|
config_path: None,
|
||||||
|
uris: vec!["sftp://example.com/file.bin".to_owned()],
|
||||||
|
},
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should execute with sftp fixture downloader");
|
||||||
|
|
||||||
|
assert_eq!(report.accepted_uri_count, 1);
|
||||||
|
assert_eq!(report.first_status.as_deref(), Some("complete"));
|
||||||
|
assert_eq!(report.first_total_length, Some(12));
|
||||||
|
assert_eq!(report.first_completed_length, Some(12));
|
||||||
|
assert_eq!(report.recognized_schemes, vec!["sftp"]);
|
||||||
|
assert_eq!(report.completed_download_count, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_with_live_sftp_server_marks_download_complete() {
|
||||||
|
let Some(server) = LocalSftpDockerServer::spawn() else {
|
||||||
|
eprintln!("skipping live sftp smoke: docker unavailable");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let downloader = LiveSftpSmokeDownloader;
|
||||||
|
let expected_len = server.payload_len();
|
||||||
|
|
||||||
|
let report = execute_runtime_with_downloader(
|
||||||
|
Invocation::Run {
|
||||||
|
config_path: None,
|
||||||
|
uris: vec![format!(
|
||||||
|
"sftp://foo:pass@127.0.0.1:{}/upload/hello.txt",
|
||||||
|
server.host_port()
|
||||||
|
)],
|
||||||
|
},
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should execute with live sftp server");
|
||||||
|
|
||||||
|
let expected_len = u64::try_from(expected_len).expect("payload length should fit in u64");
|
||||||
|
assert_eq!(report.accepted_uri_count, 1);
|
||||||
|
assert_eq!(report.first_status.as_deref(), Some("complete"));
|
||||||
|
assert_eq!(report.first_total_length, Some(expected_len));
|
||||||
|
assert_eq!(report.first_completed_length, Some(expected_len));
|
||||||
|
assert_eq!(report.recognized_schemes, vec!["sftp"]);
|
||||||
|
assert_eq!(report.completed_download_count, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_with_local_metalink_fixture_http_marks_download_complete() {
|
||||||
|
let mut downloader = FixtureHttpDownloader::new();
|
||||||
|
downloader.register("http://example.com/file.bin", b"metalink-body");
|
||||||
|
|
||||||
|
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-metalink-runtime-test");
|
||||||
|
let _ = fs::create_dir_all(&temp_dir);
|
||||||
|
let metalink_path = temp_dir.join("fixture.meta4");
|
||||||
|
fs::write(
|
||||||
|
&metalink_path,
|
||||||
|
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<metalink version="4.0">
|
||||||
|
<file name="file.bin">
|
||||||
|
<size>13</size>
|
||||||
|
<url priority="1">http://example.com/file.bin</url>
|
||||||
|
</file>
|
||||||
|
</metalink>"#,
|
||||||
|
)
|
||||||
|
.expect("metalink file should write");
|
||||||
|
|
||||||
|
let report = execute_runtime_with_downloader(
|
||||||
|
Invocation::Run {
|
||||||
|
config_path: None,
|
||||||
|
uris: vec![metalink_path.to_string_lossy().into_owned()],
|
||||||
|
},
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should execute with local metalink fixture");
|
||||||
|
|
||||||
|
assert_eq!(report.accepted_uri_count, 1);
|
||||||
|
assert_eq!(report.first_status.as_deref(), Some("complete"));
|
||||||
|
assert_eq!(report.first_total_length, Some(13));
|
||||||
|
assert_eq!(report.first_completed_length, Some(13));
|
||||||
|
assert_eq!(report.transfer_kinds, vec![TransferSelection::Metalink]);
|
||||||
|
assert_eq!(report.completed_download_count, 1);
|
||||||
|
|
||||||
|
let _ = fs::remove_file(metalink_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_with_local_metalink_fixture_prefers_protocol_candidate_resource() {
|
||||||
|
let mut downloader = FixtureHttpDownloader::new();
|
||||||
|
downloader.register("http://example.com/preferred.bin", b"preferred-body");
|
||||||
|
|
||||||
|
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-metalink-preferred-test");
|
||||||
|
let _ = fs::create_dir_all(&temp_dir);
|
||||||
|
let metalink_path = temp_dir.join("fixture.meta4");
|
||||||
|
fs::write(
|
||||||
|
&metalink_path,
|
||||||
|
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<metalink version="4.0">
|
||||||
|
<file name="ignored.bin">
|
||||||
|
<url priority="1"></url>
|
||||||
|
</file>
|
||||||
|
<file name="picked.bin">
|
||||||
|
<size>14</size>
|
||||||
|
<url priority="2">http://example.com/first.bin</url>
|
||||||
|
<url priority="1">http://example.com/preferred.bin</url>
|
||||||
|
</file>
|
||||||
|
</metalink>"#,
|
||||||
|
)
|
||||||
|
.expect("metalink file should write");
|
||||||
|
|
||||||
|
let report = execute_runtime_with_downloader(
|
||||||
|
Invocation::Run {
|
||||||
|
config_path: None,
|
||||||
|
uris: vec![metalink_path.to_string_lossy().into_owned()],
|
||||||
|
},
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should execute with preferred metalink resource");
|
||||||
|
|
||||||
|
assert_eq!(report.accepted_uri_count, 1);
|
||||||
|
assert_eq!(report.first_status.as_deref(), Some("complete"));
|
||||||
|
assert_eq!(report.first_total_length, Some(14));
|
||||||
|
assert_eq!(report.first_completed_length, Some(14));
|
||||||
|
assert_eq!(report.transfer_kinds, vec![TransferSelection::Metalink]);
|
||||||
|
assert_eq!(report.completed_download_count, 1);
|
||||||
|
|
||||||
|
let _ = fs::remove_file(metalink_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_with_remote_metalink_fixture_fetches_document_then_downloads_resource() {
|
||||||
|
let mut downloader = FixtureHttpDownloader::new();
|
||||||
|
downloader.register(
|
||||||
|
"http://example.com/doc.meta4",
|
||||||
|
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<metalink version="4.0">
|
||||||
|
<file name="remote.bin">
|
||||||
|
<size>11</size>
|
||||||
|
<url priority="1">http://example.com/remote.bin</url>
|
||||||
|
</file>
|
||||||
|
</metalink>"#,
|
||||||
|
);
|
||||||
|
downloader.register("http://example.com/remote.bin", b"remote-body");
|
||||||
|
|
||||||
|
let report = execute_runtime_with_downloader(
|
||||||
|
Invocation::Run {
|
||||||
|
config_path: None,
|
||||||
|
uris: vec!["http://example.com/doc.meta4".to_owned()],
|
||||||
|
},
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should execute with remote metalink fixture");
|
||||||
|
|
||||||
|
assert_eq!(report.accepted_uri_count, 1);
|
||||||
|
assert_eq!(report.first_status.as_deref(), Some("complete"));
|
||||||
|
assert_eq!(report.first_total_length, Some(11));
|
||||||
|
assert_eq!(report.first_completed_length, Some(11));
|
||||||
|
assert_eq!(report.transfer_kinds, vec![TransferSelection::Metalink]);
|
||||||
|
assert_eq!(report.recognized_schemes, vec!["http"]);
|
||||||
|
assert_eq!(report.completed_download_count, 1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
pub(super) use super::*;
|
||||||
|
pub(super) use crate::{args, derive_rpc_listen_host};
|
||||||
|
|
||||||
|
mod bt_runtime_and_tracker;
|
||||||
|
mod cli_overrides_and_proxy;
|
||||||
|
mod config_and_protocol_surface;
|
||||||
|
mod http_runtime_and_checksum;
|
||||||
|
mod input_file_and_source_order;
|
||||||
|
mod rpc_pressure_and_command_surface;
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_reports_bt_status_snapshot_for_magnet_inputs() {
|
||||||
|
let report = execute_runtime(Invocation::Run {
|
||||||
|
config_path: None,
|
||||||
|
uris: vec![String::from(
|
||||||
|
"magnet:?xt=urn:btih:00112233445566778899aabbccddeeff00112233&dn=bt-dht.iso&tr=http%3A%2F%2Ftracker.example.org%2Fannounce&tr=udp%3A%2F%2Ftracker.example.org%3A6969&x.pe=198.51.100.9%3A51413",
|
||||||
|
)],
|
||||||
|
})
|
||||||
|
.expect("runtime should execute for a bt magnet");
|
||||||
|
|
||||||
|
assert_eq!(report.accepted_uri_count, 1);
|
||||||
|
assert_eq!(report.tracked_download_count, 1);
|
||||||
|
assert_eq!(report.transfer_kinds, vec![TransferSelection::Magnet]);
|
||||||
|
assert_eq!(report.recognized_schemes, vec![String::from("magnet")]);
|
||||||
|
|
||||||
|
let bt = report
|
||||||
|
.first_bt_status
|
||||||
|
.as_ref()
|
||||||
|
.expect("bt status snapshot should exist");
|
||||||
|
assert_eq!(bt.is_bt, Some(true));
|
||||||
|
assert_eq!(bt.metadata_only, Some(true));
|
||||||
|
assert_eq!(bt.share_time, Some(0));
|
||||||
|
assert_eq!(bt.share_ratio.as_deref(), Some("0.000"));
|
||||||
|
assert_eq!(bt.share_ratio_progress.as_deref(), Some("0.000"));
|
||||||
|
assert_eq!(bt.share_ratio_remaining.as_deref(), Some("0.000"));
|
||||||
|
assert_eq!(bt.num_seeders, Some(0));
|
||||||
|
assert!(
|
||||||
|
bt.announce_list_tier_count.unwrap_or_default() >= 1,
|
||||||
|
"magnet inputs should retain announce tiers in the cli-visible bt snapshot"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(bt.magnet_uri.as_deref(), Some(uri) if uri.starts_with("magnet:?xt=urn:btih:")),
|
||||||
|
"runtime report should preserve the canonical magnet uri"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_with_magnet_executes_live_tracker_announce_when_available() {
|
||||||
|
let (tracker_url, handle) = start_live_bt_tracker_fixture();
|
||||||
|
let magnet_uri = format!(
|
||||||
|
"magnet:?xt=urn:btih:00112233445566778899aabbccddeeff00112233&dn=live-tracker.iso&tr={}",
|
||||||
|
percent_encode_uri_component(&tracker_url)
|
||||||
|
);
|
||||||
|
|
||||||
|
let report = execute_runtime(Invocation::Run {
|
||||||
|
config_path: None,
|
||||||
|
uris: vec![magnet_uri],
|
||||||
|
})
|
||||||
|
.expect("runtime should execute live tracker-backed magnet orchestration");
|
||||||
|
|
||||||
|
assert_eq!(report.accepted_uri_count, 1);
|
||||||
|
assert_eq!(report.tracked_download_count, 1);
|
||||||
|
assert_eq!(report.first_connections, Some(1));
|
||||||
|
let bt = report
|
||||||
|
.first_bt_status
|
||||||
|
.as_ref()
|
||||||
|
.expect("bt status snapshot should exist for tracker-backed magnet");
|
||||||
|
assert_eq!(bt.is_bt, Some(true));
|
||||||
|
assert_eq!(bt.metadata_only, Some(true));
|
||||||
|
assert_eq!(bt.num_seeders, Some(4));
|
||||||
|
assert!(
|
||||||
|
bt.announce_list_tier_count.unwrap_or_default() >= 1,
|
||||||
|
"tracker-backed magnet should retain announce tiers"
|
||||||
|
);
|
||||||
|
|
||||||
|
handle.join().expect("tracker server thread should join");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_with_remote_torrent_url_fetches_payload_and_executes_live_tracker_announce() {
|
||||||
|
let (tracker_url, handle) = start_live_bt_tracker_fixture();
|
||||||
|
let torrent_bytes =
|
||||||
|
build_single_file_torrent_bytes(&tracker_url, "live-tracker.iso", 16_384, 16_384);
|
||||||
|
let mut downloader = FixtureHttpDownloader::new();
|
||||||
|
downloader.register("http://example.com/live-tracker.torrent", &torrent_bytes);
|
||||||
|
|
||||||
|
let report = execute_runtime_with_downloader(
|
||||||
|
Invocation::Run {
|
||||||
|
config_path: None,
|
||||||
|
uris: vec!["http://example.com/live-tracker.torrent".to_owned()],
|
||||||
|
},
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should fetch torrent payload then execute live tracker announce");
|
||||||
|
|
||||||
|
assert_eq!(report.accepted_uri_count, 1);
|
||||||
|
assert_eq!(report.tracked_download_count, 1);
|
||||||
|
assert_eq!(report.transfer_kinds, vec![TransferSelection::Torrent]);
|
||||||
|
assert_eq!(report.first_connections, Some(1));
|
||||||
|
let bt = report
|
||||||
|
.first_bt_status
|
||||||
|
.as_ref()
|
||||||
|
.expect("bt status snapshot should exist for remote torrent input");
|
||||||
|
assert_eq!(bt.is_bt, Some(true));
|
||||||
|
assert_eq!(bt.metadata_only, Some(false));
|
||||||
|
assert_eq!(bt.num_seeders, Some(4));
|
||||||
|
assert!(
|
||||||
|
bt.announce_list_tier_count.unwrap_or_default() >= 1,
|
||||||
|
"remote torrent input should register announce tiers through addTorrent"
|
||||||
|
);
|
||||||
|
|
||||||
|
handle.join().expect("tracker server thread should join");
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cli_tracks_rpc_profile() {
|
||||||
|
let parsed = parse_cli(vec![
|
||||||
|
OsString::from("aria2-rust-pro"),
|
||||||
|
OsString::from("--enable-rpc"),
|
||||||
|
OsString::from("--rpc-listen-all"),
|
||||||
|
OsString::from("--rpc-listen-port=16800"),
|
||||||
|
OsString::from("--rpc-secret=token"),
|
||||||
|
OsString::from("magnet:?xt=urn:btih:abc"),
|
||||||
|
])
|
||||||
|
.expect("cli should parse");
|
||||||
|
|
||||||
|
assert!(parsed.profile.rpc.enabled);
|
||||||
|
assert_eq!(parsed.profile.rpc.listen_host, "0.0.0.0");
|
||||||
|
assert_eq!(parsed.profile.rpc.listen_port, 16_800);
|
||||||
|
assert_eq!(parsed.profile.rpc.secret.as_deref(), Some("token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cli_routes_short_continue_into_cli_overrides() {
|
||||||
|
let parsed = parse_cli(vec![
|
||||||
|
OsString::from("aria2-rust-pro"),
|
||||||
|
OsString::from("-c"),
|
||||||
|
OsString::from("-R"),
|
||||||
|
OsString::from("--dir"),
|
||||||
|
OsString::from("downloads"),
|
||||||
|
OsString::from("http://example.com/file.bin"),
|
||||||
|
])
|
||||||
|
.expect("cli should parse");
|
||||||
|
|
||||||
|
match parsed.invocation {
|
||||||
|
Invocation::Run { config_path, uris } => {
|
||||||
|
assert_eq!(config_path, None);
|
||||||
|
assert_eq!(uris, vec!["http://example.com/file.bin"]);
|
||||||
|
}
|
||||||
|
other => panic!("unexpected invocation: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let cli_profile = parsed
|
||||||
|
.cli_profile
|
||||||
|
.as_ref()
|
||||||
|
.expect("cli overrides should produce a transient profile");
|
||||||
|
let options = profile_option_map(cli_profile);
|
||||||
|
assert_eq!(options.get("continue").map(String::as_str), Some("true"));
|
||||||
|
assert_eq!(options.get("remote-time").map(String::as_str), Some("true"));
|
||||||
|
assert_eq!(options.get("dir").map(String::as_str), Some("downloads"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cli_canonicalizes_long_compat_aliases_and_live_options() {
|
||||||
|
let parsed = parse_cli(vec![
|
||||||
|
OsString::from("aria2-rust-pro"),
|
||||||
|
OsString::from("--http-want-digest=false"),
|
||||||
|
OsString::from("--check-certificate"),
|
||||||
|
OsString::from("false"),
|
||||||
|
OsString::from("--retry-on-403"),
|
||||||
|
OsString::from("--all-proxy"),
|
||||||
|
OsString::from("http://127.0.0.1:8080"),
|
||||||
|
OsString::from("--max-overall-download-limit=12M"),
|
||||||
|
OsString::from("http://example.com/file.bin"),
|
||||||
|
])
|
||||||
|
.expect("cli should parse compat aliases and live options");
|
||||||
|
|
||||||
|
match parsed.invocation {
|
||||||
|
Invocation::Run { config_path, uris } => {
|
||||||
|
assert_eq!(config_path, None);
|
||||||
|
assert_eq!(uris, vec!["http://example.com/file.bin"]);
|
||||||
|
}
|
||||||
|
other => panic!("unexpected invocation: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let cli_profile = parsed
|
||||||
|
.cli_profile
|
||||||
|
.as_ref()
|
||||||
|
.expect("cli overrides should produce a transient profile");
|
||||||
|
let options = profile_option_map(cli_profile);
|
||||||
|
assert_eq!(
|
||||||
|
options.get("no-want-digest-header").map(String::as_str),
|
||||||
|
Some("false")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
options.get("check-certificate").map(String::as_str),
|
||||||
|
Some("false")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
options.get("retry-on-403").map(String::as_str),
|
||||||
|
Some("true")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
options.get("all-proxy").map(String::as_str),
|
||||||
|
Some("http://127.0.0.1:8080")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
options
|
||||||
|
.get("max-overall-download-limit")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("12M")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_with_parsed_cli_overrides_wins_over_config_file() {
|
||||||
|
let mut downloader = FixtureHttpDownloader::new();
|
||||||
|
downloader.register("http://example.com/override.bin", b"override-body");
|
||||||
|
|
||||||
|
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-inline-override-test");
|
||||||
|
let _ = fs::create_dir_all(&temp_dir);
|
||||||
|
let config_target_dir = temp_dir.join("config-downloads");
|
||||||
|
let cli_target_dir = temp_dir.join("cli-downloads");
|
||||||
|
let config_path = temp_dir.join("aria2.conf");
|
||||||
|
fs::write(
|
||||||
|
&config_path,
|
||||||
|
[
|
||||||
|
format!("dir={}", config_target_dir.display()),
|
||||||
|
"out=config.bin".to_owned(),
|
||||||
|
"split=2".to_owned(),
|
||||||
|
"max-connection-per-server=4".to_owned(),
|
||||||
|
"all-proxy=http://user:pass@127.0.0.1:9000".to_owned(),
|
||||||
|
"check-certificate=true".to_owned(),
|
||||||
|
"save-session=config.session".to_owned(),
|
||||||
|
]
|
||||||
|
.join("\n"),
|
||||||
|
)
|
||||||
|
.expect("config should write");
|
||||||
|
|
||||||
|
let parsed = parse_cli(vec![
|
||||||
|
OsString::from("aria2-rust-pro"),
|
||||||
|
OsString::from("--conf-path"),
|
||||||
|
config_path.as_os_str().to_os_string(),
|
||||||
|
OsString::from("--dir"),
|
||||||
|
cli_target_dir.as_os_str().to_os_string(),
|
||||||
|
OsString::from("--out"),
|
||||||
|
OsString::from("cli.bin"),
|
||||||
|
OsString::from("--split"),
|
||||||
|
OsString::from("5"),
|
||||||
|
OsString::from("--max-connection-per-server"),
|
||||||
|
OsString::from("7"),
|
||||||
|
OsString::from("--all-proxy"),
|
||||||
|
OsString::from("http://user:pass@127.0.0.1:8080"),
|
||||||
|
OsString::from("--check-certificate"),
|
||||||
|
OsString::from("false"),
|
||||||
|
OsString::from("--save-session"),
|
||||||
|
OsString::from("cli.session"),
|
||||||
|
OsString::from("http://example.com/override.bin"),
|
||||||
|
])
|
||||||
|
.expect("cli should parse");
|
||||||
|
|
||||||
|
let report = execute_runtime_with_context(
|
||||||
|
parsed.invocation,
|
||||||
|
&parsed.profile,
|
||||||
|
parsed.cli_profile.as_ref(),
|
||||||
|
Some(&parsed.cli_transfer_sources),
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should honor parsed cli overrides");
|
||||||
|
|
||||||
|
assert_eq!(report.completed_download_count, 1);
|
||||||
|
assert_eq!(report.first_connections, Some(5));
|
||||||
|
assert_eq!(report.derived_runtime.split, 5);
|
||||||
|
assert_eq!(report.derived_runtime.max_connections_per_server, 7);
|
||||||
|
assert_eq!(
|
||||||
|
report.derived_runtime.session_path.as_deref(),
|
||||||
|
Some(std::path::Path::new("cli.session"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
report.http_session.proxy.as_ref().map(|proxy| proxy.port),
|
||||||
|
Some(8080)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
report.http_session.tls.as_ref().map(|tls| tls.verify_peer),
|
||||||
|
Some(false)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(cli_target_dir.join("cli.bin")).expect("cli target should persist payload"),
|
||||||
|
b"override-body"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!config_target_dir.join("config.bin").exists(),
|
||||||
|
"config target should not win over CLI output overrides"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derive_http_session_layers_protocol_specific_proxy_auth_over_selected_endpoint() {
|
||||||
|
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-proxy-auth-layering");
|
||||||
|
let _ = fs::create_dir_all(&temp_dir);
|
||||||
|
let config_path = temp_dir.join("aria2.conf");
|
||||||
|
fs::write(
|
||||||
|
&config_path,
|
||||||
|
[
|
||||||
|
"http-proxy=http://127.0.0.1:8080".to_owned(),
|
||||||
|
"all-proxy-user=global-user".to_owned(),
|
||||||
|
"all-proxy-passwd=global-pass".to_owned(),
|
||||||
|
"http-proxy-user=http-user".to_owned(),
|
||||||
|
"http-proxy-passwd=http-pass".to_owned(),
|
||||||
|
String::new(),
|
||||||
|
]
|
||||||
|
.join("\n"),
|
||||||
|
)
|
||||||
|
.expect("config should be writable");
|
||||||
|
|
||||||
|
let report = load_config_report(&config_path, true).expect("config should load");
|
||||||
|
let session = derive_http_session(Some(&report.profile), &StartupProfile::default());
|
||||||
|
let proxy = session.proxy.expect("http proxy should be derived");
|
||||||
|
|
||||||
|
assert_eq!(proxy.host, "127.0.0.1");
|
||||||
|
assert_eq!(proxy.port, 8080);
|
||||||
|
assert_eq!(proxy.username.as_deref(), Some("http-user"));
|
||||||
|
assert_eq!(proxy.password.as_deref(), Some("http-pass"));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derive_http_session_falls_back_to_all_proxy_auth_when_scheme_specific_auth_is_missing() {
|
||||||
|
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-proxy-auth-fallback");
|
||||||
|
let _ = fs::create_dir_all(&temp_dir);
|
||||||
|
let config_path = temp_dir.join("aria2.conf");
|
||||||
|
fs::write(
|
||||||
|
&config_path,
|
||||||
|
[
|
||||||
|
"https-proxy=http://127.0.0.1:8443".to_owned(),
|
||||||
|
"all-proxy-user=global-user".to_owned(),
|
||||||
|
"all-proxy-passwd=global-pass".to_owned(),
|
||||||
|
String::new(),
|
||||||
|
]
|
||||||
|
.join("\n"),
|
||||||
|
)
|
||||||
|
.expect("config should be writable");
|
||||||
|
|
||||||
|
let report = load_config_report(&config_path, true).expect("config should load");
|
||||||
|
let session = derive_http_session(Some(&report.profile), &StartupProfile::default());
|
||||||
|
let proxy = session.proxy.expect("https proxy should be derived");
|
||||||
|
|
||||||
|
assert_eq!(proxy.host, "127.0.0.1");
|
||||||
|
assert_eq!(proxy.port, 8443);
|
||||||
|
assert_eq!(proxy.username.as_deref(), Some("global-user"));
|
||||||
|
assert_eq!(proxy.password.as_deref(), Some("global-pass"));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_ftp_transfer_parts_prefers_ftp_proxy_and_honors_ftp_pasv_setting() {
|
||||||
|
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-ftp-proxy-derivation");
|
||||||
|
let _ = fs::create_dir_all(&temp_dir);
|
||||||
|
let config_path = temp_dir.join("aria2.conf");
|
||||||
|
fs::write(
|
||||||
|
&config_path,
|
||||||
|
[
|
||||||
|
"all-proxy=http://127.0.0.1:9000".to_owned(),
|
||||||
|
"all-proxy-user=global-user".to_owned(),
|
||||||
|
"all-proxy-passwd=global-pass".to_owned(),
|
||||||
|
"ftp-proxy=http://127.0.0.1:2121".to_owned(),
|
||||||
|
"ftp-proxy-user=ftp-user".to_owned(),
|
||||||
|
"ftp-proxy-passwd=ftp-pass".to_owned(),
|
||||||
|
"ftp-pasv=false".to_owned(),
|
||||||
|
String::new(),
|
||||||
|
]
|
||||||
|
.join("\n"),
|
||||||
|
)
|
||||||
|
.expect("config should be writable");
|
||||||
|
|
||||||
|
let report = load_config_report(&config_path, true).expect("config should load");
|
||||||
|
let profile = Some(&report.profile);
|
||||||
|
let session = derive_http_session(profile, &StartupProfile::default());
|
||||||
|
let (config, request) =
|
||||||
|
build_ftp_transfer_parts("FTP://download.example.org/file.bin", profile, &session)
|
||||||
|
.expect("ftp transfer parts should derive");
|
||||||
|
|
||||||
|
assert_eq!(config.host, "download.example.org");
|
||||||
|
assert_eq!(config.port, 21);
|
||||||
|
assert_eq!(config.mode, aria2_rust_pro_protocol::FtpMode::Active);
|
||||||
|
let proxy = config.proxy.expect("ftp proxy should be derived");
|
||||||
|
assert_eq!(proxy.host, "127.0.0.1");
|
||||||
|
assert_eq!(proxy.port, 2121);
|
||||||
|
assert_eq!(proxy.username.as_deref(), Some("ftp-user"));
|
||||||
|
assert_eq!(proxy.password.as_deref(), Some("ftp-pass"));
|
||||||
|
assert_eq!(request.path.as_deref(), Some("/file.bin"));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cli_overrides_win_for_ftp_proxy_auth_and_pasv_mode() {
|
||||||
|
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-ftp-override-precedence");
|
||||||
|
let _ = fs::create_dir_all(&temp_dir);
|
||||||
|
let config_path = temp_dir.join("aria2.conf");
|
||||||
|
fs::write(
|
||||||
|
&config_path,
|
||||||
|
[
|
||||||
|
"ftp-proxy=http://127.0.0.1:2121".to_owned(),
|
||||||
|
"ftp-proxy-user=config-user".to_owned(),
|
||||||
|
"ftp-proxy-passwd=config-pass".to_owned(),
|
||||||
|
"ftp-pasv=true".to_owned(),
|
||||||
|
String::new(),
|
||||||
|
]
|
||||||
|
.join("\n"),
|
||||||
|
)
|
||||||
|
.expect("config should be writable");
|
||||||
|
|
||||||
|
let parsed = parse_cli(vec![
|
||||||
|
OsString::from("aria2-rust-pro"),
|
||||||
|
OsString::from("--conf-path"),
|
||||||
|
config_path.as_os_str().to_os_string(),
|
||||||
|
OsString::from("--ftp-proxy-user"),
|
||||||
|
OsString::from("cli-user"),
|
||||||
|
OsString::from("--ftp-proxy-passwd"),
|
||||||
|
OsString::from("cli-pass"),
|
||||||
|
OsString::from("--ftp-pasv"),
|
||||||
|
OsString::from("false"),
|
||||||
|
OsString::from("ftp://download.example.org/file.bin"),
|
||||||
|
])
|
||||||
|
.expect("cli should parse");
|
||||||
|
|
||||||
|
let report = load_config_report(&config_path, true).expect("config should load");
|
||||||
|
let effective_profile = merged_profile(Some(&report.profile), parsed.cli_profile.as_ref());
|
||||||
|
let profile = effective_profile.as_ref();
|
||||||
|
let session = derive_http_session(profile, &parsed.profile);
|
||||||
|
let (config, _) =
|
||||||
|
build_ftp_transfer_parts("ftp://download.example.org/file.bin", profile, &session)
|
||||||
|
.expect("ftp transfer parts should derive");
|
||||||
|
|
||||||
|
assert_eq!(config.mode, aria2_rust_pro_protocol::FtpMode::Active);
|
||||||
|
let proxy = config.proxy.expect("ftp proxy should be derived");
|
||||||
|
assert_eq!(proxy.username.as_deref(), Some("cli-user"));
|
||||||
|
assert_eq!(proxy.password.as_deref(), Some("cli-pass"));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(temp_dir);
|
||||||
|
}
|
||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_config_report_derives_runtime_and_http_session_semantics() {
|
||||||
|
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-config-semantics");
|
||||||
|
fs::create_dir_all(&temp_dir).expect("temp dir should be creatable");
|
||||||
|
let config_path = temp_dir.join("aria2.conf");
|
||||||
|
fs::write(
|
||||||
|
&config_path,
|
||||||
|
[
|
||||||
|
"rpc-listen-all=true",
|
||||||
|
"rpc-listen-port=16801",
|
||||||
|
"listen-port=16901",
|
||||||
|
"dht-listen-port=16902",
|
||||||
|
"split=6",
|
||||||
|
"max-concurrent-downloads=8",
|
||||||
|
"max-connection-per-server=32",
|
||||||
|
"max-overall-download-limit=12M",
|
||||||
|
"max-download-limit=3M",
|
||||||
|
"max-overall-upload-limit=4M",
|
||||||
|
"max-upload-limit=2M",
|
||||||
|
"disk-cache=32M",
|
||||||
|
"min-split-size=4M",
|
||||||
|
"piece-length=2M",
|
||||||
|
"save-session=session.dat",
|
||||||
|
"save-session-interval=45",
|
||||||
|
"disable-ipv6=false",
|
||||||
|
"user-agent=aria2-rust-pro-test",
|
||||||
|
"header=Accept: */*,X-Test: yes",
|
||||||
|
"all-proxy=http://user:pass@127.0.0.1:8080",
|
||||||
|
"no-proxy=localhost,127.0.0.1",
|
||||||
|
"check-certificate=false",
|
||||||
|
"retry-wait=5",
|
||||||
|
"max-tries=7",
|
||||||
|
"retry-on-403=true",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
.join("\n"),
|
||||||
|
)
|
||||||
|
.expect("config should be writable");
|
||||||
|
|
||||||
|
let report = load_config_report(&config_path, true).expect("report should load");
|
||||||
|
let startup = StartupProfile::default();
|
||||||
|
let runtime = derive_runtime_config(Some(&report.profile), &startup);
|
||||||
|
let rpc_listen_host = derive_rpc_listen_host(Some(&report.profile), &startup);
|
||||||
|
let session = derive_http_session(Some(&report.profile), &startup);
|
||||||
|
|
||||||
|
assert_eq!(rpc_listen_host, "0.0.0.0");
|
||||||
|
assert_eq!(runtime.rpc_port, 16_801);
|
||||||
|
assert_eq!(runtime.listen_port, 16_902);
|
||||||
|
assert_eq!(runtime.split, 6);
|
||||||
|
assert_eq!(runtime.max_active_downloads, 8);
|
||||||
|
assert_eq!(runtime.max_connections_per_server, 32);
|
||||||
|
assert_eq!(runtime.max_overall_download_limit, Some(12 * 1024 * 1024));
|
||||||
|
assert_eq!(runtime.max_download_limit, Some(3 * 1024 * 1024));
|
||||||
|
assert_eq!(runtime.max_overall_upload_limit, Some(4 * 1024 * 1024));
|
||||||
|
assert_eq!(runtime.max_upload_limit, Some(2 * 1024 * 1024));
|
||||||
|
assert_eq!(runtime.disk_cache_bytes, 32 * 1024 * 1024);
|
||||||
|
assert_eq!(runtime.min_split_size, 4 * 1024 * 1024);
|
||||||
|
assert_eq!(runtime.piece_length, 2 * 1024 * 1024);
|
||||||
|
assert_eq!(runtime.save_session_interval_secs, 45);
|
||||||
|
assert!(runtime.enable_ipv6);
|
||||||
|
assert_eq!(
|
||||||
|
runtime.session_path.as_deref(),
|
||||||
|
Some(std::path::Path::new("session.dat"))
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(session.user_agent.as_deref(), Some("aria2-rust-pro-test"));
|
||||||
|
assert_eq!(session.default_headers.len(), 2);
|
||||||
|
assert_eq!(session.proxy.as_ref().map(|proxy| proxy.port), Some(8080));
|
||||||
|
assert_eq!(
|
||||||
|
session
|
||||||
|
.proxy
|
||||||
|
.as_ref()
|
||||||
|
.map(|proxy| proxy.bypass_hosts.clone()),
|
||||||
|
Some(vec!["localhost".to_owned(), "127.0.0.1".to_owned()])
|
||||||
|
);
|
||||||
|
assert_eq!(session.tls.as_ref().map(|tls| tls.verify_peer), Some(false));
|
||||||
|
assert_eq!(session.retry.policy.max_attempts, 7);
|
||||||
|
assert_eq!(session.retry.policy.initial_backoff_ms, 5000);
|
||||||
|
assert!(session.retry.policy.retry_on_4xx);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classify_transfer_and_protocol_cover_bt_and_magnet_surface() {
|
||||||
|
let cases = vec![
|
||||||
|
(
|
||||||
|
"magnet:?xt=urn:btih:00112233445566778899aabbccddeeff00112233",
|
||||||
|
TransferSelection::Magnet,
|
||||||
|
Some(Protocol::Magnet),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"https://cdn.example.org/image.torrent",
|
||||||
|
TransferSelection::Torrent,
|
||||||
|
Some(Protocol::Https),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"http://cdn.example.org/doc.meta4",
|
||||||
|
TransferSelection::Metalink,
|
||||||
|
Some(Protocol::Http),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"magnet:?xt=urn:btih:00112233445566778899aabbccddeeff00112233&tr=udp%3A%2F%2Ftracker.example.org%3A6969&x.pe=198.51.100.9%3A51413",
|
||||||
|
TransferSelection::Magnet,
|
||||||
|
Some(Protocol::Magnet),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"sftp://mirror.example.org/archive.iso",
|
||||||
|
TransferSelection::Uri,
|
||||||
|
Some(Protocol::Sftp),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"HTTPS://cdn.example.org/MIXED.TORRENT",
|
||||||
|
TransferSelection::Torrent,
|
||||||
|
Some(Protocol::Https),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"MAGNET:?xt=urn:btih:00112233445566778899aabbccddeeff00112233",
|
||||||
|
TransferSelection::Magnet,
|
||||||
|
Some(Protocol::Magnet),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"udp://tracker.example.org:6969",
|
||||||
|
TransferSelection::Uri,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (input, expected_kind, expected_protocol) in cases {
|
||||||
|
assert_eq!(
|
||||||
|
classify_transfer(input),
|
||||||
|
expected_kind,
|
||||||
|
"transfer kind mismatch"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_protocol(input),
|
||||||
|
expected_protocol,
|
||||||
|
"protocol parse mismatch"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_parallel_live_http_respects_base_profile_checksum_hook() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
|
||||||
|
let addr = listener.local_addr().expect("local addr should exist");
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
for _ in 0..2 {
|
||||||
|
let (mut stream, _) = listener.accept().expect("client should connect");
|
||||||
|
let mut request = [0_u8; 2048];
|
||||||
|
let _ = stream.read(&mut request);
|
||||||
|
stream
|
||||||
|
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\nConnection: close\r\n\r\nabc")
|
||||||
|
.expect("response should write");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-parallel-checksum-base-profile");
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||||
|
let config_path = temp_dir.join("aria2.conf");
|
||||||
|
fs::write(
|
||||||
|
&config_path,
|
||||||
|
format!(
|
||||||
|
"dir={}\nsplit=1\nchecksum=sha-1=0000000000000000000000000000000000000000\n",
|
||||||
|
temp_dir.display()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.expect("config should write");
|
||||||
|
|
||||||
|
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
|
||||||
|
let downloader = aria2_rust_pro_protocol::downloader::ConnectorBackedDownloader::new(
|
||||||
|
connector.clone(),
|
||||||
|
connector,
|
||||||
|
);
|
||||||
|
|
||||||
|
let report = execute_runtime_with_downloader(
|
||||||
|
Invocation::Run {
|
||||||
|
config_path: Some(config_path),
|
||||||
|
uris: vec![
|
||||||
|
format!("http://{addr}/parallel-a.bin"),
|
||||||
|
format!("http://{addr}/parallel-b.bin"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should execute parallel live transfers");
|
||||||
|
|
||||||
|
assert_eq!(report.accepted_uri_count, 2);
|
||||||
|
assert_eq!(report.tracked_download_count, 2);
|
||||||
|
assert_eq!(report.completed_download_count, 0);
|
||||||
|
assert_eq!(report.first_status.as_deref(), Some("active"));
|
||||||
|
|
||||||
|
handle.join().expect("server thread should join");
|
||||||
|
let _ = fs::remove_dir_all(temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_http_transfer_with_retry_uses_streamed_observed_truth_for_terminal_checksum() {
|
||||||
|
let downloader = FixtureHttpDownloader::new();
|
||||||
|
downloader.register_streamed_ok_with_checksum(
|
||||||
|
"http://example.com/retry-streamed.bin",
|
||||||
|
b"abc",
|
||||||
|
"md5",
|
||||||
|
"900150983cd24fb0d6963f7d28e17f72",
|
||||||
|
);
|
||||||
|
let runtime = RuntimeConfig::default();
|
||||||
|
let session = derive_http_session(None, &StartupProfile::default());
|
||||||
|
let task = build_http_transfer_task(
|
||||||
|
"gid-streamed".to_owned(),
|
||||||
|
"http://example.com/retry-streamed.bin".to_owned(),
|
||||||
|
&session,
|
||||||
|
&runtime,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
let execution = execute_http_transfer_with_retry(&downloader, &task, &runtime);
|
||||||
|
let response = execution.response.expect("response should exist");
|
||||||
|
|
||||||
|
assert_eq!(response.status, 200);
|
||||||
|
assert!(execution.checksum_observed);
|
||||||
|
assert!(execution.checksum_complete);
|
||||||
|
assert_eq!(response.completed_length(), 3);
|
||||||
|
assert_eq!(response.total_length(), Some(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_requires_terminal_success_for_streamed_checksum_completion() {
|
||||||
|
let downloader = SequencedHttpDownloader::new(vec![Ok(HttpResponseModel {
|
||||||
|
status: 206,
|
||||||
|
reason: "Partial Content".to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: Vec::new(),
|
||||||
|
},
|
||||||
|
body: ResponseBody::Streamed {
|
||||||
|
expected_len: Some(10),
|
||||||
|
observed_len: Some(5),
|
||||||
|
observed_digest: Some("900150983cd24fb0d6963f7d28e17f72".to_owned()),
|
||||||
|
temp_path: None,
|
||||||
|
},
|
||||||
|
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
start: 0,
|
||||||
|
end_inclusive: 4,
|
||||||
|
total_size: Some(10),
|
||||||
|
unsatisfied: false,
|
||||||
|
}),
|
||||||
|
partial_content: true,
|
||||||
|
checksum: Some(aria2_rust_pro_protocol::ChecksumSpec {
|
||||||
|
algorithm: "md5".to_owned(),
|
||||||
|
expected_hex: "900150983cd24fb0d6963f7d28e17f72".to_owned(),
|
||||||
|
actual_hex: None,
|
||||||
|
}),
|
||||||
|
redirected_from: None,
|
||||||
|
})]);
|
||||||
|
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-streamed-partial-single-test");
|
||||||
|
let _ = fs::create_dir_all(&temp_dir);
|
||||||
|
let config_path = temp_dir.join("aria2.conf");
|
||||||
|
fs::write(&config_path, "split=1\n").expect("config should write");
|
||||||
|
|
||||||
|
let report = execute_runtime_with_downloader(
|
||||||
|
Invocation::Run {
|
||||||
|
config_path: Some(config_path.clone()),
|
||||||
|
uris: vec!["http://example.com/streamed-partial.bin".to_owned()],
|
||||||
|
},
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should execute");
|
||||||
|
|
||||||
|
assert_eq!(report.first_status.as_deref(), Some("active"));
|
||||||
|
assert_eq!(report.first_total_length, Some(10));
|
||||||
|
assert_eq!(report.first_completed_length, Some(5));
|
||||||
|
assert_eq!(report.completed_download_count, 0);
|
||||||
|
let _ = fs::remove_file(config_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_http_transfer_task_caps_connections_by_split_budget() {
|
||||||
|
let session = derive_http_session(None, &StartupProfile::default());
|
||||||
|
let runtime = RuntimeConfig {
|
||||||
|
split: 3,
|
||||||
|
max_connections_per_server: 8,
|
||||||
|
..RuntimeConfig::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let task = build_http_transfer_task(
|
||||||
|
"gid".to_owned(),
|
||||||
|
"http://example.com/file.bin".to_owned(),
|
||||||
|
&session,
|
||||||
|
&runtime,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(task.max_connections, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_http_transfer_task_derives_checksum_hook_from_profile() {
|
||||||
|
let session = derive_http_session(None, &StartupProfile::default());
|
||||||
|
let runtime = RuntimeConfig::default();
|
||||||
|
let profile = args::config_profile_from_directives(
|
||||||
|
"checksum-hook",
|
||||||
|
aria2_rust_pro_compat::ConfigSource::RuntimeOverride,
|
||||||
|
vec![aria2_rust_pro_compat::ConfigDirective {
|
||||||
|
name: "checksum".to_owned(),
|
||||||
|
value: Some("sha-256=abcdef".to_owned()),
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.expect("profile should exist");
|
||||||
|
|
||||||
|
let task = build_http_transfer_task(
|
||||||
|
"gid-checksum".to_owned(),
|
||||||
|
"http://example.com/file.bin".to_owned(),
|
||||||
|
&session,
|
||||||
|
&runtime,
|
||||||
|
Some(&profile),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
task.checksum_hook.as_ref().map(|hook| (
|
||||||
|
hook.spec.algorithm.as_str(),
|
||||||
|
hook.spec.expected_hex.as_str()
|
||||||
|
)),
|
||||||
|
Some(("sha-256", "abcdef"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn planned_segment_span_respects_split_and_size_floors() {
|
||||||
|
let mut runtime = RuntimeConfig {
|
||||||
|
split: 3,
|
||||||
|
min_split_size: 4,
|
||||||
|
piece_length: 4,
|
||||||
|
..RuntimeConfig::default()
|
||||||
|
};
|
||||||
|
assert_eq!(planned_segment_span(10, &runtime), Some(4));
|
||||||
|
|
||||||
|
runtime.min_split_size = 8;
|
||||||
|
runtime.piece_length = 4;
|
||||||
|
assert_eq!(planned_segment_span(18, &runtime), Some(8));
|
||||||
|
|
||||||
|
runtime.split = 1;
|
||||||
|
assert_eq!(planned_segment_span(18, &runtime), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_reports_connection_budget_from_split_and_server_cap() {
|
||||||
|
let downloader = SequencedHttpDownloader::new(vec![Ok(HttpResponseModel {
|
||||||
|
status: 200,
|
||||||
|
reason: "OK".to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: vec![aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-length".to_owned(),
|
||||||
|
value: "4".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
body: ResponseBody::Inline(b"done".to_vec()),
|
||||||
|
content_range: None,
|
||||||
|
partial_content: false,
|
||||||
|
checksum: None,
|
||||||
|
redirected_from: None,
|
||||||
|
})]);
|
||||||
|
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-split-test");
|
||||||
|
let _ = fs::create_dir_all(&temp_dir);
|
||||||
|
let config_path = temp_dir.join("aria2.conf");
|
||||||
|
fs::write(&config_path, "split=3\nmax-connection-per-server=8\n").expect("config should write");
|
||||||
|
|
||||||
|
let report = execute_runtime_with_downloader(
|
||||||
|
Invocation::Run {
|
||||||
|
config_path: Some(config_path.clone()),
|
||||||
|
uris: vec!["http://example.com/split.bin".to_owned()],
|
||||||
|
},
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should execute with split budget");
|
||||||
|
|
||||||
|
assert_eq!(report.first_connections, Some(3));
|
||||||
|
let recorded = downloader.recorded_requests();
|
||||||
|
let [request] = recorded.as_slice() else {
|
||||||
|
panic!("expected exactly one recorded request");
|
||||||
|
};
|
||||||
|
assert_eq!(request.max_connections, 3);
|
||||||
|
|
||||||
|
let _ = fs::remove_file(config_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_persists_http_payload_to_configured_dir() {
|
||||||
|
let mut downloader = FixtureHttpDownloader::new();
|
||||||
|
downloader.register("http://example.com/payload.bin", b"fixture-file-body");
|
||||||
|
|
||||||
|
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-file-persist-test");
|
||||||
|
let _ = fs::create_dir_all(&temp_dir);
|
||||||
|
let target_dir = temp_dir.join("downloads");
|
||||||
|
let config_path = temp_dir.join("aria2.conf");
|
||||||
|
fs::write(
|
||||||
|
&config_path,
|
||||||
|
format!("dir={}\nout=payload.bin\nsplit=1\n", target_dir.display()),
|
||||||
|
)
|
||||||
|
.expect("config should write");
|
||||||
|
|
||||||
|
let report = execute_runtime_with_downloader(
|
||||||
|
Invocation::Run {
|
||||||
|
config_path: Some(config_path),
|
||||||
|
uris: vec!["http://example.com/payload.bin".to_owned()],
|
||||||
|
},
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should execute");
|
||||||
|
|
||||||
|
assert_eq!(report.completed_download_count, 1);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(target_dir.join("payload.bin")).expect("payload should persist"),
|
||||||
|
b"fixture-file-body"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(temp_dir);
|
||||||
|
}
|
||||||
+225
@@ -0,0 +1,225 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_with_input_file_cli_option_loads_multiple_downloads() {
|
||||||
|
let mut downloader = FixtureHttpDownloader::new();
|
||||||
|
downloader.register("http://example.com/input-a.bin", b"aaaa");
|
||||||
|
downloader.register("http://example.com/input-b.bin", b"bbbb");
|
||||||
|
|
||||||
|
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-input-file-test");
|
||||||
|
let _ = fs::create_dir_all(&temp_dir);
|
||||||
|
let input_path = temp_dir.join("downloads.txt");
|
||||||
|
fs::write(
|
||||||
|
&input_path,
|
||||||
|
"http://example.com/input-a.bin\nhttp://example.com/input-b.bin\n",
|
||||||
|
)
|
||||||
|
.expect("input-file should write");
|
||||||
|
|
||||||
|
let parsed = parse_cli(vec![
|
||||||
|
OsString::from("aria2-rust-pro"),
|
||||||
|
OsString::from("--input-file"),
|
||||||
|
input_path.as_os_str().to_os_string(),
|
||||||
|
])
|
||||||
|
.expect("cli should parse input-file");
|
||||||
|
|
||||||
|
let report = execute_runtime_with_context(
|
||||||
|
parsed.invocation,
|
||||||
|
&parsed.profile,
|
||||||
|
parsed.cli_profile.as_ref(),
|
||||||
|
Some(&parsed.cli_transfer_sources),
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should expand input-file downloads");
|
||||||
|
|
||||||
|
assert_eq!(report.accepted_uri_count, 2);
|
||||||
|
assert_eq!(report.tracked_download_count, 2);
|
||||||
|
assert_eq!(report.completed_download_count, 2);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_with_input_file_groups_tab_separated_mirrors_as_one_entity() {
|
||||||
|
let mut downloader = FixtureHttpDownloader::new();
|
||||||
|
downloader.register("http://example.com/mirror-a.bin", b"mirror");
|
||||||
|
|
||||||
|
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-input-file-mirror-test");
|
||||||
|
let _ = fs::create_dir_all(&temp_dir);
|
||||||
|
let input_path = temp_dir.join("downloads.txt");
|
||||||
|
fs::write(
|
||||||
|
&input_path,
|
||||||
|
"http://example.com/mirror-a.bin\thttp://mirror.example.com/mirror-a.bin\n",
|
||||||
|
)
|
||||||
|
.expect("input-file should write");
|
||||||
|
|
||||||
|
let parsed = parse_cli(vec![
|
||||||
|
OsString::from("aria2-rust-pro"),
|
||||||
|
OsString::from("--input-file"),
|
||||||
|
input_path.as_os_str().to_os_string(),
|
||||||
|
])
|
||||||
|
.expect("cli should parse input-file");
|
||||||
|
|
||||||
|
let report = execute_runtime_with_context(
|
||||||
|
parsed.invocation,
|
||||||
|
&parsed.profile,
|
||||||
|
parsed.cli_profile.as_ref(),
|
||||||
|
Some(&parsed.cli_transfer_sources),
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should keep mirror rows as one logical entity");
|
||||||
|
|
||||||
|
assert_eq!(report.accepted_uri_count, 1);
|
||||||
|
assert_eq!(report.tracked_download_count, 1);
|
||||||
|
assert_eq!(report.completed_download_count, 1);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_with_input_file_entry_options_override_global_output_path() {
|
||||||
|
let mut downloader = FixtureHttpDownloader::new();
|
||||||
|
downloader.register("http://example.com/from-input.bin", b"from-input");
|
||||||
|
|
||||||
|
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-input-file-entry-option-test");
|
||||||
|
let _ = fs::create_dir_all(&temp_dir);
|
||||||
|
let config_dir = temp_dir.join("config-output");
|
||||||
|
let entry_dir = temp_dir.join("entry-output");
|
||||||
|
let config_path = temp_dir.join("aria2.conf");
|
||||||
|
let input_path = temp_dir.join("downloads.txt");
|
||||||
|
fs::write(
|
||||||
|
&config_path,
|
||||||
|
[
|
||||||
|
format!("dir={}", config_dir.display()),
|
||||||
|
"out=config.bin".to_owned(),
|
||||||
|
]
|
||||||
|
.join("\n"),
|
||||||
|
)
|
||||||
|
.expect("config should write");
|
||||||
|
fs::write(
|
||||||
|
&input_path,
|
||||||
|
[
|
||||||
|
"http://example.com/from-input.bin".to_owned(),
|
||||||
|
format!(" dir={}", entry_dir.display()),
|
||||||
|
" out=entry.bin".to_owned(),
|
||||||
|
]
|
||||||
|
.join("\n"),
|
||||||
|
)
|
||||||
|
.expect("input-file should write");
|
||||||
|
|
||||||
|
let parsed = parse_cli(vec![
|
||||||
|
OsString::from("aria2-rust-pro"),
|
||||||
|
OsString::from("--conf-path"),
|
||||||
|
config_path.as_os_str().to_os_string(),
|
||||||
|
OsString::from("--input-file"),
|
||||||
|
input_path.as_os_str().to_os_string(),
|
||||||
|
])
|
||||||
|
.expect("cli should parse input-file with config");
|
||||||
|
|
||||||
|
let report = execute_runtime_with_context(
|
||||||
|
parsed.invocation,
|
||||||
|
&parsed.profile,
|
||||||
|
parsed.cli_profile.as_ref(),
|
||||||
|
Some(&parsed.cli_transfer_sources),
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should apply per-entry input-file overrides");
|
||||||
|
|
||||||
|
assert_eq!(report.accepted_uri_count, 1);
|
||||||
|
assert_eq!(report.completed_download_count, 1);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(entry_dir.join("entry.bin")).expect("entry override target should exist"),
|
||||||
|
b"from-input"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!config_dir.join("config.bin").exists(),
|
||||||
|
"global config output should not win over entry-specific input-file overrides"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cli_preserves_mixed_uri_and_input_file_source_order() {
|
||||||
|
let parsed = parse_cli(vec![
|
||||||
|
OsString::from("aria2-rust-pro"),
|
||||||
|
OsString::from("https://example.com/a.bin"),
|
||||||
|
OsString::from("--input-file"),
|
||||||
|
OsString::from("first.txt"),
|
||||||
|
OsString::from("http://example.com/c.bin"),
|
||||||
|
OsString::from("-i=second.txt"),
|
||||||
|
])
|
||||||
|
.expect("cli should parse mixed uri and input-file sources");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
parsed.cli_transfer_sources,
|
||||||
|
vec![
|
||||||
|
CliTransferSource::Uri("https://example.com/a.bin".to_owned()),
|
||||||
|
CliTransferSource::InputFile("first.txt".to_owned()),
|
||||||
|
CliTransferSource::Uri("http://example.com/c.bin".to_owned()),
|
||||||
|
CliTransferSource::InputFile("second.txt".to_owned()),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_preserves_cli_source_order_across_repeated_input_files() {
|
||||||
|
let mut downloader = FixtureHttpDownloader::new();
|
||||||
|
downloader.register("https://example.com/a.bin", b"a");
|
||||||
|
downloader.register_ftp(
|
||||||
|
"ftp://example.com:21/b.bin",
|
||||||
|
FtpResponseModel {
|
||||||
|
code: 226,
|
||||||
|
message: "transfer complete".to_owned(),
|
||||||
|
data: Some(b"b".to_vec()),
|
||||||
|
path: Some("/b.bin".to_owned()),
|
||||||
|
transferable: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
downloader.register("http://example.com/c.bin", b"c");
|
||||||
|
downloader.register_sftp(
|
||||||
|
"sftp://example.com:22/d.txt",
|
||||||
|
SftpResponseModel {
|
||||||
|
ok: true,
|
||||||
|
message: "read ok".to_owned(),
|
||||||
|
payload: Some(b"d".to_vec()),
|
||||||
|
path: Some("/d.txt".to_owned()),
|
||||||
|
transferable: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-input-file-order-test");
|
||||||
|
let _ = fs::create_dir_all(&temp_dir);
|
||||||
|
let first_input = temp_dir.join("first.txt");
|
||||||
|
let second_input = temp_dir.join("second.txt");
|
||||||
|
fs::write(&first_input, "ftp://example.com/b.bin\n").expect("first input-file should write");
|
||||||
|
fs::write(&second_input, "sftp://example.com/d.txt\n").expect("second input-file should write");
|
||||||
|
|
||||||
|
let parsed = parse_cli(vec![
|
||||||
|
OsString::from("aria2-rust-pro"),
|
||||||
|
OsString::from("https://example.com/a.bin"),
|
||||||
|
OsString::from("--input-file"),
|
||||||
|
first_input.as_os_str().to_os_string(),
|
||||||
|
OsString::from("http://example.com/c.bin"),
|
||||||
|
OsString::from(format!("-i={}", second_input.display())),
|
||||||
|
])
|
||||||
|
.expect("cli should parse ordered transfer sources");
|
||||||
|
|
||||||
|
let report = execute_runtime_with_context(
|
||||||
|
parsed.invocation,
|
||||||
|
&parsed.profile,
|
||||||
|
parsed.cli_profile.as_ref(),
|
||||||
|
Some(&parsed.cli_transfer_sources),
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should preserve ordered transfer-source expansion");
|
||||||
|
|
||||||
|
assert_eq!(report.accepted_uri_count, 4);
|
||||||
|
assert_eq!(report.tracked_download_count, 4);
|
||||||
|
assert_eq!(report.completed_download_count, 4);
|
||||||
|
assert_eq!(
|
||||||
|
report.recognized_schemes,
|
||||||
|
vec!["https", "ftp", "http", "sftp"]
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(temp_dir);
|
||||||
|
}
|
||||||
+222
@@ -0,0 +1,222 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn synthetic_bt_like_pressure_keeps_rpc_tell_status_responsive() {
|
||||||
|
let runtime = RuntimeConfig {
|
||||||
|
allow_jsonrpc: true,
|
||||||
|
allow_xmlrpc: true,
|
||||||
|
..RuntimeConfig::default()
|
||||||
|
};
|
||||||
|
let mut dispatcher = InProcessRpcDispatcher::with_runtime(runtime);
|
||||||
|
|
||||||
|
// Keep this smoke lightweight: enough concurrency-like pressure to catch
|
||||||
|
// dispatcher starvation without depending on real BT sessions.
|
||||||
|
let synthetic_bt_inputs = (0..128)
|
||||||
|
.map(|index| {
|
||||||
|
format!(
|
||||||
|
"magnet:?xt=urn:btih:{:040x}&dn=synthetic-{index}&tr=http://127.0.0.1:6969/announce",
|
||||||
|
index + 1
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let mut gids = Vec::with_capacity(synthetic_bt_inputs.len());
|
||||||
|
for magnet_uri in &synthetic_bt_inputs {
|
||||||
|
let add_response = dispatcher.dispatch_json(JsonRpcRequest {
|
||||||
|
jsonrpc: Some("2.0".to_owned()),
|
||||||
|
id: None,
|
||||||
|
method: RpcMethod::Aria2AddUri.as_str().to_owned(),
|
||||||
|
params: vec![RpcValue::String(magnet_uri.clone())],
|
||||||
|
meta: aria2_rust_pro_rpc::RpcMeta::default(),
|
||||||
|
});
|
||||||
|
match add_response.result {
|
||||||
|
Some(RpcValue::String(gid)) => gids.push(gid),
|
||||||
|
_ => panic!("expected gid from addUri, got: {add_response:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
dispatcher.tracked_download_count(),
|
||||||
|
synthetic_bt_inputs.len()
|
||||||
|
);
|
||||||
|
let probe_gid = gids
|
||||||
|
.first()
|
||||||
|
.cloned()
|
||||||
|
.expect("synthetic bt probe should register at least one gid");
|
||||||
|
let mut ok_rounds = 0_usize;
|
||||||
|
let probe_rounds = 256_usize;
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(2);
|
||||||
|
for _ in 0..probe_rounds {
|
||||||
|
let status_response = dispatcher.dispatch_json(JsonRpcRequest {
|
||||||
|
jsonrpc: Some("2.0".to_owned()),
|
||||||
|
id: None,
|
||||||
|
method: RpcMethod::Aria2TellStatus.as_str().to_owned(),
|
||||||
|
params: vec![RpcValue::String(probe_gid.clone())],
|
||||||
|
meta: aria2_rust_pro_rpc::RpcMeta::default(),
|
||||||
|
});
|
||||||
|
if matches!(status_response.result, Some(RpcValue::Object(_))) {
|
||||||
|
ok_rounds += 1;
|
||||||
|
} else {
|
||||||
|
panic!("tellStatus should stay responsive: {status_response:?}");
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
Instant::now() < deadline,
|
||||||
|
"synthetic RPC probe exceeded responsiveness budget"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(ok_rounds, probe_rounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_surface_uses_validate_mode_for_dry_run() {
|
||||||
|
let parsed = parse_cli(vec![
|
||||||
|
OsString::from("aria2-rust-pro"),
|
||||||
|
OsString::from("--dry-run"),
|
||||||
|
OsString::from("--conf-path=aria2.conf"),
|
||||||
|
])
|
||||||
|
.expect("cli should parse");
|
||||||
|
|
||||||
|
match command_surface(&parsed) {
|
||||||
|
CommandSurface::ValidateConfig {
|
||||||
|
config_path,
|
||||||
|
strict,
|
||||||
|
} => {
|
||||||
|
assert_eq!(config_path, std::path::PathBuf::from("aria2.conf"));
|
||||||
|
assert!(strict);
|
||||||
|
}
|
||||||
|
other => panic!("unexpected command surface: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cli_bt_batch_inputs_keep_order_and_rpc_daemon_surface() {
|
||||||
|
let bt_inputs = vec![
|
||||||
|
"magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&dn=alpha&tr=http://tracker.example.org/a",
|
||||||
|
"https://tracker.example.org/files/tracker-a.torrent",
|
||||||
|
"magnet:?xt=urn:btih:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&dn=beta&tr=http://tracker.example.org/b",
|
||||||
|
"http://cdn.example.org/tracker-b.torrent",
|
||||||
|
];
|
||||||
|
let mut args = vec![
|
||||||
|
OsString::from("aria2-rust-pro"),
|
||||||
|
OsString::from("--enable-rpc"),
|
||||||
|
OsString::from("--rpc-only"),
|
||||||
|
OsString::from("--rpc-listen-port=16999"),
|
||||||
|
OsString::from("--conf-path=bt-runtime.conf"),
|
||||||
|
];
|
||||||
|
args.extend(bt_inputs.iter().map(OsString::from));
|
||||||
|
|
||||||
|
let parsed = parse_cli(args).expect("bt batch args should parse");
|
||||||
|
let surface = command_surface(&parsed);
|
||||||
|
match surface {
|
||||||
|
CommandSurface::RpcDaemon {
|
||||||
|
config_path,
|
||||||
|
inputs,
|
||||||
|
} => {
|
||||||
|
assert_eq!(
|
||||||
|
config_path,
|
||||||
|
Some(std::path::PathBuf::from("bt-runtime.conf"))
|
||||||
|
);
|
||||||
|
assert_eq!(inputs, bt_inputs);
|
||||||
|
}
|
||||||
|
other => panic!("expected rpc-daemon command surface, got: {other:?}"),
|
||||||
|
}
|
||||||
|
assert_eq!(parsed.profile.mode, RuntimeMode::RpcOnly);
|
||||||
|
assert_eq!(parsed.profile.rpc.listen_port, 16_999);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn synthetic_bt_mixed_status_probe_keeps_runtime_fields_shape_stable() {
|
||||||
|
let runtime = RuntimeConfig {
|
||||||
|
allow_jsonrpc: true,
|
||||||
|
allow_xmlrpc: true,
|
||||||
|
..RuntimeConfig::default()
|
||||||
|
};
|
||||||
|
let mut dispatcher = InProcessRpcDispatcher::with_runtime(runtime);
|
||||||
|
|
||||||
|
let mut gids = Vec::new();
|
||||||
|
for index in 0..48 {
|
||||||
|
let uri = if index % 3 == 0 {
|
||||||
|
format!("https://assets.example.org/bt-{index}.torrent")
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"magnet:?xt=urn:btih:{:040x}&dn=mix-{index}&tr=http://127.0.0.1:6969/announce&tr=udp://127.0.0.1:6969",
|
||||||
|
index + 100
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let add = dispatcher.dispatch_json(JsonRpcRequest {
|
||||||
|
jsonrpc: Some("2.0".to_owned()),
|
||||||
|
id: None,
|
||||||
|
method: RpcMethod::Aria2AddUri.as_str().to_owned(),
|
||||||
|
params: vec![RpcValue::String(uri)],
|
||||||
|
meta: aria2_rust_pro_rpc::RpcMeta::default(),
|
||||||
|
});
|
||||||
|
match add.result {
|
||||||
|
Some(RpcValue::String(gid)) => gids.push(gid),
|
||||||
|
other => panic!("unexpected addUri result: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut bt_object_count = 0usize;
|
||||||
|
for gid in &gids {
|
||||||
|
let status = dispatcher.dispatch_json(JsonRpcRequest {
|
||||||
|
jsonrpc: Some("2.0".to_owned()),
|
||||||
|
id: None,
|
||||||
|
method: RpcMethod::Aria2TellStatus.as_str().to_owned(),
|
||||||
|
params: vec![RpcValue::String(gid.clone())],
|
||||||
|
meta: aria2_rust_pro_rpc::RpcMeta::default(),
|
||||||
|
});
|
||||||
|
match status.result {
|
||||||
|
Some(RpcValue::Object(payload)) => {
|
||||||
|
assert!(
|
||||||
|
payload.contains_key("status"),
|
||||||
|
"status key should remain visible under mixed bt load"
|
||||||
|
);
|
||||||
|
if payload.get("isBt") == Some(&RpcValue::Bool(true)) {
|
||||||
|
bt_object_count += 1;
|
||||||
|
match payload.get("seeder") {
|
||||||
|
Some(RpcValue::Bool(false)) => {}
|
||||||
|
Some(RpcValue::String(value)) if value == "false" => {}
|
||||||
|
other => panic!(
|
||||||
|
"bt payload should expose seeder as a visible scalar, got: {other:?}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
payload.contains_key("numSeeders"),
|
||||||
|
"bt payload should expose numSeeders"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
payload.contains_key("shareRatio"),
|
||||||
|
"bt payload should expose shareRatio"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
payload.contains_key("shareRatioProgress"),
|
||||||
|
"bt payload should expose shareRatioProgress"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
payload.contains_key("shareRatioRemaining"),
|
||||||
|
"bt payload should expose shareRatioRemaining"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
payload.contains_key("shareTime"),
|
||||||
|
"bt payload should expose shareTime"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
payload.contains_key("metadataOnly"),
|
||||||
|
"bt payload should expose metadataOnly"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
payload.contains_key("announceList"),
|
||||||
|
"bt payload should expose announceList"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => panic!("unexpected tellStatus result: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(dispatcher.tracked_download_count(), 48);
|
||||||
|
assert!(
|
||||||
|
bt_object_count >= 32,
|
||||||
|
"magnet-heavy mix should be mostly BT"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
pub(super) use super::*;
|
||||||
|
|
||||||
|
mod checksum_completion;
|
||||||
|
mod live_http_connector;
|
||||||
|
mod retry_and_partial;
|
||||||
|
mod segment_parallelism;
|
||||||
|
mod segment_planning;
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_marks_completion_when_checksum_seen_on_terminal_success() {
|
||||||
|
let downloader = SequencedHttpDownloader::new(vec![Ok(HttpResponseModel {
|
||||||
|
status: 200,
|
||||||
|
reason: "OK".to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: vec![aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-length".to_owned(),
|
||||||
|
value: "3".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
body: ResponseBody::Inline(b"abc".to_vec()),
|
||||||
|
content_range: None,
|
||||||
|
partial_content: false,
|
||||||
|
checksum: Some(aria2_rust_pro_protocol::ChecksumSpec {
|
||||||
|
algorithm: "sha-1".to_owned(),
|
||||||
|
expected_hex: "a9993e364706816aba3e25717850c26c9cd0d89d".to_owned(),
|
||||||
|
actual_hex: None,
|
||||||
|
}),
|
||||||
|
redirected_from: None,
|
||||||
|
})]);
|
||||||
|
|
||||||
|
let report = execute_runtime_with_downloader(
|
||||||
|
Invocation::Run {
|
||||||
|
config_path: None,
|
||||||
|
uris: vec!["http://example.com/checksum.bin".to_owned()],
|
||||||
|
},
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should treat checksum terminal response as complete");
|
||||||
|
|
||||||
|
assert_eq!(report.completed_download_count, 1);
|
||||||
|
assert_eq!(report.first_status.as_deref(), Some("complete"));
|
||||||
|
assert_eq!(report.first_completed_length, Some(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_uses_streamed_observed_truth_for_checksum_completion() {
|
||||||
|
let downloader = FixtureHttpDownloader::new();
|
||||||
|
downloader.register_streamed_ok_with_checksum(
|
||||||
|
"http://example.com/streamed-checksum.bin",
|
||||||
|
b"abc",
|
||||||
|
"md5",
|
||||||
|
"900150983cd24fb0d6963f7d28e17f72",
|
||||||
|
);
|
||||||
|
|
||||||
|
let report = execute_runtime_with_downloader(
|
||||||
|
Invocation::Run {
|
||||||
|
config_path: None,
|
||||||
|
uris: vec!["http://example.com/streamed-checksum.bin".to_owned()],
|
||||||
|
},
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should treat streamed observed checksum as complete");
|
||||||
|
|
||||||
|
assert_eq!(report.completed_download_count, 1);
|
||||||
|
assert_eq!(report.first_status.as_deref(), Some("complete"));
|
||||||
|
assert_eq!(report.first_completed_length, Some(3));
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_with_live_http_connector_completes_local_response() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
|
||||||
|
let addr = listener.local_addr().expect("local addr should exist");
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
let (mut stream, _) = listener.accept().expect("client should connect");
|
||||||
|
let mut request = [0_u8; 2048];
|
||||||
|
let _ = stream.read(&mut request);
|
||||||
|
stream
|
||||||
|
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nabc")
|
||||||
|
.expect("response should write");
|
||||||
|
});
|
||||||
|
|
||||||
|
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
|
||||||
|
let downloader = aria2_rust_pro_protocol::downloader::ConnectorBackedDownloader::new(
|
||||||
|
connector.clone(),
|
||||||
|
connector,
|
||||||
|
);
|
||||||
|
|
||||||
|
let report = execute_runtime_with_downloader(
|
||||||
|
Invocation::Run {
|
||||||
|
config_path: None,
|
||||||
|
uris: vec![format!("http://{addr}/live")],
|
||||||
|
},
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should complete via live connector");
|
||||||
|
|
||||||
|
assert_eq!(report.completed_download_count, 1);
|
||||||
|
assert_eq!(report.first_status.as_deref(), Some("complete"));
|
||||||
|
assert_eq!(report.first_completed_length, Some(3));
|
||||||
|
|
||||||
|
handle.join().expect("server thread should join");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_with_live_http_connector_persists_streamed_payload_to_configured_dir() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
|
||||||
|
let addr = listener.local_addr().expect("local addr should exist");
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
let (mut stream, _) = listener.accept().expect("client should connect");
|
||||||
|
let mut request = [0_u8; 2048];
|
||||||
|
let _ = stream.read(&mut request);
|
||||||
|
stream
|
||||||
|
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 7\r\n\r\npersist")
|
||||||
|
.expect("response should write");
|
||||||
|
});
|
||||||
|
|
||||||
|
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-live-persist-test");
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||||
|
let target_dir = temp_dir.join("downloads");
|
||||||
|
let config_path = temp_dir.join("aria2.conf");
|
||||||
|
fs::write(
|
||||||
|
&config_path,
|
||||||
|
format!("dir={}\nout=payload.bin\nsplit=1\n", target_dir.display()),
|
||||||
|
)
|
||||||
|
.expect("config should write");
|
||||||
|
|
||||||
|
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
|
||||||
|
let downloader = aria2_rust_pro_protocol::downloader::ConnectorBackedDownloader::new(
|
||||||
|
connector.clone(),
|
||||||
|
connector,
|
||||||
|
);
|
||||||
|
|
||||||
|
let report = execute_runtime_with_downloader(
|
||||||
|
Invocation::Run {
|
||||||
|
config_path: Some(config_path),
|
||||||
|
uris: vec![format!("http://{addr}/payload.bin")],
|
||||||
|
},
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should persist the streamed live response");
|
||||||
|
|
||||||
|
assert_eq!(report.completed_download_count, 1);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(target_dir.join("payload.bin")).expect("payload should persist"),
|
||||||
|
b"persist"
|
||||||
|
);
|
||||||
|
|
||||||
|
handle.join().expect("server thread should join");
|
||||||
|
let _ = fs::remove_dir_all(temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_with_live_http_connector_avoids_second_scale_delay_for_large_single_file() {
|
||||||
|
let payload = vec![b'a'; 8 * 1024 * 1024];
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
|
||||||
|
let addr = listener.local_addr().expect("local addr should exist");
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
let (mut stream, _) = listener.accept().expect("client should connect");
|
||||||
|
let mut request = [0_u8; 2048];
|
||||||
|
let _ = stream.read(&mut request);
|
||||||
|
let headers = format!(
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||||
|
payload.len()
|
||||||
|
);
|
||||||
|
stream
|
||||||
|
.write_all(headers.as_bytes())
|
||||||
|
.expect("response headers should write");
|
||||||
|
stream
|
||||||
|
.write_all(&payload)
|
||||||
|
.expect("response body should write");
|
||||||
|
});
|
||||||
|
|
||||||
|
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
|
||||||
|
let downloader = aria2_rust_pro_protocol::downloader::ConnectorBackedDownloader::new(
|
||||||
|
connector.clone(),
|
||||||
|
connector,
|
||||||
|
);
|
||||||
|
|
||||||
|
let started = Instant::now();
|
||||||
|
let report = execute_runtime_with_downloader(
|
||||||
|
Invocation::Run {
|
||||||
|
config_path: None,
|
||||||
|
uris: vec![format!("http://{addr}/large-live.bin")],
|
||||||
|
},
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should complete large live connector response");
|
||||||
|
let elapsed = started.elapsed();
|
||||||
|
|
||||||
|
assert_eq!(report.completed_download_count, 1);
|
||||||
|
assert_eq!(report.first_completed_length, Some(8 * 1024 * 1024));
|
||||||
|
assert!(
|
||||||
|
elapsed < Duration::from_millis(900),
|
||||||
|
"large single-file live connector execution should stay below a second-scale delay: {elapsed:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
handle.join().expect("server thread should join");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_with_live_http_connector_completes_multiple_local_responses_in_one_runtime() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
|
||||||
|
let addr = listener.local_addr().expect("local addr should exist");
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
let mut workers = Vec::new();
|
||||||
|
for _ in 0..2 {
|
||||||
|
let (mut stream, _) = listener.accept().expect("client should connect");
|
||||||
|
workers.push(thread::spawn(move || {
|
||||||
|
let mut request = [0_u8; 2048];
|
||||||
|
let _ = stream.read(&mut request);
|
||||||
|
thread::sleep(Duration::from_millis(80));
|
||||||
|
stream
|
||||||
|
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nabc")
|
||||||
|
.expect("response should write");
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for worker in workers {
|
||||||
|
worker.join().expect("http response worker should join");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
|
||||||
|
let downloader = aria2_rust_pro_protocol::downloader::ConnectorBackedDownloader::new(
|
||||||
|
connector.clone(),
|
||||||
|
connector,
|
||||||
|
);
|
||||||
|
|
||||||
|
let started = Instant::now();
|
||||||
|
let report = execute_runtime_with_downloader(
|
||||||
|
Invocation::Run {
|
||||||
|
config_path: None,
|
||||||
|
uris: vec![
|
||||||
|
format!("http://{addr}/live-a"),
|
||||||
|
format!("http://{addr}/live-b"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should complete multiple live connector responses");
|
||||||
|
let elapsed = started.elapsed();
|
||||||
|
|
||||||
|
assert_eq!(report.accepted_uri_count, 2);
|
||||||
|
assert_eq!(report.tracked_download_count, 2);
|
||||||
|
assert_eq!(report.completed_download_count, 2);
|
||||||
|
assert_eq!(report.first_status.as_deref(), Some("complete"));
|
||||||
|
assert!(
|
||||||
|
elapsed < Duration::from_millis(600),
|
||||||
|
"same-runtime live connector execution should overlap request latency: {elapsed:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
handle.join().expect("server thread should join");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::too_many_lines,
|
||||||
|
reason = "live retry/resume fixture is clearer as one test"
|
||||||
|
)]
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_with_live_http_connector_retries_resumes_and_completes_checksum() {
|
||||||
|
fn read_http_request(stream: &mut TcpStream) -> String {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut chunk = [0_u8; 1024];
|
||||||
|
loop {
|
||||||
|
let read = stream.read(&mut chunk).expect("socket should read");
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let payload = chunk
|
||||||
|
.get(..read)
|
||||||
|
.expect("read count should stay within the temporary buffer");
|
||||||
|
buf.extend_from_slice(payload);
|
||||||
|
if buf.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&buf).to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn requested_range(request: &str) -> Option<(usize, Option<usize>)> {
|
||||||
|
let range_line = request
|
||||||
|
.lines()
|
||||||
|
.find(|line| line.trim_start().starts_with("range: bytes="))?;
|
||||||
|
let raw = range_line
|
||||||
|
.trim_start()
|
||||||
|
.strip_prefix("range: bytes=")?
|
||||||
|
.trim();
|
||||||
|
let (start, end) = raw.split_once('-')?;
|
||||||
|
Some((
|
||||||
|
start.parse().ok()?,
|
||||||
|
(!end.is_empty()).then(|| end.parse().ok()).flatten(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
|
||||||
|
let addr = listener.local_addr().expect("local addr should exist");
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
let (mut retry, _) = listener.accept().expect("retry client should connect");
|
||||||
|
let retry_request = read_http_request(&mut retry);
|
||||||
|
assert!(retry_request.contains("get /resume-checksum.bin http/1.1"));
|
||||||
|
assert!(!retry_request.contains("range:"));
|
||||||
|
retry
|
||||||
|
.write_all(
|
||||||
|
b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
|
||||||
|
)
|
||||||
|
.expect("retry response should write");
|
||||||
|
|
||||||
|
let (mut bootstrap, _) = listener.accept().expect("bootstrap client should connect");
|
||||||
|
let bootstrap_request = read_http_request(&mut bootstrap);
|
||||||
|
assert!(bootstrap_request.contains("get /resume-checksum.bin http/1.1"));
|
||||||
|
assert!(!bootstrap_request.contains("range:"));
|
||||||
|
bootstrap
|
||||||
|
.write_all(
|
||||||
|
b"HTTP/1.1 206 Partial Content\r\nContent-Length: 4\r\nContent-Range: bytes 0-3/10\r\nConnection: close\r\n\r\n1234",
|
||||||
|
)
|
||||||
|
.expect("bootstrap response should write");
|
||||||
|
|
||||||
|
let (mut resumed, _) = listener.accept().expect("resume client should connect");
|
||||||
|
let resumed_request = read_http_request(&mut resumed);
|
||||||
|
let payload = b"1234567890";
|
||||||
|
let (start, end) =
|
||||||
|
requested_range(&resumed_request).expect("resume request should include range");
|
||||||
|
let end = end.unwrap_or_else(|| payload.len().saturating_sub(1));
|
||||||
|
assert_eq!(start, 4);
|
||||||
|
assert_eq!(end, 9);
|
||||||
|
let body = payload
|
||||||
|
.get(start..=end)
|
||||||
|
.expect("resume request should stay within the scripted payload");
|
||||||
|
let response = format!(
|
||||||
|
"HTTP/1.1 206 Partial Content\r\nContent-Length: {}\r\nContent-Range: bytes {start}-{end}/{}\r\nConnection: close\r\n\r\n",
|
||||||
|
body.len(),
|
||||||
|
payload.len()
|
||||||
|
);
|
||||||
|
resumed
|
||||||
|
.write_all(response.as_bytes())
|
||||||
|
.expect("resume response headers should write");
|
||||||
|
resumed
|
||||||
|
.write_all(body)
|
||||||
|
.expect("resume response body should write");
|
||||||
|
});
|
||||||
|
|
||||||
|
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-live-resume-checksum-test");
|
||||||
|
let _ = fs::remove_dir_all(&temp_dir);
|
||||||
|
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||||
|
let config_path = temp_dir.join("aria2.conf");
|
||||||
|
fs::write(
|
||||||
|
&config_path,
|
||||||
|
format!(
|
||||||
|
"dir={}\nout=resume-checksum.bin\nsplit=1\nmax-tries=3\nchecksum=sha-1=01b307acba4f54f55aafc33bb06bbbf6ca803e9a\n",
|
||||||
|
temp_dir.display()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.expect("config should write");
|
||||||
|
|
||||||
|
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
|
||||||
|
let downloader = aria2_rust_pro_protocol::downloader::ConnectorBackedDownloader::new(
|
||||||
|
connector.clone(),
|
||||||
|
connector,
|
||||||
|
);
|
||||||
|
|
||||||
|
let report = execute_runtime_with_downloader(
|
||||||
|
Invocation::Run {
|
||||||
|
config_path: Some(config_path),
|
||||||
|
uris: vec![format!("http://{addr}/resume-checksum.bin")],
|
||||||
|
},
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should complete retried ranged live transfer with checksum");
|
||||||
|
|
||||||
|
assert_eq!(report.completed_download_count, 1);
|
||||||
|
assert_eq!(report.first_status.as_deref(), Some("complete"));
|
||||||
|
assert_eq!(report.first_total_length, Some(10));
|
||||||
|
assert_eq!(report.first_completed_length, Some(10));
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(temp_dir.join("resume-checksum.bin")).expect("target should read"),
|
||||||
|
b"1234567890"
|
||||||
|
);
|
||||||
|
|
||||||
|
handle.join().expect("server thread should join");
|
||||||
|
let _ = fs::remove_dir_all(temp_dir);
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_retries_http_failure_then_completes() {
|
||||||
|
let downloader = SequencedHttpDownloader::new(vec![
|
||||||
|
Ok(HttpResponseModel {
|
||||||
|
status: 503,
|
||||||
|
reason: "Service Unavailable".to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: Vec::new(),
|
||||||
|
},
|
||||||
|
body: ResponseBody::Empty,
|
||||||
|
content_range: None,
|
||||||
|
partial_content: false,
|
||||||
|
checksum: None,
|
||||||
|
redirected_from: None,
|
||||||
|
}),
|
||||||
|
Ok(HttpResponseModel {
|
||||||
|
status: 200,
|
||||||
|
reason: "OK".to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: vec![aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-length".to_owned(),
|
||||||
|
value: "8".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
body: ResponseBody::Inline(b"aaaaaaaa".to_vec()),
|
||||||
|
content_range: None,
|
||||||
|
partial_content: false,
|
||||||
|
checksum: None,
|
||||||
|
redirected_from: None,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-retry-test");
|
||||||
|
let _ = fs::create_dir_all(&temp_dir);
|
||||||
|
let config_path = temp_dir.join("aria2.conf");
|
||||||
|
fs::write(&config_path, "retry-wait=1\nmax-tries=2\n").expect("config should write");
|
||||||
|
|
||||||
|
let report = execute_runtime_with_downloader(
|
||||||
|
Invocation::Run {
|
||||||
|
config_path: Some(config_path.clone()),
|
||||||
|
uris: vec!["http://example.com/retry.bin".to_owned()],
|
||||||
|
},
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should recover via retry");
|
||||||
|
|
||||||
|
assert_eq!(report.completed_download_count, 1);
|
||||||
|
assert_eq!(report.first_status.as_deref(), Some("complete"));
|
||||||
|
assert_eq!(report.first_completed_length, Some(8));
|
||||||
|
assert_eq!(report.first_connections, Some(1));
|
||||||
|
let _ = fs::remove_file(config_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_accepts_partial_content_as_complete_progress() {
|
||||||
|
let downloader = SequencedHttpDownloader::new(vec![Ok(HttpResponseModel {
|
||||||
|
status: 206,
|
||||||
|
reason: "Partial Content".to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: vec![
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-length".to_owned(),
|
||||||
|
value: "5".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-range".to_owned(),
|
||||||
|
value: "bytes 0-4/10".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
body: ResponseBody::Inline(b"12345".to_vec()),
|
||||||
|
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
start: 0,
|
||||||
|
end_inclusive: 4,
|
||||||
|
total_size: Some(10),
|
||||||
|
unsatisfied: false,
|
||||||
|
}),
|
||||||
|
partial_content: true,
|
||||||
|
checksum: None,
|
||||||
|
redirected_from: None,
|
||||||
|
})]);
|
||||||
|
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-partial-single-test");
|
||||||
|
let _ = fs::create_dir_all(&temp_dir);
|
||||||
|
let config_path = temp_dir.join("aria2.conf");
|
||||||
|
fs::write(&config_path, "split=1\n").expect("config should write");
|
||||||
|
|
||||||
|
let report = execute_runtime_with_downloader(
|
||||||
|
Invocation::Run {
|
||||||
|
config_path: Some(config_path.clone()),
|
||||||
|
uris: vec!["http://example.com/partial.bin".to_owned()],
|
||||||
|
},
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should accept 206 transfer");
|
||||||
|
|
||||||
|
assert_eq!(report.completed_download_count, 0);
|
||||||
|
assert_eq!(report.first_status.as_deref(), Some("active"));
|
||||||
|
assert_eq!(report.first_total_length, Some(10));
|
||||||
|
assert_eq!(report.first_completed_length, Some(5));
|
||||||
|
assert_eq!(report.first_connections, Some(1));
|
||||||
|
let _ = fs::remove_file(config_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::too_many_lines,
|
||||||
|
reason = "partial-range regression fixture keeps the response sequence and assertions together"
|
||||||
|
)]
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_advances_multi_step_partial_ranges_until_complete() {
|
||||||
|
let downloader = SequencedHttpDownloader::new(vec![
|
||||||
|
Ok(HttpResponseModel {
|
||||||
|
status: 206,
|
||||||
|
reason: "Partial Content".to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: vec![
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-length".to_owned(),
|
||||||
|
value: "4".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-range".to_owned(),
|
||||||
|
value: "bytes 0-3/10".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
body: ResponseBody::Inline(b"1234".to_vec()),
|
||||||
|
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
start: 0,
|
||||||
|
end_inclusive: 3,
|
||||||
|
total_size: Some(10),
|
||||||
|
unsatisfied: false,
|
||||||
|
}),
|
||||||
|
partial_content: true,
|
||||||
|
checksum: None,
|
||||||
|
redirected_from: None,
|
||||||
|
}),
|
||||||
|
Ok(HttpResponseModel {
|
||||||
|
status: 206,
|
||||||
|
reason: "Partial Content".to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: vec![
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-length".to_owned(),
|
||||||
|
value: "3".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-range".to_owned(),
|
||||||
|
value: "bytes 4-6/10".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
body: ResponseBody::Inline(b"567".to_vec()),
|
||||||
|
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
start: 4,
|
||||||
|
end_inclusive: 6,
|
||||||
|
total_size: Some(10),
|
||||||
|
unsatisfied: false,
|
||||||
|
}),
|
||||||
|
partial_content: true,
|
||||||
|
checksum: None,
|
||||||
|
redirected_from: None,
|
||||||
|
}),
|
||||||
|
Ok(HttpResponseModel {
|
||||||
|
status: 206,
|
||||||
|
reason: "Partial Content".to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: vec![
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-length".to_owned(),
|
||||||
|
value: "2".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-range".to_owned(),
|
||||||
|
value: "bytes 8-9/10".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
body: ResponseBody::Inline(b"90".to_vec()),
|
||||||
|
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
start: 8,
|
||||||
|
end_inclusive: 9,
|
||||||
|
total_size: Some(10),
|
||||||
|
unsatisfied: false,
|
||||||
|
}),
|
||||||
|
partial_content: true,
|
||||||
|
checksum: None,
|
||||||
|
redirected_from: None,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-multipart-test");
|
||||||
|
let _ = fs::create_dir_all(&temp_dir);
|
||||||
|
let config_path = temp_dir.join("aria2.conf");
|
||||||
|
fs::write(
|
||||||
|
&config_path,
|
||||||
|
"max-tries=3\nsplit=3\nmin-split-size=4\npiece-length=4\n",
|
||||||
|
)
|
||||||
|
.expect("config should write");
|
||||||
|
|
||||||
|
let report = execute_runtime_with_downloader(
|
||||||
|
Invocation::Run {
|
||||||
|
config_path: Some(config_path.clone()),
|
||||||
|
uris: vec!["http://example.com/multipart.bin".to_owned()],
|
||||||
|
},
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should advance through multiple partial segments");
|
||||||
|
|
||||||
|
assert_eq!(report.completed_download_count, 1);
|
||||||
|
assert_eq!(report.first_status.as_deref(), Some("complete"));
|
||||||
|
assert_eq!(report.first_total_length, Some(10));
|
||||||
|
assert_eq!(report.first_completed_length, Some(10));
|
||||||
|
assert_eq!(report.first_connections, Some(1));
|
||||||
|
|
||||||
|
let recorded = downloader.recorded_requests();
|
||||||
|
let [bootstrap, first_followup, second_followup] = recorded.as_slice() else {
|
||||||
|
panic!("expected exactly three recorded requests");
|
||||||
|
};
|
||||||
|
assert_eq!(bootstrap.request.range, None);
|
||||||
|
let mut followup_ranges = [first_followup, second_followup]
|
||||||
|
.iter()
|
||||||
|
.map(|task| task.request.range)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
followup_ranges.sort_by_key(|range| range.as_ref().map(|range| range.start));
|
||||||
|
assert_eq!(
|
||||||
|
followup_ranges,
|
||||||
|
vec![
|
||||||
|
Some(aria2_rust_pro_protocol::RangeSpec {
|
||||||
|
start: 4,
|
||||||
|
end_inclusive: Some(9),
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
}),
|
||||||
|
Some(aria2_rust_pro_protocol::RangeSpec {
|
||||||
|
start: 7,
|
||||||
|
end_inclusive: Some(9),
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(bootstrap.retry_attempts.len(), 0);
|
||||||
|
assert_eq!(first_followup.retry_attempts.len(), 0);
|
||||||
|
assert_eq!(second_followup.retry_attempts.len(), 1);
|
||||||
|
assert!(
|
||||||
|
first_followup
|
||||||
|
.retry_attempts
|
||||||
|
.iter()
|
||||||
|
.all(|attempt| attempt.status == Some(206))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
second_followup
|
||||||
|
.retry_attempts
|
||||||
|
.iter()
|
||||||
|
.all(|attempt| attempt.status == Some(206))
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_file(config_path);
|
||||||
|
}
|
||||||
@@ -0,0 +1,582 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::too_many_lines,
|
||||||
|
reason = "concurrency regression fixture keeps queued responses and observed range assertions together"
|
||||||
|
)]
|
||||||
|
#[test]
|
||||||
|
fn execute_segment_transfers_runs_planned_segments_concurrently() {
|
||||||
|
let downloader = ConcurrentProbeDownloader::new(
|
||||||
|
HttpResponseModel {
|
||||||
|
status: 206,
|
||||||
|
reason: "Partial Content".to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: vec![
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-length".to_owned(),
|
||||||
|
value: "4".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-range".to_owned(),
|
||||||
|
value: "bytes 0-3/16".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
body: ResponseBody::Inline(b"1234".to_vec()),
|
||||||
|
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
start: 0,
|
||||||
|
end_inclusive: 3,
|
||||||
|
total_size: Some(16),
|
||||||
|
unsatisfied: false,
|
||||||
|
}),
|
||||||
|
partial_content: true,
|
||||||
|
checksum: None,
|
||||||
|
redirected_from: None,
|
||||||
|
},
|
||||||
|
vec![
|
||||||
|
(
|
||||||
|
4,
|
||||||
|
HttpResponseModel {
|
||||||
|
status: 206,
|
||||||
|
reason: "Partial Content".to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: vec![
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-length".to_owned(),
|
||||||
|
value: "4".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-range".to_owned(),
|
||||||
|
value: "bytes 4-7/16".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
body: ResponseBody::Inline(b"5678".to_vec()),
|
||||||
|
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
start: 4,
|
||||||
|
end_inclusive: 7,
|
||||||
|
total_size: Some(16),
|
||||||
|
unsatisfied: false,
|
||||||
|
}),
|
||||||
|
partial_content: true,
|
||||||
|
checksum: None,
|
||||||
|
redirected_from: None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
8,
|
||||||
|
HttpResponseModel {
|
||||||
|
status: 206,
|
||||||
|
reason: "Partial Content".to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: vec![
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-length".to_owned(),
|
||||||
|
value: "2".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-range".to_owned(),
|
||||||
|
value: "bytes 8-11/16".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
body: ResponseBody::Inline(b"90ab".to_vec()),
|
||||||
|
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
start: 8,
|
||||||
|
end_inclusive: 11,
|
||||||
|
total_size: Some(16),
|
||||||
|
unsatisfied: false,
|
||||||
|
}),
|
||||||
|
partial_content: true,
|
||||||
|
checksum: None,
|
||||||
|
redirected_from: None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
12,
|
||||||
|
HttpResponseModel {
|
||||||
|
status: 206,
|
||||||
|
reason: "Partial Content".to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: vec![
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-length".to_owned(),
|
||||||
|
value: "4".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-range".to_owned(),
|
||||||
|
value: "bytes 12-15/16".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
body: ResponseBody::Inline(b"cdef".to_vec()),
|
||||||
|
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
start: 12,
|
||||||
|
end_inclusive: 15,
|
||||||
|
total_size: Some(16),
|
||||||
|
unsatisfied: false,
|
||||||
|
}),
|
||||||
|
partial_content: true,
|
||||||
|
checksum: None,
|
||||||
|
redirected_from: None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
Duration::from_millis(80),
|
||||||
|
);
|
||||||
|
let runtime = RuntimeConfig {
|
||||||
|
split: 4,
|
||||||
|
max_connections_per_server: 4,
|
||||||
|
max_connection_per_server: 4,
|
||||||
|
min_split_size: 4,
|
||||||
|
piece_length: 4,
|
||||||
|
..RuntimeConfig::default()
|
||||||
|
};
|
||||||
|
let session = derive_http_session(None, &StartupProfile::default());
|
||||||
|
let base_task = build_http_transfer_task(
|
||||||
|
"gid-concurrent".to_owned(),
|
||||||
|
"http://example.com/concurrent-segments.bin".to_owned(),
|
||||||
|
&session,
|
||||||
|
&runtime,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let planned_tasks = vec![(4_u64, 7_u64), (8, 11), (12, 15)]
|
||||||
|
.into_iter()
|
||||||
|
.map(|(start, end_inclusive)| {
|
||||||
|
let mut task = base_task.clone();
|
||||||
|
task.request.range = Some(aria2_rust_pro_protocol::RangeSpec {
|
||||||
|
start,
|
||||||
|
end_inclusive: Some(end_inclusive),
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
});
|
||||||
|
task.resume_state = Some(aria2_rust_pro_protocol::ResumeState {
|
||||||
|
requested_offset: start,
|
||||||
|
accepted_offset: None,
|
||||||
|
resumed: true,
|
||||||
|
});
|
||||||
|
task
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let executions = execute_segment_transfers(&downloader, planned_tasks, &runtime);
|
||||||
|
|
||||||
|
assert_eq!(executions.len(), 3);
|
||||||
|
assert!(
|
||||||
|
downloader.max_concurrent_calls() >= 2,
|
||||||
|
"follow-up segment transfers should overlap in flight"
|
||||||
|
);
|
||||||
|
|
||||||
|
let recorded = downloader.recorded_requests();
|
||||||
|
assert_eq!(recorded.len(), 3);
|
||||||
|
let mut followup_ranges = recorded
|
||||||
|
.iter()
|
||||||
|
.map(|task| task.request.range)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
followup_ranges.sort_by_key(|range| range.as_ref().map(|range| range.start));
|
||||||
|
assert_eq!(
|
||||||
|
followup_ranges,
|
||||||
|
vec![
|
||||||
|
Some(aria2_rust_pro_protocol::RangeSpec {
|
||||||
|
start: 4,
|
||||||
|
end_inclusive: Some(7),
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
}),
|
||||||
|
Some(aria2_rust_pro_protocol::RangeSpec {
|
||||||
|
start: 8,
|
||||||
|
end_inclusive: Some(11),
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
}),
|
||||||
|
Some(aria2_rust_pro_protocol::RangeSpec {
|
||||||
|
start: 12,
|
||||||
|
end_inclusive: Some(15),
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::too_many_lines,
|
||||||
|
reason = "throttling regression fixture keeps the speed-cap setup and concurrency assertions together"
|
||||||
|
)]
|
||||||
|
#[test]
|
||||||
|
fn execute_segment_transfers_throttles_parallelism_when_speed_cap_is_tight() {
|
||||||
|
let downloader = ConcurrentProbeDownloader::new(
|
||||||
|
HttpResponseModel {
|
||||||
|
status: 206,
|
||||||
|
reason: "Partial Content".to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: vec![
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-length".to_owned(),
|
||||||
|
value: "4".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-range".to_owned(),
|
||||||
|
value: "bytes 0-3/16".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
body: ResponseBody::Inline(b"1234".to_vec()),
|
||||||
|
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
start: 0,
|
||||||
|
end_inclusive: 3,
|
||||||
|
total_size: Some(16),
|
||||||
|
unsatisfied: false,
|
||||||
|
}),
|
||||||
|
partial_content: true,
|
||||||
|
checksum: None,
|
||||||
|
redirected_from: None,
|
||||||
|
},
|
||||||
|
vec![
|
||||||
|
(
|
||||||
|
4,
|
||||||
|
HttpResponseModel {
|
||||||
|
status: 206,
|
||||||
|
reason: "Partial Content".to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: vec![
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-length".to_owned(),
|
||||||
|
value: "4".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-range".to_owned(),
|
||||||
|
value: "bytes 4-7/16".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
body: ResponseBody::Inline(b"5678".to_vec()),
|
||||||
|
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
start: 4,
|
||||||
|
end_inclusive: 7,
|
||||||
|
total_size: Some(16),
|
||||||
|
unsatisfied: false,
|
||||||
|
}),
|
||||||
|
partial_content: true,
|
||||||
|
checksum: None,
|
||||||
|
redirected_from: None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
8,
|
||||||
|
HttpResponseModel {
|
||||||
|
status: 206,
|
||||||
|
reason: "Partial Content".to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: vec![
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-length".to_owned(),
|
||||||
|
value: "4".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-range".to_owned(),
|
||||||
|
value: "bytes 8-11/16".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
body: ResponseBody::Inline(b"90ab".to_vec()),
|
||||||
|
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
start: 8,
|
||||||
|
end_inclusive: 11,
|
||||||
|
total_size: Some(16),
|
||||||
|
unsatisfied: false,
|
||||||
|
}),
|
||||||
|
partial_content: true,
|
||||||
|
checksum: None,
|
||||||
|
redirected_from: None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
12,
|
||||||
|
HttpResponseModel {
|
||||||
|
status: 206,
|
||||||
|
reason: "Partial Content".to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: vec![
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-length".to_owned(),
|
||||||
|
value: "4".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-range".to_owned(),
|
||||||
|
value: "bytes 12-15/16".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
body: ResponseBody::Inline(b"cdef".to_vec()),
|
||||||
|
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
start: 12,
|
||||||
|
end_inclusive: 15,
|
||||||
|
total_size: Some(16),
|
||||||
|
unsatisfied: false,
|
||||||
|
}),
|
||||||
|
partial_content: true,
|
||||||
|
checksum: None,
|
||||||
|
redirected_from: None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
Duration::from_millis(80),
|
||||||
|
);
|
||||||
|
let runtime = RuntimeConfig {
|
||||||
|
split: 4,
|
||||||
|
max_connections_per_server: 4,
|
||||||
|
max_connection_per_server: 4,
|
||||||
|
min_split_size: 4,
|
||||||
|
piece_length: 4,
|
||||||
|
max_overall_download_limit: Some(4),
|
||||||
|
..RuntimeConfig::default()
|
||||||
|
};
|
||||||
|
let session = derive_http_session(None, &StartupProfile::default());
|
||||||
|
let base_task = build_http_transfer_task(
|
||||||
|
"gid-throttled".to_owned(),
|
||||||
|
"http://example.com/throttled-segments.bin".to_owned(),
|
||||||
|
&session,
|
||||||
|
&runtime,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let planned_tasks = vec![(4_u64, 7_u64), (8, 11), (12, 15)]
|
||||||
|
.into_iter()
|
||||||
|
.map(|(start, end_inclusive)| {
|
||||||
|
let mut task = base_task.clone();
|
||||||
|
task.request.range = Some(aria2_rust_pro_protocol::RangeSpec {
|
||||||
|
start,
|
||||||
|
end_inclusive: Some(end_inclusive),
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
});
|
||||||
|
task.resume_state = Some(aria2_rust_pro_protocol::ResumeState {
|
||||||
|
requested_offset: start,
|
||||||
|
accepted_offset: None,
|
||||||
|
resumed: true,
|
||||||
|
});
|
||||||
|
task
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let executions = execute_segment_transfers(&downloader, planned_tasks, &runtime);
|
||||||
|
|
||||||
|
assert_eq!(executions.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
downloader.max_concurrent_calls(),
|
||||||
|
3,
|
||||||
|
"global download caps should not collapse one download's follow-up segments into serial transfers"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::too_many_lines,
|
||||||
|
reason = "per-download speed-cap regression keeps mirrored segment fixtures beside the serialism assertion"
|
||||||
|
)]
|
||||||
|
#[test]
|
||||||
|
fn execute_segment_transfers_respect_per_download_speed_cap() {
|
||||||
|
let downloader = ConcurrentProbeDownloader::new(
|
||||||
|
HttpResponseModel {
|
||||||
|
status: 206,
|
||||||
|
reason: "Partial Content".to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: vec![
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-length".to_owned(),
|
||||||
|
value: "4".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-range".to_owned(),
|
||||||
|
value: "bytes 0-3/16".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
body: ResponseBody::Inline(b"1234".to_vec()),
|
||||||
|
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
start: 0,
|
||||||
|
end_inclusive: 3,
|
||||||
|
total_size: Some(16),
|
||||||
|
unsatisfied: false,
|
||||||
|
}),
|
||||||
|
partial_content: true,
|
||||||
|
checksum: None,
|
||||||
|
redirected_from: None,
|
||||||
|
},
|
||||||
|
vec![
|
||||||
|
(
|
||||||
|
4,
|
||||||
|
HttpResponseModel {
|
||||||
|
status: 206,
|
||||||
|
reason: "Partial Content".to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: vec![
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-length".to_owned(),
|
||||||
|
value: "4".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-range".to_owned(),
|
||||||
|
value: "bytes 4-7/16".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
body: ResponseBody::Inline(b"5678".to_vec()),
|
||||||
|
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
start: 4,
|
||||||
|
end_inclusive: 7,
|
||||||
|
total_size: Some(16),
|
||||||
|
unsatisfied: false,
|
||||||
|
}),
|
||||||
|
partial_content: true,
|
||||||
|
checksum: None,
|
||||||
|
redirected_from: None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
8,
|
||||||
|
HttpResponseModel {
|
||||||
|
status: 206,
|
||||||
|
reason: "Partial Content".to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: vec![
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-length".to_owned(),
|
||||||
|
value: "4".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-range".to_owned(),
|
||||||
|
value: "bytes 8-11/16".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
body: ResponseBody::Inline(b"90ab".to_vec()),
|
||||||
|
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
start: 8,
|
||||||
|
end_inclusive: 11,
|
||||||
|
total_size: Some(16),
|
||||||
|
unsatisfied: false,
|
||||||
|
}),
|
||||||
|
partial_content: true,
|
||||||
|
checksum: None,
|
||||||
|
redirected_from: None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
12,
|
||||||
|
HttpResponseModel {
|
||||||
|
status: 206,
|
||||||
|
reason: "Partial Content".to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: vec![
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-length".to_owned(),
|
||||||
|
value: "4".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-range".to_owned(),
|
||||||
|
value: "bytes 12-15/16".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
body: ResponseBody::Inline(b"cdef".to_vec()),
|
||||||
|
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
start: 12,
|
||||||
|
end_inclusive: 15,
|
||||||
|
total_size: Some(16),
|
||||||
|
unsatisfied: false,
|
||||||
|
}),
|
||||||
|
partial_content: true,
|
||||||
|
checksum: None,
|
||||||
|
redirected_from: None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
Duration::from_millis(80),
|
||||||
|
);
|
||||||
|
let runtime = RuntimeConfig {
|
||||||
|
split: 4,
|
||||||
|
max_connections_per_server: 4,
|
||||||
|
max_connection_per_server: 4,
|
||||||
|
min_split_size: 4,
|
||||||
|
piece_length: 4,
|
||||||
|
max_download_limit: Some(4),
|
||||||
|
..RuntimeConfig::default()
|
||||||
|
};
|
||||||
|
let session = derive_http_session(None, &StartupProfile::default());
|
||||||
|
let base_task = build_http_transfer_task(
|
||||||
|
"gid-throttled".to_owned(),
|
||||||
|
"http://example.com/throttled-segments.bin".to_owned(),
|
||||||
|
&session,
|
||||||
|
&runtime,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let planned_tasks = vec![(4_u64, 7_u64), (8, 11), (12, 15)]
|
||||||
|
.into_iter()
|
||||||
|
.map(|(start, end_inclusive)| {
|
||||||
|
let mut task = base_task.clone();
|
||||||
|
task.request.range = Some(aria2_rust_pro_protocol::RangeSpec {
|
||||||
|
start,
|
||||||
|
end_inclusive: Some(end_inclusive),
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
});
|
||||||
|
task.resume_state = Some(aria2_rust_pro_protocol::ResumeState {
|
||||||
|
requested_offset: start,
|
||||||
|
accepted_offset: None,
|
||||||
|
resumed: true,
|
||||||
|
});
|
||||||
|
task
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let executions = execute_segment_transfers(&downloader, planned_tasks, &runtime);
|
||||||
|
|
||||||
|
assert_eq!(executions.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
downloader.max_concurrent_calls(),
|
||||||
|
1,
|
||||||
|
"per-download speed caps should still force serial follow-up segments for one download"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::too_many_lines,
|
||||||
|
reason = "segment-plan regression fixture keeps response bodies, ranges, and request assertions adjacent"
|
||||||
|
)]
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_uses_segment_plan_after_bootstrap_partial_response() {
|
||||||
|
let downloader = SequencedHttpDownloader::new(vec![
|
||||||
|
Ok(HttpResponseModel {
|
||||||
|
status: 206,
|
||||||
|
reason: "Partial Content".to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: vec![
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-length".to_owned(),
|
||||||
|
value: "4".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-range".to_owned(),
|
||||||
|
value: "bytes 0-3/10".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
body: ResponseBody::Inline(b"1234".to_vec()),
|
||||||
|
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
start: 0,
|
||||||
|
end_inclusive: 3,
|
||||||
|
total_size: Some(10),
|
||||||
|
unsatisfied: false,
|
||||||
|
}),
|
||||||
|
partial_content: true,
|
||||||
|
checksum: None,
|
||||||
|
redirected_from: None,
|
||||||
|
}),
|
||||||
|
Ok(HttpResponseModel {
|
||||||
|
status: 206,
|
||||||
|
reason: "Partial Content".to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: vec![
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-length".to_owned(),
|
||||||
|
value: "4".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-range".to_owned(),
|
||||||
|
value: "bytes 4-7/10".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
body: ResponseBody::Inline(b"5678".to_vec()),
|
||||||
|
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
start: 4,
|
||||||
|
end_inclusive: 7,
|
||||||
|
total_size: Some(10),
|
||||||
|
unsatisfied: false,
|
||||||
|
}),
|
||||||
|
partial_content: true,
|
||||||
|
checksum: None,
|
||||||
|
redirected_from: None,
|
||||||
|
}),
|
||||||
|
Ok(HttpResponseModel {
|
||||||
|
status: 206,
|
||||||
|
reason: "Partial Content".to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: vec![
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-length".to_owned(),
|
||||||
|
value: "2".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-range".to_owned(),
|
||||||
|
value: "bytes 8-9/10".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
body: ResponseBody::Inline(b"90".to_vec()),
|
||||||
|
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
start: 8,
|
||||||
|
end_inclusive: 9,
|
||||||
|
total_size: Some(10),
|
||||||
|
unsatisfied: false,
|
||||||
|
}),
|
||||||
|
partial_content: true,
|
||||||
|
checksum: None,
|
||||||
|
redirected_from: None,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-explicit-segments-test");
|
||||||
|
let _ = fs::create_dir_all(&temp_dir);
|
||||||
|
let config_path = temp_dir.join("aria2.conf");
|
||||||
|
fs::write(
|
||||||
|
&config_path,
|
||||||
|
"max-tries=3\nsplit=3\nmin-split-size=4\npiece-length=4\n",
|
||||||
|
)
|
||||||
|
.expect("config should write");
|
||||||
|
|
||||||
|
let report = execute_runtime_with_downloader(
|
||||||
|
Invocation::Run {
|
||||||
|
config_path: Some(config_path.clone()),
|
||||||
|
uris: vec!["http://example.com/segment-plan.bin".to_owned()],
|
||||||
|
},
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should execute via explicit segments");
|
||||||
|
|
||||||
|
assert_eq!(report.completed_download_count, 1);
|
||||||
|
assert_eq!(report.first_status.as_deref(), Some("complete"));
|
||||||
|
assert_eq!(report.first_completed_length, Some(10));
|
||||||
|
|
||||||
|
let recorded = downloader.recorded_requests();
|
||||||
|
let [bootstrap, first_followup, second_followup] = recorded.as_slice() else {
|
||||||
|
panic!("expected exactly three recorded requests");
|
||||||
|
};
|
||||||
|
assert_eq!(bootstrap.request.range, None);
|
||||||
|
let mut followup_ranges = [first_followup, second_followup]
|
||||||
|
.iter()
|
||||||
|
.map(|task| task.request.range)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
followup_ranges.sort_by_key(|range| range.as_ref().map(|range| range.start));
|
||||||
|
assert_eq!(
|
||||||
|
followup_ranges,
|
||||||
|
vec![
|
||||||
|
Some(aria2_rust_pro_protocol::RangeSpec {
|
||||||
|
start: 4,
|
||||||
|
end_inclusive: Some(9),
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
}),
|
||||||
|
Some(aria2_rust_pro_protocol::RangeSpec {
|
||||||
|
start: 8,
|
||||||
|
end_inclusive: Some(9),
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_file(config_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::too_many_lines,
|
||||||
|
reason = "initial range-probe regression keeps bootstrap and follow-up range fixtures adjacent"
|
||||||
|
)]
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_uses_initial_range_probe_when_split_budget_allows_parallel_segments() {
|
||||||
|
let downloader = SequencedHttpDownloader::new(vec![
|
||||||
|
Ok(HttpResponseModel {
|
||||||
|
status: 206,
|
||||||
|
reason: "Partial Content".to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: vec![
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-length".to_owned(),
|
||||||
|
value: "4".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-range".to_owned(),
|
||||||
|
value: "bytes 0-3/10".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
body: ResponseBody::Inline(b"1234".to_vec()),
|
||||||
|
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
start: 0,
|
||||||
|
end_inclusive: 3,
|
||||||
|
total_size: Some(10),
|
||||||
|
unsatisfied: false,
|
||||||
|
}),
|
||||||
|
partial_content: true,
|
||||||
|
checksum: None,
|
||||||
|
redirected_from: None,
|
||||||
|
}),
|
||||||
|
Ok(HttpResponseModel {
|
||||||
|
status: 206,
|
||||||
|
reason: "Partial Content".to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: vec![
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-length".to_owned(),
|
||||||
|
value: "4".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-range".to_owned(),
|
||||||
|
value: "bytes 4-7/10".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
body: ResponseBody::Inline(b"5678".to_vec()),
|
||||||
|
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
start: 4,
|
||||||
|
end_inclusive: 7,
|
||||||
|
total_size: Some(10),
|
||||||
|
unsatisfied: false,
|
||||||
|
}),
|
||||||
|
partial_content: true,
|
||||||
|
checksum: None,
|
||||||
|
redirected_from: None,
|
||||||
|
}),
|
||||||
|
Ok(HttpResponseModel {
|
||||||
|
status: 206,
|
||||||
|
reason: "Partial Content".to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: vec![
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-length".to_owned(),
|
||||||
|
value: "2".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-range".to_owned(),
|
||||||
|
value: "bytes 8-9/10".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
body: ResponseBody::Inline(b"90".to_vec()),
|
||||||
|
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
start: 8,
|
||||||
|
end_inclusive: 9,
|
||||||
|
total_size: Some(10),
|
||||||
|
unsatisfied: false,
|
||||||
|
}),
|
||||||
|
partial_content: true,
|
||||||
|
checksum: None,
|
||||||
|
redirected_from: None,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-segment-probe-test");
|
||||||
|
let _ = fs::create_dir_all(&temp_dir);
|
||||||
|
let config_path = temp_dir.join("aria2.conf");
|
||||||
|
fs::write(
|
||||||
|
&config_path,
|
||||||
|
"split=3\nmax-connection-per-server=3\nmin-split-size=4\npiece-length=4\n",
|
||||||
|
)
|
||||||
|
.expect("config should write");
|
||||||
|
|
||||||
|
let report = execute_runtime_with_downloader(
|
||||||
|
Invocation::Run {
|
||||||
|
config_path: Some(config_path.clone()),
|
||||||
|
uris: vec!["http://example.com/probe-plan.bin".to_owned()],
|
||||||
|
},
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should execute via initial range probe");
|
||||||
|
|
||||||
|
assert_eq!(report.completed_download_count, 1);
|
||||||
|
assert_eq!(report.first_status.as_deref(), Some("complete"));
|
||||||
|
assert_eq!(report.first_completed_length, Some(10));
|
||||||
|
|
||||||
|
let recorded = downloader.recorded_requests();
|
||||||
|
let [probe, first_followup, second_followup] = recorded.as_slice() else {
|
||||||
|
panic!("expected exactly three recorded requests");
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
probe.request.range,
|
||||||
|
Some(aria2_rust_pro_protocol::RangeSpec {
|
||||||
|
start: 0,
|
||||||
|
end_inclusive: Some(3),
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let mut followup_ranges = [first_followup, second_followup]
|
||||||
|
.iter()
|
||||||
|
.map(|task| task.request.range)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
followup_ranges.sort_by_key(|range| range.as_ref().map(|range| range.start));
|
||||||
|
assert_eq!(
|
||||||
|
followup_ranges,
|
||||||
|
vec![
|
||||||
|
Some(aria2_rust_pro_protocol::RangeSpec {
|
||||||
|
start: 4,
|
||||||
|
end_inclusive: Some(7),
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
}),
|
||||||
|
Some(aria2_rust_pro_protocol::RangeSpec {
|
||||||
|
start: 8,
|
||||||
|
end_inclusive: Some(9),
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_file(config_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::too_many_lines,
|
||||||
|
reason = "tiny-tail regression keeps the coalesced probe fixture next to the expected request ranges"
|
||||||
|
)]
|
||||||
|
#[test]
|
||||||
|
fn execute_runtime_coalesces_tiny_followup_tail_after_initial_probe() {
|
||||||
|
let downloader = SequencedHttpDownloader::new(vec![
|
||||||
|
Ok(HttpResponseModel {
|
||||||
|
status: 206,
|
||||||
|
reason: "Partial Content".to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: vec![
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-length".to_owned(),
|
||||||
|
value: "32".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-range".to_owned(),
|
||||||
|
value: "bytes 0-31/36".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
body: ResponseBody::Inline(b"1234567890abcdefghijklmnopqrstuv".to_vec()),
|
||||||
|
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
start: 0,
|
||||||
|
end_inclusive: 31,
|
||||||
|
total_size: Some(36),
|
||||||
|
unsatisfied: false,
|
||||||
|
}),
|
||||||
|
partial_content: true,
|
||||||
|
checksum: None,
|
||||||
|
redirected_from: None,
|
||||||
|
}),
|
||||||
|
Ok(HttpResponseModel {
|
||||||
|
status: 206,
|
||||||
|
reason: "Partial Content".to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: vec![
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-length".to_owned(),
|
||||||
|
value: "4".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
aria2_rust_pro_protocol::HttpHeader {
|
||||||
|
name: "content-range".to_owned(),
|
||||||
|
value: "bytes 32-35/36".to_owned(),
|
||||||
|
kind: aria2_rust_pro_protocol::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
body: ResponseBody::Inline(b"ghij".to_vec()),
|
||||||
|
content_range: Some(aria2_rust_pro_protocol::ContentRangeSpec {
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
start: 32,
|
||||||
|
end_inclusive: 35,
|
||||||
|
total_size: Some(36),
|
||||||
|
unsatisfied: false,
|
||||||
|
}),
|
||||||
|
partial_content: true,
|
||||||
|
checksum: None,
|
||||||
|
redirected_from: None,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
let temp_dir = std::env::temp_dir().join("aria2-rust-pro-cli-tiny-tail-coalesce-test");
|
||||||
|
let _ = fs::create_dir_all(&temp_dir);
|
||||||
|
let config_path = temp_dir.join("aria2.conf");
|
||||||
|
fs::write(
|
||||||
|
&config_path,
|
||||||
|
"split=4\nmax-connection-per-server=4\nmin-split-size=4\npiece-length=4\n",
|
||||||
|
)
|
||||||
|
.expect("config should write");
|
||||||
|
|
||||||
|
let report = execute_runtime_with_downloader(
|
||||||
|
Invocation::Run {
|
||||||
|
config_path: Some(config_path.clone()),
|
||||||
|
uris: vec!["http://example.com/coalesced-tail.bin".to_owned()],
|
||||||
|
},
|
||||||
|
&downloader,
|
||||||
|
)
|
||||||
|
.expect("runtime should coalesce tiny follow-up tail after probe");
|
||||||
|
|
||||||
|
assert_eq!(report.completed_download_count, 1);
|
||||||
|
assert_eq!(report.first_status.as_deref(), Some("complete"));
|
||||||
|
assert_eq!(report.first_completed_length, Some(36));
|
||||||
|
|
||||||
|
let recorded = downloader.recorded_requests();
|
||||||
|
let [probe, first_followup] = recorded.as_slice() else {
|
||||||
|
panic!("expected exactly two recorded requests");
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
probe.request.range,
|
||||||
|
Some(aria2_rust_pro_protocol::RangeSpec {
|
||||||
|
start: 0,
|
||||||
|
end_inclusive: Some(31),
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
first_followup.request.range,
|
||||||
|
Some(aria2_rust_pro_protocol::RangeSpec {
|
||||||
|
start: 32,
|
||||||
|
end_inclusive: Some(35),
|
||||||
|
unit: aria2_rust_pro_protocol::RangeUnit::Bytes,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_file(config_path);
|
||||||
|
}
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
#![doc(hidden)]
|
||||||
|
#![expect(
|
||||||
|
clippy::redundant_pub_crate,
|
||||||
|
reason = "this private transfer-resolution module shares parent-only helpers across the split CLI facade"
|
||||||
|
)]
|
||||||
|
|
||||||
|
use std::{fs, path::Path};
|
||||||
|
|
||||||
|
use aria2_rust_pro_compat::{ConfigParseError, ConfigProfile};
|
||||||
|
use aria2_rust_pro_core::RuntimeConfig;
|
||||||
|
use aria2_rust_pro_protocol::{
|
||||||
|
Downloader, FtpCommandModel, FtpConfigModel, FtpMode, FtpRequestModel, HttpResponseModel,
|
||||||
|
HttpSessionModel, MetalinkParserModel, Protocol, ResponseBody, SftpCommandModel,
|
||||||
|
SftpConfigModel, SftpRequestModel,
|
||||||
|
};
|
||||||
|
use aria2_rust_pro_rpc::{InProcessRpcDispatcher, JsonRpcRequest, RpcMeta, RpcMethod, RpcValue};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
CliError, TransferInputEntry, apply_proxy_auth_overrides, build_http_transfer_task,
|
||||||
|
classify_transfer, execute_http_transfer_with_retry, merged_profile,
|
||||||
|
metalink_entry_implied_profile, parse_bool_text, parse_csv_text, parse_protocol,
|
||||||
|
parse_proxy_text, profile_option_value, rpc_option_object,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Parsed authority and path components for FTP- and SFTP-style URIs.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub(super) struct ParsedRemoteUri {
|
||||||
|
/// URI scheme such as `ftp` or `sftp`.
|
||||||
|
pub(super) scheme: String,
|
||||||
|
/// Remote hostname.
|
||||||
|
pub(super) host: String,
|
||||||
|
/// Remote service port.
|
||||||
|
pub(super) port: u16,
|
||||||
|
/// Optional username from URI user info.
|
||||||
|
pub(super) username: Option<String>,
|
||||||
|
/// Optional password from URI user info.
|
||||||
|
pub(super) password: Option<String>,
|
||||||
|
/// Canonical path component beginning with `/`.
|
||||||
|
pub(super) path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses an FTP- or SFTP-style URI into its normalized components.
|
||||||
|
pub(super) fn parse_remote_uri(uri: &str) -> Option<ParsedRemoteUri> {
|
||||||
|
let (scheme, remainder) = uri.split_once("://")?;
|
||||||
|
let (authority, path_part) = match remainder.split_once('/') {
|
||||||
|
Some((authority, path)) => (authority, format!("/{path}")),
|
||||||
|
None => (remainder, "/".to_owned()),
|
||||||
|
};
|
||||||
|
let (user_info, host_port) = match authority.rsplit_once('@') {
|
||||||
|
Some((user_info, host_port)) => (Some(user_info), host_port),
|
||||||
|
None => (None, authority),
|
||||||
|
};
|
||||||
|
let (host, port) = match host_port.rsplit_once(':') {
|
||||||
|
Some((host, port_text)) => (host, port_text.parse().ok()?),
|
||||||
|
None if scheme.eq_ignore_ascii_case("ftp") => (host_port, 21),
|
||||||
|
None if scheme.eq_ignore_ascii_case("sftp") => (host_port, 22),
|
||||||
|
None => return None,
|
||||||
|
};
|
||||||
|
let (username, password) = match user_info.and_then(|value| value.split_once(':')) {
|
||||||
|
Some((username, password)) => (Some(username.to_owned()), Some(password.to_owned())),
|
||||||
|
None => (user_info.map(str::to_owned), None),
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(ParsedRemoteUri {
|
||||||
|
scheme: scheme.to_owned(),
|
||||||
|
host: host.to_owned(),
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
path: path_part,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds protocol-layer FTP config and request models for a URI.
|
||||||
|
pub(super) fn build_ftp_transfer_parts(
|
||||||
|
uri: &str,
|
||||||
|
profile: Option<&ConfigProfile>,
|
||||||
|
http_session: &HttpSessionModel,
|
||||||
|
) -> Option<(FtpConfigModel, FtpRequestModel)> {
|
||||||
|
let parsed = parse_remote_uri(uri)?;
|
||||||
|
if !parsed.scheme.eq_ignore_ascii_case("ftp") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ftp_passive = profile
|
||||||
|
.and_then(|profile| profile_option_value(profile, "ftp-pasv"))
|
||||||
|
.and_then(parse_bool_text)
|
||||||
|
.unwrap_or(true);
|
||||||
|
let ftp_mode = if ftp_passive {
|
||||||
|
FtpMode::Passive
|
||||||
|
} else {
|
||||||
|
FtpMode::Active
|
||||||
|
};
|
||||||
|
let ftp_proxy = profile.and_then(|profile| {
|
||||||
|
let bypass_hosts =
|
||||||
|
profile_option_value(profile, "no-proxy").map_or_else(Vec::new, parse_csv_text);
|
||||||
|
if let Some(proxy_text) = profile_option_value(profile, "ftp-proxy") {
|
||||||
|
let mut proxy = parse_proxy_text(proxy_text, bypass_hosts)?;
|
||||||
|
apply_proxy_auth_overrides(&mut proxy, profile, "ftp-proxy-user", "ftp-proxy-passwd");
|
||||||
|
Some(proxy)
|
||||||
|
} else if let Some(proxy_text) = profile_option_value(profile, "all-proxy") {
|
||||||
|
let mut proxy = parse_proxy_text(proxy_text, bypass_hosts)?;
|
||||||
|
apply_proxy_auth_overrides(&mut proxy, profile, "all-proxy-user", "all-proxy-passwd");
|
||||||
|
Some(proxy)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Some((
|
||||||
|
FtpConfigModel {
|
||||||
|
host: parsed.host,
|
||||||
|
port: parsed.port,
|
||||||
|
username: parsed.username.or_else(|| {
|
||||||
|
profile
|
||||||
|
.and_then(|profile| profile_option_value(profile, "ftp-user"))
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
}),
|
||||||
|
password: parsed.password.or_else(|| {
|
||||||
|
profile
|
||||||
|
.and_then(|profile| profile_option_value(profile, "ftp-passwd"))
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
}),
|
||||||
|
secure: false,
|
||||||
|
mode: ftp_mode,
|
||||||
|
initial_cwd: None,
|
||||||
|
proxy: ftp_proxy,
|
||||||
|
tls: None,
|
||||||
|
retry: http_session.retry,
|
||||||
|
},
|
||||||
|
FtpRequestModel {
|
||||||
|
command: FtpCommandModel::Retr(parsed.path.clone()),
|
||||||
|
path: Some(parsed.path),
|
||||||
|
headers: Vec::new(),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds protocol-layer SFTP config and request models for a URI.
|
||||||
|
pub(super) fn build_sftp_transfer_parts(
|
||||||
|
uri: &str,
|
||||||
|
http_session: &HttpSessionModel,
|
||||||
|
) -> Option<(SftpConfigModel, SftpRequestModel)> {
|
||||||
|
let parsed = parse_remote_uri(uri)?;
|
||||||
|
if !parsed.scheme.eq_ignore_ascii_case("sftp") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some((
|
||||||
|
SftpConfigModel {
|
||||||
|
host: parsed.host,
|
||||||
|
port: parsed.port,
|
||||||
|
username: parsed.username,
|
||||||
|
password: parsed.password,
|
||||||
|
private_key_path: None,
|
||||||
|
known_hosts_path: None,
|
||||||
|
strict_host_key_checking: true,
|
||||||
|
proxy: http_session.proxy.clone(),
|
||||||
|
tls: None,
|
||||||
|
retry: http_session.retry,
|
||||||
|
},
|
||||||
|
SftpRequestModel {
|
||||||
|
command: SftpCommandModel::Read {
|
||||||
|
path: parsed.path.clone(),
|
||||||
|
offset: 0,
|
||||||
|
length: u64::MAX,
|
||||||
|
},
|
||||||
|
path: Some(parsed.path),
|
||||||
|
headers: Vec::new(),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads a Metalink file from disk and returns executable transfer entries.
|
||||||
|
pub(super) fn parse_metalink_transfer_entries(
|
||||||
|
path: &Path,
|
||||||
|
) -> Result<Vec<TransferInputEntry>, CliError> {
|
||||||
|
let text = fs::read_to_string(path).map_err(|error| CliError::Io(error.to_string()))?;
|
||||||
|
parse_metalink_transfer_entries_from_text(&text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses Metalink XML text and returns executable transfer entries.
|
||||||
|
pub(super) fn parse_metalink_transfer_entries_from_text(
|
||||||
|
text: &str,
|
||||||
|
) -> Result<Vec<TransferInputEntry>, CliError> {
|
||||||
|
let result = MetalinkParserModel::new(true, false).parse(text);
|
||||||
|
let document = result.document.ok_or_else(|| {
|
||||||
|
CliError::Io(
|
||||||
|
result
|
||||||
|
.parser
|
||||||
|
.last_error
|
||||||
|
.unwrap_or_else(|| "metalink parse failed".to_owned()),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let entries = aria2_rust_pro_protocol::metalink_download_plan(&document)
|
||||||
|
.into_iter()
|
||||||
|
.map(|entry| TransferInputEntry {
|
||||||
|
uris: entry.uris,
|
||||||
|
implied_profile: metalink_entry_implied_profile(
|
||||||
|
&entry.file_name,
|
||||||
|
entry.checksum.as_ref(),
|
||||||
|
),
|
||||||
|
profile: None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
if entries.is_empty() {
|
||||||
|
return Err(CliError::Io(
|
||||||
|
"metalink document contains no usable resource url".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a streamed or inline HTTP response body into owned bytes.
|
||||||
|
pub(super) fn http_response_body_bytes(
|
||||||
|
response: &HttpResponseModel,
|
||||||
|
label: &str,
|
||||||
|
) -> Result<Vec<u8>, CliError> {
|
||||||
|
match &response.body {
|
||||||
|
ResponseBody::Empty => Ok(Vec::new()),
|
||||||
|
ResponseBody::Inline(bytes) => Ok(bytes.clone()),
|
||||||
|
ResponseBody::Streamed { temp_path, .. } => {
|
||||||
|
let Some(temp_path) = temp_path else {
|
||||||
|
return Err(CliError::Io(format!(
|
||||||
|
"{label} response missing streamed temp file"
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
let bytes = fs::read(temp_path).map_err(|error| CliError::Io(error.to_string()))?;
|
||||||
|
let _ = fs::remove_file(temp_path);
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes an HTTP response body into text for Metalink parsing.
|
||||||
|
pub(super) fn metalink_response_text(response: &HttpResponseModel) -> Result<String, CliError> {
|
||||||
|
let bytes = http_response_body_bytes(response, "metalink document")?;
|
||||||
|
String::from_utf8(bytes)
|
||||||
|
.map_err(|error| CliError::Io(format!("metalink document is not valid utf-8: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves one logical transfer entity, dereferencing Metalink inputs when needed.
|
||||||
|
pub(super) fn resolve_transfer_entry_with_downloader<D: Downloader + Sync>(
|
||||||
|
entry: &TransferInputEntry,
|
||||||
|
downloader: &D,
|
||||||
|
session: &HttpSessionModel,
|
||||||
|
runtime: &RuntimeConfig,
|
||||||
|
) -> Result<Vec<TransferInputEntry>, CliError> {
|
||||||
|
let Some(primary_uri) = entry.uris.first() else {
|
||||||
|
return Err(CliError::Config(ConfigParseError::InvalidDirective(
|
||||||
|
"input-file entry missing URI".to_owned(),
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
|
||||||
|
let shared_profile = entry.profile.clone();
|
||||||
|
|
||||||
|
let with_shared_profile = |entries: Vec<TransferInputEntry>| {
|
||||||
|
entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|resolved| TransferInputEntry {
|
||||||
|
uris: resolved.uris,
|
||||||
|
implied_profile: resolved.implied_profile,
|
||||||
|
profile: shared_profile.clone(),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
|
||||||
|
if classify_transfer(primary_uri) != super::TransferSelection::Metalink {
|
||||||
|
return Ok(vec![entry.clone()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
match parse_protocol(primary_uri) {
|
||||||
|
Some(Protocol::Http | Protocol::Https) => {
|
||||||
|
let task = build_http_transfer_task(
|
||||||
|
"metalink-bootstrap".to_owned(),
|
||||||
|
primary_uri.clone(),
|
||||||
|
session,
|
||||||
|
runtime,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let execution = execute_http_transfer_with_retry(downloader, &task, runtime);
|
||||||
|
let Some(response) = execution.response else {
|
||||||
|
return Err(CliError::Io(format!(
|
||||||
|
"failed to fetch metalink document: {primary_uri}"
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
if !(200..=299).contains(&response.status) {
|
||||||
|
return Err(CliError::Io(format!(
|
||||||
|
"failed to fetch metalink document {primary_uri}: HTTP {}",
|
||||||
|
response.status
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let text = metalink_response_text(&response)?;
|
||||||
|
parse_metalink_transfer_entries_from_text(&text).map(with_shared_profile)
|
||||||
|
}
|
||||||
|
_ => parse_metalink_transfer_entries(Path::new(primary_uri)).map(with_shared_profile),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads torrent payload bytes from a local path or supported remote transport.
|
||||||
|
pub(super) fn load_torrent_payload_bytes<D: Downloader + Sync>(
|
||||||
|
downloader: &D,
|
||||||
|
uri: &str,
|
||||||
|
profile: Option<&ConfigProfile>,
|
||||||
|
http_session: &HttpSessionModel,
|
||||||
|
derived_runtime: &RuntimeConfig,
|
||||||
|
) -> Result<Vec<u8>, CliError> {
|
||||||
|
match parse_protocol(uri) {
|
||||||
|
Some(Protocol::Http | Protocol::Https) => {
|
||||||
|
let task = build_http_transfer_task(
|
||||||
|
"torrent-bootstrap".to_owned(),
|
||||||
|
uri.to_owned(),
|
||||||
|
http_session,
|
||||||
|
derived_runtime,
|
||||||
|
profile,
|
||||||
|
);
|
||||||
|
let execution = execute_http_transfer_with_retry(downloader, &task, derived_runtime);
|
||||||
|
let Some(response) = execution.response else {
|
||||||
|
return Err(CliError::Io(format!(
|
||||||
|
"failed to fetch torrent metadata: {uri}"
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
if !(200..=299).contains(&response.status) {
|
||||||
|
return Err(CliError::Io(format!(
|
||||||
|
"failed to fetch torrent metadata {uri}: HTTP {}",
|
||||||
|
response.status
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
http_response_body_bytes(&response, "torrent metadata")
|
||||||
|
}
|
||||||
|
Some(Protocol::Ftp) => {
|
||||||
|
let Some((config, request)) = build_ftp_transfer_parts(uri, profile, http_session)
|
||||||
|
else {
|
||||||
|
return Err(CliError::Io(format!("failed to parse ftp uri: {uri}")));
|
||||||
|
};
|
||||||
|
let response = downloader
|
||||||
|
.start_ftp_transfer(&config, &request)
|
||||||
|
.map_err(|error| CliError::Io(error.to_string()))?;
|
||||||
|
response.data.ok_or_else(|| {
|
||||||
|
CliError::Io(format!(
|
||||||
|
"torrent metadata transfer returned no ftp payload: {uri}"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Some(Protocol::Sftp) => {
|
||||||
|
let Some((config, request)) = build_sftp_transfer_parts(uri, http_session) else {
|
||||||
|
return Err(CliError::Io(format!("failed to parse sftp uri: {uri}")));
|
||||||
|
};
|
||||||
|
let response = downloader
|
||||||
|
.start_sftp_transfer(&config, &request)
|
||||||
|
.map_err(|error| CliError::Io(error.to_string()))?;
|
||||||
|
response.payload.ok_or_else(|| {
|
||||||
|
CliError::Io(format!(
|
||||||
|
"torrent metadata transfer returned no sftp payload: {uri}"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => fs::read(uri)
|
||||||
|
.map_err(|error| CliError::Io(format!("failed to read torrent file {uri}: {error}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes bytes as standard base64 without pulling extra crate ownership into the CLI.
|
||||||
|
pub(super) fn encode_base64(bytes: &[u8]) -> String {
|
||||||
|
let mut encoded = String::with_capacity(bytes.len().div_ceil(3).saturating_mul(4));
|
||||||
|
|
||||||
|
for chunk in bytes.chunks(3) {
|
||||||
|
match chunk {
|
||||||
|
[b0, b1, b2] => {
|
||||||
|
let combined = (u32::from(*b0) << 16) | (u32::from(*b1) << 8) | u32::from(*b2);
|
||||||
|
encoded.push(base64_alphabet_char((combined >> 18) & 0x3f));
|
||||||
|
encoded.push(base64_alphabet_char((combined >> 12) & 0x3f));
|
||||||
|
encoded.push(base64_alphabet_char((combined >> 6) & 0x3f));
|
||||||
|
encoded.push(base64_alphabet_char(combined & 0x3f));
|
||||||
|
}
|
||||||
|
[b0, b1] => {
|
||||||
|
let combined = (u32::from(*b0) << 16) | (u32::from(*b1) << 8);
|
||||||
|
encoded.push(base64_alphabet_char((combined >> 18) & 0x3f));
|
||||||
|
encoded.push(base64_alphabet_char((combined >> 12) & 0x3f));
|
||||||
|
encoded.push(base64_alphabet_char((combined >> 6) & 0x3f));
|
||||||
|
encoded.push('=');
|
||||||
|
}
|
||||||
|
[b0] => {
|
||||||
|
let combined = u32::from(*b0) << 16;
|
||||||
|
encoded.push(base64_alphabet_char((combined >> 18) & 0x3f));
|
||||||
|
encoded.push(base64_alphabet_char((combined >> 12) & 0x3f));
|
||||||
|
encoded.push('=');
|
||||||
|
encoded.push('=');
|
||||||
|
}
|
||||||
|
[] => {}
|
||||||
|
_ => unreachable!("chunks(3) never yields slices longer than 3"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps one 6-bit base64 alphabet index into its ASCII output character.
|
||||||
|
fn base64_alphabet_char(index: u32) -> char {
|
||||||
|
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
let normalized = u8::try_from(index).expect("base64 alphabet indices fit into u8");
|
||||||
|
ALPHABET
|
||||||
|
.get(usize::from(normalized))
|
||||||
|
.copied()
|
||||||
|
.map(char::from)
|
||||||
|
.expect("base64 alphabet index must remain within range")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal registration surface used when seeding dispatcher state.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub(super) enum DispatcherRegistrationKind {
|
||||||
|
/// Register the entry through `aria2.addUri`.
|
||||||
|
Uri,
|
||||||
|
/// Register the entry through `aria2.addTorrent`.
|
||||||
|
Torrent,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the dispatcher registration path for a resolved entry.
|
||||||
|
pub(super) fn register_resolved_entry_with_dispatcher<D: Downloader + Sync>(
|
||||||
|
dispatcher: &mut InProcessRpcDispatcher,
|
||||||
|
downloader: &D,
|
||||||
|
entry: &TransferInputEntry,
|
||||||
|
resolved_uri: &str,
|
||||||
|
profile: Option<&ConfigProfile>,
|
||||||
|
http_session: &HttpSessionModel,
|
||||||
|
derived_runtime: &RuntimeConfig,
|
||||||
|
) -> Result<(String, DispatcherRegistrationKind), CliError> {
|
||||||
|
let rpc_profile = merged_profile(entry.implied_profile.as_ref(), entry.profile.as_ref());
|
||||||
|
let registration_kind = match classify_transfer(resolved_uri) {
|
||||||
|
super::TransferSelection::Torrent => DispatcherRegistrationKind::Torrent,
|
||||||
|
_ => DispatcherRegistrationKind::Uri,
|
||||||
|
};
|
||||||
|
|
||||||
|
let gid = match registration_kind {
|
||||||
|
DispatcherRegistrationKind::Uri => {
|
||||||
|
let uris = entry
|
||||||
|
.uris
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, uri)| {
|
||||||
|
if index == 0 {
|
||||||
|
resolved_uri.to_owned()
|
||||||
|
} else {
|
||||||
|
uri.clone()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let options = rpc_profile
|
||||||
|
.as_ref()
|
||||||
|
.map_or_else(Vec::new, profile_string_options);
|
||||||
|
dispatcher
|
||||||
|
.add_uri_direct_string_options(uris, options)
|
||||||
|
.map_err(|error| CliError::Rpc(error.message))?
|
||||||
|
}
|
||||||
|
DispatcherRegistrationKind::Torrent => {
|
||||||
|
let payload = load_torrent_payload_bytes(
|
||||||
|
downloader,
|
||||||
|
resolved_uri,
|
||||||
|
profile,
|
||||||
|
http_session,
|
||||||
|
derived_runtime,
|
||||||
|
)?;
|
||||||
|
let mut params = vec![RpcValue::String(encode_base64(&payload))];
|
||||||
|
if let Some(options) = rpc_option_object(rpc_profile.as_ref()) {
|
||||||
|
params.push(RpcValue::Array(Vec::new()));
|
||||||
|
params.push(options);
|
||||||
|
}
|
||||||
|
dispatcher
|
||||||
|
.dispatch_json(JsonRpcRequest {
|
||||||
|
jsonrpc: Some("2.0".to_owned()),
|
||||||
|
id: None,
|
||||||
|
method: RpcMethod::Aria2AddTorrent.as_str().to_owned(),
|
||||||
|
params,
|
||||||
|
meta: RpcMeta::default(),
|
||||||
|
})
|
||||||
|
.result
|
||||||
|
.and_then(|value| match value {
|
||||||
|
RpcValue::String(gid) => Some(gid),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.ok_or_else(|| {
|
||||||
|
CliError::Rpc("dispatcher registration did not return a gid".to_owned())
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok((gid, registration_kind))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Projects profile directives into the string option shape consumed by direct URI registration.
|
||||||
|
pub(super) fn profile_string_options(profile: &ConfigProfile) -> Vec<(String, String)> {
|
||||||
|
profile
|
||||||
|
.document
|
||||||
|
.directives
|
||||||
|
.iter()
|
||||||
|
.filter_map(|directive| {
|
||||||
|
directive
|
||||||
|
.value
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| (directive.name.clone(), value.clone()))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
#![doc(hidden)]
|
||||||
|
#![expect(
|
||||||
|
clippy::redundant_pub_crate,
|
||||||
|
reason = "this private transfer-runtime module shares parent-only helpers across the split CLI facade"
|
||||||
|
)]
|
||||||
|
|
||||||
|
use std::{env, time::Instant};
|
||||||
|
|
||||||
|
use aria2_rust_pro_compat::ConfigProfile;
|
||||||
|
use aria2_rust_pro_core::{DownloadStatus, RuntimeConfig};
|
||||||
|
use aria2_rust_pro_protocol::{
|
||||||
|
Downloader, HttpResponseModel, HttpSessionModel, Protocol, ReqwestTrackerTransport,
|
||||||
|
StdDhtTransport, StdTcpPeerWireTransportConnector,
|
||||||
|
};
|
||||||
|
use aria2_rust_pro_rpc::{
|
||||||
|
InProcessRpcDispatcher, JsonRpcRequest, RpcMeta, RpcMethod, RpcStatusSummary, RpcValue,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
CliError, build_ftp_transfer_parts, build_http_transfer_task_with_target,
|
||||||
|
build_initial_http_execution_plan, build_segment_transfer_tasks, build_sftp_transfer_parts,
|
||||||
|
http_execution_completed_via_checksum, lossless_u64_from_usize, parse_protocol,
|
||||||
|
persist_http_response_body_to_target, prepare_http_target_path, rpc_bool, rpc_u64,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Minimal BT execution plan that keeps current tracker support and future hooks aligned.
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub(super) struct BtRuntimeExecutionPlan {
|
||||||
|
/// Whether the runtime exposes BitTorrent-specific state for the download.
|
||||||
|
pub(super) is_bt: bool,
|
||||||
|
/// Whether an HTTP(S) tracker is immediately runnable via the live reqwest transport.
|
||||||
|
pub(super) has_live_http_tracker: bool,
|
||||||
|
/// Whether the request still lacks metadata, which blocks later peer-wire execution.
|
||||||
|
pub(super) metadata_only: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maximum BT coordinator iterations attempted for one foreground execution pass.
|
||||||
|
const MAX_BT_RUNTIME_ROUNDS: usize = 32;
|
||||||
|
/// Maximum consecutive idle coordinator rounds tolerated before returning.
|
||||||
|
const MAX_BT_IDLE_ROUNDS: usize = 6;
|
||||||
|
|
||||||
|
/// Reads one `aria2.tellStatus` object from the dispatcher.
|
||||||
|
pub(super) fn dispatcher_status_for_gid(
|
||||||
|
dispatcher: &mut InProcessRpcDispatcher,
|
||||||
|
gid: &str,
|
||||||
|
) -> Result<std::collections::BTreeMap<String, RpcValue>, CliError> {
|
||||||
|
let response = dispatcher.dispatch_json(JsonRpcRequest {
|
||||||
|
jsonrpc: Some("2.0".to_owned()),
|
||||||
|
id: None,
|
||||||
|
method: RpcMethod::Aria2TellStatus.as_str().to_owned(),
|
||||||
|
params: vec![RpcValue::String(gid.to_owned())],
|
||||||
|
meta: RpcMeta::default(),
|
||||||
|
});
|
||||||
|
let Some(RpcValue::Object(status)) = response.result else {
|
||||||
|
let message = response.error.map_or_else(
|
||||||
|
|| "tellStatus did not return a status".to_owned(),
|
||||||
|
|error| error.message,
|
||||||
|
);
|
||||||
|
return Err(CliError::Rpc(message));
|
||||||
|
};
|
||||||
|
Ok(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the lightweight internal status summary for one tracked download.
|
||||||
|
pub(super) fn dispatcher_status_summary_for_gid(
|
||||||
|
dispatcher: &InProcessRpcDispatcher,
|
||||||
|
gid: &str,
|
||||||
|
) -> Result<RpcStatusSummary, CliError> {
|
||||||
|
dispatcher
|
||||||
|
.status_summary_for_gid(gid)
|
||||||
|
.map_err(|error| CliError::Rpc(error.message))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts a core download status into the canonical aria2 RPC status text.
|
||||||
|
pub(super) fn rpc_status_text(status: DownloadStatus) -> String {
|
||||||
|
status.as_rpc_status().to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether one HTTP response represents an observed terminal success.
|
||||||
|
pub(super) fn http_response_is_terminal_success(response: &HttpResponseModel) -> bool {
|
||||||
|
(200..=299).contains(&response.status)
|
||||||
|
&& response
|
||||||
|
.total_length()
|
||||||
|
.is_none_or(|total_length| response.completed_length() >= total_length)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derives the currently runnable BT phases from a tellStatus payload.
|
||||||
|
pub(super) fn bt_runtime_execution_plan(
|
||||||
|
status: &std::collections::BTreeMap<String, RpcValue>,
|
||||||
|
) -> BtRuntimeExecutionPlan {
|
||||||
|
let has_live_http_tracker = status
|
||||||
|
.get("announceList")
|
||||||
|
.and_then(|value| match value {
|
||||||
|
RpcValue::Array(tiers) => Some(tiers.iter().any(|tier| match tier {
|
||||||
|
RpcValue::Array(trackers) => trackers.iter().any(|tracker| match tracker {
|
||||||
|
RpcValue::String(uri) => {
|
||||||
|
matches!(parse_protocol(uri), Some(Protocol::Http | Protocol::Https))
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}),
|
||||||
|
_ => false,
|
||||||
|
})),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
BtRuntimeExecutionPlan {
|
||||||
|
is_bt: rpc_bool(status.get("isBt")).unwrap_or(false),
|
||||||
|
has_live_http_tracker,
|
||||||
|
metadata_only: rpc_bool(status.get("metadataOnly")).unwrap_or(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compact BT progress snapshot used to detect observable runtime movement.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub(super) struct BtRuntimeProgressSnapshot {
|
||||||
|
/// Downloaded payload bytes reported by `aria2.tellStatus`.
|
||||||
|
pub(super) completed_length: u64,
|
||||||
|
/// Uploaded payload bytes reported by `aria2.tellStatus`.
|
||||||
|
pub(super) upload_length: u64,
|
||||||
|
/// Active peer connection count reported by `aria2.tellStatus`.
|
||||||
|
pub(super) connections: u64,
|
||||||
|
/// Whether the session still operates in metadata-only magnet mode.
|
||||||
|
pub(super) metadata_only: bool,
|
||||||
|
/// Whether the runtime already reported terminal completion.
|
||||||
|
pub(super) complete: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts the BT progress counters relevant for foreground progress detection.
|
||||||
|
pub(super) fn bt_runtime_progress_snapshot(
|
||||||
|
status: &std::collections::BTreeMap<String, RpcValue>,
|
||||||
|
) -> BtRuntimeProgressSnapshot {
|
||||||
|
BtRuntimeProgressSnapshot {
|
||||||
|
completed_length: rpc_u64(status.get("completedLength")).unwrap_or(0),
|
||||||
|
upload_length: rpc_u64(status.get("uploadLength")).unwrap_or(0),
|
||||||
|
connections: rpc_u64(status.get("connections")).unwrap_or(0),
|
||||||
|
metadata_only: rpc_bool(status.get("metadataOnly")).unwrap_or(false),
|
||||||
|
complete: matches!(status.get("status"), Some(RpcValue::String(value)) if value == "complete"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether a later BT snapshot shows visible progress versus the earlier one.
|
||||||
|
pub(super) const fn bt_runtime_has_progress(
|
||||||
|
before: BtRuntimeProgressSnapshot,
|
||||||
|
after: BtRuntimeProgressSnapshot,
|
||||||
|
) -> bool {
|
||||||
|
after.complete
|
||||||
|
|| after.completed_length > before.completed_length
|
||||||
|
|| after.upload_length > before.upload_length
|
||||||
|
|| after.connections > before.connections
|
||||||
|
|| after.metadata_only != before.metadata_only
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes the currently available BT runtime phases for one registered download.
|
||||||
|
pub(super) fn execute_bt_runtime_for_gid(
|
||||||
|
dispatcher: &mut InProcessRpcDispatcher,
|
||||||
|
gid: &str,
|
||||||
|
) -> Result<(), CliError> {
|
||||||
|
let tracker_transport = ReqwestTrackerTransport::new().ok();
|
||||||
|
let dht_transport = StdDhtTransport::default();
|
||||||
|
let peer_wire_transport = StdTcpPeerWireTransportConnector::default();
|
||||||
|
let mut idle_rounds = 0_usize;
|
||||||
|
|
||||||
|
for _ in 0..MAX_BT_RUNTIME_ROUNDS {
|
||||||
|
let status = dispatcher_status_for_gid(dispatcher, gid)?;
|
||||||
|
let plan = bt_runtime_execution_plan(&status);
|
||||||
|
if !plan.is_bt {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let before = bt_runtime_progress_snapshot(&status);
|
||||||
|
if before.complete {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tracker_transport_ref = tracker_transport
|
||||||
|
.as_ref()
|
||||||
|
.filter(|_| plan.has_live_http_tracker)
|
||||||
|
.map(|transport| -> &dyn aria2_rust_pro_protocol::TrackerTransport { transport });
|
||||||
|
|
||||||
|
let _ = dispatcher.drive_bt_runtime_once(
|
||||||
|
gid,
|
||||||
|
tracker_transport_ref,
|
||||||
|
Some(&dht_transport),
|
||||||
|
Some(&peer_wire_transport),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
let after_status = dispatcher_status_for_gid(dispatcher, gid)?;
|
||||||
|
let after = bt_runtime_progress_snapshot(&after_status);
|
||||||
|
if after.complete {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if bt_runtime_has_progress(before, after) {
|
||||||
|
idle_rounds = 0;
|
||||||
|
} else {
|
||||||
|
idle_rounds = idle_rounds.saturating_add(1);
|
||||||
|
if idle_rounds >= MAX_BT_IDLE_ROUNDS {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::too_many_lines,
|
||||||
|
reason = "one URI execution keeps protocol dispatch, telemetry, and completion accounting in one observable flow"
|
||||||
|
)]
|
||||||
|
/// Executes one transfer URI through the in-process runtime surface.
|
||||||
|
pub(super) fn execute_transfer_for_uri<D: Downloader + Sync>(
|
||||||
|
dispatcher: &mut InProcessRpcDispatcher,
|
||||||
|
downloader: &D,
|
||||||
|
uri: &str,
|
||||||
|
gid: &str,
|
||||||
|
profile: Option<&ConfigProfile>,
|
||||||
|
http_session: &HttpSessionModel,
|
||||||
|
derived_runtime: &RuntimeConfig,
|
||||||
|
) -> Result<bool, CliError> {
|
||||||
|
match parse_protocol(uri) {
|
||||||
|
Some(Protocol::Http | Protocol::Https) => {
|
||||||
|
let timing_probe = env::var_os("ARIA2_RUST_PRO_HTTP_TIMING").is_some();
|
||||||
|
let overall_started = timing_probe.then(Instant::now);
|
||||||
|
let target_path = prepare_http_target_path(profile, uri)?;
|
||||||
|
let task = build_http_transfer_task_with_target(
|
||||||
|
gid.to_owned(),
|
||||||
|
uri.to_owned(),
|
||||||
|
http_session,
|
||||||
|
derived_runtime,
|
||||||
|
profile,
|
||||||
|
Some(target_path.clone()),
|
||||||
|
);
|
||||||
|
let mut cumulative_retry_count = 0_u32;
|
||||||
|
let mut uri_marked_complete = false;
|
||||||
|
let initial_plan_started = timing_probe.then(Instant::now);
|
||||||
|
let initial_plan =
|
||||||
|
build_initial_http_execution_plan(downloader, &task, derived_runtime);
|
||||||
|
let initial_plan_elapsed_ms = initial_plan_started
|
||||||
|
.as_ref()
|
||||||
|
.map(|started| started.elapsed().as_millis());
|
||||||
|
let bootstrap_task = initial_plan.task;
|
||||||
|
let bootstrap_execution = initial_plan.execution;
|
||||||
|
let mut planned_segment_tasks = initial_plan.planned_segments;
|
||||||
|
let _telemetry_trace = (
|
||||||
|
bootstrap_execution.retry_attempts.len(),
|
||||||
|
bootstrap_execution.planned_ranges.len(),
|
||||||
|
);
|
||||||
|
if let Some(ref response) = bootstrap_execution.response {
|
||||||
|
cumulative_retry_count =
|
||||||
|
cumulative_retry_count.saturating_add(bootstrap_execution.retry_count);
|
||||||
|
let persist_started = timing_probe.then(Instant::now);
|
||||||
|
persist_http_response_body_to_target(&target_path, &bootstrap_task, response)?;
|
||||||
|
let persist_elapsed_ms = persist_started
|
||||||
|
.as_ref()
|
||||||
|
.map(|started| started.elapsed().as_millis());
|
||||||
|
let completed_via_checksum = http_execution_completed_via_checksum(
|
||||||
|
&bootstrap_execution,
|
||||||
|
response,
|
||||||
|
profile,
|
||||||
|
uri,
|
||||||
|
&bootstrap_task,
|
||||||
|
);
|
||||||
|
let record_started = timing_probe.then(Instant::now);
|
||||||
|
dispatcher
|
||||||
|
.record_http_transfer_result(
|
||||||
|
gid,
|
||||||
|
response,
|
||||||
|
bootstrap_task.max_connections,
|
||||||
|
cumulative_retry_count,
|
||||||
|
!bootstrap_execution.checksum_observed || completed_via_checksum,
|
||||||
|
)
|
||||||
|
.map_err(|error| CliError::Rpc(error.message))?;
|
||||||
|
let record_elapsed_ms = record_started
|
||||||
|
.as_ref()
|
||||||
|
.map(|started| started.elapsed().as_millis());
|
||||||
|
if http_response_is_terminal_success(response)
|
||||||
|
&& (!bootstrap_execution.checksum_observed || completed_via_checksum)
|
||||||
|
{
|
||||||
|
uri_marked_complete = true;
|
||||||
|
}
|
||||||
|
if let Some(total_started) = overall_started.as_ref() {
|
||||||
|
eprintln!(
|
||||||
|
"http timing uri={uri} status={} initial_plan_ms={} persist_ms={} record_ms={} total_ms={} partial={} completed={} total_length={:?}",
|
||||||
|
response.status,
|
||||||
|
initial_plan_elapsed_ms.unwrap_or_default(),
|
||||||
|
persist_elapsed_ms.unwrap_or_default(),
|
||||||
|
record_elapsed_ms.unwrap_or_default(),
|
||||||
|
total_started.elapsed().as_millis(),
|
||||||
|
response.partial_content,
|
||||||
|
response.completed_length(),
|
||||||
|
response.total_length(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let needs_segment_followups = response.partial_content
|
||||||
|
&& response
|
||||||
|
.total_length()
|
||||||
|
.is_some_and(|total| response.completed_length() < total);
|
||||||
|
if needs_segment_followups {
|
||||||
|
if planned_segment_tasks.is_empty() {
|
||||||
|
let group = dispatcher
|
||||||
|
.prepare_http_download(gid)
|
||||||
|
.map_err(|error| CliError::Rpc(error.message))?;
|
||||||
|
planned_segment_tasks = build_segment_transfer_tasks(&task, &group);
|
||||||
|
}
|
||||||
|
let segment_executions = super::execute_segment_transfers(
|
||||||
|
downloader,
|
||||||
|
planned_segment_tasks,
|
||||||
|
derived_runtime,
|
||||||
|
);
|
||||||
|
for (planned_task, execution) in segment_executions {
|
||||||
|
if let Some(ref response) = execution.response {
|
||||||
|
cumulative_retry_count =
|
||||||
|
cumulative_retry_count.saturating_add(execution.retry_count);
|
||||||
|
persist_http_response_body_to_target(
|
||||||
|
&target_path,
|
||||||
|
&planned_task,
|
||||||
|
response,
|
||||||
|
)?;
|
||||||
|
let completed_via_checksum = http_execution_completed_via_checksum(
|
||||||
|
&execution,
|
||||||
|
response,
|
||||||
|
profile,
|
||||||
|
uri,
|
||||||
|
&planned_task,
|
||||||
|
);
|
||||||
|
dispatcher
|
||||||
|
.record_http_transfer_result(
|
||||||
|
gid,
|
||||||
|
response,
|
||||||
|
bootstrap_task.max_connections,
|
||||||
|
cumulative_retry_count,
|
||||||
|
!execution.checksum_observed || completed_via_checksum,
|
||||||
|
)
|
||||||
|
.map_err(|error| CliError::Rpc(error.message))?;
|
||||||
|
if http_response_is_terminal_success(response)
|
||||||
|
&& (!execution.checksum_observed || completed_via_checksum)
|
||||||
|
{
|
||||||
|
uri_marked_complete = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(uri_marked_complete)
|
||||||
|
}
|
||||||
|
Some(Protocol::Ftp) => {
|
||||||
|
let Some((config, request)) = build_ftp_transfer_parts(uri, profile, http_session)
|
||||||
|
else {
|
||||||
|
return Err(CliError::Io(format!("failed to parse ftp uri: {uri}")));
|
||||||
|
};
|
||||||
|
let response = downloader
|
||||||
|
.start_ftp_transfer(&config, &request)
|
||||||
|
.map_err(|error| CliError::Io(error.to_string()))?;
|
||||||
|
let payload_len = response
|
||||||
|
.data
|
||||||
|
.as_ref()
|
||||||
|
.map_or(0_u64, |data| lossless_u64_from_usize(data.len()));
|
||||||
|
dispatcher
|
||||||
|
.record_transfer_result(gid, payload_len, payload_len, 1, response.transferable, 0)
|
||||||
|
.map_err(|error| CliError::Rpc(error.message))?;
|
||||||
|
Ok(response.transferable)
|
||||||
|
}
|
||||||
|
Some(Protocol::Sftp) => {
|
||||||
|
let Some((config, request)) = build_sftp_transfer_parts(uri, http_session) else {
|
||||||
|
return Err(CliError::Io(format!("failed to parse sftp uri: {uri}")));
|
||||||
|
};
|
||||||
|
let response = downloader
|
||||||
|
.start_sftp_transfer(&config, &request)
|
||||||
|
.map_err(|error| CliError::Io(error.to_string()))?;
|
||||||
|
let payload_len = response
|
||||||
|
.payload
|
||||||
|
.as_ref()
|
||||||
|
.map_or(0_u64, |payload| lossless_u64_from_usize(payload.len()));
|
||||||
|
dispatcher
|
||||||
|
.record_transfer_result(
|
||||||
|
gid,
|
||||||
|
payload_len,
|
||||||
|
payload_len,
|
||||||
|
1,
|
||||||
|
response.transferable && response.ok,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.map_err(|error| CliError::Rpc(error.message))?;
|
||||||
|
Ok(response.transferable && response.ok)
|
||||||
|
}
|
||||||
|
_ => Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
#![expect(
|
||||||
|
clippy::redundant_pub_crate,
|
||||||
|
reason = "this private CLI type hub intentionally reuses the split crate surface and keeps parent-visible types local to the crate API façade"
|
||||||
|
)]
|
||||||
|
|
||||||
|
use super::{ConfigParseError, ConfigProfile, HttpSessionModel, PathBuf, RuntimeConfig};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
/// Selects the top-level process mode implied by parsed CLI options.
|
||||||
|
pub enum RuntimeMode {
|
||||||
|
/// Execute downloads in the foreground process.
|
||||||
|
Foreground,
|
||||||
|
/// Start the RPC daemon after applying daemon-specific flags.
|
||||||
|
Daemon,
|
||||||
|
/// Start only the RPC daemon surface without foreground transfer output.
|
||||||
|
RpcOnly,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
/// Classifies a user-supplied transfer input by its visible surface.
|
||||||
|
pub enum TransferSelection {
|
||||||
|
/// A plain URI-style transfer input.
|
||||||
|
Uri,
|
||||||
|
/// A `.torrent` file or URL.
|
||||||
|
Torrent,
|
||||||
|
/// A Metalink document or file path.
|
||||||
|
Metalink,
|
||||||
|
/// A `BitTorrent` magnet URI.
|
||||||
|
Magnet,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
/// Captures RPC daemon launch settings derived from startup flags.
|
||||||
|
pub struct RpcLaunchConfig {
|
||||||
|
/// Whether the RPC server should be enabled.
|
||||||
|
pub enabled: bool,
|
||||||
|
/// Host/IP the RPC listener should bind to.
|
||||||
|
pub listen_host: String,
|
||||||
|
/// TCP port the RPC listener should bind to.
|
||||||
|
pub listen_port: u16,
|
||||||
|
/// Optional shared-secret token accepted by the RPC surface.
|
||||||
|
pub secret: Option<String>,
|
||||||
|
/// HTTP path exposed by the RPC listener.
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RpcLaunchConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
listen_host: "127.0.0.1".to_owned(),
|
||||||
|
listen_port: 6800,
|
||||||
|
secret: None,
|
||||||
|
path: "/jsonrpc".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
/// Normalized startup flags that shape execution after argument parsing.
|
||||||
|
pub struct StartupProfile {
|
||||||
|
/// Requested top-level runtime mode.
|
||||||
|
pub mode: RuntimeMode,
|
||||||
|
/// RPC launch overrides collected from CLI flags.
|
||||||
|
pub rpc: RpcLaunchConfig,
|
||||||
|
/// Whether daemonization was explicitly requested.
|
||||||
|
pub daemonize: bool,
|
||||||
|
/// Whether the invocation should validate config only.
|
||||||
|
pub dry_run: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for StartupProfile {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
mode: RuntimeMode::Foreground,
|
||||||
|
rpc: RpcLaunchConfig::default(),
|
||||||
|
daemonize: false,
|
||||||
|
dry_run: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
/// Full CLI parse result, including the selected invocation and startup flags.
|
||||||
|
pub struct ParsedArguments {
|
||||||
|
/// High-level action requested by the user.
|
||||||
|
pub invocation: Invocation,
|
||||||
|
/// Startup modifiers that further shape execution.
|
||||||
|
pub profile: StartupProfile,
|
||||||
|
/// CLI-originated compat directives that should override config-file values.
|
||||||
|
pub cli_profile: Option<ConfigProfile>,
|
||||||
|
/// Ordered CLI transfer sources so mixed positional URIs and `--input-file`
|
||||||
|
/// entries can preserve argv order.
|
||||||
|
pub(crate) cli_transfer_sources: Vec<CliTransferSource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
/// One transfer source observed directly on the command line.
|
||||||
|
pub(crate) enum CliTransferSource {
|
||||||
|
/// A positional URI-like input.
|
||||||
|
Uri(String),
|
||||||
|
/// An input-file reference that should expand into one or more entities.
|
||||||
|
InputFile(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
/// Decides which high-level entrypoint should handle a parsed invocation.
|
||||||
|
pub enum CommandSurface {
|
||||||
|
/// Execute the invocation in the foreground CLI flow.
|
||||||
|
Foreground(Invocation),
|
||||||
|
/// Launch the RPC daemon with optional pre-seeded inputs.
|
||||||
|
RpcDaemon {
|
||||||
|
/// Optional config path to load before serving RPC.
|
||||||
|
config_path: Option<PathBuf>,
|
||||||
|
/// Inputs that should be registered before the daemon starts serving.
|
||||||
|
inputs: Vec<String>,
|
||||||
|
},
|
||||||
|
/// Print the version banner.
|
||||||
|
PrintVersion,
|
||||||
|
/// Print help text, optionally filtered by a query term.
|
||||||
|
PrintHelp {
|
||||||
|
/// Optional help topic or filter string.
|
||||||
|
query: Option<String>,
|
||||||
|
},
|
||||||
|
/// Validate the selected config file without starting transfers.
|
||||||
|
ValidateConfig {
|
||||||
|
/// Config file to validate.
|
||||||
|
config_path: PathBuf,
|
||||||
|
/// Whether strict parsing should be used.
|
||||||
|
strict: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
/// High-level CLI invocations supported by the binary.
|
||||||
|
pub enum Invocation {
|
||||||
|
/// Print version information and exit.
|
||||||
|
Version,
|
||||||
|
/// Print help output and exit.
|
||||||
|
Help {
|
||||||
|
/// Optional help query for filtered help output.
|
||||||
|
query: Option<String>,
|
||||||
|
},
|
||||||
|
/// Execute or stage one or more transfer inputs.
|
||||||
|
Run {
|
||||||
|
/// Optional config file path supplied on the command line.
|
||||||
|
config_path: Option<PathBuf>,
|
||||||
|
/// Ordered transfer inputs supplied on the command line.
|
||||||
|
uris: Vec<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
/// Errors surfaced by CLI parsing and execution.
|
||||||
|
pub enum CliError {
|
||||||
|
/// An unsupported flag or malformed option was provided.
|
||||||
|
UnknownOption(String),
|
||||||
|
/// A flag requiring a value was not followed by one.
|
||||||
|
MissingValue(String),
|
||||||
|
/// An OS argument could not be converted to UTF-8.
|
||||||
|
InvalidUtf8Argument,
|
||||||
|
/// Config parsing failed.
|
||||||
|
Config(ConfigParseError),
|
||||||
|
/// Local I/O failed.
|
||||||
|
Io(String),
|
||||||
|
/// The in-process RPC/runtime layer returned an application error.
|
||||||
|
Rpc(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for CliError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::UnknownOption(option) => write!(f, "unknown option: {option}"),
|
||||||
|
Self::MissingValue(option) => write!(f, "missing value for {option}"),
|
||||||
|
Self::InvalidUtf8Argument => f.write_str("invalid utf-8 in command-line argument"),
|
||||||
|
Self::Config(error) => write!(f, "{error}"),
|
||||||
|
Self::Io(error) | Self::Rpc(error) => write!(f, "{error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for CliError {}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
/// Summarizes a parsed config file that was loaded by the CLI.
|
||||||
|
pub struct ConfigLoadReport {
|
||||||
|
/// Source path of the loaded config file.
|
||||||
|
pub path: PathBuf,
|
||||||
|
/// Number of directives accepted by the selected parser mode.
|
||||||
|
pub directive_count: usize,
|
||||||
|
/// Whether strict parsing was enabled.
|
||||||
|
pub strict: bool,
|
||||||
|
/// Normalized config profile projected for downstream runtime use.
|
||||||
|
pub profile: ConfigProfile,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
/// Small compatibility snapshot exposed for documentation and smoke checks.
|
||||||
|
pub struct CompatibilitySnapshot {
|
||||||
|
/// Version text rendered by the CLI surface.
|
||||||
|
pub version_banner: String,
|
||||||
|
/// Number of help sections currently exposed.
|
||||||
|
pub help_sections: usize,
|
||||||
|
/// Number of tracked protocol entries in the compatibility ledger.
|
||||||
|
pub tracked_protocol_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
/// Structured execution summary returned by runtime-oriented entrypoints.
|
||||||
|
pub struct RuntimeReport {
|
||||||
|
/// Number of URI-like inputs accepted by the invocation.
|
||||||
|
pub accepted_uri_count: usize,
|
||||||
|
/// Number of downloads registered in the dispatcher/runtime.
|
||||||
|
pub tracked_download_count: usize,
|
||||||
|
/// Number of downloads observed as complete during execution.
|
||||||
|
pub completed_download_count: usize,
|
||||||
|
/// First registered GID, when one exists.
|
||||||
|
pub first_gid: Option<String>,
|
||||||
|
/// First visible status from `aria2.tellStatus`, when available.
|
||||||
|
pub first_status: Option<String>,
|
||||||
|
/// First visible total length from `aria2.tellStatus`, when available.
|
||||||
|
pub first_total_length: Option<u64>,
|
||||||
|
/// First visible completed length from `aria2.tellStatus`, when available.
|
||||||
|
pub first_completed_length: Option<u64>,
|
||||||
|
/// First visible connection count from `aria2.tellStatus`, when available.
|
||||||
|
pub first_connections: Option<u32>,
|
||||||
|
/// Recognized transfer schemes extracted from the provided inputs.
|
||||||
|
pub recognized_schemes: Vec<String>,
|
||||||
|
/// High-level transfer classifications derived from the inputs.
|
||||||
|
pub transfer_kinds: Vec<TransferSelection>,
|
||||||
|
/// Loaded config summary, when a config file participated in execution.
|
||||||
|
pub config_report: Option<ConfigLoadReport>,
|
||||||
|
/// Runtime config projected from the startup profile and config.
|
||||||
|
pub derived_runtime: RuntimeConfig,
|
||||||
|
/// HTTP session projected from the startup profile and config.
|
||||||
|
pub http_session: HttpSessionModel,
|
||||||
|
/// Major version of the control-file format surfaced by storage.
|
||||||
|
pub control_file_version_major: u16,
|
||||||
|
/// First visible `BitTorrent` status projection, when available.
|
||||||
|
pub first_bt_status: Option<BtStatusReport>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
/// One logical transfer entity after expanding CLI arguments and input files.
|
||||||
|
pub(crate) struct TransferInputEntry {
|
||||||
|
/// Ordered URI candidates for the entity. Multiple entries represent mirrors.
|
||||||
|
pub(crate) uris: Vec<String>,
|
||||||
|
/// Implied per-download defaults synthesized from the source document.
|
||||||
|
pub(crate) implied_profile: Option<ConfigProfile>,
|
||||||
|
/// Optional per-entry overrides originating from input-file indentation blocks.
|
||||||
|
pub(crate) profile: Option<ConfigProfile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||||
|
/// CLI-facing projection of BitTorrent-specific tellStatus fields.
|
||||||
|
pub struct BtStatusReport {
|
||||||
|
/// Whether the download is BitTorrent-backed.
|
||||||
|
pub is_bt: Option<bool>,
|
||||||
|
/// Whether the download still represents metadata-only state.
|
||||||
|
pub metadata_only: Option<bool>,
|
||||||
|
/// Canonical magnet URI, when available.
|
||||||
|
pub magnet_uri: Option<String>,
|
||||||
|
/// Number of announce-list tiers surfaced by the runtime.
|
||||||
|
pub announce_list_tier_count: Option<usize>,
|
||||||
|
/// Whether the download is currently seeding.
|
||||||
|
pub seeder: Option<bool>,
|
||||||
|
/// Current visible seeder count.
|
||||||
|
pub num_seeders: Option<u64>,
|
||||||
|
/// Current share ratio string.
|
||||||
|
pub share_ratio: Option<String>,
|
||||||
|
/// In-progress share ratio projection.
|
||||||
|
pub share_ratio_progress: Option<String>,
|
||||||
|
/// Remaining share ratio projection.
|
||||||
|
pub share_ratio_remaining: Option<String>,
|
||||||
|
/// Current share time in seconds.
|
||||||
|
pub share_time: Option<u64>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "aria2-rust-pro-compat"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
description.workspace = true
|
||||||
|
readme.workspace = true
|
||||||
|
keywords.workspace = true
|
||||||
|
categories.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "aria2_rust_pro_compat"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
//! Compatibility inventory and baseline ledger helpers.
|
||||||
|
|
||||||
|
use crate::options::{OPTION_SPECS, OptionFamily, OptionSource, OptionStatus};
|
||||||
|
|
||||||
|
/// Protocols that the compat layer treats as part of the required surface.
|
||||||
|
pub const REQUIRED_PROTOCOLS: &[&str] = &[
|
||||||
|
"cli",
|
||||||
|
"config",
|
||||||
|
"json-rpc",
|
||||||
|
"xml-rpc",
|
||||||
|
"http",
|
||||||
|
"https",
|
||||||
|
"ftp",
|
||||||
|
"sftp",
|
||||||
|
"metalink",
|
||||||
|
"bittorrent",
|
||||||
|
"magnet",
|
||||||
|
"docker",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Compatibility depth for a surfaced feature.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum CompatLevel {
|
||||||
|
/// The feature exists at the API or metadata layer.
|
||||||
|
Surface,
|
||||||
|
/// The feature is expected to match legacy behavior.
|
||||||
|
Behavioral,
|
||||||
|
/// The feature is expected to be effectively identical to the baseline.
|
||||||
|
StrictEquivalent,
|
||||||
|
}
|
||||||
|
/// Top-level feature areas tracked by the compat ledger.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum FeatureSurface {
|
||||||
|
/// Command-line and config option handling.
|
||||||
|
Option,
|
||||||
|
/// Config-file loading and normalization.
|
||||||
|
Config,
|
||||||
|
/// RPC method and field compatibility.
|
||||||
|
Rpc,
|
||||||
|
/// BitTorrent-related surface area.
|
||||||
|
Bt,
|
||||||
|
/// Metalink-related surface area.
|
||||||
|
Metalink,
|
||||||
|
/// Session import or export semantics.
|
||||||
|
Session,
|
||||||
|
/// Input-file parsing and expansion behavior.
|
||||||
|
InputFile,
|
||||||
|
/// Error reporting and mapping behavior.
|
||||||
|
ErrorMap,
|
||||||
|
/// Help text and user-facing documentation outputs.
|
||||||
|
HelpText,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row in the compatibility inventory.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct CompatLedgerEntry {
|
||||||
|
/// Canonical item name.
|
||||||
|
pub name: &'static str,
|
||||||
|
/// Feature area where the item belongs.
|
||||||
|
pub surface: FeatureSurface,
|
||||||
|
/// Expected depth of compatibility for the item.
|
||||||
|
pub level: CompatLevel,
|
||||||
|
/// Current implementation status for the item.
|
||||||
|
pub status: OptionStatus,
|
||||||
|
/// Short human-readable note describing the item.
|
||||||
|
pub note: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Free-form compatibility note attached to the ledger.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct CompatNote {
|
||||||
|
/// Stable identifier for the note.
|
||||||
|
pub id: &'static str,
|
||||||
|
/// User-facing note text.
|
||||||
|
pub text: &'static str,
|
||||||
|
}
|
||||||
|
/// Full compatibility ledger snapshot.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct CompatLedger {
|
||||||
|
/// Itemized compatibility entries.
|
||||||
|
pub entries: Vec<CompatLedgerEntry>,
|
||||||
|
/// Additional notes that summarize current caveats.
|
||||||
|
pub notes: Vec<CompatNote>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the static compatibility notes for the current rewrite snapshot.
|
||||||
|
#[must_use]
|
||||||
|
pub fn compatibility_notes() -> Vec<CompatNote> {
|
||||||
|
vec![
|
||||||
|
CompatNote {
|
||||||
|
id: "rapid-rewrite",
|
||||||
|
text: "Public compatibility surfaces are scaffolded for follow-up behavioral parity.",
|
||||||
|
},
|
||||||
|
CompatNote {
|
||||||
|
id: "bt-options",
|
||||||
|
text: "BT option family exists with metadata and parser stubs.",
|
||||||
|
},
|
||||||
|
CompatNote {
|
||||||
|
id: "metalink-options",
|
||||||
|
text: "Metalink option family exists with metadata and parser stubs.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the current compatibility ledger from the option registry.
|
||||||
|
#[must_use]
|
||||||
|
pub fn compat_ledger() -> CompatLedger {
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
for spec in OPTION_SPECS.iter() {
|
||||||
|
let surface = match spec.metadata.family {
|
||||||
|
OptionFamily::Bt => FeatureSurface::Bt,
|
||||||
|
OptionFamily::Metalink => FeatureSurface::Metalink,
|
||||||
|
OptionFamily::Rpc => FeatureSurface::Rpc,
|
||||||
|
OptionFamily::Session => FeatureSurface::Session,
|
||||||
|
OptionFamily::Input => FeatureSurface::InputFile,
|
||||||
|
_ => FeatureSurface::Option,
|
||||||
|
};
|
||||||
|
let level = match spec.source {
|
||||||
|
OptionSource::Original => CompatLevel::Behavioral,
|
||||||
|
OptionSource::Pro
|
||||||
|
| OptionSource::CompatibilityAlias
|
||||||
|
| OptionSource::RpcAlias
|
||||||
|
| OptionSource::Experimental => CompatLevel::Surface,
|
||||||
|
};
|
||||||
|
entries.push(CompatLedgerEntry {
|
||||||
|
name: spec.name,
|
||||||
|
surface,
|
||||||
|
level,
|
||||||
|
status: spec.status,
|
||||||
|
note: spec.metadata.compatibility_note,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
CompatLedger {
|
||||||
|
entries,
|
||||||
|
notes: compatibility_notes(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
//! Config parsing and normalization helpers for aria2-style option files.
|
||||||
|
|
||||||
|
use crate::options::{OptionScope, option_spec, reserved_option_names};
|
||||||
|
|
||||||
|
/// Where a compat config document originated.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ConfigLocationKind {
|
||||||
|
/// The document was loaded from a file on disk.
|
||||||
|
File,
|
||||||
|
/// The document was provided inline as raw text.
|
||||||
|
Inline,
|
||||||
|
/// The document originated from an RPC payload.
|
||||||
|
Rpc,
|
||||||
|
/// The document originated from environment variables.
|
||||||
|
Env,
|
||||||
|
/// The document originated from CLI arguments.
|
||||||
|
Cli,
|
||||||
|
}
|
||||||
|
/// Scope inferred for a config document or directive set.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ConfigScope {
|
||||||
|
/// Only global options are expected.
|
||||||
|
Global,
|
||||||
|
/// Only per-download options are expected.
|
||||||
|
PerDownload,
|
||||||
|
/// The document can contain both global and per-download options.
|
||||||
|
Mixed,
|
||||||
|
}
|
||||||
|
/// Source category for parsed config data.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ConfigSource {
|
||||||
|
/// User-authored config file such as `aria2.conf`.
|
||||||
|
UserConfig,
|
||||||
|
/// Persisted session file content.
|
||||||
|
SessionFile,
|
||||||
|
/// Input-file content that expands downloads.
|
||||||
|
InputFile,
|
||||||
|
/// Runtime overrides originating from transient inputs.
|
||||||
|
RuntimeOverride,
|
||||||
|
/// Named profile content.
|
||||||
|
Profile,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single `name=value` config directive.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ConfigDirective {
|
||||||
|
/// Directive name after canonicalization.
|
||||||
|
pub name: String,
|
||||||
|
/// Optional directive value.
|
||||||
|
pub value: Option<String>,
|
||||||
|
}
|
||||||
|
/// Parsed config directives with source metadata.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ConfigAst {
|
||||||
|
/// Ordered directives found in the source input.
|
||||||
|
pub directives: Vec<ConfigDirective>,
|
||||||
|
/// Origin category for the parsed directives.
|
||||||
|
pub source: ConfigSource,
|
||||||
|
}
|
||||||
|
/// Parsed config document with location metadata.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ConfigDocument {
|
||||||
|
/// Ordered directives found in the document.
|
||||||
|
pub directives: Vec<ConfigDirective>,
|
||||||
|
/// Where the document was loaded from.
|
||||||
|
pub location: ConfigLocationKind,
|
||||||
|
/// Scope classification for the document.
|
||||||
|
pub scope: ConfigScope,
|
||||||
|
}
|
||||||
|
/// Session save/load compatibility metadata.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct SessionFileModel {
|
||||||
|
/// Path where the session file is stored.
|
||||||
|
pub session_path: String,
|
||||||
|
/// Optional input file associated with the session.
|
||||||
|
pub input_path: Option<String>,
|
||||||
|
/// Autosave interval for session persistence.
|
||||||
|
pub autosave_interval_secs: u64,
|
||||||
|
}
|
||||||
|
/// Named profile loaded through compat config handling.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ConfigProfile {
|
||||||
|
/// Profile name.
|
||||||
|
pub name: String,
|
||||||
|
/// Source category for the profile.
|
||||||
|
pub source: ConfigSource,
|
||||||
|
/// Parsed profile document.
|
||||||
|
pub document: ConfigDocument,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors produced while parsing compat config input.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ConfigParseError {
|
||||||
|
/// A line could not be parsed as a valid directive.
|
||||||
|
InvalidDirective(String),
|
||||||
|
/// A directive that requires a value omitted one.
|
||||||
|
MissingValue(String),
|
||||||
|
/// A directive named an unknown option.
|
||||||
|
UnknownOption(String),
|
||||||
|
/// A directive used an option name reserved for internal use.
|
||||||
|
ReservedOption(String),
|
||||||
|
}
|
||||||
|
impl std::fmt::Display for ConfigParseError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::InvalidDirective(line) => write!(f, "invalid config directive: {line}"),
|
||||||
|
Self::MissingValue(name) => write!(f, "missing value for option: {name}"),
|
||||||
|
Self::UnknownOption(name) => write!(f, "unknown option: {name}"),
|
||||||
|
Self::ReservedOption(name) => write!(f, "reserved option name: {name}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl std::error::Error for ConfigParseError {}
|
||||||
|
|
||||||
|
/// Canonicalizes a config option name through the option registry.
|
||||||
|
fn canonicalize_option_name(name: &str) -> String {
|
||||||
|
let name = normalize_option_token(name);
|
||||||
|
option_spec(name).map_or_else(|| name.to_owned(), |spec| spec.name.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a UTF-8 byte-order mark when one prefixes a text fragment.
|
||||||
|
fn strip_utf8_bom(text: &str) -> &str {
|
||||||
|
text.strip_prefix('\u{feff}').unwrap_or(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalizes a CLI- or config-style option token to its raw name form.
|
||||||
|
fn normalize_option_token(name: &str) -> &str {
|
||||||
|
let name = strip_utf8_bom(name.trim());
|
||||||
|
name.strip_prefix("--").unwrap_or_else(|| {
|
||||||
|
name.strip_prefix('-')
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map_or(name, |name| name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes trailing comments that are safely separated from a value.
|
||||||
|
fn strip_safe_trailing_comment(value: &str) -> &str {
|
||||||
|
for (index, ch) in value.char_indices() {
|
||||||
|
let Some(prefix) = value.get(..index) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if (ch == '#' || ch == ';') && prefix.chars().next_back().is_some_and(char::is_whitespace) {
|
||||||
|
return prefix.trim_end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a single aria2-style config line, ignoring blank lines and comments.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`ConfigParseError`] when the line is malformed or omits a required value.
|
||||||
|
pub fn parse_config_line(line: &str) -> Result<Option<ConfigDirective>, ConfigParseError> {
|
||||||
|
let trimmed = strip_utf8_bom(line).trim();
|
||||||
|
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let (name, value) = trimmed
|
||||||
|
.split_once('=')
|
||||||
|
.ok_or_else(|| ConfigParseError::InvalidDirective(trimmed.to_owned()))?;
|
||||||
|
let name = normalize_option_token(name);
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err(ConfigParseError::InvalidDirective(trimmed.to_owned()));
|
||||||
|
}
|
||||||
|
let value = strip_safe_trailing_comment(value);
|
||||||
|
if value.is_empty() {
|
||||||
|
return Err(ConfigParseError::MissingValue(name.to_owned()));
|
||||||
|
}
|
||||||
|
Ok(Some(ConfigDirective {
|
||||||
|
name: canonicalize_option_name(name),
|
||||||
|
value: Some(value.to_owned()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a single config line and enforces the known-option / non-reserved subset.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`ConfigParseError`] when the line is malformed, reserved, or names an unknown option.
|
||||||
|
pub fn parse_config_line_strict(line: &str) -> Result<Option<ConfigDirective>, ConfigParseError> {
|
||||||
|
let directive = parse_config_line(line)?;
|
||||||
|
if let Some(ref d) = directive {
|
||||||
|
if reserved_option_names().iter().any(|r| r.name == d.name) {
|
||||||
|
return Err(ConfigParseError::ReservedOption(d.name.clone()));
|
||||||
|
}
|
||||||
|
if option_spec(d.name.as_str()).is_none() {
|
||||||
|
return Err(ConfigParseError::UnknownOption(d.name.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(directive)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a full config document using strict option validation.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`ConfigParseError`] when any non-comment line is malformed, reserved, or unknown.
|
||||||
|
pub fn parse_config(text: &str) -> Result<Vec<ConfigDirective>, ConfigParseError> {
|
||||||
|
let mut directives = Vec::new();
|
||||||
|
for line in text.lines() {
|
||||||
|
if let Some(d) = parse_config_line_strict(line)? {
|
||||||
|
directives.push(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(directives)
|
||||||
|
}
|
||||||
|
/// Parses a full config document while tolerating unknown options.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`ConfigParseError`] when any non-comment line is malformed or omits a required value.
|
||||||
|
pub fn parse_config_lenient(text: &str) -> Result<Vec<ConfigDirective>, ConfigParseError> {
|
||||||
|
let mut directives = Vec::new();
|
||||||
|
for line in text.lines() {
|
||||||
|
if let Some(d) = parse_config_line(line)? {
|
||||||
|
directives.push(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(directives)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Infers config scope for a named option when the option is known.
|
||||||
|
#[must_use]
|
||||||
|
pub fn infer_scope(name: &str) -> Option<ConfigScope> {
|
||||||
|
option_spec(name).map(|s| match s.metadata.scope {
|
||||||
|
OptionScope::Global => ConfigScope::Global,
|
||||||
|
OptionScope::PerDownload => ConfigScope::PerDownload,
|
||||||
|
OptionScope::Both => ConfigScope::Mixed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a document whose directive names have been canonicalized.
|
||||||
|
#[must_use]
|
||||||
|
pub fn normalize_document(mut document: ConfigDocument) -> ConfigDocument {
|
||||||
|
for directive in &mut document.directives {
|
||||||
|
directive.name = canonicalize_option_name(&directive.name);
|
||||||
|
}
|
||||||
|
document
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads an inline named profile using lenient parsing semantics.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`ConfigParseError`] when the profile text contains malformed directives.
|
||||||
|
pub fn load_profile(name: &str, text: &str) -> Result<ConfigProfile, ConfigParseError> {
|
||||||
|
let directives = parse_config_lenient(text)?;
|
||||||
|
Ok(ConfigProfile {
|
||||||
|
name: name.to_owned(),
|
||||||
|
source: ConfigSource::Profile,
|
||||||
|
document: ConfigDocument {
|
||||||
|
directives,
|
||||||
|
location: ConfigLocationKind::Inline,
|
||||||
|
scope: ConfigScope::Mixed,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{
|
||||||
|
ConfigDirective, ConfigDocument, ConfigLocationKind, ConfigParseError, ConfigScope,
|
||||||
|
ConfigSource, load_profile, normalize_document, parse_config_line,
|
||||||
|
parse_config_line_strict,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_config_line_strict_canonicalizes_known_alias_names() {
|
||||||
|
let directive = parse_config_line_strict("http-want-digest=true")
|
||||||
|
.expect("strict alias parse should succeed")
|
||||||
|
.expect("directive should be present");
|
||||||
|
|
||||||
|
assert_eq!(directive.name, "no-want-digest-header");
|
||||||
|
assert_eq!(directive.value.as_deref(), Some("true"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_config_line_lenient_canonicalizes_known_alias_names() {
|
||||||
|
let directive = parse_config_line("http-want-digest=true")
|
||||||
|
.expect("lenient alias parse should succeed")
|
||||||
|
.expect("directive should be present");
|
||||||
|
|
||||||
|
assert_eq!(directive.name, "no-want-digest-header");
|
||||||
|
assert_eq!(directive.value.as_deref(), Some("true"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_profile_keeps_profile_metadata_while_canonicalizing_known_aliases() {
|
||||||
|
let profile = load_profile(
|
||||||
|
"demo",
|
||||||
|
"# comment\nhttp-want-digest=true\nrpc-listen-all=true\n",
|
||||||
|
)
|
||||||
|
.expect("profile should load");
|
||||||
|
|
||||||
|
assert_eq!(profile.name, "demo");
|
||||||
|
assert_eq!(profile.source, ConfigSource::Profile);
|
||||||
|
assert_eq!(profile.document.location, ConfigLocationKind::Inline);
|
||||||
|
assert_eq!(profile.document.scope, ConfigScope::Mixed);
|
||||||
|
let [first, second] = profile.document.directives.as_slice() else {
|
||||||
|
panic!("profile should contain exactly two directives");
|
||||||
|
};
|
||||||
|
assert_eq!(first.name, "no-want-digest-header");
|
||||||
|
assert_eq!(second.name, "rpc-listen-all");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_config_line_strict_still_rejects_unknown_options() {
|
||||||
|
let error = parse_config_line_strict("not-a-real-option=true")
|
||||||
|
.expect_err("unknown option should still be rejected");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
error,
|
||||||
|
ConfigParseError::UnknownOption("not-a-real-option".to_owned())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_config_line_accepts_bom_and_cli_style_option_spellings() {
|
||||||
|
let parameterized = parse_config_line_strict("\u{feff}--parameterized-uri=true")
|
||||||
|
.expect("long CLI spelling should parse")
|
||||||
|
.expect("directive should be present");
|
||||||
|
assert_eq!(parameterized.name, "parameterized-uri");
|
||||||
|
assert_eq!(parameterized.value.as_deref(), Some("true"));
|
||||||
|
|
||||||
|
let remote_time = parse_config_line_strict("-R=true")
|
||||||
|
.expect("short CLI alias should parse")
|
||||||
|
.expect("directive should be present");
|
||||||
|
assert_eq!(remote_time.name, "remote-time");
|
||||||
|
assert_eq!(remote_time.value.as_deref(), Some("true"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_config_line_strips_safe_trailing_comments_but_preserves_uri_fragments() {
|
||||||
|
let referer = parse_config_line_strict(
|
||||||
|
"referer=http://example.invalid/download#frag # copied note",
|
||||||
|
)
|
||||||
|
.expect("referer with fragment should parse")
|
||||||
|
.expect("directive should be present");
|
||||||
|
assert_eq!(referer.name, "referer");
|
||||||
|
assert_eq!(
|
||||||
|
referer.value.as_deref(),
|
||||||
|
Some("http://example.invalid/download#frag")
|
||||||
|
);
|
||||||
|
|
||||||
|
let tracker = parse_config_line("bt-tracker=udp://tracker.invalid:80/announce ; mirror")
|
||||||
|
.expect("tracker line should parse")
|
||||||
|
.expect("directive should be present");
|
||||||
|
assert_eq!(tracker.name, "bt-tracker");
|
||||||
|
assert_eq!(
|
||||||
|
tracker.value.as_deref(),
|
||||||
|
Some("udp://tracker.invalid:80/announce")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_profile_canonicalizes_extended_cli_spellings_and_comments() {
|
||||||
|
let profile = load_profile(
|
||||||
|
"compat",
|
||||||
|
"\u{feff}--select-file=1-3,5 # keep files\n-R=true\n",
|
||||||
|
)
|
||||||
|
.expect("profile should load");
|
||||||
|
|
||||||
|
let [first, second] = profile.document.directives.as_slice() else {
|
||||||
|
panic!("profile should contain exactly two directives");
|
||||||
|
};
|
||||||
|
assert_eq!(first.name, "select-file");
|
||||||
|
assert_eq!(first.value.as_deref(), Some("1-3,5"));
|
||||||
|
assert_eq!(second.name, "remote-time");
|
||||||
|
assert_eq!(second.value.as_deref(), Some("true"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_document_canonicalizes_cli_style_and_bom_prefixed_names() {
|
||||||
|
let normalized = normalize_document(ConfigDocument {
|
||||||
|
directives: vec![
|
||||||
|
ConfigDirective {
|
||||||
|
name: "\u{feff}--parameterized-uri".to_owned(),
|
||||||
|
value: Some("true".to_owned()),
|
||||||
|
},
|
||||||
|
ConfigDirective {
|
||||||
|
name: "-R".to_owned(),
|
||||||
|
value: Some("true".to_owned()),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
location: ConfigLocationKind::Inline,
|
||||||
|
scope: ConfigScope::Mixed,
|
||||||
|
});
|
||||||
|
|
||||||
|
let [first, second] = normalized.directives.as_slice() else {
|
||||||
|
panic!("normalized document should contain exactly two directives");
|
||||||
|
};
|
||||||
|
assert_eq!(first.name, "parameterized-uri");
|
||||||
|
assert_eq!(second.name, "remote-time");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
//! Help-text and version-banner rendering for the compat surface.
|
||||||
|
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
BASELINE_COMMIT, PRODUCT_NAME, VERSION,
|
||||||
|
options::{OptionKind, live_option_specs},
|
||||||
|
version_line,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Usage block shown at the top of compat help output.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct UsageSection {
|
||||||
|
/// Section title or identifier.
|
||||||
|
pub title: &'static str,
|
||||||
|
/// Ordered lines rendered in the section.
|
||||||
|
pub lines: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Named help section containing pre-rendered blocks.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct HelpSection {
|
||||||
|
/// Section heading.
|
||||||
|
pub name: &'static str,
|
||||||
|
/// Pre-rendered blocks within the section.
|
||||||
|
pub body: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full help document returned by compat helpers.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct HelpDocument {
|
||||||
|
/// Usage sections rendered before option details.
|
||||||
|
pub usage: Vec<UsageSection>,
|
||||||
|
/// Detail sections rendered after usage.
|
||||||
|
pub sections: Vec<HelpSection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query selectors accepted by the compat help surface.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum HelpQuery {
|
||||||
|
/// Request all help entries.
|
||||||
|
All,
|
||||||
|
/// Request entries matching a help tag.
|
||||||
|
Tag(String),
|
||||||
|
/// Request entries matching a keyword search.
|
||||||
|
Keyword(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal rendered representation of a help option entry.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
struct HelpOptionEntry {
|
||||||
|
/// Combined CLI switch spellings.
|
||||||
|
switches: String,
|
||||||
|
/// User-facing description line.
|
||||||
|
description: String,
|
||||||
|
/// Optional human-readable value hints.
|
||||||
|
possible_values: Option<String>,
|
||||||
|
/// Optional default value summary.
|
||||||
|
default_value: Option<String>,
|
||||||
|
/// Help tags associated with the entry.
|
||||||
|
tags: Vec<&'static str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Help tags recognized by the compat help renderer.
|
||||||
|
const ALL_HELP_TAGS: &[&str] = &[
|
||||||
|
"#basic",
|
||||||
|
"#advanced",
|
||||||
|
"#http",
|
||||||
|
"#https",
|
||||||
|
"#ftp",
|
||||||
|
"#metalink",
|
||||||
|
"#bittorrent",
|
||||||
|
"#cookie",
|
||||||
|
"#hook",
|
||||||
|
"#file",
|
||||||
|
"#rpc",
|
||||||
|
"#checksum",
|
||||||
|
"#experimental",
|
||||||
|
"#deprecated",
|
||||||
|
"#help",
|
||||||
|
"#all",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Returns the top-level usage section rendered by compat help.
|
||||||
|
#[must_use]
|
||||||
|
pub fn usage_sections() -> Vec<UsageSection> {
|
||||||
|
vec![UsageSection {
|
||||||
|
title: "main",
|
||||||
|
lines: vec!["aria2c [OPTIONS] [URI | MAGNET | TORRENT_FILE | METALINK_FILE]...".to_owned()],
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the default help sections for the compat help output.
|
||||||
|
#[must_use]
|
||||||
|
pub fn help_sections() -> Vec<HelpSection> {
|
||||||
|
vec![HelpSection {
|
||||||
|
name: "Options",
|
||||||
|
body: render_option_entries(&option_entries()),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns manpage metadata fields exposed by the compat help layer.
|
||||||
|
#[must_use]
|
||||||
|
pub fn manpage_metadata() -> Vec<(String, String)> {
|
||||||
|
vec![
|
||||||
|
("NAME".to_owned(), PRODUCT_NAME.to_owned()),
|
||||||
|
("VERSION".to_owned(), version_line()),
|
||||||
|
("BASELINE_COMMIT".to_owned(), BASELINE_COMMIT.to_owned()),
|
||||||
|
(
|
||||||
|
"IMPLEMENTED_OPTION_COUNT".to_owned(),
|
||||||
|
live_option_specs().len().to_string(),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the aria2-style version banner for the compat CLI.
|
||||||
|
#[must_use]
|
||||||
|
pub fn cli_version_text() -> String {
|
||||||
|
let lines = vec![
|
||||||
|
"aria2 version 1.37.0".to_owned(),
|
||||||
|
format!("Rust rewrite package: {PRODUCT_NAME} {VERSION}"),
|
||||||
|
format!("Compatibility baseline: {BASELINE_COMMIT}"),
|
||||||
|
String::new(),
|
||||||
|
"** Configuration **".to_owned(),
|
||||||
|
"Enabled Features: Async DNS, BitTorrent, GZip, HTTPS, Message Digest, Metalink, XML-RPC, SFTP".to_owned(),
|
||||||
|
"Hash Algorithms: sha-1, sha-224, sha-256, sha-384, sha-512, md5, adler32".to_owned(),
|
||||||
|
"Libraries: tokio, reqwest, rustls, quick-xml".to_owned(),
|
||||||
|
format!(
|
||||||
|
"System: {} ({})",
|
||||||
|
std::env::consts::OS,
|
||||||
|
std::env::consts::ARCH
|
||||||
|
),
|
||||||
|
String::new(),
|
||||||
|
"Report bugs to https://github.com/aria2/aria2/issues".to_owned(),
|
||||||
|
"Visit https://aria2.github.io/".to_owned(),
|
||||||
|
];
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the default compatibility help text.
|
||||||
|
#[must_use]
|
||||||
|
pub fn compatibility_help_text() -> String {
|
||||||
|
help_text()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the default rendered help text.
|
||||||
|
#[must_use]
|
||||||
|
pub fn help_text() -> String {
|
||||||
|
help_text_for_query(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns rendered help text filtered by an optional query.
|
||||||
|
#[must_use]
|
||||||
|
pub fn help_text_for_query(query: Option<&str>) -> String {
|
||||||
|
render_help(
|
||||||
|
&HelpDocument {
|
||||||
|
usage: usage_sections(),
|
||||||
|
sections: help_sections_for_query(query),
|
||||||
|
},
|
||||||
|
query.and_then(parse_help_query),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the internal help-entry list from built-ins and option specs.
|
||||||
|
fn option_entries() -> Vec<HelpOptionEntry> {
|
||||||
|
let mut entries = vec![
|
||||||
|
HelpOptionEntry {
|
||||||
|
switches: "-v, --version".to_owned(),
|
||||||
|
description: "Print the version number and exit.".to_owned(),
|
||||||
|
possible_values: None,
|
||||||
|
default_value: None,
|
||||||
|
tags: vec!["#basic"],
|
||||||
|
},
|
||||||
|
HelpOptionEntry {
|
||||||
|
switches: "-h, --help[=TAG|KEYWORD]".to_owned(),
|
||||||
|
description: "Print usage and exit.".to_owned(),
|
||||||
|
possible_values: Some(ALL_HELP_TAGS.join(", ")),
|
||||||
|
default_value: Some("#basic".to_owned()),
|
||||||
|
tags: vec!["#basic", "#help"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
entries.extend(live_option_specs().into_iter().map(|spec| HelpOptionEntry {
|
||||||
|
switches: spec.help_synopsis(),
|
||||||
|
description: spec.metadata.compatibility_note.to_owned(),
|
||||||
|
possible_values: possible_values(spec.kind, spec.value_hint(), spec.metadata.validator),
|
||||||
|
default_value: (!spec.default_value.is_empty()).then(|| spec.default_value.to_owned()),
|
||||||
|
tags: help_tags_for_option(spec.name, spec.metadata.family, spec.metadata.tags),
|
||||||
|
}));
|
||||||
|
|
||||||
|
entries
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds help sections filtered by a parsed query.
|
||||||
|
fn help_sections_for_query(query: Option<&str>) -> Vec<HelpSection> {
|
||||||
|
let entries = option_entries();
|
||||||
|
let filtered = match query.and_then(parse_help_query) {
|
||||||
|
None | Some(HelpQuery::All) => entries,
|
||||||
|
Some(HelpQuery::Tag(tag)) => entries
|
||||||
|
.into_iter()
|
||||||
|
.filter(|entry| entry.tags.iter().any(|value| *value == tag))
|
||||||
|
.collect(),
|
||||||
|
Some(HelpQuery::Keyword(keyword)) => {
|
||||||
|
let needle = keyword.to_ascii_lowercase();
|
||||||
|
entries
|
||||||
|
.into_iter()
|
||||||
|
.filter(|entry| {
|
||||||
|
entry.switches.to_ascii_lowercase().contains(&needle)
|
||||||
|
|| entry.description.to_ascii_lowercase().contains(&needle)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
vec![HelpSection {
|
||||||
|
name: "Options",
|
||||||
|
body: render_option_entries(&filtered),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a user-facing help query string.
|
||||||
|
fn parse_help_query(query: &str) -> Option<HelpQuery> {
|
||||||
|
let trimmed = query.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if trimmed.eq_ignore_ascii_case("#all") {
|
||||||
|
return Some(HelpQuery::All);
|
||||||
|
}
|
||||||
|
if trimmed.starts_with('#') {
|
||||||
|
return Some(HelpQuery::Tag(trimmed.to_ascii_lowercase()));
|
||||||
|
}
|
||||||
|
Some(HelpQuery::Keyword(trimmed.to_ascii_lowercase()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes human-readable possible values for a help entry.
|
||||||
|
fn possible_values(kind: OptionKind, value_hint: &str, validator: &str) -> Option<String> {
|
||||||
|
match kind {
|
||||||
|
OptionKind::Bool => Some("true, false".to_owned()),
|
||||||
|
OptionKind::Path | OptionKind::Text if validator == "any" => None,
|
||||||
|
OptionKind::Path if validator == "path" || validator == "non-empty path" => {
|
||||||
|
Some("/path/to/file".to_owned())
|
||||||
|
}
|
||||||
|
OptionKind::Text if matches!(value_hint, "FILE" | "COMMAND" | "URI" | "HEADER") => {
|
||||||
|
Some(value_hint.to_owned())
|
||||||
|
}
|
||||||
|
OptionKind::List if value_hint == "URI,..." => Some("URI,...".to_owned()),
|
||||||
|
OptionKind::List if value_hint == "HOST,..." => Some("HOST,...".to_owned()),
|
||||||
|
_ if validator.is_empty() || validator == "any" => None,
|
||||||
|
_ if value_hint == "VALUE" => Some(validator.to_owned()),
|
||||||
|
_ => Some(value_hint.to_owned()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps option metadata to help tags used by filtered help output.
|
||||||
|
fn help_tags_for_option(
|
||||||
|
name: &str,
|
||||||
|
family: crate::options::OptionFamily,
|
||||||
|
extra_tags: &[crate::options::OptionTag],
|
||||||
|
) -> Vec<&'static str> {
|
||||||
|
let mut tags = vec!["#basic"];
|
||||||
|
|
||||||
|
match family {
|
||||||
|
crate::options::OptionFamily::Core
|
||||||
|
| crate::options::OptionFamily::Input
|
||||||
|
| crate::options::OptionFamily::Session => {}
|
||||||
|
crate::options::OptionFamily::Http => tags.push("#http"),
|
||||||
|
crate::options::OptionFamily::Ftp | crate::options::OptionFamily::Sftp => tags.push("#ftp"),
|
||||||
|
crate::options::OptionFamily::Rpc => tags.push("#rpc"),
|
||||||
|
crate::options::OptionFamily::Bt => tags.push("#bittorrent"),
|
||||||
|
crate::options::OptionFamily::Metalink => tags.push("#metalink"),
|
||||||
|
crate::options::OptionFamily::Checksum => tags.push("#checksum"),
|
||||||
|
crate::options::OptionFamily::Proxy => {
|
||||||
|
tags.push("#http");
|
||||||
|
tags.push("#ftp");
|
||||||
|
}
|
||||||
|
crate::options::OptionFamily::Security => tags.push("#https"),
|
||||||
|
crate::options::OptionFamily::Performance => {
|
||||||
|
if matches!(
|
||||||
|
name,
|
||||||
|
"max-overall-download-limit"
|
||||||
|
| "max-download-limit"
|
||||||
|
| "max-overall-upload-limit"
|
||||||
|
| "max-upload-limit"
|
||||||
|
) {
|
||||||
|
tags.extend(["#http", "#ftp", "#bittorrent"]);
|
||||||
|
} else {
|
||||||
|
tags.extend(["#http", "#ftp"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for tag in extra_tags {
|
||||||
|
match tag {
|
||||||
|
crate::options::OptionTag::Deprecated => tags.push("#deprecated"),
|
||||||
|
crate::options::OptionTag::Bt => tags.push("#bittorrent"),
|
||||||
|
crate::options::OptionTag::Metalink => tags.push("#metalink"),
|
||||||
|
crate::options::OptionTag::Rpc => tags.push("#rpc"),
|
||||||
|
crate::options::OptionTag::GlobalOnly | crate::options::OptionTag::PerDownloadOnly => {
|
||||||
|
tags.push("#advanced");
|
||||||
|
}
|
||||||
|
crate::options::OptionTag::InputFile => tags.push("#basic"),
|
||||||
|
crate::options::OptionTag::SessionFile => tags.push("#advanced"),
|
||||||
|
crate::options::OptionTag::Alias => tags.push("#experimental"),
|
||||||
|
crate::options::OptionTag::HighRisk | crate::options::OptionTag::RequiredCompat => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tags.sort_unstable();
|
||||||
|
tags.dedup();
|
||||||
|
tags
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders each help entry into a block of text.
|
||||||
|
fn render_option_entries(entries: &[HelpOptionEntry]) -> Vec<String> {
|
||||||
|
entries.iter().map(format_option_entry).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats one help entry block in an aria2-style layout.
|
||||||
|
fn format_option_entry(entry: &HelpOptionEntry) -> String {
|
||||||
|
let mut block = Vec::new();
|
||||||
|
if entry.switches.len() >= 34 {
|
||||||
|
block.push(format!(" {}", entry.switches));
|
||||||
|
block.push(format!(
|
||||||
|
" {}",
|
||||||
|
entry.description
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
block.push(format!(" {:<34}{}", entry.switches, entry.description));
|
||||||
|
}
|
||||||
|
if let Some(values) = &entry.possible_values {
|
||||||
|
block.push(String::new());
|
||||||
|
block.push(format!(
|
||||||
|
" Possible Values: {values}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(default_value) = &entry.default_value {
|
||||||
|
block.push(format!(
|
||||||
|
" Default: {default_value}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !entry.tags.is_empty() {
|
||||||
|
block.push(format!(
|
||||||
|
" Tags: {}",
|
||||||
|
entry.tags.join(", ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
block.push(String::new());
|
||||||
|
block.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders a full help document to user-facing text.
|
||||||
|
fn render_help(document: &HelpDocument, query: Option<HelpQuery>) -> String {
|
||||||
|
let mut text = String::new();
|
||||||
|
if let Some(usage_line) = document
|
||||||
|
.usage
|
||||||
|
.first()
|
||||||
|
.and_then(|section| section.lines.first())
|
||||||
|
{
|
||||||
|
text.push_str("Usage: ");
|
||||||
|
text.push_str(usage_line);
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
|
match query {
|
||||||
|
None | Some(HelpQuery::All) => text.push_str("Printing all options.\n"),
|
||||||
|
Some(HelpQuery::Tag(tag)) => {
|
||||||
|
let _ = writeln!(text, "Printing options tagged with \"{tag}\".");
|
||||||
|
}
|
||||||
|
Some(HelpQuery::Keyword(keyword)) => {
|
||||||
|
let _ = writeln!(text, "Printing options whose name includes \"{keyword}\".");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for section in &document.sections {
|
||||||
|
text.push_str(section.name);
|
||||||
|
text.push_str(":\n");
|
||||||
|
for block in §ion.body {
|
||||||
|
text.push_str(block);
|
||||||
|
if !block.ends_with('\n') {
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text.push_str("Refer to man page for more information.\n");
|
||||||
|
text
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{cli_version_text, help_text, help_text_for_query};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn version_text_uses_aria2_style_banner() {
|
||||||
|
let version = cli_version_text();
|
||||||
|
assert!(version.contains("aria2 version 1.37.0"));
|
||||||
|
assert!(version.contains("Rust rewrite package: aria2-rust-pro"));
|
||||||
|
assert!(version.contains("Enabled Features:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_text_uses_aria2_style_usage_and_options() {
|
||||||
|
let help = help_text();
|
||||||
|
assert!(help.starts_with("Usage: aria2c [OPTIONS]"));
|
||||||
|
assert!(help.contains("Printing all options."));
|
||||||
|
assert!(help.contains("Options:"));
|
||||||
|
assert!(help.contains("-h, --help[=TAG|KEYWORD]"));
|
||||||
|
assert!(help.contains("--retry-on-400[=true|false]"));
|
||||||
|
assert!(help.contains("Refer to man page for more information."));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_text_for_tag_filters_to_matching_entries() {
|
||||||
|
let help = help_text_for_query(Some("#http"));
|
||||||
|
assert!(help.contains("Printing options tagged with \"#http\"."));
|
||||||
|
assert!(help.contains("--user-agent=VALUE, -U"));
|
||||||
|
assert!(help.contains("--retry-on-400[=true|false]"));
|
||||||
|
assert!(!help.contains("--bt-save-metadata[=true|false]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_text_for_keyword_filters_to_matching_entries() {
|
||||||
|
let help = help_text_for_query(Some("rpc"));
|
||||||
|
assert!(help.contains("Printing options whose name includes \"rpc\"."));
|
||||||
|
assert!(help.contains("--rpc-listen-port=PORT"));
|
||||||
|
assert!(help.contains("--rpc-secret=VALUE"));
|
||||||
|
assert!(!help.contains("--bt-tracker=URI,..."));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
//! Compatibility-facing metadata and helpers for `aria2-rust-pro`.
|
||||||
|
//!
|
||||||
|
//! This crate centralizes the option registry, config parsing surface, help
|
||||||
|
//! text scaffolding, and compatibility bookkeeping that mirror the historical
|
||||||
|
//! `aria2` user-facing contract.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
/// Compatibility ledger types and baseline coverage metadata.
|
||||||
|
pub mod compat;
|
||||||
|
/// Config parsing and normalization helpers for aria2-style inputs.
|
||||||
|
pub mod config;
|
||||||
|
/// Help-text rendering and CLI version banner helpers.
|
||||||
|
pub mod help;
|
||||||
|
/// Canonical option metadata used by the compat surface.
|
||||||
|
pub mod options;
|
||||||
|
|
||||||
|
pub use compat::{
|
||||||
|
CompatLedger, CompatLedgerEntry, CompatLevel, CompatNote, FeatureSurface, REQUIRED_PROTOCOLS,
|
||||||
|
compatibility_notes,
|
||||||
|
};
|
||||||
|
pub use config::{
|
||||||
|
ConfigAst, ConfigDirective, ConfigDocument, ConfigLocationKind, ConfigParseError,
|
||||||
|
ConfigProfile, ConfigScope, ConfigSource, SessionFileModel, parse_config, parse_config_lenient,
|
||||||
|
parse_config_line, parse_config_line_strict,
|
||||||
|
};
|
||||||
|
pub use help::{
|
||||||
|
HelpDocument, HelpQuery, HelpSection, UsageSection, cli_version_text, compatibility_help_text,
|
||||||
|
help_text, help_text_for_query, manpage_metadata,
|
||||||
|
};
|
||||||
|
pub use options::{
|
||||||
|
OPTION_SPECS, OptionFamily, OptionKind, OptionMetadata, OptionParser, OptionScope,
|
||||||
|
OptionSource, OptionSpec, OptionStatus, OptionTag, ReservedOptionName, env_var_for_option,
|
||||||
|
global_option_specs, is_required_pro_option, option_spec, per_download_option_specs,
|
||||||
|
reserved_option_names, rpc_option_name,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Commit hash for the upstream baseline used by the compat layer.
|
||||||
|
pub const BASELINE_COMMIT: &str = "1f1323128cae942f5440c035cb5f42788b3de33f";
|
||||||
|
/// Product name shown by compat-oriented outputs.
|
||||||
|
pub const PRODUCT_NAME: &str = "aria2-rust-pro";
|
||||||
|
/// Crate version exposed by compatibility banners.
|
||||||
|
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
|
/// Errors produced while translating older compatibility inputs.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum CompatError {
|
||||||
|
/// The requested option name is not part of the compat registry.
|
||||||
|
UnknownOption(String),
|
||||||
|
/// The requested protocol name is not part of the required surface.
|
||||||
|
UnknownProtocol(String),
|
||||||
|
/// A config line could not be interpreted as a valid directive.
|
||||||
|
InvalidConfigDirective(String),
|
||||||
|
/// A config option that requires a value was provided without one.
|
||||||
|
MissingConfigValue(String),
|
||||||
|
/// A named option received an invalid value.
|
||||||
|
InvalidValue {
|
||||||
|
/// Canonical option name that failed validation.
|
||||||
|
option: String,
|
||||||
|
/// Raw value that failed validation.
|
||||||
|
value: String,
|
||||||
|
/// Human-readable validation failure detail.
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
/// A compat-only alias or deprecated switch was encountered.
|
||||||
|
DeprecatedOption {
|
||||||
|
/// Deprecated option spelling.
|
||||||
|
option: String,
|
||||||
|
/// Replacement spelling when one exists.
|
||||||
|
replacement: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for CompatError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::UnknownOption(name) => write!(f, "unknown option: {name}"),
|
||||||
|
Self::UnknownProtocol(name) => write!(f, "unknown protocol: {name}"),
|
||||||
|
Self::InvalidConfigDirective(line) => write!(f, "invalid config directive: {line}"),
|
||||||
|
Self::MissingConfigValue(name) => write!(f, "missing value for option: {name}"),
|
||||||
|
Self::InvalidValue {
|
||||||
|
option,
|
||||||
|
value,
|
||||||
|
reason,
|
||||||
|
} => write!(f, "invalid value for {option}: {value} ({reason})"),
|
||||||
|
Self::DeprecatedOption {
|
||||||
|
option,
|
||||||
|
replacement,
|
||||||
|
} => {
|
||||||
|
if let Some(replacement) = replacement {
|
||||||
|
write!(f, "deprecated option: {option} (use {replacement})")
|
||||||
|
} else {
|
||||||
|
write!(f, "deprecated option: {option}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for CompatError {}
|
||||||
|
|
||||||
|
/// Returns the standard version banner for the compat surface.
|
||||||
|
#[must_use]
|
||||||
|
pub fn version_line() -> String {
|
||||||
|
format!("{PRODUCT_NAME} {VERSION} (compat baseline {BASELINE_COMMIT})")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether a protocol belongs to the required compatibility baseline.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_required_protocol(protocol: &str) -> bool {
|
||||||
|
REQUIRED_PROTOCOLS.contains(&protocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps a config or CLI option spelling to its canonical option name.
|
||||||
|
#[must_use]
|
||||||
|
pub fn normalize_option_name(name: &str) -> Option<&'static str> {
|
||||||
|
option_spec(name).map(|spec| spec.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps any known option spelling to its canonical RPC field name.
|
||||||
|
#[must_use]
|
||||||
|
pub fn normalize_rpc_option_name(name: &str) -> Option<&'static str> {
|
||||||
|
rpc_option_name(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrites directive names in-place to their canonical registry spellings.
|
||||||
|
pub fn normalize_config_document(document: &mut ConfigDocument) {
|
||||||
|
for directive in &mut document.directives {
|
||||||
|
if let Some(canonical) = normalize_option_name(&directive.name) {
|
||||||
|
directive.name = canonical.to_owned();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
//! Canonical option registry for the compat surface.
|
||||||
|
|
||||||
|
/// Shared option data model used by the compat registry and query helpers.
|
||||||
|
mod model;
|
||||||
|
/// Lookup and filtering helpers for canonical, scoped, and RPC option spellings.
|
||||||
|
mod query;
|
||||||
|
/// Static aria2-compatible option specifications exposed by the compat layer.
|
||||||
|
mod registry;
|
||||||
|
/// Internal option names reserved for compat-layer metadata.
|
||||||
|
mod reserved;
|
||||||
|
|
||||||
|
pub use self::model::{
|
||||||
|
OptionFamily, OptionKind, OptionMetadata, OptionParser, OptionScope, OptionSource, OptionSpec,
|
||||||
|
OptionStatus, OptionTag,
|
||||||
|
};
|
||||||
|
pub use self::query::{
|
||||||
|
env_var_for_option, global_option_specs, is_live_option_status, is_required_pro_option,
|
||||||
|
live_option_specs, option_spec, per_download_option_specs, reserved_option_names,
|
||||||
|
rpc_option_name,
|
||||||
|
};
|
||||||
|
pub use self::registry::OPTION_SPECS;
|
||||||
|
pub use self::reserved::{RESERVED_OPTION_NAMES, ReservedOptionName};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
/// Regression coverage for compat option registry behavior.
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
//! Canonical option registry for the compat surface.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
/// High-level value kind for an option.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum OptionKind {
|
||||||
|
/// Boolean on/off value.
|
||||||
|
Bool,
|
||||||
|
/// Signed or unsigned integral value.
|
||||||
|
Integer,
|
||||||
|
/// Byte-size value such as `1M`.
|
||||||
|
Size,
|
||||||
|
/// Duration value such as seconds.
|
||||||
|
Duration,
|
||||||
|
/// Free-form text value.
|
||||||
|
Text,
|
||||||
|
/// Floating-point numeric value.
|
||||||
|
Float,
|
||||||
|
/// Filesystem path value.
|
||||||
|
Path,
|
||||||
|
/// Closed set of named values.
|
||||||
|
Enum,
|
||||||
|
/// Comma-separated list-like value.
|
||||||
|
List,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for OptionKind {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Bool => f.write_str("bool"),
|
||||||
|
Self::Integer => f.write_str("integer"),
|
||||||
|
Self::Size => f.write_str("size"),
|
||||||
|
Self::Duration => f.write_str("duration"),
|
||||||
|
Self::Text => f.write_str("text"),
|
||||||
|
Self::Float => f.write_str("float"),
|
||||||
|
Self::Path => f.write_str("path"),
|
||||||
|
Self::Enum => f.write_str("enum"),
|
||||||
|
Self::List => f.write_str("list"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provenance for an option specification.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum OptionSource {
|
||||||
|
/// Native upstream aria2 option.
|
||||||
|
Original,
|
||||||
|
/// Option introduced by `aria2-rust-pro`.
|
||||||
|
Pro,
|
||||||
|
/// Long-form alias kept for compatibility.
|
||||||
|
CompatibilityAlias,
|
||||||
|
/// RPC-facing alias kept for compatibility.
|
||||||
|
RpcAlias,
|
||||||
|
/// Experimental surface that may still change.
|
||||||
|
Experimental,
|
||||||
|
}
|
||||||
|
/// Implementation status for an option surface.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum OptionStatus {
|
||||||
|
/// Option is planned but not yet implemented.
|
||||||
|
Planned,
|
||||||
|
/// Option is implemented but not fully verified.
|
||||||
|
Implemented,
|
||||||
|
/// Option is implemented and verified.
|
||||||
|
Verified,
|
||||||
|
/// Option remains available but is deprecated.
|
||||||
|
Deprecated,
|
||||||
|
/// Option was intentionally removed from the live surface.
|
||||||
|
Removed,
|
||||||
|
}
|
||||||
|
/// Placement scope for an option.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum OptionScope {
|
||||||
|
/// Option is only valid globally.
|
||||||
|
Global,
|
||||||
|
/// Option is only valid per download.
|
||||||
|
PerDownload,
|
||||||
|
/// Option is valid in both scopes.
|
||||||
|
Both,
|
||||||
|
}
|
||||||
|
/// Functional family used for grouping and help tagging.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum OptionFamily {
|
||||||
|
/// Core download behavior.
|
||||||
|
Core,
|
||||||
|
/// HTTP-specific behavior.
|
||||||
|
Http,
|
||||||
|
/// FTP-specific behavior.
|
||||||
|
Ftp,
|
||||||
|
/// SFTP-specific behavior.
|
||||||
|
Sftp,
|
||||||
|
/// RPC server behavior.
|
||||||
|
Rpc,
|
||||||
|
/// `BitTorrent` behavior.
|
||||||
|
Bt,
|
||||||
|
/// Metalink behavior.
|
||||||
|
Metalink,
|
||||||
|
/// Session import or save behavior.
|
||||||
|
Session,
|
||||||
|
/// Input-file behavior.
|
||||||
|
Input,
|
||||||
|
/// Checksum-related behavior.
|
||||||
|
Checksum,
|
||||||
|
/// Proxy-related behavior.
|
||||||
|
Proxy,
|
||||||
|
/// TLS and certificate behavior.
|
||||||
|
Security,
|
||||||
|
/// Performance and throughput behavior.
|
||||||
|
Performance,
|
||||||
|
}
|
||||||
|
/// Fine-grained compatibility tags attached to option metadata.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum OptionTag {
|
||||||
|
/// Required to cover the targeted compatibility baseline.
|
||||||
|
RequiredCompat,
|
||||||
|
/// High-risk option that benefits from extra care.
|
||||||
|
HighRisk,
|
||||||
|
/// Option spelling is deprecated.
|
||||||
|
Deprecated,
|
||||||
|
/// Option acts as a compatibility alias.
|
||||||
|
Alias,
|
||||||
|
/// Option participates in the RPC surface.
|
||||||
|
Rpc,
|
||||||
|
/// Option participates in the `BitTorrent` surface.
|
||||||
|
Bt,
|
||||||
|
/// Option participates in the Metalink surface.
|
||||||
|
Metalink,
|
||||||
|
/// Option belongs in session files.
|
||||||
|
SessionFile,
|
||||||
|
/// Option belongs in input files.
|
||||||
|
InputFile,
|
||||||
|
/// Option is only valid globally.
|
||||||
|
GlobalOnly,
|
||||||
|
/// Option is only valid per download.
|
||||||
|
PerDownloadOnly,
|
||||||
|
}
|
||||||
|
/// Parser shape used for user-facing validation hints.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum OptionParser {
|
||||||
|
/// Boolean parser.
|
||||||
|
Boolean,
|
||||||
|
/// Integer parser.
|
||||||
|
Integer,
|
||||||
|
/// Size parser.
|
||||||
|
Size,
|
||||||
|
/// Duration parser.
|
||||||
|
Duration,
|
||||||
|
/// Free-form text parser.
|
||||||
|
Text,
|
||||||
|
/// Comma-separated list parser.
|
||||||
|
Csv,
|
||||||
|
/// Enum parser.
|
||||||
|
Enum,
|
||||||
|
/// URI-list parser.
|
||||||
|
UriList,
|
||||||
|
/// Filesystem path parser.
|
||||||
|
Path,
|
||||||
|
/// Repeated header parser.
|
||||||
|
Headers,
|
||||||
|
/// `key=value` parser.
|
||||||
|
KeyValue,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supplemental metadata attached to an option specification.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct OptionMetadata {
|
||||||
|
/// Scope where the option is valid.
|
||||||
|
pub scope: OptionScope,
|
||||||
|
/// Functional family for grouping and help tagging.
|
||||||
|
pub family: OptionFamily,
|
||||||
|
/// Parser shape used for validation and help text.
|
||||||
|
pub parser: OptionParser,
|
||||||
|
/// Extra compatibility tags attached to the option.
|
||||||
|
pub tags: &'static [OptionTag],
|
||||||
|
/// Human-readable validator summary.
|
||||||
|
pub validator: &'static str,
|
||||||
|
/// Short source description for the option origin.
|
||||||
|
pub source_text: &'static str,
|
||||||
|
/// Brief compat-oriented description for help and ledger output.
|
||||||
|
pub compatibility_note: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonical registry entry for a compat option.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct OptionSpec {
|
||||||
|
/// Canonical option name.
|
||||||
|
pub name: &'static str,
|
||||||
|
/// High-level option value kind.
|
||||||
|
pub kind: OptionKind,
|
||||||
|
/// Default value shown by help and metadata.
|
||||||
|
pub default_value: &'static str,
|
||||||
|
/// Provenance for the option surface.
|
||||||
|
pub source: OptionSource,
|
||||||
|
/// Current implementation status.
|
||||||
|
pub status: OptionStatus,
|
||||||
|
/// CLI aliases accepted for the option.
|
||||||
|
pub aliases: &'static [&'static str],
|
||||||
|
/// RPC names accepted for the option.
|
||||||
|
pub rpc_names: &'static [&'static str],
|
||||||
|
/// Supplemental metadata for the option.
|
||||||
|
pub metadata: OptionMetadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OptionSpec {
|
||||||
|
/// Returns whether the option carries a given compatibility tag.
|
||||||
|
#[must_use]
|
||||||
|
pub fn has_tag(&self, tag: OptionTag) -> bool {
|
||||||
|
self.metadata.tags.contains(&tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all accepted CLI spellings for the option.
|
||||||
|
#[must_use]
|
||||||
|
pub fn cli_spellings(&self) -> Vec<String> {
|
||||||
|
let mut spellings = vec![format!("--{}", self.name)];
|
||||||
|
for alias in self.aliases {
|
||||||
|
if alias.len() == 1 {
|
||||||
|
spellings.push(format!("-{alias}"));
|
||||||
|
} else {
|
||||||
|
spellings.push(format!("--{alias}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
spellings
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns config-file spellings for the option, excluding short aliases.
|
||||||
|
#[must_use]
|
||||||
|
pub fn config_spellings(&self) -> Vec<&'static str> {
|
||||||
|
let mut spellings = vec![self.name];
|
||||||
|
for alias in self.aliases {
|
||||||
|
if alias.len() > 1 && !spellings.contains(alias) {
|
||||||
|
spellings.push(alias);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
spellings
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all accepted lookup spellings across CLI aliases and RPC names.
|
||||||
|
#[must_use]
|
||||||
|
pub fn lookup_spellings(&self) -> Vec<&'static str> {
|
||||||
|
let mut spellings = vec![self.name];
|
||||||
|
for alias in self.aliases {
|
||||||
|
if !spellings.contains(alias) {
|
||||||
|
spellings.push(alias);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for rpc_name in self.rpc_names {
|
||||||
|
if !spellings.contains(rpc_name) {
|
||||||
|
spellings.push(rpc_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
spellings
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the human-readable value hint used by help rendering.
|
||||||
|
#[must_use]
|
||||||
|
pub fn value_hint(&self) -> &'static str {
|
||||||
|
if self.metadata.validator.contains('|') {
|
||||||
|
return self.metadata.validator;
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.metadata.parser {
|
||||||
|
OptionParser::Boolean => "true|false",
|
||||||
|
OptionParser::Integer => {
|
||||||
|
if self.name.ends_with("-port") {
|
||||||
|
"PORT"
|
||||||
|
} else {
|
||||||
|
"NUM"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OptionParser::Size => "SIZE",
|
||||||
|
OptionParser::Duration => "SEC",
|
||||||
|
OptionParser::Text => match self.metadata.validator {
|
||||||
|
"command string" => "COMMAND",
|
||||||
|
"proxy url" => "URI",
|
||||||
|
"filename-safe" => "FILE",
|
||||||
|
_ => "VALUE",
|
||||||
|
},
|
||||||
|
OptionParser::Csv => match self.name {
|
||||||
|
"bt-tracker" => "URI,...",
|
||||||
|
"no-proxy" => "HOST,...",
|
||||||
|
"select-file" => "INDEX,...",
|
||||||
|
_ => "VALUE,...",
|
||||||
|
},
|
||||||
|
OptionParser::Enum => self.metadata.validator,
|
||||||
|
OptionParser::UriList => "URI,...",
|
||||||
|
OptionParser::Path => "PATH",
|
||||||
|
OptionParser::Headers => "HEADER",
|
||||||
|
OptionParser::KeyValue => "KEY=VALUE",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the help synopsis rendered for the option.
|
||||||
|
#[must_use]
|
||||||
|
pub fn help_synopsis(&self) -> String {
|
||||||
|
let mut synopsis = format!("--{}", self.name);
|
||||||
|
let value_hint = self.value_hint();
|
||||||
|
if self.kind == OptionKind::Bool {
|
||||||
|
synopsis.push_str("[=");
|
||||||
|
synopsis.push_str(value_hint);
|
||||||
|
synopsis.push(']');
|
||||||
|
} else {
|
||||||
|
synopsis.push('=');
|
||||||
|
synopsis.push_str(value_hint);
|
||||||
|
}
|
||||||
|
|
||||||
|
let aliases = self.cli_spellings();
|
||||||
|
if let Some((_, extra_aliases)) = aliases.split_first()
|
||||||
|
&& !extra_aliases.is_empty()
|
||||||
|
{
|
||||||
|
synopsis.push_str(", ");
|
||||||
|
synopsis.push_str(&extra_aliases.join(", "));
|
||||||
|
}
|
||||||
|
synopsis
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
use super::registry::OPTION_SPECS;
|
||||||
|
use super::reserved::{RESERVED_OPTION_NAMES, ReservedOptionName};
|
||||||
|
use super::{OptionScope, OptionSource, OptionSpec, OptionStatus};
|
||||||
|
|
||||||
|
/// Returns the canonical option specification for a known spelling.
|
||||||
|
#[must_use]
|
||||||
|
pub fn option_spec(name: &str) -> Option<&'static OptionSpec> {
|
||||||
|
OPTION_SPECS
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.name == name)
|
||||||
|
.or_else(|| OPTION_SPECS.iter().find(|s| s.aliases.contains(&name)))
|
||||||
|
}
|
||||||
|
/// Returns whether an option is required by the `aria2-rust-pro` surface.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_required_pro_option(option: &str) -> bool {
|
||||||
|
OPTION_SPECS.iter().any(|s| {
|
||||||
|
s.name == option
|
||||||
|
&& (s.source == OptionSource::Pro || s.source == OptionSource::CompatibilityAlias)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether a status should be exposed in live option listings.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn is_live_option_status(status: OptionStatus) -> bool {
|
||||||
|
matches!(
|
||||||
|
status,
|
||||||
|
OptionStatus::Implemented | OptionStatus::Verified | OptionStatus::Deprecated
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filters the registry while preserving canonical-name uniqueness.
|
||||||
|
fn unique_option_specs(predicate: impl Fn(&OptionSpec) -> bool) -> Vec<&'static OptionSpec> {
|
||||||
|
let mut seen = std::collections::BTreeSet::new();
|
||||||
|
OPTION_SPECS
|
||||||
|
.iter()
|
||||||
|
.filter(|spec| predicate(spec) && seen.insert(spec.name))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all live option specs without duplicate canonical names.
|
||||||
|
#[must_use]
|
||||||
|
pub fn live_option_specs() -> Vec<&'static OptionSpec> {
|
||||||
|
unique_option_specs(|spec| is_live_option_status(spec.status))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all options available in the global scope.
|
||||||
|
#[must_use]
|
||||||
|
pub fn global_option_specs() -> Vec<&'static OptionSpec> {
|
||||||
|
unique_option_specs(|spec| {
|
||||||
|
matches!(spec.metadata.scope, OptionScope::Global | OptionScope::Both)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/// Returns all options available in the per-download scope.
|
||||||
|
#[must_use]
|
||||||
|
pub fn per_download_option_specs() -> Vec<&'static OptionSpec> {
|
||||||
|
unique_option_specs(|spec| {
|
||||||
|
matches!(
|
||||||
|
spec.metadata.scope,
|
||||||
|
OptionScope::PerDownload | OptionScope::Both
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/// Returns the reserved option-name table.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn reserved_option_names() -> &'static [ReservedOptionName] {
|
||||||
|
RESERVED_OPTION_NAMES
|
||||||
|
}
|
||||||
|
/// Returns the canonical RPC option name for a known spelling.
|
||||||
|
#[must_use]
|
||||||
|
pub fn rpc_option_name(name: &str) -> Option<&'static str> {
|
||||||
|
option_spec(name).and_then(|s| s.rpc_names.first().copied())
|
||||||
|
}
|
||||||
|
/// Returns the environment-variable name associated with an option.
|
||||||
|
#[must_use]
|
||||||
|
pub fn env_var_for_option(name: &str) -> Option<String> {
|
||||||
|
option_spec(name).map(|s| format!("ARIA2_{}", s.name.replace('-', "_").to_ascii_uppercase()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
OptionFamily, OptionKind, OptionMetadata, OptionParser, OptionScope, OptionSource, OptionSpec,
|
||||||
|
OptionStatus, OptionTag,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Shared tag slice for per-download-only options.
|
||||||
|
const TAG_PER: &[OptionTag] = &[OptionTag::PerDownloadOnly];
|
||||||
|
/// Shared tag slice for RPC options.
|
||||||
|
const TAG_RPC: &[OptionTag] = &[OptionTag::Rpc];
|
||||||
|
/// Shared tag slice for `BitTorrent` options.
|
||||||
|
const TAG_BT: &[OptionTag] = &[OptionTag::Bt];
|
||||||
|
/// Shared tag slice for Metalink options.
|
||||||
|
const TAG_METALINK: &[OptionTag] = &[OptionTag::Metalink];
|
||||||
|
/// Shared tag slice for compatibility aliases.
|
||||||
|
const TAG_ALIAS: &[OptionTag] = &[OptionTag::Alias];
|
||||||
|
/// Shared tag slice for global-only options.
|
||||||
|
const TAG_GLOBAL: &[OptionTag] = &[OptionTag::GlobalOnly];
|
||||||
|
|
||||||
|
/// Option entries that extend the baseline registry with compatibility extras.
|
||||||
|
mod compatibility_extension_entries;
|
||||||
|
/// Foundational core, RPC, and BT registry entries used across the compat layer.
|
||||||
|
mod foundational_entries;
|
||||||
|
/// Hook, proxy, TLS, and RPC-adjacent registry entries.
|
||||||
|
mod hooks_and_rpc_entries;
|
||||||
|
/// Transfer-tuning and performance-oriented registry entries.
|
||||||
|
mod transfer_tuning_entries;
|
||||||
|
|
||||||
|
use self::{
|
||||||
|
compatibility_extension_entries::COMPATIBILITY_EXTENSION_ENTRIES,
|
||||||
|
foundational_entries::FOUNDATIONAL_ENTRIES, hooks_and_rpc_entries::HOOKS_AND_RPC_ENTRIES,
|
||||||
|
transfer_tuning_entries::TRANSFER_TUNING_ENTRIES,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Builds the flattened compat registry once from the semantic entry groups.
|
||||||
|
fn build_option_specs() -> Box<[OptionSpec]> {
|
||||||
|
let total_len = FOUNDATIONAL_ENTRIES
|
||||||
|
.len()
|
||||||
|
.checked_add(HOOKS_AND_RPC_ENTRIES.len())
|
||||||
|
.and_then(|value| value.checked_add(TRANSFER_TUNING_ENTRIES.len()))
|
||||||
|
.and_then(|value| value.checked_add(COMPATIBILITY_EXTENSION_ENTRIES.len()))
|
||||||
|
.expect("compat option registry entry count should fit in usize");
|
||||||
|
let mut flattened = Vec::with_capacity(total_len);
|
||||||
|
flattened.extend_from_slice(FOUNDATIONAL_ENTRIES);
|
||||||
|
flattened.extend_from_slice(HOOKS_AND_RPC_ENTRIES);
|
||||||
|
flattened.extend_from_slice(TRANSFER_TUNING_ENTRIES);
|
||||||
|
flattened.extend_from_slice(COMPATIBILITY_EXTENSION_ENTRIES);
|
||||||
|
flattened.into_boxed_slice()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonical compat option registry.
|
||||||
|
pub static OPTION_SPECS: LazyLock<Box<[OptionSpec]>> = LazyLock::new(build_option_specs);
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
use super::{
|
||||||
|
OptionFamily, OptionKind, OptionMetadata, OptionParser, OptionScope, OptionSource, OptionSpec,
|
||||||
|
OptionStatus, OptionTag, TAG_ALIAS, TAG_GLOBAL, TAG_PER,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Compatibility-extension entries layered onto the canonical compat registry.
|
||||||
|
pub(super) const COMPATIBILITY_EXTENSION_ENTRIES: &[OptionSpec] = &[
|
||||||
|
OptionSpec {
|
||||||
|
name: "pause",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "false",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["pause"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::PerDownload,
|
||||||
|
family: OptionFamily::Core,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: TAG_PER,
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "start in paused state after registration",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "dry-run",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "false",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["dry-run"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::PerDownload,
|
||||||
|
family: OptionFamily::Core,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: TAG_PER,
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "validate target reachability without committing download data",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "on-download-pause",
|
||||||
|
kind: OptionKind::Text,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["on-download-pause"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Core,
|
||||||
|
parser: OptionParser::Text,
|
||||||
|
tags: TAG_GLOBAL,
|
||||||
|
validator: "command string",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "pause hook command",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "checksum",
|
||||||
|
kind: OptionKind::Text,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["checksum"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::PerDownload,
|
||||||
|
family: OptionFamily::Checksum,
|
||||||
|
parser: OptionParser::KeyValue,
|
||||||
|
tags: TAG_PER,
|
||||||
|
validator: "TYPE=DIGEST",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "expected file digest supplied at add time",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "select-file",
|
||||||
|
kind: OptionKind::List,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["select-file"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::PerDownload,
|
||||||
|
family: OptionFamily::Bt,
|
||||||
|
parser: OptionParser::Csv,
|
||||||
|
tags: &[OptionTag::Bt, OptionTag::PerDownloadOnly],
|
||||||
|
validator: "torrent file indexes",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "choose torrent file indexes or ranges to download",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "bt-remove-unselected-file",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "false",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["bt-remove-unselected-file"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::PerDownload,
|
||||||
|
family: OptionFamily::Bt,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: &[OptionTag::Bt, OptionTag::PerDownloadOnly],
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "delete skipped torrent files when selection is active",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "metalink-base-uri",
|
||||||
|
kind: OptionKind::Text,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["metalink-base-uri"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::PerDownload,
|
||||||
|
family: OptionFamily::Metalink,
|
||||||
|
parser: OptionParser::Text,
|
||||||
|
tags: &[OptionTag::Metalink, OptionTag::PerDownloadOnly],
|
||||||
|
validator: "any",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "base URI used to resolve relative Metalink resources",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "remote-time",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "false",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &["R"],
|
||||||
|
rpc_names: &["remote-time"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Http,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: &[],
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "persist remote timestamp on the output file",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "rpc-user",
|
||||||
|
kind: OptionKind::Text,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["rpc-user"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Rpc,
|
||||||
|
parser: OptionParser::Text,
|
||||||
|
tags: &[OptionTag::Rpc, OptionTag::GlobalOnly],
|
||||||
|
validator: "any",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "legacy basic-auth rpc username",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "rpc-passwd",
|
||||||
|
kind: OptionKind::Text,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["rpc-passwd"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Rpc,
|
||||||
|
parser: OptionParser::Text,
|
||||||
|
tags: &[OptionTag::Rpc, OptionTag::GlobalOnly],
|
||||||
|
validator: "any",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "legacy basic-auth rpc password",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "rpc-max-request-size",
|
||||||
|
kind: OptionKind::Size,
|
||||||
|
default_value: "2M",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["rpc-max-request-size"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Rpc,
|
||||||
|
parser: OptionParser::Size,
|
||||||
|
tags: &[OptionTag::Rpc, OptionTag::GlobalOnly],
|
||||||
|
validator: ">=0",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "maximum accepted rpc request payload size",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "rpc-allow-origin-all",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "false",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["rpc-allow-origin-all"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Rpc,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: &[OptionTag::Rpc, OptionTag::GlobalOnly],
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "allow any origin for browser rpc access",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "rpc-certificate",
|
||||||
|
kind: OptionKind::Path,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["rpc-certificate"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Rpc,
|
||||||
|
parser: OptionParser::Path,
|
||||||
|
tags: &[OptionTag::Rpc, OptionTag::GlobalOnly],
|
||||||
|
validator: "path",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "tls certificate for secure rpc mode",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "rpc-private-key",
|
||||||
|
kind: OptionKind::Path,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["rpc-private-key"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Rpc,
|
||||||
|
parser: OptionParser::Path,
|
||||||
|
tags: &[OptionTag::Rpc, OptionTag::GlobalOnly],
|
||||||
|
validator: "path",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "tls private key for secure rpc mode",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "rpc-secure",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "false",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["rpc-secure"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Rpc,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: &[OptionTag::Rpc, OptionTag::GlobalOnly],
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "serve rpc over tls",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "rpc-save-upload-metadata",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "true",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["rpc-save-upload-metadata"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Rpc,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: &[OptionTag::Rpc, OptionTag::GlobalOnly],
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "persist uploaded torrent or metalink metadata",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "input-file",
|
||||||
|
kind: OptionKind::Path,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &["i"],
|
||||||
|
rpc_names: &["input-file"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Input,
|
||||||
|
parser: OptionParser::Path,
|
||||||
|
tags: &[OptionTag::InputFile, OptionTag::GlobalOnly],
|
||||||
|
validator: "existing path",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "load uri list from file",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "save-session",
|
||||||
|
kind: OptionKind::Path,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["save-session"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Session,
|
||||||
|
parser: OptionParser::Path,
|
||||||
|
tags: &[OptionTag::SessionFile, OptionTag::GlobalOnly],
|
||||||
|
validator: "writable path",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "save active tasks on exit",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "save-session-interval",
|
||||||
|
kind: OptionKind::Duration,
|
||||||
|
default_value: "0",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["save-session-interval"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Session,
|
||||||
|
parser: OptionParser::Duration,
|
||||||
|
tags: &[OptionTag::SessionFile, OptionTag::GlobalOnly],
|
||||||
|
validator: ">=0",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "periodic session flush interval",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "no-want-digest-header",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "false",
|
||||||
|
source: OptionSource::Pro,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &["http-want-digest"],
|
||||||
|
rpc_names: &["no-want-digest-header", "http-want-digest"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Http,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: TAG_ALIAS,
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2-rust-pro",
|
||||||
|
compatibility_note: "compat switch for digest header behavior",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
use super::{
|
||||||
|
OptionFamily, OptionKind, OptionMetadata, OptionParser, OptionScope, OptionSource, OptionSpec,
|
||||||
|
OptionStatus, OptionTag, TAG_BT, TAG_GLOBAL, TAG_PER, TAG_RPC,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Foundational option entries that anchor the compat registry surface.
|
||||||
|
pub(super) const FOUNDATIONAL_ENTRIES: &[OptionSpec] = &[
|
||||||
|
OptionSpec {
|
||||||
|
name: "dir",
|
||||||
|
kind: OptionKind::Path,
|
||||||
|
default_value: ".",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Verified,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["dir"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::PerDownload,
|
||||||
|
family: OptionFamily::Core,
|
||||||
|
parser: OptionParser::Path,
|
||||||
|
tags: TAG_PER,
|
||||||
|
validator: "non-empty path",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "download target directory",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "out",
|
||||||
|
kind: OptionKind::Text,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Verified,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["out"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::PerDownload,
|
||||||
|
family: OptionFamily::Core,
|
||||||
|
parser: OptionParser::Text,
|
||||||
|
tags: TAG_PER,
|
||||||
|
validator: "filename-safe",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "output file name override",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "split",
|
||||||
|
kind: OptionKind::Integer,
|
||||||
|
default_value: "5",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Verified,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["split"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::PerDownload,
|
||||||
|
family: OptionFamily::Performance,
|
||||||
|
parser: OptionParser::Integer,
|
||||||
|
tags: TAG_PER,
|
||||||
|
validator: ">=1",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "piece split count",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "max-concurrent-downloads",
|
||||||
|
kind: OptionKind::Integer,
|
||||||
|
default_value: "5",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Verified,
|
||||||
|
aliases: &["j"],
|
||||||
|
rpc_names: &["max-concurrent-downloads"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Performance,
|
||||||
|
parser: OptionParser::Integer,
|
||||||
|
tags: TAG_GLOBAL,
|
||||||
|
validator: ">=1",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "maximum number of active downloads",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "continue",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "false",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Verified,
|
||||||
|
aliases: &["c"],
|
||||||
|
rpc_names: &["continue"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::PerDownload,
|
||||||
|
family: OptionFamily::Core,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: TAG_PER,
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "resume partial download",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "pause",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "false",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["pause"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::PerDownload,
|
||||||
|
family: OptionFamily::Core,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: TAG_PER,
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "add download in paused state",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "allow-overwrite",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "false",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["allow-overwrite"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::PerDownload,
|
||||||
|
family: OptionFamily::Core,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: TAG_PER,
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "allow overwriting existing destination files",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "checksum",
|
||||||
|
kind: OptionKind::Text,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["checksum"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::PerDownload,
|
||||||
|
family: OptionFamily::Checksum,
|
||||||
|
parser: OptionParser::KeyValue,
|
||||||
|
tags: TAG_PER,
|
||||||
|
validator: "algorithm=value",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "expected checksum for content verification",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "parameterized-uri",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "false",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &["P"],
|
||||||
|
rpc_names: &["parameterized-uri"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::PerDownload,
|
||||||
|
family: OptionFamily::Input,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: &[OptionTag::InputFile, OptionTag::PerDownloadOnly],
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "treat input URIs as parameterized templates",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "remote-time",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "false",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &["R"],
|
||||||
|
rpc_names: &["remote-time"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::PerDownload,
|
||||||
|
family: OptionFamily::Http,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: TAG_PER,
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "preserve remote Last-Modified timestamp",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "referer",
|
||||||
|
kind: OptionKind::Text,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["referer"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::PerDownload,
|
||||||
|
family: OptionFamily::Http,
|
||||||
|
parser: OptionParser::Text,
|
||||||
|
tags: TAG_PER,
|
||||||
|
validator: "URI",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "HTTP referer header override",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "enable-rpc",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "false",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Verified,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["enable-rpc"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Rpc,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: TAG_RPC,
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "enable rpc server",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "rpc-listen-port",
|
||||||
|
kind: OptionKind::Integer,
|
||||||
|
default_value: "6800",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Verified,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["rpc-listen-port"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Rpc,
|
||||||
|
parser: OptionParser::Integer,
|
||||||
|
tags: TAG_RPC,
|
||||||
|
validator: "1..65535",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "rpc tcp port",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "rpc-listen-all",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "false",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["rpc-listen-all"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Rpc,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: TAG_RPC,
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "bind rpc server to all interfaces",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "rpc-allow-origin-all",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "false",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["rpc-allow-origin-all"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Rpc,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: &[OptionTag::Rpc, OptionTag::GlobalOnly],
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "allow any Origin header on RPC responses",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "rpc-secure",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "false",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["rpc-secure"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Rpc,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: &[OptionTag::Rpc, OptionTag::GlobalOnly],
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "enable TLS on the RPC server",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "rpc-secret",
|
||||||
|
kind: OptionKind::Text,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["rpc-secret", "token"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Rpc,
|
||||||
|
parser: OptionParser::Text,
|
||||||
|
tags: TAG_RPC,
|
||||||
|
validator: "any",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "rpc auth token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "rpc-save-upload-metadata",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "true",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["rpc-save-upload-metadata"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Rpc,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: &[OptionTag::Rpc, OptionTag::GlobalOnly],
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "persist uploaded torrent or metalink metadata through RPC",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "listen-port",
|
||||||
|
kind: OptionKind::Integer,
|
||||||
|
default_value: "6881",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["listen-port"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Bt,
|
||||||
|
parser: OptionParser::Integer,
|
||||||
|
tags: TAG_BT,
|
||||||
|
validator: "1..65535",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "bt tcp/udp listen port",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "dht-listen-port",
|
||||||
|
kind: OptionKind::Integer,
|
||||||
|
default_value: "6881",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["dht-listen-port"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Bt,
|
||||||
|
parser: OptionParser::Integer,
|
||||||
|
tags: TAG_BT,
|
||||||
|
validator: "1..65535",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "dht udp listen port",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "select-file",
|
||||||
|
kind: OptionKind::List,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["select-file"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::PerDownload,
|
||||||
|
family: OptionFamily::Bt,
|
||||||
|
parser: OptionParser::Csv,
|
||||||
|
tags: &[OptionTag::Bt, OptionTag::PerDownloadOnly],
|
||||||
|
validator: "comma-separated file indexes",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "select BT or Metalink files to download",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "bt-tracker",
|
||||||
|
kind: OptionKind::List,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["bt-tracker"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Bt,
|
||||||
|
parser: OptionParser::Csv,
|
||||||
|
tags: TAG_BT,
|
||||||
|
validator: "comma-separated tracker list",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "bt tracker announce list",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "ftp-user",
|
||||||
|
kind: OptionKind::Text,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["ftp-user"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Ftp,
|
||||||
|
parser: OptionParser::Text,
|
||||||
|
tags: &[OptionTag::GlobalOnly],
|
||||||
|
validator: "VALUE",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "default FTP username",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,512 @@
|
|||||||
|
use super::{
|
||||||
|
OptionFamily, OptionKind, OptionMetadata, OptionParser, OptionScope, OptionSource, OptionSpec,
|
||||||
|
OptionStatus, OptionTag, TAG_BT, TAG_METALINK,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Hook, proxy, TLS, and RPC-adjacent compat registry entries.
|
||||||
|
pub(super) const HOOKS_AND_RPC_ENTRIES: &[OptionSpec] = &[
|
||||||
|
OptionSpec {
|
||||||
|
name: "on-download-complete",
|
||||||
|
kind: OptionKind::Text,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["on-download-complete"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Core,
|
||||||
|
parser: OptionParser::Text,
|
||||||
|
tags: &[OptionTag::GlobalOnly],
|
||||||
|
validator: "command string",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "completion hook command",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "on-download-stop",
|
||||||
|
kind: OptionKind::Text,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["on-download-stop"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Core,
|
||||||
|
parser: OptionParser::Text,
|
||||||
|
tags: &[OptionTag::GlobalOnly],
|
||||||
|
validator: "command string",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "stop hook command",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "save-cookies",
|
||||||
|
kind: OptionKind::Path,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["save-cookies"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Http,
|
||||||
|
parser: OptionParser::Path,
|
||||||
|
tags: &[OptionTag::GlobalOnly],
|
||||||
|
validator: "writable path",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "persist the cookie jar to disk",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "disable-ipv6",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "true",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["disable-ipv6"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Core,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: &[],
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "disable ipv6 sockets and resolution",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "user-agent",
|
||||||
|
kind: OptionKind::Text,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &["U"],
|
||||||
|
rpc_names: &["user-agent"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Http,
|
||||||
|
parser: OptionParser::Text,
|
||||||
|
tags: &[],
|
||||||
|
validator: "any",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "request user agent header",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "header",
|
||||||
|
kind: OptionKind::List,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["header"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Http,
|
||||||
|
parser: OptionParser::Headers,
|
||||||
|
tags: &[],
|
||||||
|
validator: "header lines",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "custom request headers",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "all-proxy",
|
||||||
|
kind: OptionKind::Text,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["all-proxy"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Proxy,
|
||||||
|
parser: OptionParser::Text,
|
||||||
|
tags: &[],
|
||||||
|
validator: "proxy url",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "generic proxy endpoint",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "http-proxy",
|
||||||
|
kind: OptionKind::Text,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["http-proxy"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Proxy,
|
||||||
|
parser: OptionParser::Text,
|
||||||
|
tags: &[],
|
||||||
|
validator: "proxy url",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "http proxy endpoint",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "https-proxy",
|
||||||
|
kind: OptionKind::Text,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["https-proxy"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Proxy,
|
||||||
|
parser: OptionParser::Text,
|
||||||
|
tags: &[],
|
||||||
|
validator: "proxy url",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "https proxy endpoint",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "no-proxy",
|
||||||
|
kind: OptionKind::List,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["no-proxy"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Proxy,
|
||||||
|
parser: OptionParser::Csv,
|
||||||
|
tags: &[],
|
||||||
|
validator: "csv host list",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "proxy bypass host list",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "ftp-proxy",
|
||||||
|
kind: OptionKind::Text,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["ftp-proxy"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Proxy,
|
||||||
|
parser: OptionParser::Text,
|
||||||
|
tags: &[],
|
||||||
|
validator: "proxy url",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "ftp proxy endpoint",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "http-proxy-user",
|
||||||
|
kind: OptionKind::Text,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["http-proxy-user"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Proxy,
|
||||||
|
parser: OptionParser::Text,
|
||||||
|
tags: &[],
|
||||||
|
validator: "any",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "username override for http proxy endpoint",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "http-proxy-passwd",
|
||||||
|
kind: OptionKind::Text,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["http-proxy-passwd"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Proxy,
|
||||||
|
parser: OptionParser::Text,
|
||||||
|
tags: &[],
|
||||||
|
validator: "any",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "password override for http proxy endpoint",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "https-proxy-user",
|
||||||
|
kind: OptionKind::Text,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["https-proxy-user"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Proxy,
|
||||||
|
parser: OptionParser::Text,
|
||||||
|
tags: &[],
|
||||||
|
validator: "any",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "username override for https proxy endpoint",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "https-proxy-passwd",
|
||||||
|
kind: OptionKind::Text,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["https-proxy-passwd"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Proxy,
|
||||||
|
parser: OptionParser::Text,
|
||||||
|
tags: &[],
|
||||||
|
validator: "any",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "password override for https proxy endpoint",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "ftp-proxy-user",
|
||||||
|
kind: OptionKind::Text,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["ftp-proxy-user"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Proxy,
|
||||||
|
parser: OptionParser::Text,
|
||||||
|
tags: &[],
|
||||||
|
validator: "any",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "username override for ftp proxy endpoint",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "ftp-proxy-passwd",
|
||||||
|
kind: OptionKind::Text,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["ftp-proxy-passwd"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Proxy,
|
||||||
|
parser: OptionParser::Text,
|
||||||
|
tags: &[],
|
||||||
|
validator: "any",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "password override for ftp proxy endpoint",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "all-proxy-user",
|
||||||
|
kind: OptionKind::Text,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["all-proxy-user"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Proxy,
|
||||||
|
parser: OptionParser::Text,
|
||||||
|
tags: &[],
|
||||||
|
validator: "any",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "username override for generic proxy endpoint",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "all-proxy-passwd",
|
||||||
|
kind: OptionKind::Text,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["all-proxy-passwd"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Proxy,
|
||||||
|
parser: OptionParser::Text,
|
||||||
|
tags: &[],
|
||||||
|
validator: "any",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "password override for generic proxy endpoint",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "check-certificate",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "true",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["check-certificate"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Security,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: &[],
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "verify peer and host certificates",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "ca-certificate",
|
||||||
|
kind: OptionKind::Path,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["ca-certificate"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Security,
|
||||||
|
parser: OptionParser::Path,
|
||||||
|
tags: &[],
|
||||||
|
validator: "path",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "ca certificate file",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "certificate",
|
||||||
|
kind: OptionKind::Path,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["certificate"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Security,
|
||||||
|
parser: OptionParser::Path,
|
||||||
|
tags: &[],
|
||||||
|
validator: "path",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "client certificate file",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "private-key",
|
||||||
|
kind: OptionKind::Path,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["private-key"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Security,
|
||||||
|
parser: OptionParser::Path,
|
||||||
|
tags: &[],
|
||||||
|
validator: "path",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "client private key file",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "retry-wait",
|
||||||
|
kind: OptionKind::Duration,
|
||||||
|
default_value: "0",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["retry-wait"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Http,
|
||||||
|
parser: OptionParser::Duration,
|
||||||
|
tags: &[],
|
||||||
|
validator: ">=0",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "retry delay in seconds",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "max-tries",
|
||||||
|
kind: OptionKind::Integer,
|
||||||
|
default_value: "5",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["max-tries"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Http,
|
||||||
|
parser: OptionParser::Integer,
|
||||||
|
tags: &[],
|
||||||
|
validator: ">=0",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "maximum retry attempts",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "bt-save-metadata",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "false",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Verified,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["bt-save-metadata"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::PerDownload,
|
||||||
|
family: OptionFamily::Bt,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: TAG_BT,
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "save metadata file",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "follow-torrent",
|
||||||
|
kind: OptionKind::Text,
|
||||||
|
default_value: "true",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Verified,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["follow-torrent"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::PerDownload,
|
||||||
|
family: OptionFamily::Bt,
|
||||||
|
parser: OptionParser::Enum,
|
||||||
|
tags: TAG_BT,
|
||||||
|
validator: "true|false|mem",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "torrent follow behavior",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "metalink-enable-unique-protocol",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "true",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Verified,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["metalink-enable-unique-protocol"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::PerDownload,
|
||||||
|
family: OptionFamily::Metalink,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: TAG_METALINK,
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "metalink dedupe by protocol",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,566 @@
|
|||||||
|
use super::{
|
||||||
|
OptionFamily, OptionKind, OptionMetadata, OptionParser, OptionScope, OptionSource, OptionSpec,
|
||||||
|
OptionStatus, OptionTag, TAG_GLOBAL, TAG_PER, TAG_RPC,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Transfer-tuning and performance-oriented compat registry entries.
|
||||||
|
pub(super) const TRANSFER_TUNING_ENTRIES: &[OptionSpec] = &[
|
||||||
|
OptionSpec {
|
||||||
|
name: "max-overall-download-limit",
|
||||||
|
kind: OptionKind::Size,
|
||||||
|
default_value: "0",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["max-overall-download-limit"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Performance,
|
||||||
|
parser: OptionParser::Size,
|
||||||
|
tags: TAG_PER,
|
||||||
|
validator: ">=0",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "global aggregate download-speed cap",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "max-download-limit",
|
||||||
|
kind: OptionKind::Size,
|
||||||
|
default_value: "0",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["max-download-limit"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Performance,
|
||||||
|
parser: OptionParser::Size,
|
||||||
|
tags: TAG_PER,
|
||||||
|
validator: ">=0",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "per-download download-speed cap",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "max-overall-upload-limit",
|
||||||
|
kind: OptionKind::Size,
|
||||||
|
default_value: "0",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["max-overall-upload-limit"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Performance,
|
||||||
|
parser: OptionParser::Size,
|
||||||
|
tags: TAG_PER,
|
||||||
|
validator: ">=0",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "global aggregate upload-speed cap",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "max-upload-limit",
|
||||||
|
kind: OptionKind::Size,
|
||||||
|
default_value: "0",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["max-upload-limit"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Performance,
|
||||||
|
parser: OptionParser::Size,
|
||||||
|
tags: TAG_PER,
|
||||||
|
validator: ">=0",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "per-download upload-speed cap",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "disk-cache",
|
||||||
|
kind: OptionKind::Size,
|
||||||
|
default_value: "16M",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["disk-cache"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Performance,
|
||||||
|
parser: OptionParser::Size,
|
||||||
|
tags: TAG_PER,
|
||||||
|
validator: ">=0",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "configured disk cache budget",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "max-connection-per-server",
|
||||||
|
kind: OptionKind::Integer,
|
||||||
|
default_value: "1",
|
||||||
|
source: OptionSource::Pro,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &["x"],
|
||||||
|
rpc_names: &["max-connection-per-server"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::PerDownload,
|
||||||
|
family: OptionFamily::Performance,
|
||||||
|
parser: OptionParser::Integer,
|
||||||
|
tags: TAG_PER,
|
||||||
|
validator: ">=1",
|
||||||
|
source_text: "aria2-rust-pro",
|
||||||
|
compatibility_note: "legacy speed tuning",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "min-split-size",
|
||||||
|
kind: OptionKind::Size,
|
||||||
|
default_value: "20M",
|
||||||
|
source: OptionSource::Pro,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["min-split-size"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::PerDownload,
|
||||||
|
family: OptionFamily::Performance,
|
||||||
|
parser: OptionParser::Size,
|
||||||
|
tags: TAG_PER,
|
||||||
|
validator: ">=1024",
|
||||||
|
source_text: "aria2-rust-pro",
|
||||||
|
compatibility_note: "pro lower split-size floor target",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "piece-length",
|
||||||
|
kind: OptionKind::Size,
|
||||||
|
default_value: "1M",
|
||||||
|
source: OptionSource::Pro,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["piece-length"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::PerDownload,
|
||||||
|
family: OptionFamily::Performance,
|
||||||
|
parser: OptionParser::Size,
|
||||||
|
tags: TAG_PER,
|
||||||
|
validator: ">=1024",
|
||||||
|
source_text: "aria2-rust-pro",
|
||||||
|
compatibility_note: "pro lower piece-length floor target",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "retry-on-400",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "false",
|
||||||
|
source: OptionSource::Pro,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["retry-on-400"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Http,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: TAG_RPC,
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2-rust-pro",
|
||||||
|
compatibility_note: "retry HTTP 400 when explicitly enabled",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "retry-on-403",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "false",
|
||||||
|
source: OptionSource::Pro,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["retry-on-403"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Http,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: TAG_RPC,
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2-rust-pro",
|
||||||
|
compatibility_note: "retry HTTP 403 when explicitly enabled",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "retry-on-406",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "false",
|
||||||
|
source: OptionSource::Pro,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["retry-on-406"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Http,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: TAG_RPC,
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2-rust-pro",
|
||||||
|
compatibility_note: "retry HTTP 406 when explicitly enabled",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "retry-on-unknown",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "false",
|
||||||
|
source: OptionSource::Pro,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["retry-on-unknown"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Http,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: TAG_RPC,
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2-rust-pro",
|
||||||
|
compatibility_note: "retry unknown HTTP failure when explicitly enabled",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "referer",
|
||||||
|
kind: OptionKind::Text,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["referer"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Http,
|
||||||
|
parser: OptionParser::Text,
|
||||||
|
tags: &[],
|
||||||
|
validator: "any",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "http referer header override",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "lowest-speed-limit",
|
||||||
|
kind: OptionKind::Size,
|
||||||
|
default_value: "0",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["lowest-speed-limit"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Performance,
|
||||||
|
parser: OptionParser::Size,
|
||||||
|
tags: &[],
|
||||||
|
validator: ">=0",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "abort slow connections below this transfer rate",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "allow-overwrite",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "false",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["allow-overwrite"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::PerDownload,
|
||||||
|
family: OptionFamily::Core,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: TAG_PER,
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "overwrite existing target file instead of refusing",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "auto-file-renaming",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "true",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["auto-file-renaming"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::PerDownload,
|
||||||
|
family: OptionFamily::Core,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: TAG_PER,
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "rename colliding output file automatically",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "parameterized-uri",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "false",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &["P"],
|
||||||
|
rpc_names: &["parameterized-uri"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::PerDownload,
|
||||||
|
family: OptionFamily::Input,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: &[OptionTag::InputFile, OptionTag::PerDownloadOnly],
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "expand numbered or ranged URI templates",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "realtime-chunk-checksum",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "true",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["realtime-chunk-checksum"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::PerDownload,
|
||||||
|
family: OptionFamily::Checksum,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: TAG_PER,
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "verify chunk checksums while downloading when available",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "load-cookies",
|
||||||
|
kind: OptionKind::Path,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["load-cookies"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Http,
|
||||||
|
parser: OptionParser::Path,
|
||||||
|
tags: &[],
|
||||||
|
validator: "existing path",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "load Mozilla-format cookies from disk",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "save-cookies",
|
||||||
|
kind: OptionKind::Path,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["save-cookies"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Global,
|
||||||
|
family: OptionFamily::Http,
|
||||||
|
parser: OptionParser::Path,
|
||||||
|
tags: TAG_GLOBAL,
|
||||||
|
validator: "writable path",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "save Mozilla-format cookies on exit",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "ftp-user",
|
||||||
|
kind: OptionKind::Text,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["ftp-user"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Ftp,
|
||||||
|
parser: OptionParser::Text,
|
||||||
|
tags: &[],
|
||||||
|
validator: "any",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "ftp username applied to matching transfers",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "ftp-passwd",
|
||||||
|
kind: OptionKind::Text,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["ftp-passwd"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Ftp,
|
||||||
|
parser: OptionParser::Text,
|
||||||
|
tags: &[],
|
||||||
|
validator: "any",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "ftp password applied to matching transfers",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "ftp-type",
|
||||||
|
kind: OptionKind::Enum,
|
||||||
|
default_value: "binary",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["ftp-type"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Ftp,
|
||||||
|
parser: OptionParser::Enum,
|
||||||
|
tags: &[],
|
||||||
|
validator: "binary|ascii",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "ftp transfer type preference",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "ftp-pasv",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "true",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &["p"],
|
||||||
|
rpc_names: &["ftp-pasv"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Ftp,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: &[],
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "ftp passive mode toggle",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "ftp-reuse-connection",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "true",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["ftp-reuse-connection"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Ftp,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: &[],
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "ftp connection reuse preference",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "http-user",
|
||||||
|
kind: OptionKind::Text,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["http-user"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Http,
|
||||||
|
parser: OptionParser::Text,
|
||||||
|
tags: &[],
|
||||||
|
validator: "any",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "http auth username applied to matching transfers",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "http-passwd",
|
||||||
|
kind: OptionKind::Text,
|
||||||
|
default_value: "",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["http-passwd"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Http,
|
||||||
|
parser: OptionParser::Text,
|
||||||
|
tags: &[],
|
||||||
|
validator: "any",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "http auth password applied to matching transfers",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "use-head",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "false",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["use-head"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Http,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: &[],
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "probe with HEAD before the first GET when supported",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "always-resume",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "true",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["always-resume"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Session,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: &[],
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "refuse to restart from scratch when resume is possible",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "max-resume-failure-tries",
|
||||||
|
kind: OptionKind::Integer,
|
||||||
|
default_value: "0",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["max-resume-failure-tries"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Session,
|
||||||
|
parser: OptionParser::Integer,
|
||||||
|
tags: &[],
|
||||||
|
validator: ">=0",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "allow limited resume mismatches before restarting",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OptionSpec {
|
||||||
|
name: "conditional-get",
|
||||||
|
kind: OptionKind::Bool,
|
||||||
|
default_value: "false",
|
||||||
|
source: OptionSource::Original,
|
||||||
|
status: OptionStatus::Implemented,
|
||||||
|
aliases: &[],
|
||||||
|
rpc_names: &["conditional-get"],
|
||||||
|
metadata: OptionMetadata {
|
||||||
|
scope: OptionScope::Both,
|
||||||
|
family: OptionFamily::Http,
|
||||||
|
parser: OptionParser::Boolean,
|
||||||
|
tags: &[],
|
||||||
|
validator: "bool",
|
||||||
|
source_text: "aria2 option",
|
||||||
|
compatibility_note: "skip download when local file is already current",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/// Reserved option name that cannot be used by external inputs.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ReservedOptionName {
|
||||||
|
/// Reserved option spelling.
|
||||||
|
pub name: &'static str,
|
||||||
|
/// Explanation for the reservation.
|
||||||
|
pub reason: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reserved option spellings used internally by the compat layer.
|
||||||
|
pub const RESERVED_OPTION_NAMES: &[ReservedOptionName] = &[
|
||||||
|
ReservedOptionName {
|
||||||
|
name: "_profile",
|
||||||
|
reason: "internal profile selector",
|
||||||
|
},
|
||||||
|
ReservedOptionName {
|
||||||
|
name: "_compat-mode",
|
||||||
|
reason: "internal compatibility switch",
|
||||||
|
},
|
||||||
|
ReservedOptionName {
|
||||||
|
name: "_source",
|
||||||
|
reason: "config source marker",
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
use super::{
|
||||||
|
OptionFamily, OptionScope, OptionStatus, OptionTag, global_option_specs, live_option_specs,
|
||||||
|
option_spec, per_download_option_specs,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cli_spellings_keep_short_and_long_alias_forms() {
|
||||||
|
let continue_spec = option_spec("continue").expect("continue should exist");
|
||||||
|
assert_eq!(
|
||||||
|
continue_spec.cli_spellings(),
|
||||||
|
vec!["--continue".to_owned(), "-c".to_owned()]
|
||||||
|
);
|
||||||
|
|
||||||
|
let digest_spec =
|
||||||
|
option_spec("no-want-digest-header").expect("compat digest option should exist");
|
||||||
|
assert_eq!(
|
||||||
|
digest_spec.cli_spellings(),
|
||||||
|
vec![
|
||||||
|
"--no-want-digest-header".to_owned(),
|
||||||
|
"--http-want-digest".to_owned()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_spellings_exclude_short_cli_aliases_but_keep_long_compat_aliases() {
|
||||||
|
let continue_spec = option_spec("continue").expect("continue should exist");
|
||||||
|
assert_eq!(continue_spec.config_spellings(), vec!["continue"]);
|
||||||
|
|
||||||
|
let digest_spec =
|
||||||
|
option_spec("no-want-digest-header").expect("compat digest option should exist");
|
||||||
|
assert_eq!(
|
||||||
|
digest_spec.config_spellings(),
|
||||||
|
vec!["no-want-digest-header", "http-want-digest"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lookup_spellings_merge_canonical_alias_and_rpc_names_without_duplicates() {
|
||||||
|
let rpc_secret = option_spec("rpc-secret").expect("rpc-secret should exist");
|
||||||
|
assert_eq!(rpc_secret.lookup_spellings(), vec!["rpc-secret", "token"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn value_hint_and_help_synopsis_are_help_ready() {
|
||||||
|
let continue_spec = option_spec("continue").expect("continue should exist");
|
||||||
|
assert_eq!(continue_spec.value_hint(), "true|false");
|
||||||
|
assert_eq!(continue_spec.help_synopsis(), "--continue[=true|false], -c");
|
||||||
|
|
||||||
|
let port_spec = option_spec("rpc-listen-port").expect("rpc-listen-port should exist");
|
||||||
|
assert_eq!(port_spec.value_hint(), "PORT");
|
||||||
|
assert_eq!(port_spec.help_synopsis(), "--rpc-listen-port=PORT");
|
||||||
|
|
||||||
|
let follow_torrent = option_spec("follow-torrent").expect("follow-torrent should exist");
|
||||||
|
assert_eq!(follow_torrent.value_hint(), "true|false|mem");
|
||||||
|
|
||||||
|
let ftp_type = option_spec("ftp-type").expect("ftp-type should exist");
|
||||||
|
assert_eq!(ftp_type.value_hint(), "binary|ascii");
|
||||||
|
|
||||||
|
let ftp_pasv = option_spec("ftp-pasv").expect("ftp-pasv should exist");
|
||||||
|
assert_eq!(ftp_pasv.help_synopsis(), "--ftp-pasv[=true|false], -p");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn live_option_specs_cover_only_non_planned_surface() {
|
||||||
|
let live_specs = live_option_specs();
|
||||||
|
assert!(live_specs.iter().all(|spec| {
|
||||||
|
spec.status != OptionStatus::Planned && spec.status != OptionStatus::Removed
|
||||||
|
}));
|
||||||
|
assert!(live_specs.iter().any(|spec| spec.name == "bt-tracker"));
|
||||||
|
assert!(
|
||||||
|
live_specs
|
||||||
|
.iter()
|
||||||
|
.any(|spec| spec.name == "on-download-complete")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extended_registry_surface_exposes_aliases_families_tags_and_value_hints() {
|
||||||
|
let parameterized = option_spec("parameterized-uri")
|
||||||
|
.expect("parameterized-uri compatibility option should exist");
|
||||||
|
assert_eq!(option_spec("P"), Some(parameterized));
|
||||||
|
assert_eq!(
|
||||||
|
parameterized.cli_spellings(),
|
||||||
|
vec!["--parameterized-uri".to_owned(), "-P".to_owned()]
|
||||||
|
);
|
||||||
|
assert_eq!(parameterized.metadata.family, OptionFamily::Input);
|
||||||
|
assert_eq!(parameterized.metadata.scope, OptionScope::PerDownload);
|
||||||
|
assert!(parameterized.has_tag(OptionTag::InputFile));
|
||||||
|
|
||||||
|
let remote_time = option_spec("R").expect("remote-time short alias should resolve");
|
||||||
|
assert_eq!(remote_time.name, "remote-time");
|
||||||
|
assert_eq!(remote_time.metadata.family, OptionFamily::Http);
|
||||||
|
|
||||||
|
let checksum = option_spec("checksum").expect("checksum option should exist");
|
||||||
|
assert_eq!(checksum.metadata.family, OptionFamily::Checksum);
|
||||||
|
assert_eq!(checksum.value_hint(), "KEY=VALUE");
|
||||||
|
assert!(checksum.has_tag(OptionTag::PerDownloadOnly));
|
||||||
|
|
||||||
|
let select_file = option_spec("select-file").expect("select-file should exist");
|
||||||
|
assert_eq!(select_file.value_hint(), "INDEX,...");
|
||||||
|
assert_eq!(select_file.metadata.family, OptionFamily::Bt);
|
||||||
|
assert!(select_file.has_tag(OptionTag::Bt));
|
||||||
|
|
||||||
|
let rpc_origin =
|
||||||
|
option_spec("rpc-allow-origin-all").expect("rpc-allow-origin-all should exist");
|
||||||
|
assert_eq!(rpc_origin.metadata.family, OptionFamily::Rpc);
|
||||||
|
assert_eq!(rpc_origin.metadata.scope, OptionScope::Global);
|
||||||
|
assert!(rpc_origin.has_tag(OptionTag::Rpc));
|
||||||
|
|
||||||
|
let ftp_proxy = option_spec("ftp-proxy").expect("ftp-proxy should exist");
|
||||||
|
assert_eq!(ftp_proxy.metadata.family, OptionFamily::Proxy);
|
||||||
|
assert_eq!(ftp_proxy.metadata.scope, OptionScope::Both);
|
||||||
|
|
||||||
|
let all_proxy_user = option_spec("all-proxy-user").expect("all-proxy-user should exist");
|
||||||
|
assert_eq!(all_proxy_user.metadata.family, OptionFamily::Proxy);
|
||||||
|
assert_eq!(all_proxy_user.value_hint(), "VALUE");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extended_registry_surface_flows_into_global_and_per_download_views() {
|
||||||
|
let global_names = global_option_specs()
|
||||||
|
.into_iter()
|
||||||
|
.map(|spec| spec.name)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let per_names = per_download_option_specs()
|
||||||
|
.into_iter()
|
||||||
|
.map(|spec| spec.name)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
assert!(global_names.contains(&"rpc-secure"));
|
||||||
|
assert!(global_names.contains(&"save-cookies"));
|
||||||
|
assert!(global_names.contains(&"rpc-save-upload-metadata"));
|
||||||
|
assert!(global_names.contains(&"ftp-user"));
|
||||||
|
assert!(global_names.contains(&"ftp-proxy-user"));
|
||||||
|
assert!(global_names.contains(&"ftp-pasv"));
|
||||||
|
|
||||||
|
assert!(per_names.contains(&"select-file"));
|
||||||
|
assert!(per_names.contains(&"pause"));
|
||||||
|
assert!(per_names.contains(&"checksum"));
|
||||||
|
assert!(per_names.contains(&"allow-overwrite"));
|
||||||
|
assert!(per_names.contains(&"ftp-proxy"));
|
||||||
|
assert!(per_names.contains(&"ftp-reuse-connection"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn option_registry_views_do_not_emit_duplicate_canonical_names() {
|
||||||
|
let live_names = live_option_specs()
|
||||||
|
.into_iter()
|
||||||
|
.map(|spec| spec.name)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let global_names = global_option_specs()
|
||||||
|
.into_iter()
|
||||||
|
.map(|spec| spec.name)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let per_names = per_download_option_specs()
|
||||||
|
.into_iter()
|
||||||
|
.map(|spec| spec.name)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
for (label, names) in [
|
||||||
|
("live", live_names),
|
||||||
|
("global", global_names),
|
||||||
|
("per-download", per_names),
|
||||||
|
] {
|
||||||
|
let unique = names
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
unique.len(),
|
||||||
|
names.len(),
|
||||||
|
"{label} option projection should not contain duplicate canonical names: {names:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "aria2-rust-pro-core"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
description.workspace = true
|
||||||
|
readme.workspace = true
|
||||||
|
keywords.workspace = true
|
||||||
|
categories.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "aria2_rust_pro_core"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
aria2-rust-pro-storage.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,679 @@
|
|||||||
|
//! Download-engine orchestration, queue management, and session persistence glue.
|
||||||
|
#![expect(
|
||||||
|
clippy::arithmetic_side_effects,
|
||||||
|
reason = "engine counters and scheduler math are guarded by domain tests rather than checked arithmetic at every step"
|
||||||
|
)]
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
use aria2_rust_pro_storage::{
|
||||||
|
ControlFileVersion, ControlMetadata, DownloadFile as StoredDownloadFile,
|
||||||
|
PieceIndex as StoredPieceIndex, PieceState as StoredPieceState, SessionFile, SessionFileEntry,
|
||||||
|
load_session_file, save_session_file, write_aria2_control_file,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
error::{CoreError, Result},
|
||||||
|
events::{EventBus, EventListener, RuntimeEvent, RuntimeEventKind},
|
||||||
|
options::{OptionKey, OptionPatch, OptionValue},
|
||||||
|
progress::{GlobalStat, ProgressSnapshot},
|
||||||
|
request::{
|
||||||
|
BtFileInfo, BtPeerInfo, BtPeerMutationResult, BtPieceAvailabilityMutationResult,
|
||||||
|
BtPieceAvailabilityUpdate, BtPieceBlockUpdate, BtPieceMutationResult, BtPressureSnapshot,
|
||||||
|
BtRuntimeState, BtRuntimeTickResult, BtTrackerInfo, DownloadId, DownloadStatus,
|
||||||
|
RequestContext, RequestGroup, RetryAttempt, SegmentAssignment, SegmentRuntimeStats,
|
||||||
|
SegmentState,
|
||||||
|
},
|
||||||
|
runtime::RuntimeConfig,
|
||||||
|
scheduler::{
|
||||||
|
RetryHistoryEntry, ScheduleDecision, Scheduler, SchedulerActivityCounters,
|
||||||
|
SchedulerPlanningObservation, SchedulerState,
|
||||||
|
},
|
||||||
|
session::{SaveSessionTarget, Session, SessionState},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Session-file and control-file persistence helpers for the download engine.
|
||||||
|
mod session_persistence;
|
||||||
|
use self::session_persistence::{
|
||||||
|
build_control_metadata, control_path_for_session_entry, resolve_target_path,
|
||||||
|
should_persist_group,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `BitTorrent` runtime mutation helpers attached to the download engine.
|
||||||
|
mod bt_runtime;
|
||||||
|
/// Runtime inspection snapshots and progress aggregation helpers.
|
||||||
|
mod inspection;
|
||||||
|
/// Waiting-queue and stopped-result management helpers for the engine.
|
||||||
|
mod queue;
|
||||||
|
/// Scheduler integration and segment-assignment helpers for the engine.
|
||||||
|
mod scheduling;
|
||||||
|
|
||||||
|
pub use self::inspection::{DownloadRuntimeSnapshot, RuntimeInstrumentationSnapshot};
|
||||||
|
use self::inspection::{
|
||||||
|
active_runtime_group_count, build_progress_snapshot, clamp_speed, effective_piece_length,
|
||||||
|
effective_speed_caps, infer_total_length,
|
||||||
|
};
|
||||||
|
use self::scheduling::{build_segment_assignments, retry_history_from_group};
|
||||||
|
|
||||||
|
/// Converts a `usize` into `i64`, saturating to `i64::MAX` when it does not fit.
|
||||||
|
fn usize_to_i64(value: usize) -> i64 {
|
||||||
|
i64::try_from(value).unwrap_or(i64::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts a `usize` into `u32`, saturating to `u32::MAX` when it does not fit.
|
||||||
|
fn usize_to_u32(value: usize) -> u32 {
|
||||||
|
u32::try_from(value).unwrap_or(u32::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts a `usize` into `u64`.
|
||||||
|
fn usize_to_u64(value: usize) -> u64 {
|
||||||
|
u64::try_from(value).unwrap_or(u64::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts a `u32` into `usize`, saturating to `usize::MAX` on unsupported targets.
|
||||||
|
fn u32_to_usize(value: u32) -> usize {
|
||||||
|
usize::try_from(value).unwrap_or(usize::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable handle used by higher layers to refer to a download in the engine.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct DownloadHandle {
|
||||||
|
/// Download identifier carried by this handle.
|
||||||
|
gid: DownloadId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DownloadHandle {
|
||||||
|
/// Creates a new handle for the provided download identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(gid: DownloadId) -> Self {
|
||||||
|
Self { gid }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the identifier carried by this handle.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn gid(self) -> DownloadId {
|
||||||
|
self.gid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory registry that owns all tracked request groups.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct DownloadRegistry {
|
||||||
|
/// Next synthetic gid assigned when callers add a new request.
|
||||||
|
next_gid: u64,
|
||||||
|
/// Stored downloads keyed by gid.
|
||||||
|
groups: HashMap<DownloadId, RequestGroup>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DownloadRegistry {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DownloadRegistry {
|
||||||
|
/// Creates an empty registry with gid allocation starting at `1`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
next_gid: 1,
|
||||||
|
groups: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allocates the next gid without inserting a request group.
|
||||||
|
pub fn allocate_gid(&mut self) -> DownloadId {
|
||||||
|
let gid = DownloadId::new(self.next_gid);
|
||||||
|
self.next_gid = self.next_gid.saturating_add(1);
|
||||||
|
gid
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts an existing request group and returns its external handle.
|
||||||
|
pub fn insert(&mut self, group: RequestGroup) -> DownloadHandle {
|
||||||
|
let gid = group.gid();
|
||||||
|
self.groups.insert(gid, group);
|
||||||
|
DownloadHandle::new(gid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a simple URI-backed request group and inserts it into the registry.
|
||||||
|
pub fn add_uri(&mut self, uri: impl Into<String>) -> DownloadHandle {
|
||||||
|
let gid = self.allocate_gid();
|
||||||
|
self.insert(RequestGroup::new(gid, uri))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the immutable request group for `gid` when present.
|
||||||
|
#[must_use]
|
||||||
|
pub fn get(&self, gid: DownloadId) -> Option<&RequestGroup> {
|
||||||
|
self.groups.get(&gid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the mutable request group for `gid` when present.
|
||||||
|
pub fn get_mut(&mut self, gid: DownloadId) -> Option<&mut RequestGroup> {
|
||||||
|
self.groups.get_mut(&gid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes and returns the request group associated with `gid`.
|
||||||
|
pub fn remove(&mut self, gid: DownloadId) -> Option<RequestGroup> {
|
||||||
|
self.groups.remove(&gid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of tracked downloads.
|
||||||
|
#[must_use]
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.groups.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the registry is empty.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.groups.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterates over handles for every currently registered gid.
|
||||||
|
pub fn handles(&self) -> impl Iterator<Item = DownloadHandle> + '_ {
|
||||||
|
self.groups.keys().copied().map(DownloadHandle::new)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reference frame used when changing a waiting download's queue position.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum QueuePositionMode {
|
||||||
|
/// Treat the supplied offset as an absolute queue index.
|
||||||
|
Set,
|
||||||
|
/// Apply the supplied offset relative to the current queue index.
|
||||||
|
Cur,
|
||||||
|
/// Apply the supplied offset relative to the end of the waiting queue.
|
||||||
|
End,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// High-level in-memory engine that coordinates downloads, scheduling, session IO, and events.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct DownloadEngine {
|
||||||
|
/// Registry holding all live request groups.
|
||||||
|
registry: DownloadRegistry,
|
||||||
|
/// Waiting queue order for paused and waiting downloads.
|
||||||
|
reserved_queue: Vec<DownloadId>,
|
||||||
|
/// Shared scheduler used to plan active segments and retry flow.
|
||||||
|
scheduler: Scheduler,
|
||||||
|
/// Embedded session/runtime bridge.
|
||||||
|
session: Session,
|
||||||
|
/// Cached global stat record updated on demand.
|
||||||
|
global_stat: GlobalStat,
|
||||||
|
/// Event bus used by RPC and other observers.
|
||||||
|
events: EventBus,
|
||||||
|
/// Engine lifecycle state.
|
||||||
|
state: SessionState,
|
||||||
|
/// Monotonic sequence assigned to stopped downloads for tellStopped ordering.
|
||||||
|
next_stopped_sequence: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DownloadEngine {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DownloadEngine {
|
||||||
|
/// Creates a new engine with the default runtime configuration.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::with_runtime(RuntimeConfig::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new engine backed by the provided runtime configuration.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_runtime(runtime: RuntimeConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
registry: DownloadRegistry::new(),
|
||||||
|
reserved_queue: Vec::new(),
|
||||||
|
scheduler: Scheduler::new(),
|
||||||
|
global_stat: GlobalStat::default(),
|
||||||
|
events: EventBus::new(),
|
||||||
|
state: SessionState::Idle,
|
||||||
|
session: Session::new(runtime),
|
||||||
|
next_stopped_sequence: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the runtime configuration currently attached to the session bridge.
|
||||||
|
#[must_use]
|
||||||
|
pub fn runtime(&self) -> &RuntimeConfig {
|
||||||
|
self.session.runtime()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the underlying download registry.
|
||||||
|
#[must_use]
|
||||||
|
pub fn registry(&self) -> &DownloadRegistry {
|
||||||
|
&self.registry
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a mutable reference to the underlying download registry.
|
||||||
|
pub fn registry_mut(&mut self) -> &mut DownloadRegistry {
|
||||||
|
&mut self.registry
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the scheduler used by the engine.
|
||||||
|
#[must_use]
|
||||||
|
pub fn scheduler(&self) -> &Scheduler {
|
||||||
|
&self.scheduler
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a mutable reference to the scheduler used by the engine.
|
||||||
|
pub fn scheduler_mut(&mut self) -> &mut Scheduler {
|
||||||
|
&mut self.scheduler
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the engine event bus.
|
||||||
|
#[must_use]
|
||||||
|
pub fn events(&self) -> &EventBus {
|
||||||
|
&self.events
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a mutable reference to the engine event bus.
|
||||||
|
pub fn events_mut(&mut self) -> &mut EventBus {
|
||||||
|
&mut self.events
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the embedded session bridge.
|
||||||
|
#[must_use]
|
||||||
|
pub fn session(&self) -> &Session {
|
||||||
|
&self.session
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a mutable reference to the embedded session bridge.
|
||||||
|
pub fn session_mut(&mut self) -> &mut Session {
|
||||||
|
&mut self.session
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current engine lifecycle state.
|
||||||
|
#[must_use]
|
||||||
|
pub fn state(&self) -> &SessionState {
|
||||||
|
&self.state
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the cached global stat structure.
|
||||||
|
#[must_use]
|
||||||
|
pub fn global_stat(&self) -> &GlobalStat {
|
||||||
|
&self.global_stat
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a URI download to the registry and enqueues it at the back of the waiting queue.
|
||||||
|
pub fn add_uri(&mut self, uri: impl Into<String>) -> DownloadHandle {
|
||||||
|
let handle = self.registry.add_uri(uri);
|
||||||
|
self.enqueue_reserved_back(handle.gid());
|
||||||
|
self.events
|
||||||
|
.emit(RuntimeEvent::new(RuntimeEventKind::DownloadAdded).with_gid(handle.gid()));
|
||||||
|
handle
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a fully-formed request context to the registry and waiting queue.
|
||||||
|
pub fn add_request(&mut self, context: RequestContext) -> DownloadHandle {
|
||||||
|
let gid = self.registry.allocate_gid();
|
||||||
|
let handle = self
|
||||||
|
.registry
|
||||||
|
.insert(RequestGroup::with_context(gid, context));
|
||||||
|
self.enqueue_reserved_back(handle.gid());
|
||||||
|
self.events
|
||||||
|
.emit(RuntimeEvent::new(RuntimeEventKind::DownloadAdded).with_gid(handle.gid()));
|
||||||
|
handle
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requests an orderly shutdown through the session bridge.
|
||||||
|
pub fn shutdown(&mut self) -> Result<()> {
|
||||||
|
self.state = SessionState::ShuttingDown;
|
||||||
|
self.scheduler.set_state(SchedulerState::ShuttingDown);
|
||||||
|
self.session.shutdown()?;
|
||||||
|
self.events
|
||||||
|
.emit(RuntimeEvent::new(RuntimeEventKind::ShutdownRequested));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requests an immediate forced shutdown through the session bridge.
|
||||||
|
pub fn force_shutdown(&mut self) -> Result<()> {
|
||||||
|
self.state = SessionState::ForceShuttingDown;
|
||||||
|
self.scheduler.set_state(SchedulerState::Stopped);
|
||||||
|
self.session.force_shutdown()?;
|
||||||
|
self.events
|
||||||
|
.emit(RuntimeEvent::new(RuntimeEventKind::ForceShutdownRequested));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists the in-memory session and, for file targets, control-file metadata.
|
||||||
|
pub fn save_session(&mut self, target: SaveSessionTarget) -> Result<()> {
|
||||||
|
self.events
|
||||||
|
.emit(RuntimeEvent::new(RuntimeEventKind::SessionSaving));
|
||||||
|
self.session.save_session(target.clone())?;
|
||||||
|
if let SaveSessionTarget::Path(path) = &target {
|
||||||
|
let session_file = self.build_session_file(path);
|
||||||
|
save_session_file(path, &session_file)
|
||||||
|
.map_err(|_| CoreError::StorageUnavailable("failed to write session file"))?;
|
||||||
|
for group in self.registry.groups.values() {
|
||||||
|
if !should_persist_group(*group.status()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let target_path = resolve_target_path(group, self.session.global_options());
|
||||||
|
let control_path = control_path_for_session_entry(path, group.gid());
|
||||||
|
let control =
|
||||||
|
build_control_metadata(group, &target_path, self.runtime().piece_length);
|
||||||
|
if let Some(parent) = control_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).map_err(|_| {
|
||||||
|
CoreError::StorageUnavailable("failed to create control-file directory")
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
write_aria2_control_file(&control_path, &control)
|
||||||
|
.map_err(|_| CoreError::StorageUnavailable("failed to write control file"))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.events
|
||||||
|
.emit(RuntimeEvent::new(RuntimeEventKind::SessionSaved));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads session state from memory or disk and rebuilds the registry when needed.
|
||||||
|
pub fn load_session(&mut self, source: SaveSessionTarget) -> Result<()> {
|
||||||
|
match source {
|
||||||
|
SaveSessionTarget::Memory => self.session.load_session(SaveSessionTarget::Memory),
|
||||||
|
SaveSessionTarget::Path(path) => {
|
||||||
|
let session_file = load_session_file(&path)
|
||||||
|
.map_err(|_| CoreError::StorageUnavailable("failed to read session file"))?;
|
||||||
|
self.rebuild_registry_from_session_file(&path, session_file);
|
||||||
|
if let Err(error) = self
|
||||||
|
.session
|
||||||
|
.load_session(SaveSessionTarget::Path(path.clone()))
|
||||||
|
{
|
||||||
|
match error {
|
||||||
|
CoreError::StorageUnavailable(_) => self.session.mark_external_load(path),
|
||||||
|
other => return Err(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribes a listener to engine events.
|
||||||
|
pub fn register_listener(&mut self, listener: impl EventListener + 'static) {
|
||||||
|
self.events.subscribe(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets one global option on the embedded session bridge.
|
||||||
|
pub fn set_option(&mut self, key: impl Into<OptionKey>, value: impl Into<OptionValue>) {
|
||||||
|
self.session.set_global_option(key, value);
|
||||||
|
self.events
|
||||||
|
.emit(RuntimeEvent::new(RuntimeEventKind::OptionChanged));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies a batch global-option patch to the embedded session bridge.
|
||||||
|
pub fn apply_options(&mut self, patch: OptionPatch) {
|
||||||
|
self.session.apply_global_option_patch(patch);
|
||||||
|
self.events
|
||||||
|
.emit(RuntimeEvent::new(RuntimeEventKind::OptionChanged));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a progress snapshot for a specific download.
|
||||||
|
pub fn progress_snapshot(&self, gid: DownloadId) -> Result<ProgressSnapshot> {
|
||||||
|
let group = self
|
||||||
|
.registry
|
||||||
|
.get(gid)
|
||||||
|
.ok_or(CoreError::UnknownDownloadId(gid))?;
|
||||||
|
Ok(build_progress_snapshot(
|
||||||
|
group,
|
||||||
|
self.runtime(),
|
||||||
|
active_runtime_group_count(&self.registry),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds an engine-level runtime snapshot for a specific download.
|
||||||
|
pub fn download_runtime_snapshot(&self, gid: DownloadId) -> Result<DownloadRuntimeSnapshot> {
|
||||||
|
let group = self
|
||||||
|
.registry
|
||||||
|
.get(gid)
|
||||||
|
.ok_or(CoreError::UnknownDownloadId(gid))?;
|
||||||
|
let total_length = group
|
||||||
|
.bt_effective_target_length()
|
||||||
|
.unwrap_or_else(|| group.total_length());
|
||||||
|
let active_count = active_runtime_group_count(&self.registry);
|
||||||
|
let (effective_download_limit, effective_upload_limit) =
|
||||||
|
effective_speed_caps(group, self.runtime(), active_count);
|
||||||
|
Ok(DownloadRuntimeSnapshot {
|
||||||
|
gid,
|
||||||
|
status: *group.status(),
|
||||||
|
total_length,
|
||||||
|
completed_length: group.completed_length(),
|
||||||
|
remaining_length: total_length
|
||||||
|
.saturating_sub(group.completed_length().min(total_length)),
|
||||||
|
download_speed: clamp_speed(group.download_speed(), effective_download_limit),
|
||||||
|
upload_speed: clamp_speed(group.upload_speed(), effective_upload_limit),
|
||||||
|
effective_download_limit,
|
||||||
|
effective_upload_limit,
|
||||||
|
retry_count: group.retry_count(),
|
||||||
|
num_connections: group.num_connections(),
|
||||||
|
segment_stats: group.segment_runtime_stats(),
|
||||||
|
bt_pressure: group.bt_pressure_snapshot(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns aggregate scheduler and resource instrumentation for all downloads.
|
||||||
|
#[must_use]
|
||||||
|
pub fn runtime_instrumentation_snapshot(&self) -> RuntimeInstrumentationSnapshot {
|
||||||
|
let mut snapshot = RuntimeInstrumentationSnapshot {
|
||||||
|
download_count: self.registry.len(),
|
||||||
|
active_download_count: 0,
|
||||||
|
waiting_download_count: 0,
|
||||||
|
stopped_download_count: 0,
|
||||||
|
error_download_count: 0,
|
||||||
|
complete_download_count: 0,
|
||||||
|
total_active_segments: 0,
|
||||||
|
total_planned_bytes: 0,
|
||||||
|
total_remaining_segment_bytes: 0,
|
||||||
|
total_requestable_pieces: 0,
|
||||||
|
total_scarce_requestable_pieces: 0,
|
||||||
|
total_bt_peers: 0,
|
||||||
|
configured_disk_cache_bytes: self.runtime().disk_cache_bytes,
|
||||||
|
max_overall_download_limit: self.runtime().max_overall_download_limit,
|
||||||
|
max_download_limit: self.runtime().max_download_limit,
|
||||||
|
max_overall_upload_limit: self.runtime().max_overall_upload_limit,
|
||||||
|
max_upload_limit: self.runtime().max_upload_limit,
|
||||||
|
scheduler_state: self.scheduler.state(),
|
||||||
|
scheduler_counters: *self.scheduler.activity_counters(),
|
||||||
|
last_scheduler_plan: self.scheduler.last_planning_observation().copied(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for group in self.registry.groups.values() {
|
||||||
|
match group.status() {
|
||||||
|
DownloadStatus::Active => snapshot.active_download_count += 1,
|
||||||
|
DownloadStatus::Waiting => snapshot.waiting_download_count += 1,
|
||||||
|
DownloadStatus::Paused | DownloadStatus::Removed => {
|
||||||
|
snapshot.stopped_download_count += 1;
|
||||||
|
}
|
||||||
|
DownloadStatus::Error => snapshot.error_download_count += 1,
|
||||||
|
DownloadStatus::Complete => snapshot.complete_download_count += 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
let segment_stats = group.segment_runtime_stats();
|
||||||
|
snapshot.total_active_segments +=
|
||||||
|
segment_stats.active_count + segment_stats.retrying_count;
|
||||||
|
snapshot.total_planned_bytes = snapshot
|
||||||
|
.total_planned_bytes
|
||||||
|
.saturating_add(segment_stats.planned_bytes);
|
||||||
|
snapshot.total_remaining_segment_bytes = snapshot
|
||||||
|
.total_remaining_segment_bytes
|
||||||
|
.saturating_add(segment_stats.remaining_bytes);
|
||||||
|
|
||||||
|
if let Some(pressure) = group.bt_pressure_snapshot() {
|
||||||
|
snapshot.total_requestable_pieces += pressure.requestable_pieces;
|
||||||
|
snapshot.total_scarce_requestable_pieces += pressure.scarce_requestable_pieces;
|
||||||
|
snapshot.total_bt_peers += pressure.peer_count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns handles for active downloads.
|
||||||
|
#[must_use]
|
||||||
|
pub fn tell_active(&self) -> Vec<DownloadHandle> {
|
||||||
|
self.registry
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(gid, group)| {
|
||||||
|
(group.status() == &DownloadStatus::Active).then_some(DownloadHandle::new(*gid))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns handles for waiting and paused downloads in reserved-queue order.
|
||||||
|
#[must_use]
|
||||||
|
pub fn tell_waiting(&self) -> Vec<DownloadHandle> {
|
||||||
|
self.reserved_queue
|
||||||
|
.iter()
|
||||||
|
.filter_map(|gid| {
|
||||||
|
self.registry.get(*gid).and_then(|group| {
|
||||||
|
matches!(
|
||||||
|
group.status(),
|
||||||
|
DownloadStatus::Waiting | DownloadStatus::Paused
|
||||||
|
)
|
||||||
|
.then_some(DownloadHandle::new(*gid))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns handles for stopped downloads ordered by stopped-sequence.
|
||||||
|
#[must_use]
|
||||||
|
pub fn tell_stopped(&self) -> Vec<DownloadHandle> {
|
||||||
|
let mut stopped = self
|
||||||
|
.registry
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(gid, group)| {
|
||||||
|
matches!(
|
||||||
|
group.status(),
|
||||||
|
DownloadStatus::Complete | DownloadStatus::Removed | DownloadStatus::Error
|
||||||
|
)
|
||||||
|
.then_some((group.stopped_sequence().unwrap_or_default(), *gid))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
stopped.sort_by_key(|(sequence, gid)| (*sequence, *gid));
|
||||||
|
stopped
|
||||||
|
.into_iter()
|
||||||
|
.map(|(_, gid)| DownloadHandle::new(gid))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the total number of downloads that have entered a stopped terminal state.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn num_stopped_total(&self) -> u64 {
|
||||||
|
self.next_stopped_sequence.saturating_sub(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emits a prebuilt runtime event through the engine event bus.
|
||||||
|
pub fn emit(&mut self, event: RuntimeEvent) {
|
||||||
|
self.events.emit(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a lightweight handle when the download exists.
|
||||||
|
#[must_use]
|
||||||
|
pub fn handle(&self, gid: DownloadId) -> Option<DownloadHandle> {
|
||||||
|
self.registry.get(gid).map(|_| DownloadHandle::new(gid))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a mutable request group when the download exists.
|
||||||
|
pub fn handle_mut(&mut self, gid: DownloadId) -> Option<&mut RequestGroup> {
|
||||||
|
self.registry.get_mut(gid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves a mutable request group or returns `UnknownDownloadId`.
|
||||||
|
fn group_mut(&mut self, gid: DownloadId) -> Result<&mut RequestGroup> {
|
||||||
|
self.registry
|
||||||
|
.get_mut(gid)
|
||||||
|
.ok_or(CoreError::UnknownDownloadId(gid))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies one scheduler decision to a specific group and synchronizes the session bridge.
|
||||||
|
fn apply_schedule_decision(&mut self, gid: DownloadId, decision: &ScheduleDecision) {
|
||||||
|
self.scheduler.record_decision(decision);
|
||||||
|
match decision {
|
||||||
|
ScheduleDecision::RunNow(_) | ScheduleDecision::Queue(_) => {
|
||||||
|
let runtime = self.runtime().clone();
|
||||||
|
self.remove_from_reserved_queue(gid);
|
||||||
|
let Some(group) = self.registry.get_mut(gid) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
group.set_status(DownloadStatus::Active);
|
||||||
|
let segments = self.scheduler.plan_active_segments(group, &runtime);
|
||||||
|
let assignments = build_segment_assignments(group, &runtime, segments);
|
||||||
|
group.set_segment_assignments(assignments);
|
||||||
|
self.scheduler.observe_plan(group, &runtime, segments);
|
||||||
|
let completed = group.completed_length();
|
||||||
|
let retry_count = group.retry_count();
|
||||||
|
let retry_attempts = group.retry_attempts().to_vec();
|
||||||
|
let active_segments = u32_to_usize(group.num_connections());
|
||||||
|
self.sync_runtime_bridge(completed, retry_count, retry_attempts, active_segments);
|
||||||
|
self.events
|
||||||
|
.emit(RuntimeEvent::new(RuntimeEventKind::DownloadStarted).with_gid(gid));
|
||||||
|
}
|
||||||
|
ScheduleDecision::RetryLater(_) => {
|
||||||
|
let (completed, retry_count, retry_attempts, active_segments) = {
|
||||||
|
let Some(group) = self.registry.get_mut(gid) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
group.increment_retry_count();
|
||||||
|
let mut attempt =
|
||||||
|
RetryAttempt::new(group.retry_count(), group.completed_length());
|
||||||
|
attempt.length = Some(
|
||||||
|
group
|
||||||
|
.total_length()
|
||||||
|
.saturating_sub(group.completed_length()),
|
||||||
|
);
|
||||||
|
attempt.error = Some("schedule-retry:error-state".to_string());
|
||||||
|
attempt.recoverable = true;
|
||||||
|
group.push_retry_attempt(attempt);
|
||||||
|
group.clear_segment_assignments();
|
||||||
|
group.set_status(DownloadStatus::Waiting);
|
||||||
|
(
|
||||||
|
group.completed_length(),
|
||||||
|
group.retry_count(),
|
||||||
|
group.retry_attempts().to_vec(),
|
||||||
|
u32_to_usize(group.num_connections()),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if !self.is_in_reserved_queue(gid) {
|
||||||
|
self.enqueue_reserved_back(gid);
|
||||||
|
}
|
||||||
|
self.sync_runtime_bridge(completed, retry_count, retry_attempts, active_segments);
|
||||||
|
}
|
||||||
|
ScheduleDecision::Pause(_) | ScheduleDecision::Remove(_) | ScheduleDecision::Noop => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirrors scheduler planning state into the embedded session bridge.
|
||||||
|
fn sync_runtime_bridge(
|
||||||
|
&mut self,
|
||||||
|
completed_length: u64,
|
||||||
|
retry_count: u32,
|
||||||
|
retry_attempts: Vec<RetryAttempt>,
|
||||||
|
active_segments: usize,
|
||||||
|
) {
|
||||||
|
let segment_plan = self.scheduler.bridge_segment_plan(self.runtime());
|
||||||
|
self.session.set_segment_plan(segment_plan);
|
||||||
|
let retry_history = retry_history_from_group(&retry_attempts);
|
||||||
|
let runtime_state = self.scheduler.bridge_runtime_state(
|
||||||
|
completed_length,
|
||||||
|
retry_count,
|
||||||
|
retry_history,
|
||||||
|
active_segments,
|
||||||
|
);
|
||||||
|
self.session.apply_runtime_schedule_state(runtime_state);
|
||||||
|
self.session.apply_scheduler_instrumentation(
|
||||||
|
*self.scheduler.activity_counters(),
|
||||||
|
self.scheduler.last_planning_observation().copied(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
/// Engine-focused tests covering registry flow, persistence, snapshots, and BT runtime helpers.
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
use super::{
|
||||||
|
BtPeerInfo, BtPeerMutationResult, BtPieceAvailabilityMutationResult, BtPieceAvailabilityUpdate,
|
||||||
|
BtPieceBlockUpdate, BtPieceMutationResult, BtRuntimeTickResult, BtTrackerInfo, CoreError,
|
||||||
|
DownloadEngine, DownloadId, Result,
|
||||||
|
};
|
||||||
|
|
||||||
|
impl DownloadEngine {
|
||||||
|
/// Replaces the full BT peer snapshot for a download.
|
||||||
|
pub fn apply_bt_peer_snapshot(
|
||||||
|
&mut self,
|
||||||
|
gid: DownloadId,
|
||||||
|
peers: Vec<BtPeerInfo>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let group = self.group_mut(gid)?;
|
||||||
|
if group.bt().is_none() {
|
||||||
|
return Err(CoreError::InvalidState(
|
||||||
|
"bt runtime state is not initialized",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let _ = group.replace_bt_peer_snapshot(peers);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies an incremental BT peer update to a download.
|
||||||
|
pub fn apply_bt_peer_update(
|
||||||
|
&mut self,
|
||||||
|
gid: DownloadId,
|
||||||
|
peer: BtPeerInfo,
|
||||||
|
) -> Result<BtPeerMutationResult> {
|
||||||
|
let group = self.group_mut(gid)?;
|
||||||
|
if group.bt().is_none() {
|
||||||
|
return Err(CoreError::InvalidState(
|
||||||
|
"bt runtime state is not initialized",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(group.apply_bt_peer_update(peer))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies an incremental BT piece availability update to a download.
|
||||||
|
pub fn apply_bt_piece_availability_update(
|
||||||
|
&mut self,
|
||||||
|
gid: DownloadId,
|
||||||
|
update: BtPieceAvailabilityUpdate,
|
||||||
|
) -> Result<BtPieceAvailabilityMutationResult> {
|
||||||
|
let group = self.group_mut(gid)?;
|
||||||
|
if group.bt().is_none() {
|
||||||
|
return Err(CoreError::InvalidState(
|
||||||
|
"bt runtime state is not initialized",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(group.apply_bt_piece_availability_update(update))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies an incremental BT block-completion update to a download.
|
||||||
|
pub fn apply_bt_piece_block_update(
|
||||||
|
&mut self,
|
||||||
|
gid: DownloadId,
|
||||||
|
update: BtPieceBlockUpdate,
|
||||||
|
) -> Result<BtPieceMutationResult> {
|
||||||
|
let group = self.group_mut(gid)?;
|
||||||
|
if group.bt().is_none() {
|
||||||
|
return Err(CoreError::InvalidState(
|
||||||
|
"bt runtime state is not initialized",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(group.apply_bt_piece_block_update(update))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::too_many_arguments,
|
||||||
|
reason = "BT runtime tick mirrors the RPC-visible counters updated together by one event"
|
||||||
|
)]
|
||||||
|
/// Applies a full BT runtime tick, including byte deltas, speeds, timers, and connection count.
|
||||||
|
pub fn apply_bt_runtime_tick(
|
||||||
|
&mut self,
|
||||||
|
gid: DownloadId,
|
||||||
|
downloaded_delta: u64,
|
||||||
|
uploaded_delta: u64,
|
||||||
|
download_speed: u64,
|
||||||
|
upload_speed: u64,
|
||||||
|
share_time_delta_secs: u64,
|
||||||
|
seeding_time_delta_secs: u64,
|
||||||
|
seeding: bool,
|
||||||
|
num_connections: Option<u32>,
|
||||||
|
) -> Result<BtRuntimeTickResult> {
|
||||||
|
let group = self.group_mut(gid)?;
|
||||||
|
if group.bt().is_none() {
|
||||||
|
return Err(CoreError::InvalidState(
|
||||||
|
"bt runtime state is not initialized",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(group.apply_bt_runtime_tick(
|
||||||
|
downloaded_delta,
|
||||||
|
uploaded_delta,
|
||||||
|
download_speed,
|
||||||
|
upload_speed,
|
||||||
|
share_time_delta_secs,
|
||||||
|
seeding_time_delta_secs,
|
||||||
|
seeding,
|
||||||
|
num_connections,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances BT share/seeding timers to `now_unix_secs`.
|
||||||
|
pub fn tick_bt_runtime_clock(
|
||||||
|
&mut self,
|
||||||
|
gid: DownloadId,
|
||||||
|
now_unix_secs: u64,
|
||||||
|
seeding: bool,
|
||||||
|
) -> Result<BtRuntimeTickResult> {
|
||||||
|
let group = self.group_mut(gid)?;
|
||||||
|
if group.bt().is_none() {
|
||||||
|
return Err(CoreError::InvalidState(
|
||||||
|
"bt runtime state is not initialized",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(group.tick_bt_runtime_clock(now_unix_secs, seeding))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggles BT seeding state while preserving share-runtime accounting.
|
||||||
|
pub fn set_bt_seeding_state(
|
||||||
|
&mut self,
|
||||||
|
gid: DownloadId,
|
||||||
|
seeding: bool,
|
||||||
|
at_unix_secs: Option<u64>,
|
||||||
|
) -> Result<BtRuntimeTickResult> {
|
||||||
|
let group = self.group_mut(gid)?;
|
||||||
|
if group.bt().is_none() {
|
||||||
|
return Err(CoreError::InvalidState(
|
||||||
|
"bt runtime state is not initialized",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(group.set_bt_seeding_state(seeding, at_unix_secs))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upserts a BT tracker runtime snapshot keyed by tracker URL.
|
||||||
|
pub fn apply_bt_tracker_snapshot(
|
||||||
|
&mut self,
|
||||||
|
gid: DownloadId,
|
||||||
|
tracker_url: &str,
|
||||||
|
tracker_id: Option<String>,
|
||||||
|
seeders: Option<u32>,
|
||||||
|
leechers: Option<u32>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let group = self.group_mut(gid)?;
|
||||||
|
let bt = group.bt_mut().ok_or(CoreError::InvalidState(
|
||||||
|
"bt runtime state is not initialized",
|
||||||
|
))?;
|
||||||
|
if let Some(tracker) = bt
|
||||||
|
.trackers
|
||||||
|
.iter_mut()
|
||||||
|
.find(|tracker| tracker.url == tracker_url)
|
||||||
|
{
|
||||||
|
if let Some(tracker_id) = tracker_id {
|
||||||
|
tracker.id = Some(tracker_id);
|
||||||
|
}
|
||||||
|
if seeders.is_some() {
|
||||||
|
tracker.seeders = seeders;
|
||||||
|
}
|
||||||
|
if leechers.is_some() {
|
||||||
|
tracker.leechers = leechers;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bt.trackers.push(BtTrackerInfo {
|
||||||
|
url: tracker_url.to_owned(),
|
||||||
|
tier: None,
|
||||||
|
id: tracker_id,
|
||||||
|
seeders,
|
||||||
|
leechers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records BT byte deltas and timer deltas without changing live speed counters.
|
||||||
|
pub fn record_bt_runtime_tick(
|
||||||
|
&mut self,
|
||||||
|
gid: DownloadId,
|
||||||
|
downloaded_delta: u64,
|
||||||
|
uploaded_delta: u64,
|
||||||
|
share_time_delta_secs: u64,
|
||||||
|
seeding_time_delta_secs: u64,
|
||||||
|
seeding: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
let _ = self.apply_bt_runtime_tick(
|
||||||
|
gid,
|
||||||
|
downloaded_delta,
|
||||||
|
uploaded_delta,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
share_time_delta_secs,
|
||||||
|
seeding_time_delta_secs,
|
||||||
|
seeding,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
use super::{
|
||||||
|
BtPressureSnapshot, BtRuntimeState, CoreError, DownloadEngine, DownloadId, DownloadRegistry,
|
||||||
|
DownloadStatus, GlobalStat, ProgressSnapshot, RequestGroup, Result, RuntimeConfig,
|
||||||
|
SchedulerActivityCounters, SchedulerPlanningObservation, SchedulerState, SegmentRuntimeStats,
|
||||||
|
usize_to_u32, usize_to_u64,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Runtime metrics for a single download at a specific sampling point.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct DownloadRuntimeSnapshot {
|
||||||
|
/// Download identifier.
|
||||||
|
pub gid: DownloadId,
|
||||||
|
/// Current download state.
|
||||||
|
pub status: DownloadStatus,
|
||||||
|
/// Total payload length tracked by this snapshot.
|
||||||
|
pub total_length: u64,
|
||||||
|
/// Persisted completed length for the download.
|
||||||
|
pub completed_length: u64,
|
||||||
|
/// Remaining bytes derived from the tracked total and completed lengths.
|
||||||
|
pub remaining_length: u64,
|
||||||
|
/// Effective download throughput after engine-side capping.
|
||||||
|
pub download_speed: u64,
|
||||||
|
/// Effective upload throughput after engine-side capping.
|
||||||
|
pub upload_speed: u64,
|
||||||
|
/// Effective per-download download cap after global and local policy are merged.
|
||||||
|
pub effective_download_limit: Option<u64>,
|
||||||
|
/// Effective per-download upload cap after global and local policy are merged.
|
||||||
|
pub effective_upload_limit: Option<u64>,
|
||||||
|
/// Retry counter recorded on the request group.
|
||||||
|
pub retry_count: u32,
|
||||||
|
/// Number of active connections the request currently reports.
|
||||||
|
pub num_connections: u32,
|
||||||
|
/// Segment planner metrics exported from the request group.
|
||||||
|
pub segment_stats: SegmentRuntimeStats,
|
||||||
|
/// `BitTorrent` pressure metrics when the request has BT runtime state.
|
||||||
|
pub bt_pressure: Option<BtPressureSnapshot>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cross-download runtime counters used by diagnostics and pressure tests.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct RuntimeInstrumentationSnapshot {
|
||||||
|
/// Number of registered downloads.
|
||||||
|
pub download_count: usize,
|
||||||
|
/// Number of active downloads.
|
||||||
|
pub active_download_count: usize,
|
||||||
|
/// Number of waiting downloads.
|
||||||
|
pub waiting_download_count: usize,
|
||||||
|
/// Number of paused or removed downloads still retained in memory.
|
||||||
|
pub stopped_download_count: usize,
|
||||||
|
/// Number of errored downloads.
|
||||||
|
pub error_download_count: usize,
|
||||||
|
/// Number of completed downloads.
|
||||||
|
pub complete_download_count: usize,
|
||||||
|
/// Total count of active or retrying segments across all groups.
|
||||||
|
pub total_active_segments: usize,
|
||||||
|
/// Total bytes planned across all runtime segment assignments.
|
||||||
|
pub total_planned_bytes: u64,
|
||||||
|
/// Total remaining bytes across all runtime segment assignments.
|
||||||
|
pub total_remaining_segment_bytes: u64,
|
||||||
|
/// Aggregate number of requestable BT pieces.
|
||||||
|
pub total_requestable_pieces: usize,
|
||||||
|
/// Aggregate number of scarce requestable BT pieces.
|
||||||
|
pub total_scarce_requestable_pieces: usize,
|
||||||
|
/// Aggregate number of observed BT peers.
|
||||||
|
pub total_bt_peers: usize,
|
||||||
|
/// Configured disk cache capacity in bytes.
|
||||||
|
pub configured_disk_cache_bytes: u64,
|
||||||
|
/// Global overall download limit configured in the runtime.
|
||||||
|
pub max_overall_download_limit: Option<u64>,
|
||||||
|
/// Global per-download download limit configured in the runtime.
|
||||||
|
pub max_download_limit: Option<u64>,
|
||||||
|
/// Global overall upload limit configured in the runtime.
|
||||||
|
pub max_overall_upload_limit: Option<u64>,
|
||||||
|
/// Global per-download upload limit configured in the runtime.
|
||||||
|
pub max_upload_limit: Option<u64>,
|
||||||
|
/// Current scheduler state snapshot.
|
||||||
|
pub scheduler_state: SchedulerState,
|
||||||
|
/// Scheduler activity counters accumulated so far.
|
||||||
|
pub scheduler_counters: SchedulerActivityCounters,
|
||||||
|
/// Last recorded planning observation, when available.
|
||||||
|
pub last_scheduler_plan: Option<SchedulerPlanningObservation>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DownloadEngine {
|
||||||
|
/// Returns the current status of a specific download.
|
||||||
|
pub fn tell_status(&self, gid: DownloadId) -> Result<DownloadStatus> {
|
||||||
|
self.registry
|
||||||
|
.get(gid)
|
||||||
|
.map(|group| *group.status())
|
||||||
|
.ok_or(CoreError::UnknownDownloadId(gid))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregates global counters and capped runtime speeds across all downloads.
|
||||||
|
#[must_use]
|
||||||
|
pub fn get_global_stat(&self) -> GlobalStat {
|
||||||
|
let mut stat = self.global_stat;
|
||||||
|
let active_count = active_runtime_group_count(&self.registry);
|
||||||
|
stat.num_active = 0;
|
||||||
|
stat.num_waiting = 0;
|
||||||
|
stat.num_stopped = 0;
|
||||||
|
stat.num_error = 0;
|
||||||
|
stat.num_complete = 0;
|
||||||
|
stat.total_length = 0;
|
||||||
|
stat.completed_length = 0;
|
||||||
|
stat.download_speed = 0;
|
||||||
|
stat.upload_speed = 0;
|
||||||
|
|
||||||
|
for group in self.registry.groups.values() {
|
||||||
|
let snapshot = build_progress_snapshot(group, self.runtime(), active_count);
|
||||||
|
match group.status() {
|
||||||
|
DownloadStatus::Active => stat.num_active += 1,
|
||||||
|
DownloadStatus::Waiting => stat.num_waiting += 1,
|
||||||
|
DownloadStatus::Paused | DownloadStatus::Removed => stat.num_stopped += 1,
|
||||||
|
DownloadStatus::Error => stat.num_error += 1,
|
||||||
|
DownloadStatus::Complete => stat.num_complete += 1,
|
||||||
|
}
|
||||||
|
stat.total_length = stat.total_length.saturating_add(snapshot.total_length);
|
||||||
|
stat.completed_length = stat
|
||||||
|
.completed_length
|
||||||
|
.saturating_add(snapshot.completed_length);
|
||||||
|
stat.download_speed = stat.download_speed.saturating_add(snapshot.download_speed);
|
||||||
|
stat.upload_speed = stat.upload_speed.saturating_add(snapshot.upload_speed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(limit) = self.runtime().max_overall_download_limit {
|
||||||
|
stat.download_speed = stat.download_speed.min(limit);
|
||||||
|
}
|
||||||
|
if let Some(limit) = self.runtime().max_overall_upload_limit {
|
||||||
|
stat.upload_speed = stat.upload_speed.min(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
stat
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of registered downloads.
|
||||||
|
#[must_use]
|
||||||
|
pub fn download_count(&self) -> usize {
|
||||||
|
self.registry.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of tasks exposed by the engine, matching `download_count`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn task_count(&self) -> usize {
|
||||||
|
self.download_count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the gids of currently active downloads.
|
||||||
|
#[must_use]
|
||||||
|
pub fn active_downloads(&self) -> Vec<DownloadId> {
|
||||||
|
self.registry
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(gid, group)| (group.status() == &DownloadStatus::Active).then_some(*gid))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a progress snapshot using runtime caps, piece state, and BT-derived metrics.
|
||||||
|
pub(super) fn build_progress_snapshot(
|
||||||
|
group: &RequestGroup,
|
||||||
|
runtime: &RuntimeConfig,
|
||||||
|
active_count: usize,
|
||||||
|
) -> ProgressSnapshot {
|
||||||
|
let piece_length = effective_piece_length(group);
|
||||||
|
let total_length = infer_total_length(group, piece_length);
|
||||||
|
let completed_length = completed_length(group, piece_length, total_length);
|
||||||
|
let bt_selected_payload_length = group.bt_effective_target_length().unwrap_or(0);
|
||||||
|
let bt_remaining_payload_length = group.bt_remaining_work_length().unwrap_or(0);
|
||||||
|
let bt_true_seeding = group.bt_is_true_seeding();
|
||||||
|
let peer_metrics = group.bt_peer_runtime_stats();
|
||||||
|
let (_, queued, downloading, verified, missing, _) = group.piece_state_counts();
|
||||||
|
let (download_cap, upload_cap) = effective_speed_caps(group, runtime, active_count);
|
||||||
|
let download_speed = clamp_speed(
|
||||||
|
group
|
||||||
|
.download_speed()
|
||||||
|
.max(peer_metrics.total_download_speed),
|
||||||
|
download_cap,
|
||||||
|
);
|
||||||
|
let eta_seconds = if download_speed > 0 && total_length > completed_length {
|
||||||
|
Some((total_length - completed_length).div_ceil(download_speed))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
ProgressSnapshot {
|
||||||
|
gid: group.gid(),
|
||||||
|
status: *group.status(),
|
||||||
|
total_length,
|
||||||
|
completed_length,
|
||||||
|
upload_length: group.upload_length(),
|
||||||
|
upload_speed: clamp_speed(
|
||||||
|
group.upload_speed().max(peer_metrics.total_upload_speed),
|
||||||
|
upload_cap,
|
||||||
|
),
|
||||||
|
download_speed,
|
||||||
|
num_connections: group
|
||||||
|
.num_connections()
|
||||||
|
.max(usize_to_u32(peer_metrics.peer_count)),
|
||||||
|
eta_seconds,
|
||||||
|
seeding: bt_true_seeding,
|
||||||
|
share_ratio_milli: compute_share_ratio_milli(group, completed_length),
|
||||||
|
share_time_secs: group.bt_share_time_secs(),
|
||||||
|
seeding_time_secs: group.bt_seeding_time_secs(),
|
||||||
|
bt_selected_payload_length,
|
||||||
|
bt_remaining_payload_length,
|
||||||
|
bt_true_seeding,
|
||||||
|
bt_total_peers: usize_to_u32(peer_metrics.peer_count),
|
||||||
|
bt_seeders: usize_to_u32(peer_metrics.seeder_count),
|
||||||
|
bt_leechers: usize_to_u32(peer_metrics.leecher_count),
|
||||||
|
bt_available_pieces: usize_to_u32(group.bt_available_piece_count()),
|
||||||
|
bt_verified_pieces: usize_to_u32(verified),
|
||||||
|
bt_downloading_pieces: usize_to_u32(downloading),
|
||||||
|
bt_queued_pieces: usize_to_u32(queued),
|
||||||
|
bt_missing_pieces: usize_to_u32(missing),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counts active runtime groups, returning at least `1` for cap sharing math.
|
||||||
|
pub(super) fn active_runtime_group_count(registry: &DownloadRegistry) -> usize {
|
||||||
|
registry
|
||||||
|
.groups
|
||||||
|
.values()
|
||||||
|
.filter(|group| {
|
||||||
|
matches!(
|
||||||
|
group.status(),
|
||||||
|
DownloadStatus::Active | DownloadStatus::Waiting
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.count()
|
||||||
|
.max(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes effective download and upload caps by combining global and per-group settings.
|
||||||
|
pub(super) fn effective_speed_caps(
|
||||||
|
group: &RequestGroup,
|
||||||
|
runtime: &RuntimeConfig,
|
||||||
|
active_count: usize,
|
||||||
|
) -> (Option<u64>, Option<u64>) {
|
||||||
|
let active_count = usize_to_u64(active_count.max(1));
|
||||||
|
let overall_download_share = runtime
|
||||||
|
.max_overall_download_limit
|
||||||
|
.and_then(|limit| limit.checked_div(active_count))
|
||||||
|
.map(|limit| limit.max(1));
|
||||||
|
let overall_upload_share = runtime
|
||||||
|
.max_overall_upload_limit
|
||||||
|
.and_then(|limit| limit.checked_div(active_count))
|
||||||
|
.map(|limit| limit.max(1));
|
||||||
|
|
||||||
|
let download_cap = combine_caps(
|
||||||
|
overall_download_share,
|
||||||
|
group
|
||||||
|
.option_limit("max-download-limit")
|
||||||
|
.or(runtime.max_download_limit),
|
||||||
|
);
|
||||||
|
let upload_cap = combine_caps(
|
||||||
|
overall_upload_share,
|
||||||
|
group
|
||||||
|
.option_limit("max-upload-limit")
|
||||||
|
.or(runtime.max_upload_limit),
|
||||||
|
);
|
||||||
|
(download_cap, upload_cap)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Intersects two optional bandwidth caps.
|
||||||
|
fn combine_caps(left: Option<u64>, right: Option<u64>) -> Option<u64> {
|
||||||
|
match (left, right) {
|
||||||
|
(Some(left), Some(right)) => Some(left.min(right)),
|
||||||
|
(Some(left), None) => Some(left),
|
||||||
|
(None, Some(right)) => Some(right),
|
||||||
|
(None, None) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies an optional cap to a runtime speed sample.
|
||||||
|
pub(super) fn clamp_speed(value: u64, cap: Option<u64>) -> u64 {
|
||||||
|
cap.map_or(value, |limit| value.min(limit))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the piece length used for segment and progress math, clamped to at least `1`.
|
||||||
|
pub(super) fn effective_piece_length(group: &RequestGroup) -> u64 {
|
||||||
|
group.piece_length().max(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Infers a request's total length from explicit metadata or known piece state.
|
||||||
|
pub(super) fn infer_total_length(group: &RequestGroup, piece_length: u64) -> u64 {
|
||||||
|
if group.total_length() > 0 {
|
||||||
|
return group.total_length();
|
||||||
|
}
|
||||||
|
group
|
||||||
|
.piece_map()
|
||||||
|
.iter()
|
||||||
|
.map(|(piece, _)| {
|
||||||
|
u64::from(piece.0)
|
||||||
|
.saturating_add(1)
|
||||||
|
.saturating_mul(piece_length)
|
||||||
|
})
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes the most trustworthy completed length for progress reporting.
|
||||||
|
fn completed_length(group: &RequestGroup, piece_length: u64, total_length: u64) -> u64 {
|
||||||
|
let reported = if total_length > 0 {
|
||||||
|
group.completed_length().min(total_length)
|
||||||
|
} else {
|
||||||
|
group.completed_length()
|
||||||
|
};
|
||||||
|
let from_verified = group
|
||||||
|
.piece_map()
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, state)| **state == crate::piece::PieceState::Verified)
|
||||||
|
.map(|(piece, _)| {
|
||||||
|
let start = u64::from(piece.0).saturating_mul(piece_length);
|
||||||
|
if total_length == 0 {
|
||||||
|
piece_length
|
||||||
|
} else {
|
||||||
|
total_length.saturating_sub(start).min(piece_length)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sum::<u64>();
|
||||||
|
let merged = reported.max(from_verified);
|
||||||
|
match group.status() {
|
||||||
|
DownloadStatus::Complete if total_length > 0 && completion_is_trustworthy(group) => {
|
||||||
|
total_length
|
||||||
|
}
|
||||||
|
_ => merged.min(total_length.max(merged)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether a completed group can safely report its entire total length as finished.
|
||||||
|
fn completion_is_trustworthy(group: &RequestGroup) -> bool {
|
||||||
|
if let Some(bt) = group.bt() {
|
||||||
|
if bt.metadata_only {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let selected_total = bt_selected_total_length(bt);
|
||||||
|
if selected_total > 0 && group.completed_length() < selected_total {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sums the selected BT payload length across all torrent files.
|
||||||
|
fn bt_selected_total_length(bt: &BtRuntimeState) -> u64 {
|
||||||
|
bt.files
|
||||||
|
.iter()
|
||||||
|
.filter(|file| file.selected)
|
||||||
|
.fold(0_u64, |acc, file| acc.saturating_add(file.length))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes the BT share ratio in milli-units from live upload and effective payload size.
|
||||||
|
fn compute_share_ratio_milli(group: &RequestGroup, completed_length: u64) -> Option<u64> {
|
||||||
|
if let Some(ratio) = group.bt_share_ratio_milli() {
|
||||||
|
return Some(ratio);
|
||||||
|
}
|
||||||
|
let bt = group.bt()?;
|
||||||
|
let denominator = group
|
||||||
|
.bt_share_ratio_base_length()
|
||||||
|
.unwrap_or_else(|| bt_selected_total_length(bt).max(completed_length));
|
||||||
|
if denominator == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(
|
||||||
|
group
|
||||||
|
.upload_length()
|
||||||
|
.saturating_mul(1000)
|
||||||
|
.saturating_div(denominator),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
use super::{
|
||||||
|
CoreError, DownloadEngine, DownloadId, DownloadStatus, QueuePositionMode, Result, RuntimeEvent,
|
||||||
|
RuntimeEventKind, usize_to_i64,
|
||||||
|
};
|
||||||
|
use std::cmp::Ordering;
|
||||||
|
|
||||||
|
impl DownloadEngine {
|
||||||
|
/// Pauses an active or waiting download and keeps it in the reserved queue.
|
||||||
|
pub fn pause(&mut self, gid: DownloadId) -> Result<()> {
|
||||||
|
let status = self
|
||||||
|
.registry
|
||||||
|
.get(gid)
|
||||||
|
.map(|group| *group.status())
|
||||||
|
.ok_or(CoreError::UnknownDownloadId(gid))?;
|
||||||
|
let was_active = matches!(status, DownloadStatus::Active);
|
||||||
|
if !matches!(status, DownloadStatus::Active | DownloadStatus::Waiting) {
|
||||||
|
return Err(CoreError::InvalidState("download cannot be paused now"));
|
||||||
|
}
|
||||||
|
let group = self.group_mut(gid)?;
|
||||||
|
group.set_status(DownloadStatus::Paused);
|
||||||
|
if was_active {
|
||||||
|
self.enqueue_reserved_front(gid);
|
||||||
|
} else if !self.is_in_reserved_queue(gid) {
|
||||||
|
self.enqueue_reserved_back(gid);
|
||||||
|
}
|
||||||
|
self.events
|
||||||
|
.emit(RuntimeEvent::new(RuntimeEventKind::DownloadPaused).with_gid(gid));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resumes a paused download by moving it back into the waiting state.
|
||||||
|
pub fn resume(&mut self, gid: DownloadId) -> Result<()> {
|
||||||
|
let status = self
|
||||||
|
.registry
|
||||||
|
.get(gid)
|
||||||
|
.map(|group| *group.status())
|
||||||
|
.ok_or(CoreError::UnknownDownloadId(gid))?;
|
||||||
|
if !matches!(status, DownloadStatus::Paused) {
|
||||||
|
return Err(CoreError::InvalidState("download cannot be unpaused now"));
|
||||||
|
}
|
||||||
|
let group = self.group_mut(gid)?;
|
||||||
|
group.set_status(DownloadStatus::Waiting);
|
||||||
|
if !self.is_in_reserved_queue(gid) {
|
||||||
|
self.enqueue_reserved_back(gid);
|
||||||
|
}
|
||||||
|
self.events
|
||||||
|
.emit(RuntimeEvent::new(RuntimeEventKind::DownloadResumed).with_gid(gid));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks a download as removed and assigns it a stopped sequence.
|
||||||
|
pub fn remove(&mut self, gid: DownloadId) -> Result<()> {
|
||||||
|
self.remove_from_reserved_queue(gid);
|
||||||
|
self.transition_to_stopped_status(gid, DownloadStatus::Removed)?;
|
||||||
|
self.events
|
||||||
|
.emit(RuntimeEvent::new(RuntimeEventKind::DownloadRemoved).with_gid(gid));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Permanently removes a stopped download result from the registry.
|
||||||
|
pub fn remove_download_result(&mut self, gid: DownloadId) -> Result<()> {
|
||||||
|
let Some(group) = self.registry.get(gid) else {
|
||||||
|
return Err(CoreError::UnknownDownloadId(gid));
|
||||||
|
};
|
||||||
|
if !matches!(
|
||||||
|
group.status(),
|
||||||
|
DownloadStatus::Complete | DownloadStatus::Removed | DownloadStatus::Error
|
||||||
|
) {
|
||||||
|
return Err(CoreError::InvalidState(
|
||||||
|
"download result is not available for active or waiting downloads",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.registry.remove(gid);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repositions a waiting download within the reserved queue.
|
||||||
|
pub fn change_position(
|
||||||
|
&mut self,
|
||||||
|
gid: DownloadId,
|
||||||
|
offset: i64,
|
||||||
|
mode: QueuePositionMode,
|
||||||
|
) -> Result<usize> {
|
||||||
|
let Some(current_index) = self
|
||||||
|
.reserved_queue
|
||||||
|
.iter()
|
||||||
|
.position(|candidate| *candidate == gid)
|
||||||
|
else {
|
||||||
|
return Err(CoreError::InvalidState(
|
||||||
|
"download is not in the waiting queue",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let size = usize_to_i64(self.reserved_queue.len());
|
||||||
|
let current = usize_to_i64(current_index);
|
||||||
|
let mut dest = match mode {
|
||||||
|
QueuePositionMode::Set => offset,
|
||||||
|
QueuePositionMode::Cur => current.saturating_add(offset),
|
||||||
|
QueuePositionMode::End => size.saturating_sub(1).saturating_add(offset),
|
||||||
|
};
|
||||||
|
dest = dest.clamp(0, size.saturating_sub(1));
|
||||||
|
let dest_index = usize::try_from(dest).unwrap_or_default();
|
||||||
|
match current_index.cmp(&dest_index) {
|
||||||
|
Ordering::Less => {
|
||||||
|
let Some(window) = self.reserved_queue.get_mut(current_index..=dest_index) else {
|
||||||
|
return Err(CoreError::InvalidState(
|
||||||
|
"download is not in the waiting queue",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
window.rotate_left(1);
|
||||||
|
}
|
||||||
|
Ordering::Greater => {
|
||||||
|
let Some(window) = self.reserved_queue.get_mut(dest_index..=current_index) else {
|
||||||
|
return Err(CoreError::InvalidState(
|
||||||
|
"download is not in the waiting queue",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
window.rotate_right(1);
|
||||||
|
}
|
||||||
|
Ordering::Equal => {}
|
||||||
|
}
|
||||||
|
Ok(dest_index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes every stopped download result and returns the number removed.
|
||||||
|
pub fn purge_download_results(&mut self) -> usize {
|
||||||
|
let stopped = self
|
||||||
|
.registry
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(gid, group)| {
|
||||||
|
matches!(
|
||||||
|
group.status(),
|
||||||
|
DownloadStatus::Complete | DownloadStatus::Removed | DownloadStatus::Error
|
||||||
|
)
|
||||||
|
.then_some(*gid)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let removed = stopped.len();
|
||||||
|
for gid in stopped {
|
||||||
|
let _ = self.registry.remove(gid);
|
||||||
|
}
|
||||||
|
removed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks a download as complete and emits the completion event.
|
||||||
|
pub fn complete(&mut self, gid: DownloadId) -> Result<()> {
|
||||||
|
self.remove_from_reserved_queue(gid);
|
||||||
|
self.transition_to_stopped_status(gid, DownloadStatus::Complete)?;
|
||||||
|
self.events
|
||||||
|
.emit(RuntimeEvent::new(RuntimeEventKind::DownloadCompleted).with_gid(gid));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks a download as errored and emits the failure event.
|
||||||
|
pub fn fail(&mut self, gid: DownloadId) -> Result<()> {
|
||||||
|
self.remove_from_reserved_queue(gid);
|
||||||
|
self.transition_to_stopped_status(gid, DownloadStatus::Error)?;
|
||||||
|
self.events
|
||||||
|
.emit(RuntimeEvent::new(RuntimeEventKind::DownloadErrored).with_gid(gid));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assigns a stopped sequence and terminal status to a request group.
|
||||||
|
fn transition_to_stopped_status(
|
||||||
|
&mut self,
|
||||||
|
gid: DownloadId,
|
||||||
|
status: DownloadStatus,
|
||||||
|
) -> Result<()> {
|
||||||
|
let sequence = self.next_stopped_sequence;
|
||||||
|
self.next_stopped_sequence = self.next_stopped_sequence.saturating_add(1);
|
||||||
|
let group = self.group_mut(gid)?;
|
||||||
|
group.set_stopped_sequence(Some(sequence));
|
||||||
|
group.set_status(status);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether `gid` currently appears in the reserved queue.
|
||||||
|
pub(super) fn is_in_reserved_queue(&self, gid: DownloadId) -> bool {
|
||||||
|
self.reserved_queue.contains(&gid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes `gid` from the reserved queue when present.
|
||||||
|
pub(super) fn remove_from_reserved_queue(&mut self, gid: DownloadId) {
|
||||||
|
if let Some(index) = self
|
||||||
|
.reserved_queue
|
||||||
|
.iter()
|
||||||
|
.position(|candidate| *candidate == gid)
|
||||||
|
{
|
||||||
|
self.reserved_queue.remove(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Places `gid` at the back of the reserved queue, removing older duplicates first.
|
||||||
|
pub(super) fn enqueue_reserved_back(&mut self, gid: DownloadId) {
|
||||||
|
self.remove_from_reserved_queue(gid);
|
||||||
|
self.reserved_queue.push(gid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Places `gid` at the front of the reserved queue, removing older duplicates first.
|
||||||
|
pub(super) fn enqueue_reserved_front(&mut self, gid: DownloadId) {
|
||||||
|
self.remove_from_reserved_queue(gid);
|
||||||
|
self.reserved_queue.insert(0, gid);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
CoreError, DownloadEngine, DownloadId, DownloadStatus, RequestGroup, Result, RetryAttempt,
|
||||||
|
RetryHistoryEntry, RuntimeConfig, RuntimeEvent, RuntimeEventKind, ScheduleDecision,
|
||||||
|
SegmentAssignment, SegmentState, effective_piece_length, infer_total_length, usize_to_u64,
|
||||||
|
};
|
||||||
|
|
||||||
|
impl DownloadEngine {
|
||||||
|
/// Advances the scheduler clock and emits a scheduler tick event.
|
||||||
|
pub fn scheduler_tick(&mut self) -> Result<()> {
|
||||||
|
let _ = self.scheduler.tick();
|
||||||
|
self.events
|
||||||
|
.emit(RuntimeEvent::new(RuntimeEventKind::SchedulerTick));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prepares a single HTTP download by applying the scheduler decision for its current state.
|
||||||
|
pub fn prepare_http_download(&mut self, gid: DownloadId) -> Result<RequestGroup> {
|
||||||
|
let decision = {
|
||||||
|
let group = self
|
||||||
|
.registry
|
||||||
|
.get(gid)
|
||||||
|
.ok_or(CoreError::UnknownDownloadId(gid))?;
|
||||||
|
match group.status() {
|
||||||
|
DownloadStatus::Waiting => ScheduleDecision::Queue(gid),
|
||||||
|
DownloadStatus::Active => ScheduleDecision::RunNow(gid),
|
||||||
|
DownloadStatus::Error => ScheduleDecision::RetryLater(gid),
|
||||||
|
DownloadStatus::Paused | DownloadStatus::Complete | DownloadStatus::Removed => {
|
||||||
|
ScheduleDecision::Noop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
self.apply_schedule_decision(gid, &decision);
|
||||||
|
self.registry
|
||||||
|
.get(gid)
|
||||||
|
.cloned()
|
||||||
|
.ok_or(CoreError::UnknownDownloadId(gid))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs one scheduler pass and returns the first actionable decision.
|
||||||
|
#[must_use]
|
||||||
|
pub fn schedule_once(&mut self) -> ScheduleDecision {
|
||||||
|
self.scheduler.record_schedule_run();
|
||||||
|
let _ = self.scheduler.tick();
|
||||||
|
let mut gids = self
|
||||||
|
.registry
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(gid, group)| (group.status() == &DownloadStatus::Active).then_some(*gid))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
gids.sort_by_key(|gid| gid.as_u64());
|
||||||
|
gids.extend(
|
||||||
|
self.reserved_queue
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|gid| self.registry.get(*gid).is_some()),
|
||||||
|
);
|
||||||
|
let mut retry_gids = self
|
||||||
|
.registry
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(gid, group)| (group.status() == &DownloadStatus::Error).then_some(*gid))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
retry_gids.sort_by_key(|gid| gid.as_u64());
|
||||||
|
gids.extend(retry_gids);
|
||||||
|
|
||||||
|
for gid in gids {
|
||||||
|
let Some(group) = self.registry.get(gid) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let decision = self.scheduler.decide(group);
|
||||||
|
match decision {
|
||||||
|
ScheduleDecision::RunNow(_)
|
||||||
|
| ScheduleDecision::Queue(_)
|
||||||
|
| ScheduleDecision::RetryLater(_) => {
|
||||||
|
self.apply_schedule_decision(gid, &decision);
|
||||||
|
self.events
|
||||||
|
.emit(RuntimeEvent::new(RuntimeEventKind::SchedulerTick).with_gid(gid));
|
||||||
|
return decision;
|
||||||
|
}
|
||||||
|
ScheduleDecision::Pause(_) | ScheduleDecision::Remove(_) => {
|
||||||
|
self.scheduler.record_decision(&decision);
|
||||||
|
return decision;
|
||||||
|
}
|
||||||
|
ScheduleDecision::Noop => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.scheduler.record_decision(&ScheduleDecision::Noop);
|
||||||
|
ScheduleDecision::Noop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts retry attempts into scheduler-facing retry-history entries.
|
||||||
|
pub(super) fn retry_history_from_group(attempts: &[RetryAttempt]) -> Vec<RetryHistoryEntry> {
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_or(0, |duration| duration.as_secs());
|
||||||
|
attempts
|
||||||
|
.iter()
|
||||||
|
.map(|attempt| RetryHistoryEntry {
|
||||||
|
at_unix_secs: now,
|
||||||
|
reason: attempt.error.clone().unwrap_or_else(|| "retry".to_string()),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds runtime segment assignments for the scheduler-selected active segment count.
|
||||||
|
pub(super) fn build_segment_assignments(
|
||||||
|
group: &RequestGroup,
|
||||||
|
runtime: &RuntimeConfig,
|
||||||
|
desired_segments: usize,
|
||||||
|
) -> Vec<SegmentAssignment> {
|
||||||
|
if desired_segments == 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_length = infer_total_length(group, effective_piece_length(group));
|
||||||
|
let start_offset = group
|
||||||
|
.resume_state()
|
||||||
|
.map_or(0, |state| state.resume_offset)
|
||||||
|
.max(group.completed_length())
|
||||||
|
.min(total_length);
|
||||||
|
let remaining = total_length.saturating_sub(start_offset);
|
||||||
|
if remaining == 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let piece_length = effective_piece_length(group);
|
||||||
|
let alignment = piece_length.max(runtime.min_split_size.max(1));
|
||||||
|
let segment_count = desired_segments
|
||||||
|
.min(
|
||||||
|
usize::try_from(remaining.div_ceil(runtime.min_split_size.max(1)))
|
||||||
|
.unwrap_or(usize::MAX),
|
||||||
|
)
|
||||||
|
.max(1);
|
||||||
|
let target_span = remaining.div_ceil(usize_to_u64(segment_count));
|
||||||
|
|
||||||
|
let mut cursor = start_offset;
|
||||||
|
let mut assignments = Vec::with_capacity(segment_count);
|
||||||
|
for slot in 0..segment_count {
|
||||||
|
if cursor >= total_length {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let end = if slot + 1 == segment_count {
|
||||||
|
total_length
|
||||||
|
} else {
|
||||||
|
let raw_end = cursor.saturating_add(target_span).min(total_length);
|
||||||
|
let aligned_end = raw_end
|
||||||
|
.div_ceil(alignment)
|
||||||
|
.saturating_mul(alignment)
|
||||||
|
.min(total_length);
|
||||||
|
aligned_end.max(cursor.saturating_add(1))
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut assignment =
|
||||||
|
SegmentAssignment::new(slot, crate::piece::PieceRange::new(cursor, end));
|
||||||
|
assignment.state = SegmentState::Active;
|
||||||
|
assignments.push(assignment);
|
||||||
|
cursor = end;
|
||||||
|
}
|
||||||
|
|
||||||
|
assignments
|
||||||
|
}
|
||||||
@@ -0,0 +1,621 @@
|
|||||||
|
use super::{
|
||||||
|
BtFileInfo, BtRuntimeState, ControlFileVersion, ControlMetadata, DownloadEngine, DownloadId,
|
||||||
|
DownloadRegistry, DownloadStatus, OptionKey, OptionValue, Path, PathBuf, RequestContext,
|
||||||
|
RequestGroup, SessionFile, SessionFileEntry, StoredDownloadFile, StoredPieceIndex,
|
||||||
|
StoredPieceState, infer_total_length, usize_to_u32,
|
||||||
|
};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use crate::{ResumeState, RetryAttempt};
|
||||||
|
|
||||||
|
impl DownloadEngine {
|
||||||
|
/// Builds a serializable session file from every persistable group in the registry.
|
||||||
|
pub(super) fn build_session_file(&self, session_path: &Path) -> SessionFile {
|
||||||
|
let entries = self
|
||||||
|
.registry
|
||||||
|
.groups
|
||||||
|
.values()
|
||||||
|
.filter(|group| should_persist_group(*group.status()))
|
||||||
|
.map(|group| build_session_entry(group, self.session.global_options(), session_path))
|
||||||
|
.collect();
|
||||||
|
SessionFile { entries }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reconstructs the in-memory registry and waiting queue from a persisted session file.
|
||||||
|
pub(super) fn rebuild_registry_from_session_file(
|
||||||
|
&mut self,
|
||||||
|
session_path: &Path,
|
||||||
|
session_file: SessionFile,
|
||||||
|
) {
|
||||||
|
let mut registry = DownloadRegistry::new();
|
||||||
|
let mut reserved_queue = Vec::new();
|
||||||
|
let mut max_gid = 0_u64;
|
||||||
|
for entry in session_file.entries {
|
||||||
|
let mut context = RequestContext::new(entry.uri.clone());
|
||||||
|
if entry.uris.is_empty() {
|
||||||
|
context.replace_uris(vec![entry.uri.clone()]);
|
||||||
|
} else {
|
||||||
|
context.replace_uris(entry.uris.clone());
|
||||||
|
}
|
||||||
|
let mut group = if let Some(gid) = DownloadId::parse_hex(&entry.gid) {
|
||||||
|
max_gid = max_gid.max(gid.as_u64());
|
||||||
|
RequestGroup::with_context(gid, context)
|
||||||
|
} else {
|
||||||
|
let gid = registry.allocate_gid();
|
||||||
|
max_gid = max_gid.max(gid.as_u64());
|
||||||
|
RequestGroup::with_context(gid, context)
|
||||||
|
};
|
||||||
|
restore_group_metadata(&mut group, entry.metadata);
|
||||||
|
let control_path = entry
|
||||||
|
.metadata_path
|
||||||
|
.unwrap_or_else(|| control_path_for_session_entry(session_path, group.gid()));
|
||||||
|
if let Ok(control) = aria2_rust_pro_storage::read_aria2_control_file(&control_path) {
|
||||||
|
restore_control_metadata(&mut group, control);
|
||||||
|
}
|
||||||
|
if matches!(
|
||||||
|
group.status(),
|
||||||
|
DownloadStatus::Waiting | DownloadStatus::Paused
|
||||||
|
) {
|
||||||
|
reserved_queue.push(group.gid());
|
||||||
|
}
|
||||||
|
registry.insert(group);
|
||||||
|
}
|
||||||
|
registry.next_gid = max_gid.saturating_add(1).max(1);
|
||||||
|
self.registry = registry;
|
||||||
|
self.reserved_queue = reserved_queue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether a group status should be persisted into session artifacts.
|
||||||
|
pub(super) fn should_persist_group(status: DownloadStatus) -> bool {
|
||||||
|
!matches!(status, DownloadStatus::Complete | DownloadStatus::Removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds one persisted session entry from a request group.
|
||||||
|
fn build_session_entry(
|
||||||
|
group: &RequestGroup,
|
||||||
|
global_options: &crate::session::GlobalOptions,
|
||||||
|
session_path: &Path,
|
||||||
|
) -> SessionFileEntry {
|
||||||
|
let target_path = resolve_target_path(group, global_options);
|
||||||
|
let metadata = Some(build_group_metadata(group));
|
||||||
|
SessionFileEntry {
|
||||||
|
gid: group.gid().to_string(),
|
||||||
|
uri: group.uri().to_owned(),
|
||||||
|
uris: group.uris().to_vec(),
|
||||||
|
target_path,
|
||||||
|
metadata_path: Some(control_path_for_session_entry(session_path, group.gid())),
|
||||||
|
metadata,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serializes selected request-group runtime metadata into session-file string fields.
|
||||||
|
fn build_group_metadata(group: &RequestGroup) -> BTreeMap<String, String> {
|
||||||
|
let mut metadata = BTreeMap::from([
|
||||||
|
(
|
||||||
|
"status".to_owned(),
|
||||||
|
group.status().as_rpc_status().to_owned(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"num_connections".to_owned(),
|
||||||
|
group.num_connections().to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"download_speed".to_owned(),
|
||||||
|
group.download_speed().to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"upload_length".to_owned(),
|
||||||
|
group.upload_length().to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"completed_length".to_owned(),
|
||||||
|
group.completed_length().to_string(),
|
||||||
|
),
|
||||||
|
("retry_count".to_owned(), group.retry_count().to_string()),
|
||||||
|
]);
|
||||||
|
if let Some(resume_state) = group.resume_state() {
|
||||||
|
metadata.insert(
|
||||||
|
"resume_state".to_owned(),
|
||||||
|
encode_resume_state_metadata(resume_state),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !group.retry_attempts().is_empty() {
|
||||||
|
metadata.insert(
|
||||||
|
"retry_attempts".to_owned(),
|
||||||
|
encode_retry_attempts_metadata(group.retry_attempts()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (key, value) in group.options().entries() {
|
||||||
|
metadata.insert(format!("opt.{}", key.as_str()), option_value_text(value));
|
||||||
|
}
|
||||||
|
if let Some(bt) = group.bt() {
|
||||||
|
metadata.insert("bt.info_hash".to_owned(), bt.info_hash.clone());
|
||||||
|
metadata.insert("bt.metadata_only".to_owned(), bt.metadata_only.to_string());
|
||||||
|
if let Some(name) = &bt.name {
|
||||||
|
metadata.insert("bt.name".to_owned(), escape_metadata_field(name));
|
||||||
|
}
|
||||||
|
if let Some(magnet_uri) = &bt.magnet_uri {
|
||||||
|
metadata.insert(
|
||||||
|
"bt.magnet_uri".to_owned(),
|
||||||
|
escape_metadata_field(magnet_uri),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(creation_date) = &bt.creation_date {
|
||||||
|
metadata.insert(
|
||||||
|
"bt.creation_date".to_owned(),
|
||||||
|
escape_metadata_field(creation_date),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(comment) = &bt.comment {
|
||||||
|
metadata.insert("bt.comment".to_owned(), escape_metadata_field(comment));
|
||||||
|
}
|
||||||
|
metadata.insert("bt.files_count".to_owned(), bt.files.len().to_string());
|
||||||
|
for (index, file) in bt.files.iter().enumerate() {
|
||||||
|
metadata.insert(
|
||||||
|
format!("bt.file.{index}.path"),
|
||||||
|
escape_metadata_field(&file.path),
|
||||||
|
);
|
||||||
|
metadata.insert(format!("bt.file.{index}.length"), file.length.to_string());
|
||||||
|
metadata.insert(
|
||||||
|
format!("bt.file.{index}.selected"),
|
||||||
|
file.selected.to_string(),
|
||||||
|
);
|
||||||
|
if let Some(piece_offset) = file.piece_offset {
|
||||||
|
metadata.insert(
|
||||||
|
format!("bt.file.{index}.piece_offset"),
|
||||||
|
piece_offset.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the output path that should be associated with a request group.
|
||||||
|
pub(super) fn resolve_target_path(
|
||||||
|
group: &RequestGroup,
|
||||||
|
global_options: &crate::session::GlobalOptions,
|
||||||
|
) -> PathBuf {
|
||||||
|
let dir = group
|
||||||
|
.options()
|
||||||
|
.get(&OptionKey::from("dir"))
|
||||||
|
.or_else(|| global_options.get(&OptionKey::from("dir")))
|
||||||
|
.and_then(OptionValue::as_text)
|
||||||
|
.map(PathBuf::from);
|
||||||
|
let file_name = group
|
||||||
|
.options()
|
||||||
|
.get(&OptionKey::from("out"))
|
||||||
|
.and_then(OptionValue::as_text)
|
||||||
|
.map(str::to_owned)
|
||||||
|
.or_else(|| uri_file_name(group.uri()))
|
||||||
|
.unwrap_or_else(|| group.gid().to_string());
|
||||||
|
match dir {
|
||||||
|
Some(dir) => dir.join(file_name),
|
||||||
|
None => PathBuf::from(file_name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts a best-effort file name from a URI path component.
|
||||||
|
fn uri_file_name(uri: &str) -> Option<String> {
|
||||||
|
let trimmed = uri
|
||||||
|
.split(['?', '#'])
|
||||||
|
.next()
|
||||||
|
.unwrap_or(uri)
|
||||||
|
.trim_end_matches('/');
|
||||||
|
let candidate = trimmed.rsplit('/').next()?;
|
||||||
|
if candidate.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(candidate.to_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the per-download control-file path relative to a session file path.
|
||||||
|
pub(super) fn control_path_for_session_entry(session_path: &Path, gid: DownloadId) -> PathBuf {
|
||||||
|
let parent = session_path
|
||||||
|
.parent()
|
||||||
|
.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
|
||||||
|
parent.join("control").join(format!("{gid}.aria2"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds persisted control-file metadata for a request group.
|
||||||
|
pub(super) fn build_control_metadata(
|
||||||
|
group: &RequestGroup,
|
||||||
|
target_path: &Path,
|
||||||
|
piece_length: u64,
|
||||||
|
) -> ControlMetadata {
|
||||||
|
let resolved_piece_length = group.piece_length().max(piece_length);
|
||||||
|
let inferred_total_length = infer_total_length(group, resolved_piece_length);
|
||||||
|
ControlMetadata {
|
||||||
|
version: ControlFileVersion::CURRENT,
|
||||||
|
files: vec![StoredDownloadFile {
|
||||||
|
path: target_path.to_path_buf(),
|
||||||
|
length: inferred_total_length,
|
||||||
|
piece_length: resolved_piece_length,
|
||||||
|
}],
|
||||||
|
checksums: Vec::new(),
|
||||||
|
completed_length: group.completed_length(),
|
||||||
|
retry_count: group.retry_count(),
|
||||||
|
last_error: group
|
||||||
|
.retry_attempts()
|
||||||
|
.last()
|
||||||
|
.and_then(|attempt| attempt.error.clone()),
|
||||||
|
last_error_at_unix_ms: None,
|
||||||
|
last_retry_at_unix_ms: None,
|
||||||
|
next_retry_at_unix_ms: None,
|
||||||
|
consecutive_failure_count: Some(usize_to_u32(group.retry_attempts().len())),
|
||||||
|
active_segment_count: Some(group.num_connections()),
|
||||||
|
resume_verified_at_unix_ms: None,
|
||||||
|
resume_generation: group
|
||||||
|
.resume_state()
|
||||||
|
.and_then(|resume_state| resume_state.persisted.then_some(1)),
|
||||||
|
piece_states: group
|
||||||
|
.piece_map()
|
||||||
|
.iter()
|
||||||
|
.map(|(piece, state)| {
|
||||||
|
(
|
||||||
|
StoredPieceIndex(piece.0),
|
||||||
|
map_piece_state_to_storage(*state),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps in-memory piece states into storage-layer piece states.
|
||||||
|
fn map_piece_state_to_storage(state: crate::piece::PieceState) -> StoredPieceState {
|
||||||
|
match state {
|
||||||
|
crate::piece::PieceState::Verified => StoredPieceState::Verified,
|
||||||
|
crate::piece::PieceState::Queued | crate::piece::PieceState::Downloading => {
|
||||||
|
StoredPieceState::InFlight
|
||||||
|
}
|
||||||
|
crate::piece::PieceState::Pending
|
||||||
|
| crate::piece::PieceState::Missing
|
||||||
|
| crate::piece::PieceState::Skipped => StoredPieceState::Pending,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps storage-layer piece states back into in-memory piece states.
|
||||||
|
fn map_piece_state_from_storage(state: StoredPieceState) -> crate::piece::PieceState {
|
||||||
|
match state {
|
||||||
|
StoredPieceState::Pending => crate::piece::PieceState::Pending,
|
||||||
|
StoredPieceState::InFlight => crate::piece::PieceState::Downloading,
|
||||||
|
StoredPieceState::Verified => crate::piece::PieceState::Verified,
|
||||||
|
StoredPieceState::Failed => crate::piece::PieceState::Missing,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serializes an option value into the session metadata text format.
|
||||||
|
fn option_value_text(value: &OptionValue) -> String {
|
||||||
|
match value {
|
||||||
|
OptionValue::Bool(value) => value.to_string(),
|
||||||
|
OptionValue::Int(value) => value.to_string(),
|
||||||
|
OptionValue::UInt(value) => value.to_string(),
|
||||||
|
OptionValue::Text(value) => value.clone(),
|
||||||
|
OptionValue::List(value) => value.join(","),
|
||||||
|
OptionValue::Map(value) => value
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| format!("{key}={value}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(","),
|
||||||
|
OptionValue::Empty => String::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restores request-group runtime metadata from persisted session metadata fields.
|
||||||
|
fn restore_group_metadata(group: &mut RequestGroup, metadata: Option<BTreeMap<String, String>>) {
|
||||||
|
let Some(metadata) = metadata else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut bt = BtRuntimeState::default();
|
||||||
|
let mut bt_seen = false;
|
||||||
|
let mut bt_files: BTreeMap<usize, BtFileInfo> = BTreeMap::new();
|
||||||
|
for (key, value) in metadata {
|
||||||
|
if restore_group_metadata_field(group, &key, &value) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if restore_bt_metadata_field(&mut bt, &mut bt_files, &key, &value) {
|
||||||
|
bt_seen = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bt_seen {
|
||||||
|
bt.files = bt_files.into_values().collect();
|
||||||
|
group.set_bt(bt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restores one non-BitTorrent metadata field onto a request group.
|
||||||
|
fn restore_group_metadata_field(group: &mut RequestGroup, key: &str, value: &str) -> bool {
|
||||||
|
match key {
|
||||||
|
"status" => {
|
||||||
|
if let Some(status) = parse_status(value) {
|
||||||
|
group.set_status(status);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"num_connections" => {
|
||||||
|
if let Ok(parsed) = value.parse::<u32>() {
|
||||||
|
group.set_num_connections(parsed);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"download_speed" => {
|
||||||
|
if let Ok(parsed) = value.parse::<u64>() {
|
||||||
|
group.set_download_speed(parsed);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"upload_length" => {
|
||||||
|
if let Ok(parsed) = value.parse::<u64>() {
|
||||||
|
group.set_upload_length(parsed);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"completed_length" => {
|
||||||
|
if let Ok(parsed) = value.parse::<u64>() {
|
||||||
|
group.set_completed_length(parsed);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"retry_count" => {
|
||||||
|
if let Ok(parsed) = value.parse::<u32>() {
|
||||||
|
group.set_retry_count(parsed);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"resume_state" => {
|
||||||
|
if let Some(parsed) = decode_resume_state_metadata(value) {
|
||||||
|
group.set_resume_state(parsed);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"retry_attempts" => {
|
||||||
|
group.set_retry_attempts(decode_retry_attempts_metadata(value));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => key.strip_prefix("opt.").is_some_and(|option_key| {
|
||||||
|
group.set_option(option_key.to_owned(), value.to_owned());
|
||||||
|
true
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restores one `BitTorrent` metadata field.
|
||||||
|
fn restore_bt_metadata_field(
|
||||||
|
bt: &mut BtRuntimeState,
|
||||||
|
bt_files: &mut BTreeMap<usize, BtFileInfo>,
|
||||||
|
key: &str,
|
||||||
|
value: &str,
|
||||||
|
) -> bool {
|
||||||
|
match key {
|
||||||
|
"bt.info_hash" => {
|
||||||
|
value.clone_into(&mut bt.info_hash);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"bt.metadata_only" => {
|
||||||
|
bt.metadata_only = value.parse::<bool>().unwrap_or(false);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"bt.name" => {
|
||||||
|
bt.name = Some(unescape_metadata_field(value));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"bt.magnet_uri" => {
|
||||||
|
bt.magnet_uri = Some(unescape_metadata_field(value));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"bt.creation_date" => {
|
||||||
|
bt.creation_date = Some(unescape_metadata_field(value));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"bt.comment" => {
|
||||||
|
bt.comment = Some(unescape_metadata_field(value));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => key
|
||||||
|
.strip_prefix("bt.file.")
|
||||||
|
.is_some_and(|rest| restore_bt_file_metadata_field(bt_files, rest, value)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restores one `BitTorrent` file metadata field.
|
||||||
|
fn restore_bt_file_metadata_field(
|
||||||
|
bt_files: &mut BTreeMap<usize, BtFileInfo>,
|
||||||
|
rest: &str,
|
||||||
|
value: &str,
|
||||||
|
) -> bool {
|
||||||
|
let mut parts = rest.split('.');
|
||||||
|
let Some(index_raw) = parts.next() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(field) = parts.next() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if parts.next().is_some() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Ok(index) = index_raw.parse::<usize>() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let file = bt_files.entry(index).or_default();
|
||||||
|
match field {
|
||||||
|
"path" => file.path = unescape_metadata_field(value),
|
||||||
|
"length" => {
|
||||||
|
if let Ok(parsed) = value.parse::<u64>() {
|
||||||
|
file.length = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"selected" => {
|
||||||
|
file.selected = value.parse::<bool>().unwrap_or(false);
|
||||||
|
}
|
||||||
|
"piece_offset" => {
|
||||||
|
if let Ok(parsed) = value.parse::<u64>() {
|
||||||
|
file.piece_offset = Some(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restores request-group progress and piece state from persisted control metadata.
|
||||||
|
fn restore_control_metadata(group: &mut RequestGroup, control: ControlMetadata) {
|
||||||
|
if let Some(file) = control.files.first() {
|
||||||
|
group.set_total_length(file.length);
|
||||||
|
group.set_piece_length(file.piece_length);
|
||||||
|
}
|
||||||
|
group.set_completed_length(control.completed_length);
|
||||||
|
group.set_retry_count(control.retry_count);
|
||||||
|
if control.retry_count > 0 && group.retry_attempts().is_empty() {
|
||||||
|
let mut attempt = RetryAttempt::new(control.retry_count, control.completed_length);
|
||||||
|
attempt.length = Some(control.completed_length);
|
||||||
|
attempt.error.clone_from(&control.last_error);
|
||||||
|
attempt.recoverable = true;
|
||||||
|
group.push_retry_attempt(attempt);
|
||||||
|
}
|
||||||
|
if control.completed_length > 0 || control.resume_generation.is_some() {
|
||||||
|
group.set_resume_state(ResumeState {
|
||||||
|
persisted: control.resume_generation.is_some(),
|
||||||
|
resume_offset: control.completed_length,
|
||||||
|
validated_length: Some(control.completed_length),
|
||||||
|
segment_cursor: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (piece, state) in control.piece_states {
|
||||||
|
group.set_piece_state(
|
||||||
|
crate::piece::PieceId(piece.0),
|
||||||
|
map_piece_state_from_storage(state),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes retry attempts into a compact session-metadata string.
|
||||||
|
fn encode_retry_attempts_metadata(attempts: &[RetryAttempt]) -> String {
|
||||||
|
attempts
|
||||||
|
.iter()
|
||||||
|
.map(|attempt| {
|
||||||
|
let length = attempt
|
||||||
|
.length
|
||||||
|
.map_or_else(|| "-".to_owned(), |value| value.to_string());
|
||||||
|
let error = attempt.error.as_deref().unwrap_or("-");
|
||||||
|
format!(
|
||||||
|
"{}:{}:{}:{}:{}",
|
||||||
|
attempt.attempt,
|
||||||
|
attempt.offset,
|
||||||
|
length,
|
||||||
|
u8::from(attempt.recoverable),
|
||||||
|
escape_metadata_field(error)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes retry attempts from the compact session-metadata string.
|
||||||
|
fn decode_retry_attempts_metadata(raw: &str) -> Vec<RetryAttempt> {
|
||||||
|
raw.split(',')
|
||||||
|
.filter(|entry| !entry.trim().is_empty())
|
||||||
|
.filter_map(|entry| {
|
||||||
|
let mut parts = entry.splitn(5, ':');
|
||||||
|
let attempt = parts.next()?.parse().ok()?;
|
||||||
|
let offset = parts.next()?.parse().ok()?;
|
||||||
|
let length = match parts.next()? {
|
||||||
|
"-" => None,
|
||||||
|
value => value.parse().ok(),
|
||||||
|
};
|
||||||
|
let recoverable = matches!(parts.next()?, "1" | "true");
|
||||||
|
let error = match parts.next()? {
|
||||||
|
"-" => None,
|
||||||
|
value => Some(unescape_metadata_field(value)),
|
||||||
|
};
|
||||||
|
Some(RetryAttempt {
|
||||||
|
attempt,
|
||||||
|
offset,
|
||||||
|
length,
|
||||||
|
error,
|
||||||
|
recoverable,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes resume-state metadata into a compact session-metadata string.
|
||||||
|
fn encode_resume_state_metadata(resume_state: &ResumeState) -> String {
|
||||||
|
let validated_length = resume_state
|
||||||
|
.validated_length
|
||||||
|
.map_or_else(|| "-".to_owned(), |value| value.to_string());
|
||||||
|
let segment_cursor = resume_state
|
||||||
|
.segment_cursor
|
||||||
|
.map_or_else(|| "-".to_owned(), |piece| piece.0.to_string());
|
||||||
|
format!(
|
||||||
|
"{}:{}:{}:{}",
|
||||||
|
u8::from(resume_state.persisted),
|
||||||
|
resume_state.resume_offset,
|
||||||
|
validated_length,
|
||||||
|
segment_cursor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes resume-state metadata from the compact session-metadata string.
|
||||||
|
fn decode_resume_state_metadata(raw: &str) -> Option<ResumeState> {
|
||||||
|
let mut parts = raw.splitn(4, ':');
|
||||||
|
let persisted = matches!(parts.next()?, "1" | "true");
|
||||||
|
let resume_offset = parts.next()?.parse().ok()?;
|
||||||
|
let validated_length = match parts.next()? {
|
||||||
|
"-" => None,
|
||||||
|
value => value.parse().ok(),
|
||||||
|
};
|
||||||
|
let segment_cursor = match parts.next()? {
|
||||||
|
"-" => None,
|
||||||
|
value => value.parse().ok().map(crate::piece::PieceId),
|
||||||
|
};
|
||||||
|
Some(ResumeState {
|
||||||
|
persisted,
|
||||||
|
resume_offset,
|
||||||
|
validated_length,
|
||||||
|
segment_cursor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Escapes reserved delimiters used by compact metadata encodings.
|
||||||
|
fn escape_metadata_field(raw: &str) -> String {
|
||||||
|
raw.replace('\\', "\\\\")
|
||||||
|
.replace(',', "\\c")
|
||||||
|
.replace(':', "\\d")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reverses `escape_metadata_field`.
|
||||||
|
fn unescape_metadata_field(raw: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut chars = raw.chars();
|
||||||
|
while let Some(ch) = chars.next() {
|
||||||
|
if ch == '\\' {
|
||||||
|
match chars.next() {
|
||||||
|
Some('c') => out.push(','),
|
||||||
|
Some('d') => out.push(':'),
|
||||||
|
Some('\\') | None => out.push('\\'),
|
||||||
|
Some(other) => {
|
||||||
|
out.push('\\');
|
||||||
|
out.push(other);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the persisted RPC-style download status string.
|
||||||
|
fn parse_status(value: &str) -> Option<DownloadStatus> {
|
||||||
|
match value {
|
||||||
|
"active" => Some(DownloadStatus::Active),
|
||||||
|
"waiting" => Some(DownloadStatus::Waiting),
|
||||||
|
"paused" => Some(DownloadStatus::Paused),
|
||||||
|
"error" => Some(DownloadStatus::Error),
|
||||||
|
"complete" => Some(DownloadStatus::Complete),
|
||||||
|
"removed" => Some(DownloadStatus::Removed),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,66 @@
|
|||||||
|
//! Error codes and error values emitted by the core crate.
|
||||||
|
|
||||||
|
use std::fmt::{Display, Formatter};
|
||||||
|
|
||||||
|
use crate::request::DownloadId;
|
||||||
|
|
||||||
|
/// Standard result type returned by core APIs.
|
||||||
|
pub type Result<T> = std::result::Result<T, CoreError>;
|
||||||
|
|
||||||
|
/// Stable error categories for mapping runtime failures to RPC-facing codes.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ErrorCode {
|
||||||
|
/// The requested operation is not supported by the current implementation.
|
||||||
|
Unsupported,
|
||||||
|
/// The supplied download id does not resolve to a tracked request group.
|
||||||
|
UnknownDownload,
|
||||||
|
/// The requested state transition is not valid for the current runtime state.
|
||||||
|
InvalidState,
|
||||||
|
/// The runtime is shutting down and cannot accept the requested operation.
|
||||||
|
ShutdownInProgress,
|
||||||
|
/// A required persistence or storage action failed.
|
||||||
|
StorageUnavailable,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Concrete errors returned by the core engine and state surfaces.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum CoreError {
|
||||||
|
/// The referenced download id is not present in the registry.
|
||||||
|
UnknownDownloadId(DownloadId),
|
||||||
|
/// The caller requested a feature that is not yet implemented.
|
||||||
|
UnsupportedOperation(&'static str),
|
||||||
|
/// The caller requested an invalid state transition or runtime action.
|
||||||
|
InvalidState(&'static str),
|
||||||
|
/// Shutdown has started and the runtime is no longer accepting work.
|
||||||
|
ShutdownInProgress,
|
||||||
|
/// Session or control-file storage was unavailable.
|
||||||
|
StorageUnavailable(&'static str),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CoreError {
|
||||||
|
/// Returns the stable error code associated with this error value.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn code(&self) -> ErrorCode {
|
||||||
|
match self {
|
||||||
|
Self::UnknownDownloadId(_) => ErrorCode::UnknownDownload,
|
||||||
|
Self::UnsupportedOperation(_) => ErrorCode::Unsupported,
|
||||||
|
Self::InvalidState(_) => ErrorCode::InvalidState,
|
||||||
|
Self::ShutdownInProgress => ErrorCode::ShutdownInProgress,
|
||||||
|
Self::StorageUnavailable(_) => ErrorCode::StorageUnavailable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for CoreError {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::UnknownDownloadId(gid) => write!(f, "unknown download id: {gid}"),
|
||||||
|
Self::UnsupportedOperation(msg) => write!(f, "unsupported operation: {msg}"),
|
||||||
|
Self::InvalidState(msg) => write!(f, "invalid runtime state: {msg}"),
|
||||||
|
Self::ShutdownInProgress => write!(f, "engine shutdown is in progress"),
|
||||||
|
Self::StorageUnavailable(msg) => write!(f, "storage unavailable: {msg}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for CoreError {}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
//! Runtime event definitions and the in-memory event bus.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
use crate::{progress::ProgressSnapshot, request::DownloadId};
|
||||||
|
|
||||||
|
/// Event categories emitted by the engine as downloads and sessions evolve.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum RuntimeEventKind {
|
||||||
|
/// A download was added to the registry.
|
||||||
|
DownloadAdded,
|
||||||
|
/// A download moved into the active set.
|
||||||
|
DownloadStarted,
|
||||||
|
/// A download was paused.
|
||||||
|
DownloadPaused,
|
||||||
|
/// A paused download was resumed.
|
||||||
|
DownloadResumed,
|
||||||
|
/// A download was removed.
|
||||||
|
DownloadRemoved,
|
||||||
|
/// A download completed successfully.
|
||||||
|
DownloadCompleted,
|
||||||
|
/// A download entered the error state.
|
||||||
|
DownloadErrored,
|
||||||
|
/// Global or per-download options changed.
|
||||||
|
OptionChanged,
|
||||||
|
/// Session persistence is starting.
|
||||||
|
SessionSaving,
|
||||||
|
/// Session persistence finished.
|
||||||
|
SessionSaved,
|
||||||
|
/// Graceful shutdown was requested.
|
||||||
|
ShutdownRequested,
|
||||||
|
/// Forced shutdown was requested.
|
||||||
|
ForceShutdownRequested,
|
||||||
|
/// The scheduler advanced a planning tick.
|
||||||
|
SchedulerTick,
|
||||||
|
/// Aggregated statistics were refreshed.
|
||||||
|
StatisticsUpdated,
|
||||||
|
/// Piece-level state changed.
|
||||||
|
PieceUpdated,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runtime event payload queued by the in-memory event bus.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct RuntimeEvent {
|
||||||
|
/// The event category.
|
||||||
|
pub kind: RuntimeEventKind,
|
||||||
|
/// The affected download id, when applicable.
|
||||||
|
pub gid: Option<DownloadId>,
|
||||||
|
/// Optional human-readable message text.
|
||||||
|
pub message: Option<String>,
|
||||||
|
/// Optional progress snapshot captured for the event.
|
||||||
|
pub snapshot: Option<ProgressSnapshot>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RuntimeEvent {
|
||||||
|
/// Creates a new event with the provided kind.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(kind: RuntimeEventKind) -> Self {
|
||||||
|
Self {
|
||||||
|
kind,
|
||||||
|
gid: None,
|
||||||
|
message: None,
|
||||||
|
snapshot: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attaches the download id affected by the event.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_gid(mut self, gid: DownloadId) -> Self {
|
||||||
|
self.gid = Some(gid);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attaches a human-readable message to the event.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_message(mut self, message: impl Into<String>) -> Self {
|
||||||
|
self.message = Some(message.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Listener interface for consumers that want push-style event delivery.
|
||||||
|
pub trait EventListener: Send {
|
||||||
|
/// Handles a newly emitted event.
|
||||||
|
fn on_event(&mut self, event: &RuntimeEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FIFO event queue with immediate listener fan-out.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct EventBus {
|
||||||
|
/// Registered listeners that receive push-style fan-out.
|
||||||
|
listeners: Vec<Box<dyn EventListener>>,
|
||||||
|
/// FIFO queue of emitted events waiting to be drained.
|
||||||
|
queue: VecDeque<RuntimeEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for EventBus {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("EventBus")
|
||||||
|
.field("listener_count", &self.listeners.len())
|
||||||
|
.field("queue_len", &self.queue.len())
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventBus {
|
||||||
|
/// Creates an empty event bus.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers a new listener that will receive future events.
|
||||||
|
pub fn subscribe(&mut self, listener: impl EventListener + 'static) {
|
||||||
|
self.listeners.push(Box::new(listener));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emits an event to listeners and stores it in the queue.
|
||||||
|
pub fn emit(&mut self, event: RuntimeEvent) {
|
||||||
|
for listener in &mut self.listeners {
|
||||||
|
listener.on_event(&event);
|
||||||
|
}
|
||||||
|
self.queue.push_back(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drains and returns all queued events in FIFO order.
|
||||||
|
#[must_use]
|
||||||
|
pub fn drain(&mut self) -> Vec<RuntimeEvent> {
|
||||||
|
self.queue.drain(..).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of queued events.
|
||||||
|
#[must_use]
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.queue.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the event queue is empty.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.queue.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#![doc = "Core runtime, scheduling, and request-state primitives for aria2-rust-pro."]
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![expect(
|
||||||
|
clippy::if_not_else,
|
||||||
|
clippy::missing_const_for_fn,
|
||||||
|
clippy::missing_errors_doc,
|
||||||
|
clippy::must_use_candidate,
|
||||||
|
clippy::needless_pass_by_value,
|
||||||
|
clippy::struct_excessive_bools,
|
||||||
|
clippy::struct_field_names,
|
||||||
|
clippy::use_self,
|
||||||
|
reason = "core crate exposes compatibility-oriented runtime models where strict style lints add noise"
|
||||||
|
)]
|
||||||
|
|
||||||
|
/// Download engine orchestration, queue management, and session persistence.
|
||||||
|
mod engine;
|
||||||
|
/// Error types returned by the core crate.
|
||||||
|
mod error;
|
||||||
|
/// Runtime event types and the in-memory event bus.
|
||||||
|
mod events;
|
||||||
|
/// Typed option keys, values, and patches.
|
||||||
|
mod options;
|
||||||
|
/// Piece identifiers, piece ranges, and piece-state storage.
|
||||||
|
mod piece;
|
||||||
|
/// Aggregated progress and statistics snapshots.
|
||||||
|
mod progress;
|
||||||
|
/// Request, `BitTorrent`, and segment runtime state models.
|
||||||
|
mod request;
|
||||||
|
/// Runtime configuration and human-readable size parsing helpers.
|
||||||
|
mod runtime;
|
||||||
|
/// Download scheduling policies, planning, and observations.
|
||||||
|
mod scheduler;
|
||||||
|
/// Session state, global options, and persistence bridge data.
|
||||||
|
mod session;
|
||||||
|
|
||||||
|
pub use engine::{
|
||||||
|
DownloadEngine, DownloadHandle, DownloadRegistry, DownloadRuntimeSnapshot, QueuePositionMode,
|
||||||
|
RuntimeInstrumentationSnapshot,
|
||||||
|
};
|
||||||
|
pub use error::{CoreError, ErrorCode, Result};
|
||||||
|
pub use events::{EventBus, EventListener, RuntimeEvent, RuntimeEventKind};
|
||||||
|
pub use options::{OptionKey, OptionPatch, OptionValue};
|
||||||
|
pub use piece::{PieceId, PieceMap, PieceRange, PieceState};
|
||||||
|
pub use progress::{GlobalStat, GoalProgress, ProgressSnapshot, WorkState};
|
||||||
|
pub use request::{
|
||||||
|
BtFileInfo, BtPeerInfo, BtPieceAvailabilityUpdate, BtPressureSnapshot, BtRuntimeState,
|
||||||
|
BtTrackerInfo, DownloadId, DownloadStatus, RequestContext, RequestGroup, ResumeState,
|
||||||
|
RetryAttempt, SegmentAssignment, SegmentRuntimeStats, SegmentState,
|
||||||
|
};
|
||||||
|
pub use runtime::RuntimeConfig;
|
||||||
|
pub use scheduler::{
|
||||||
|
ScheduleDecision, ScheduleDecisionKind, Scheduler, SchedulerActivityCounters,
|
||||||
|
SchedulerPlanningObservation, SchedulerPolicy, SchedulerState,
|
||||||
|
};
|
||||||
|
pub use session::{GlobalOptions, SaveSessionTarget, Session, SessionState};
|
||||||
|
|
||||||
|
/// Convenience alias for the primary download task model.
|
||||||
|
pub type DownloadTask = RequestGroup;
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
//! Typed option keys, values, and patch collections.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
/// Strongly typed option key used by session and request surfaces.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
|
||||||
|
pub struct OptionKey(
|
||||||
|
/// Raw option-key text stored by the runtime.
|
||||||
|
pub String,
|
||||||
|
);
|
||||||
|
|
||||||
|
impl OptionKey {
|
||||||
|
/// Builds a new owned option key.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(key: impl Into<String>) -> Self {
|
||||||
|
Self(key.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the raw string representation of the key.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supported option value shapes accepted by the core surfaces.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum OptionValue {
|
||||||
|
/// Boolean option value.
|
||||||
|
Bool(bool),
|
||||||
|
/// Signed integer option value.
|
||||||
|
Int(i64),
|
||||||
|
/// Unsigned integer option value.
|
||||||
|
UInt(u64),
|
||||||
|
/// Text option value.
|
||||||
|
Text(String),
|
||||||
|
/// Repeated text values.
|
||||||
|
List(Vec<String>),
|
||||||
|
/// String-keyed string map values.
|
||||||
|
Map(BTreeMap<String, String>),
|
||||||
|
/// Explicit empty value.
|
||||||
|
Empty,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OptionValue {
|
||||||
|
/// Returns the inner string when the value is textual.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_text(&self) -> Option<&str> {
|
||||||
|
match self {
|
||||||
|
Self::Text(value) => Some(value.as_str()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mergeable collection of option overrides.
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct OptionPatch {
|
||||||
|
/// Ordered option entries applied by the patch.
|
||||||
|
entries: BTreeMap<OptionKey, OptionValue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OptionPatch {
|
||||||
|
/// Creates an empty patch.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts or replaces a value in the patch.
|
||||||
|
pub fn insert(
|
||||||
|
&mut self,
|
||||||
|
key: impl Into<OptionKey>,
|
||||||
|
value: impl Into<OptionValue>,
|
||||||
|
) -> Option<OptionValue> {
|
||||||
|
self.entries.insert(key.into(), value.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the value for a given key when present.
|
||||||
|
#[must_use]
|
||||||
|
pub fn get(&self, key: &OptionKey) -> Option<&OptionValue> {
|
||||||
|
self.entries.get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the patch contains any entries.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.entries.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merges another patch into this one, overwriting matching keys.
|
||||||
|
pub fn merge(&mut self, other: OptionPatch) {
|
||||||
|
self.entries.extend(other.entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the underlying ordered patch entries.
|
||||||
|
#[must_use]
|
||||||
|
pub fn entries(&self) -> &BTreeMap<OptionKey, OptionValue> {
|
||||||
|
&self.entries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for OptionKey {
|
||||||
|
fn from(value: &str) -> Self {
|
||||||
|
Self::new(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for OptionKey {
|
||||||
|
fn from(value: String) -> Self {
|
||||||
|
Self(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<bool> for OptionValue {
|
||||||
|
fn from(value: bool) -> Self {
|
||||||
|
Self::Bool(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<i64> for OptionValue {
|
||||||
|
fn from(value: i64) -> Self {
|
||||||
|
Self::Int(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<u64> for OptionValue {
|
||||||
|
fn from(value: u64) -> Self {
|
||||||
|
Self::UInt(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for OptionValue {
|
||||||
|
fn from(value: String) -> Self {
|
||||||
|
Self::Text(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for OptionValue {
|
||||||
|
fn from(value: &str) -> Self {
|
||||||
|
Self::Text(value.to_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Vec<String>> for OptionValue {
|
||||||
|
fn from(value: Vec<String>) -> Self {
|
||||||
|
Self::List(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
//! Piece identifiers, piece states, and in-memory piece maps.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
/// Stable identifier for a single piece within a download.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||||
|
pub struct PieceId(
|
||||||
|
/// Zero-based piece index within the download.
|
||||||
|
pub u32,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Current lifecycle state of a piece.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum PieceState {
|
||||||
|
/// The piece has not been queued yet.
|
||||||
|
Pending,
|
||||||
|
/// The piece is queued and ready to be assigned.
|
||||||
|
Queued,
|
||||||
|
/// The piece is currently being downloaded.
|
||||||
|
Downloading,
|
||||||
|
/// The piece has been verified successfully.
|
||||||
|
Verified,
|
||||||
|
/// The piece is missing and should be retried.
|
||||||
|
Missing,
|
||||||
|
/// The piece is intentionally skipped.
|
||||||
|
Skipped,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Half-open byte range occupied by a piece or segment.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct PieceRange {
|
||||||
|
/// Inclusive starting byte offset.
|
||||||
|
pub start: u64,
|
||||||
|
/// Exclusive ending byte offset.
|
||||||
|
pub end: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PieceRange {
|
||||||
|
/// Creates a new half-open byte range.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(start: u64, end: u64) -> Self {
|
||||||
|
Self { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the byte length of the range.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn len(&self) -> u64 {
|
||||||
|
self.end.saturating_sub(self.start)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the range contains no bytes.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn is_empty(&self) -> bool {
|
||||||
|
self.start >= self.end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ordered in-memory map from piece ids to piece states.
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct PieceMap {
|
||||||
|
/// Ordered mapping from piece ids to their current states.
|
||||||
|
pieces: BTreeMap<PieceId, PieceState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PieceMap {
|
||||||
|
/// Creates an empty piece map.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts or replaces a piece state.
|
||||||
|
pub fn insert(&mut self, id: PieceId, state: PieceState) -> Option<PieceState> {
|
||||||
|
self.pieces.insert(id, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the state for a piece when present.
|
||||||
|
#[must_use]
|
||||||
|
pub fn get(&self, id: &PieceId) -> Option<PieceState> {
|
||||||
|
self.pieces.get(id).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the state for a piece id.
|
||||||
|
pub fn set_state(&mut self, id: PieceId, state: PieceState) {
|
||||||
|
self.pieces.insert(id, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterates over all tracked pieces in key order.
|
||||||
|
pub fn iter(&self) -> impl Iterator<Item = (&PieceId, &PieceState)> {
|
||||||
|
self.pieces.iter()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of tracked pieces.
|
||||||
|
#[must_use]
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.pieces.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the map is empty.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.pieces.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
//! Progress snapshots and aggregate statistics exposed by the core runtime.
|
||||||
|
|
||||||
|
use crate::request::{DownloadId, DownloadStatus};
|
||||||
|
|
||||||
|
/// High-level work state used for coarse progress reporting.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum WorkState {
|
||||||
|
/// Work is planned but not yet implemented.
|
||||||
|
Planned,
|
||||||
|
/// Work has been implemented.
|
||||||
|
Implemented,
|
||||||
|
/// Work has been verified.
|
||||||
|
Verified,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coarse progress information for a multi-phase goal.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct GoalProgress {
|
||||||
|
/// Overall completion percent across the whole goal.
|
||||||
|
overall_percent: u8,
|
||||||
|
/// Human-readable name of the current phase.
|
||||||
|
phase_name: String,
|
||||||
|
/// Completion percent within the current phase.
|
||||||
|
phase_percent: u8,
|
||||||
|
/// Coarse progress state for the current phase.
|
||||||
|
state: WorkState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GoalProgress {
|
||||||
|
/// Creates a new progress tracker for the given phase.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(phase_name: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
overall_percent: 1,
|
||||||
|
phase_name: phase_name.into(),
|
||||||
|
phase_percent: 20,
|
||||||
|
state: WorkState::Planned,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the overall completion percentage.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn overall_percent(&self) -> u8 {
|
||||||
|
self.overall_percent
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current phase name.
|
||||||
|
#[must_use]
|
||||||
|
pub fn phase_name(&self) -> &str {
|
||||||
|
&self.phase_name
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current phase completion percentage.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn phase_percent(&self) -> u8 {
|
||||||
|
self.phase_percent
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the coarse work state.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn state(&self) -> WorkState {
|
||||||
|
self.state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregated global transfer statistics.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct GlobalStat {
|
||||||
|
/// Aggregate download throughput in bytes per second.
|
||||||
|
pub download_speed: u64,
|
||||||
|
/// Aggregate upload throughput in bytes per second.
|
||||||
|
pub upload_speed: u64,
|
||||||
|
/// Number of downloads currently active.
|
||||||
|
pub num_active: u32,
|
||||||
|
/// Number of downloads queued and waiting.
|
||||||
|
pub num_waiting: u32,
|
||||||
|
/// Number of downloads stopped without error.
|
||||||
|
pub num_stopped: u32,
|
||||||
|
/// Number of downloads currently in an error state.
|
||||||
|
pub num_error: u32,
|
||||||
|
/// Number of downloads completed successfully.
|
||||||
|
pub num_complete: u32,
|
||||||
|
/// Total tracked payload length across downloads.
|
||||||
|
pub total_length: u64,
|
||||||
|
/// Total completed payload length across downloads.
|
||||||
|
pub completed_length: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GlobalStat {
|
||||||
|
/// Creates an empty statistics snapshot.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
download_speed: 0,
|
||||||
|
upload_speed: 0,
|
||||||
|
num_active: 0,
|
||||||
|
num_waiting: 0,
|
||||||
|
num_stopped: 0,
|
||||||
|
num_error: 0,
|
||||||
|
num_complete: 0,
|
||||||
|
total_length: 0,
|
||||||
|
completed_length: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detailed progress snapshot for a single download.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ProgressSnapshot {
|
||||||
|
/// Download id associated with this snapshot.
|
||||||
|
pub gid: DownloadId,
|
||||||
|
/// Current lifecycle status of the download.
|
||||||
|
pub status: DownloadStatus,
|
||||||
|
/// Total payload length in bytes.
|
||||||
|
pub total_length: u64,
|
||||||
|
/// Completed payload length in bytes.
|
||||||
|
pub completed_length: u64,
|
||||||
|
/// Uploaded payload length in bytes.
|
||||||
|
pub upload_length: u64,
|
||||||
|
/// Current upload throughput in bytes per second.
|
||||||
|
pub upload_speed: u64,
|
||||||
|
/// Current download throughput in bytes per second.
|
||||||
|
pub download_speed: u64,
|
||||||
|
/// Number of active connections assigned to the download.
|
||||||
|
pub num_connections: u32,
|
||||||
|
/// Estimated seconds remaining when known.
|
||||||
|
pub eta_seconds: Option<u64>,
|
||||||
|
/// Whether the runtime currently considers the download seeding.
|
||||||
|
pub seeding: bool,
|
||||||
|
/// Share ratio expressed in milli-units when available.
|
||||||
|
pub share_ratio_milli: Option<u64>,
|
||||||
|
/// Accumulated share time in seconds when available.
|
||||||
|
pub share_time_secs: Option<u64>,
|
||||||
|
/// Accumulated seeding time in seconds when available.
|
||||||
|
pub seeding_time_secs: Option<u64>,
|
||||||
|
/// Total selected `BitTorrent` payload length in bytes.
|
||||||
|
pub bt_selected_payload_length: u64,
|
||||||
|
/// Remaining selected `BitTorrent` payload length in bytes.
|
||||||
|
pub bt_remaining_payload_length: u64,
|
||||||
|
/// Whether the torrent has completed selected work and is truly seeding.
|
||||||
|
pub bt_true_seeding: bool,
|
||||||
|
/// Number of peers in the current swarm snapshot.
|
||||||
|
pub bt_total_peers: u32,
|
||||||
|
/// Number of peers currently identified as seeders.
|
||||||
|
pub bt_seeders: u32,
|
||||||
|
/// Number of peers currently identified as leechers.
|
||||||
|
pub bt_leechers: u32,
|
||||||
|
/// Number of pieces with non-zero availability.
|
||||||
|
pub bt_available_pieces: u32,
|
||||||
|
/// Number of verified pieces.
|
||||||
|
pub bt_verified_pieces: u32,
|
||||||
|
/// Number of actively downloading pieces.
|
||||||
|
pub bt_downloading_pieces: u32,
|
||||||
|
/// Number of queued pieces.
|
||||||
|
pub bt_queued_pieces: u32,
|
||||||
|
/// Number of missing pieces.
|
||||||
|
pub bt_missing_pieces: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProgressSnapshot {
|
||||||
|
/// Creates an empty progress snapshot for the given download id and status.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(gid: DownloadId, status: DownloadStatus) -> Self {
|
||||||
|
Self {
|
||||||
|
gid,
|
||||||
|
status,
|
||||||
|
total_length: 0,
|
||||||
|
completed_length: 0,
|
||||||
|
upload_length: 0,
|
||||||
|
upload_speed: 0,
|
||||||
|
download_speed: 0,
|
||||||
|
num_connections: 0,
|
||||||
|
eta_seconds: None,
|
||||||
|
seeding: false,
|
||||||
|
share_ratio_milli: None,
|
||||||
|
share_time_secs: None,
|
||||||
|
seeding_time_secs: None,
|
||||||
|
bt_selected_payload_length: 0,
|
||||||
|
bt_remaining_payload_length: 0,
|
||||||
|
bt_true_seeding: false,
|
||||||
|
bt_total_peers: 0,
|
||||||
|
bt_seeders: 0,
|
||||||
|
bt_leechers: 0,
|
||||||
|
bt_available_pieces: 0,
|
||||||
|
bt_verified_pieces: 0,
|
||||||
|
bt_downloading_pieces: 0,
|
||||||
|
bt_queued_pieces: 0,
|
||||||
|
bt_missing_pieces: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the snapshot has a non-zero payload length.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn has_payload_length(&self) -> bool {
|
||||||
|
self.total_length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the remaining payload length in bytes.
|
||||||
|
#[must_use]
|
||||||
|
pub fn remaining_length(&self) -> u64 {
|
||||||
|
self.total_length
|
||||||
|
.saturating_sub(self.completed_length.min(self.total_length))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the payload transfer is complete.
|
||||||
|
#[must_use]
|
||||||
|
pub fn transfer_complete(&self) -> bool {
|
||||||
|
self.has_payload_length() && self.remaining_length() == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the transfer is complete or actively seeding.
|
||||||
|
#[must_use]
|
||||||
|
pub fn bt_transfer_complete_or_seeding(&self) -> bool {
|
||||||
|
self.transfer_complete() || self.seeding
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether any `BitTorrent` share-runtime data is present.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn bt_has_share_runtime(&self) -> bool {
|
||||||
|
self.share_time_secs.is_some() || self.share_ratio_milli.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether peer or piece-availability activity exists.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn bt_has_swarm_activity(&self) -> bool {
|
||||||
|
self.bt_total_peers > 0 || self.bt_available_pieces > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the total number of active `BitTorrent` pieces.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn bt_active_piece_count(&self) -> u32 {
|
||||||
|
self.bt_downloading_pieces
|
||||||
|
.saturating_add(self.bt_queued_pieces)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the selected `BitTorrent` payload is complete.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn bt_payload_complete(&self) -> bool {
|
||||||
|
self.bt_selected_payload_length > 0
|
||||||
|
&& self.bt_remaining_payload_length == 0
|
||||||
|
&& self.completed_length >= self.bt_selected_payload_length
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns completion percent in milli-units.
|
||||||
|
#[must_use]
|
||||||
|
pub fn completion_percent_milli(&self) -> u64 {
|
||||||
|
if self.total_length == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
self.completed_length
|
||||||
|
.min(self.total_length)
|
||||||
|
.saturating_mul(1000)
|
||||||
|
.checked_div(self.total_length)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_snapshot_new_initializes_bt_share_fields() {
|
||||||
|
let snapshot = ProgressSnapshot::new(DownloadId::new(0x42), DownloadStatus::Waiting);
|
||||||
|
assert!(!snapshot.seeding);
|
||||||
|
assert_eq!(snapshot.share_ratio_milli, None);
|
||||||
|
assert_eq!(snapshot.upload_speed, 0);
|
||||||
|
assert_eq!(snapshot.share_time_secs, None);
|
||||||
|
assert!(!snapshot.bt_has_swarm_activity());
|
||||||
|
assert!(!snapshot.bt_has_share_runtime());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_snapshot_bt_completion_semantics_avoid_false_completion() {
|
||||||
|
let mut snapshot = ProgressSnapshot::new(DownloadId::new(0x43), DownloadStatus::Active);
|
||||||
|
snapshot.total_length = 10_000;
|
||||||
|
snapshot.completed_length = 9_000;
|
||||||
|
assert_eq!(snapshot.remaining_length(), 1_000);
|
||||||
|
assert!(!snapshot.transfer_complete());
|
||||||
|
assert!(!snapshot.bt_transfer_complete_or_seeding());
|
||||||
|
|
||||||
|
snapshot.seeding = true;
|
||||||
|
assert!(snapshot.bt_transfer_complete_or_seeding());
|
||||||
|
assert!(!snapshot.transfer_complete());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_snapshot_completion_percent_milli_caps_completed_length() {
|
||||||
|
let mut snapshot = ProgressSnapshot::new(DownloadId::new(0x44), DownloadStatus::Active);
|
||||||
|
snapshot.total_length = 2_000;
|
||||||
|
snapshot.completed_length = 2_500;
|
||||||
|
assert_eq!(snapshot.remaining_length(), 0);
|
||||||
|
assert_eq!(snapshot.completion_percent_milli(), 1000);
|
||||||
|
|
||||||
|
snapshot.total_length = 0;
|
||||||
|
assert_eq!(snapshot.completion_percent_milli(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_snapshot_bt_runtime_metrics_report_activity() {
|
||||||
|
let mut snapshot = ProgressSnapshot::new(DownloadId::new(0x45), DownloadStatus::Active);
|
||||||
|
snapshot.bt_total_peers = 2;
|
||||||
|
snapshot.bt_seeders = 1;
|
||||||
|
snapshot.bt_leechers = 1;
|
||||||
|
snapshot.bt_available_pieces = 3;
|
||||||
|
snapshot.bt_downloading_pieces = 2;
|
||||||
|
snapshot.bt_queued_pieces = 1;
|
||||||
|
snapshot.bt_missing_pieces = 4;
|
||||||
|
|
||||||
|
assert!(snapshot.bt_has_swarm_activity());
|
||||||
|
assert_eq!(snapshot.bt_active_piece_count(), 3);
|
||||||
|
assert_eq!(snapshot.bt_seeders + snapshot.bt_leechers, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_snapshot_bt_share_runtime_helpers_report_true_seeding() {
|
||||||
|
let mut snapshot = ProgressSnapshot::new(DownloadId::new(0x46), DownloadStatus::Complete);
|
||||||
|
snapshot.completed_length = 4_096;
|
||||||
|
snapshot.share_ratio_milli = Some(1250);
|
||||||
|
snapshot.share_time_secs = Some(120);
|
||||||
|
snapshot.seeding_time_secs = Some(90);
|
||||||
|
snapshot.bt_selected_payload_length = 4_096;
|
||||||
|
snapshot.bt_remaining_payload_length = 0;
|
||||||
|
snapshot.bt_true_seeding = true;
|
||||||
|
snapshot.seeding = true;
|
||||||
|
|
||||||
|
assert!(snapshot.bt_has_share_runtime());
|
||||||
|
assert!(snapshot.bt_payload_complete());
|
||||||
|
assert!(snapshot.bt_transfer_complete_or_seeding());
|
||||||
|
assert!(snapshot.bt_true_seeding);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
//! Request, segment, and BitTorrent runtime state models.
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
use crate::piece::{PieceId, PieceRange, PieceState};
|
||||||
|
|
||||||
|
/// `BitTorrent` runtime metadata, peer state, and mutation result types.
|
||||||
|
mod bt;
|
||||||
|
/// Ordered URI lists, headers, and request metadata for one download.
|
||||||
|
mod context;
|
||||||
|
/// Request-group state and request/BT helper submodules.
|
||||||
|
mod group;
|
||||||
|
/// Stable download identifiers, statuses, and resume metadata.
|
||||||
|
mod identity;
|
||||||
|
/// Segment-assignment models and aggregated segment runtime counters.
|
||||||
|
mod segment;
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::redundant_pub_crate,
|
||||||
|
reason = "these BT helper types stay crate-internal while sibling modules import them through crate::request"
|
||||||
|
)]
|
||||||
|
pub(crate) use self::bt::{
|
||||||
|
BtPeerMutationResult, BtPieceAvailabilityMutationResult, BtPieceBlockUpdate,
|
||||||
|
BtPieceMutationResult, BtRuntimeTickResult, BtShareRuntimeState,
|
||||||
|
};
|
||||||
|
pub use self::{
|
||||||
|
bt::{
|
||||||
|
BtFileInfo, BtPeerInfo, BtPieceAvailabilityUpdate, BtPressureSnapshot, BtRuntimeState,
|
||||||
|
BtTrackerInfo,
|
||||||
|
},
|
||||||
|
context::RequestContext,
|
||||||
|
group::RequestGroup,
|
||||||
|
identity::{DownloadId, DownloadStatus, ResumeState, RetryAttempt},
|
||||||
|
segment::{SegmentAssignment, SegmentRuntimeStats, SegmentState},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod request_tests;
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use crate::piece::{PieceId, PieceState};
|
||||||
|
|
||||||
|
/// File entry exposed by torrent metadata and BT RPC responses.
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct BtFileInfo {
|
||||||
|
/// Output path or logical file name for the torrent entry.
|
||||||
|
pub path: String,
|
||||||
|
/// Declared file length in bytes.
|
||||||
|
pub length: u64,
|
||||||
|
/// Piece-aligned offset where this file begins, when known.
|
||||||
|
pub piece_offset: Option<u64>,
|
||||||
|
/// Whether the file is selected for download.
|
||||||
|
pub selected: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BtFileInfo {
|
||||||
|
/// Returns whether the torrent file is selected for transfer.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn is_selected(&self) -> bool {
|
||||||
|
self.selected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tracker entry associated with a torrent.
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct BtTrackerInfo {
|
||||||
|
/// Tracker announce URL.
|
||||||
|
pub url: String,
|
||||||
|
/// Optional tracker tier index.
|
||||||
|
pub tier: Option<u32>,
|
||||||
|
/// Optional tracker identifier reported by the server.
|
||||||
|
pub id: Option<String>,
|
||||||
|
/// Seeder count reported by the tracker, when available.
|
||||||
|
pub seeders: Option<u32>,
|
||||||
|
/// Leecher count reported by the tracker, when available.
|
||||||
|
pub leechers: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Peer entry associated with BT swarm runtime state.
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct BtPeerInfo {
|
||||||
|
/// Optional peer ID from the handshake.
|
||||||
|
pub peer_id: Option<String>,
|
||||||
|
/// Peer IP address or host.
|
||||||
|
pub ip: String,
|
||||||
|
/// Peer listening port.
|
||||||
|
pub port: u16,
|
||||||
|
/// Optional peer client identification string.
|
||||||
|
pub client_name: Option<String>,
|
||||||
|
/// Whether the peer is interested in our pieces.
|
||||||
|
pub interested: bool,
|
||||||
|
/// Whether the peer currently chokes us.
|
||||||
|
pub choked: bool,
|
||||||
|
/// Reported or inferred peer-to-local download speed.
|
||||||
|
pub download_speed: u64,
|
||||||
|
/// Reported or inferred local-to-peer upload speed.
|
||||||
|
pub upload_speed: u64,
|
||||||
|
/// Whether the peer appears to have the full payload.
|
||||||
|
pub seeder: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Piece block completion update emitted by the BT runtime.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct BtPieceBlockUpdate {
|
||||||
|
/// Piece being updated.
|
||||||
|
pub piece_id: PieceId,
|
||||||
|
/// Number of completed blocks inside the piece.
|
||||||
|
pub completed_blocks: u32,
|
||||||
|
/// Total number of blocks in the piece.
|
||||||
|
pub total_blocks: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Piece availability update emitted by the BT runtime.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct BtPieceAvailabilityUpdate {
|
||||||
|
/// Piece being updated.
|
||||||
|
pub piece_id: PieceId,
|
||||||
|
/// Number of peers advertising the piece.
|
||||||
|
pub peers_with_piece: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of applying one piece state transition.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct BtPieceMutationResult {
|
||||||
|
/// Change in verified completed length caused by the mutation.
|
||||||
|
pub completed_length_delta: i64,
|
||||||
|
/// Whether the piece transitioned into the verified state.
|
||||||
|
pub transitioned_to_verified: bool,
|
||||||
|
/// Previous piece state, if one existed.
|
||||||
|
pub previous_state: Option<PieceState>,
|
||||||
|
/// Resulting piece state after the mutation.
|
||||||
|
pub next_state: PieceState,
|
||||||
|
/// Byte span covered by the piece.
|
||||||
|
pub piece_span_length: u64,
|
||||||
|
/// Number of completed blocks after the mutation.
|
||||||
|
pub completed_blocks: u32,
|
||||||
|
/// Total number of blocks in the piece.
|
||||||
|
pub total_blocks: u32,
|
||||||
|
/// Block completion ratio in thousandths.
|
||||||
|
pub block_completion_milli: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BtPieceMutationResult {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
completed_length_delta: 0,
|
||||||
|
transitioned_to_verified: false,
|
||||||
|
previous_state: None,
|
||||||
|
next_state: PieceState::Pending,
|
||||||
|
piece_span_length: 0,
|
||||||
|
completed_blocks: 0,
|
||||||
|
total_blocks: 0,
|
||||||
|
block_completion_milli: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of applying one piece availability update.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct BtPieceAvailabilityMutationResult {
|
||||||
|
/// Peer count currently advertising the piece.
|
||||||
|
pub peers_with_piece: u32,
|
||||||
|
/// Number of pieces currently available from at least one peer.
|
||||||
|
pub available_piece_count: usize,
|
||||||
|
/// Whether the updated piece is requestable right now.
|
||||||
|
pub piece_is_requestable: bool,
|
||||||
|
/// Whether the updated piece is already verified locally.
|
||||||
|
pub piece_is_verified: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregated swarm counters after a peer mutation.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct BtPeerMutationResult {
|
||||||
|
/// Total connected peer count after the mutation.
|
||||||
|
pub peer_count: usize,
|
||||||
|
/// Seeder count after the mutation.
|
||||||
|
pub seeder_count: usize,
|
||||||
|
/// Leecher count after the mutation.
|
||||||
|
pub leecher_count: usize,
|
||||||
|
/// Aggregate download speed after the mutation.
|
||||||
|
pub total_download_speed: u64,
|
||||||
|
/// Aggregate upload speed after the mutation.
|
||||||
|
pub total_upload_speed: u64,
|
||||||
|
/// Whether the mutation replaced an existing peer entry.
|
||||||
|
pub replaced_existing: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transfer and seeding counters observed for one BT runtime tick.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct BtRuntimeTickResult {
|
||||||
|
/// Completed payload length after the tick.
|
||||||
|
pub completed_length: u64,
|
||||||
|
/// Uploaded payload length after the tick.
|
||||||
|
pub upload_length: u64,
|
||||||
|
/// Download speed observed during the tick.
|
||||||
|
pub download_speed: u64,
|
||||||
|
/// Upload speed observed during the tick.
|
||||||
|
pub upload_speed: u64,
|
||||||
|
/// Number of active peer connections.
|
||||||
|
pub num_connections: u32,
|
||||||
|
/// Whether the torrent is currently seeding.
|
||||||
|
pub seeding: bool,
|
||||||
|
/// Share ratio in thousandths, when it can be derived.
|
||||||
|
pub share_ratio_milli: Option<u64>,
|
||||||
|
/// Total share time in seconds.
|
||||||
|
pub share_time_secs: u64,
|
||||||
|
/// Total seeding time in seconds.
|
||||||
|
pub seeding_time_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot used by BT heuristics and diagnostics to describe swarm pressure.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct BtPressureSnapshot {
|
||||||
|
/// Total piece count in the torrent.
|
||||||
|
pub total_pieces: usize,
|
||||||
|
/// Number of pieces currently requestable by the local client.
|
||||||
|
pub requestable_pieces: usize,
|
||||||
|
/// Number of pieces actively being worked on.
|
||||||
|
pub active_pieces: usize,
|
||||||
|
/// Number of requestable pieces that at least one peer can serve.
|
||||||
|
pub available_requestable_pieces: usize,
|
||||||
|
/// Number of requestable pieces served by very few peers.
|
||||||
|
pub scarce_requestable_pieces: usize,
|
||||||
|
/// Total connected peer count.
|
||||||
|
pub peer_count: usize,
|
||||||
|
/// Number of peers believed to be complete seeders.
|
||||||
|
pub seeder_count: usize,
|
||||||
|
/// Number of peers still downloading pieces.
|
||||||
|
pub leecher_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// BitTorrent-specific metadata and swarm state attached to a request group.
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct BtRuntimeState {
|
||||||
|
/// Uppercase hexadecimal torrent info hash.
|
||||||
|
pub info_hash: String,
|
||||||
|
/// Optional torrent display name.
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// Original magnet URI when the torrent was bootstrapped from magnet.
|
||||||
|
pub magnet_uri: Option<String>,
|
||||||
|
/// Whether the runtime is still waiting for full torrent metadata.
|
||||||
|
pub metadata_only: bool,
|
||||||
|
/// Total `.torrent` metadata size announced through BEP 10 / BEP 9, when known.
|
||||||
|
pub metadata_size: Option<u32>,
|
||||||
|
/// Known per-peer `ut_metadata` extension ids keyed by `host:port`.
|
||||||
|
pub metadata_extension_ids: BTreeMap<String, u8>,
|
||||||
|
/// Buffered metadata pieces keyed by metadata piece index.
|
||||||
|
pub metadata_piece_payloads: BTreeMap<u32, Vec<u8>>,
|
||||||
|
/// Optional torrent creation date string.
|
||||||
|
pub creation_date: Option<String>,
|
||||||
|
/// Optional torrent comment string.
|
||||||
|
pub comment: Option<String>,
|
||||||
|
/// Known DHT bootstrap or discovered nodes.
|
||||||
|
pub dht_nodes: Vec<String>,
|
||||||
|
/// Torrent file entries.
|
||||||
|
pub files: Vec<BtFileInfo>,
|
||||||
|
/// Tracker entries associated with the torrent.
|
||||||
|
pub trackers: Vec<BtTrackerInfo>,
|
||||||
|
/// Connected or recently seen peers.
|
||||||
|
pub peers: Vec<BtPeerInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BtRuntimeState {
|
||||||
|
/// Returns the current DHT node list associated with the torrent runtime.
|
||||||
|
#[must_use]
|
||||||
|
pub fn dht_nodes(&self) -> &[String] {
|
||||||
|
&self.dht_nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the torrent file entries currently attached to the runtime state.
|
||||||
|
#[must_use]
|
||||||
|
pub fn files(&self) -> &[BtFileInfo] {
|
||||||
|
&self.files
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterates over torrent file entries that are currently selected.
|
||||||
|
pub fn selected_files(&self) -> impl Iterator<Item = &BtFileInfo> + '_ {
|
||||||
|
self.files.iter().filter(|file| file.is_selected())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of torrent file entries currently selected.
|
||||||
|
#[must_use]
|
||||||
|
pub fn selected_file_count(&self) -> usize {
|
||||||
|
self.selected_files().count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the sum of selected torrent file lengths.
|
||||||
|
#[must_use]
|
||||||
|
pub fn selected_total_length(&self) -> u64 {
|
||||||
|
self.selected_files()
|
||||||
|
.fold(0_u64, |acc, file| acc.saturating_add(file.length))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether at least one torrent file entry is selected.
|
||||||
|
#[must_use]
|
||||||
|
pub fn has_selected_files(&self) -> bool {
|
||||||
|
self.files.iter().any(BtFileInfo::is_selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the selected total length, or the full torrent length when nothing is selected.
|
||||||
|
#[must_use]
|
||||||
|
pub fn selected_or_all_total_length(&self) -> u64 {
|
||||||
|
if self.files.is_empty() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let selected = self.selected_total_length();
|
||||||
|
if selected > 0 {
|
||||||
|
selected
|
||||||
|
} else {
|
||||||
|
self.files
|
||||||
|
.iter()
|
||||||
|
.fold(0_u64, |acc, file| acc.saturating_add(file.length))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mutable seeding and share-ratio counters for one torrent session.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct BtShareRuntimeState {
|
||||||
|
/// Whether the runtime currently considers the torrent to be seeding.
|
||||||
|
pub seeding: bool,
|
||||||
|
/// Share ratio in thousandths, when derivable.
|
||||||
|
pub share_ratio_milli: Option<u64>,
|
||||||
|
/// Total share time in seconds.
|
||||||
|
pub share_time_secs: u64,
|
||||||
|
/// Total seeding time in seconds.
|
||||||
|
pub seeding_time_secs: u64,
|
||||||
|
/// Wall-clock second when seeding most recently began.
|
||||||
|
pub seeding_started_at_secs: Option<u64>,
|
||||||
|
/// Wall-clock second of the last runtime tick update.
|
||||||
|
pub last_runtime_tick_secs: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BtShareRuntimeState {
|
||||||
|
/// Builds a zeroed BT share-state snapshot.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
seeding: false,
|
||||||
|
share_ratio_milli: None,
|
||||||
|
share_time_secs: 0,
|
||||||
|
seeding_time_secs: 0,
|
||||||
|
seeding_started_at_secs: None,
|
||||||
|
last_runtime_tick_secs: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the torrent is currently in a seeding state.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn is_seeding(&self) -> bool {
|
||||||
|
self.seeding
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the seeding flag and clears the start timestamp when seeding stops.
|
||||||
|
pub fn set_seeding(&mut self, value: bool) {
|
||||||
|
self.seeding = value;
|
||||||
|
if !value {
|
||||||
|
self.seeding_started_at_secs = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current share ratio in thousandths, when it is known.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn share_ratio_milli(&self) -> Option<u64> {
|
||||||
|
self.share_ratio_milli
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the current share ratio in thousandths.
|
||||||
|
pub fn set_share_ratio_milli(&mut self, value: Option<u64>) {
|
||||||
|
self.share_ratio_milli = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the total accumulated share time in seconds.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn share_time_secs(&self) -> u64 {
|
||||||
|
self.share_time_secs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overwrites the total accumulated share time in seconds.
|
||||||
|
pub fn set_share_time_secs(&mut self, value: u64) {
|
||||||
|
self.share_time_secs = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds to the accumulated share time using saturating arithmetic.
|
||||||
|
pub fn add_share_time_secs(&mut self, delta: u64) {
|
||||||
|
self.share_time_secs = self.share_time_secs.saturating_add(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the total accumulated seeding time in seconds.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn seeding_time_secs(&self) -> u64 {
|
||||||
|
self.seeding_time_secs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overwrites the accumulated seeding time in seconds.
|
||||||
|
pub fn set_seeding_time_secs(&mut self, value: u64) {
|
||||||
|
self.seeding_time_secs = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds to the accumulated seeding time using saturating arithmetic.
|
||||||
|
pub fn add_seeding_time_secs(&mut self, delta: u64) {
|
||||||
|
self.seeding_time_secs = self.seeding_time_secs.saturating_add(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts seeding bookkeeping at the provided unix timestamp.
|
||||||
|
pub fn start_seeding(&mut self, at_unix_secs: u64) {
|
||||||
|
if self.seeding {
|
||||||
|
self.last_runtime_tick_secs = Some(at_unix_secs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.seeding = true;
|
||||||
|
self.seeding_started_at_secs = Some(at_unix_secs);
|
||||||
|
self.last_runtime_tick_secs = Some(at_unix_secs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops seeding bookkeeping after first accounting for elapsed runtime.
|
||||||
|
pub fn stop_seeding(&mut self, at_unix_secs: u64) {
|
||||||
|
self.tick_runtime(at_unix_secs);
|
||||||
|
self.seeding = false;
|
||||||
|
self.seeding_started_at_secs = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances share and seeding runtime counters to the provided unix timestamp.
|
||||||
|
pub fn tick_runtime(&mut self, now_unix_secs: u64) {
|
||||||
|
let Some(last_tick) = self.last_runtime_tick_secs else {
|
||||||
|
self.last_runtime_tick_secs = Some(now_unix_secs);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let delta = now_unix_secs.saturating_sub(last_tick);
|
||||||
|
self.last_runtime_tick_secs = Some(now_unix_secs);
|
||||||
|
self.share_time_secs = self.share_time_secs.saturating_add(delta);
|
||||||
|
if self.seeding {
|
||||||
|
self.seeding_time_secs = self.seeding_time_secs.saturating_add(delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derives a share ratio in thousandths from uploaded and completed byte counts.
|
||||||
|
#[must_use]
|
||||||
|
pub fn derive_share_ratio_milli(uploaded: u64, completed_base: u64) -> Option<u64> {
|
||||||
|
if completed_base == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
uploaded.saturating_mul(1000).checked_div(completed_base)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes the stored share ratio using the provided length counters.
|
||||||
|
pub fn refresh_share_ratio_from_lengths(&mut self, uploaded: u64, completed_base: u64) {
|
||||||
|
self.share_ratio_milli = Self::derive_share_ratio_milli(uploaded, completed_base);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
/// Source URIs and request headers associated with a download group.
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct RequestContext {
|
||||||
|
/// Optional origin label describing how the request was seeded.
|
||||||
|
pub source: Option<String>,
|
||||||
|
/// Primary URI shown on RPC and CLI surfaces.
|
||||||
|
pub uri: String,
|
||||||
|
/// Full ordered URI list associated with the request.
|
||||||
|
pub uris: Vec<String>,
|
||||||
|
/// Optional HTTP referer applied to outbound requests.
|
||||||
|
pub referer: Option<String>,
|
||||||
|
/// Additional request headers carried with the request.
|
||||||
|
pub headers: Vec<(String, String)>,
|
||||||
|
/// Optional higher-level group identifier from imported session/config data.
|
||||||
|
pub group_id: Option<String>,
|
||||||
|
/// Optional diagnostic or migration note carried with the request.
|
||||||
|
pub note: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RequestContext {
|
||||||
|
/// Builds a request context seeded with one primary URI candidate.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(uri: impl Into<String>) -> Self {
|
||||||
|
let uris = Self::normalize_uris(vec![uri.into()]);
|
||||||
|
let uri = uris.first().cloned().unwrap_or_default();
|
||||||
|
Self {
|
||||||
|
source: None,
|
||||||
|
uri,
|
||||||
|
uris,
|
||||||
|
referer: None,
|
||||||
|
headers: Vec::new(),
|
||||||
|
group_id: None,
|
||||||
|
note: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the primary URI currently exposed for the request.
|
||||||
|
#[must_use]
|
||||||
|
pub fn uri(&self) -> &str {
|
||||||
|
&self.uri
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the ordered URI list associated with the request.
|
||||||
|
#[must_use]
|
||||||
|
pub fn uris(&self) -> &[String] {
|
||||||
|
&self.uris
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the full URI list after normalizing blank entries and duplicates.
|
||||||
|
pub fn replace_uris(&mut self, uris: Vec<String>) {
|
||||||
|
self.uris = Self::normalize_uris(uris);
|
||||||
|
self.sync_primary_uri();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends a URI to the end of the ordered candidate list.
|
||||||
|
pub fn append_uri(&mut self, uri: impl Into<String>) {
|
||||||
|
self.insert_uri(self.uris.len(), uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts or repositions a URI at the requested index.
|
||||||
|
pub fn insert_uri(&mut self, index: usize, uri: impl Into<String>) {
|
||||||
|
let Some(uri) = Self::normalize_uri(uri.into()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut index = index.min(self.uris.len());
|
||||||
|
if let Some(existing_index) = self.uris.iter().position(|current| current == &uri) {
|
||||||
|
self.uris.remove(existing_index);
|
||||||
|
if existing_index < index {
|
||||||
|
index = index.saturating_sub(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.uris.insert(index, uri);
|
||||||
|
self.sync_primary_uri();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the first URI exactly matching the provided string.
|
||||||
|
pub fn remove_first_matching_uri(&mut self, uri: &str) -> bool {
|
||||||
|
if let Some(index) = self.uris.iter().position(|current| current == uri) {
|
||||||
|
self.uris.remove(index);
|
||||||
|
self.sync_primary_uri();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds one request header pair to the context.
|
||||||
|
pub fn push_header(&mut self, key: impl Into<String>, value: impl Into<String>) {
|
||||||
|
self.headers.push((key.into(), value.into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalizes an ordered URI list by dropping blank entries and duplicates.
|
||||||
|
fn normalize_uris(uris: Vec<String>) -> Vec<String> {
|
||||||
|
let mut normalized = Vec::with_capacity(uris.len());
|
||||||
|
let mut seen = BTreeSet::new();
|
||||||
|
for uri in uris {
|
||||||
|
let Some(uri) = Self::normalize_uri(uri) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if seen.insert(uri.clone()) {
|
||||||
|
normalized.push(uri);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `Some(uri)` when the supplied URI text is non-blank after trimming.
|
||||||
|
fn normalize_uri(uri: String) -> Option<String> {
|
||||||
|
(!uri.trim().is_empty()).then_some(uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synchronizes the primary URI field with the first normalized URI entry.
|
||||||
|
fn sync_primary_uri(&mut self) {
|
||||||
|
self.uri = self.uris.first().cloned().unwrap_or_default();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
#![expect(
|
||||||
|
clippy::arithmetic_side_effects,
|
||||||
|
reason = "request state uses compact counter math over bounded scheduler/runtime fields"
|
||||||
|
)]
|
||||||
|
|
||||||
|
pub(super) use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
pub(super) use crate::{
|
||||||
|
options::{OptionKey, OptionPatch, OptionValue},
|
||||||
|
piece::{PieceId, PieceMap, PieceRange, PieceState},
|
||||||
|
runtime::parse_human_size_text,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(super) use super::{
|
||||||
|
BtPeerInfo, BtPeerMutationResult, BtPieceAvailabilityMutationResult, BtPieceAvailabilityUpdate,
|
||||||
|
BtPieceBlockUpdate, BtPieceMutationResult, BtPressureSnapshot, BtRuntimeState,
|
||||||
|
BtRuntimeTickResult, BtShareRuntimeState, DownloadId, DownloadStatus, RequestContext,
|
||||||
|
ResumeState, RetryAttempt, SegmentAssignment, SegmentRuntimeStats, SegmentState,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `BitTorrent` peer snapshot and peer-mutation helpers for a request group.
|
||||||
|
mod bt_peers;
|
||||||
|
/// `BitTorrent` piece availability, verification, and pressure helpers.
|
||||||
|
mod bt_pieces;
|
||||||
|
/// `BitTorrent` share-ratio, share-time, and seeding-time helpers.
|
||||||
|
mod bt_share;
|
||||||
|
/// Core `RequestGroup` data model and field layout.
|
||||||
|
mod model;
|
||||||
|
/// General request-group state mutation, accessors, and option helpers.
|
||||||
|
mod state;
|
||||||
|
|
||||||
|
pub use self::model::RequestGroup;
|
||||||
|
|
||||||
|
impl RequestGroup {
|
||||||
|
/// Returns the byte span covered by a piece after clamping the tail piece to the target length.
|
||||||
|
fn bt_piece_span_length(&self, piece: PieceId) -> u64 {
|
||||||
|
let piece_length = self.piece_length.max(1);
|
||||||
|
let start = u64::from(piece.0).saturating_mul(piece_length);
|
||||||
|
match self.bt_effective_target_length() {
|
||||||
|
Some(target) if target > 0 => target.saturating_sub(start).min(piece_length),
|
||||||
|
_ => piece_length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies a piece-state transition and refreshes completion and share-ratio counters accordingly.
|
||||||
|
fn bt_set_piece_state_and_refresh_completion(
|
||||||
|
&mut self,
|
||||||
|
piece: PieceId,
|
||||||
|
next_state: PieceState,
|
||||||
|
) -> BtPieceMutationResult {
|
||||||
|
let previous = self.piece_state(piece);
|
||||||
|
let span = self.bt_piece_span_length(piece);
|
||||||
|
if previous != Some(next_state) {
|
||||||
|
self.set_piece_state(piece, next_state);
|
||||||
|
}
|
||||||
|
let was_verified = matches!(previous, Some(PieceState::Verified));
|
||||||
|
let is_verified = next_state == PieceState::Verified;
|
||||||
|
let mut delta = 0_i64;
|
||||||
|
let signed_span = i64::try_from(span).unwrap_or(i64::MAX);
|
||||||
|
if previous != Some(next_state) && !was_verified && is_verified {
|
||||||
|
self.add_completed_length(span);
|
||||||
|
delta = signed_span;
|
||||||
|
} else if previous != Some(next_state) && was_verified && !is_verified {
|
||||||
|
self.completed_length = self.completed_length.saturating_sub(span);
|
||||||
|
delta = signed_span.saturating_neg();
|
||||||
|
}
|
||||||
|
self.refresh_bt_share_ratio_from_lengths();
|
||||||
|
BtPieceMutationResult {
|
||||||
|
completed_length_delta: delta,
|
||||||
|
transitioned_to_verified: !was_verified && is_verified,
|
||||||
|
previous_state: previous,
|
||||||
|
next_state,
|
||||||
|
piece_span_length: span,
|
||||||
|
completed_blocks: 0,
|
||||||
|
total_blocks: 0,
|
||||||
|
block_completion_milli: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the final mutation result for a block-progress update after applying the new piece state.
|
||||||
|
fn bt_apply_piece_progress_result(
|
||||||
|
&mut self,
|
||||||
|
update: BtPieceBlockUpdate,
|
||||||
|
next_state: PieceState,
|
||||||
|
) -> BtPieceMutationResult {
|
||||||
|
let mut result =
|
||||||
|
self.bt_set_piece_state_and_refresh_completion(update.piece_id, next_state);
|
||||||
|
result.completed_blocks = update.completed_blocks.min(update.total_blocks);
|
||||||
|
result.total_blocks = update.total_blocks;
|
||||||
|
result.block_completion_milli =
|
||||||
|
Self::bt_block_completion_milli(result.completed_blocks, result.total_blocks);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts completed block counts into a per-thousand completion ratio for UI and RPC reporting.
|
||||||
|
fn bt_block_completion_milli(completed_blocks: u32, total_blocks: u32) -> u64 {
|
||||||
|
if total_blocks == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
u64::from(completed_blocks.min(total_blocks))
|
||||||
|
.saturating_mul(1000)
|
||||||
|
.saturating_div(u64::from(total_blocks))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recomputes the active `BitTorrent` share ratio from the latest uploaded and base lengths.
|
||||||
|
fn refresh_bt_share_ratio_from_lengths(&mut self) {
|
||||||
|
let Some(denominator) = self.bt_share_ratio_base_length() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(share_state) = self.bt_share_state.as_mut() {
|
||||||
|
share_state.refresh_share_ratio_from_lengths(self.upload_length, denominator);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirrors the current peer count into the generic connection counter exposed by the request group.
|
||||||
|
fn sync_bt_num_connections_to_peer_count(&mut self) {
|
||||||
|
let peer_count = self.bt.as_ref().map_or(0, |bt| bt.peers.len());
|
||||||
|
self.num_connections = u32::try_from(peer_count).unwrap_or(u32::MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Produces aggregate peer counters and bandwidth totals after a peer mutation step.
|
||||||
|
fn bt_peer_runtime_stats_with_replaced(&self, replaced_existing: bool) -> BtPeerMutationResult {
|
||||||
|
let Some(bt) = self.bt() else {
|
||||||
|
return BtPeerMutationResult::default();
|
||||||
|
};
|
||||||
|
let peer_count = bt.peers.len();
|
||||||
|
let seeder_count = bt.peers.iter().filter(|peer| peer.seeder).count();
|
||||||
|
BtPeerMutationResult {
|
||||||
|
peer_count,
|
||||||
|
seeder_count,
|
||||||
|
leecher_count: peer_count.saturating_sub(seeder_count),
|
||||||
|
total_download_speed: bt.peers.iter().map(|peer| peer.download_speed).sum(),
|
||||||
|
total_upload_speed: bt.peers.iter().map(|peer| peer.upload_speed).sum(),
|
||||||
|
replaced_existing,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshots the current `BitTorrent` runtime counters for periodic scheduler and RPC updates.
|
||||||
|
fn bt_runtime_tick_result(&self) -> BtRuntimeTickResult {
|
||||||
|
let share_state = self.bt_share_state();
|
||||||
|
BtRuntimeTickResult {
|
||||||
|
completed_length: self.completed_length(),
|
||||||
|
upload_length: self.upload_length(),
|
||||||
|
download_speed: self.download_speed(),
|
||||||
|
upload_speed: self.upload_speed(),
|
||||||
|
num_connections: self.num_connections(),
|
||||||
|
seeding: self.bt_is_true_seeding(),
|
||||||
|
share_ratio_milli: self.bt_share_ratio_milli(),
|
||||||
|
share_time_secs: share_state.map_or(0, BtShareRuntimeState::share_time_secs),
|
||||||
|
seeding_time_secs: share_state.map_or(0, BtShareRuntimeState::seeding_time_secs),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
#![expect(
|
||||||
|
missing_docs,
|
||||||
|
reason = "RequestGroup BT peer helpers keep the established compatibility facade while isolating peer-state logic"
|
||||||
|
)]
|
||||||
|
|
||||||
|
use super::{BtPeerInfo, BtPeerMutationResult, BtRuntimeState, RequestGroup};
|
||||||
|
|
||||||
|
impl RequestGroup {
|
||||||
|
#[must_use]
|
||||||
|
pub fn bt(&self) -> Option<&BtRuntimeState> {
|
||||||
|
self.bt.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bt_mut(&mut self) -> Option<&mut BtRuntimeState> {
|
||||||
|
self.bt.as_mut()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_bt(&mut self, bt: BtRuntimeState) {
|
||||||
|
let had_bt = self.bt.is_some();
|
||||||
|
self.bt = Some(bt);
|
||||||
|
if had_bt {
|
||||||
|
self.refresh_bt_share_ratio_from_lengths();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_bt(&mut self) {
|
||||||
|
self.bt = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn replace_bt_peer_snapshot(&mut self, peers: Vec<BtPeerInfo>) -> BtPeerMutationResult {
|
||||||
|
let Some(bt) = self.bt_mut() else {
|
||||||
|
return BtPeerMutationResult::default();
|
||||||
|
};
|
||||||
|
bt.peers = peers;
|
||||||
|
self.sync_bt_num_connections_to_peer_count();
|
||||||
|
self.bt_peer_runtime_stats()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn bt_peer_runtime_stats(&self) -> BtPeerMutationResult {
|
||||||
|
self.bt_peer_runtime_stats_with_replaced(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_bt_peer_update(&mut self, peer: BtPeerInfo) -> BtPeerMutationResult {
|
||||||
|
let Some(bt) = self.bt_mut() else {
|
||||||
|
return BtPeerMutationResult::default();
|
||||||
|
};
|
||||||
|
let key_peer_id = peer.peer_id.as_deref();
|
||||||
|
let key_ip = peer.ip.as_str();
|
||||||
|
let key_port = peer.port;
|
||||||
|
let replaced_existing = if let Some(existing) = bt.peers.iter_mut().find(|candidate| {
|
||||||
|
(key_peer_id.is_some() && candidate.peer_id.as_deref() == key_peer_id)
|
||||||
|
|| (candidate.ip == key_ip && candidate.port == key_port)
|
||||||
|
}) {
|
||||||
|
*existing = peer;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
bt.peers.push(peer);
|
||||||
|
false
|
||||||
|
};
|
||||||
|
self.sync_bt_num_connections_to_peer_count();
|
||||||
|
self.bt_peer_runtime_stats_with_replaced(replaced_existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_bt_peer(
|
||||||
|
&mut self,
|
||||||
|
peer_id: Option<&str>,
|
||||||
|
ip: Option<&str>,
|
||||||
|
port: Option<u16>,
|
||||||
|
) -> bool {
|
||||||
|
let Some(bt) = self.bt_mut() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let before = bt.peers.len();
|
||||||
|
bt.peers.retain(|peer| {
|
||||||
|
let peer_id_match = peer_id.is_some() && peer.peer_id.as_deref() == peer_id;
|
||||||
|
let endpoint_match = matches!(
|
||||||
|
(ip, port),
|
||||||
|
(Some(expected_ip), Some(expected_port))
|
||||||
|
if peer.ip == expected_ip && peer.port == expected_port
|
||||||
|
);
|
||||||
|
!(peer_id_match || endpoint_match)
|
||||||
|
});
|
||||||
|
let removed = bt.peers.len() != before;
|
||||||
|
if removed {
|
||||||
|
self.sync_bt_num_connections_to_peer_count();
|
||||||
|
}
|
||||||
|
removed
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn bt_selected_file_count(&self) -> Option<usize> {
|
||||||
|
self.bt.as_ref().map(BtRuntimeState::selected_file_count)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn bt_selected_total_length(&self) -> Option<u64> {
|
||||||
|
self.bt.as_ref().map(BtRuntimeState::selected_total_length)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn bt_has_selected_files(&self) -> bool {
|
||||||
|
self.bt
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(BtRuntimeState::has_selected_files)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
#![expect(
|
||||||
|
missing_docs,
|
||||||
|
reason = "RequestGroup BT piece helpers keep piece-selection behavior stable while isolating swarm-facing state logic"
|
||||||
|
)]
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
BTreeSet, BtPieceAvailabilityMutationResult, BtPieceAvailabilityUpdate, BtPieceBlockUpdate,
|
||||||
|
BtPieceMutationResult, BtPressureSnapshot, PieceId, PieceState, RequestGroup,
|
||||||
|
};
|
||||||
|
|
||||||
|
impl RequestGroup {
|
||||||
|
#[must_use]
|
||||||
|
pub fn piece_availability(&self) -> &std::collections::BTreeMap<PieceId, u32> {
|
||||||
|
&self.piece_availability
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_bt_piece_availability_update(
|
||||||
|
&mut self,
|
||||||
|
update: BtPieceAvailabilityUpdate,
|
||||||
|
) -> BtPieceAvailabilityMutationResult {
|
||||||
|
if update.peers_with_piece == 0 {
|
||||||
|
self.piece_availability.remove(&update.piece_id);
|
||||||
|
} else {
|
||||||
|
self.piece_availability
|
||||||
|
.insert(update.piece_id, update.peers_with_piece);
|
||||||
|
}
|
||||||
|
let piece_state = self.piece_state(update.piece_id);
|
||||||
|
BtPieceAvailabilityMutationResult {
|
||||||
|
peers_with_piece: update.peers_with_piece,
|
||||||
|
available_piece_count: self.bt_available_piece_count(),
|
||||||
|
piece_is_requestable: matches!(
|
||||||
|
piece_state,
|
||||||
|
Some(PieceState::Pending | PieceState::Queued | PieceState::Missing)
|
||||||
|
) && update.peers_with_piece > 0,
|
||||||
|
piece_is_verified: piece_state == Some(PieceState::Verified),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_piece_availability(&mut self) {
|
||||||
|
self.piece_availability.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_bt_piece_block_update(
|
||||||
|
&mut self,
|
||||||
|
update: BtPieceBlockUpdate,
|
||||||
|
) -> BtPieceMutationResult {
|
||||||
|
if update.total_blocks == 0 {
|
||||||
|
return self.bt_apply_piece_progress_result(update, PieceState::Missing);
|
||||||
|
}
|
||||||
|
if update.completed_blocks >= update.total_blocks {
|
||||||
|
return self.bt_apply_piece_progress_result(update, PieceState::Verified);
|
||||||
|
}
|
||||||
|
if update.completed_blocks > 0 {
|
||||||
|
return self.bt_apply_piece_progress_result(update, PieceState::Downloading);
|
||||||
|
}
|
||||||
|
self.bt_apply_piece_progress_result(update, PieceState::Queued)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mark_bt_piece_verified(&mut self, piece: PieceId) -> BtPieceMutationResult {
|
||||||
|
self.bt_set_piece_state_and_refresh_completion(piece, PieceState::Verified)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mark_bt_piece_missing(&mut self, piece: PieceId) -> BtPieceMutationResult {
|
||||||
|
self.bt_set_piece_state_and_refresh_completion(piece, PieceState::Missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mark_bt_piece_downloading(&mut self, piece: PieceId) -> BtPieceMutationResult {
|
||||||
|
self.bt_set_piece_state_and_refresh_completion(piece, PieceState::Downloading)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn piece_state_counts(&self) -> (usize, usize, usize, usize, usize, usize) {
|
||||||
|
let mut pending = 0;
|
||||||
|
let mut queued = 0;
|
||||||
|
let mut downloading = 0;
|
||||||
|
let mut verified = 0;
|
||||||
|
let mut missing = 0;
|
||||||
|
let mut skipped = 0;
|
||||||
|
for (_, state) in self.pieces.iter() {
|
||||||
|
match state {
|
||||||
|
PieceState::Pending => pending += 1,
|
||||||
|
PieceState::Queued => queued += 1,
|
||||||
|
PieceState::Downloading => downloading += 1,
|
||||||
|
PieceState::Verified => verified += 1,
|
||||||
|
PieceState::Missing => missing += 1,
|
||||||
|
PieceState::Skipped => skipped += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(pending, queued, downloading, verified, missing, skipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn bt_verified_piece_count(&self) -> usize {
|
||||||
|
self.pieces
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, state)| matches!(state, PieceState::Verified))
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn bt_requestable_piece_ids(&self, endgame: bool, limit: usize) -> Vec<PieceId> {
|
||||||
|
if limit == 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let mut primary = BTreeSet::new();
|
||||||
|
let mut endgame_candidates = BTreeSet::new();
|
||||||
|
for (piece_id, state) in self.pieces.iter() {
|
||||||
|
match state {
|
||||||
|
PieceState::Pending | PieceState::Queued | PieceState::Missing => {
|
||||||
|
primary.insert(*piece_id);
|
||||||
|
}
|
||||||
|
PieceState::Downloading if endgame => {
|
||||||
|
endgame_candidates.insert(*piece_id);
|
||||||
|
}
|
||||||
|
PieceState::Verified | PieceState::Skipped | PieceState::Downloading => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut selected = Vec::with_capacity(limit);
|
||||||
|
selected.extend(primary.into_iter().take(limit));
|
||||||
|
if selected.len() < limit {
|
||||||
|
selected.extend(endgame_candidates.into_iter().take(limit - selected.len()));
|
||||||
|
}
|
||||||
|
selected
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn bt_available_piece_count(&self) -> usize {
|
||||||
|
self.piece_availability
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, peers)| **peers > 0)
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn bt_pressure_snapshot(&self) -> Option<BtPressureSnapshot> {
|
||||||
|
self.bt.as_ref()?;
|
||||||
|
let piece_count = self.pieces.iter().count();
|
||||||
|
let requestable = self.bt_requestable_piece_ids(false, piece_count.max(1));
|
||||||
|
let (_, queued, downloading, _, _, _) = self.piece_state_counts();
|
||||||
|
let peer_stats = self.bt_peer_runtime_stats();
|
||||||
|
|
||||||
|
let mut available_requestable_pieces = 0;
|
||||||
|
let mut scarce_requestable_pieces = 0;
|
||||||
|
for piece_id in &requestable {
|
||||||
|
let peers = self.piece_availability.get(piece_id).copied().unwrap_or(0);
|
||||||
|
if peers > 0 {
|
||||||
|
available_requestable_pieces += 1;
|
||||||
|
}
|
||||||
|
if peers > 0 && peers <= 1 {
|
||||||
|
scarce_requestable_pieces += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(BtPressureSnapshot {
|
||||||
|
total_pieces: piece_count,
|
||||||
|
requestable_pieces: requestable.len(),
|
||||||
|
active_pieces: downloading.saturating_add(queued),
|
||||||
|
available_requestable_pieces,
|
||||||
|
scarce_requestable_pieces,
|
||||||
|
peer_count: peer_stats.peer_count,
|
||||||
|
seeder_count: peer_stats.seeder_count,
|
||||||
|
leecher_count: peer_stats.leecher_count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
#![expect(
|
||||||
|
missing_docs,
|
||||||
|
reason = "RequestGroup BT share/runtime helpers keep seeding counters and ratio semantics stable while isolating runtime bookkeeping"
|
||||||
|
)]
|
||||||
|
|
||||||
|
use super::{BtRuntimeTickResult, BtShareRuntimeState, RequestGroup};
|
||||||
|
|
||||||
|
impl RequestGroup {
|
||||||
|
#[must_use]
|
||||||
|
pub fn bt_share_state(&self) -> Option<&BtShareRuntimeState> {
|
||||||
|
self.bt_share_state.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bt_share_state_mut(&mut self) -> Option<&mut BtShareRuntimeState> {
|
||||||
|
self.bt_share_state.as_mut()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_bt_share_state(&mut self, state: BtShareRuntimeState) {
|
||||||
|
self.bt_share_state = Some(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_bt_share_state(&mut self) {
|
||||||
|
self.bt_share_state = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn bt_is_seeding(&self) -> bool {
|
||||||
|
self.bt_share_state
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(BtShareRuntimeState::is_seeding)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn bt_share_ratio_milli(&self) -> Option<u64> {
|
||||||
|
self.bt_share_state
|
||||||
|
.as_ref()
|
||||||
|
.and_then(BtShareRuntimeState::share_ratio_milli)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn bt_share_time_secs(&self) -> Option<u64> {
|
||||||
|
self.bt_share_state
|
||||||
|
.as_ref()
|
||||||
|
.map(BtShareRuntimeState::share_time_secs)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn bt_seeding_time_secs(&self) -> Option<u64> {
|
||||||
|
self.bt_share_state
|
||||||
|
.as_ref()
|
||||||
|
.map(BtShareRuntimeState::seeding_time_secs)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn bt_share_ratio_base_length(&self) -> Option<u64> {
|
||||||
|
let target = self.bt_effective_target_length()?;
|
||||||
|
if target == 0 {
|
||||||
|
return Some(0);
|
||||||
|
}
|
||||||
|
Some(target.max(self.completed_length))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn bt_effective_target_length(&self) -> Option<u64> {
|
||||||
|
let bt = self.bt.as_ref()?;
|
||||||
|
if bt.metadata_only {
|
||||||
|
return Some(0);
|
||||||
|
}
|
||||||
|
let selected_or_all = bt.selected_or_all_total_length();
|
||||||
|
if selected_or_all == 0 {
|
||||||
|
return Some(self.total_length);
|
||||||
|
}
|
||||||
|
if self.total_length == 0 {
|
||||||
|
Some(selected_or_all)
|
||||||
|
} else {
|
||||||
|
Some(selected_or_all.min(self.total_length))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn bt_remaining_work_length(&self) -> Option<u64> {
|
||||||
|
let target = self.bt_effective_target_length()?;
|
||||||
|
Some(target.saturating_sub(self.completed_length.min(target)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn bt_is_true_seeding(&self) -> bool {
|
||||||
|
self.bt_is_seeding()
|
||||||
|
&& matches!(self.bt_effective_target_length(), Some(target) if target > 0)
|
||||||
|
&& self.bt_remaining_work_length() == Some(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_bt_share_state(&mut self) -> Option<&mut BtShareRuntimeState> {
|
||||||
|
self.bt.as_ref()?;
|
||||||
|
if self.bt_share_state.is_none() {
|
||||||
|
self.bt_share_state = Some(BtShareRuntimeState::default());
|
||||||
|
}
|
||||||
|
self.bt_share_state.as_mut()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn refresh_bt_share_runtime(&mut self) -> BtRuntimeTickResult {
|
||||||
|
self.refresh_bt_share_ratio_from_lengths();
|
||||||
|
self.bt_runtime_tick_result()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_bt_seeding_state(
|
||||||
|
&mut self,
|
||||||
|
seeding: bool,
|
||||||
|
at_unix_secs: Option<u64>,
|
||||||
|
) -> BtRuntimeTickResult {
|
||||||
|
if let Some(share_state) = self.ensure_bt_share_state() {
|
||||||
|
match (seeding, at_unix_secs) {
|
||||||
|
(true, Some(now)) => share_state.start_seeding(now),
|
||||||
|
(false, Some(now)) => share_state.stop_seeding(now),
|
||||||
|
(value, None) => share_state.set_seeding(value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.refresh_bt_share_runtime()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tick_bt_runtime_clock(
|
||||||
|
&mut self,
|
||||||
|
now_unix_secs: u64,
|
||||||
|
seeding: bool,
|
||||||
|
) -> BtRuntimeTickResult {
|
||||||
|
if let Some(share_state) = self.ensure_bt_share_state() {
|
||||||
|
if share_state.is_seeding() != seeding {
|
||||||
|
if seeding {
|
||||||
|
share_state.start_seeding(now_unix_secs);
|
||||||
|
} else {
|
||||||
|
share_state.stop_seeding(now_unix_secs);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
share_state.tick_runtime(now_unix_secs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.refresh_bt_share_runtime()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::too_many_arguments,
|
||||||
|
reason = "BT runtime tick input mirrors the grouped counters provided by the dispatcher"
|
||||||
|
)]
|
||||||
|
pub fn apply_bt_runtime_tick(
|
||||||
|
&mut self,
|
||||||
|
downloaded_delta: u64,
|
||||||
|
uploaded_delta: u64,
|
||||||
|
download_speed: u64,
|
||||||
|
upload_speed: u64,
|
||||||
|
share_time_delta_secs: u64,
|
||||||
|
seeding_time_delta_secs: u64,
|
||||||
|
seeding: bool,
|
||||||
|
num_connections: Option<u32>,
|
||||||
|
) -> BtRuntimeTickResult {
|
||||||
|
if downloaded_delta > 0 {
|
||||||
|
self.add_completed_length(downloaded_delta);
|
||||||
|
}
|
||||||
|
if uploaded_delta > 0 {
|
||||||
|
self.set_upload_length(self.upload_length().saturating_add(uploaded_delta));
|
||||||
|
}
|
||||||
|
self.set_download_speed(download_speed);
|
||||||
|
self.set_upload_speed(upload_speed);
|
||||||
|
if let Some(num_connections) = num_connections {
|
||||||
|
self.set_num_connections(num_connections);
|
||||||
|
}
|
||||||
|
if let Some(share_state) = self.ensure_bt_share_state() {
|
||||||
|
share_state.set_seeding(seeding);
|
||||||
|
if share_time_delta_secs > 0 {
|
||||||
|
share_state.add_share_time_secs(share_time_delta_secs);
|
||||||
|
}
|
||||||
|
if seeding_time_delta_secs > 0 {
|
||||||
|
share_state.add_seeding_time_secs(seeding_time_delta_secs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.refresh_bt_share_runtime()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
use super::{
|
||||||
|
BTreeMap, BtRuntimeState, BtShareRuntimeState, DownloadId, DownloadStatus, OptionPatch,
|
||||||
|
PieceId, PieceMap, RequestContext, ResumeState, RetryAttempt, SegmentAssignment,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Canonical in-memory request group model used by the runtime and RPC layers.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct RequestGroup {
|
||||||
|
/// Stable identifier exposed on the RPC surface.
|
||||||
|
pub(super) gid: DownloadId,
|
||||||
|
/// Source URIs, headers, and request metadata for the transfer.
|
||||||
|
pub(super) context: RequestContext,
|
||||||
|
/// Current lifecycle state of the transfer.
|
||||||
|
pub(super) status: DownloadStatus,
|
||||||
|
/// Piece map tracking completed and pending ranges.
|
||||||
|
pub(super) pieces: PieceMap,
|
||||||
|
/// Per-download option overrides layered over runtime defaults.
|
||||||
|
pub(super) options: OptionPatch,
|
||||||
|
/// Total expected payload length in bytes.
|
||||||
|
pub(super) total_length: u64,
|
||||||
|
/// Piece size used for segmented scheduling and control-file state.
|
||||||
|
pub(super) piece_length: u64,
|
||||||
|
/// Total uploaded payload length in bytes.
|
||||||
|
pub(super) upload_length: u64,
|
||||||
|
/// Last observed upload speed in bytes per second.
|
||||||
|
pub(super) upload_speed: u64,
|
||||||
|
/// Last observed download speed in bytes per second.
|
||||||
|
pub(super) download_speed: u64,
|
||||||
|
/// Number of currently active source connections.
|
||||||
|
pub(super) num_connections: u32,
|
||||||
|
/// Verified completed payload length in bytes.
|
||||||
|
pub(super) completed_length: u64,
|
||||||
|
/// Monotonic sequence used to order stopped downloads for RPC listing.
|
||||||
|
pub(super) stopped_sequence: Option<u64>,
|
||||||
|
/// Number of retry cycles already consumed by this request group.
|
||||||
|
pub(super) retry_count: u32,
|
||||||
|
/// Recorded retry attempts for diagnostics and RPC status reporting.
|
||||||
|
pub(super) retry_attempts: Vec<RetryAttempt>,
|
||||||
|
/// Resume metadata recovered from storage or prior runtime state.
|
||||||
|
pub(super) resume_state: Option<ResumeState>,
|
||||||
|
/// DHT token cached for the next announce-peer exchange.
|
||||||
|
pub(super) dht_token: Option<Vec<u8>>,
|
||||||
|
/// Planned and active segment assignments for split transfers.
|
||||||
|
pub(super) segment_assignments: Vec<SegmentAssignment>,
|
||||||
|
/// Availability counters for each piece observed from swarm peers.
|
||||||
|
pub(super) piece_availability: BTreeMap<PieceId, u32>,
|
||||||
|
/// BitTorrent-specific runtime state when the group is BT-backed.
|
||||||
|
pub(super) bt: Option<BtRuntimeState>,
|
||||||
|
/// Seeding and share-ratio counters when the BT runtime is active.
|
||||||
|
pub(super) bt_share_state: Option<BtShareRuntimeState>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
#![expect(
|
||||||
|
missing_docs,
|
||||||
|
reason = "RequestGroup state accessors intentionally keep the aria2-compatible surface flat and stable"
|
||||||
|
)]
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
DownloadId, DownloadStatus, OptionKey, OptionPatch, OptionValue, PieceId, PieceMap, PieceRange,
|
||||||
|
PieceState, RequestContext, RequestGroup, ResumeState, RetryAttempt, SegmentAssignment,
|
||||||
|
SegmentRuntimeStats, SegmentState, parse_human_size_text,
|
||||||
|
};
|
||||||
|
|
||||||
|
impl RequestGroup {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(gid: DownloadId, uri: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
gid,
|
||||||
|
context: RequestContext::new(uri),
|
||||||
|
status: DownloadStatus::Waiting,
|
||||||
|
pieces: PieceMap::new(),
|
||||||
|
options: OptionPatch::new(),
|
||||||
|
total_length: 0,
|
||||||
|
piece_length: 0,
|
||||||
|
upload_length: 0,
|
||||||
|
upload_speed: 0,
|
||||||
|
download_speed: 0,
|
||||||
|
num_connections: 0,
|
||||||
|
completed_length: 0,
|
||||||
|
stopped_sequence: None,
|
||||||
|
retry_count: 0,
|
||||||
|
retry_attempts: Vec::new(),
|
||||||
|
resume_state: None,
|
||||||
|
dht_token: None,
|
||||||
|
segment_assignments: Vec::new(),
|
||||||
|
piece_availability: std::collections::BTreeMap::new(),
|
||||||
|
bt: None,
|
||||||
|
bt_share_state: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_context(gid: DownloadId, context: RequestContext) -> Self {
|
||||||
|
let mut context = context;
|
||||||
|
let uris = if context.uris.is_empty() {
|
||||||
|
vec![context.uri.clone()]
|
||||||
|
} else {
|
||||||
|
std::mem::take(&mut context.uris)
|
||||||
|
};
|
||||||
|
context.replace_uris(uris);
|
||||||
|
Self {
|
||||||
|
gid,
|
||||||
|
context,
|
||||||
|
status: DownloadStatus::Waiting,
|
||||||
|
pieces: PieceMap::new(),
|
||||||
|
options: OptionPatch::new(),
|
||||||
|
total_length: 0,
|
||||||
|
piece_length: 0,
|
||||||
|
upload_length: 0,
|
||||||
|
upload_speed: 0,
|
||||||
|
download_speed: 0,
|
||||||
|
num_connections: 0,
|
||||||
|
completed_length: 0,
|
||||||
|
stopped_sequence: None,
|
||||||
|
retry_count: 0,
|
||||||
|
retry_attempts: Vec::new(),
|
||||||
|
resume_state: None,
|
||||||
|
dht_token: None,
|
||||||
|
segment_assignments: Vec::new(),
|
||||||
|
piece_availability: std::collections::BTreeMap::new(),
|
||||||
|
bt: None,
|
||||||
|
bt_share_state: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn gid(&self) -> DownloadId {
|
||||||
|
self.gid
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn uri(&self) -> &str {
|
||||||
|
self.context.uri()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn uris(&self) -> &[String] {
|
||||||
|
self.context.uris()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn status(&self) -> &DownloadStatus {
|
||||||
|
&self.status
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn context(&self) -> &RequestContext {
|
||||||
|
&self.context
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn context_mut(&mut self) -> &mut RequestContext {
|
||||||
|
&mut self.context
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_status(&mut self, status: DownloadStatus) {
|
||||||
|
self.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn piece_map(&self) -> &PieceMap {
|
||||||
|
&self.pieces
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn piece_map_mut(&mut self) -> &mut PieceMap {
|
||||||
|
&mut self.pieces
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_piece_state(&mut self, piece: PieceId, state: PieceState) {
|
||||||
|
self.pieces.set_state(piece, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn piece_state(&self, piece: PieceId) -> Option<PieceState> {
|
||||||
|
self.pieces.get(&piece)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn options(&self) -> &OptionPatch {
|
||||||
|
&self.options
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn options_mut(&mut self) -> &mut OptionPatch {
|
||||||
|
&mut self.options
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_option(&mut self, key: impl Into<OptionKey>, value: impl Into<OptionValue>) {
|
||||||
|
self.options.insert(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn option_limit(&self, key: &str) -> Option<u64> {
|
||||||
|
parse_option_limit(self.options.get(&OptionKey::new(key)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn total_length(&self) -> u64 {
|
||||||
|
self.total_length
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_total_length(&mut self, value: u64) {
|
||||||
|
self.total_length = value;
|
||||||
|
self.refresh_bt_share_ratio_from_lengths();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn piece_length(&self) -> u64 {
|
||||||
|
self.piece_length
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_piece_length(&mut self, value: u64) {
|
||||||
|
self.piece_length = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn upload_length(&self) -> u64 {
|
||||||
|
self.upload_length
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_upload_length(&mut self, value: u64) {
|
||||||
|
self.upload_length = value;
|
||||||
|
self.refresh_bt_share_ratio_from_lengths();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn upload_speed(&self) -> u64 {
|
||||||
|
self.upload_speed
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_upload_speed(&mut self, value: u64) {
|
||||||
|
self.upload_speed = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn download_speed(&self) -> u64 {
|
||||||
|
self.download_speed
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_download_speed(&mut self, value: u64) {
|
||||||
|
self.download_speed = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn num_connections(&self) -> u32 {
|
||||||
|
self.num_connections
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_num_connections(&mut self, value: u32) {
|
||||||
|
self.num_connections = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn completed_length(&self) -> u64 {
|
||||||
|
self.completed_length
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_completed_length(&mut self, value: u64) {
|
||||||
|
self.completed_length = value;
|
||||||
|
self.refresh_bt_share_ratio_from_lengths();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_completed_length(&mut self, delta: u64) {
|
||||||
|
self.completed_length = self.completed_length.saturating_add(delta);
|
||||||
|
self.refresh_bt_share_ratio_from_lengths();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn stopped_sequence(&self) -> Option<u64> {
|
||||||
|
self.stopped_sequence
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_stopped_sequence(&mut self, value: Option<u64>) {
|
||||||
|
self.stopped_sequence = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub const fn retry_count(&self) -> u32 {
|
||||||
|
self.retry_count
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_retry_count(&mut self, value: u32) {
|
||||||
|
self.retry_count = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn increment_retry_count(&mut self) {
|
||||||
|
self.retry_count = self.retry_count.saturating_add(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn retry_attempts(&self) -> &[RetryAttempt] {
|
||||||
|
&self.retry_attempts
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn retry_attempts_mut(&mut self) -> &mut Vec<RetryAttempt> {
|
||||||
|
&mut self.retry_attempts
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_retry_attempts(&mut self, attempts: Vec<RetryAttempt>) {
|
||||||
|
self.retry_attempts = attempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_retry_attempt(&mut self, attempt: RetryAttempt) {
|
||||||
|
self.retry_attempts.push(attempt);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_retry_attempts(&mut self) {
|
||||||
|
self.retry_attempts.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn resume_state(&self) -> Option<&ResumeState> {
|
||||||
|
self.resume_state.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resume_state_mut(&mut self) -> Option<&mut ResumeState> {
|
||||||
|
self.resume_state.as_mut()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_resume_state(&mut self, state: ResumeState) {
|
||||||
|
self.resume_state = Some(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_resume_state(&mut self) {
|
||||||
|
self.resume_state = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn dht_token(&self) -> Option<&[u8]> {
|
||||||
|
self.dht_token.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_dht_token(&mut self, token: Option<Vec<u8>>) {
|
||||||
|
self.dht_token = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn segment_assignments(&self) -> &[SegmentAssignment] {
|
||||||
|
&self.segment_assignments
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn segment_assignments_mut(&mut self) -> &mut Vec<SegmentAssignment> {
|
||||||
|
&mut self.segment_assignments
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_segment_assignments(&mut self, assignments: Vec<SegmentAssignment>) {
|
||||||
|
self.num_connections = u32::try_from(assignments.len()).unwrap_or(u32::MAX);
|
||||||
|
self.segment_assignments = assignments;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_segment_assignments(&mut self) {
|
||||||
|
self.num_connections = 0;
|
||||||
|
self.segment_assignments.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn segment_runtime_stats(&self) -> SegmentRuntimeStats {
|
||||||
|
let mut stats = SegmentRuntimeStats::default();
|
||||||
|
let mut covered_start: Option<u64> = None;
|
||||||
|
let mut covered_end: Option<u64> = None;
|
||||||
|
|
||||||
|
for assignment in &self.segment_assignments {
|
||||||
|
stats.segment_count += 1;
|
||||||
|
match assignment.state {
|
||||||
|
SegmentState::Active => stats.active_count += 1,
|
||||||
|
SegmentState::Retrying => stats.retrying_count += 1,
|
||||||
|
SegmentState::Complete => stats.complete_count += 1,
|
||||||
|
SegmentState::Planned => {}
|
||||||
|
}
|
||||||
|
stats.planned_bytes = stats.planned_bytes.saturating_add(assignment.range.len());
|
||||||
|
stats.completed_bytes = stats
|
||||||
|
.completed_bytes
|
||||||
|
.saturating_add(assignment.completed_length.min(assignment.range.len()));
|
||||||
|
stats.remaining_bytes = stats
|
||||||
|
.remaining_bytes
|
||||||
|
.saturating_add(assignment.remaining_length());
|
||||||
|
covered_start = Some(covered_start.map_or(assignment.range.start, |start| {
|
||||||
|
start.min(assignment.range.start)
|
||||||
|
}));
|
||||||
|
covered_end =
|
||||||
|
Some(covered_end.map_or(assignment.range.end, |end| end.max(assignment.range.end)));
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.covered_range = covered_start
|
||||||
|
.zip(covered_end)
|
||||||
|
.map(|(start, end)| PieceRange::new(start, end));
|
||||||
|
stats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses positive numeric option values from integer or human-size option forms.
|
||||||
|
fn parse_option_limit(value: Option<&OptionValue>) -> Option<u64> {
|
||||||
|
match value {
|
||||||
|
Some(OptionValue::UInt(value)) => (*value > 0).then_some(*value),
|
||||||
|
Some(OptionValue::Int(value)) => u64::try_from(*value).ok().filter(|value| *value > 0),
|
||||||
|
Some(OptionValue::Text(value)) => parse_human_size_text(value).filter(|limit| *limit > 0),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
use std::fmt::{Display, Formatter};
|
||||||
|
|
||||||
|
use crate::piece::PieceId;
|
||||||
|
|
||||||
|
/// Stable identifier for a download group.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||||
|
pub struct DownloadId(u64);
|
||||||
|
|
||||||
|
impl DownloadId {
|
||||||
|
/// Wraps the raw numeric identifier used internally and on the RPC surface.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(raw: u64) -> Self {
|
||||||
|
Self(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the raw numeric identifier.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn as_u64(self) -> u64 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the hexadecimal GID representation used by aria2 RPC clients.
|
||||||
|
#[must_use]
|
||||||
|
pub fn parse_hex(raw: &str) -> Option<Self> {
|
||||||
|
u64::from_str_radix(raw, 16).ok().map(Self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for DownloadId {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{:016x}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User-visible lifecycle state for a download group.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum DownloadStatus {
|
||||||
|
/// The group is actively transferring data.
|
||||||
|
Active,
|
||||||
|
/// The group is queued and waiting to start.
|
||||||
|
Waiting,
|
||||||
|
/// The group is paused by user or scheduler action.
|
||||||
|
Paused,
|
||||||
|
/// The group stopped because the last attempt failed.
|
||||||
|
Error,
|
||||||
|
/// The group finished successfully.
|
||||||
|
Complete,
|
||||||
|
/// The group was removed from runtime state.
|
||||||
|
Removed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DownloadStatus {
|
||||||
|
/// Returns the lowercase RPC status token expected by aria2-compatible clients.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn as_rpc_status(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Active => "active",
|
||||||
|
Self::Waiting => "waiting",
|
||||||
|
Self::Paused => "paused",
|
||||||
|
Self::Error => "error",
|
||||||
|
Self::Complete => "complete",
|
||||||
|
Self::Removed => "removed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Captures one retry decision for a request or segment.
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct RetryAttempt {
|
||||||
|
/// Retry attempt ordinal, starting at one for the first retry.
|
||||||
|
pub attempt: u32,
|
||||||
|
/// Byte offset at which the retry resumes.
|
||||||
|
pub offset: u64,
|
||||||
|
/// Optional retry length when the retry only covers one segment.
|
||||||
|
pub length: Option<u64>,
|
||||||
|
/// Human-readable error that triggered the retry.
|
||||||
|
pub error: Option<String>,
|
||||||
|
/// Whether the error is considered recoverable by the scheduler.
|
||||||
|
pub recoverable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RetryAttempt {
|
||||||
|
/// Builds a recoverable retry record for the provided attempt number and offset.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(attempt: u32, offset: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
attempt,
|
||||||
|
offset,
|
||||||
|
length: None,
|
||||||
|
error: None,
|
||||||
|
recoverable: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resume metadata recovered from persisted session state.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct ResumeState {
|
||||||
|
/// Whether this resume snapshot came from persisted storage.
|
||||||
|
pub persisted: bool,
|
||||||
|
/// Byte offset from which the resumed transfer should continue.
|
||||||
|
pub resume_offset: u64,
|
||||||
|
/// Verified payload length recovered from prior state, if known.
|
||||||
|
pub validated_length: Option<u64>,
|
||||||
|
/// Optional piece cursor used to continue segmented scheduling.
|
||||||
|
pub segment_cursor: Option<PieceId>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,929 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_context_replace_uris_filters_blank_entries_and_duplicates() {
|
||||||
|
let mut context = RequestContext::new(String::new());
|
||||||
|
assert_eq!(context.uri(), "");
|
||||||
|
assert!(context.uris().is_empty());
|
||||||
|
|
||||||
|
context.replace_uris(vec![
|
||||||
|
String::new(),
|
||||||
|
"https://example.org/file.iso".to_owned(),
|
||||||
|
"https://example.org/file.iso".to_owned(),
|
||||||
|
" ".to_owned(),
|
||||||
|
"https://mirror.example.org/file.iso".to_owned(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(context.uri(), "https://example.org/file.iso");
|
||||||
|
assert_eq!(
|
||||||
|
context.uris(),
|
||||||
|
&[
|
||||||
|
"https://example.org/file.iso".to_owned(),
|
||||||
|
"https://mirror.example.org/file.iso".to_owned(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_context_insert_and_append_reposition_existing_uris_without_duplicates() {
|
||||||
|
let mut context = RequestContext::new("https://example.org/file.iso");
|
||||||
|
context.replace_uris(vec![
|
||||||
|
"https://example.org/file.iso".to_owned(),
|
||||||
|
"https://mirror-a.example.org/file.iso".to_owned(),
|
||||||
|
"https://mirror-b.example.org/file.iso".to_owned(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
context.insert_uri(0, "https://mirror-b.example.org/file.iso");
|
||||||
|
assert_eq!(context.uri(), "https://mirror-b.example.org/file.iso");
|
||||||
|
assert_eq!(
|
||||||
|
context.uris(),
|
||||||
|
&[
|
||||||
|
"https://mirror-b.example.org/file.iso".to_owned(),
|
||||||
|
"https://example.org/file.iso".to_owned(),
|
||||||
|
"https://mirror-a.example.org/file.iso".to_owned(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
context.append_uri("https://example.org/file.iso");
|
||||||
|
context.append_uri(" ");
|
||||||
|
assert_eq!(context.uri(), "https://mirror-b.example.org/file.iso");
|
||||||
|
assert_eq!(
|
||||||
|
context.uris(),
|
||||||
|
&[
|
||||||
|
"https://mirror-b.example.org/file.iso".to_owned(),
|
||||||
|
"https://mirror-a.example.org/file.iso".to_owned(),
|
||||||
|
"https://example.org/file.iso".to_owned(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_context_remove_last_uri_clears_primary_uri() {
|
||||||
|
let mut context = RequestContext::new("https://example.org/last.iso");
|
||||||
|
|
||||||
|
assert!(context.remove_first_matching_uri("https://example.org/last.iso"));
|
||||||
|
assert_eq!(context.uri(), "");
|
||||||
|
assert!(context.uris().is_empty());
|
||||||
|
assert!(!context.remove_first_matching_uri("https://example.org/last.iso"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_group_with_context_normalizes_stale_primary_and_uri_list() {
|
||||||
|
let group = RequestGroup::with_context(
|
||||||
|
DownloadId::new(0x77),
|
||||||
|
RequestContext {
|
||||||
|
source: None,
|
||||||
|
uri: "https://stale.example.org/file.iso".to_owned(),
|
||||||
|
uris: vec![
|
||||||
|
String::new(),
|
||||||
|
"https://mirror-a.example.org/file.iso".to_owned(),
|
||||||
|
"https://mirror-a.example.org/file.iso".to_owned(),
|
||||||
|
"https://mirror-b.example.org/file.iso".to_owned(),
|
||||||
|
],
|
||||||
|
referer: None,
|
||||||
|
headers: Vec::new(),
|
||||||
|
group_id: None,
|
||||||
|
note: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(group.uri(), "https://mirror-a.example.org/file.iso");
|
||||||
|
assert_eq!(
|
||||||
|
group.uris(),
|
||||||
|
&[
|
||||||
|
"https://mirror-a.example.org/file.iso".to_owned(),
|
||||||
|
"https://mirror-b.example.org/file.iso".to_owned(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_group_tracks_retry_attempt_history() {
|
||||||
|
let mut group = RequestGroup::new(DownloadId::new(0x1234), "https://example.test/file");
|
||||||
|
|
||||||
|
group.increment_retry_count();
|
||||||
|
group.push_retry_attempt(RetryAttempt {
|
||||||
|
attempt: group.retry_count(),
|
||||||
|
offset: 8192,
|
||||||
|
length: Some(4096),
|
||||||
|
error: Some("connection reset".to_string()),
|
||||||
|
recoverable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(group.retry_count(), 1);
|
||||||
|
let [attempt] = group.retry_attempts() else {
|
||||||
|
panic!("retry_attempts should contain exactly one entry");
|
||||||
|
};
|
||||||
|
assert_eq!(attempt.offset, 8192);
|
||||||
|
assert_eq!(attempt.length, Some(4096));
|
||||||
|
assert_eq!(attempt.error.as_deref(), Some("connection reset"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_group_resume_state_roundtrip() {
|
||||||
|
let mut group = RequestGroup::new(DownloadId::new(0x66), "https://example.test/file");
|
||||||
|
group.set_resume_state(ResumeState {
|
||||||
|
persisted: true,
|
||||||
|
resume_offset: 32768,
|
||||||
|
validated_length: Some(4096),
|
||||||
|
segment_cursor: Some(PieceId(8)),
|
||||||
|
});
|
||||||
|
|
||||||
|
let resume = group.resume_state().expect("resume state should exist");
|
||||||
|
assert!(resume.persisted);
|
||||||
|
assert_eq!(resume.resume_offset, 32768);
|
||||||
|
assert_eq!(resume.validated_length, Some(4096));
|
||||||
|
assert_eq!(resume.segment_cursor, Some(PieceId(8)));
|
||||||
|
|
||||||
|
group.clear_resume_state();
|
||||||
|
assert!(group.resume_state().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_group_dht_token_roundtrip() {
|
||||||
|
let mut group = RequestGroup::new(DownloadId::new(0x67), "magnet:?xt=urn:btih:token");
|
||||||
|
assert!(group.dht_token().is_none());
|
||||||
|
|
||||||
|
group.set_dht_token(Some(b"tok".to_vec()));
|
||||||
|
assert_eq!(group.dht_token(), Some(&b"tok"[..]));
|
||||||
|
|
||||||
|
group.set_dht_token(None);
|
||||||
|
assert!(group.dht_token().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_group_clear_retry_attempts() {
|
||||||
|
let mut group = RequestGroup::new(DownloadId::new(0x9), "https://example.test/file");
|
||||||
|
group.push_retry_attempt(RetryAttempt::new(1, 0));
|
||||||
|
group.push_retry_attempt(RetryAttempt::new(2, 1024));
|
||||||
|
assert_eq!(group.retry_attempts().len(), 2);
|
||||||
|
|
||||||
|
group.clear_retry_attempts();
|
||||||
|
assert!(group.retry_attempts().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_group_tracks_segment_assignments() {
|
||||||
|
let mut group = RequestGroup::new(DownloadId::new(0xa), "https://example.test/file");
|
||||||
|
group.set_segment_assignments(vec![
|
||||||
|
SegmentAssignment::new(0, PieceRange::new(0, 1024)),
|
||||||
|
SegmentAssignment::new(1, PieceRange::new(1024, 2048)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(group.num_connections(), 2);
|
||||||
|
let [_, second_assignment] = group.segment_assignments() else {
|
||||||
|
panic!("segment_assignments should contain exactly two entries");
|
||||||
|
};
|
||||||
|
assert_eq!(second_assignment.range, PieceRange::new(1024, 2048));
|
||||||
|
|
||||||
|
group.clear_segment_assignments();
|
||||||
|
assert_eq!(group.num_connections(), 0);
|
||||||
|
assert!(group.segment_assignments().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_group_bt_runtime_state_roundtrip_with_full_payload() {
|
||||||
|
let mut group = RequestGroup::new(DownloadId::new(0xb), "magnet:?xt=urn:btih:ABCDEF");
|
||||||
|
let bt = BtRuntimeState {
|
||||||
|
info_hash: "0123456789ABCDEF0123456789ABCDEF01234567".to_owned(),
|
||||||
|
name: Some("ubuntu.iso".to_owned()),
|
||||||
|
magnet_uri: Some("magnet:?xt=urn:btih:0123456789ABCDEF0123456789ABCDEF01234567".to_owned()),
|
||||||
|
metadata_only: true,
|
||||||
|
metadata_size: Some(32_768),
|
||||||
|
metadata_extension_ids: BTreeMap::from([("192.0.2.10:51413".to_owned(), 3_u8)]),
|
||||||
|
metadata_piece_payloads: BTreeMap::from([(0_u32, b"metadata-piece-0".to_vec())]),
|
||||||
|
creation_date: Some("2026-05-26T12:00:00Z".to_owned()),
|
||||||
|
comment: Some("bt runtime".to_owned()),
|
||||||
|
dht_nodes: vec!["router.bittorrent.com:6881".to_owned()],
|
||||||
|
files: vec![
|
||||||
|
BtFileInfo {
|
||||||
|
path: "ubuntu.iso".to_owned(),
|
||||||
|
length: 2048,
|
||||||
|
piece_offset: Some(0),
|
||||||
|
selected: true,
|
||||||
|
},
|
||||||
|
BtFileInfo {
|
||||||
|
path: "readme.txt".to_owned(),
|
||||||
|
length: 128,
|
||||||
|
piece_offset: Some(2),
|
||||||
|
selected: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
trackers: vec![
|
||||||
|
BtTrackerInfo {
|
||||||
|
url: "udp://tracker.example.org:6969/announce".to_owned(),
|
||||||
|
tier: Some(0),
|
||||||
|
id: Some("trk-0".to_owned()),
|
||||||
|
seeders: Some(10),
|
||||||
|
leechers: Some(3),
|
||||||
|
},
|
||||||
|
BtTrackerInfo {
|
||||||
|
url: "https://tracker2.example.org/announce".to_owned(),
|
||||||
|
tier: Some(1),
|
||||||
|
id: None,
|
||||||
|
seeders: None,
|
||||||
|
leechers: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
peers: vec![BtPeerInfo {
|
||||||
|
peer_id: Some("-TR3000-ABCDEF123456".to_owned()),
|
||||||
|
ip: "192.0.2.10".to_owned(),
|
||||||
|
port: 51413,
|
||||||
|
client_name: Some("Transmission".to_owned()),
|
||||||
|
interested: true,
|
||||||
|
choked: false,
|
||||||
|
download_speed: 4096,
|
||||||
|
upload_speed: 2048,
|
||||||
|
seeder: false,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
group.set_bt(bt.clone());
|
||||||
|
|
||||||
|
let saved = group.bt().expect("bt runtime state should be set");
|
||||||
|
assert_eq!(saved, &bt);
|
||||||
|
assert_eq!(
|
||||||
|
saved.dht_nodes(),
|
||||||
|
&["router.bittorrent.com:6881".to_owned()]
|
||||||
|
);
|
||||||
|
assert_eq!(saved.files.len(), 2);
|
||||||
|
assert_eq!(saved.trackers.len(), 2);
|
||||||
|
assert_eq!(saved.peers.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_group_bt_runtime_state_mutation_via_bt_mut() {
|
||||||
|
let mut group = RequestGroup::new(DownloadId::new(0xc), "magnet:?xt=urn:btih:AAAA");
|
||||||
|
group.set_bt(BtRuntimeState {
|
||||||
|
info_hash: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_owned(),
|
||||||
|
name: Some("seed".to_owned()),
|
||||||
|
magnet_uri: Some("magnet:?xt=urn:btih:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_owned()),
|
||||||
|
metadata_only: true,
|
||||||
|
metadata_size: None,
|
||||||
|
metadata_extension_ids: BTreeMap::new(),
|
||||||
|
metadata_piece_payloads: BTreeMap::new(),
|
||||||
|
creation_date: None,
|
||||||
|
comment: None,
|
||||||
|
dht_nodes: vec!["dht.transmissionbt.com:6881".to_owned()],
|
||||||
|
files: vec![BtFileInfo {
|
||||||
|
path: "seed.bin".to_owned(),
|
||||||
|
length: 1,
|
||||||
|
piece_offset: Some(0),
|
||||||
|
selected: true,
|
||||||
|
}],
|
||||||
|
trackers: vec![],
|
||||||
|
peers: vec![],
|
||||||
|
});
|
||||||
|
|
||||||
|
let bt = group.bt_mut().expect("bt runtime state should be mutable");
|
||||||
|
bt.metadata_only = false;
|
||||||
|
bt.comment = Some("metadata complete".to_owned());
|
||||||
|
bt.dht_nodes.push("router.utorrent.com:6881".to_owned());
|
||||||
|
bt.files.push(BtFileInfo {
|
||||||
|
path: "extra.bin".to_owned(),
|
||||||
|
length: 512,
|
||||||
|
piece_offset: Some(1),
|
||||||
|
selected: true,
|
||||||
|
});
|
||||||
|
bt.trackers.push(BtTrackerInfo {
|
||||||
|
url: "https://tracker.example.org/announce".to_owned(),
|
||||||
|
tier: Some(0),
|
||||||
|
id: Some("trk-a".to_owned()),
|
||||||
|
seeders: Some(1),
|
||||||
|
leechers: Some(0),
|
||||||
|
});
|
||||||
|
bt.peers.push(BtPeerInfo {
|
||||||
|
peer_id: None,
|
||||||
|
ip: "198.51.100.20".to_owned(),
|
||||||
|
port: 60000,
|
||||||
|
client_name: None,
|
||||||
|
interested: true,
|
||||||
|
choked: true,
|
||||||
|
download_speed: 0,
|
||||||
|
upload_speed: 0,
|
||||||
|
seeder: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
let after = group.bt().expect("bt runtime state should still exist");
|
||||||
|
assert!(!after.metadata_only);
|
||||||
|
assert_eq!(after.comment.as_deref(), Some("metadata complete"));
|
||||||
|
assert_eq!(after.dht_nodes.len(), 2);
|
||||||
|
assert_eq!(after.files.len(), 2);
|
||||||
|
assert_eq!(after.trackers.len(), 1);
|
||||||
|
assert_eq!(after.peers.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_group_bt_runtime_state_can_be_cleared() {
|
||||||
|
let mut group = RequestGroup::new(DownloadId::new(0xd), "magnet:?xt=urn:btih:BBBB");
|
||||||
|
group.set_bt(BtRuntimeState {
|
||||||
|
info_hash: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_owned(),
|
||||||
|
name: None,
|
||||||
|
magnet_uri: Some("magnet:?xt=urn:btih:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".to_owned()),
|
||||||
|
metadata_only: true,
|
||||||
|
metadata_size: None,
|
||||||
|
metadata_extension_ids: BTreeMap::new(),
|
||||||
|
metadata_piece_payloads: BTreeMap::new(),
|
||||||
|
creation_date: None,
|
||||||
|
comment: None,
|
||||||
|
dht_nodes: Vec::new(),
|
||||||
|
files: Vec::new(),
|
||||||
|
trackers: Vec::new(),
|
||||||
|
peers: Vec::new(),
|
||||||
|
});
|
||||||
|
assert!(group.bt().is_some());
|
||||||
|
assert!(group.bt_mut().is_some());
|
||||||
|
|
||||||
|
group.clear_bt();
|
||||||
|
|
||||||
|
assert!(group.bt().is_none());
|
||||||
|
assert!(group.bt_mut().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bt_runtime_state_reports_selected_file_helpers() {
|
||||||
|
let bt = BtRuntimeState {
|
||||||
|
info_hash: "0123456789abcdef0123456789abcdef01234567".to_owned(),
|
||||||
|
name: Some("example".to_owned()),
|
||||||
|
magnet_uri: None,
|
||||||
|
metadata_only: false,
|
||||||
|
metadata_size: None,
|
||||||
|
metadata_extension_ids: BTreeMap::new(),
|
||||||
|
metadata_piece_payloads: BTreeMap::new(),
|
||||||
|
creation_date: None,
|
||||||
|
comment: None,
|
||||||
|
dht_nodes: vec![
|
||||||
|
"router.bittorrent.com:6881".to_owned(),
|
||||||
|
"router.utorrent.com:6881".to_owned(),
|
||||||
|
],
|
||||||
|
files: vec![
|
||||||
|
BtFileInfo {
|
||||||
|
path: "selected.iso".to_owned(),
|
||||||
|
length: 2048,
|
||||||
|
piece_offset: Some(0),
|
||||||
|
selected: true,
|
||||||
|
},
|
||||||
|
BtFileInfo {
|
||||||
|
path: "ignored.txt".to_owned(),
|
||||||
|
length: 128,
|
||||||
|
piece_offset: Some(2048),
|
||||||
|
selected: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
trackers: vec![],
|
||||||
|
peers: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
let [first_file, ..] = bt.files() else {
|
||||||
|
panic!("bt files should contain at least one entry");
|
||||||
|
};
|
||||||
|
assert!(first_file.is_selected());
|
||||||
|
assert_eq!(bt.dht_nodes().len(), 2);
|
||||||
|
assert!(bt.has_selected_files());
|
||||||
|
assert_eq!(bt.selected_file_count(), 1);
|
||||||
|
assert_eq!(bt.selected_total_length(), 2048);
|
||||||
|
let selected_paths: Vec<_> = bt.selected_files().map(|file| file.path.as_str()).collect();
|
||||||
|
assert_eq!(selected_paths, vec!["selected.iso"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_group_bt_share_state_roundtrip_and_accessors_work() {
|
||||||
|
let mut group = RequestGroup::new(DownloadId::new(0xe), "magnet:?xt=urn:btih:CCCC");
|
||||||
|
group.set_bt_share_state(BtShareRuntimeState {
|
||||||
|
seeding: true,
|
||||||
|
share_ratio_milli: Some(1500),
|
||||||
|
share_time_secs: 3600,
|
||||||
|
seeding_time_secs: 900,
|
||||||
|
seeding_started_at_secs: None,
|
||||||
|
last_runtime_tick_secs: None,
|
||||||
|
});
|
||||||
|
group.set_bt(BtRuntimeState {
|
||||||
|
info_hash: "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC".to_owned(),
|
||||||
|
name: Some("payload".to_owned()),
|
||||||
|
magnet_uri: None,
|
||||||
|
metadata_only: false,
|
||||||
|
metadata_size: None,
|
||||||
|
metadata_extension_ids: BTreeMap::new(),
|
||||||
|
metadata_piece_payloads: BTreeMap::new(),
|
||||||
|
creation_date: None,
|
||||||
|
comment: None,
|
||||||
|
dht_nodes: vec!["router.bittorrent.com:6881".to_owned()],
|
||||||
|
files: vec![BtFileInfo {
|
||||||
|
path: "payload.bin".to_owned(),
|
||||||
|
length: 4096,
|
||||||
|
piece_offset: Some(0),
|
||||||
|
selected: true,
|
||||||
|
}],
|
||||||
|
trackers: vec![],
|
||||||
|
peers: vec![],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(group.bt_is_seeding());
|
||||||
|
assert_eq!(group.bt_share_ratio_milli(), Some(1500));
|
||||||
|
assert_eq!(group.bt_share_time_secs(), Some(3600));
|
||||||
|
assert_eq!(group.bt_seeding_time_secs(), Some(900));
|
||||||
|
assert_eq!(group.bt_selected_file_count(), Some(1));
|
||||||
|
assert_eq!(group.bt_selected_total_length(), Some(4096));
|
||||||
|
assert!(group.bt_has_selected_files());
|
||||||
|
|
||||||
|
let share = group
|
||||||
|
.bt_share_state_mut()
|
||||||
|
.expect("share state should exist");
|
||||||
|
share.add_share_time_secs(120);
|
||||||
|
share.add_seeding_time_secs(30);
|
||||||
|
share.set_seeding(false);
|
||||||
|
|
||||||
|
assert!(!group.bt_is_seeding());
|
||||||
|
assert_eq!(group.bt_share_time_secs(), Some(3720));
|
||||||
|
assert_eq!(group.bt_seeding_time_secs(), Some(930));
|
||||||
|
|
||||||
|
group.clear_bt_share_state();
|
||||||
|
assert!(group.bt_share_state().is_none());
|
||||||
|
assert!(!group.bt_is_seeding());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bt_share_runtime_state_tracks_seeding_runtime_and_ratio_derivation() {
|
||||||
|
let mut state = BtShareRuntimeState::new();
|
||||||
|
state.start_seeding(100);
|
||||||
|
state.tick_runtime(130);
|
||||||
|
state.tick_runtime(170);
|
||||||
|
state.stop_seeding(200);
|
||||||
|
state.tick_runtime(250);
|
||||||
|
state.refresh_share_ratio_from_lengths(6000, 4000);
|
||||||
|
|
||||||
|
assert!(!state.is_seeding());
|
||||||
|
assert_eq!(state.share_time_secs(), 150);
|
||||||
|
assert_eq!(state.seeding_time_secs(), 100);
|
||||||
|
assert_eq!(state.share_ratio_milli(), Some(1500));
|
||||||
|
assert_eq!(BtShareRuntimeState::derive_share_ratio_milli(100, 0), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_group_bt_runtime_tick_updates_true_seeding_and_share_runtime() {
|
||||||
|
let mut group = RequestGroup::new(DownloadId::new(0x101), "magnet:?xt=urn:btih:RUNTIME");
|
||||||
|
group.set_total_length(2_048);
|
||||||
|
group.set_completed_length(2_048);
|
||||||
|
group.set_bt(BtRuntimeState {
|
||||||
|
metadata_only: false,
|
||||||
|
files: vec![BtFileInfo {
|
||||||
|
path: "payload.bin".to_owned(),
|
||||||
|
length: 2_048,
|
||||||
|
piece_offset: Some(0),
|
||||||
|
selected: true,
|
||||||
|
}],
|
||||||
|
..BtRuntimeState::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let started = group.set_bt_seeding_state(true, Some(100));
|
||||||
|
assert!(group.bt_is_seeding());
|
||||||
|
assert!(group.bt_is_true_seeding());
|
||||||
|
assert!(started.seeding);
|
||||||
|
assert_eq!(started.share_ratio_milli, Some(0));
|
||||||
|
|
||||||
|
let advanced = group.tick_bt_runtime_clock(130, true);
|
||||||
|
assert_eq!(advanced.share_time_secs, 30);
|
||||||
|
assert_eq!(advanced.seeding_time_secs, 30);
|
||||||
|
assert!(advanced.seeding);
|
||||||
|
|
||||||
|
let tick = group.apply_bt_runtime_tick(0, 512, 64, 128, 10, 10, true, Some(8));
|
||||||
|
assert_eq!(group.upload_length(), 512);
|
||||||
|
assert_eq!(group.download_speed(), 64);
|
||||||
|
assert_eq!(group.upload_speed(), 128);
|
||||||
|
assert_eq!(group.num_connections(), 8);
|
||||||
|
assert_eq!(tick.share_time_secs, 40);
|
||||||
|
assert_eq!(tick.seeding_time_secs, 40);
|
||||||
|
assert_eq!(tick.share_ratio_milli, Some(250));
|
||||||
|
assert!(tick.seeding);
|
||||||
|
|
||||||
|
let stopped = group.tick_bt_runtime_clock(160, false);
|
||||||
|
assert!(!group.bt_is_seeding());
|
||||||
|
assert!(!group.bt_is_true_seeding());
|
||||||
|
assert!(!stopped.seeding);
|
||||||
|
assert_eq!(stopped.share_time_secs, 70);
|
||||||
|
assert_eq!(stopped.seeding_time_secs, 70);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_group_bt_share_ratio_base_length_follows_selected_payload() {
|
||||||
|
let mut group = RequestGroup::new(DownloadId::new(0x102), "magnet:?xt=urn:btih:BASE");
|
||||||
|
group.set_total_length(10_000);
|
||||||
|
group.set_completed_length(3_000);
|
||||||
|
group.set_bt(BtRuntimeState {
|
||||||
|
metadata_only: false,
|
||||||
|
files: vec![
|
||||||
|
BtFileInfo {
|
||||||
|
path: "selected-a.bin".to_owned(),
|
||||||
|
length: 2_000,
|
||||||
|
piece_offset: Some(0),
|
||||||
|
selected: true,
|
||||||
|
},
|
||||||
|
BtFileInfo {
|
||||||
|
path: "selected-b.bin".to_owned(),
|
||||||
|
length: 1_000,
|
||||||
|
piece_offset: Some(2),
|
||||||
|
selected: true,
|
||||||
|
},
|
||||||
|
BtFileInfo {
|
||||||
|
path: "ignored.bin".to_owned(),
|
||||||
|
length: 7_000,
|
||||||
|
piece_offset: Some(3),
|
||||||
|
selected: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
..BtRuntimeState::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(group.bt_share_ratio_base_length(), Some(3_000));
|
||||||
|
group.set_completed_length(1_000);
|
||||||
|
assert_eq!(group.bt_share_ratio_base_length(), Some(3_000));
|
||||||
|
group.set_completed_length(5_000);
|
||||||
|
assert_eq!(group.bt_share_ratio_base_length(), Some(5_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_group_refresh_bt_share_runtime_uses_share_ratio_base_length() {
|
||||||
|
let mut group = RequestGroup::new(DownloadId::new(0x103), "magnet:?xt=urn:btih:RATIO");
|
||||||
|
group.set_total_length(10_000);
|
||||||
|
group.set_completed_length(6_000);
|
||||||
|
group.set_upload_length(3_000);
|
||||||
|
group.set_bt(BtRuntimeState {
|
||||||
|
metadata_only: false,
|
||||||
|
files: vec![
|
||||||
|
BtFileInfo {
|
||||||
|
path: "selected.bin".to_owned(),
|
||||||
|
length: 4_000,
|
||||||
|
piece_offset: Some(0),
|
||||||
|
selected: true,
|
||||||
|
},
|
||||||
|
BtFileInfo {
|
||||||
|
path: "ignored.bin".to_owned(),
|
||||||
|
length: 6_000,
|
||||||
|
piece_offset: Some(4),
|
||||||
|
selected: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
..BtRuntimeState::default()
|
||||||
|
});
|
||||||
|
group.set_bt_share_state(BtShareRuntimeState::default());
|
||||||
|
|
||||||
|
let snapshot = group.refresh_bt_share_runtime();
|
||||||
|
|
||||||
|
assert_eq!(group.bt_share_ratio_base_length(), Some(6_000));
|
||||||
|
assert_eq!(snapshot.share_ratio_milli, Some(500));
|
||||||
|
assert_eq!(group.bt_share_ratio_milli(), Some(500));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_group_set_bt_refreshes_cached_share_ratio_after_selection_changes() {
|
||||||
|
let mut group = RequestGroup::new(DownloadId::new(0x104), "magnet:?xt=urn:btih:SELECT");
|
||||||
|
group.set_total_length(10_000);
|
||||||
|
group.set_completed_length(4_000);
|
||||||
|
group.set_upload_length(2_000);
|
||||||
|
group.set_bt(BtRuntimeState {
|
||||||
|
metadata_only: false,
|
||||||
|
files: vec![
|
||||||
|
BtFileInfo {
|
||||||
|
path: "disc-a.bin".to_owned(),
|
||||||
|
length: 5_000,
|
||||||
|
piece_offset: Some(0),
|
||||||
|
selected: true,
|
||||||
|
},
|
||||||
|
BtFileInfo {
|
||||||
|
path: "disc-b.bin".to_owned(),
|
||||||
|
length: 5_000,
|
||||||
|
piece_offset: Some(5),
|
||||||
|
selected: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
..BtRuntimeState::default()
|
||||||
|
});
|
||||||
|
group.set_bt_share_state(BtShareRuntimeState::default());
|
||||||
|
assert_eq!(
|
||||||
|
group.refresh_bt_share_runtime().share_ratio_milli,
|
||||||
|
Some(200)
|
||||||
|
);
|
||||||
|
|
||||||
|
group.set_bt(BtRuntimeState {
|
||||||
|
metadata_only: false,
|
||||||
|
files: vec![
|
||||||
|
BtFileInfo {
|
||||||
|
path: "disc-a.bin".to_owned(),
|
||||||
|
length: 4_000,
|
||||||
|
piece_offset: Some(0),
|
||||||
|
selected: true,
|
||||||
|
},
|
||||||
|
BtFileInfo {
|
||||||
|
path: "disc-b.bin".to_owned(),
|
||||||
|
length: 6_000,
|
||||||
|
piece_offset: Some(4),
|
||||||
|
selected: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
..BtRuntimeState::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(group.bt_selected_total_length(), Some(4_000));
|
||||||
|
assert_eq!(group.bt_share_ratio_milli(), Some(500));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_group_set_upload_length_refreshes_cached_bt_share_ratio() {
|
||||||
|
let mut group = RequestGroup::new(DownloadId::new(0x105), "magnet:?xt=urn:btih:UPLOAD");
|
||||||
|
group.set_total_length(4_000);
|
||||||
|
group.set_completed_length(4_000);
|
||||||
|
group.set_upload_length(1_000);
|
||||||
|
group.set_bt(BtRuntimeState {
|
||||||
|
metadata_only: false,
|
||||||
|
files: vec![BtFileInfo {
|
||||||
|
path: "payload.bin".to_owned(),
|
||||||
|
length: 4_000,
|
||||||
|
piece_offset: Some(0),
|
||||||
|
selected: true,
|
||||||
|
}],
|
||||||
|
..BtRuntimeState::default()
|
||||||
|
});
|
||||||
|
group.set_bt_share_state(BtShareRuntimeState::default());
|
||||||
|
assert_eq!(
|
||||||
|
group.refresh_bt_share_runtime().share_ratio_milli,
|
||||||
|
Some(250)
|
||||||
|
);
|
||||||
|
|
||||||
|
group.set_upload_length(2_000);
|
||||||
|
|
||||||
|
assert_eq!(group.bt_share_ratio_milli(), Some(500));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_group_bt_piece_helpers_report_counts_and_requestable_ids() {
|
||||||
|
let mut group = RequestGroup::new(DownloadId::new(0xf), "magnet:?xt=urn:btih:DDDD");
|
||||||
|
group.set_piece_state(PieceId(0), PieceState::Verified);
|
||||||
|
group.set_piece_state(PieceId(1), PieceState::Pending);
|
||||||
|
group.set_piece_state(PieceId(2), PieceState::Queued);
|
||||||
|
group.set_piece_state(PieceId(3), PieceState::Downloading);
|
||||||
|
group.set_piece_state(PieceId(4), PieceState::Missing);
|
||||||
|
group.set_piece_state(PieceId(5), PieceState::Skipped);
|
||||||
|
|
||||||
|
assert_eq!(group.bt_verified_piece_count(), 1);
|
||||||
|
assert_eq!(group.piece_state_counts(), (1, 1, 1, 1, 1, 1));
|
||||||
|
assert_eq!(
|
||||||
|
group.bt_requestable_piece_ids(false, 8),
|
||||||
|
vec![PieceId(1), PieceId(2), PieceId(4)]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
group.bt_requestable_piece_ids(true, 8),
|
||||||
|
vec![PieceId(1), PieceId(2), PieceId(4), PieceId(3)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_group_bt_effective_target_and_remaining_length_follow_selection() {
|
||||||
|
let mut group = RequestGroup::new(DownloadId::new(0x10), "magnet:?xt=urn:btih:EEEE");
|
||||||
|
group.set_total_length(10_000);
|
||||||
|
group.set_completed_length(4_000);
|
||||||
|
group.set_bt(BtRuntimeState {
|
||||||
|
metadata_only: false,
|
||||||
|
files: vec![
|
||||||
|
BtFileInfo {
|
||||||
|
path: "wanted-a.bin".to_owned(),
|
||||||
|
length: 3_000,
|
||||||
|
piece_offset: Some(0),
|
||||||
|
selected: true,
|
||||||
|
},
|
||||||
|
BtFileInfo {
|
||||||
|
path: "wanted-b.bin".to_owned(),
|
||||||
|
length: 2_000,
|
||||||
|
piece_offset: Some(3),
|
||||||
|
selected: true,
|
||||||
|
},
|
||||||
|
BtFileInfo {
|
||||||
|
path: "ignored.bin".to_owned(),
|
||||||
|
length: 5_000,
|
||||||
|
piece_offset: Some(5),
|
||||||
|
selected: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
..BtRuntimeState::default()
|
||||||
|
});
|
||||||
|
assert_eq!(group.bt_effective_target_length(), Some(5_000));
|
||||||
|
assert_eq!(group.bt_remaining_work_length(), Some(1_000));
|
||||||
|
|
||||||
|
group.set_completed_length(9_000);
|
||||||
|
assert_eq!(group.bt_remaining_work_length(), Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_group_bt_piece_block_update_tracks_piece_progress_and_selected_span() {
|
||||||
|
let mut group = RequestGroup::new(DownloadId::new(0x11), "magnet:?xt=urn:btih:FFFF");
|
||||||
|
group.set_total_length(4_096);
|
||||||
|
group.set_piece_length(1_024);
|
||||||
|
group.set_bt(BtRuntimeState {
|
||||||
|
metadata_only: false,
|
||||||
|
files: vec![BtFileInfo {
|
||||||
|
path: "wanted.bin".to_owned(),
|
||||||
|
length: 2_500,
|
||||||
|
piece_offset: Some(0),
|
||||||
|
selected: true,
|
||||||
|
}],
|
||||||
|
..BtRuntimeState::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let partial = group.apply_bt_piece_block_update(BtPieceBlockUpdate {
|
||||||
|
piece_id: PieceId(2),
|
||||||
|
completed_blocks: 2,
|
||||||
|
total_blocks: 4,
|
||||||
|
});
|
||||||
|
assert_eq!(group.piece_state(PieceId(2)), Some(PieceState::Downloading));
|
||||||
|
assert_eq!(group.completed_length(), 0);
|
||||||
|
assert_eq!(partial.completed_length_delta, 0);
|
||||||
|
assert_eq!(partial.previous_state, None);
|
||||||
|
assert_eq!(partial.next_state, PieceState::Downloading);
|
||||||
|
assert_eq!(partial.piece_span_length, 452);
|
||||||
|
assert_eq!(partial.block_completion_milli, 500);
|
||||||
|
assert!(!partial.transitioned_to_verified);
|
||||||
|
|
||||||
|
let verified = group.apply_bt_piece_block_update(BtPieceBlockUpdate {
|
||||||
|
piece_id: PieceId(2),
|
||||||
|
completed_blocks: 4,
|
||||||
|
total_blocks: 4,
|
||||||
|
});
|
||||||
|
assert_eq!(group.piece_state(PieceId(2)), Some(PieceState::Verified));
|
||||||
|
assert_eq!(group.completed_length(), 452);
|
||||||
|
assert_eq!(verified.completed_length_delta, 452);
|
||||||
|
assert_eq!(verified.previous_state, Some(PieceState::Downloading));
|
||||||
|
assert_eq!(verified.next_state, PieceState::Verified);
|
||||||
|
assert_eq!(verified.block_completion_milli, 1000);
|
||||||
|
assert!(verified.transitioned_to_verified);
|
||||||
|
|
||||||
|
let missing = group.apply_bt_piece_block_update(BtPieceBlockUpdate {
|
||||||
|
piece_id: PieceId(2),
|
||||||
|
completed_blocks: 0,
|
||||||
|
total_blocks: 0,
|
||||||
|
});
|
||||||
|
assert_eq!(group.piece_state(PieceId(2)), Some(PieceState::Missing));
|
||||||
|
assert_eq!(group.completed_length(), 0);
|
||||||
|
assert_eq!(missing.completed_length_delta, -452);
|
||||||
|
assert_eq!(missing.previous_state, Some(PieceState::Verified));
|
||||||
|
assert_eq!(missing.next_state, PieceState::Missing);
|
||||||
|
assert_eq!(missing.piece_span_length, 452);
|
||||||
|
assert_eq!(missing.block_completion_milli, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_group_bt_peer_and_availability_updates_report_runtime_stats() {
|
||||||
|
let mut group = RequestGroup::new(DownloadId::new(0x12), "magnet:?xt=urn:btih:9999");
|
||||||
|
group.set_piece_state(PieceId(0), PieceState::Missing);
|
||||||
|
group.set_piece_state(PieceId(1), PieceState::Queued);
|
||||||
|
group.set_piece_state(PieceId(2), PieceState::Verified);
|
||||||
|
group.set_bt(BtRuntimeState {
|
||||||
|
info_hash: "9999".to_owned(),
|
||||||
|
metadata_only: false,
|
||||||
|
..BtRuntimeState::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let available = group.apply_bt_piece_availability_update(BtPieceAvailabilityUpdate {
|
||||||
|
piece_id: PieceId(0),
|
||||||
|
peers_with_piece: 3,
|
||||||
|
});
|
||||||
|
assert_eq!(available.available_piece_count, 1);
|
||||||
|
assert_eq!(available.peers_with_piece, 3);
|
||||||
|
assert!(available.piece_is_requestable);
|
||||||
|
assert!(!available.piece_is_verified);
|
||||||
|
|
||||||
|
let verified_piece = group.apply_bt_piece_availability_update(BtPieceAvailabilityUpdate {
|
||||||
|
piece_id: PieceId(2),
|
||||||
|
peers_with_piece: 5,
|
||||||
|
});
|
||||||
|
assert_eq!(verified_piece.available_piece_count, 2);
|
||||||
|
assert!(!verified_piece.piece_is_requestable);
|
||||||
|
assert!(verified_piece.piece_is_verified);
|
||||||
|
|
||||||
|
let peer_a = group.apply_bt_peer_update(BtPeerInfo {
|
||||||
|
peer_id: Some("peer-a".to_owned()),
|
||||||
|
ip: "127.0.0.1".to_owned(),
|
||||||
|
port: 6881,
|
||||||
|
client_name: Some("client-a".to_owned()),
|
||||||
|
interested: true,
|
||||||
|
choked: false,
|
||||||
|
download_speed: 512,
|
||||||
|
upload_speed: 64,
|
||||||
|
seeder: false,
|
||||||
|
});
|
||||||
|
assert_eq!(peer_a.peer_count, 1);
|
||||||
|
assert_eq!(peer_a.seeder_count, 0);
|
||||||
|
assert_eq!(peer_a.leecher_count, 1);
|
||||||
|
assert_eq!(peer_a.total_download_speed, 512);
|
||||||
|
assert_eq!(peer_a.total_upload_speed, 64);
|
||||||
|
assert!(!peer_a.replaced_existing);
|
||||||
|
|
||||||
|
let peer_b = group.apply_bt_peer_update(BtPeerInfo {
|
||||||
|
peer_id: Some("peer-a".to_owned()),
|
||||||
|
ip: "127.0.0.1".to_owned(),
|
||||||
|
port: 6881,
|
||||||
|
client_name: Some("client-a2".to_owned()),
|
||||||
|
interested: false,
|
||||||
|
choked: true,
|
||||||
|
download_speed: 1_024,
|
||||||
|
upload_speed: 256,
|
||||||
|
seeder: true,
|
||||||
|
});
|
||||||
|
assert_eq!(peer_b.peer_count, 1);
|
||||||
|
assert_eq!(peer_b.seeder_count, 1);
|
||||||
|
assert_eq!(peer_b.leecher_count, 0);
|
||||||
|
assert_eq!(peer_b.total_download_speed, 1_024);
|
||||||
|
assert_eq!(peer_b.total_upload_speed, 256);
|
||||||
|
assert!(peer_b.replaced_existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_group_segment_runtime_stats_capture_assignment_load() {
|
||||||
|
let mut group = RequestGroup::new(DownloadId::new(0x13), "https://example.org/segments.bin");
|
||||||
|
group.set_segment_assignments(vec![
|
||||||
|
SegmentAssignment {
|
||||||
|
slot: 0,
|
||||||
|
range: PieceRange::new(0, 1024),
|
||||||
|
completed_length: 512,
|
||||||
|
state: SegmentState::Active,
|
||||||
|
},
|
||||||
|
SegmentAssignment {
|
||||||
|
slot: 1,
|
||||||
|
range: PieceRange::new(1024, 2048),
|
||||||
|
completed_length: 1024,
|
||||||
|
state: SegmentState::Complete,
|
||||||
|
},
|
||||||
|
SegmentAssignment {
|
||||||
|
slot: 2,
|
||||||
|
range: PieceRange::new(2048, 3072),
|
||||||
|
completed_length: 128,
|
||||||
|
state: SegmentState::Retrying,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
let stats = group.segment_runtime_stats();
|
||||||
|
assert_eq!(stats.segment_count, 3);
|
||||||
|
assert_eq!(stats.active_count, 1);
|
||||||
|
assert_eq!(stats.retrying_count, 1);
|
||||||
|
assert_eq!(stats.complete_count, 1);
|
||||||
|
assert_eq!(stats.planned_bytes, 3072);
|
||||||
|
assert_eq!(stats.completed_bytes, 1664);
|
||||||
|
assert_eq!(stats.remaining_bytes, 1408);
|
||||||
|
assert_eq!(stats.covered_range, Some(PieceRange::new(0, 3072)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_group_bt_pressure_snapshot_reports_requestable_and_scarcity() {
|
||||||
|
let mut group = RequestGroup::new(DownloadId::new(0x14), "magnet:?xt=urn:btih:PRESSURE2");
|
||||||
|
group.set_piece_state(PieceId(0), PieceState::Verified);
|
||||||
|
group.set_piece_state(PieceId(1), PieceState::Pending);
|
||||||
|
group.set_piece_state(PieceId(2), PieceState::Downloading);
|
||||||
|
group.set_piece_state(PieceId(3), PieceState::Queued);
|
||||||
|
group.set_piece_state(PieceId(4), PieceState::Missing);
|
||||||
|
group.set_bt(BtRuntimeState {
|
||||||
|
metadata_only: false,
|
||||||
|
peers: vec![
|
||||||
|
BtPeerInfo {
|
||||||
|
peer_id: Some("peer-a".to_owned()),
|
||||||
|
ip: "198.51.100.10".to_owned(),
|
||||||
|
port: 6881,
|
||||||
|
client_name: None,
|
||||||
|
interested: true,
|
||||||
|
choked: false,
|
||||||
|
download_speed: 256,
|
||||||
|
upload_speed: 64,
|
||||||
|
seeder: false,
|
||||||
|
},
|
||||||
|
BtPeerInfo {
|
||||||
|
peer_id: Some("peer-b".to_owned()),
|
||||||
|
ip: "198.51.100.11".to_owned(),
|
||||||
|
port: 6882,
|
||||||
|
client_name: None,
|
||||||
|
interested: false,
|
||||||
|
choked: true,
|
||||||
|
download_speed: 0,
|
||||||
|
upload_speed: 32,
|
||||||
|
seeder: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
..BtRuntimeState::default()
|
||||||
|
});
|
||||||
|
group.apply_bt_piece_availability_update(BtPieceAvailabilityUpdate {
|
||||||
|
piece_id: PieceId(1),
|
||||||
|
peers_with_piece: 1,
|
||||||
|
});
|
||||||
|
group.apply_bt_piece_availability_update(BtPieceAvailabilityUpdate {
|
||||||
|
piece_id: PieceId(3),
|
||||||
|
peers_with_piece: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
let pressure = group
|
||||||
|
.bt_pressure_snapshot()
|
||||||
|
.expect("bt pressure snapshot should exist");
|
||||||
|
assert_eq!(pressure.total_pieces, 5);
|
||||||
|
assert_eq!(pressure.requestable_pieces, 3);
|
||||||
|
assert_eq!(pressure.active_pieces, 2);
|
||||||
|
assert_eq!(pressure.available_requestable_pieces, 2);
|
||||||
|
assert_eq!(pressure.scarce_requestable_pieces, 1);
|
||||||
|
assert_eq!(pressure.peer_count, 2);
|
||||||
|
assert_eq!(pressure.seeder_count, 1);
|
||||||
|
assert_eq!(pressure.leecher_count, 1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
use crate::piece::PieceRange;
|
||||||
|
|
||||||
|
/// Scheduling state for a single HTTP segment assignment.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum SegmentState {
|
||||||
|
/// The segment is planned but no worker has claimed it yet.
|
||||||
|
Planned,
|
||||||
|
/// The segment is actively downloading.
|
||||||
|
Active,
|
||||||
|
/// The segment is waiting for a retry.
|
||||||
|
Retrying,
|
||||||
|
/// The segment finished successfully.
|
||||||
|
Complete,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Active or planned range assignment for a segmented transfer.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct SegmentAssignment {
|
||||||
|
/// Scheduler slot associated with this assignment.
|
||||||
|
pub slot: usize,
|
||||||
|
/// Piece range covered by the assignment.
|
||||||
|
pub range: PieceRange,
|
||||||
|
/// Number of bytes already completed inside the range.
|
||||||
|
pub completed_length: u64,
|
||||||
|
/// Current scheduling state of the assignment.
|
||||||
|
pub state: SegmentState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SegmentAssignment {
|
||||||
|
/// Builds a planned assignment for the provided slot and range.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(slot: usize, range: PieceRange) -> Self {
|
||||||
|
Self {
|
||||||
|
slot,
|
||||||
|
range,
|
||||||
|
completed_length: 0,
|
||||||
|
state: SegmentState::Planned,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the remaining byte count inside the assigned range.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn remaining_length(&self) -> u64 {
|
||||||
|
self.range.len().saturating_sub(self.completed_length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregated runtime counters for the segment scheduler.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct SegmentRuntimeStats {
|
||||||
|
/// Total number of segment assignments known to the scheduler.
|
||||||
|
pub segment_count: usize,
|
||||||
|
/// Number of assignments currently marked active.
|
||||||
|
pub active_count: usize,
|
||||||
|
/// Number of assignments currently waiting for retry.
|
||||||
|
pub retrying_count: usize,
|
||||||
|
/// Number of assignments already completed.
|
||||||
|
pub complete_count: usize,
|
||||||
|
/// Total byte length covered by all planned ranges.
|
||||||
|
pub planned_bytes: u64,
|
||||||
|
/// Total byte length completed across all assignments.
|
||||||
|
pub completed_bytes: u64,
|
||||||
|
/// Remaining byte length across all assignments.
|
||||||
|
pub remaining_bytes: u64,
|
||||||
|
/// Smallest range spanning all scheduled segments, if one exists.
|
||||||
|
pub covered_range: Option<PieceRange>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
//! Runtime configuration defaults and human-readable size parsing helpers.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Runtime configuration used to build and operate the download engine.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct RuntimeConfig {
|
||||||
|
/// Number of worker threads available to the runtime.
|
||||||
|
pub worker_threads: usize,
|
||||||
|
/// Maximum number of simultaneously active downloads.
|
||||||
|
pub max_active_downloads: usize,
|
||||||
|
/// Maximum number of tracked downloads.
|
||||||
|
pub max_downloads: usize,
|
||||||
|
/// Desired split count per download.
|
||||||
|
pub split: usize,
|
||||||
|
/// Canonical maximum connections per server option.
|
||||||
|
pub max_connections_per_server: usize,
|
||||||
|
/// Compatibility alias for the maximum connections per server option.
|
||||||
|
pub max_connection_per_server: usize,
|
||||||
|
/// Global download-rate cap in bytes per second.
|
||||||
|
pub max_overall_download_limit: Option<u64>,
|
||||||
|
/// Per-download download-rate cap in bytes per second.
|
||||||
|
pub max_download_limit: Option<u64>,
|
||||||
|
/// Global upload-rate cap in bytes per second.
|
||||||
|
pub max_overall_upload_limit: Option<u64>,
|
||||||
|
/// Per-download upload-rate cap in bytes per second.
|
||||||
|
pub max_upload_limit: Option<u64>,
|
||||||
|
/// Minimum size used when splitting work into segments.
|
||||||
|
pub min_split_size: u64,
|
||||||
|
/// Default piece length for newly created downloads.
|
||||||
|
pub piece_length: u64,
|
||||||
|
/// RPC listen port.
|
||||||
|
pub rpc_port: u16,
|
||||||
|
/// `BitTorrent` listen port.
|
||||||
|
pub listen_port: u16,
|
||||||
|
/// Configured disk-cache size in bytes.
|
||||||
|
pub disk_cache_bytes: u64,
|
||||||
|
/// Event queue buffer size.
|
||||||
|
pub event_buffer_size: usize,
|
||||||
|
/// Optional session file path.
|
||||||
|
pub session_path: Option<PathBuf>,
|
||||||
|
/// Interval between session saves in seconds.
|
||||||
|
pub save_session_interval_secs: u64,
|
||||||
|
/// Graceful shutdown timeout in seconds.
|
||||||
|
pub graceful_shutdown_timeout_secs: u64,
|
||||||
|
/// Whether XML-RPC endpoints are enabled.
|
||||||
|
pub allow_xmlrpc: bool,
|
||||||
|
/// Whether JSON-RPC endpoints are enabled.
|
||||||
|
pub allow_jsonrpc: bool,
|
||||||
|
/// Whether resume behavior is enabled.
|
||||||
|
pub allow_resume: bool,
|
||||||
|
/// Whether IPv6 support is enabled.
|
||||||
|
pub enable_ipv6: bool,
|
||||||
|
/// Whether HTTP 400 responses are retryable.
|
||||||
|
pub retry_on_400: bool,
|
||||||
|
/// Whether HTTP 403 responses are retryable.
|
||||||
|
pub retry_on_403: bool,
|
||||||
|
/// Whether HTTP 406 responses are retryable.
|
||||||
|
pub retry_on_406: bool,
|
||||||
|
/// Whether unknown failures are retryable.
|
||||||
|
pub retry_on_unknown: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RuntimeConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
worker_threads: 4,
|
||||||
|
max_active_downloads: 5,
|
||||||
|
max_downloads: 16,
|
||||||
|
split: 5,
|
||||||
|
max_connections_per_server: 1,
|
||||||
|
max_connection_per_server: 1,
|
||||||
|
max_overall_download_limit: None,
|
||||||
|
max_download_limit: None,
|
||||||
|
max_overall_upload_limit: None,
|
||||||
|
max_upload_limit: None,
|
||||||
|
min_split_size: 1_024,
|
||||||
|
piece_length: 1_024,
|
||||||
|
rpc_port: 6_800,
|
||||||
|
listen_port: 6_881,
|
||||||
|
disk_cache_bytes: 16 * 1_024 * 1_024,
|
||||||
|
event_buffer_size: 256,
|
||||||
|
session_path: None,
|
||||||
|
save_session_interval_secs: 30,
|
||||||
|
graceful_shutdown_timeout_secs: 10,
|
||||||
|
allow_xmlrpc: true,
|
||||||
|
allow_jsonrpc: true,
|
||||||
|
allow_resume: true,
|
||||||
|
enable_ipv6: false,
|
||||||
|
retry_on_400: true,
|
||||||
|
retry_on_403: true,
|
||||||
|
retry_on_406: true,
|
||||||
|
retry_on_unknown: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RuntimeConfig {
|
||||||
|
/// Returns a copy with the session path set.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_session_path(mut self, path: impl Into<PathBuf>) -> Self {
|
||||||
|
self.session_path = Some(path.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a copy with the worker-thread count overridden.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_worker_threads(mut self, count: usize) -> Self {
|
||||||
|
self.worker_threads = count;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a copy with the RPC port overridden.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_rpc_port(mut self, port: u16) -> Self {
|
||||||
|
self.rpc_port = port;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a copy with the session path parsed from a string-like value.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_session_path_str(mut self, path: impl Into<String>) -> Self {
|
||||||
|
self.session_path = Some(PathBuf::from(path.into()));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the effective max-connections-per-server setting.
|
||||||
|
#[must_use]
|
||||||
|
pub fn effective_max_connections_per_server(&self) -> usize {
|
||||||
|
self.max_connections_per_server
|
||||||
|
.max(self.max_connection_per_server)
|
||||||
|
.max(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the effective split count.
|
||||||
|
#[must_use]
|
||||||
|
pub fn effective_split(&self) -> usize {
|
||||||
|
self.split.max(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the effective maximum parallel segment count.
|
||||||
|
#[must_use]
|
||||||
|
pub fn effective_parallel_segments(&self) -> usize {
|
||||||
|
self.effective_split()
|
||||||
|
.min(self.effective_max_connections_per_server())
|
||||||
|
.max(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns retryability flags for the common HTTP error buckets.
|
||||||
|
#[must_use]
|
||||||
|
pub fn retryable_status_codes(&self) -> [bool; 4] {
|
||||||
|
[
|
||||||
|
self.retry_on_400,
|
||||||
|
self.retry_on_403,
|
||||||
|
self.retry_on_406,
|
||||||
|
self.retry_on_unknown,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a human-readable byte-size string into a byte count.
|
||||||
|
#[must_use]
|
||||||
|
#[expect(
|
||||||
|
clippy::redundant_pub_crate,
|
||||||
|
reason = "session and request helpers reuse the parser while the runtime module remains crate-private"
|
||||||
|
)]
|
||||||
|
pub(crate) fn parse_human_size_text(value: &str) -> Option<u64> {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let split_index = trimmed
|
||||||
|
.find(|ch: char| !ch.is_ascii_digit())
|
||||||
|
.unwrap_or(trimmed.len());
|
||||||
|
let (digits, suffix) = trimmed.split_at(split_index);
|
||||||
|
let base = digits.parse::<u64>().ok()?;
|
||||||
|
let suffix = suffix.trim();
|
||||||
|
let factor = if suffix.is_empty() {
|
||||||
|
1
|
||||||
|
} else if suffix.eq_ignore_ascii_case("k") || suffix.eq_ignore_ascii_case("kb") {
|
||||||
|
1_024
|
||||||
|
} else if suffix.eq_ignore_ascii_case("m") || suffix.eq_ignore_ascii_case("mb") {
|
||||||
|
1_024 * 1_024
|
||||||
|
} else if suffix.eq_ignore_ascii_case("g") || suffix.eq_ignore_ascii_case("gb") {
|
||||||
|
1_024 * 1_024 * 1_024
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
Some(base.saturating_mul(factor))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_human_size_text_supports_plain_and_suffix_forms() {
|
||||||
|
assert_eq!(parse_human_size_text("2048"), Some(2048));
|
||||||
|
assert_eq!(parse_human_size_text("2K"), Some(2 * 1024));
|
||||||
|
assert_eq!(parse_human_size_text("4M"), Some(4 * 1024 * 1024));
|
||||||
|
assert_eq!(parse_human_size_text("3gb"), Some(3 * 1024 * 1024 * 1024));
|
||||||
|
assert_eq!(parse_human_size_text(""), None);
|
||||||
|
assert_eq!(parse_human_size_text("12T"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,559 @@
|
|||||||
|
//! Scheduling policies, planning state, and per-tick observations.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
piece::{PieceId, PieceRange, PieceState},
|
||||||
|
request::{DownloadId, DownloadStatus, RequestGroup},
|
||||||
|
runtime::RuntimeConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Policy used to choose the next runnable download.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub enum SchedulerPolicy {
|
||||||
|
/// Fair scheduling that balances active and waiting work.
|
||||||
|
#[default]
|
||||||
|
Fair,
|
||||||
|
/// FIFO queue ordering.
|
||||||
|
FirstInFirstOut,
|
||||||
|
/// LIFO queue ordering.
|
||||||
|
LastInFirstOut,
|
||||||
|
/// Round-robin scheduling.
|
||||||
|
RoundRobin,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current scheduler lifecycle state.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub enum SchedulerState {
|
||||||
|
/// Scheduler is idle.
|
||||||
|
#[default]
|
||||||
|
Idle,
|
||||||
|
/// Scheduler is ready to plan work.
|
||||||
|
Ready,
|
||||||
|
/// Scheduler is actively running work.
|
||||||
|
Running,
|
||||||
|
/// Scheduler is paused.
|
||||||
|
Paused,
|
||||||
|
/// Scheduler is shutting down.
|
||||||
|
ShuttingDown,
|
||||||
|
/// Scheduler has stopped.
|
||||||
|
Stopped,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Action produced by a scheduling pass.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ScheduleDecision {
|
||||||
|
/// Start the given download immediately.
|
||||||
|
RunNow(DownloadId),
|
||||||
|
/// Keep the given download in the waiting queue.
|
||||||
|
Queue(DownloadId),
|
||||||
|
/// Pause the given download.
|
||||||
|
Pause(DownloadId),
|
||||||
|
/// Remove the given download.
|
||||||
|
Remove(DownloadId),
|
||||||
|
/// Requeue the given download for later retry.
|
||||||
|
RetryLater(DownloadId),
|
||||||
|
/// No action was required.
|
||||||
|
Noop,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copyable discriminator for schedule decisions.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ScheduleDecisionKind {
|
||||||
|
/// Decision kind for starting a download immediately.
|
||||||
|
RunNow,
|
||||||
|
/// Decision kind for queueing a download.
|
||||||
|
Queue,
|
||||||
|
/// Decision kind for pausing a download.
|
||||||
|
Pause,
|
||||||
|
/// Decision kind for removing a download.
|
||||||
|
Remove,
|
||||||
|
/// Decision kind for retrying a download later.
|
||||||
|
RetryLater,
|
||||||
|
/// Decision kind for taking no action.
|
||||||
|
Noop,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counters gathered while the scheduler is running.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct SchedulerActivityCounters {
|
||||||
|
/// Number of scheduler ticks observed.
|
||||||
|
pub tick_count: u64,
|
||||||
|
/// Number of scheduling passes that started.
|
||||||
|
pub schedule_run_count: u64,
|
||||||
|
/// Number of immediate-run decisions emitted.
|
||||||
|
pub run_now_decision_count: u64,
|
||||||
|
/// Number of queue decisions emitted.
|
||||||
|
pub queue_decision_count: u64,
|
||||||
|
/// Number of pause decisions emitted.
|
||||||
|
pub pause_decision_count: u64,
|
||||||
|
/// Number of remove decisions emitted.
|
||||||
|
pub remove_decision_count: u64,
|
||||||
|
/// Number of retry-later decisions emitted.
|
||||||
|
pub retry_later_decision_count: u64,
|
||||||
|
/// Number of noop decisions emitted.
|
||||||
|
pub noop_decision_count: u64,
|
||||||
|
/// Most recent decision kind, when one has been recorded.
|
||||||
|
pub last_decision: Option<ScheduleDecisionKind>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot of the most recent planning observation.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct SchedulerPlanningObservation {
|
||||||
|
/// Download id for the observation.
|
||||||
|
pub gid: DownloadId,
|
||||||
|
/// Total payload length reported by the group.
|
||||||
|
pub total_length: u64,
|
||||||
|
/// Payload length considered plannable by the scheduler.
|
||||||
|
pub plannable_length: u64,
|
||||||
|
/// Completed portion of the plannable length.
|
||||||
|
pub completed_length: u64,
|
||||||
|
/// Remaining plannable bytes.
|
||||||
|
pub remaining_bytes: u64,
|
||||||
|
/// Number of segments the scheduler planned.
|
||||||
|
pub planned_segments: usize,
|
||||||
|
/// Number of active segments already running.
|
||||||
|
pub active_segment_count: usize,
|
||||||
|
/// Number of pieces currently requestable.
|
||||||
|
pub requestable_pieces: usize,
|
||||||
|
/// Number of active pieces currently downloading or queued.
|
||||||
|
pub active_piece_count: usize,
|
||||||
|
/// Number of requestable pieces available from peers.
|
||||||
|
pub available_requestable_pieces: usize,
|
||||||
|
/// Number of requestable pieces available from scarce peers only.
|
||||||
|
pub scarce_requestable_pieces: usize,
|
||||||
|
/// Number of peers visible in the swarm snapshot.
|
||||||
|
pub peer_count: usize,
|
||||||
|
/// Whether the observation considers endgame mode ready.
|
||||||
|
pub bt_endgame_ready: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters used to split a download into active segments.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct SegmentPlan {
|
||||||
|
/// Requested split count for the download.
|
||||||
|
pub split: usize,
|
||||||
|
/// Minimum byte size allowed for a segment.
|
||||||
|
pub min_split_size: u64,
|
||||||
|
/// Piece length used to align piece-aware work.
|
||||||
|
pub piece_length: u64,
|
||||||
|
/// Maximum connections allowed per server.
|
||||||
|
pub max_connections_per_server: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SegmentPlan {
|
||||||
|
/// Builds a segment plan from the runtime configuration.
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_runtime(runtime: &RuntimeConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
split: runtime.effective_split(),
|
||||||
|
min_split_size: runtime.min_split_size.max(1),
|
||||||
|
piece_length: runtime.piece_length.max(1),
|
||||||
|
max_connections_per_server: runtime.effective_max_connections_per_server(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entry recorded for a retry in the scheduler bridge state.
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct RetryHistoryEntry {
|
||||||
|
/// Unix timestamp when the retry was recorded.
|
||||||
|
pub at_unix_secs: u64,
|
||||||
|
/// Human-readable retry reason.
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runtime state mirrored from the scheduler into the session bridge.
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct RuntimeScheduleState {
|
||||||
|
/// Completed payload length mirrored from runtime state.
|
||||||
|
pub completed_length: u64,
|
||||||
|
/// Total retry count mirrored from runtime state.
|
||||||
|
pub retry_count: u32,
|
||||||
|
/// Retry history mirrored from runtime state.
|
||||||
|
pub retry_history: Vec<RetryHistoryEntry>,
|
||||||
|
/// Number of active segments mirrored from runtime state.
|
||||||
|
pub active_segments: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RuntimeScheduleState {
|
||||||
|
/// Records a retry entry in the bridge state.
|
||||||
|
pub fn record_retry(&mut self, at_unix_secs: u64, reason: impl Into<String>) {
|
||||||
|
self.retry_count = self.retry_count.saturating_add(1);
|
||||||
|
self.retry_history.push(RetryHistoryEntry {
|
||||||
|
at_unix_secs,
|
||||||
|
reason: reason.into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the mirrored completed length.
|
||||||
|
pub fn set_completed_length(&mut self, completed_length: u64) {
|
||||||
|
self.completed_length = completed_length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the mirrored active-segment count.
|
||||||
|
pub fn set_active_segments(&mut self, active_segments: usize) {
|
||||||
|
self.active_segments = active_segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the scheduler should enter endgame mode.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_endgame_ready(remaining_pieces: usize, endgame_threshold: usize) -> bool {
|
||||||
|
remaining_pieces > 0 && remaining_pieces <= endgame_threshold.max(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Piece-selection options used when choosing `BitTorrent` work.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct BtPieceSelectionOptions {
|
||||||
|
/// Maximum number of candidate pieces to return.
|
||||||
|
pub max_candidates: usize,
|
||||||
|
/// Whether downloading pieces stay eligible during endgame.
|
||||||
|
pub include_downloading_in_endgame: bool,
|
||||||
|
/// Whether endgame mode is currently active.
|
||||||
|
pub endgame_mode: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BtPieceSelectionOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_candidates: 32,
|
||||||
|
include_downloading_in_endgame: true,
|
||||||
|
endgame_mode: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Main scheduler state machine and planning helper.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct Scheduler {
|
||||||
|
/// Scheduling policy used for decisions.
|
||||||
|
policy: SchedulerPolicy,
|
||||||
|
/// Current scheduler lifecycle state.
|
||||||
|
state: SchedulerState,
|
||||||
|
/// Maximum number of active downloads allowed at once.
|
||||||
|
max_active: usize,
|
||||||
|
/// Accumulated activity counters.
|
||||||
|
activity_counters: SchedulerActivityCounters,
|
||||||
|
/// Most recent planning observation captured by the scheduler.
|
||||||
|
last_planning_observation: Option<SchedulerPlanningObservation>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Scheduler {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Scheduler {
|
||||||
|
#[must_use]
|
||||||
|
/// Derives the number of bytes that are actually plannable for a group.
|
||||||
|
fn effective_plannable_total_length(group: &RequestGroup) -> u64 {
|
||||||
|
let total = group.total_length();
|
||||||
|
let Some(bt) = group.bt() else {
|
||||||
|
return total;
|
||||||
|
};
|
||||||
|
|
||||||
|
if bt.metadata_only {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if bt.files.is_empty() {
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
let selected_total = bt
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.filter(|file| file.selected)
|
||||||
|
.fold(0_u64, |acc, file| acc.saturating_add(file.length));
|
||||||
|
|
||||||
|
if selected_total == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if total == 0 {
|
||||||
|
selected_total
|
||||||
|
} else {
|
||||||
|
selected_total.min(total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a scheduler with the default fair policy.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
policy: SchedulerPolicy::default(),
|
||||||
|
state: SchedulerState::default(),
|
||||||
|
max_active: 3,
|
||||||
|
activity_counters: SchedulerActivityCounters::default(),
|
||||||
|
last_planning_observation: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a copy with the requested scheduling policy.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_policy(mut self, policy: SchedulerPolicy) -> Self {
|
||||||
|
self.policy = policy;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the active scheduling policy.
|
||||||
|
#[must_use]
|
||||||
|
pub fn policy(&self) -> SchedulerPolicy {
|
||||||
|
self.policy
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the scheduler lifecycle state.
|
||||||
|
#[must_use]
|
||||||
|
pub fn state(&self) -> SchedulerState {
|
||||||
|
self.state
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the scheduler lifecycle state.
|
||||||
|
pub fn set_state(&mut self, state: SchedulerState) {
|
||||||
|
self.state = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the maximum number of active downloads.
|
||||||
|
pub fn set_max_active(&mut self, max_active: usize) {
|
||||||
|
self.max_active = max_active;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the configured max-active count.
|
||||||
|
#[must_use]
|
||||||
|
pub fn max_active(&self) -> usize {
|
||||||
|
self.max_active
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the accumulated activity counters.
|
||||||
|
#[must_use]
|
||||||
|
pub fn activity_counters(&self) -> &SchedulerActivityCounters {
|
||||||
|
&self.activity_counters
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the latest planning observation when available.
|
||||||
|
#[must_use]
|
||||||
|
pub fn last_planning_observation(&self) -> Option<&SchedulerPlanningObservation> {
|
||||||
|
self.last_planning_observation.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records that a scheduling pass started.
|
||||||
|
pub fn record_schedule_run(&mut self) {
|
||||||
|
self.activity_counters.schedule_run_count =
|
||||||
|
self.activity_counters.schedule_run_count.saturating_add(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records a single scheduling decision in the activity counters.
|
||||||
|
pub fn record_decision(&mut self, decision: &ScheduleDecision) {
|
||||||
|
let kind = match decision {
|
||||||
|
ScheduleDecision::RunNow(_) => {
|
||||||
|
self.activity_counters.run_now_decision_count = self
|
||||||
|
.activity_counters
|
||||||
|
.run_now_decision_count
|
||||||
|
.saturating_add(1);
|
||||||
|
ScheduleDecisionKind::RunNow
|
||||||
|
}
|
||||||
|
ScheduleDecision::Queue(_) => {
|
||||||
|
self.activity_counters.queue_decision_count = self
|
||||||
|
.activity_counters
|
||||||
|
.queue_decision_count
|
||||||
|
.saturating_add(1);
|
||||||
|
ScheduleDecisionKind::Queue
|
||||||
|
}
|
||||||
|
ScheduleDecision::Pause(_) => {
|
||||||
|
self.activity_counters.pause_decision_count = self
|
||||||
|
.activity_counters
|
||||||
|
.pause_decision_count
|
||||||
|
.saturating_add(1);
|
||||||
|
ScheduleDecisionKind::Pause
|
||||||
|
}
|
||||||
|
ScheduleDecision::Remove(_) => {
|
||||||
|
self.activity_counters.remove_decision_count = self
|
||||||
|
.activity_counters
|
||||||
|
.remove_decision_count
|
||||||
|
.saturating_add(1);
|
||||||
|
ScheduleDecisionKind::Remove
|
||||||
|
}
|
||||||
|
ScheduleDecision::RetryLater(_) => {
|
||||||
|
self.activity_counters.retry_later_decision_count = self
|
||||||
|
.activity_counters
|
||||||
|
.retry_later_decision_count
|
||||||
|
.saturating_add(1);
|
||||||
|
ScheduleDecisionKind::RetryLater
|
||||||
|
}
|
||||||
|
ScheduleDecision::Noop => {
|
||||||
|
self.activity_counters.noop_decision_count =
|
||||||
|
self.activity_counters.noop_decision_count.saturating_add(1);
|
||||||
|
ScheduleDecisionKind::Noop
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.activity_counters.last_decision = Some(kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chooses the next coarse action for the provided download group.
|
||||||
|
#[must_use]
|
||||||
|
pub fn decide(&self, group: &RequestGroup) -> ScheduleDecision {
|
||||||
|
match group.status() {
|
||||||
|
DownloadStatus::Waiting => ScheduleDecision::Queue(group.gid()),
|
||||||
|
DownloadStatus::Paused => ScheduleDecision::Pause(group.gid()),
|
||||||
|
DownloadStatus::Removed => ScheduleDecision::Remove(group.gid()),
|
||||||
|
DownloadStatus::Error => ScheduleDecision::RetryLater(group.gid()),
|
||||||
|
DownloadStatus::Complete => ScheduleDecision::Noop,
|
||||||
|
DownloadStatus::Active => ScheduleDecision::RunNow(group.gid()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances the scheduler lifecycle by one tick.
|
||||||
|
#[must_use]
|
||||||
|
pub fn tick(&mut self) -> SchedulerState {
|
||||||
|
self.activity_counters.tick_count = self.activity_counters.tick_count.saturating_add(1);
|
||||||
|
self.state = match self.state {
|
||||||
|
SchedulerState::Idle => SchedulerState::Ready,
|
||||||
|
SchedulerState::Ready | SchedulerState::Running => SchedulerState::Running,
|
||||||
|
SchedulerState::Paused => SchedulerState::Paused,
|
||||||
|
SchedulerState::ShuttingDown | SchedulerState::Stopped => SchedulerState::Stopped,
|
||||||
|
};
|
||||||
|
self.state
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the segment-plan snapshot mirrored into session state.
|
||||||
|
#[must_use]
|
||||||
|
pub fn bridge_segment_plan(&self, runtime: &RuntimeConfig) -> SegmentPlan {
|
||||||
|
let _ = self;
|
||||||
|
SegmentPlan::from_runtime(runtime)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the runtime-state snapshot mirrored into session state.
|
||||||
|
pub fn bridge_runtime_state(
|
||||||
|
&self,
|
||||||
|
completed_length: u64,
|
||||||
|
retry_count: u32,
|
||||||
|
retry_history: Vec<RetryHistoryEntry>,
|
||||||
|
active_segments: usize,
|
||||||
|
) -> RuntimeScheduleState {
|
||||||
|
let _ = self;
|
||||||
|
RuntimeScheduleState {
|
||||||
|
completed_length,
|
||||||
|
retry_count,
|
||||||
|
retry_history,
|
||||||
|
active_segments,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes the number of active segments the scheduler should plan.
|
||||||
|
#[must_use]
|
||||||
|
pub fn plan_active_segments(&self, group: &RequestGroup, runtime: &RuntimeConfig) -> usize {
|
||||||
|
if !matches!(
|
||||||
|
group.status(),
|
||||||
|
DownloadStatus::Active | DownloadStatus::Waiting
|
||||||
|
) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let split = runtime.effective_split();
|
||||||
|
let max_conn = runtime.effective_max_connections_per_server();
|
||||||
|
let max_parallel = split.min(max_conn).max(1);
|
||||||
|
let min_split_size = runtime.min_split_size.max(1);
|
||||||
|
|
||||||
|
let total = Self::effective_plannable_total_length(group);
|
||||||
|
let completed = group.completed_length().min(total);
|
||||||
|
let remaining = total.saturating_sub(completed);
|
||||||
|
if remaining == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let by_size = usize::try_from(remaining.div_ceil(min_split_size)).unwrap_or(usize::MAX);
|
||||||
|
max_parallel.min(by_size.max(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Captures a planning observation for later inspection and persistence.
|
||||||
|
pub fn observe_plan(
|
||||||
|
&mut self,
|
||||||
|
group: &RequestGroup,
|
||||||
|
_runtime: &RuntimeConfig,
|
||||||
|
planned_segments: usize,
|
||||||
|
) {
|
||||||
|
let plannable_length = Self::effective_plannable_total_length(group);
|
||||||
|
let completed_length = group.completed_length().min(plannable_length);
|
||||||
|
let remaining_bytes = plannable_length.saturating_sub(completed_length);
|
||||||
|
let pressure = group.bt_pressure_snapshot();
|
||||||
|
|
||||||
|
self.last_planning_observation = Some(SchedulerPlanningObservation {
|
||||||
|
gid: group.gid(),
|
||||||
|
total_length: group.total_length(),
|
||||||
|
plannable_length,
|
||||||
|
completed_length,
|
||||||
|
remaining_bytes,
|
||||||
|
planned_segments,
|
||||||
|
active_segment_count: usize::try_from(group.num_connections()).unwrap_or(usize::MAX),
|
||||||
|
requestable_pieces: pressure
|
||||||
|
.as_ref()
|
||||||
|
.map_or(0, |snapshot| snapshot.requestable_pieces),
|
||||||
|
active_piece_count: pressure
|
||||||
|
.as_ref()
|
||||||
|
.map_or(0, |snapshot| snapshot.active_pieces),
|
||||||
|
available_requestable_pieces: pressure
|
||||||
|
.as_ref()
|
||||||
|
.map_or(0, |snapshot| snapshot.available_requestable_pieces),
|
||||||
|
scarce_requestable_pieces: pressure
|
||||||
|
.as_ref()
|
||||||
|
.map_or(0, |snapshot| snapshot.scarce_requestable_pieces),
|
||||||
|
peer_count: pressure.as_ref().map_or(0, |snapshot| snapshot.peer_count),
|
||||||
|
bt_endgame_ready: pressure.as_ref().is_some_and(|snapshot| {
|
||||||
|
snapshot.requestable_pieces > 0
|
||||||
|
&& snapshot.requestable_pieces <= planned_segments.max(1)
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selects candidate `BitTorrent` pieces that are eligible for requests.
|
||||||
|
pub fn select_bt_piece_candidates(
|
||||||
|
&self,
|
||||||
|
group: &RequestGroup,
|
||||||
|
options: BtPieceSelectionOptions,
|
||||||
|
) -> Vec<PieceId> {
|
||||||
|
let _ = self;
|
||||||
|
group.bt_requestable_piece_ids(
|
||||||
|
options.endgame_mode && options.include_downloading_in_endgame,
|
||||||
|
options.max_candidates.max(1),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds piece-aligned byte ranges for the selected `BitTorrent` pieces.
|
||||||
|
pub fn plan_bt_piece_request_ranges(
|
||||||
|
&self,
|
||||||
|
group: &RequestGroup,
|
||||||
|
runtime: &RuntimeConfig,
|
||||||
|
options: BtPieceSelectionOptions,
|
||||||
|
) -> Vec<PieceRange> {
|
||||||
|
let _ = self;
|
||||||
|
let piece_length = runtime.piece_length.max(1);
|
||||||
|
self.select_bt_piece_candidates(group, options)
|
||||||
|
.into_iter()
|
||||||
|
.map(|piece_id| {
|
||||||
|
let start = u64::from(piece_id.0).saturating_mul(piece_length);
|
||||||
|
PieceRange::new(start, start.saturating_add(piece_length))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counts pieces that still require `BitTorrent` work.
|
||||||
|
pub fn bt_remaining_piece_count(&self, group: &RequestGroup) -> usize {
|
||||||
|
let _ = self;
|
||||||
|
group
|
||||||
|
.piece_map()
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, state)| {
|
||||||
|
matches!(
|
||||||
|
state,
|
||||||
|
PieceState::Pending
|
||||||
|
| PieceState::Queued
|
||||||
|
| PieceState::Missing
|
||||||
|
| PieceState::Downloading
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod scheduler_tests;
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::{
|
||||||
|
piece::{PieceId, PieceState},
|
||||||
|
request::{BtFileInfo, BtPeerInfo, BtRuntimeState, DownloadId},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn segment_plan_uses_runtime_limits() {
|
||||||
|
let runtime = RuntimeConfig {
|
||||||
|
split: 8,
|
||||||
|
max_connections_per_server: 3,
|
||||||
|
max_connection_per_server: 2,
|
||||||
|
min_split_size: 1024,
|
||||||
|
..RuntimeConfig::default()
|
||||||
|
};
|
||||||
|
let plan = SegmentPlan::from_runtime(&runtime);
|
||||||
|
assert_eq!(plan.split, 8);
|
||||||
|
assert_eq!(plan.max_connections_per_server, 3);
|
||||||
|
assert_eq!(plan.min_split_size, 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plan_active_segments_respects_remaining_size_and_limits() {
|
||||||
|
let scheduler = Scheduler::new();
|
||||||
|
let runtime = RuntimeConfig {
|
||||||
|
split: 6,
|
||||||
|
max_connections_per_server: 4,
|
||||||
|
max_connection_per_server: 4,
|
||||||
|
min_split_size: 1024,
|
||||||
|
..RuntimeConfig::default()
|
||||||
|
};
|
||||||
|
let mut group = RequestGroup::new(DownloadId::new(1), "https://example.org/file.bin");
|
||||||
|
group.set_status(DownloadStatus::Active);
|
||||||
|
group.set_total_length(10 * 1024);
|
||||||
|
group.set_completed_length(2 * 1024);
|
||||||
|
|
||||||
|
assert_eq!(scheduler.plan_active_segments(&group, &runtime), 4);
|
||||||
|
|
||||||
|
group.set_completed_length(9 * 1024 + 900);
|
||||||
|
assert_eq!(scheduler.plan_active_segments(&group, &runtime), 1);
|
||||||
|
|
||||||
|
group.set_completed_length(group.total_length());
|
||||||
|
assert_eq!(scheduler.plan_active_segments(&group, &runtime), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plan_active_segments_returns_zero_for_non_runnable_states() {
|
||||||
|
let scheduler = Scheduler::new();
|
||||||
|
let runtime = RuntimeConfig::default();
|
||||||
|
let mut group = RequestGroup::new(DownloadId::new(2), "https://example.org/file.bin");
|
||||||
|
group.set_total_length(2048);
|
||||||
|
group.set_completed_length(0);
|
||||||
|
group.set_status(DownloadStatus::Paused);
|
||||||
|
assert_eq!(scheduler.plan_active_segments(&group, &runtime), 0);
|
||||||
|
group.set_status(DownloadStatus::Error);
|
||||||
|
assert_eq!(scheduler.plan_active_segments(&group, &runtime), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plan_active_segments_returns_zero_for_bt_metadata_only() {
|
||||||
|
let scheduler = Scheduler::new();
|
||||||
|
let runtime = RuntimeConfig {
|
||||||
|
split: 4,
|
||||||
|
max_connections_per_server: 4,
|
||||||
|
max_connection_per_server: 4,
|
||||||
|
min_split_size: 1024,
|
||||||
|
..RuntimeConfig::default()
|
||||||
|
};
|
||||||
|
let mut group = RequestGroup::new(DownloadId::new(3), "magnet:?xt=urn:btih:ABC");
|
||||||
|
group.set_status(DownloadStatus::Active);
|
||||||
|
group.set_total_length(8 * 1024);
|
||||||
|
group.set_completed_length(1024);
|
||||||
|
group.set_bt(BtRuntimeState {
|
||||||
|
metadata_only: true,
|
||||||
|
..BtRuntimeState::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(scheduler.plan_active_segments(&group, &runtime), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plan_active_segments_uses_bt_selected_files_length() {
|
||||||
|
let scheduler = Scheduler::new();
|
||||||
|
let runtime = RuntimeConfig {
|
||||||
|
split: 8,
|
||||||
|
max_connections_per_server: 8,
|
||||||
|
max_connection_per_server: 8,
|
||||||
|
min_split_size: 1024,
|
||||||
|
..RuntimeConfig::default()
|
||||||
|
};
|
||||||
|
let mut group = RequestGroup::new(DownloadId::new(4), "magnet:?xt=urn:btih:DEF");
|
||||||
|
group.set_status(DownloadStatus::Active);
|
||||||
|
group.set_total_length(10 * 1024);
|
||||||
|
group.set_completed_length(3500);
|
||||||
|
group.set_bt(BtRuntimeState {
|
||||||
|
files: vec![
|
||||||
|
BtFileInfo {
|
||||||
|
path: "wanted.bin".to_owned(),
|
||||||
|
length: 4 * 1024,
|
||||||
|
piece_offset: Some(0),
|
||||||
|
selected: true,
|
||||||
|
},
|
||||||
|
BtFileInfo {
|
||||||
|
path: "unwanted.bin".to_owned(),
|
||||||
|
length: 6 * 1024,
|
||||||
|
piece_offset: Some(4),
|
||||||
|
selected: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
..BtRuntimeState::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Selected total is 4096; after completed 3500 only 596 bytes remain,
|
||||||
|
// so the scheduler should avoid over-planning and keep a single segment.
|
||||||
|
assert_eq!(scheduler.plan_active_segments(&group, &runtime), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scheduler_selects_bt_piece_candidates_and_endgame_behavior() {
|
||||||
|
let scheduler = Scheduler::new();
|
||||||
|
let mut group = RequestGroup::new(DownloadId::new(5), "magnet:?xt=urn:btih:FFF");
|
||||||
|
group.set_piece_state(PieceId(0), PieceState::Verified);
|
||||||
|
group.set_piece_state(PieceId(1), PieceState::Pending);
|
||||||
|
group.set_piece_state(PieceId(2), PieceState::Missing);
|
||||||
|
group.set_piece_state(PieceId(3), PieceState::Downloading);
|
||||||
|
group.set_piece_state(PieceId(4), PieceState::Queued);
|
||||||
|
|
||||||
|
let normal = scheduler.select_bt_piece_candidates(
|
||||||
|
&group,
|
||||||
|
BtPieceSelectionOptions {
|
||||||
|
max_candidates: 8,
|
||||||
|
include_downloading_in_endgame: true,
|
||||||
|
endgame_mode: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(normal, vec![PieceId(1), PieceId(2), PieceId(4)]);
|
||||||
|
|
||||||
|
let endgame = scheduler.select_bt_piece_candidates(
|
||||||
|
&group,
|
||||||
|
BtPieceSelectionOptions {
|
||||||
|
max_candidates: 8,
|
||||||
|
include_downloading_in_endgame: true,
|
||||||
|
endgame_mode: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
endgame,
|
||||||
|
vec![PieceId(1), PieceId(2), PieceId(4), PieceId(3)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scheduler_plans_bt_piece_ranges_from_runtime_piece_length() {
|
||||||
|
let scheduler = Scheduler::new();
|
||||||
|
let runtime = RuntimeConfig {
|
||||||
|
piece_length: 1024,
|
||||||
|
..RuntimeConfig::default()
|
||||||
|
};
|
||||||
|
let mut group = RequestGroup::new(DownloadId::new(6), "magnet:?xt=urn:btih:GGG");
|
||||||
|
group.set_piece_state(PieceId(2), PieceState::Pending);
|
||||||
|
group.set_piece_state(PieceId(5), PieceState::Missing);
|
||||||
|
|
||||||
|
let ranges = scheduler.plan_bt_piece_request_ranges(
|
||||||
|
&group,
|
||||||
|
&runtime,
|
||||||
|
BtPieceSelectionOptions {
|
||||||
|
max_candidates: 2,
|
||||||
|
include_downloading_in_endgame: false,
|
||||||
|
endgame_mode: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
ranges,
|
||||||
|
vec![PieceRange::new(2048, 3072), PieceRange::new(5120, 6144)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_schedule_state_reports_endgame_readiness() {
|
||||||
|
assert!(RuntimeScheduleState::is_endgame_ready(1, 3));
|
||||||
|
assert!(RuntimeScheduleState::is_endgame_ready(3, 3));
|
||||||
|
assert!(!RuntimeScheduleState::is_endgame_ready(4, 3));
|
||||||
|
assert!(!RuntimeScheduleState::is_endgame_ready(0, 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scheduler_activity_counters_track_ticks_and_decisions() {
|
||||||
|
let mut scheduler = Scheduler::new();
|
||||||
|
assert_eq!(scheduler.activity_counters().tick_count, 0);
|
||||||
|
assert_eq!(scheduler.activity_counters().schedule_run_count, 0);
|
||||||
|
|
||||||
|
scheduler.record_schedule_run();
|
||||||
|
let _ = scheduler.tick();
|
||||||
|
scheduler.record_decision(&ScheduleDecision::Queue(DownloadId::new(0x21)));
|
||||||
|
scheduler.record_decision(&ScheduleDecision::RunNow(DownloadId::new(0x21)));
|
||||||
|
scheduler.record_decision(&ScheduleDecision::RetryLater(DownloadId::new(0x21)));
|
||||||
|
scheduler.record_decision(&ScheduleDecision::Noop);
|
||||||
|
|
||||||
|
let counters = scheduler.activity_counters();
|
||||||
|
assert_eq!(counters.tick_count, 1);
|
||||||
|
assert_eq!(counters.schedule_run_count, 1);
|
||||||
|
assert_eq!(counters.queue_decision_count, 1);
|
||||||
|
assert_eq!(counters.run_now_decision_count, 1);
|
||||||
|
assert_eq!(counters.retry_later_decision_count, 1);
|
||||||
|
assert_eq!(counters.noop_decision_count, 1);
|
||||||
|
assert_eq!(counters.last_decision, Some(ScheduleDecisionKind::Noop));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scheduler_records_last_planning_observation_for_bt_pressure() {
|
||||||
|
let mut scheduler = Scheduler::new();
|
||||||
|
let runtime = RuntimeConfig {
|
||||||
|
split: 5,
|
||||||
|
max_connections_per_server: 3,
|
||||||
|
max_connection_per_server: 3,
|
||||||
|
min_split_size: 1024,
|
||||||
|
..RuntimeConfig::default()
|
||||||
|
};
|
||||||
|
let mut group = RequestGroup::new(DownloadId::new(0x22), "magnet:?xt=urn:btih:PRESSURE");
|
||||||
|
group.set_status(DownloadStatus::Active);
|
||||||
|
group.set_total_length(6 * 1024);
|
||||||
|
group.set_completed_length(1024);
|
||||||
|
group.set_piece_state(PieceId(0), PieceState::Verified);
|
||||||
|
group.set_piece_state(PieceId(1), PieceState::Pending);
|
||||||
|
group.set_piece_state(PieceId(2), PieceState::Downloading);
|
||||||
|
group.set_piece_state(PieceId(3), PieceState::Queued);
|
||||||
|
group.set_piece_state(PieceId(4), PieceState::Missing);
|
||||||
|
group.set_bt(BtRuntimeState {
|
||||||
|
metadata_only: false,
|
||||||
|
files: vec![BtFileInfo {
|
||||||
|
path: "payload.bin".to_owned(),
|
||||||
|
length: 6 * 1024,
|
||||||
|
piece_offset: Some(0),
|
||||||
|
selected: true,
|
||||||
|
}],
|
||||||
|
peers: vec![
|
||||||
|
BtPeerInfo {
|
||||||
|
peer_id: Some("peer-a".to_owned()),
|
||||||
|
ip: "192.0.2.1".to_owned(),
|
||||||
|
port: 6881,
|
||||||
|
client_name: None,
|
||||||
|
interested: true,
|
||||||
|
choked: false,
|
||||||
|
download_speed: 64,
|
||||||
|
upload_speed: 32,
|
||||||
|
seeder: false,
|
||||||
|
},
|
||||||
|
BtPeerInfo {
|
||||||
|
peer_id: Some("peer-b".to_owned()),
|
||||||
|
ip: "192.0.2.2".to_owned(),
|
||||||
|
port: 6882,
|
||||||
|
client_name: None,
|
||||||
|
interested: false,
|
||||||
|
choked: true,
|
||||||
|
download_speed: 0,
|
||||||
|
upload_speed: 16,
|
||||||
|
seeder: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
..BtRuntimeState::default()
|
||||||
|
});
|
||||||
|
group.apply_bt_piece_availability_update(crate::request::BtPieceAvailabilityUpdate {
|
||||||
|
piece_id: PieceId(1),
|
||||||
|
peers_with_piece: 1,
|
||||||
|
});
|
||||||
|
group.apply_bt_piece_availability_update(crate::request::BtPieceAvailabilityUpdate {
|
||||||
|
piece_id: PieceId(3),
|
||||||
|
peers_with_piece: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
let planned = scheduler.plan_active_segments(&group, &runtime);
|
||||||
|
scheduler.observe_plan(&group, &runtime, planned);
|
||||||
|
|
||||||
|
let observation = scheduler
|
||||||
|
.last_planning_observation()
|
||||||
|
.expect("planning observation should be recorded");
|
||||||
|
assert_eq!(observation.gid, DownloadId::new(0x22));
|
||||||
|
assert_eq!(observation.planned_segments, 3);
|
||||||
|
assert_eq!(observation.remaining_bytes, 5 * 1024);
|
||||||
|
assert_eq!(observation.requestable_pieces, 3);
|
||||||
|
assert_eq!(observation.active_piece_count, 2);
|
||||||
|
assert_eq!(observation.available_requestable_pieces, 2);
|
||||||
|
assert_eq!(observation.scarce_requestable_pieces, 1);
|
||||||
|
assert_eq!(observation.peer_count, 2);
|
||||||
|
assert!(observation.bt_endgame_ready);
|
||||||
|
}
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
//! Session state, runtime option projection, and persistence bridge snapshots.
|
||||||
|
|
||||||
|
use std::{collections::BTreeMap, path::PathBuf};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
error::{CoreError, Result},
|
||||||
|
options::{OptionKey, OptionPatch, OptionValue},
|
||||||
|
progress::GlobalStat,
|
||||||
|
runtime::{RuntimeConfig, parse_human_size_text},
|
||||||
|
scheduler::{
|
||||||
|
RetryHistoryEntry, RuntimeScheduleState, SchedulerActivityCounters,
|
||||||
|
SchedulerPlanningObservation, SegmentPlan,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// High-level lifecycle state for the session and engine.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum SessionState {
|
||||||
|
/// No active runtime work is happening.
|
||||||
|
Idle,
|
||||||
|
/// The runtime is actively processing downloads.
|
||||||
|
Running,
|
||||||
|
/// Work is paused but can be resumed.
|
||||||
|
Paused,
|
||||||
|
/// Session data is currently being saved.
|
||||||
|
Saving,
|
||||||
|
/// Graceful shutdown has been requested.
|
||||||
|
ShuttingDown,
|
||||||
|
/// Forced shutdown has been requested.
|
||||||
|
ForceShuttingDown,
|
||||||
|
/// The runtime has stopped.
|
||||||
|
Stopped,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Global option store applied across downloads.
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct GlobalOptions {
|
||||||
|
/// Stored global option values keyed by option name.
|
||||||
|
values: BTreeMap<OptionKey, OptionValue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GlobalOptions {
|
||||||
|
/// Creates an empty global option store.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets or replaces a global option value.
|
||||||
|
pub fn set(&mut self, key: impl Into<OptionKey>, value: impl Into<OptionValue>) {
|
||||||
|
self.values.insert(key.into(), value.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a global option value when present.
|
||||||
|
#[must_use]
|
||||||
|
pub fn get(&self, key: &OptionKey) -> Option<&OptionValue> {
|
||||||
|
self.values.get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all stored global options.
|
||||||
|
#[must_use]
|
||||||
|
pub fn values(&self) -> &BTreeMap<OptionKey, OptionValue> {
|
||||||
|
&self.values
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies every entry from the provided patch.
|
||||||
|
pub fn apply_patch(&mut self, patch: OptionPatch) {
|
||||||
|
self.values.extend(patch.entries().clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Target used when saving or loading a session snapshot.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum SaveSessionTarget {
|
||||||
|
/// Use the in-memory snapshot slot.
|
||||||
|
Memory,
|
||||||
|
/// Use a snapshot associated with a persisted path.
|
||||||
|
Path(PathBuf),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory session state plus persisted snapshots and scheduler bridge data.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct Session {
|
||||||
|
/// Runtime configuration projected from defaults and global options.
|
||||||
|
runtime: RuntimeConfig,
|
||||||
|
/// Current high-level session state.
|
||||||
|
state: SessionState,
|
||||||
|
/// Global option store applied to all downloads.
|
||||||
|
global_options: GlobalOptions,
|
||||||
|
/// Aggregated transfer statistics.
|
||||||
|
stats: GlobalStat,
|
||||||
|
/// Preferred session file path when persistence is configured.
|
||||||
|
session_file: Option<PathBuf>,
|
||||||
|
/// In-memory snapshot slot used for round trips.
|
||||||
|
memory_snapshot: Option<SessionSnapshot>,
|
||||||
|
/// Path-keyed snapshots saved during the current process lifetime.
|
||||||
|
path_snapshots: BTreeMap<PathBuf, SessionSnapshot>,
|
||||||
|
/// Mirrored scheduler and runtime bridge data.
|
||||||
|
bridge: SessionBridge,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal saved session payload used for in-memory and path snapshots.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
/// Saved session payload mirrored into in-memory and path snapshots.
|
||||||
|
struct SessionSnapshot {
|
||||||
|
/// Global options captured at save time.
|
||||||
|
global_options: GlobalOptions,
|
||||||
|
/// Global statistics captured at save time.
|
||||||
|
stats: GlobalStat,
|
||||||
|
/// Scheduler bridge data captured at save time.
|
||||||
|
bridge: SessionBridge,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scheduler and runtime data mirrored into the session snapshot.
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct SessionBridge {
|
||||||
|
/// Effective split count mirrored from scheduler planning.
|
||||||
|
pub split: usize,
|
||||||
|
/// Last active segment plan when present.
|
||||||
|
pub segment_plan: Option<SegmentPlan>,
|
||||||
|
/// Completed payload length mirrored from runtime state.
|
||||||
|
pub completed_length: u64,
|
||||||
|
/// Accumulated retry count mirrored from runtime state.
|
||||||
|
pub retry_count: u32,
|
||||||
|
/// Retry history mirrored from runtime state.
|
||||||
|
pub retry_history: Vec<RetryHistoryEntry>,
|
||||||
|
/// Number of active segments mirrored from runtime state.
|
||||||
|
pub active_segments: usize,
|
||||||
|
/// Scheduler counters mirrored into the session snapshot.
|
||||||
|
pub scheduler_counters: SchedulerActivityCounters,
|
||||||
|
/// Most recent scheduler planning observation when available.
|
||||||
|
pub last_scheduler_plan: Option<SchedulerPlanningObservation>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
/// Creates a new session from runtime configuration defaults.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(runtime: RuntimeConfig) -> Self {
|
||||||
|
let bridge = SessionBridge::from_runtime(&runtime);
|
||||||
|
let session_file = runtime.session_path.clone();
|
||||||
|
Self {
|
||||||
|
session_file,
|
||||||
|
runtime,
|
||||||
|
state: SessionState::Idle,
|
||||||
|
global_options: GlobalOptions::default(),
|
||||||
|
stats: GlobalStat::default(),
|
||||||
|
memory_snapshot: None,
|
||||||
|
path_snapshots: BTreeMap::new(),
|
||||||
|
bridge,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the projected runtime configuration.
|
||||||
|
#[must_use]
|
||||||
|
pub fn runtime(&self) -> &RuntimeConfig {
|
||||||
|
&self.runtime
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current session state.
|
||||||
|
#[must_use]
|
||||||
|
pub fn state(&self) -> &SessionState {
|
||||||
|
&self.state
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the global option store.
|
||||||
|
#[must_use]
|
||||||
|
pub fn global_options(&self) -> &GlobalOptions {
|
||||||
|
&self.global_options
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a mutable reference to the global option store.
|
||||||
|
pub fn global_options_mut(&mut self) -> &mut GlobalOptions {
|
||||||
|
&mut self.global_options
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the global statistics snapshot.
|
||||||
|
#[must_use]
|
||||||
|
pub fn stats(&self) -> &GlobalStat {
|
||||||
|
&self.stats
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a mutable reference to the global statistics snapshot.
|
||||||
|
pub fn stats_mut(&mut self) -> &mut GlobalStat {
|
||||||
|
&mut self.stats
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current session file path when configured.
|
||||||
|
#[must_use]
|
||||||
|
pub fn session_file(&self) -> Option<&PathBuf> {
|
||||||
|
self.session_file.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the session file path.
|
||||||
|
pub fn set_session_file(&mut self, path: impl Into<PathBuf>) {
|
||||||
|
self.session_file = Some(path.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks a session as loaded from an external source path.
|
||||||
|
pub fn mark_external_load(&mut self, path: impl Into<PathBuf>) {
|
||||||
|
self.session_file = Some(path.into());
|
||||||
|
self.state = SessionState::Idle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves the session into the paused state.
|
||||||
|
pub fn pause(&mut self) -> Result<()> {
|
||||||
|
self.state = SessionState::Paused;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves the session into the running state.
|
||||||
|
pub fn resume(&mut self) -> Result<()> {
|
||||||
|
self.state = SessionState::Running;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts graceful shutdown.
|
||||||
|
pub fn shutdown(&mut self) -> Result<()> {
|
||||||
|
self.state = SessionState::ShuttingDown;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts forced shutdown.
|
||||||
|
pub fn force_shutdown(&mut self) -> Result<()> {
|
||||||
|
self.state = SessionState::ForceShuttingDown;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves the current session snapshot to memory or a path target.
|
||||||
|
pub fn save_session(&mut self, target: SaveSessionTarget) -> Result<()> {
|
||||||
|
self.state = SessionState::Saving;
|
||||||
|
let snapshot = SessionSnapshot {
|
||||||
|
global_options: self.global_options.clone(),
|
||||||
|
stats: self.stats,
|
||||||
|
bridge: self.bridge.clone(),
|
||||||
|
};
|
||||||
|
match target {
|
||||||
|
SaveSessionTarget::Memory => {
|
||||||
|
self.memory_snapshot = Some(snapshot);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
SaveSessionTarget::Path(path) => {
|
||||||
|
self.path_snapshots.insert(path.clone(), snapshot);
|
||||||
|
self.session_file = Some(path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads a previously saved session snapshot.
|
||||||
|
pub fn load_session(&mut self, source: SaveSessionTarget) -> Result<()> {
|
||||||
|
let snapshot = match source {
|
||||||
|
SaveSessionTarget::Memory => self
|
||||||
|
.memory_snapshot
|
||||||
|
.as_ref()
|
||||||
|
.ok_or(CoreError::StorageUnavailable(
|
||||||
|
"no in-memory session snapshot available",
|
||||||
|
))?
|
||||||
|
.clone(),
|
||||||
|
SaveSessionTarget::Path(path) => {
|
||||||
|
self.session_file = Some(path.clone());
|
||||||
|
self.path_snapshots
|
||||||
|
.get(&path)
|
||||||
|
.ok_or(CoreError::StorageUnavailable(
|
||||||
|
"no session snapshot available for requested path",
|
||||||
|
))?
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
self.global_options = snapshot.global_options;
|
||||||
|
self.stats = snapshot.stats;
|
||||||
|
self.bridge = snapshot.bridge;
|
||||||
|
self.refresh_runtime_from_global_options();
|
||||||
|
self.state = SessionState::Idle;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets and immediately applies a single global option.
|
||||||
|
pub fn set_global_option(&mut self, key: impl Into<OptionKey>, value: impl Into<OptionValue>) {
|
||||||
|
let key = key.into();
|
||||||
|
let value = value.into();
|
||||||
|
self.apply_runtime_option(&key, &value);
|
||||||
|
self.global_options.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies and stores a patch of global options.
|
||||||
|
pub fn apply_global_option_patch(&mut self, patch: OptionPatch) {
|
||||||
|
for (key, value) in patch.entries() {
|
||||||
|
self.apply_runtime_option(key, value);
|
||||||
|
}
|
||||||
|
self.global_options.apply_patch(patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the mirrored scheduler bridge snapshot.
|
||||||
|
#[must_use]
|
||||||
|
pub fn bridge(&self) -> &SessionBridge {
|
||||||
|
&self.bridge
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a mutable scheduler bridge snapshot.
|
||||||
|
pub fn bridge_mut(&mut self) -> &mut SessionBridge {
|
||||||
|
&mut self.bridge
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the active segment plan stored in the session bridge.
|
||||||
|
pub fn set_segment_plan(&mut self, segment_plan: SegmentPlan) {
|
||||||
|
self.bridge.split = segment_plan.split;
|
||||||
|
self.bridge.segment_plan = Some(segment_plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirrors scheduler runtime state into the bridge snapshot.
|
||||||
|
pub fn apply_runtime_schedule_state(&mut self, state: RuntimeScheduleState) {
|
||||||
|
self.bridge.completed_length = state.completed_length;
|
||||||
|
self.bridge.retry_count = state.retry_count;
|
||||||
|
self.bridge.retry_history = state.retry_history;
|
||||||
|
self.bridge.active_segments = state.active_segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirrors scheduler instrumentation into the bridge snapshot.
|
||||||
|
pub fn apply_scheduler_instrumentation(
|
||||||
|
&mut self,
|
||||||
|
counters: SchedulerActivityCounters,
|
||||||
|
last_plan: Option<SchedulerPlanningObservation>,
|
||||||
|
) {
|
||||||
|
self.bridge.scheduler_counters = counters;
|
||||||
|
self.bridge.last_scheduler_plan = last_plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies a single stored option onto the projected runtime config.
|
||||||
|
fn apply_runtime_option(&mut self, key: &OptionKey, value: &OptionValue) {
|
||||||
|
match key.as_str() {
|
||||||
|
"max-overall-download-limit" => {
|
||||||
|
self.runtime.max_overall_download_limit = parse_optional_limit(value);
|
||||||
|
}
|
||||||
|
"max-download-limit" => {
|
||||||
|
self.runtime.max_download_limit = parse_optional_limit(value);
|
||||||
|
}
|
||||||
|
"max-overall-upload-limit" => {
|
||||||
|
self.runtime.max_overall_upload_limit = parse_optional_limit(value);
|
||||||
|
}
|
||||||
|
"max-upload-limit" => {
|
||||||
|
self.runtime.max_upload_limit = parse_optional_limit(value);
|
||||||
|
}
|
||||||
|
"disk-cache" => {
|
||||||
|
if let Some(value) = parse_option_size(value) {
|
||||||
|
self.runtime.disk_cache_bytes = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuilds runtime projection from every stored global option.
|
||||||
|
fn refresh_runtime_from_global_options(&mut self) {
|
||||||
|
let entries = self.global_options.values().clone();
|
||||||
|
for (key, value) in &entries {
|
||||||
|
self.apply_runtime_option(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionBridge {
|
||||||
|
/// Builds a bridge snapshot from runtime defaults.
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_runtime(runtime: &RuntimeConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
split: runtime.effective_split(),
|
||||||
|
segment_plan: Some(SegmentPlan::from_runtime(runtime)),
|
||||||
|
completed_length: 0,
|
||||||
|
retry_count: 0,
|
||||||
|
retry_history: Vec::new(),
|
||||||
|
active_segments: 0,
|
||||||
|
scheduler_counters: SchedulerActivityCounters::default(),
|
||||||
|
last_scheduler_plan: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a single option value into a byte count when possible.
|
||||||
|
fn parse_option_size(value: &OptionValue) -> Option<u64> {
|
||||||
|
match value {
|
||||||
|
OptionValue::UInt(value) => Some(*value),
|
||||||
|
OptionValue::Int(value) => u64::try_from(*value).ok(),
|
||||||
|
OptionValue::Text(value) => parse_human_size_text(value),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a positive byte-sized limit value from an option.
|
||||||
|
fn parse_optional_limit(value: &OptionValue) -> Option<u64> {
|
||||||
|
parse_option_size(value).filter(|limit| *limit > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
error::{CoreError, Result},
|
||||||
|
runtime::RuntimeConfig,
|
||||||
|
scheduler::{RetryHistoryEntry, RuntimeScheduleState, SegmentPlan},
|
||||||
|
session::{SaveSessionTarget, Session},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_round_trip_memory_snapshot() -> Result<()> {
|
||||||
|
let mut session = Session::new(RuntimeConfig::default());
|
||||||
|
session.set_global_option("max-concurrent-downloads", "8");
|
||||||
|
session.stats_mut().download_speed = 1024;
|
||||||
|
session.save_session(SaveSessionTarget::Memory)?;
|
||||||
|
|
||||||
|
session.set_global_option("max-concurrent-downloads", "1");
|
||||||
|
session.stats_mut().download_speed = 1;
|
||||||
|
session.load_session(SaveSessionTarget::Memory)?;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
session
|
||||||
|
.global_options()
|
||||||
|
.get(&"max-concurrent-downloads".into())
|
||||||
|
.and_then(|v| v.as_text()),
|
||||||
|
Some("8")
|
||||||
|
);
|
||||||
|
assert_eq!(session.stats().download_speed, 1024);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_load_path_snapshot_updates_runtime_target() -> Result<()> {
|
||||||
|
let mut session = Session::new(RuntimeConfig::default());
|
||||||
|
let path = PathBuf::from("session-a2.txt");
|
||||||
|
session.set_global_option("dir", "/srv/aria2/a2");
|
||||||
|
session.save_session(SaveSessionTarget::Path(path.clone()))?;
|
||||||
|
|
||||||
|
session.set_global_option("dir", "/srv/aria2/override");
|
||||||
|
session.load_session(SaveSessionTarget::Path(path.clone()))?;
|
||||||
|
|
||||||
|
assert_eq!(session.session_file(), Some(&path));
|
||||||
|
assert_eq!(
|
||||||
|
session
|
||||||
|
.global_options()
|
||||||
|
.get(&"dir".into())
|
||||||
|
.and_then(|v| v.as_text()),
|
||||||
|
Some("/srv/aria2/a2")
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_load_missing_snapshot_reports_storage_unavailable() {
|
||||||
|
let mut session = Session::new(RuntimeConfig::default());
|
||||||
|
let result = session.load_session(SaveSessionTarget::Path(PathBuf::from("missing.txt")));
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Err(CoreError::StorageUnavailable(
|
||||||
|
"no session snapshot available for requested path"
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_bridge_round_trip_persists_completed_and_retry_state() -> Result<()> {
|
||||||
|
let mut session = Session::new(RuntimeConfig::default());
|
||||||
|
session.set_segment_plan(SegmentPlan {
|
||||||
|
split: 6,
|
||||||
|
min_split_size: 1024,
|
||||||
|
piece_length: 1024,
|
||||||
|
max_connections_per_server: 3,
|
||||||
|
});
|
||||||
|
session.apply_runtime_schedule_state(RuntimeScheduleState {
|
||||||
|
completed_length: 8192,
|
||||||
|
retry_count: 2,
|
||||||
|
retry_history: vec![RetryHistoryEntry {
|
||||||
|
at_unix_secs: 1_700_000_000,
|
||||||
|
reason: "http 403".to_string(),
|
||||||
|
}],
|
||||||
|
active_segments: 2,
|
||||||
|
});
|
||||||
|
session.save_session(SaveSessionTarget::Memory)?;
|
||||||
|
|
||||||
|
session.apply_runtime_schedule_state(RuntimeScheduleState {
|
||||||
|
completed_length: 4,
|
||||||
|
retry_count: 0,
|
||||||
|
retry_history: Vec::new(),
|
||||||
|
active_segments: 0,
|
||||||
|
});
|
||||||
|
session.load_session(SaveSessionTarget::Memory)?;
|
||||||
|
|
||||||
|
assert_eq!(session.bridge().split, 6);
|
||||||
|
assert_eq!(session.bridge().completed_length, 8192);
|
||||||
|
assert_eq!(session.bridge().retry_count, 2);
|
||||||
|
assert_eq!(session.bridge().retry_history.len(), 1);
|
||||||
|
assert_eq!(session.bridge().active_segments, 2);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_bridge_round_trip_persists_scheduler_instrumentation() -> Result<()> {
|
||||||
|
let mut session = Session::new(RuntimeConfig::default());
|
||||||
|
let counters = crate::scheduler::SchedulerActivityCounters {
|
||||||
|
tick_count: 3,
|
||||||
|
schedule_run_count: 2,
|
||||||
|
queue_decision_count: 1,
|
||||||
|
run_now_decision_count: 1,
|
||||||
|
last_decision: Some(crate::scheduler::ScheduleDecisionKind::RunNow),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
session.bridge_mut().scheduler_counters = counters;
|
||||||
|
session.bridge_mut().last_scheduler_plan =
|
||||||
|
Some(crate::scheduler::SchedulerPlanningObservation {
|
||||||
|
gid: crate::request::DownloadId::new(0x44),
|
||||||
|
total_length: 4096,
|
||||||
|
plannable_length: 4096,
|
||||||
|
completed_length: 1024,
|
||||||
|
remaining_bytes: 3072,
|
||||||
|
planned_segments: 3,
|
||||||
|
active_segment_count: 2,
|
||||||
|
requestable_pieces: 2,
|
||||||
|
active_piece_count: 1,
|
||||||
|
available_requestable_pieces: 1,
|
||||||
|
scarce_requestable_pieces: 1,
|
||||||
|
peer_count: 4,
|
||||||
|
bt_endgame_ready: true,
|
||||||
|
});
|
||||||
|
session.save_session(SaveSessionTarget::Memory)?;
|
||||||
|
session.bridge_mut().scheduler_counters =
|
||||||
|
crate::scheduler::SchedulerActivityCounters::default();
|
||||||
|
session.bridge_mut().last_scheduler_plan = None;
|
||||||
|
|
||||||
|
session.load_session(SaveSessionTarget::Memory)?;
|
||||||
|
|
||||||
|
assert_eq!(session.bridge().scheduler_counters, counters);
|
||||||
|
assert_eq!(
|
||||||
|
session
|
||||||
|
.bridge()
|
||||||
|
.last_scheduler_plan
|
||||||
|
.as_ref()
|
||||||
|
.map(|plan| plan.remaining_bytes),
|
||||||
|
Some(3072)
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_global_speed_and_cache_options_mutate_runtime_surface() {
|
||||||
|
let mut session = Session::new(RuntimeConfig::default());
|
||||||
|
session.set_global_option("max-overall-download-limit", "8M");
|
||||||
|
session.set_global_option("max-download-limit", "2M");
|
||||||
|
session.set_global_option("max-overall-upload-limit", "4M");
|
||||||
|
session.set_global_option("max-upload-limit", "1M");
|
||||||
|
session.set_global_option("disk-cache", "32M");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
session.runtime().max_overall_download_limit,
|
||||||
|
Some(8 * 1024 * 1024)
|
||||||
|
);
|
||||||
|
assert_eq!(session.runtime().max_download_limit, Some(2 * 1024 * 1024));
|
||||||
|
assert_eq!(
|
||||||
|
session.runtime().max_overall_upload_limit,
|
||||||
|
Some(4 * 1024 * 1024)
|
||||||
|
);
|
||||||
|
assert_eq!(session.runtime().max_upload_limit, Some(1024 * 1024));
|
||||||
|
assert_eq!(session.runtime().disk_cache_bytes, 32 * 1024 * 1024);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
[package]
|
||||||
|
name = "aria2-rust-pro-protocol"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
description.workspace = true
|
||||||
|
readme.workspace = true
|
||||||
|
keywords.workspace = true
|
||||||
|
categories.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "aria2_rust_pro_protocol"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
adler2 = "2"
|
||||||
|
crc32fast = "1"
|
||||||
|
md-5 = "0.10"
|
||||||
|
quick-xml = "0.38"
|
||||||
|
sha1 = "0.10"
|
||||||
|
sha2 = "0.10"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
|
||||||
|
aria2-rust-pro-storage.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
//! Authentication models shared across protocol connectors.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Authentication scheme recognized by the protocol layer.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum AuthScheme {
|
||||||
|
/// HTTP Basic authentication.
|
||||||
|
Basic,
|
||||||
|
/// HTTP Digest authentication.
|
||||||
|
Digest,
|
||||||
|
/// Bearer-token authentication.
|
||||||
|
Bearer,
|
||||||
|
/// SPNEGO or Negotiate authentication.
|
||||||
|
Negotiate,
|
||||||
|
/// NTLM authentication.
|
||||||
|
Ntlm,
|
||||||
|
/// OAuth2-derived bearer flows.
|
||||||
|
OAuth2,
|
||||||
|
/// Caller accepts any supported scheme.
|
||||||
|
Any,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authentication material supplied to a protocol connector.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct AuthCredentialModel {
|
||||||
|
/// Scheme the credential applies to.
|
||||||
|
pub scheme: AuthScheme,
|
||||||
|
/// Optional username component.
|
||||||
|
pub username: Option<String>,
|
||||||
|
/// Optional password or shared secret.
|
||||||
|
pub password: Option<String>,
|
||||||
|
/// Optional opaque bearer token.
|
||||||
|
pub token: Option<String>,
|
||||||
|
/// Optional authentication realm.
|
||||||
|
pub realm: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authentication challenge emitted by a server.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct AuthChallengeModel {
|
||||||
|
/// Challenged scheme.
|
||||||
|
pub scheme: AuthScheme,
|
||||||
|
/// Optional realm attached to the challenge.
|
||||||
|
pub realm: Option<String>,
|
||||||
|
/// Additional challenge parameters keyed by attribute name.
|
||||||
|
pub parameters: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cached credential entry associated with an origin.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct AuthCacheEntry {
|
||||||
|
/// Origin or protection-space key.
|
||||||
|
pub origin: String,
|
||||||
|
/// Credential cached for the origin.
|
||||||
|
pub credential: AuthCredentialModel,
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
//! BitTorrent-oriented re-exports from the protocol crate.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
pub use crate::{
|
||||||
|
bt_metalink::{
|
||||||
|
BtMetalinkError, MagnetUri, MetalinkDocument, ParserStatus, ProtocolSupportMatrix,
|
||||||
|
ProtocolSupportState, TorrentMetadata, TorrentMetadataError, protocol_support_matrix,
|
||||||
|
},
|
||||||
|
magnet::{MagnetBootstrapModel, MagnetMetadataModel, MagnetUriModel, parse_magnet_bootstrap},
|
||||||
|
metalink::{
|
||||||
|
MetalinkChecksumModel, MetalinkDocumentModel, MetalinkFileModel, MetalinkParseResult,
|
||||||
|
MetalinkParserModel, MetalinkResourceModel,
|
||||||
|
},
|
||||||
|
torrent::{
|
||||||
|
DhtMessageModel, PeerWireExtensionHandshakeModel, PeerWireMessageModel,
|
||||||
|
PeerWireMetadataMessageModel, PeerWireMetadataMessageType, TorrentBootstrapModel,
|
||||||
|
TorrentFileEntryModel, TorrentHashModel, TorrentInfoModel, TorrentMessageModel,
|
||||||
|
TorrentMetadataModel, TorrentPeerModel, TorrentPieceModel, TorrentTrackerModel,
|
||||||
|
parse_torrent_bootstrap,
|
||||||
|
},
|
||||||
|
tracker::{DhtNodeModel, TrackerPeerListModel, TrackerRequestModel},
|
||||||
|
};
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
//! Compatibility wrappers that bridge BitTorrent, magnet, and Metalink models.
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
use std::fmt::{Display, Formatter};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
magnet::{MagnetUriModel, parse_magnet_uri},
|
||||||
|
metalink::{MetalinkDocumentModel, parse_metalink_document},
|
||||||
|
torrent::{TorrentMetadataModel, parse_torrent_metadata},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Declares how far a protocol family has progressed in the current implementation.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ProtocolSupportState {
|
||||||
|
/// The protocol is known but not yet wired into the workspace.
|
||||||
|
Planned,
|
||||||
|
/// Parsing and model registration exist, but transfer execution is pending.
|
||||||
|
Registered,
|
||||||
|
/// A compatibility skeleton is present and exposes the public API shape.
|
||||||
|
Skeleton,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Summarizes protocol readiness across BitTorrent-adjacent inputs.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct ProtocolSupportMatrix {
|
||||||
|
/// Current state of `.torrent` metadata handling.
|
||||||
|
pub bit_torrent: ProtocolSupportState,
|
||||||
|
/// Current state of magnet URI handling.
|
||||||
|
pub magnet: ProtocolSupportState,
|
||||||
|
/// Current state of Metalink XML handling.
|
||||||
|
pub metalink: ProtocolSupportState,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current protocol support matrix exposed by this compatibility layer.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn protocol_support_matrix() -> ProtocolSupportMatrix {
|
||||||
|
ProtocolSupportMatrix {
|
||||||
|
bit_torrent: ProtocolSupportState::Skeleton,
|
||||||
|
magnet: ProtocolSupportState::Registered,
|
||||||
|
metalink: ProtocolSupportState::Registered,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compatibility wrapper for parsed magnet URI metadata.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct MagnetUri {
|
||||||
|
/// Parsed BTIH info hash.
|
||||||
|
pub info_hash: String,
|
||||||
|
/// Optional display name from the `dn` query field.
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
/// Ordered tracker URLs from `tr` query fields.
|
||||||
|
pub trackers: Vec<String>,
|
||||||
|
/// Ordered web-seed URLs from `ws` query fields.
|
||||||
|
pub web_seeds: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compatibility wrapper for parsed Metalink documents.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct MetalinkDocument {
|
||||||
|
/// Root element name used for diagnostics.
|
||||||
|
pub root_element: String,
|
||||||
|
/// Parser state describing whether a real model was produced.
|
||||||
|
pub status: ParserStatus,
|
||||||
|
/// Parsed Metalink document model when parsing succeeded.
|
||||||
|
pub document: Option<MetalinkDocumentModel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records whether a compatibility parser stayed stubbed or produced a model.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ParserStatus {
|
||||||
|
/// Parsing support is registered but no real model was built.
|
||||||
|
RegisteredStub,
|
||||||
|
/// Parsing produced a protocol-layer document model.
|
||||||
|
ParsedModel,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors raised while converting BitTorrent-adjacent formats into compatibility models.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum BtMetalinkError {
|
||||||
|
/// The magnet URI was malformed.
|
||||||
|
InvalidMagnet {
|
||||||
|
/// Parser-specific explanation of the magnet failure.
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
/// The requested torrent feature is not yet implemented.
|
||||||
|
UnsupportedTorrentBinary,
|
||||||
|
/// The Metalink document was malformed or unsupported.
|
||||||
|
InvalidMetalink {
|
||||||
|
/// Parser-specific explanation of the Metalink failure.
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for BtMetalinkError {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::InvalidMagnet { reason } => write!(f, "invalid magnet: {reason}"),
|
||||||
|
Self::UnsupportedTorrentBinary => {
|
||||||
|
f.write_str("torrent binary parsing is not implemented")
|
||||||
|
}
|
||||||
|
Self::InvalidMetalink { reason } => write!(f, "invalid metalink: {reason}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for BtMetalinkError {}
|
||||||
|
|
||||||
|
impl MagnetUri {
|
||||||
|
/// Builds the compatibility wrapper from the protocol-layer model.
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_model(model: MagnetUriModel) -> Self {
|
||||||
|
Self {
|
||||||
|
info_hash: model.info_hash,
|
||||||
|
display_name: model.display_name,
|
||||||
|
trackers: model.trackers,
|
||||||
|
web_seeds: model.web_seeds,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a magnet URI into the compatibility skeleton.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error when the `magnet:?` prefix is missing or the URI does
|
||||||
|
/// not contain an `xt=urn:btih:<hash>` value.
|
||||||
|
pub fn parse(input: &str) -> Result<Self, BtMetalinkError> {
|
||||||
|
parse_magnet_uri(input).map(Self::from_model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MetalinkDocument {
|
||||||
|
/// Builds the compatibility wrapper from the protocol-layer Metalink model.
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_model(model: MetalinkDocumentModel) -> Self {
|
||||||
|
Self {
|
||||||
|
root_element: "metalink".to_owned(),
|
||||||
|
status: ParserStatus::ParsedModel,
|
||||||
|
document: Some(model),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses Metalink XML text through the protocol-layer Metalink parser.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error when the input is not a valid Metalink document or
|
||||||
|
/// when the document has no actionable resources.
|
||||||
|
pub fn parse(input: &str) -> Result<Self, BtMetalinkError> {
|
||||||
|
let document = parse_metalink_document(input)
|
||||||
|
.map_err(|reason| BtMetalinkError::InvalidMetalink { reason })?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
root_element: "metalink".to_owned(),
|
||||||
|
status: ParserStatus::ParsedModel,
|
||||||
|
document: Some(document),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compatibility wrapper for parsed torrent metadata.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct TorrentMetadata {
|
||||||
|
/// Parsed torrent metadata model.
|
||||||
|
pub model: TorrentMetadataModel,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors raised while parsing `.torrent` metadata.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum TorrentMetadataError {
|
||||||
|
/// The torrent payload was syntactically invalid.
|
||||||
|
Invalid {
|
||||||
|
/// Parser-specific explanation of the torrent failure.
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
/// The payload used an unsupported feature.
|
||||||
|
Unsupported {
|
||||||
|
/// Name of the unsupported torrent feature.
|
||||||
|
feature: &'static str,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for TorrentMetadataError {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Invalid { reason } => write!(f, "invalid torrent metadata: {reason}"),
|
||||||
|
Self::Unsupported { feature } => write!(f, "{feature} is not implemented"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for TorrentMetadataError {}
|
||||||
|
|
||||||
|
impl TorrentMetadata {
|
||||||
|
/// Wraps a protocol-layer torrent model.
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_model(model: TorrentMetadataModel) -> Self {
|
||||||
|
Self { model }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a shared reference to the underlying torrent metadata model.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_model(&self) -> &TorrentMetadataModel {
|
||||||
|
&self.model
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consumes the wrapper and returns the underlying torrent metadata model.
|
||||||
|
#[must_use]
|
||||||
|
pub fn into_model(self) -> TorrentMetadataModel {
|
||||||
|
self.model
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses torrent binary metadata through the shared protocol-layer parser.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns a structured invalid-metadata error when the payload cannot be parsed.
|
||||||
|
pub fn parse(input: &[u8]) -> Result<Self, TorrentMetadataError> {
|
||||||
|
parse_torrent_metadata(input)
|
||||||
|
.map(Self::from_model)
|
||||||
|
.map_err(|error| TorrentMetadataError::Invalid { reason: error })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{
|
||||||
|
MagnetUri, MetalinkDocument, ParserStatus, TorrentMetadata, TorrentMetadataError,
|
||||||
|
parse_magnet_uri,
|
||||||
|
};
|
||||||
|
use crate::{
|
||||||
|
TorrentFileEntryModel, TorrentInfoModel, TorrentMetadataModel, TorrentPeerModel,
|
||||||
|
TorrentTrackerModel,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn magnet_wrapper_parse_matches_magnet_model_fields() {
|
||||||
|
let input = "magnet:?xt=urn:btih:0123456789abcdef&dn=Ubuntu%2024.04&tr=http%3A%2F%2Ftracker.example.org%2Fannounce&ws=https%3A%2F%2Fcdn.example.org%2Fubuntu.iso";
|
||||||
|
|
||||||
|
let parsed = MagnetUri::parse(input).expect("magnet wrapper should parse");
|
||||||
|
let model = parse_magnet_uri(input).expect("protocol magnet parser should parse");
|
||||||
|
|
||||||
|
assert_eq!(parsed.info_hash, model.info_hash);
|
||||||
|
assert_eq!(parsed.display_name, model.display_name);
|
||||||
|
assert_eq!(parsed.trackers, model.trackers);
|
||||||
|
assert_eq!(parsed.web_seeds, model.web_seeds);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn torrent_wrapper_round_trips_model_without_changing_shape() {
|
||||||
|
let model = TorrentMetadataModel {
|
||||||
|
info: TorrentInfoModel {
|
||||||
|
name: "sample".to_owned(),
|
||||||
|
piece_length: 16,
|
||||||
|
pieces: vec![[0_u8; 20]],
|
||||||
|
files: vec![TorrentFileEntryModel {
|
||||||
|
path: "sample.bin".to_owned(),
|
||||||
|
length: 16,
|
||||||
|
piece_offset: Some(0),
|
||||||
|
selected: true,
|
||||||
|
}],
|
||||||
|
hash: None,
|
||||||
|
private: false,
|
||||||
|
},
|
||||||
|
announce: Some("http://tracker.example.org/announce".to_owned()),
|
||||||
|
trackers: vec![TorrentTrackerModel {
|
||||||
|
url: "http://tracker.example.org/announce".to_owned(),
|
||||||
|
tier: Some(1),
|
||||||
|
id: None,
|
||||||
|
seeders: None,
|
||||||
|
leechers: None,
|
||||||
|
}],
|
||||||
|
peers: vec![TorrentPeerModel {
|
||||||
|
peer_id: None,
|
||||||
|
ip: "127.0.0.1".to_owned(),
|
||||||
|
port: 6881,
|
||||||
|
client_name: None,
|
||||||
|
interested: false,
|
||||||
|
choked: true,
|
||||||
|
}],
|
||||||
|
dht_nodes: vec!["router.example.org:6881".to_owned()],
|
||||||
|
pieces: Vec::new(),
|
||||||
|
creation_date: None,
|
||||||
|
comment: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let wrapper = TorrentMetadata::from_model(model.clone());
|
||||||
|
assert_eq!(wrapper.as_model(), &model);
|
||||||
|
assert_eq!(wrapper.into_model(), model);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn torrent_wrapper_surfaces_parse_errors_as_invalid_metadata() {
|
||||||
|
let error = TorrentMetadata::parse(b"not-a-torrent").expect_err("bad torrent should fail");
|
||||||
|
|
||||||
|
match error {
|
||||||
|
TorrentMetadataError::Invalid { reason } => assert!(!reason.is_empty()),
|
||||||
|
TorrentMetadataError::Unsupported { feature } => {
|
||||||
|
panic!("unexpected torrent error variant: unsupported feature {feature}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metalink_wrapper_parse_returns_real_parsed_model() {
|
||||||
|
let parsed = MetalinkDocument::parse(
|
||||||
|
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<metalink version="4.0" xmlns="urn:ietf:params:xml:ns:metalink">
|
||||||
|
<identity>wrapped fixture</identity>
|
||||||
|
<file name="wrapped.iso">
|
||||||
|
<description>real parser payload</description>
|
||||||
|
<url priority="1" location="us">https://mirror.example.com/wrapped.iso</url>
|
||||||
|
</file>
|
||||||
|
</metalink>"#,
|
||||||
|
)
|
||||||
|
.expect("metalink wrapper should parse through the real model");
|
||||||
|
|
||||||
|
assert_eq!(parsed.root_element, "metalink");
|
||||||
|
assert_eq!(parsed.status, ParserStatus::ParsedModel);
|
||||||
|
let document = parsed
|
||||||
|
.document
|
||||||
|
.expect("wrapper should retain parsed document");
|
||||||
|
assert_eq!(document.version.as_deref(), Some("4.0"));
|
||||||
|
assert_eq!(document.identity.as_deref(), Some("wrapped fixture"));
|
||||||
|
assert_eq!(document.files.len(), 1);
|
||||||
|
assert_eq!(document.files[0].name, "wrapped.iso");
|
||||||
|
assert_eq!(
|
||||||
|
document.files[0].description.as_deref(),
|
||||||
|
Some("real parser payload")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
document.files[0].resources[0].url,
|
||||||
|
"https://mirror.example.com/wrapped.iso"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metalink_wrapper_preserves_normalized_file_metadata_and_resource_hints() {
|
||||||
|
let parsed = MetalinkDocument::parse(
|
||||||
|
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<metalink version="4.0" xmlns="urn:ietf:params:xml:ns:metalink">
|
||||||
|
<identity>wrapper fixture</identity>
|
||||||
|
<file name=" wrapped.iso ">
|
||||||
|
<identity> release-42 </identity>
|
||||||
|
<signature><![CDATA[SIG-WRAP]]></signature>
|
||||||
|
<hash type="SHA256"> AA BB </hash>
|
||||||
|
<url location=" us " maxconnections="8">https://mirror.example.com/wrapped.iso</url>
|
||||||
|
</file>
|
||||||
|
</metalink>"#,
|
||||||
|
)
|
||||||
|
.expect("metalink wrapper should preserve normalized parser output");
|
||||||
|
|
||||||
|
let document = parsed
|
||||||
|
.document
|
||||||
|
.expect("wrapper should retain parsed document");
|
||||||
|
assert_eq!(document.identity.as_deref(), Some("wrapper fixture"));
|
||||||
|
assert_eq!(document.files[0].name, "wrapped.iso");
|
||||||
|
assert_eq!(document.files[0].identifier.as_deref(), Some("release-42"));
|
||||||
|
assert_eq!(document.files[0].signatures, vec!["SIG-WRAP".to_owned()]);
|
||||||
|
assert_eq!(document.files[0].checksums[0].algorithm, "sha-256");
|
||||||
|
assert_eq!(document.files[0].checksums[0].value, "aabb");
|
||||||
|
assert_eq!(
|
||||||
|
document.files[0].resources[0].location.as_deref(),
|
||||||
|
Some("us")
|
||||||
|
);
|
||||||
|
assert_eq!(document.files[0].resources[0].max_connections, Some(8));
|
||||||
|
assert_eq!(
|
||||||
|
document.files[0].resources[0].type_hint.as_deref(),
|
||||||
|
Some("https")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metalink_wrapper_parse_rejects_invalid_root_only_stub_shape() {
|
||||||
|
let error = MetalinkDocument::parse(
|
||||||
|
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<metalink version="4.0" xmlns="urn:ietf:params:xml:ns:metalink">
|
||||||
|
<file name="empty.iso">
|
||||||
|
<url priority="1"> </url>
|
||||||
|
</file>
|
||||||
|
</metalink>"#,
|
||||||
|
)
|
||||||
|
.expect_err("root-only stub success should be rejected");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
error
|
||||||
|
.to_string()
|
||||||
|
.contains("metalink document contains no resource urls"),
|
||||||
|
"unexpected error: {error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
//! Downloader traits plus real and fixture-backed transport implementations.
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
pub(super) use std::{
|
||||||
|
collections::{BTreeMap, HashMap},
|
||||||
|
env,
|
||||||
|
error::Error as StdError,
|
||||||
|
fs::{File, OpenOptions},
|
||||||
|
io::SeekFrom,
|
||||||
|
sync::{
|
||||||
|
Arc, Mutex, OnceLock,
|
||||||
|
atomic::{AtomicU64, Ordering},
|
||||||
|
},
|
||||||
|
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(super) use aria2_rust_pro_storage::{ByteSink, ObservedByteSink, ObservedFileSink};
|
||||||
|
pub(super) use reqwest::{
|
||||||
|
NoProxy, Proxy,
|
||||||
|
blocking::{Client, Response},
|
||||||
|
header::{HeaderMap, HeaderName, HeaderValue, RANGE},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Monotonic suffix used to keep streamed fixture temp paths unique even when
|
||||||
|
/// wall-clock precision collapses under parallel test execution.
|
||||||
|
static NEXT_TEMP_STREAM_SINK_ID: AtomicU64 = AtomicU64::new(0);
|
||||||
|
/// Process-wide cache for optional live HTTP timing diagnostics.
|
||||||
|
static HTTP_TIMING_PROBE_ENABLED: OnceLock<bool> = OnceLock::new();
|
||||||
|
/// Maximum normalized request shapes retained for repeated live HTTP requests.
|
||||||
|
const MAX_PREPARED_REQUEST_CACHE_ENTRIES: usize = 512;
|
||||||
|
/// Maximum proxy-specific clients retained to preserve connection pooling.
|
||||||
|
const MAX_PROXY_CLIENT_CACHE_ENTRIES: usize = 128;
|
||||||
|
/// Default idle connection budget kept per host for repeated live HTTP range work.
|
||||||
|
const LIVE_HTTP_POOL_MAX_IDLE_PER_HOST: usize = 32;
|
||||||
|
/// Idle timeout used to keep same-host range fanout warm across short runtime bursts.
|
||||||
|
const LIVE_HTTP_POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(90);
|
||||||
|
/// TCP keepalive used for long-lived live HTTP sessions.
|
||||||
|
const LIVE_HTTP_TCP_KEEPALIVE: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
pub(super) use crate::{
|
||||||
|
auth::AuthCredentialModel,
|
||||||
|
ftp::{FtpConfigModel, FtpRequestModel, FtpResponseModel},
|
||||||
|
http::{
|
||||||
|
ChecksumSpec, ContentRangeSpec, HttpHeader, HttpRequestModel, HttpResponseHeaders,
|
||||||
|
HttpResponseModel, HttpTransferTaskModel, HttpVersion, RangeSpec, RangeUnit, ResponseBody,
|
||||||
|
},
|
||||||
|
metalink::MetalinkDocumentModel,
|
||||||
|
sftp::{SftpConfigModel, SftpRequestModel, SftpResponseModel},
|
||||||
|
torrent::TorrentMetadataModel,
|
||||||
|
transport::TransportError,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Transfer-facing traits shared by the downloader implementations.
|
||||||
|
mod contracts;
|
||||||
|
/// Core connector-backed downloader implementations and request normalization helpers.
|
||||||
|
mod core_downloader;
|
||||||
|
/// Fixture-backed downloader used by tests and local runtime smokes.
|
||||||
|
mod fixture_downloader;
|
||||||
|
/// Live reqwest-backed downloader connector implementation.
|
||||||
|
mod reqwest_connector;
|
||||||
|
|
||||||
|
pub use self::contracts::{
|
||||||
|
AuthProvider, ChecksumVerifier, Downloader, FtpConnector, HttpConnector, HttpsConnector,
|
||||||
|
MetalinkConnector, RetryStrategyProvider, SftpConnector, TorrentConnector,
|
||||||
|
};
|
||||||
|
pub use self::core_downloader::{
|
||||||
|
ConnectorBackedDownloader, HttpOnlyDownloader, NullHttpConnector, NullHttpsConnector,
|
||||||
|
};
|
||||||
|
pub use self::fixture_downloader::{FixtureHttpDownloader, FixtureStep, HttpFixtureResponseSpec};
|
||||||
|
pub use self::reqwest_connector::ReqwestHttpConnector;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn execute_streamed_body(body: &[u8], checksum: Option<&ChecksumSpec>) -> ResponseBody {
|
||||||
|
fixture_downloader::execute_streamed_body(body, checksum)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn temp_stream_sink_path() -> std::path::PathBuf {
|
||||||
|
fixture_downloader::temp_stream_sink_path()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn request_body_bytes(body: &crate::http::HttpBody) -> Option<Vec<u8>> {
|
||||||
|
reqwest_connector::request_body_bytes(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod downloader_tests;
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
use super::{
|
||||||
|
AuthCredentialModel, ChecksumSpec, FtpConfigModel, FtpRequestModel, FtpResponseModel,
|
||||||
|
HttpRequestModel, HttpResponseModel, HttpTransferTaskModel, MetalinkDocumentModel,
|
||||||
|
SftpConfigModel, SftpRequestModel, SftpResponseModel, TorrentMetadataModel, TransportError,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Verifies a checksum against downloaded payload bytes.
|
||||||
|
pub trait ChecksumVerifier {
|
||||||
|
/// Validates `payload` against `spec`.
|
||||||
|
fn verify_checksum(&self, spec: &ChecksumSpec, payload: &[u8]) -> Result<(), TransportError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derives retry behavior for one HTTP request.
|
||||||
|
pub trait RetryStrategyProvider {
|
||||||
|
/// Returns the retry strategy that should apply to `request`.
|
||||||
|
fn retry_strategy(&self, request: &HttpRequestModel) -> crate::http::RetryStrategy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves credentials for one origin.
|
||||||
|
pub trait AuthProvider {
|
||||||
|
/// Returns the credential configured for `origin`, when one exists.
|
||||||
|
fn credential_for(&self, origin: &str) -> Option<AuthCredentialModel>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connects plain HTTP requests.
|
||||||
|
pub trait HttpConnector {
|
||||||
|
/// Executes one HTTP request and returns the normalized response model.
|
||||||
|
fn connect_http(&self, request: &HttpRequestModel)
|
||||||
|
-> Result<HttpResponseModel, TransportError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connects HTTPS requests.
|
||||||
|
pub trait HttpsConnector {
|
||||||
|
/// Executes one HTTPS request and returns the normalized response model.
|
||||||
|
fn connect_https(
|
||||||
|
&self,
|
||||||
|
request: &HttpRequestModel,
|
||||||
|
) -> Result<HttpResponseModel, TransportError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connects FTP requests.
|
||||||
|
pub trait FtpConnector {
|
||||||
|
/// Executes one FTP request with the supplied session config.
|
||||||
|
fn connect_ftp(
|
||||||
|
&self,
|
||||||
|
config: &FtpConfigModel,
|
||||||
|
request: &FtpRequestModel,
|
||||||
|
) -> Result<FtpResponseModel, TransportError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connects SFTP requests.
|
||||||
|
pub trait SftpConnector {
|
||||||
|
/// Executes one SFTP request with the supplied session config.
|
||||||
|
fn connect_sftp(
|
||||||
|
&self,
|
||||||
|
config: &SftpConfigModel,
|
||||||
|
request: &SftpRequestModel,
|
||||||
|
) -> Result<SftpResponseModel, TransportError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches Metalink documents through the transport layer.
|
||||||
|
pub trait MetalinkConnector {
|
||||||
|
/// Resolves one Metalink document into an HTTP-style response model.
|
||||||
|
fn connect_metalink(
|
||||||
|
&self,
|
||||||
|
document: &MetalinkDocumentModel,
|
||||||
|
) -> Result<HttpResponseModel, TransportError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches torrent metadata through the transport layer.
|
||||||
|
pub trait TorrentConnector {
|
||||||
|
/// Resolves one torrent metadata document into an HTTP-style response model.
|
||||||
|
fn connect_torrent(
|
||||||
|
&self,
|
||||||
|
metadata: &TorrentMetadataModel,
|
||||||
|
) -> Result<HttpResponseModel, TransportError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// High-level transfer runner used by the CLI and integration fixtures.
|
||||||
|
pub trait Downloader {
|
||||||
|
/// Starts one HTTP or HTTPS transfer.
|
||||||
|
fn start_http_transfer(
|
||||||
|
&self,
|
||||||
|
task: &HttpTransferTaskModel,
|
||||||
|
) -> Result<HttpResponseModel, TransportError>;
|
||||||
|
/// Starts one FTP transfer.
|
||||||
|
fn start_ftp_transfer(
|
||||||
|
&self,
|
||||||
|
config: &FtpConfigModel,
|
||||||
|
request: &FtpRequestModel,
|
||||||
|
) -> Result<FtpResponseModel, TransportError>;
|
||||||
|
/// Starts one SFTP transfer.
|
||||||
|
fn start_sftp_transfer(
|
||||||
|
&self,
|
||||||
|
config: &SftpConfigModel,
|
||||||
|
request: &SftpRequestModel,
|
||||||
|
) -> Result<SftpResponseModel, TransportError>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
use super::{
|
||||||
|
Downloader, FtpConfigModel, FtpRequestModel, FtpResponseModel, HttpConnector, HttpRequestModel,
|
||||||
|
HttpResponseModel, HttpTransferTaskModel, HttpsConnector, SftpConfigModel, SftpRequestModel,
|
||||||
|
SftpResponseModel, TransportError,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
|
/// Placeholder HTTP connector that always reports that no connector is configured.
|
||||||
|
pub struct NullHttpConnector;
|
||||||
|
|
||||||
|
impl HttpConnector for NullHttpConnector {
|
||||||
|
fn connect_http(
|
||||||
|
&self,
|
||||||
|
request: &HttpRequestModel,
|
||||||
|
) -> Result<HttpResponseModel, TransportError> {
|
||||||
|
Err(TransportError {
|
||||||
|
kind: crate::transport::TransportErrorKind::NotConnected,
|
||||||
|
message: format!("no http connector configured for {}", request.url),
|
||||||
|
source: None,
|
||||||
|
context: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
|
/// Placeholder HTTPS connector that always reports that no connector is configured.
|
||||||
|
pub struct NullHttpsConnector;
|
||||||
|
|
||||||
|
impl HttpsConnector for NullHttpsConnector {
|
||||||
|
fn connect_https(
|
||||||
|
&self,
|
||||||
|
request: &HttpRequestModel,
|
||||||
|
) -> Result<HttpResponseModel, TransportError> {
|
||||||
|
Err(TransportError {
|
||||||
|
kind: crate::transport::TransportErrorKind::NotConnected,
|
||||||
|
message: format!("no https connector configured for {}", request.url),
|
||||||
|
source: None,
|
||||||
|
context: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
/// Downloader wrapper that routes HTTP and HTTPS requests through connector implementations.
|
||||||
|
pub struct ConnectorBackedDownloader<HC, HSC> {
|
||||||
|
/// Connector used for plain HTTP requests.
|
||||||
|
http: HC,
|
||||||
|
/// Connector used for HTTPS requests.
|
||||||
|
https: HSC,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<HC, HSC> ConnectorBackedDownloader<HC, HSC> {
|
||||||
|
#[must_use]
|
||||||
|
/// Builds a downloader from plain HTTP and HTTPS connector implementations.
|
||||||
|
pub const fn new(http: HC, https: HSC) -> Self {
|
||||||
|
Self { http, https }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
/// Downloader that only supports HTTP(S) transfers.
|
||||||
|
pub struct HttpOnlyDownloader {
|
||||||
|
/// Connector-backed executor used by the HTTP-only wrapper.
|
||||||
|
inner: ConnectorBackedDownloader<NullHttpConnector, NullHttpsConnector>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpOnlyDownloader {
|
||||||
|
#[must_use]
|
||||||
|
/// Builds the HTTP-only downloader.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: ConnectorBackedDownloader::new(NullHttpConnector, NullHttpsConnector),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Downloader for HttpOnlyDownloader {
|
||||||
|
fn start_http_transfer(
|
||||||
|
&self,
|
||||||
|
task: &HttpTransferTaskModel,
|
||||||
|
) -> Result<HttpResponseModel, TransportError> {
|
||||||
|
self.inner.start_http_transfer(task)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_ftp_transfer(
|
||||||
|
&self,
|
||||||
|
_config: &FtpConfigModel,
|
||||||
|
_request: &FtpRequestModel,
|
||||||
|
) -> Result<FtpResponseModel, TransportError> {
|
||||||
|
Err(TransportError {
|
||||||
|
kind: crate::transport::TransportErrorKind::UnsupportedScheme,
|
||||||
|
message: "ftp transfer is not supported by the HTTP-only downloader".to_owned(),
|
||||||
|
source: None,
|
||||||
|
context: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_sftp_transfer(
|
||||||
|
&self,
|
||||||
|
_config: &SftpConfigModel,
|
||||||
|
_request: &SftpRequestModel,
|
||||||
|
) -> Result<SftpResponseModel, TransportError> {
|
||||||
|
Err(TransportError {
|
||||||
|
kind: crate::transport::TransportErrorKind::UnsupportedScheme,
|
||||||
|
message: "sftp transfer is not supported by the HTTP-only downloader".to_owned(),
|
||||||
|
source: None,
|
||||||
|
context: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<HC, HSC> Downloader for ConnectorBackedDownloader<HC, HSC>
|
||||||
|
where
|
||||||
|
HC: HttpConnector,
|
||||||
|
HSC: HttpsConnector,
|
||||||
|
{
|
||||||
|
fn start_http_transfer(
|
||||||
|
&self,
|
||||||
|
task: &HttpTransferTaskModel,
|
||||||
|
) -> Result<HttpResponseModel, TransportError> {
|
||||||
|
match request_scheme(&task.request.url) {
|
||||||
|
Some("http") => self
|
||||||
|
.http
|
||||||
|
.connect_http(&task.request)
|
||||||
|
.map(|response| normalize_http_response_for_execution(task, response)),
|
||||||
|
Some("https") => self
|
||||||
|
.https
|
||||||
|
.connect_https(&task.request)
|
||||||
|
.map(|response| normalize_http_response_for_execution(task, response)),
|
||||||
|
Some(scheme) => Err(TransportError {
|
||||||
|
kind: crate::transport::TransportErrorKind::UnsupportedScheme,
|
||||||
|
message: format!("unsupported http transfer scheme: {scheme}"),
|
||||||
|
source: None,
|
||||||
|
context: None,
|
||||||
|
}),
|
||||||
|
None => Err(TransportError {
|
||||||
|
kind: crate::transport::TransportErrorKind::ProtocolViolation,
|
||||||
|
message: format!("request url has no scheme: {}", task.request.url),
|
||||||
|
source: None,
|
||||||
|
context: None,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_ftp_transfer(
|
||||||
|
&self,
|
||||||
|
_config: &FtpConfigModel,
|
||||||
|
_request: &FtpRequestModel,
|
||||||
|
) -> Result<FtpResponseModel, TransportError> {
|
||||||
|
Err(TransportError {
|
||||||
|
kind: crate::transport::TransportErrorKind::UnsupportedScheme,
|
||||||
|
message: "ftp transfer is not implemented".to_owned(),
|
||||||
|
source: None,
|
||||||
|
context: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_sftp_transfer(
|
||||||
|
&self,
|
||||||
|
_config: &SftpConfigModel,
|
||||||
|
_request: &SftpRequestModel,
|
||||||
|
) -> Result<SftpResponseModel, TransportError> {
|
||||||
|
Err(TransportError {
|
||||||
|
kind: crate::transport::TransportErrorKind::UnsupportedScheme,
|
||||||
|
message: "sftp transfer is not implemented".to_owned(),
|
||||||
|
source: None,
|
||||||
|
context: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Injects transfer-task metadata such as checksum hooks into one HTTP response model.
|
||||||
|
pub(super) fn normalize_http_response_for_execution(
|
||||||
|
task: &HttpTransferTaskModel,
|
||||||
|
mut response: HttpResponseModel,
|
||||||
|
) -> HttpResponseModel {
|
||||||
|
if response.checksum.is_none()
|
||||||
|
&& let Some(checksum) = task
|
||||||
|
.checksum_hook
|
||||||
|
.as_ref()
|
||||||
|
.filter(|checksum| checksum.enabled)
|
||||||
|
.map(|checksum| checksum.spec.clone())
|
||||||
|
{
|
||||||
|
response.checksum = Some(checksum);
|
||||||
|
}
|
||||||
|
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts the lowercase URI scheme prefix from one request URL when present.
|
||||||
|
pub(super) fn request_scheme(url: &str) -> Option<&str> {
|
||||||
|
url.split_once("://").map(|(scheme, _)| scheme)
|
||||||
|
}
|
||||||
@@ -0,0 +1,953 @@
|
|||||||
|
use std::{
|
||||||
|
collections::BTreeSet,
|
||||||
|
io::{Read, Write},
|
||||||
|
net::TcpListener,
|
||||||
|
thread,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
ConnectorBackedDownloader, Downloader, FixtureHttpDownloader, FixtureStep, HttpConnector,
|
||||||
|
HttpFixtureResponseSpec, HttpsConnector, ReqwestHttpConnector,
|
||||||
|
};
|
||||||
|
use crate::{
|
||||||
|
ftp::{FtpCommandModel, FtpResponseModel},
|
||||||
|
http::{
|
||||||
|
HttpBody, HttpMethod, HttpRequestHeaders, HttpRequestModel, HttpResponseHeaders,
|
||||||
|
HttpTransferTaskModel, HttpVersion, ProxyConfig, ResponseBody, RetryPolicy, RetryStrategy,
|
||||||
|
},
|
||||||
|
sftp::{SftpCommandModel, SftpResponseModel},
|
||||||
|
transport::{TransportError, TransportErrorKind},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
struct HttpOkConnector;
|
||||||
|
|
||||||
|
impl HttpConnector for HttpOkConnector {
|
||||||
|
fn connect_http(
|
||||||
|
&self,
|
||||||
|
request: &HttpRequestModel,
|
||||||
|
) -> Result<crate::http::HttpResponseModel, TransportError> {
|
||||||
|
Ok(crate::http::HttpResponseModel {
|
||||||
|
status: 200,
|
||||||
|
reason: format!("HTTP {}", request.url),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: Vec::new(),
|
||||||
|
},
|
||||||
|
body: ResponseBody::Empty,
|
||||||
|
content_range: None,
|
||||||
|
partial_content: false,
|
||||||
|
checksum: None,
|
||||||
|
redirected_from: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
struct HttpsOkConnector;
|
||||||
|
|
||||||
|
impl HttpsConnector for HttpsOkConnector {
|
||||||
|
fn connect_https(
|
||||||
|
&self,
|
||||||
|
request: &HttpRequestModel,
|
||||||
|
) -> Result<crate::http::HttpResponseModel, TransportError> {
|
||||||
|
Ok(crate::http::HttpResponseModel {
|
||||||
|
status: 200,
|
||||||
|
reason: format!("HTTPS {}", request.url),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: Vec::new(),
|
||||||
|
},
|
||||||
|
body: ResponseBody::Empty,
|
||||||
|
content_range: None,
|
||||||
|
partial_content: false,
|
||||||
|
checksum: None,
|
||||||
|
redirected_from: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retry() -> RetryStrategy {
|
||||||
|
RetryStrategy {
|
||||||
|
policy: RetryPolicy {
|
||||||
|
max_attempts: 1,
|
||||||
|
initial_backoff_ms: 0,
|
||||||
|
max_backoff_ms: 0,
|
||||||
|
retry_on_3xx: false,
|
||||||
|
retry_on_4xx: false,
|
||||||
|
retry_on_5xx: false,
|
||||||
|
retry_on_network_error: false,
|
||||||
|
retry_on_timeout: false,
|
||||||
|
},
|
||||||
|
jitter: None,
|
||||||
|
max_elapsed_ms: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn task(url: &str) -> HttpTransferTaskModel {
|
||||||
|
let request = HttpRequestModel {
|
||||||
|
method: HttpMethod::Get,
|
||||||
|
url: url.to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpRequestHeaders {
|
||||||
|
headers: Vec::new(),
|
||||||
|
},
|
||||||
|
query: std::collections::HashMap::new(),
|
||||||
|
range: None,
|
||||||
|
body: HttpBody::Empty,
|
||||||
|
retry: retry(),
|
||||||
|
auth: None,
|
||||||
|
proxy: None,
|
||||||
|
response_sink: None,
|
||||||
|
};
|
||||||
|
HttpTransferTaskModel {
|
||||||
|
task_id: "gid".to_owned(),
|
||||||
|
request,
|
||||||
|
response_headers: HttpResponseHeaders {
|
||||||
|
headers: Vec::new(),
|
||||||
|
},
|
||||||
|
body: ResponseBody::Empty,
|
||||||
|
resume_state: None,
|
||||||
|
retry_attempts: vec![],
|
||||||
|
checksum_hook: None,
|
||||||
|
max_connections: 1,
|
||||||
|
retry: retry(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn proxy_config(port: u16) -> ProxyConfig {
|
||||||
|
ProxyConfig {
|
||||||
|
scheme: "http".to_owned(),
|
||||||
|
host: "127.0.0.1".to_owned(),
|
||||||
|
port,
|
||||||
|
username: None,
|
||||||
|
password: None,
|
||||||
|
bypass_hosts: vec![],
|
||||||
|
no_proxy: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn closed_loopback_port() -> u16 {
|
||||||
|
TcpListener::bind("127.0.0.1:0")
|
||||||
|
.expect("ephemeral port should bind")
|
||||||
|
.local_addr()
|
||||||
|
.expect("local addr should exist")
|
||||||
|
.port()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connector_backed_downloader_routes_http_and_https() {
|
||||||
|
let downloader = ConnectorBackedDownloader::new(HttpOkConnector, HttpsOkConnector);
|
||||||
|
|
||||||
|
let http = downloader
|
||||||
|
.start_http_transfer(&task("http://example.org/file"))
|
||||||
|
.expect("http connector should be used");
|
||||||
|
let https = downloader
|
||||||
|
.start_http_transfer(&task("https://example.org/file"))
|
||||||
|
.expect("https connector should be used");
|
||||||
|
|
||||||
|
assert!(http.reason.starts_with("HTTP "));
|
||||||
|
assert!(https.reason.starts_with("HTTPS "));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
struct InlineBodyConnector;
|
||||||
|
|
||||||
|
impl HttpConnector for InlineBodyConnector {
|
||||||
|
fn connect_http(
|
||||||
|
&self,
|
||||||
|
_request: &HttpRequestModel,
|
||||||
|
) -> Result<crate::http::HttpResponseModel, TransportError> {
|
||||||
|
Ok(crate::http::HttpResponseModel {
|
||||||
|
status: 200,
|
||||||
|
reason: "HTTP inline".to_owned(),
|
||||||
|
version: HttpVersion::Http11,
|
||||||
|
headers: HttpResponseHeaders {
|
||||||
|
headers: Vec::new(),
|
||||||
|
},
|
||||||
|
body: ResponseBody::Inline(b"abc".to_vec()),
|
||||||
|
content_range: None,
|
||||||
|
partial_content: false,
|
||||||
|
checksum: Some(crate::http::ChecksumSpec {
|
||||||
|
algorithm: "sha-1".to_owned(),
|
||||||
|
expected_hex: "a9993e364706816aba3e25717850c26c9cd0d89d".to_owned(),
|
||||||
|
actual_hex: None,
|
||||||
|
}),
|
||||||
|
redirected_from: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connector_backed_downloader_keeps_inline_body_when_no_stream_sink_is_needed() {
|
||||||
|
let downloader = ConnectorBackedDownloader::new(InlineBodyConnector, HttpsOkConnector);
|
||||||
|
|
||||||
|
let response = downloader
|
||||||
|
.start_http_transfer(&task("http://example.org/inline"))
|
||||||
|
.expect("http connector should be normalized");
|
||||||
|
|
||||||
|
match &response.body {
|
||||||
|
ResponseBody::Inline(bytes) => assert_eq!(bytes, b"abc"),
|
||||||
|
other => panic!("expected inline body after normalization, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
response.completion_model().state,
|
||||||
|
crate::http::HttpCompletionState::Verified
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connector_backed_downloader_injects_checksum_hook_when_response_omits_checksum() {
|
||||||
|
let downloader = ConnectorBackedDownloader::new(InlineBodyConnector, HttpsOkConnector);
|
||||||
|
let mut task = task("http://example.org/inline");
|
||||||
|
task.checksum_hook = Some(crate::http::ChecksumHookModel {
|
||||||
|
spec: crate::http::ChecksumSpec {
|
||||||
|
algorithm: "sha-1".to_owned(),
|
||||||
|
expected_hex: "a9993e364706816aba3e25717850c26c9cd0d89d".to_owned(),
|
||||||
|
actual_hex: None,
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = downloader
|
||||||
|
.start_http_transfer(&task)
|
||||||
|
.expect("http connector should be normalized");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
response
|
||||||
|
.checksum
|
||||||
|
.as_ref()
|
||||||
|
.map(|checksum| checksum.expected_hex.as_str()),
|
||||||
|
Some("a9993e364706816aba3e25717850c26c9cd0d89d")
|
||||||
|
);
|
||||||
|
assert!(response.completion_model().checksum_verified);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reqwest_connector_fetches_real_http_body_into_streamed_response() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
|
||||||
|
let addr = listener.local_addr().expect("local addr should exist");
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
let (mut stream, _) = listener.accept().expect("client should connect");
|
||||||
|
let mut request = [0_u8; 1024];
|
||||||
|
let _ = stream.read(&mut request);
|
||||||
|
let response = b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nabc";
|
||||||
|
stream.write_all(response).expect("response should write");
|
||||||
|
});
|
||||||
|
|
||||||
|
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
|
||||||
|
let downloader = ConnectorBackedDownloader::new(connector.clone(), connector);
|
||||||
|
let response = downloader
|
||||||
|
.start_http_transfer(&task(&format!("http://{addr}/live")))
|
||||||
|
.expect("live request should succeed");
|
||||||
|
|
||||||
|
match &response.body {
|
||||||
|
ResponseBody::Streamed {
|
||||||
|
expected_len,
|
||||||
|
observed_len,
|
||||||
|
observed_digest,
|
||||||
|
temp_path,
|
||||||
|
} => {
|
||||||
|
assert_eq!(*expected_len, Some(3));
|
||||||
|
assert_eq!(*observed_len, Some(3));
|
||||||
|
assert!(observed_digest.is_none());
|
||||||
|
assert!(temp_path.is_some());
|
||||||
|
}
|
||||||
|
other => panic!("expected streamed body from live connector, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(response.status, 200);
|
||||||
|
assert_eq!(response.completion_model().completed_length, 3);
|
||||||
|
|
||||||
|
handle.join().expect("server thread should join");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reqwest_connector_can_stream_live_http_body_directly_to_target_file() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
|
||||||
|
let addr = listener.local_addr().expect("local addr should exist");
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
let (mut stream, _) = listener.accept().expect("client should connect");
|
||||||
|
let mut request = [0_u8; 1024];
|
||||||
|
let _ = stream.read(&mut request);
|
||||||
|
let response = b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nabc";
|
||||||
|
stream.write_all(response).expect("response should write");
|
||||||
|
});
|
||||||
|
|
||||||
|
let target_path = super::temp_stream_sink_path();
|
||||||
|
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
|
||||||
|
let downloader = ConnectorBackedDownloader::new(connector.clone(), connector);
|
||||||
|
let mut direct_task = task(&format!("http://{addr}/live-direct"));
|
||||||
|
direct_task.request.response_sink = Some(crate::http::HttpResponseSinkTarget {
|
||||||
|
target_path: target_path.clone(),
|
||||||
|
});
|
||||||
|
let response = downloader
|
||||||
|
.start_http_transfer(&direct_task)
|
||||||
|
.expect("live request should succeed");
|
||||||
|
|
||||||
|
match &response.body {
|
||||||
|
ResponseBody::Streamed {
|
||||||
|
expected_len,
|
||||||
|
observed_len,
|
||||||
|
observed_digest,
|
||||||
|
temp_path,
|
||||||
|
} => {
|
||||||
|
assert_eq!(*expected_len, Some(3));
|
||||||
|
assert_eq!(*observed_len, Some(3));
|
||||||
|
assert!(observed_digest.is_none());
|
||||||
|
assert!(temp_path.is_none());
|
||||||
|
}
|
||||||
|
other => panic!("expected streamed body from live connector, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(&target_path).expect("target bytes should read"),
|
||||||
|
b"abc"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_file(&target_path);
|
||||||
|
handle.join().expect("server thread should join");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reqwest_connector_preserves_range_request_and_parses_416_total_length() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
|
||||||
|
let addr = listener.local_addr().expect("local addr should exist");
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
let (mut stream, _) = listener.accept().expect("client should connect");
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut chunk = [0_u8; 1024];
|
||||||
|
loop {
|
||||||
|
let read = stream.read(&mut chunk).expect("socket should read");
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
buf.extend_from_slice(&chunk[..read]);
|
||||||
|
if buf.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let request_text = String::from_utf8_lossy(&buf).to_lowercase();
|
||||||
|
assert!(request_text.contains("range: bytes=4096-"));
|
||||||
|
|
||||||
|
let response =
|
||||||
|
b"HTTP/1.1 416 Range Not Satisfiable\r\nContent-Range: bytes */4096\r\nContent-Length: 0\r\n\r\n";
|
||||||
|
stream.write_all(response).expect("response should write");
|
||||||
|
});
|
||||||
|
|
||||||
|
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
|
||||||
|
let mut request = task(&format!("http://{addr}/range-reject")).request;
|
||||||
|
request.range = Some(crate::http::RangeSpec {
|
||||||
|
start: 4096,
|
||||||
|
end_inclusive: None,
|
||||||
|
unit: crate::http::RangeUnit::Bytes,
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = connector
|
||||||
|
.connect_http(&request)
|
||||||
|
.expect("416 response should still be modeled");
|
||||||
|
|
||||||
|
assert_eq!(response.status, 416);
|
||||||
|
assert_eq!(response.total_length(), Some(4096));
|
||||||
|
assert_eq!(response.completed_length(), 0);
|
||||||
|
assert!(
|
||||||
|
response
|
||||||
|
.content_range
|
||||||
|
.as_ref()
|
||||||
|
.expect("content-range should parse")
|
||||||
|
.unsatisfied
|
||||||
|
);
|
||||||
|
|
||||||
|
handle.join().expect("server thread should join");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reqwest_connector_sends_text_body_and_headers() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
|
||||||
|
let addr = listener.local_addr().expect("local addr should exist");
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
let (mut stream, _) = listener.accept().expect("client should connect");
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut chunk = [0_u8; 1024];
|
||||||
|
loop {
|
||||||
|
let read = stream.read(&mut chunk).expect("socket should read");
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
buf.extend_from_slice(&chunk[..read]);
|
||||||
|
if buf.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let header_end = buf
|
||||||
|
.windows(4)
|
||||||
|
.position(|window| window == b"\r\n\r\n")
|
||||||
|
.map(|index| index + 4)
|
||||||
|
.expect("headers should terminate");
|
||||||
|
let request_text = String::from_utf8_lossy(&buf[..header_end]).to_lowercase();
|
||||||
|
assert!(request_text.contains("post /submit http/1.1"));
|
||||||
|
assert!(request_text.contains("x-test: alpha"));
|
||||||
|
assert!(request_text.contains("content-length: 4"));
|
||||||
|
|
||||||
|
while buf.len() < header_end + 4 {
|
||||||
|
let read = stream.read(&mut chunk).expect("socket should read body");
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
buf.extend_from_slice(&chunk[..read]);
|
||||||
|
}
|
||||||
|
assert_eq!(&buf[header_end..header_end + 4], b"ping");
|
||||||
|
|
||||||
|
stream
|
||||||
|
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
|
||||||
|
.expect("response should write");
|
||||||
|
});
|
||||||
|
|
||||||
|
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
|
||||||
|
let mut request = task(&format!("http://{addr}/submit")).request;
|
||||||
|
request.method = HttpMethod::Post;
|
||||||
|
request.body = HttpBody::Text("ping".to_owned());
|
||||||
|
request.headers.headers.push(crate::http::HttpHeader {
|
||||||
|
name: "x-test".to_owned(),
|
||||||
|
value: "alpha".to_owned(),
|
||||||
|
kind: crate::http::HeaderKind::Request,
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = connector
|
||||||
|
.connect_http(&request)
|
||||||
|
.expect("live post should succeed");
|
||||||
|
|
||||||
|
assert_eq!(response.status, 200);
|
||||||
|
assert_eq!(response.completed_length(), 2);
|
||||||
|
|
||||||
|
handle.join().expect("server thread should join");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_body_bytes_does_not_allocate_placeholder_payload_for_stream_body() {
|
||||||
|
assert!(
|
||||||
|
super::request_body_bytes(&HttpBody::Stream {
|
||||||
|
expected_len: Some(1024 * 1024)
|
||||||
|
})
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reqwest_connector_applies_sorted_query_parameters_to_request_url() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
|
||||||
|
let addr = listener.local_addr().expect("local addr should exist");
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
let (mut stream, _) = listener.accept().expect("client should connect");
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut chunk = [0_u8; 1024];
|
||||||
|
loop {
|
||||||
|
let read = stream.read(&mut chunk).expect("socket should read");
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
buf.extend_from_slice(&chunk[..read]);
|
||||||
|
if buf.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let request_text = String::from_utf8_lossy(&buf).to_lowercase();
|
||||||
|
assert!(request_text.contains("get /search?alpha=1&beta=2 http/1.1"));
|
||||||
|
|
||||||
|
stream
|
||||||
|
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
|
||||||
|
.expect("response should write");
|
||||||
|
});
|
||||||
|
|
||||||
|
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
|
||||||
|
let mut request = task(&format!("http://{addr}/search")).request;
|
||||||
|
request.query.insert("beta".to_owned(), "2".to_owned());
|
||||||
|
request.query.insert("alpha".to_owned(), "1".to_owned());
|
||||||
|
|
||||||
|
let response = connector
|
||||||
|
.connect_http(&request)
|
||||||
|
.expect("live request with query should succeed");
|
||||||
|
|
||||||
|
assert_eq!(response.status, 200);
|
||||||
|
handle.join().expect("server thread should join");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reqwest_connector_tracks_redirect_origin_after_following_redirect() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
|
||||||
|
let addr = listener.local_addr().expect("local addr should exist");
|
||||||
|
let first_location = format!("http://{addr}/final");
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
let (mut first, _) = listener.accept().expect("first client should connect");
|
||||||
|
let mut first_buf = Vec::new();
|
||||||
|
let mut chunk = [0_u8; 1024];
|
||||||
|
loop {
|
||||||
|
let read = first.read(&mut chunk).expect("first socket should read");
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
first_buf.extend_from_slice(&chunk[..read]);
|
||||||
|
if first_buf.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let first_request = String::from_utf8_lossy(&first_buf).to_lowercase();
|
||||||
|
assert!(first_request.contains("get /redirect http/1.1"));
|
||||||
|
let redirect = format!(
|
||||||
|
"HTTP/1.1 302 Found\r\nLocation: {first_location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||||
|
);
|
||||||
|
first
|
||||||
|
.write_all(redirect.as_bytes())
|
||||||
|
.expect("redirect response should write");
|
||||||
|
|
||||||
|
let (mut second, _) = listener.accept().expect("second client should connect");
|
||||||
|
let mut second_buf = Vec::new();
|
||||||
|
loop {
|
||||||
|
let read = second.read(&mut chunk).expect("second socket should read");
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
second_buf.extend_from_slice(&chunk[..read]);
|
||||||
|
if second_buf.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let second_request = String::from_utf8_lossy(&second_buf).to_lowercase();
|
||||||
|
assert!(second_request.contains("get /final http/1.1"));
|
||||||
|
second
|
||||||
|
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nfinal")
|
||||||
|
.expect("final response should write");
|
||||||
|
});
|
||||||
|
|
||||||
|
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
|
||||||
|
let expected_redirect = format!("http://{addr}/redirect");
|
||||||
|
let request = task(&expected_redirect).request;
|
||||||
|
|
||||||
|
let response = connector
|
||||||
|
.connect_http(&request)
|
||||||
|
.expect("redirected request should succeed");
|
||||||
|
|
||||||
|
assert_eq!(response.status, 200);
|
||||||
|
assert_eq!(
|
||||||
|
response.redirected_from.as_deref(),
|
||||||
|
Some(expected_redirect.as_str())
|
||||||
|
);
|
||||||
|
|
||||||
|
handle.join().expect("server thread should join");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reqwest_connector_maps_http10_responses_to_http10_model_version() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
|
||||||
|
let addr = listener.local_addr().expect("local addr should exist");
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
let (mut stream, _) = listener.accept().expect("client should connect");
|
||||||
|
let mut request = [0_u8; 1024];
|
||||||
|
let _ = stream.read(&mut request);
|
||||||
|
let response = b"HTTP/1.0 200 OK\r\nContent-Length: 3\r\nConnection: close\r\n\r\nold";
|
||||||
|
stream.write_all(response).expect("response should write");
|
||||||
|
});
|
||||||
|
|
||||||
|
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
|
||||||
|
let response = connector
|
||||||
|
.connect_http(&task(&format!("http://{addr}/http10")).request)
|
||||||
|
.expect("http/1.0 request should succeed");
|
||||||
|
|
||||||
|
assert_eq!(response.status, 200);
|
||||||
|
assert_eq!(response.version, HttpVersion::Http10);
|
||||||
|
|
||||||
|
handle.join().expect("server thread should join");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reqwest_connector_uses_request_proxy_for_http_requests() {
|
||||||
|
let proxy_listener = TcpListener::bind("127.0.0.1:0").expect("proxy should bind");
|
||||||
|
let proxy_addr = proxy_listener
|
||||||
|
.local_addr()
|
||||||
|
.expect("proxy addr should exist");
|
||||||
|
let target_port = closed_loopback_port();
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
let (mut stream, _) = proxy_listener
|
||||||
|
.accept()
|
||||||
|
.expect("proxy client should connect");
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut chunk = [0_u8; 1024];
|
||||||
|
loop {
|
||||||
|
let read = stream.read(&mut chunk).expect("proxy socket should read");
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
buf.extend_from_slice(&chunk[..read]);
|
||||||
|
if buf.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let header_end = buf
|
||||||
|
.windows(4)
|
||||||
|
.position(|window| window == b"\r\n\r\n")
|
||||||
|
.map(|index| index + 4)
|
||||||
|
.expect("proxy request should have headers");
|
||||||
|
let request_text = String::from_utf8_lossy(&buf[..header_end]).to_lowercase();
|
||||||
|
assert!(request_text.contains(&format!(
|
||||||
|
"get http://127.0.0.1:{target_port}/proxied http/1.1"
|
||||||
|
)));
|
||||||
|
|
||||||
|
stream
|
||||||
|
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 9\r\n\r\nproxied-ok")
|
||||||
|
.expect("proxy response should write");
|
||||||
|
});
|
||||||
|
|
||||||
|
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
|
||||||
|
let mut request = task(&format!("http://127.0.0.1:{target_port}/proxied")).request;
|
||||||
|
request.proxy = Some(proxy_config(proxy_addr.port()));
|
||||||
|
|
||||||
|
let response = connector
|
||||||
|
.connect_http(&request)
|
||||||
|
.expect("request should route through proxy");
|
||||||
|
|
||||||
|
assert_eq!(response.status, 200);
|
||||||
|
assert_eq!(response.completed_length(), 9);
|
||||||
|
|
||||||
|
handle.join().expect("proxy thread should join");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reqwest_connector_reuses_proxy_specific_clients_for_matching_proxy_config() {
|
||||||
|
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
|
||||||
|
let mut request = task("http://127.0.0.1:9/proxy-cache").request;
|
||||||
|
request.proxy = Some(proxy_config(closed_loopback_port()));
|
||||||
|
|
||||||
|
let _first = connector
|
||||||
|
.client_for_request(&request)
|
||||||
|
.expect("first proxy client should build");
|
||||||
|
let _second = connector
|
||||||
|
.client_for_request(&request)
|
||||||
|
.expect("second proxy client should reuse cache");
|
||||||
|
|
||||||
|
assert_eq!(connector.cached_proxy_client_count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reqwest_connector_prepares_request_without_waiting_for_cache_lock() {
|
||||||
|
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
|
||||||
|
let request = task("http://example.org/prepared-cache").request;
|
||||||
|
let _prepared_cache_guard = connector
|
||||||
|
.prepared_requests
|
||||||
|
.lock()
|
||||||
|
.expect("prepared request cache lock should succeed");
|
||||||
|
|
||||||
|
let prepared = connector.prepared_live_request_for(&request);
|
||||||
|
|
||||||
|
assert!(prepared.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reqwest_connector_maps_proxy_connect_failure_to_proxy_failed() {
|
||||||
|
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
|
||||||
|
let mut request = task("http://127.0.0.1:9/proxy-failure").request;
|
||||||
|
request.proxy = Some(proxy_config(closed_loopback_port()));
|
||||||
|
|
||||||
|
let error = connector
|
||||||
|
.connect_http(&request)
|
||||||
|
.expect_err("proxy connect should fail");
|
||||||
|
|
||||||
|
assert_eq!(error.kind, TransportErrorKind::ProxyFailed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reqwest_connector_maps_dns_resolution_failure_to_dns_failed() {
|
||||||
|
let connector = ReqwestHttpConnector::new().expect("reqwest connector should build");
|
||||||
|
let request = task("http://no-such-host.invalid/dns-failure").request;
|
||||||
|
|
||||||
|
let error = connector
|
||||||
|
.connect_http(&request)
|
||||||
|
.expect_err("dns lookup should fail");
|
||||||
|
|
||||||
|
assert_eq!(error.kind, TransportErrorKind::DnsFailed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixture_downloader_supports_https_too() {
|
||||||
|
let mut downloader = FixtureHttpDownloader::new();
|
||||||
|
downloader.register("https://example.org/file", b"payload");
|
||||||
|
|
||||||
|
let response = downloader
|
||||||
|
.start_http_transfer(&task("https://example.org/file"))
|
||||||
|
.expect("https fixture should resolve");
|
||||||
|
|
||||||
|
assert_eq!(response.status, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connector_backed_downloader_rejects_missing_scheme() {
|
||||||
|
let downloader = ConnectorBackedDownloader::new(HttpOkConnector, HttpsOkConnector);
|
||||||
|
let error = downloader
|
||||||
|
.start_http_transfer(&task("example.org/file"))
|
||||||
|
.expect_err("missing scheme should fail");
|
||||||
|
|
||||||
|
assert_eq!(error.kind, TransportErrorKind::ProtocolViolation);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixture_script_can_model_206_with_content_range() {
|
||||||
|
let downloader = FixtureHttpDownloader::new();
|
||||||
|
downloader.register_partial_content("https://example.org/resume.bin", b"cdef", 2, 5, 8);
|
||||||
|
|
||||||
|
let response = downloader
|
||||||
|
.start_http_transfer(&task("https://example.org/resume.bin"))
|
||||||
|
.expect("partial fixture should resolve");
|
||||||
|
|
||||||
|
assert_eq!(response.status, 206);
|
||||||
|
assert_eq!(response.reason, "Partial Content");
|
||||||
|
assert!(
|
||||||
|
response
|
||||||
|
.headers
|
||||||
|
.headers
|
||||||
|
.iter()
|
||||||
|
.any(|h| h.name == "content-range" && h.value == "bytes 2-5/8")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixture_script_can_model_416_with_unsatisfied_content_range() {
|
||||||
|
let downloader = FixtureHttpDownloader::new();
|
||||||
|
downloader.register_script(
|
||||||
|
"https://example.org/range-reject.bin",
|
||||||
|
[FixtureStep::ok(HttpFixtureResponseSpec {
|
||||||
|
status: 416,
|
||||||
|
reason: "Range Not Satisfiable".to_owned(),
|
||||||
|
headers: vec![
|
||||||
|
crate::http::HttpHeader {
|
||||||
|
name: "content-range".to_owned(),
|
||||||
|
value: "bytes */8192".to_owned(),
|
||||||
|
kind: crate::http::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
crate::http::HttpHeader {
|
||||||
|
name: "content-length".to_owned(),
|
||||||
|
value: "0".to_owned(),
|
||||||
|
kind: crate::http::HeaderKind::Response,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
body: Vec::new(),
|
||||||
|
checksum: None,
|
||||||
|
streamed: false,
|
||||||
|
})],
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = downloader
|
||||||
|
.start_http_transfer(&task("https://example.org/range-reject.bin"))
|
||||||
|
.expect("416 fixture should resolve");
|
||||||
|
|
||||||
|
assert_eq!(response.status, 416);
|
||||||
|
assert_eq!(response.total_length(), Some(8192));
|
||||||
|
assert_eq!(response.completed_length(), 0);
|
||||||
|
assert!(
|
||||||
|
response
|
||||||
|
.content_range
|
||||||
|
.as_ref()
|
||||||
|
.expect("content-range should parse")
|
||||||
|
.unsatisfied
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixture_script_supports_transient_failure_then_success() {
|
||||||
|
let downloader = FixtureHttpDownloader::new();
|
||||||
|
downloader.register_transient_failure_then_ok(
|
||||||
|
"https://example.org/retry.bin",
|
||||||
|
TransportErrorKind::Timeout,
|
||||||
|
"transient timeout",
|
||||||
|
b"ok-after-retry",
|
||||||
|
);
|
||||||
|
|
||||||
|
let first = downloader.start_http_transfer(&task("https://example.org/retry.bin"));
|
||||||
|
let first_err = first.expect_err("first attempt should fail");
|
||||||
|
assert_eq!(first_err.kind, TransportErrorKind::Timeout);
|
||||||
|
|
||||||
|
let second = downloader
|
||||||
|
.start_http_transfer(&task("https://example.org/retry.bin"))
|
||||||
|
.expect("second attempt should succeed");
|
||||||
|
assert_eq!(second.status, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixture_script_can_emit_streamed_observed_checksum_truth() {
|
||||||
|
let downloader = FixtureHttpDownloader::new();
|
||||||
|
downloader.register_streamed_ok_with_checksum(
|
||||||
|
"https://example.org/streamed.bin",
|
||||||
|
b"abc",
|
||||||
|
"md5",
|
||||||
|
"900150983cd24fb0d6963f7d28e17f72",
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = downloader
|
||||||
|
.start_http_transfer(&task("https://example.org/streamed.bin"))
|
||||||
|
.expect("streamed fixture should resolve");
|
||||||
|
|
||||||
|
match response.body {
|
||||||
|
ResponseBody::Streamed {
|
||||||
|
expected_len,
|
||||||
|
observed_len,
|
||||||
|
ref observed_digest,
|
||||||
|
ref temp_path,
|
||||||
|
} => {
|
||||||
|
assert_eq!(expected_len, Some(3));
|
||||||
|
assert_eq!(observed_len, Some(3));
|
||||||
|
assert_eq!(
|
||||||
|
observed_digest.as_deref(),
|
||||||
|
Some("900150983cd24fb0d6963f7d28e17f72")
|
||||||
|
);
|
||||||
|
assert!(temp_path.is_some());
|
||||||
|
}
|
||||||
|
other => panic!("expected streamed body, got {other:?}"),
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
response.completion_model().state,
|
||||||
|
crate::http::HttpCompletionState::Verified
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn streamed_execution_truth_comes_from_sink_writes_not_declared_body_len() {
|
||||||
|
let checksum = crate::http::ChecksumSpec {
|
||||||
|
algorithm: "sha1".to_owned(),
|
||||||
|
expected_hex: String::new(),
|
||||||
|
actual_hex: None,
|
||||||
|
};
|
||||||
|
let response_body = super::execute_streamed_body(b"hello-sink", Some(&checksum));
|
||||||
|
|
||||||
|
match response_body {
|
||||||
|
ResponseBody::Streamed {
|
||||||
|
expected_len,
|
||||||
|
observed_len,
|
||||||
|
observed_digest,
|
||||||
|
ref temp_path,
|
||||||
|
} => {
|
||||||
|
assert_eq!(expected_len, Some(10));
|
||||||
|
assert_eq!(observed_len, Some(10));
|
||||||
|
assert_eq!(
|
||||||
|
observed_digest.as_deref(),
|
||||||
|
Some("381cf617458c906e12825ac22e9c621e7bba2390")
|
||||||
|
);
|
||||||
|
assert!(temp_path.is_some());
|
||||||
|
}
|
||||||
|
other => panic!("expected streamed body, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn temp_stream_sink_path_stays_unique_across_rapid_calls() {
|
||||||
|
let paths = (0..512)
|
||||||
|
.map(|_| super::temp_stream_sink_path())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let unique = paths.iter().cloned().collect::<BTreeSet<_>>();
|
||||||
|
assert_eq!(unique.len(), paths.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixture_script_can_pin_last_step_for_additional_attempts() {
|
||||||
|
let downloader = FixtureHttpDownloader::new();
|
||||||
|
downloader.register_script(
|
||||||
|
"https://example.org/retry-stable.bin",
|
||||||
|
[
|
||||||
|
FixtureStep::err(TransportErrorKind::ConnectionReset, "reset once"),
|
||||||
|
FixtureStep::ok(HttpFixtureResponseSpec::ok(b"stable".to_vec())),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = downloader.start_http_transfer(&task("https://example.org/retry-stable.bin"));
|
||||||
|
let second = downloader
|
||||||
|
.start_http_transfer(&task("https://example.org/retry-stable.bin"))
|
||||||
|
.expect("second attempt should pass");
|
||||||
|
let third = downloader
|
||||||
|
.start_http_transfer(&task("https://example.org/retry-stable.bin"))
|
||||||
|
.expect("third attempt should still pass");
|
||||||
|
|
||||||
|
assert_eq!(second.status, 200);
|
||||||
|
assert_eq!(third.status, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixture_downloader_can_serve_ftp_transfer() {
|
||||||
|
let mut downloader = FixtureHttpDownloader::new();
|
||||||
|
downloader.register_ftp(
|
||||||
|
"ftp://example.org:21/file.bin",
|
||||||
|
FtpResponseModel {
|
||||||
|
code: 226,
|
||||||
|
message: "transfer complete".to_owned(),
|
||||||
|
data: Some(b"ftp-body".to_vec()),
|
||||||
|
path: Some("/file.bin".to_owned()),
|
||||||
|
transferable: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = downloader
|
||||||
|
.start_ftp_transfer(
|
||||||
|
&crate::ftp::FtpConfigModel {
|
||||||
|
host: "example.org".to_owned(),
|
||||||
|
port: 21,
|
||||||
|
username: None,
|
||||||
|
password: None,
|
||||||
|
secure: false,
|
||||||
|
mode: crate::ftp::FtpMode::Passive,
|
||||||
|
initial_cwd: None,
|
||||||
|
proxy: None,
|
||||||
|
tls: None,
|
||||||
|
retry: retry(),
|
||||||
|
},
|
||||||
|
&crate::ftp::FtpRequestModel {
|
||||||
|
command: FtpCommandModel::Retr("/file.bin".to_owned()),
|
||||||
|
path: Some("/file.bin".to_owned()),
|
||||||
|
headers: Vec::new(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("ftp fixture should resolve");
|
||||||
|
|
||||||
|
assert_eq!(response.code, 226);
|
||||||
|
assert_eq!(response.data.as_deref(), Some(&b"ftp-body"[..]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixture_downloader_can_serve_sftp_transfer() {
|
||||||
|
let mut downloader = FixtureHttpDownloader::new();
|
||||||
|
downloader.register_sftp(
|
||||||
|
"sftp://example.org:22/file.bin",
|
||||||
|
SftpResponseModel {
|
||||||
|
ok: true,
|
||||||
|
message: "sftp ok".to_owned(),
|
||||||
|
payload: Some(b"sftp-body".to_vec()),
|
||||||
|
path: Some("/file.bin".to_owned()),
|
||||||
|
transferable: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = downloader
|
||||||
|
.start_sftp_transfer(
|
||||||
|
&crate::sftp::SftpConfigModel {
|
||||||
|
host: "example.org".to_owned(),
|
||||||
|
port: 22,
|
||||||
|
username: None,
|
||||||
|
password: None,
|
||||||
|
private_key_path: None,
|
||||||
|
known_hosts_path: None,
|
||||||
|
strict_host_key_checking: true,
|
||||||
|
proxy: None,
|
||||||
|
tls: None,
|
||||||
|
retry: retry(),
|
||||||
|
},
|
||||||
|
&crate::sftp::SftpRequestModel {
|
||||||
|
command: SftpCommandModel::Read {
|
||||||
|
path: "/file.bin".to_owned(),
|
||||||
|
offset: 0,
|
||||||
|
length: 1024,
|
||||||
|
},
|
||||||
|
path: Some("/file.bin".to_owned()),
|
||||||
|
headers: Vec::new(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("sftp fixture should resolve");
|
||||||
|
|
||||||
|
assert!(response.ok);
|
||||||
|
assert_eq!(response.payload.as_deref(), Some(&b"sftp-body"[..]));
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user