chore: initial sanitized public snapshot

This commit is contained in:
Aria2 Rust Pro Contributors
2026-07-18 14:16:57 +08:00
commit 17688c3e34
321 changed files with 76859 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
[term]
color = "auto"
[unstable]
build-std = ["std", "panic_abort"]
build-std-features = ["optimize_for_size"]
+37
View File
@@ -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
+10
View File
@@ -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
+94
View File
@@ -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
+217
View File
@@ -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
View File
@@ -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
+51
View File
@@ -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`
+134
View File
@@ -0,0 +1,134 @@
# Contributing to aria2-rust-pro
Thanks for contributing to `aria2-rust-pro`.
This repository is maintained as a practical, compatibility-driven Rust 2024
implementation informed by [Aria2-Pro-Core](https://github.com/P3TERX/Aria2-Pro-Core)
and the upstream [aria2 project](https://github.com/aria2/aria2). The most
helpful contributions 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
View File
File diff suppressed because it is too large Load Diff
+91
View File
@@ -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 aria2-compatible implementation informed by Aria2-Pro-Core"
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"
+339
View File
@@ -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.
+144
View File
@@ -0,0 +1,144 @@
# aria2-rust-pro
`aria2-rust-pro` is an independently maintained Rust 2024 implementation of
an aria2-compatible download client. Its compatibility and migration work are
informed by [Aria2-Pro-Core](https://github.com/P3TERX/Aria2-Pro-Core) and the
upstream [aria2 project](https://github.com/aria2/aria2).
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.
## Project Origins
- [Aria2-Pro-Core](https://github.com/P3TERX/Aria2-Pro-Core) is the direct
compatibility and migration reference for the enhanced deployment profile.
- [aria2](https://github.com/aria2/aria2) is the upstream project whose CLI,
configuration, and RPC contracts guide this implementation.
See [Project Origins](docs/project-origins.md) for the scope of those
references and this project's independent maintenance model.
## 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
- released as a source snapshot under `v1.0.0`; binary and image assets are
built locally from the documented packaging workflows
- 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](https://github.com/P3TERX/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](https://github.com/P3TERX/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
View File
@@ -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 },
]
+27
View File
@@ -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
+511
View File
@@ -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
}
+248
View File
@@ -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;
+21
View File
@@ -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
);
}
}
+708
View File
@@ -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);
}
}
+126
View File
@@ -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,
&parallel_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,
&registered_entries,
base_profile,
&context.derived_runtime,
)?;
let summary = collect_runtime_execution_summary(&mut dispatcher, &registered_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)
}
+930
View File
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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),
}
}
+277
View File
@@ -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>,
}
+17
View File
@@ -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
+137
View File
@@ -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(),
}
}
+396
View File
@@ -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");
}
}
+427
View File
@@ -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 &section.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,..."));
}
}
+133
View File
@@ -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:?}"
);
}
}
+20
View File
@@ -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
+679
View File
@@ -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
+66
View File
@@ -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 {}
+143
View File
@@ -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()
}
}
+58
View File
@@ -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;
+149
View File
@@ -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)
}
}
+104
View File
@@ -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()
}
}
+332
View File
@@ -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);
}
}
+40
View File
@@ -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>,
}
+206
View File
@@ -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);
}
}
+559
View File
@@ -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);
}
+564
View File
@@ -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);
}
}
+27
View File
@@ -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,
}
+23
View File
@@ -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